Skip to content

Commit c9fb694

Browse files
Rescue reviewed retained commits from missing managed worktrees
1 parent f34511a commit c9fb694

10 files changed

Lines changed: 309 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.20.0 — native development (unreleased)
44

5+
- Native development: reviewed CLI rescue for missing worktrees restores retained
6+
commits into a new checkout while preserving the original branch, index and
7+
registration; missing uncommitted files are not reconstructed.
8+
59
- Desktop Settings adds worktree creation, opening through project trust,
610
inspection and reviewed removal. Actions bind to the displayed source project;
711
dirty or stale reviews cannot authorize removal.

‎docs/NATIVE_MIGRATION.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ the existing React visual design. This document tracks engineering acceptance;
5757
separate managed branch, preserved source edits, private recovery records and
5858
CLI inventory, desktop Settings controls and reviewed clean removal that retains
5959
branches and commits.
60-
Dirty-state transfer, missing-checkout recovery and reviewed return of changes
61-
remain open.
60+
CLI rescue restores a missing checkout’s retained commit into a separate
61+
checkout while preserving original recovery metadata. Dirty-state transfer,
62+
desktop recovery, damaged-path repair and reviewed return of changes remain open.
6263

6364
The Rust workspace is being introduced alongside the Python release. Existing
6465
features remain acceptance requirements; an unimplemented native feature must

‎docs/NATIVE_VERIFICATION.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,19 @@ these transitions. Assertions inspect fresh complete frames because incremental
278278
ANSI updates can split words. This covers resize recovery; broader picker
279279
navigation, visual review and sustained terminal stress remain open.
280280

281+
## Missing-worktree committed recovery
282+
283+
Six worktree tests pass, including a real Git checkout with an unmerged commit
284+
and staged changes moved away from its managed path. Recovery refuses existing
285+
paths, locked registrations and stale hashes. Reviewed restoration creates a
286+
separate checkout at the retained commit; the original index and recovery record
287+
are byte-for-byte unchanged, and the original branch, registration and moved
288+
files remain. The native executable passes 18 CLI scenario groups / 32 scripted
289+
model requests, including recovery preview, stale-hash rejection and restoration.
290+
Clippy and the native build pass. This restores committed work only; automatic
291+
reconstruction of missing uncommitted files is not claimed. Desktop rescue and
292+
damaged-path repair remain open.
293+
281294
## Desktop worktree controls
282295

283296
The native Settings panel creates and lists worktrees, opens the normal trust

‎docs/NATIVE_WORKTREES.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ checks; removal never uses `--force` or deletes branches. Unmerged commits remai
6060
on the preserved branch. Source files and selection remain unchanged.
6161

6262
Files deliberately changed by external programs should be preserved before
63-
removal, just as with ordinary Git operations. Missing or damaged checkout
64-
registrations still require manual inspection; the recovery record remains.
63+
removal, just as with ordinary Git operations. Damaged checkout paths or missing Git registrations still require manual
64+
inspection; the recovery record remains. For a missing path whose Git
65+
registration survives, use the reviewed rescue below.
6566

6667
Carrying uncommitted changes into an isolated checkout, reviewed return/merge
6768
operations and recovery controls for missing/damaged checkouts remain part of the
@@ -70,3 +71,25 @@ operations and recovery controls for missing/damaged checkouts remain part of th
7071
Inventory and individual actions share the same bounded, non-following record
7172
reader. A record whose filename, managed path or branch identity has changed is
7273
rejected before it can be presented as a usable worktree.
74+
75+
## Rescue a missing checkout's committed work
76+
77+
```sh
78+
shadowcode --workspace /path/to/repository worktree --recovery FULL_WORKTREE_ID
79+
shadowcode --workspace /path/to/repository worktree --restore FULL_WORKTREE_ID --recovery-hash REVIEW_HASH
80+
```
81+
82+
The review resolves the missing checkout's registered HEAD, including commits
83+
not merged into the source project. Restoration creates a **new** managed branch
84+
and checkout from that exact commit. It does not reconstruct missing uncommitted
85+
files. The original branch, registration, index and recovery record remain
86+
untouched so staged changes or a moved checkout can still be recovered manually.
87+
No global Git prune, forced removal or branch reset is performed.
88+
89+
An existing path (including a symlink), locked registration, changed repository,
90+
missing commit, unavailable registration or stale review blocks rescue. A locked
91+
worktree may be on an unavailable device; inspect its location before unlocking.
92+
Trust and writable/idle source requirements also apply. Open and trust the new
93+
checkout explicitly. The original record remains in the active inventory until
94+
its underlying checkout is repaired and reviewed for removal. Dedicated desktop
95+
rescue controls and automatic repair of damaged paths remain open.

