Skip to content

Commit c084620

Browse files
Add reviewed missing-worktree recovery to native desktop settings
1 parent c9fb694 commit c084620

8 files changed

Lines changed: 190 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## 0.20.0 — native development (unreleased)
44

5-
- Native development: reviewed CLI rescue for missing worktrees restores retained
5+
- Native development: reviewed CLI and desktop rescue for missing worktrees restores retained
66
commits into a new checkout while preserving the original branch, index and
77
registration; missing uncommitted files are not reconstructed.
88

‎docs/NATIVE_MIGRATION.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +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-
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.
60+
CLI and desktop rescue restore a missing checkout’s retained commit into a
61+
separate checkout while preserving original recovery metadata. Dirty-state
62+
transfer, damaged-path repair and reviewed return of changes remain open.
6363

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

‎docs/NATIVE_VERIFICATION.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,21 @@ are byte-for-byte unchanged, and the original branch, registration and moved
288288
files remain. The native executable passes 18 CLI scenario groups / 32 scripted
289289
model requests, including recovery preview, stale-hash rejection and restoration.
290290
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.
291+
reconstruction of missing uncommitted files is not claimed. Damaged-path repair
292+
remains open; desktop rescue is verified separately below.
293+
294+
## Desktop recovery review
295+
296+
Settings exposes the missing-checkout recovery review and an explicit restore
297+
button. The UI regression verifies displayed-source binding, focused review,
298+
visible uncommitted-file limitations, exact hash submission and review clearing
299+
after stale-state rejection. All 24 UI tests and seven browser scenarios pass.
300+
The real native-window probe moves a disposable checkout away, reviews recovery,
301+
restores into a new checkout, verifies committed file contents and checks the
302+
original record and branch remain intact. Light, dark and compact accessibility
303+
reports have zero violations, and the captured recovery screen is inspected.
304+
The test waits for the persisted `ready` state before inspecting checkout files;
305+
a `creating` recovery record intentionally precedes Git's filesystem writes.
293306

294307
## Desktop worktree controls
295308

‎docs/NATIVE_WORKTREES.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,11 @@ missing commit, unavailable registration or stale review blocks rescue. A locked
9191
worktree may be on an unavailable device; inspect its location before unlocking.
9292
Trust and writable/idle source requirements also apply. Open and trust the new
9393
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.
94+
its underlying checkout is repaired and reviewed for removal. Automatic repair of damaged paths remains open.
95+
96+
In **Settings → Worktrees**, choose **Review missing checkout** for the affected
97+
record. The focused review shows its missing path, retained branch and exact
98+
commit, together with the uncommitted-file limitation. **Restore in new worktree**
99+
creates the separate checkout; **Cancel recovery** closes the review without
100+
changes. Errors clear stale reviews. Use **Open worktree** on the newly listed
101+
checkout to enter the usual project trust flow.