‎native/core/src/cli/args.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,22 @@ pub enum Command {
5353
},
5454
/// List managed worktrees, or create an isolated branch at a local commit.
5555
Worktree {
56-
#[arg(long,conflicts_with_all=["inspect","remove"])]
56+
#[arg(long,conflicts_with_all=["inspect","remove","recovery","restore"])]
5757
create: bool,
58-
#[arg(long, conflicts_with = "remove")]
58+
#[arg(long, conflicts_with_all = ["remove","recovery","restore"])]
5959
inspect: Option<String>,
60-
#[arg(long, requires = "hash")]
60+
#[arg(long, requires = "hash", conflicts_with_all = ["recovery","restore"])]
6161
remove: Option<String>,
6262
#[arg(long, requires = "remove")]
6363
hash: Option<String>,
64+
/// Review retained commits for a missing managed checkout.
65+
#[arg(long, conflicts_with = "restore")]
66+
recovery: Option<String>,
67+
/// Restore a reviewed commit to a separate checkout, retaining original metadata.
68+
#[arg(long, requires = "recovery_hash")]
69+
restore: Option<String>,
70+
#[arg(long, requires = "restore")]
71+
recovery_hash: Option<String>,
6472
#[arg(long, default_value = "HEAD", requires = "create")]
6573
reference: String,
6674
},

‎native/core/src/cli/mod.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,8 +870,10 @@ async fn execute(backend: &Backend, workspace: &Path, options: &Options) -> Resu
870870
});
871871
}
872872
}
873-
Command::Worktree {create,reference,inspect,remove,hash}=>{
874-
if let Some(id)=remove {backend.call("POST","/api/worktrees/remove",json!({"id":id,"hash":hash})).await?}
873+
Command::Worktree {create,reference,inspect,remove,hash,recovery,restore,recovery_hash}=>{
874+
if let Some(id)=restore {backend.call("POST","/api/worktrees/restore",json!({"id":id,"hash":recovery_hash})).await?}
875+
else if let Some(id)=recovery {backend.call("POST","/api/worktrees/recovery",json!({"id":id})).await?}
876+
else if let Some(id)=remove {backend.call("POST","/api/worktrees/remove",json!({"id":id,"hash":hash})).await?}
875877
else if let Some(id)=inspect {backend.call("POST","/api/worktrees/inspect",json!({"id":id})).await?}
876878
else {backend.call(if *create {"POST"}else{"GET"},"/api/worktrees",json!({"reference":reference})).await?}
877879
},

‎native/core/src/service.rs‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,40 @@ impl Service {
213213
.await?
214214
));
215215
}
216+
("POST", "/api/worktrees/recovery") => {
217+
let workspace = self.workspace()?;
218+
ensure!(
219+
Config::load(self.engine.paths(), Some(&workspace))?.is_trusted(&workspace),
220+
"Trust the source project before inspecting recovery"
221+
);
222+
return Ok(json!(
223+
crate::worktrees::recovery(
224+
self.engine.paths(),
225+
&workspace,
226+
text("id"),
227+
CancellationToken::new()
228+
)
229+
.await?
230+
));
231+
}
232+
("POST", "/api/worktrees/restore") => {
233+
let ws = self.mutable_workspace()?;
234+
let record = crate::worktrees::restore(
235+
self.engine.paths(),
236+
&ws.path,
237+
text("id"),
238+
text("hash"),
239+
ws.reservation.cancellation(),
240+
)
241+
.await?;
242+
store.add_event(
243+
"worktree.restored",
244+
&json!({"original_id":text("id"),"checkout":record}),
245+
None,
246+
None,
247+
)?;
248+
return Ok(json!(record));
249+
}
216250
("POST", "/api/worktrees") => {
217251
let ws = self.mutable_workspace()?;
218252
let reference = body["reference"].as_str().unwrap_or("HEAD");

‎native/core/src/worktrees.rs‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,106 @@ pub async fn remove(
405405
}
406406
}
407407
}
408+
409+
/// A rescue creates a new checkout; it never prunes the missing checkout's
410+
/// registration or index, which may still contain recoverable staged changes.
411+
#[derive(Clone, Debug, Serialize)]
412+
pub struct Recovery {
413+
pub record: Record,
414+
pub commit: String,
415+
pub branch: String,
416+
pub warning: String,
417+
pub hash: String,
418+
}
419+
pub async fn recovery(
420+
paths: &AppPaths,
421+
source: &Path,
422+
id: &str,
423+
cancel: CancellationToken,
424+
) -> Result<Recovery> {
425+
use sha2::{Digest, Sha256};
426+
let record = read_record(paths, source, id)?;
427+
match fs::symlink_metadata(&record.path) {
428+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
429+
_ => anyhow::bail!(
430+
"Checkout path still exists or cannot be inspected; preserve it before recovery"
431+
),
432+
}
433+
let common = PathBuf::from(
434+
git(
435+
source,
436+
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
437+
cancel.clone(),
438+
)
439+
.await?,
440+
)
441+
.canonicalize()?;
442+
ensure!(
443+
common == record.common_directory,
444+
"Source repository identity changed"
445+
);
446+
let registered = git(
447+
source,
448+
&["worktree", "list", "--porcelain", "-z"],
449+
cancel.clone(),
450+
)
451+
.await?;
452+
let expected = format!(
453+
"worktree {}",
454+
record
455+
.path
456+
.to_str()
457+
.context("Worktree path must be UTF-8")?
458+
);
459+
let block = registered
460+
.split("\0\0")
461+
.find(|block| block.split('\0').any(|field| field == expected))
462+
.context(
463+
"Missing checkout registration is unavailable; inspect retained branches manually",
464+
)?;
465+
ensure!(!block.split('\0').any(|field| field == "locked" || field.starts_with("locked ")), "Worktree is locked; it may be on an unavailable device. Review its location before recovery");
466+
let commit = block
467+
.split('\0')
468+
.find_map(|field| field.strip_prefix("HEAD "))
469+
.context("Registered checkout has no retained commit")?
470+
.to_string();
471+
ensure!(
472+
matches!(commit.len(), 40 | 64) && commit.bytes().all(|b| b.is_ascii_hexdigit()),
473+
"Invalid retained commit"
474+
);
475+
let resolved = git(
476+
source,
477+
&["rev-parse", "--verify", &format!("{commit}^{{commit}}")],
478+
cancel,
479+
)
480+
.await?;
481+
ensure!(resolved == commit, "Retained commit is unavailable");
482+
let branch = block
483+
.split('\0')
484+
.find_map(|field| field.strip_prefix("branch refs/heads/"))
485+
.unwrap_or("")
486+
.to_string();
487+
let hash = format!(
488+
"{:x}",
489+
Sha256::digest(serde_json::to_vec(
490+
&serde_json::json!({"record":record,"registration":block})
491+
)?)
492+
);
493+
Ok(Recovery { record, commit, branch, hash, warning:"Creates a separate checkout from the retained commit. Missing uncommitted files are not reconstructed. The original branch, registration, index and recovery record remain intact for manual recovery.".into() })
494+
}
495+
pub async fn restore(
496+
paths: &AppPaths,
497+
source: &Path,
498+
id: &str,
499+
expected_hash: &str,
500+
cancel: CancellationToken,
501+
) -> Result<Record> {
502+
let review = recovery(paths, source, id, cancel.clone()).await?;
503+
ensure!(
504+
review.hash == expected_hash,
505+
"Recovery state changed; inspect it again before restoring"
506+
);
507+
// Commit objects are immutable. Create from the reviewed commit, never a
508+
// mutable branch name, and leave the original recovery evidence untouched.
509+
create(paths, source, &review.commit, cancel).await
510+
}