‎scripts/test-native-desktop.mjs‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
55
import { spawn, execFile } from "node:child_process";
66
import { promisify } from "node:util";
77
import { createWriteStream } from "node:fs";
8-
import { mkdtemp, mkdir, readFile, writeFile, readlink, readdir, rm } from "node:fs/promises";
8+
import { mkdtemp, mkdir, readFile, writeFile, readlink, readdir, rm, rename } from "node:fs/promises";
99
import { tmpdir } from "node:os";
1010
import path from "node:path";
1111
import { fileURLToPath } from "node:url";
@@ -29,6 +29,7 @@ const profile = path.join(scratch, "profile");
2929
const defaultProfile = process.env.SHADOW_NATIVE_DEFAULT_PROFILE === "1";
3030
const profileArgs = defaultProfile ? [] : ["--profile",profile];
3131
const configDirectory = path.join(profile,defaultProfile ? "config/shadow-agent" : "config");
32+
const dataDirectory = path.join(profile,defaultProfile ? "data/shadow-agent" : "data");
3233
const stateDirectory = path.join(profile,defaultProfile ? "state/shadow-agent" : "state");
3334
const nativeEnv = {...process.env, TMPDIR: path.join(scratch,"images"), ...(defaultProfile ? {
3435
XDG_CONFIG_HOME: path.join(profile,"config"), XDG_DATA_HOME: path.join(profile,"data"), XDG_STATE_HOME: path.join(profile,"state"),
@@ -332,7 +333,7 @@ try {
332333
await clickButton("Worktrees");
333334
await until("Worktree creation ready",()=>execute("return [...document.querySelectorAll('button')].some(b=>b.textContent==='Create worktree' && !b.disabled)"));
334335
await clickButton("Create worktree");
335-
const worktree=await until("Managed worktree created",async()=>(await api("GET","/api/worktrees")).worktrees[0]);
336+
const worktree=await until("Managed worktree created",async()=>(await api("GET","/api/worktrees")).worktrees.find(w=>w.state==="ready"));
336337
assert.equal(await readFile(path.join(worktree.path,"README.md"),"utf8"),"# Native desktop test\nA disposable workspace.\n");
337338
await until("Worktree open button",()=>execute("return [...document.querySelectorAll('button')].some(b=>b.textContent==='Open worktree'&&!b.disabled)"));await clickButton("Open worktree");
338339
await until("Explicit worktree trust",()=>execute("return [...document.querySelectorAll('h2')].some(e=>e.textContent==='Trust this folder?')"));await clickButton("Cancel");
@@ -344,6 +345,25 @@ try {
344345
await wd("POST", `/session/${session}/window/rect`,{width:1380,height:920});
345346
await clickButton("Remove clean worktree");await until("Managed worktree removed",async()=>!(await api("GET","/api/worktrees")).worktrees.length);
346347
assert.equal((await promisify(execFile)("git",["rev-parse",worktree.branch],{cwd:project})).stdout.trim(),worktree.base_commit);
348+
await until("Recovery fixture creation ready",()=>execute("return [...document.querySelectorAll('button')].some(b=>b.textContent==='Create worktree'&&!b.disabled)"));
349+
await clickButton("Create worktree");
350+
const missingWorktree=await until("Recovery fixture created",async()=>(await api("GET","/api/worktrees")).worktrees.find(w=>w.state==="ready"));
351+
const originalRecord=path.join(dataDirectory,"managed-worktrees/records",`${missingWorktree.id}.json`);
352+
const originalRecordBytes=await readFile(originalRecord,"utf8");
353+
await rename(missingWorktree.path,path.join(scratch,"saved-recovery-checkout"));
354+
await until("Recovery review ready",()=>execute("return [...document.querySelectorAll('button')].some(b=>b.textContent==='Review missing checkout'&&!b.disabled)"));
355+
await clickButton("Review missing checkout");
356+
await until("Recovery review focused",()=>execute("return document.activeElement?.getAttribute('aria-label')==='Review worktree recovery'"));
357+
assert.ok(await execute("return document.querySelector('[aria-label=\"Review worktree recovery\"]').textContent.includes('Missing uncommitted files are not reconstructed')"));
358+
await screenshot("worktree-recovery");await accessibility("worktree-recovery");
359+
await execute("document.documentElement.dataset.theme='dark'");await accessibility("worktree-recovery-dark");await execute("document.documentElement.dataset.theme='light'");
360+
await wd("POST", `/session/${session}/window/rect`,{width:620,height:850});await accessibility("worktree-recovery-compact");assert.equal(await execute("return document.documentElement.scrollWidth<=window.innerWidth+1"),true);
361+
await wd("POST", `/session/${session}/window/rect`,{width:1380,height:920});
362+
await clickButton("Restore in new worktree");
363+
const restoredWorktree=await until("Committed work restored",async()=>(await api("GET","/api/worktrees")).worktrees.find(w=>w.id!==missingWorktree.id&&w.state==="ready"));
364+
assert.equal(await readFile(path.join(restoredWorktree.path,"README.md"),"utf8"),"# Native desktop test\nA disposable workspace.\n");
365+
assert.equal(await readFile(originalRecord,"utf8"),originalRecordBytes);
366+
assert.equal((await promisify(execFile)("git",["rev-parse",missingWorktree.branch],{cwd:project})).stdout.trim(),missingWorktree.base_commit);
347367
await clickButton("Plugins");
348368
await until("Native plugin catalog",()=>execute("return !!document.querySelector('.plugin-settings')"));
349369
await clickButton("Review python-expert");
@@ -769,7 +789,7 @@ try {
769789
await until("Terminal cleanup", () => dead(child));
770790
await until("Background child cleanup", () => dead(backgroundChild));
771791
await until("Background process cleanup", () => dead(shutdownBackground.pid));
772-
await writeFile(path.join(artifacts, "result.json"), JSON.stringify({ passed: true, version: version.version, runtime: version.runtime, modelRequests: requests, requestedModels, mcpCalls, mcpHttpCalls, queueRequests, checks: [...(defaultProfile ? ["repeated default-profile activation preserves the live window and its extraction"] : []), "native worktree creation, trust prompt, reviewed removal, retained branch and light/dark/compact accessibility",
792+
await writeFile(path.join(artifacts, "result.json"), JSON.stringify({ passed: true, version: version.version, runtime: version.runtime, modelRequests: requests, requestedModels, mcpCalls, mcpHttpCalls, queueRequests, checks: [...(defaultProfile ? ["repeated default-profile activation preserves the live window and its extraction"] : []), "native worktree creation, trust prompt, reviewed removal, missing checkout rescue, preserved original metadata and light/dark/compact accessibility",
773793
"native built-in/custom plugin review and installation, separate hook activation, actual installed skill execution, removal with local edits preserved", "embedded interface", "Rust IPC", "native approval", "real file write and terminal verification", "durable reload", "queued follow-ups, project FIFO, cross-conversation cancellation, reload selection, model/mode snapshots and inherited results", "native routing controls and persisted model/fallback notices", "background start, live output, coexistence with tasks, stop, and child cleanup on quit", "model background tools, visible exact-command approvals, light/dark/compact approval accessibility, shared panel state and immediate stop cleanup", "selected skill execution, mode enforcement, provenance and durable command cards", "project inspection, native diagnostic cards and Health status distinctions", "task-note command persistence and goal approval after backend selection changes", "reviewed hook activation and disable in Settings, actual completion check, durable hook result", "shared CLI engine with independent project selection and background controls", "MCP registration, exact-argument approval, stdio and authenticated HTTP results, credential redaction, cleanup and removal", "cancellation", "compact layout", "native light/dark/compact/goals/routing/background/skills/hooks/mcp/mcp-http/inspection/diagnostics and queue accessibility", "goal creation, automatic milestone progression, verification, live transcript and pause", "managed native shutdown"] }, null, 2));
774794
console.log("Native desktop window passed: IPC, approval, file/terminal tools, routing, background processes, MCP, shared CLI isolation, replay, cancellation, layout, goals, accessibility, shutdown.");
775795
} catch (error) {

‎ui/src/api.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export type ManagedWorktree = {
117117
created_at: number;
118118
detail: string;
119119
};
120+
export type WorktreeRecovery = {
121+
record: ManagedWorktree;
122+
commit: string;
123+
branch: string;
124+
warning: string;
125+
hash: string;
126+
};
120127
export type WorktreeInspection = {
121128
record: ManagedWorktree;
122129
head: string;
@@ -484,6 +491,17 @@ export const api = {
484491
workspace,
485492
id,
486493
}),
494+
worktreeRecovery: (workspace: string, id: string) =>
495+
send<WorktreeRecovery>("/api/worktrees/recovery", "POST", {
496+
workspace,
497+
id,
498+
}),
499+
restoreWorktree: (workspace: string, id: string, hash: string) =>
500+
send<ManagedWorktree>("/api/worktrees/restore", "POST", {
501+
workspace,
502+
id,
503+
hash,
504+
}),
487505
removeWorktree: (workspace: string, id: string, hash: string) =>
488506
send<ManagedWorktree>("/api/worktrees/remove", "POST", {
489507
workspace,

‎ui/src/components/WorktreeSettings.test.tsx‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ vi.mock("../api", () => ({
1515
createWorktree: vi.fn(),
1616
inspectWorktree: vi.fn(),
1717
removeWorktree: vi.fn(),
18+
worktreeRecovery: vi.fn(),
19+
restoreWorktree: vi.fn(),
1820
},
1921
}));
2022
const record: ManagedWorktree = {
@@ -112,3 +114,36 @@ it("blocks dirty removal and clears a stale review after server rejection", asyn
112114
screen.queryByRole("region", { name: "Review worktree removal" }),
113115
).toBeNull();
114116
});
117+
118+
it("reviews recovery explicitly and rejects stale recovery without reuse", async () => {
119+
vi.mocked(api.worktreeRecovery).mockResolvedValue({
120+
record,
121+
commit: "retained-commit",
122+
branch: record.branch,
123+
warning: "Missing uncommitted files are not reconstructed.",
124+
hash: "recovery-hash",
125+
});
126+
vi.mocked(api.restoreWorktree).mockRejectedValue(
127+
new Error("Recovery state changed"),
128+
);
129+
render(<WorktreeSettings onToast={vi.fn()} />);
130+
fireEvent.click(await screen.findByText("Review missing checkout"));
131+
const region = await screen.findByRole("region", {
132+
name: "Review worktree recovery",
133+
});
134+
expect(document.activeElement).toBe(region);
135+
expect(api.worktreeRecovery).toHaveBeenCalledWith("/source", "managed");
136+
expect(api.restoreWorktree).not.toHaveBeenCalled();
137+
await screen.findByText("Missing uncommitted files are not reconstructed.");
138+
fireEvent.click(screen.getByText("Restore in new worktree"));
139+
await screen.findByRole("alert");
140+
expect(api.restoreWorktree).toHaveBeenCalledWith(
141+
"/source",
142+
"managed",
143+
"recovery-hash",
144+
);
145+
expect(
146+
screen.queryByRole("region", { name: "Review worktree recovery" }),
147+
).toBeNull();
148+
expect(api.removeWorktree).not.toHaveBeenCalled();
149+
});

‎ui/src/components/WorktreeSettings.tsx‎

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { useEffect, useLayoutEffect, useRef, useState } from "react";
2-
import { api, type ManagedWorktree, type WorktreeInspection } from "../api";
2+
import {
3+
api,
4+
type ManagedWorktree,
5+
type WorktreeInspection,
6+
type WorktreeRecovery,
7+
} from "../api";
38
export function WorktreeSettings({
49
onOpen,
510
onToast,
@@ -14,6 +19,7 @@ export function WorktreeSettings({
1419
const [loading, setLoading] = useState(true);
1520
const [error, setError] = useState("");
1621
const [review, setReview] = useState<WorktreeInspection | null>(null);
22+
const [recovery, setRecovery] = useState<WorktreeRecovery | null>(null);
1723
const reviewRef = useRef<HTMLDivElement>(null);
1824
useEffect(() => {
1925
let live = true;
@@ -36,16 +42,17 @@ export function WorktreeSettings({
3642
};
3743
}, []);
3844
useLayoutEffect(() => {
39-
if (review) {
45+
if (review || recovery) {
4046
reviewRef.current?.scrollIntoView({ block: "nearest" });
4147
reviewRef.current?.focus({ preventScroll: true });
4248
}
43-
}, [review]);
49+
}, [review, recovery]);
4450
async function refresh() {
4551
const data = await api.worktrees();
4652
setWorkspace(data.workspace);
4753
setRecords(data.worktrees);
4854
setReview(null);
55+
setRecovery(null);
4956
}
5057
async function perform(action: () => Promise<void>) {
5158
setBusy(true);
@@ -55,6 +62,7 @@ export function WorktreeSettings({
5562
} catch (e) {
5663
setError(String(e));
5764
setReview(null);
65+
setRecovery(null);
5866
} finally {
5967
setBusy(false);
6068
}
@@ -139,17 +147,90 @@ export function WorktreeSettings({
139147
className="ghost"
140148
disabled={busy}
141149
onClick={() =>
142-
void perform(async () =>
143-
setReview(await api.inspectWorktree(workspace, record.id)),
144-
)
150+
void perform(async () => {
151+
setRecovery(null);
152+
setReview(await api.inspectWorktree(workspace, record.id));
153+
})
145154
}
146155
>
147156
Inspect removal
148157
</button>
158+
<button
159+
type="button"
160+
className="ghost"
161+
disabled={busy}
162+
onClick={() =>
163+
void perform(async () => {
164+
setReview(null);
165+
setRecovery(
166+
await api.worktreeRecovery(workspace, record.id),
167+
);
168+
})
169+
}
170+
>
171+
Review missing checkout
172+
</button>
149173
</div>
150174
</article>
151175
))}
152176
</div>
177+
{recovery && (
178+
<div
179+
className="worktree-review"
180+
ref={reviewRef}
181+
tabIndex={-1}
182+
role="region"
183+
aria-label="Review worktree recovery"
184+
>
185+
<h4>Recover committed work</h4>
186+
<p className="hint">Missing checkout</p>
187+
<code className="worktree-path">{recovery.record.path}</code>
188+
<p>
189+
Retained branch:{" "}
190+
<strong>{recovery.branch || "Detached HEAD"}</strong>
191+
</p>
192+
<p>
193+
Commit to restore:{" "}
194+
<code className="worktree-path">{recovery.commit}</code>
195+
</p>
196+
<p>{recovery.warning}</p>
197+
<p className="hint">
198+
The new checkout will appear in this list. Open and trust it before
199+
running tasks.
200+
</p>
201+
<div className="row">
202+
<button
203+
type="button"
204+
className="ghost"
205+
disabled={busy}
206+
onClick={() => setRecovery(null)}
207+
>
208+
Cancel recovery
209+
</button>
210+
<button
211+
type="button"
212+
className="primary"
213+
disabled={busy}
214+
onClick={() =>
215+
void perform(async () => {
216+
const restored = await api.restoreWorktree(
217+
workspace,
218+
recovery.record.id,
219+
recovery.hash,
220+
);
221+
await refresh();
222+
onToast(
223+
`Recovered committed work in ${restored.branch}`,
224+
"ok",
225+
);
226+
})
227+
}
228+
>
229+
Restore in new worktree
230+
</button>
231+
</div>
232+
</div>
233+
)}
153234
{review && (
154235
<div
155236
className="worktree-review"

0 commit comments

Comments
 (0)