‎native/core/tests/worktrees.rs‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,102 @@ async fn worktree_inspection_rejects_detached_heads_symlinks_and_forged_paths()
397397
assert!(worktrees::list(&paths, &project).is_err());
398398
assert!(project.join("tracked.txt").exists());
399399
}
400+
401+
#[tokio::test]
402+
async fn missing_checkout_rescue_retains_commits_index_and_original_registration() {
403+
let root = tempfile::tempdir().unwrap();
404+
let project = root.path().join("project");
405+
repository(&project);
406+
let paths = AppPaths::isolated(&root.path().join("profile")).unwrap();
407+
Config::patch(&paths, json!({"trusted_workspaces":[project]})).unwrap();
408+
let service = Service::open(paths.clone(), Some(project.clone())).unwrap();
409+
let record = worktrees::create(&paths, &project, "HEAD", CancellationToken::new())
410+
.await
411+
.unwrap();
412+
assert!(
413+
worktrees::recovery(&paths, &project, &record.id, CancellationToken::new())
414+
.await
415+
.is_err()
416+
);
417+
fs::write(record.path.join("retained.txt"), "unmerged commit\n").unwrap();
418+
git(&record.path, &["add", "."]);
419+
git(&record.path, &["commit", "-qm", "Retained work"]);
420+
let commit = git(&record.path, &["rev-parse", "HEAD"]);
421+
fs::write(
422+
record.path.join("tracked.txt"),
423+
"staged recovery evidence\n",
424+
)
425+
.unwrap();
426+
git(&record.path, &["add", "tracked.txt"]);
427+
let index = git(
428+
&record.path,
429+
&["rev-parse", "--path-format=absolute", "--git-path", "index"],
430+
);
431+
let index_bytes = fs::read(&index).unwrap();
432+
let record_file = paths
433+
.data
434+
.join("managed-worktrees/records")
435+
.join(format!("{}.json", record.id));
436+
let record_bytes = fs::read(&record_file).unwrap();
437+
let saved = root.path().join("saved-checkout");
438+
fs::rename(&record.path, &saved).unwrap();
439+
git(
440+
&project,
441+
&["worktree", "lock", record.path.to_str().unwrap()],
442+
);
443+
assert!(
444+
worktrees::recovery(&paths, &project, &record.id, CancellationToken::new())
445+
.await
446+
.unwrap_err()
447+
.to_string()
448+
.contains("locked")
449+
);
450+
git(
451+
&project,
452+
&["worktree", "unlock", record.path.to_str().unwrap()],
453+
);
454+
let review = operation(&service, "/api/worktrees/recovery", json!({"id":record.id}))
455+
.await
456+
.unwrap();
457+
assert_eq!(review["commit"], commit);
458+
assert_eq!(review["branch"], record.branch);
459+
assert!(operation(
460+
&service,
461+
"/api/worktrees/restore",
462+
json!({"id":record.id,"hash":"stale"})
463+
)
464+
.await
465+
.unwrap_err()
466+
.to_string()
467+
.contains("changed"));
468+
assert_eq!(worktrees::list(&paths, &project).unwrap().len(), 1);
469+
let restored = operation(
470+
&service,
471+
"/api/worktrees/restore",
472+
json!({"id":record.id,"hash":review["hash"]}),
473+
)
474+
.await
475+
.unwrap();
476+
let new_path = Path::new(restored["path"].as_str().unwrap());
477+
assert_ne!(new_path, record.path);
478+
assert_eq!(git(new_path, &["rev-parse", "HEAD"]), commit);
479+
assert_eq!(
480+
fs::read_to_string(new_path.join("retained.txt")).unwrap(),
481+
"unmerged commit\n"
482+
);
483+
assert_eq!(
484+
fs::read_to_string(new_path.join("tracked.txt")).unwrap(),
485+
"committed\n"
486+
);
487+
assert_eq!(fs::read(index).unwrap(), index_bytes);
488+
assert_eq!(fs::read(record_file).unwrap(), record_bytes);
489+
assert_eq!(git(&project, &["rev-parse", &record.branch]), commit);
490+
assert!(
491+
git(&project, &["worktree", "list", "--porcelain"]).contains(record.path.to_str().unwrap())
492+
);
493+
assert_eq!(
494+
fs::read_to_string(saved.join("tracked.txt")).unwrap(),
495+
"staged recovery evidence\n"
496+
);
497+
service.engine.shutdown().await.unwrap();
498+
}

‎scripts/test-native-cli.mjs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import assert from "node:assert/strict";
33
import { spawn, execFileSync } from "node:child_process";
44
import { createServer } from "node:http";
5-
import { mkdtemp, mkdir, readFile, writeFile, rm } from "node:fs/promises";
5+
import { mkdtemp, mkdir, readFile, writeFile, rm, rename } from "node:fs/promises";
66
import { tmpdir } from "node:os";
77
import path from "node:path";
88
import { fileURLToPath } from "node:url";
@@ -392,6 +392,18 @@ try {
392392
assert.equal(fixtureGit(["rev-parse",isolated.branch]).trim(),isolated.base_commit);
393393
checks.push("native worktree creation, inventory, trust, isolated model task, reviewed clean removal and preserved branch");
394394

395+
const missing=await cli(["worktree","--create"],0,{workspace:isolatedSource});
396+
await rename(missing.path,path.join(scratch,"retained-missing-checkout"));
397+
const recovery=await cli(["worktree","--recovery",missing.id],0,{workspace:isolatedSource});
398+
assert.equal(recovery.commit,missing.base_commit);
399+
assert.match((await cli(["worktree","--restore",missing.id,"--recovery-hash","stale"],1,{workspace:isolatedSource})).error,/changed/);
400+
const rescued=await cli(["worktree","--restore",missing.id,"--recovery-hash",recovery.hash],0,{workspace:isolatedSource});
401+
assert.notEqual(rescued.path,missing.path);
402+
assert.equal(await readFile(path.join(rescued.path,"README.md"),"utf8"),"fixture-read-value committed\n");
403+
assert.ok(fixtureGit(["worktree","list","--porcelain"]).includes(missing.path));
404+
assert.equal(fixtureGit(["rev-parse",missing.branch]).trim(),missing.base_commit);
405+
checks.push("missing worktree reviewed rescue with stale-hash rejection and original metadata retained");
406+
395407
// Seed only this disposable, stopped profile. Recent-list limits must not hide
396408
// older IDs or force fetching multi-megabyte job results to resolve a prefix.
397409
const historyDb = new DatabaseSync(path.join(profile, "state/shadow-agent.db"));

0 commit comments

Comments
 (0)