diff --git a/AGENTS.md b/AGENTS.md index d1c836be..b1bfd861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ itself driving one of them. - Use the [pruning procedure](docs/worktree-hygiene.md). `remove` and the daily installed job require fresh merge proof and a verified recovery bundle before deleting local work. Do not bypass this with force removal, branch force-deletion, or manual directory deletion. + Unmerged work you are giving up goes through `scripts/worktree.sh retire --expect-head --by --apply`, which verifies separate recovery snapshots and retains the original registered checkout indefinitely in private recovery storage. It removes the managed path and local branch, not the retained disk usage. `scripts/worktree.sh audit` reports eligibility; `schedule status` reports the local job. These tools never delete remote branches or recovery archives. GitHub deletes a PR's branch itself when the PR merges (`delete_branch_on_merge`, on since 2026-10-15) and moves diff --git a/apps/desktop/e2e/code-review-a11y.spec.ts b/apps/desktop/e2e/code-review-a11y.spec.ts index 9b6d54f3..57a8955e 100644 --- a/apps/desktop/e2e/code-review-a11y.spec.ts +++ b/apps/desktop/e2e/code-review-a11y.spec.ts @@ -134,13 +134,12 @@ for (const size of [{ width: 1440, height: 900 }, { width: 900, height: 600 }]) }); } -/** The lowest WCAG contrast of any text inside an element, skipping inactive controls (WCAG 1.4.3 exempts them), with the text that scored it. */ -const lowest = (page: Page, selector: string) => page.locator(selector).first().evaluate((root) => { +/** The lowest WCAG contrast of any text inside every element the selector matches, skipping inactive controls (WCAG 1.4.3 exempts them), with the text that scored it. */ +const lowest = (page: Page, selector: string) => page.locator(selector).evaluateAll((roots) => { const rgba = (s: string) => (s.match(/[\d.]+/g) ?? []).map(Number); const lum = ([r, g, b]: number[]) => [r!, g!, b!].map((v) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }).reduce((a, c, i) => a + c * [0.2126, 0.7152, 0.0722][i]!, 0); let worst = { ratio: 21, text: "" }; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { + for (const root of roots) for (let walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), node = walker.nextNode(); node; node = walker.nextNode()) { const el = node.parentElement!; const text = node.textContent?.trim() ?? ""; if (!text || el.closest('[disabled], [aria-disabled="true"]') || !el.checkVisibility({ opacityProperty: true, visibilityProperty: true })) continue; @@ -157,7 +156,7 @@ const lowest = (page: Page, selector: string) => page.locator(selector).first(). /** The CR-4 to CR-7 text a reviewer reads, in every theme: details, activity, tree and diff, the review panel, the composers and the merge panel. */ const SLICE_TEXT: [string, string][] = [ ["activity", '[data-testid="cr-activity"]'], ["checks", '[data-testid="cr-checks"]'], ["details", 'aside[aria-label="Pull request details"]'], ["details", 'aside[aria-label="Pull request details"]'], - ["tree file", '[data-testid="cr-tree-file"]'], ["file section", '[data-testid="cr-file-section"]'], + ["diff", '[data-testid="code-review-diff"]'], ["tree", '.cr-tree'], ["review section", '[data-testid="cr-agent"]'], ["composer", '[data-testid="cr-compose"]'], ]; for (const theme of Object.keys(THEMES) as Theme[]) { diff --git a/apps/desktop/e2e/code-review-agent.spec.ts b/apps/desktop/e2e/code-review-agent.spec.ts index 936bfb77..bea849cd 100644 --- a/apps/desktop/e2e/code-review-agent.spec.ts +++ b/apps/desktop/e2e/code-review-agent.spec.ts @@ -334,3 +334,65 @@ test("integrated review: populated diff → native fixture finding → confirmed expect(runs()).toHaveLength(1); expect(h.posts()).toHaveLength(1); }); + +/** The pane's scroller for its content: the main column, or the whole body where the narrow overview stacks. */ +async function overviewScroller(page: Page) { + const main = page.locator(".cr-overview-main"); + const stacked = await main.evaluate((el) => getComputedStyle(el).overflowY === "visible"); + return stacked ? tid(page, "code-review-overview-body") : main; +} + +/** C61: the control is inside its scroller's visible box and nothing is painted over it. */ +async function expectClear(page: Page, control: ReturnType, name: string): Promise { + const scroller = await overviewScroller(page); + await control.focus(); + await expect(control, `${name} keeps the focus`).toBeFocused(); + await expect.poll(async () => control.evaluate((el, box) => { + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + return r.height > 0 && r.top >= box.top - 0.5 && r.bottom <= box.bottom + 0.5 && !!hit && (el === hit || el.contains(hit)); + }, await scroller.evaluate((el) => { const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; })), `${name} is on screen and uncovered`).toBe(true); +} + +/** + * C61: brought into view, the last thing in the overview's main column ends inside the scroller, uncovered. Where the + * narrow overview stacks, the sidebar follows the main column, so the item is scrolled to rather than the scroller's end. + */ +async function expectLastItemClear(page: Page, label: string): Promise { + const scroller = await overviewScroller(page); + const measure = () => scroller.evaluate((el) => { + const last = document.querySelector(".cr-overview-main section:last-of-type > :last-child")!; + last.scrollIntoView({ block: "end" }); + const box = el.getBoundingClientRect(), r = last.getBoundingClientRect(); + const hit = document.elementFromPoint(r.left + r.width / 2, r.bottom - 2); + return { scrollerHeight: Math.round(box.height), overrun: Math.round(r.bottom - box.bottom), covered: !hit || !(last === hit || last.contains(hit)), hit: hit?.className ?? null }; + }); + await expect.poll(async () => { const m = await measure(); return m.scrollerHeight >= 120 && m.overrun <= 0 && !m.covered ? "clear" : JSON.stringify(m); }, `${label}: the last item clears the bottom, and the pane keeps a usable height`).toBe("clear"); +} + +for (const [width, height] of [[900, 600], [1440, 900]] as const) { + test(`C61: the inline Ask section and the last activity item stay reachable at ${width}×${height}, with the review panel shut and open`, async () => { + h = await launchWithGh(baseWorld()); + installClaude("reply"); + const page = await openLending(); + // fitWindow caps the height at the display's work area, so a hosted runner's small screen tests a shorter window. + await fitWindow(h, width, height); + const ask = tid(page, "cr-agent-ask"); + await expect(ask).toBeVisible(); + + await expectClear(page, ask.getByRole("textbox"), "Ask's text box"); + await ask.getByRole("textbox").fill("A question long enough to matter.\n".repeat(8)); + await expectClear(page, ask.getByRole("button", { name: "Ask", exact: true }), "Ask"); + await expectLastItemClear(page, "panel shut"); + + await tid(page, "code-review-review").first().click(); + await expect(tid(page, "cr-agent-panel")).toBeVisible(); + await expectClear(page, ask.getByRole("textbox"), "Ask's text box with the panel open"); + await expectClear(page, ask.getByRole("button", { name: "Ask", exact: true }), "Ask with the panel open"); + await expectLastItemClear(page, "panel open"); + // The panel itself: its controls fit, or scroll, inside the window rather than off its bottom edge. + const start = tid(page, "cr-agent-start"); + await start.scrollIntoViewIfNeeded(); + await expect(start).toBeInViewport({ ratio: 1 }); + }); +} diff --git a/apps/desktop/src/renderer/code-review/code-review.css b/apps/desktop/src/renderer/code-review/code-review.css index d6fb31c7..25200fb2 100644 --- a/apps/desktop/src/renderer/code-review/code-review.css +++ b/apps/desktop/src/renderer/code-review/code-review.css @@ -256,3 +256,8 @@ button.cr-state { cursor: pointer; font: inherit; } /* C76: the deletion counts on the file header and tree use the diff's text colour, which keeps AA on every theme's panel background. */ .cr-file-heading .stat.del, .cr-tree-file .stat.del { color: var(--diff-del-text); } + +/* The diff's plain actions (Refresh files, Retry, Cancel context): without this they take the browser's button colours, which fail contrast on every dark theme. */ +.cr-diff button:not([class]) { color: var(--text-1); background: transparent; border: 1px solid var(--border-pane); border-radius: 6px; padding: 2px 8px; font: inherit; cursor: pointer; } +.cr-diff button:not([class]):hover:not(:disabled) { background: var(--bg-row-hover); } +.cr-diff button:not([class]):disabled { cursor: default; color: var(--text-3); } diff --git a/docs/design/code-review.md b/docs/design/code-review.md index ed799c23..6af6042e 100644 --- a/docs/design/code-review.md +++ b/docs/design/code-review.md @@ -580,8 +580,8 @@ avatars and C58, C60 and C61 of the composer are not built (open decisions, #325 **Deviations.** - **D6, repository in the header.** The header shows the repository name and number, as the references - do, with `owner/name` in its tooltip. It reads them from the inbox row or the resolved link until #323 - binds `overview.locator` (C22). + do, with `owner/name` in its tooltip. It reads them from the inbox row or the resolved link until the + overview loads, then from the accepted `overview.locator` (C22, #477). - **D7, state pill.** It is the state menu once the PR has loaded and main has answered for the account; otherwise, and for a merged PR, it shows the state with no menu. - **D8, a Pinned group.** Pins appear in a Pinned group above the inbox's own, which the references diff --git a/docs/reviews/2026-11-01-cr8-acceptance-matrix.md b/docs/reviews/2026-11-01-cr8-acceptance-matrix.md index 8ad20ca0..f1074f75 100644 --- a/docs/reviews/2026-11-01-cr8-acceptance-matrix.md +++ b/docs/reviews/2026-11-01-cr8-acceptance-matrix.md @@ -68,7 +68,7 @@ found that asserts it; it is not a pass. | C19 | Show more | #322 | built | contract test (`knownCount`, `mergePages`, #438 same-total rule); `code-review-navigation.spec.ts` pagination ×3; `code-review-measure.spec.ts` | — | — | open (VoiceOver) | | C20 | Independent inbox scroll | #322 | built | `code-review-navigation.spec.ts` keyboard test; `code-review-scroll.spec.ts` ×2 (#438 `afec85b`) | — | — | — | | C21 | State pill "Open ▾" | #326 (decision 2 settled 2026-10-25) | built (#416 `b3aad0d`); D7: no menu until main answers, none for merged | `code-review-actions.spec.ts` "the state menu closes and reopens a PR, and converts it to draft…", "a state change GitHub may have made stays in doubt…"; `code-review-writes.test.ts`, `code-review-write-ipc.test.ts`; `code-review-a11y.spec.ts` (pill contrast) | — | open (needs disposable repo Val names, #326) | open (VoiceOver) | -| C22 | Repository and number | #323 | deviation D6 (interim): the header still reads `pr.locator` from the row or resolved link (`PrPane.tsx` `PrHeader`), not `overview.locator` | `code-review-navigation.spec.ts` (`title` gives `owner/repo`) | — | — | open (VoiceOver) | +| C22 | Repository and number | #323 | built: #477 (`49aa449`) adopts `overview.locator` into the open PR after the account and PR-key checks, so `PrHeader`'s `pr.locator` is the accepted identity once the overview loads; D6 ended ([evidence](2026-11-01-cr4-current-locator.md)) | `code-review-overview.spec.ts` (renamed repository: header text, title and links); `code-review-navigation.spec.ts` (`title` gives `owner/repo`) | — | — | open (VoiceOver) | | C23 | Loading spinner | #322 | built | `code-review-navigation.spec.ts` (reduced motion); `code-review-regressions.spec.ts` "identity" | — | — | — | | C24 | Pin | #322 | built | `code-review-navigation.spec.ts` (local, per account, survives reload) | `A-322`, `A-main`, `A-main-2` (pin after reload) | — | open (VoiceOver) | | C25 | Copy link | #322 | built | contract test (`canonicalPrUrl`); `code-review-navigation.spec.ts` (clipboard) | — | — | open (VoiceOver) | @@ -96,8 +96,8 @@ found that asserts it; it is not a pass. | C47 | Independent sidebar scroll | #323 | built (#393 `6c40547`) | `code-review-scroll.spec.ts` (#438 `afec85b`, where the sidebar is its own scroller) | — | — | — | | C48 | Activity heading and filter | #323 | built (#393 `6c40547`) | `code-review-overview.spec.ts` "refresh does not mix fresh and pending cached pages…" (filter keeps focus) | — | — | open (VoiceOver) | | C49 | Commit groups | #323 | built (#393 `6c40547`) | `code-review-overview.spec.ts` ("7 commits"); `code-review-overview.test.ts` (dedup) | `A-main` (activity 36, read), `A-323` (35, complete) | — | open (VoiceOver) | -| C50 | "opened this pull request" | #323 | built (#393 `6c40547`; rendered in `PrOverview.tsx`) | unverified: no spec found that asserts the text | `A-323` (opening enrichment read) | — | open: reference comparison | -| C51 | Bot comment card (⋯ menu: Copy comment link, Add to chat) | #323 | built (#393 `6c40547`) | `code-review-overview.spec.ts` (cards); the ⋯ menu's Copy comment link is unverified (no spec found); its Add to chat is covered under C54 | — | — | open (VoiceOver) | +| C50 | "opened this pull request" | #323 | built (#393 `6c40547`; rendered in `PrOverview.tsx`) | candidate `cr-v050` (#481): `code-review-overview.spec.ts` "activity opening and comment link…" asserts actor, wording and time | `A-323` (opening enrichment read) | — | open: reference comparison | +| C51 | Bot comment card (⋯ menu: Copy comment link, Add to chat) | #323 | built (#393 `6c40547`) | `code-review-overview.spec.ts` (cards); candidate `cr-v050` (#481): the same spec's activity-opening case asserts the ⋯ menu's Copy comment link by keyboard, with the exact URL; its Add to chat is covered under C54 | — | — | open (VoiceOver) | | C52 | Deployment table and preview links | #323 | built (#393 `6c40547`) | `code-review-overview.test.ts` "preview destinations reject credentials…"; `code-review-read-service.test.ts`; `code-review-overview.spec.ts` | `A-323` (one real deployment and status) | — | open (VoiceOver) | | C53 | Third-party bot button | #323 | deviation D4 (inert) | `code-review-overview.spec.ts` "hostile Markdown is inert…"; `code-review-regressions.spec.ts` "hostile content" | — | — | — | | C54 | Reaction and Add to chat | #325 (reactions D3) | built: Add to chat (#422 `b643617`); reactions deviation D3 | `code-review-agent.spec.ts` "a question is answered by a run of its own, with 'Add to chat' quoting a comment into it" | — | open (needs repo Val names, #325) | open (VoiceOver) | @@ -107,21 +107,21 @@ found that asserts it; it is not a pass. | C58 | Attach (+) | #325 | not built (left out; no numbered decision covers it) | — | — | — | — | | C59 | Model and reasoning effort | #325 | built: model select only, effort left out (#422 `b643617`) | `code-review-agent.spec.ts` (runs with `--model`); `code-review-agent.test.ts` (unknown model refused) | — | open (needs repo Val names, #325) | open (VoiceOver) | | C60 | Voice input | deferred | deviation D5 | — | — | — | — | -| C61 | Composer clearance | #323 / #324 | built at fixture (#393 `6c40547`): bottom clearance; final Comment in viewport at 900×600 | `code-review-overview.spec.ts` "hostile Markdown is inert and narrow content remains reachable"; `code-review-scroll.spec.ts`. Unverified against CR-6's inline Ask/review section | — | — | open (VoiceOver) | +| C61 | Composer clearance | #323 / #324 | built at fixture (#393 `6c40547`): bottom clearance; final Comment in viewport at 900×600 | `code-review-overview.spec.ts` "hostile Markdown is inert and narrow content remains reachable"; `code-review-scroll.spec.ts`. Candidate `cr-v050-followups`: `code-review-agent.spec.ts` "C61…" measures Ask and the last activity item beside CR-6's inline section and its open panel, at 900×600 and 1440×900; red under an overlay and under a panel that takes the pane's height | — | — | open (VoiceOver) | | C62 | Header repeated over Changes | #322 | built; now repeats the live C21 and C27–C29 | `code-review-navigation.spec.ts`; journey (R4); `code-review-a11y.spec.ts` | — | — | open (VoiceOver) | -| C63 | Changes ⋯ menu and view toggles | #324 | built in part (#404 `e3527f4`): "Wrap lines" and "Highlight changes" toggles. The ⋯ menu and the tree/image/layout toggles are left out, as the inventory allows | unverified: no spec found that toggles them (they show in the diff spec and journey captures) | — | — | open (VoiceOver); open: reference comparison | +| C63 | Changes ⋯ menu and view toggles | #324 | built in part (#404 `e3527f4`): "Wrap lines" and "Highlight changes" toggles. The ⋯ menu and the tree/image/layout toggles are left out, as the inventory allows | candidate `cr-v050` (#484): `code-review-diff.spec.ts` "wrapping and highlighting change rendered code…" | — | — | open (VoiceOver); open: reference comparison | | C64 | File header | #324 | built (#404 `e3527f4`) | `code-review-diff.spec.ts` "grouped diffs, both gutters, hierarchy, filter…"; review-flow (#441) | `A-324` (service: 15/15 files) | — | open (VoiceOver); open: reference comparison | | C65 | Viewed toggle | needs decision 5 | needs decision 5 (not built) | — | — | — | — | -| C66 | File ⋯ menu (never Discard) | #324 | built (#404 `e3527f4`): Copy path and Open in GitHub only | `code-review-regressions.spec.ts` "local Changes stay local"; `code-review-diff.spec.ts` "…use remote data only". The menu items themselves are unverified (no Code Review spec found that opens them) | — | — | open (VoiceOver) | +| C66 | File ⋯ menu (never Discard) | #324 | built (#404 `e3527f4`): Copy path and Open in GitHub only | `code-review-regressions.spec.ts` "local Changes stay local"; `code-review-diff.spec.ts` "…use remote data only". Candidate `cr-v050` (#484): the same file's "file menu copies the remote path…" opens the menu and asserts both items and no Discard | — | — | open (VoiceOver) | | C67 | Folded lines, expand | #324 | built (#404 `e3527f4`) | `code-review-diff.spec.ts` "expanding context asks the exact remote revision…", "review: context expands adjacent to both ends…", "context text budget is shared…"; `code-review-diff.test.ts` "context windows reveal both ends…" | `A-main` (context read), `A-324` (base context matched the merge base) | — | open (VoiceOver) | | C68 | Line numbers, add/remove rows | #324 | built (#404 `e3527f4`) | `code-review-diff.test.ts` ×9; `code-review-diff.spec.ts` "line anchors have one keyboard entry per file…", "old-line anchors name the merge base…"; review-flow | `A-324` (15/15 patches parsed; anchor) | — | open (VoiceOver) | -| C69 | "More unchanged context…" | #324 | built (#404 `e3527f4`; `ContextFold` when `count` is null) | unverified: no spec found that asserts the text (`code-review-diff.spec.ts` clicks `/Expand.*unchanged/`) | — | — | open (VoiceOver) | +| C69 | "More unchanged context…" | #324 | built (#404 `e3527f4`; `ContextFold` when `count` is null) | candidate `cr-v050` (#484): `code-review-diff.spec.ts` "more unchanged context discloses uncertainty…" finds the control by this text and asserts the one request it sends | — | — | open (VoiceOver) | | C70 | Collapsed file sections | #324 | built (#404 `e3527f4`) | `code-review-diff.spec.ts` (Collapse a file; "1,000 files keep… mounted patch bodies bounded") | — | — | open (VoiceOver) | -| C71 | Horizontal scroll per file | #324 | built (#404 `e3527f4`; `.cr-patch { overflow-x: auto }` with wrap off) | unverified: no spec found | — | — | — | +| C71 | Horizontal scroll per file | #324 | built (#404 `e3527f4`; `.cr-patch { overflow-x: auto }` with wrap off) | candidate `cr-v050` (#484): `code-review-diff.spec.ts` "…horizontal scroll stays per file" | — | — | — | | C72 | Filter files | #324 | built (#404 `e3527f4`) | `code-review-diff.spec.ts` (Filter remote files) | — | — | open (VoiceOver) | | C73 | Folder tree, selected file | #324 | built (#404 `e3527f4`) | `code-review-diff.spec.ts` "…hierarchy, filter and keyboard selection…", "narrow tree selection returns to the diff…"; `code-review-scroll.spec.ts`; journey R4 populated (#464 `3d72c6e`) | — | — | open (VoiceOver); open: reference comparison | | C74 | Overview / Changes divider | #322 | built | `code-review-navigation.spec.ts` (keyboard resize persists across reload) | — | — | open (VoiceOver) | -| C75 | Diff / tree divider | #322 | built; pointer and keyboard resize still not asserted | `code-review-a11y.spec.ts` (reached by Tab as a separator); `code-review-navigation.spec.ts` (tree folds when narrow) | — | — | open (VoiceOver) | +| C75 | Diff / tree divider | #322 | built | candidate `cr-v050` (#484): `code-review-diff.spec.ts` "file tree divider resizes with keyboard and pointer and persists on reload"; `code-review-a11y.spec.ts` (reached by Tab as a separator); `code-review-navigation.spec.ts` (tree folds when narrow) | — | — | open (VoiceOver) | | C76 | Keyboard path, 900×600 and 1440×900 | #327 | built; candidate `cr-v050` requires the CR-3 to CR-7 controls and checks every text run in the CR-4 to CR-7 sections | `code-review-a11y.spec.ts`; `code-review-navigation.spec.ts` keyboard test; `code-review-scroll.spec.ts` (focus kept); per-slice keyboard cases in the overview, diff, agent and actions specs | — | — | **open: [VoiceOver checklist](2026-10-22-cr8-voiceover/checklist.md) parts A–D for Val (result row empty)** | | C77 | Overview column scroll | #323 | built (#393 `6c40547`) | `code-review-scroll.spec.ts` (#438 `afec85b`); journey R2 (overview moves, inbox doesn't) | — | — | — | | C78 | Diff pane scroll | #324 | built (#404 `e3527f4`) | `code-review-scroll.spec.ts`; `code-review-diff.spec.ts` "returning from Overview preserves… overall scroll position" | — | — | — | @@ -155,7 +155,7 @@ Decisions 2–4 (C21, C31, C35, C38, C43's +) were settled by Val on 2026-10-25 | #323 AC2 | Pagination and refresh: no duplicates, lost scroll or cross-PR bleed; partial never zero | `code-review-overview.spec.ts` ×8 (failed pages, refresh, delayed bleed, partial first page, retry cursor); `code-review-overview.test.ts`; [cr4-overview](2026-10-23-cr4-overview.md) | `A-323` (activity complete) | — | met at fixture (#393 `6c40547`). A live account switch was not exercised | | #323 AC3 | Stale checks, inferred stack and comment-derived previews kept distinct | `code-review-overview.spec.ts` "stale checks and unavailable collections…"; `code-review-overview.test.ts` (stack, preview destinations, stale retention); `code-review-read-service.test.ts` | `A-323` (deployment; stack unavailable) | — | met at fixture (#393 `6c40547`) | | #323 AC4 | Hostile Markdown/HTML, unsafe schemes, misleading previews inert | `code-review-overview.spec.ts` "hostile Markdown is inert…"; `code-review-regressions.spec.ts` "hostile content"; `code-review-overview.test.ts` "preview destinations reject…" | — | — | met at fixture (#393 `6c40547`). No live untrusted PR content was run | -| #323 AC5 | Long titles, large descriptions, narrow windows readable; composer clearance | `code-review-overview.spec.ts` (900×600, final Comment in viewport); `code-review-scroll.spec.ts` (#438 `afec85b`) | — | — | met at fixture (#393). Clearance next to CR-6's inline review/Ask section is unverified. **Open:** VoiceOver (Val) | +| #323 AC5 | Long titles, large descriptions, narrow windows readable; composer clearance | `code-review-overview.spec.ts` (900×600, final Comment in viewport); `code-review-scroll.spec.ts` (#438 `afec85b`) | — | — | met at fixture (#393). Candidate `cr-v050-followups` measures clearance next to CR-6's inline review/Ask section (C61). **Open:** VoiceOver (Val) | | #323 AC6 | Every visible write control is connected to a verified CR-7 capability or is unavailable; no mutation by #323 | At #393 all writes were unavailable ([cr4-overview](2026-10-23-cr4-overview.md)). Since #416 `b3aad0d` they are connected through `code-review:write` (`code-review-actions.spec.ts`) | — | see #326 AC6 | met at fixture | | #324 AC1 | R4 overview-plus-diff, tree, folds, selected file through real controls; match approved reference geometry | `code-review-diff.spec.ts` "captures populated R4 diff geometry and narrow keyboard file selection"; journey R4 populated (#464 `3d72c6e`); review-flow (#441 `eca2bd9`). Evidence: [cr5-remote-diffs](2026-10-23-cr5-remote-diffs.md) | — | — | met at fixture for the controls (#404 `e3527f4`). **Open:** approved-reference geometry, since the original R4 is missing (Val) | | #324 AC2 | Old/new line references for additions, deletions, renames, no final newline; context keeps them | `code-review-diff.test.ts` ×9; `code-review-diff.spec.ts` "review: deleted ranges and renamed paths retain base anchors…", "old-line anchors name the merge base…"; [cr5-merge-base](2026-10-24-cr5-merge-base.md) | `A-324` (anchor; merge-base match) | — | met at fixture (#404 `e3527f4`), plus an authenticated service probe | @@ -232,15 +232,12 @@ the destination is visible. A product rollback (removing a capability) is a code ## Follow-ups found in this refresh (agent work, not gates) -- C22 still shows the repository and number from the inbox row or resolved link (`PrPane.tsx` reads - `pr.locator`); D6 was meant to end when #323 bound `overview.locator`. Either bind it or record D6 as permanent. +- C22 is closed on `main`: #477 binds the accepted `overview.locator`, which ends D6. - Done in candidate `cr-v050`: `code-review-a11y.spec.ts` requires the overview, diff, review and write controls (C76). Its new per-text contrast check found the file header and tree deletion count at 4.02:1 in OpenCoven; fixed. - Done in candidate `cr-v050`: `code-review-budgets.spec.ts` gates the agreed opening budgets (AC5), and `code-review-actions.spec.ts` proves drafts stay hidden after sign-out (#326). -- No asserting spec for C50's text, C51's Copy comment link, C63's toggles, C66's menu items, C69's text, C71's - horizontal scroll or C75's resize. -- C61's clearance was planned for a floating composer; CR-6 built Ask inline and clearance was not re-measured. +- Done in candidate `cr-v050` (#481, #484): asserting specs for C50, C51, C63, C66, C69, C71 and C75. Done in `cr-v050-followups`: C61's clearance beside CR-6's inline Ask section. - CR-4's and CR-5's authenticated evidence is service-level on pre-merge candidates (`A-323`, `A-324`); an authenticated Electron read of the overview and diff on `main` needs Val's authorization (GET only). diff --git a/docs/reviews/2026-11-02-retirement-safety-repair.md b/docs/reviews/2026-11-02-retirement-safety-repair.md new file mode 100644 index 00000000..89f6f578 --- /dev/null +++ b/docs/reviews/2026-11-02-retirement-safety-repair.md @@ -0,0 +1,33 @@ +# Retirement safety repair review + +Signed candidate `783898a6996bb12bdd4ad6b230d80d4a38086e04` repairs PR #486 (`b896d44c3e6de0ee0739171f468979f574f32bf9`) in the isolated `retire-safety-repair` branch, based on `7cfb29c7b0bfa452c9285f9b85a63a704cda857f`. The candidate incorporates the original PR commit as a merge parent, preserving its authorship. The repair branch is pushed, and GitHub REST verified the candidate signature as valid. It is not merged, released or exercised against a real retirement target; protected CI and landing remain pending. The original PR owner's branch and worktree were not changed. + +## Failures and repair + +Four original defects were reproduced against the exact PR module in disposable repositories: a foreign owner could retire another session's work; staged-only blobs disappeared from the bundle; tracked/untracked writes during the second observation could be deleted; and removing the ignored tar did not stop deletion of original ignored data. The external evidence directory is `/Users/buns/Documents/Codex/artifacts/modex-land-and-prune/` (`pr486-review-findings.md`, `pr486-review-repro/`, `pr486-review-repro-races/`). + +The repair requires a structurally valid matching owner record and checks that the complete record remains unchanged after external observations. Released records must match the exact current head. An original-index commit is the parent of a separate working-tree commit, so staged-only content remains reachable in the self-contained bundle. Raw working files and unknown ignored files have private tar archives, empty-directory restore verification and byte/mode/link manifests. Nested ignored roots include their parent directories and modes without including sibling data. The complete recovery intent and artifact hashes are persisted before the original checkout moves. + +The final observation is not a filesystem lock. The implementation therefore never resets, cleans or deletes original files. Supported `git worktree move` retains the original checkout and Git registration in the private archive run; a no-dereference HEAD update detaches it without touching files or the index. A second snapshot comparison after moving catches changes during that operation. Fresh GitHub and ownership checks precede expected-SHA branch deletion. The original checkout remains indefinitely, including generated data and any writes through file descriptors that raced the observations. Retirement removes the managed path and local branch name but does not reclaim the checkout's disk usage. + +## Recovery and interruptions + +The durable manifest records `path`, `recoveryPath`, `indexSnapshot`, `workingTreeTar`, `ignoredTar`, hashes and a phase (`prepared`, `moved`, `detached`, `retired`). The existing bundle restore instruction fetches both snapshot commits. In a fresh initialized repository after fetching, set detached HEAD to the manifest tip with `git update-ref --no-deref HEAD `, then run `restoreIndex` to restore the staged tree and `restoreWorkingTree` to extract raw working bytes into the otherwise empty checkout. The retained registered checkout also preserves the original live index, staged/unstaged distinction and working file permissions. + +If moving succeeds but a later step fails, the result is preserved and the manifest identifies the recovery checkout. The branch may remain; a failure after detaching leaves a valid detached recovery checkout. An interruption after branch deletion still leaves the prepared recovery intent, bundle and original registered checkout. Nothing automatically removes or prunes this storage. Tests inject interruption after move, before detach and before branch deletion and prove original index, unstaged bytes, ignored bytes, permissions and registration survive. + +## Verification + +The initial ownership tests failed 3/3 against the original behavior, then passed after authorization checks. Eight further regression cases initially failed for independent index recovery, late tracked/untracked/ignored/owner changes, missing/corrupt tar and retained post-observation descriptor writes; they passed after the repair. Three malformed/stale ownership cases also failed before tightening record validation and passed afterward. Independent review identified nested ignored parents; the added nested fixture failed with `ignored.tar restored contents mismatch` before explicit parent archival fixed it. + +A serial run of the complete standalone maintenance test file passed 98/98 in 112.309 seconds before the additional malformed-owner and nested-parent cases were added. Its output is `retire-safety-repair-maintenance.tap` in the external evidence directory. The current retirement-focused run passed 27/27 with no skips or failures in 101.916 seconds, recorded separately in `retire-safety-repair-focused.tap`. The final serial standalone maintenance run then passed 102/102 with no skips or failures in 152.398 seconds (`retire-safety-repair-maintenance-final.tap`), including the nested-parent correction and malformed-owner regressions. + +For the unchanged candidate source, pinned `pnpm install --frozen-lockfile` passed using pnpm 10.34.5 (session 95811), `pnpm build && pnpm test` passed exit 0 (session 6540), and `pnpm typecheck` passed exit 0 across all four packages (session 60950). These were first attempts; no failure/retry was hidden. Build output includes Vite's advisory for chunks over 500 kB. Logs are `retire-safety-repair-install.log`, `retire-safety-repair-build.log`, `retire-safety-repair-test.log` and `retire-safety-repair-typecheck.log` in the external evidence directory. Full test results: scripts 147 passed; core 16 passed; desktop main 2,115 passed and 20 skipped; desktop native/browser harness 21 passed and six skipped; site 10 passed; store desktop 16 passed. Total: 2,325 passed, 26 skipped, zero failures. Skips remain skips, including platform-specific and opt-in native/live coverage. This repair has no UI changes and did not rerun Electron e2e or exercise a real retirement target. These validation runs preceded the signed commit and branch push described above; no real worktree was retired. + +## Remaining limits + +Owner labels coordinate trusted local sessions; they do not authenticate a caller. The common-Git lock prevents overlapping maintenance operations but cannot stop raw Git commands or external filesystem mutations. Portable archives cover the verified snapshot and exclude regenerable directories; the retained original covers later writes and generated data. Unsupported filesystem node types fail closed. Power-loss behavior was not fault-injected; archives and manifests are flushed, but this is not a claim that arbitrary external writers have flushed their data. Independent follow-up review reported no actionable findings after the nested-parent fix, and the required non-UI repository checks passed. Protected CI and landing remain pending. + +## Validated source identity + +Production source `scripts/lib/worktree-maintenance.mjs` SHA256 is `0be1d1d4a997c8786f8c728637d3371f066229036e757cddc63b928b4f06a2c7`; test source `scripts/test/worktree-maintenance.test.mjs` SHA256 is `8ed9d382d408c92f09b342d5dd8e6ebc7c93d71cb8be4988609b501d0bd83d08`. Both remain unchanged from the standalone-test checkpoint. Dependency installation and validation changed no tracked source. `git diff --check` passed. diff --git a/docs/reviews/2026-11-02-v0.5.0-candidate.md b/docs/reviews/2026-11-02-v0.5.0-candidate.md index 3d841d7c..172024be 100644 --- a/docs/reviews/2026-11-02-v0.5.0-candidate.md +++ b/docs/reviews/2026-11-02-v0.5.0-candidate.md @@ -10,10 +10,12 @@ Branch `cr-v050`, on `main` at `b785054e88836ac9df5ca917425031ab697d4d30`. The c | --- | --- | --- | | Code Review on by default | A persisted setting replaces `modex.codeReview`. Settings → General → "Show Code Review in the rail" is the rollback. An old explicit off is kept. The Store build keeps it off | this branch | | C76 accessibility | The Tab spec requires the CR-3 to CR-7 controls at 1440×900 and 900×600. A new check measures every text run in the CR-4 to CR-7 sections and panels, in all three themes | this branch | +| Diff button contrast, on `cr-v050-followups` | Review of #490 showed the check sampled only the first match of each selector. It now measures every match and the whole tree and diff regions. That found the diff's plain buttons (Refresh files, Retry, Cancel context) at 1.72:1 to 2.29:1 in all three themes, because they took the browser's colours. They now use theme colours | stacked on this branch | | Contrast repair | That check found the file header and tree deletion count at 4.02:1 in OpenCoven. Both use the diff's text colour now (5.12:1 or better across what it measures) | this branch | | Performance budgets | `code-review-budgets.spec.ts`, below | this branch | | CR-7 regression | A saved draft stays hidden from the surface and from main after sign-out and for another account, and returns for its own account | this branch | | CF-3 regression | A draft and attachment survive a card arriving, a refused answer and a good answer | this branch | +| C61 clearance, on `cr-v050-followups` | `code-review-agent.spec.ts` measures Ask and the last activity item beside CR-6's inline section and its open panel, at 900×600 and 1440×900. The layout needed no change | stacked on this branch | | #481, #484, #485 | C50/C51 coverage; remote diff control coverage; readiness wording | rebased; their PRs are unchanged until the force-push in [the Val session](2026-11-02-v0.5.0-val-session.md) | Unreleased source already on `main` since v0.4.1, also in this candidate: #461, #462, #463, #465 and #467. @@ -43,6 +45,7 @@ not investigated. **Not yet seen on a hosted runner.** The synthetic `gh` answer | --- | --- | --- | --- | | CR-7 sign-out | main's write `status` trusts the requested account instead of checking who is signed in | `signedOut.result.state` was "ok" | passed | | CF-3 question card | answering a question clears the thread's unsent text | composer value was "" | passed | +| C61 clearance, on `cr-v050-followups` | an overlay over the pane's bottom 200 px; a panel that takes the pane's height | both sizes failed on each: Ask or the last item covered, Ask's text box off screen | passed | The CF-3 mutation clears the text only. Attachments are asserted by the same test but were not separately mutated. @@ -67,6 +70,8 @@ The CF-3 mutation clears the text only. Attachments are asserted by the same tes | Desktop E2E | 495 passed, 7 skipped, 0 failed, 20.9 min | | Site E2E | 7 passed | +**The C61 follow-up on `cr-v050-followups`, one pass on `7e45440`** (`370db33` plus the test and these docs), 01:54–02:18 UTC: build exit 0; unit exit 0, with the same counts as above; desktop E2E 497 passed, 7 skipped, 0 failed, 19.6 min; site E2E 7 passed. The two extra E2E passes are the C61 cases. The branch was then re-stacked on the commit that records the run above, which changes only documents. + **This is the third candidate SHA. The earlier runs stay on the record:** | SHA | Result | What happened next | diff --git a/docs/reviews/2026-11-02-v0.5.0-val-session.md b/docs/reviews/2026-11-02-v0.5.0-val-session.md index 53ff06a9..4538d11d 100644 --- a/docs/reviews/2026-11-02-v0.5.0-val-session.md +++ b/docs/reviews/2026-11-02-v0.5.0-val-session.md @@ -62,6 +62,12 @@ Name `owner/repo#n` on #325 and say go. Follow the [procedure](2026-10-28-cr6-na Look for: `--safe-mode --restricted --tools Read` on the logged `-p` line, one start, a reply bound to the head you opened, and no GitHub write. **Receipt:** `~/.claude/task-evidence/cr6-live-smoke-/receipt.md`, summarized on #325. +## 4a. Authenticated read of the overview and diff (about 5 minutes, no write) + +`A-323` and `A-324` read through the service, on pre-merge candidates. Nothing has read a real overview and diff through Electron on this code. Name a PR you may read on #319 and say go, or do it yourself: start the app as in step 4's procedure, with its logging `gh` wrapper, and open that PR's overview and Changes. + +Look for: the real title, activity and diff at the head GitHub shows, and only `gh api` GET reads in the wrapper's log. **Receipt:** `~/.claude/task-evidence/cr-authenticated-read-/receipt.md`, summarized on #319. + ## 5. CR-7 live canary (about 20 minutes) Name a disposable repository on #326 and say go. Follow the [procedure](2026-11-01-cr7-live-canary.md). diff --git a/docs/status.md b/docs/status.md index 53fff3ef..4b0550d4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -246,8 +246,8 @@ before relying on a tick. | [#280](https://github.com/TypeSafeAI/modex/issues/280) CF-7 | 11/14 | Steps 1, 2 and 4: model-proposed relations, file hashes and selection/notes context are not built; step-level linking was retired | Val reviews the two saved Notes and records steps 1, 2 and 4 as deferred or retired | | [#281](https://github.com/TypeSafeAI/modex/issues/281) CF-8 | 8/27 | Keyboard A1–A8, VoiceOver B1–B7, CF-3 D1–D5, P1–P4 and P6; memory measurement; ChatGPT multi-agent restriction check; staged release recommendation; list of remaining visual differences. C1–C5, V1–V3 and P5 are deferred | Val chooses the candidate, then runs A1–A8 and D1–D5 | | [#319](https://github.com/TypeSafeAI/modex/issues/319) CR tracker | 2/10 | CR-3 to CR-8 acceptance; R1–R4 reconstruction comparison | Land #472 (current CR-8 matrix) | -| [#322](https://github.com/TypeSafeAI/modex/issues/322) CR-3 | 10/14 | Original-reference comparison (AC1), keyboard/VoiceOver (AC4), composer clearance with CR-6's composer | Val runs the [CR-8 VoiceOver checklist](reviews/2026-10-22-cr8-voiceover/checklist.md) | -| [#323](https://github.com/TypeSafeAI/modex/issues/323) CR-4 | 10/14 | Original R1/R2 comparison, VoiceOver, composer clearance; avatars wait on decision 1 | Re-check composer clearance; Val decides on references | +| [#322](https://github.com/TypeSafeAI/modex/issues/322) CR-3 | 10/14 | Original-reference comparison (AC1), keyboard/VoiceOver (AC4); clearance beside CR-6's inline section is measured only in candidate `cr-v050-followups`, not on `main` | Val runs the [CR-8 VoiceOver checklist](reviews/2026-10-22-cr8-voiceover/checklist.md) | +| [#323](https://github.com/TypeSafeAI/modex/issues/323) CR-4 | 10/14 | Original R1/R2 comparison, VoiceOver; clearance beside CR-6's inline section is measured only in candidate `cr-v050-followups`, not on `main`; avatars wait on decision 1 | Val decides on references | | [#324](https://github.com/TypeSafeAI/modex/issues/324) CR-5 | 12/14 | Original R4 comparison (AC1); the Viewed toggle waits on decision 5 | Val decides on R4 references or re-captures | | [#325](https://github.com/TypeSafeAI/modex/issues/325) CR-6 | 13/14 | Authorized live native smoke (AC6) | Val names `owner/repo#n` for the smoke | | [#326](https://github.com/TypeSafeAI/modex/issues/326) CR-7 | 12/14 | No on-main test of draft visibility after sign-out; live canary on a disposable repository (AC6) | Add the sign-out test; Val names a disposable repository | @@ -263,7 +263,8 @@ before relying on a tick. ## In flight -- **v0.5.0 candidate: Code Review on by default** (#319, #327; branch `cr-v050`, full run clean on `370db33`). Carries #481, #484 and #485, a11y coverage of the CR-4 to CR-7 controls (C76), performance budgets as gates, the CR-7 sign-out and CF-3 question-card regressions, and a default-on setting with a rollback in Settings → General that replaces the `modex.codeReview` switch. It stays a candidate: nothing is merged, tagged or released, and no live or human gate has run on it. [Candidate review](reviews/2026-11-02-v0.5.0-candidate.md); [Val's one session](reviews/2026-11-02-v0.5.0-val-session.md). +- **v0.5.0 candidate: Code Review on by default** (#319, #327; branch `cr-v050`, full run clean on `370db33`). Carries #481, #484 and #485, a11y coverage of the CR-4 to CR-7 controls (C76), performance budgets as gates, the CR-7 sign-out and CF-3 question-card regressions, and a default-on setting with a rollback in Settings → General that replaces the `modex.codeReview` switch. It stays a candidate: nothing is merged, tagged or released, and no live or human gate has run on it. [Candidate review](reviews/2026-11-02-v0.5.0-candidate.md); [Val's one session](reviews/2026-11-02-v0.5.0-val-session.md). The C61 clearance test beside CR-6's inline section is on the stacked branch `cr-v050-followups`, whose own full run passed on `7e45440`. +- **Unmerged-work retirement safety repair (#486):** signed candidate `783898a` is pushed on `retire-safety-repair`; it requires matching ownership, archives index and working state separately, verifies raw working/ignored recovery, and retains the original checkout indefinitely instead of deleting files. Focused disposable-fixture results and design are in [the repair review](reviews/2026-11-02-retirement-safety-repair.md). Pinned installation, build, full unit tests (2,325 passed, 26 skipped), typecheck and independent follow-up review passed; protected CI and landing remain pending. No real worktree retirement or repair-specific Electron e2e has been exercised. - **Claude account pool with OrcaRouter keys.** Settings → Coding CLIs → Claude Code adds separate Claude Code profiles (each with its own `CLAUDE_CONFIG_DIR` and browser login), OrcaRouter gateway keys in OS-encrypted storage, an account for new conversations, and opt-in rotation when an account hits its usage limit. Threads bind to their first account; a turn that had not started work retries once on the next account. Unit and E2E coverage use a synthetic `claude`; no live OrcaRouter turn, second subscription or real limit message has been run. OmniRouter was evaluated and not integrated. [Design and limits](claude-accounts.md). diff --git a/docs/worktree-hygiene.md b/docs/worktree-hygiene.md index 012b6557..00a8da27 100644 --- a/docs/worktree-hygiene.md +++ b/docs/worktree-hygiene.md @@ -93,6 +93,26 @@ The generated-data allowlist covers root `node_modules/` and `node_modules/`, `d Ignored `.env`, `.codex`, `.probes`, logs, release artifacts, and other unknown files preserve the checkout. Move evidence you want to keep to durable storage yourself. +## Retire unmerged work + +Work that will never merge (a closed PR, an abandoned experiment, a superseded stack) has no merge proof, so `prune` preserves it. Its recorded owner can retire its local branch explicitly instead: + +```sh +scripts/worktree.sh retire lm2-land --expect-head "$(git rev-parse lm2-land)" --by # report only +scripts/worktree.sh retire lm2-land --expect-head "$(git rev-parse lm2-land)" --by --apply +``` + +The owner label must match the existing claimed or released record and registered path; missing, malformed or different ownership refuses retirement. `CODEX_THREAD_ID` supplies the actor when `--by` is omitted. Labels coordinate sessions, not authentication: never copy another session's label to bypass ownership. An owner must explicitly hand work over before the recipient claims it. + +- The original index becomes its own commit, parented by the branch tip. A separate working snapshot descends from that index commit, preserving staged versions even when the working file differs or is absent. The source index is untouched. The self-contained bundle restores into an empty repository and passes `git fsck --full`. +- `working-tree.tar` preserves non-generated working files, modes and symlink targets independently of Git's content filters. Unknown ignored files also go into `ignored.tar`. Both archives are private (0600), flushed, extracted into empty directories and compared against byte, mode and link manifests. Secrets remain local inside the private common-Git `modex-maintenance/archives/` directory. +- Before moving anything, the durable manifest records the original tip, index and working snapshots, ownership, file manifest, archive hashes, recovery path and restore commands. Retirement repeats GitHub, process, ownership, path, HEAD and file/index checks. Changed observations preserve the work. +- Git moves the entire original checkout into the run's private `original-checkout` directory, preserving its index, working files, ignored/generated files and valid registration. It then detaches HEAD without checking out or resetting any files. Only after fresh remote and ownership observations and archive verification does it delete the branch with the expected SHA. +- The original recovery checkout is retained indefinitely. Retirement frees the managed path and branch name, **not the disk space**. No reset, clean, forced removal, recursive original-file deletion or automatic recovery cleanup runs. An open file descriptor continues to refer to its retained file after the move, protecting writes that race the final observation. +- If interrupted, inspect the manifest's `phase`, original `path` and `recoveryPath`; Git's registered checkout is at one of those locations. A failure after moving reports preserved work, possibly at the recovery path, and can leave the local branch intact. Do not delete either location or retry by bypassing the guard. Recovery checkouts are outside the managed-worktree namespace and are not eligible for scheduled pruning. + +Retirement continues to refuse the primary/default branch, open PRs, active processes, interrupted Git operations, Git locks, hidden index entries, locked/unmanaged worktrees and a changed expected tip. Unsupported filesystem types or failed recovery proofs preserve the original. The maintenance lock coordinates this tool's operations; it cannot authenticate labels, stop arbitrary raw Git commands or freeze external writers. Portable archives describe the verified snapshot; any later writes remain in the retained original checkout. Remote branches are never deleted. + ## Daily automation On macOS, install the user LaunchAgent from reviewed code: diff --git a/scripts/lib/worktree-maintenance.mjs b/scripts/lib/worktree-maintenance.mjs index 49a95793..caa229cc 100644 --- a/scripts/lib/worktree-maintenance.mjs +++ b/scripts/lib/worktree-maintenance.mjs @@ -401,6 +401,12 @@ export class WorktreeMaintenance { verifyArchive(run, entry) { for (const file of [run.bundle, run.manifest, `${run.manifest}.sha256`]) assertStatePath(run, file); const manifest = fs.readFileSync(run.manifest, 'utf8'); + const persisted = JSON.parse(manifest).entries.find(item => item.archiveRef === entry.archiveRef); + if (JSON.stringify(persisted) !== JSON.stringify(entry)) throw new Error('archive metadata changed'); + for (const archive of [entry.workingTreeTar, entry.ignoredTar && { path: entry.ignoredTar, sha256: entry.ignoredTarSha256 }].filter(Boolean)) { + assertStatePath(run, archive.path); + if (sha256(fs.readFileSync(archive.path)) !== archive.sha256) throw new Error('recovery tar integrity changed'); + } if (sha256(manifest) !== fs.readFileSync(`${run.manifest}.sha256`, 'utf8').trim() || JSON.parse(manifest).bundleSha256 !== run.bundleSha256 || sha256(fs.readFileSync(run.bundle)) !== run.bundleSha256 || this.text(['rev-parse', entry.archiveRef]) !== entry.head) throw new Error('archive integrity changed'); } @@ -463,4 +469,177 @@ export class WorktreeMaintenance { return report; }); } + + // Reasons retire accepts: it snapshots dirty files and tars unknown ignored data first. + static RETIRABLE = new Set(['dirty', 'unknown-ignored-data']); + + /** Inspect a branch for retirement. Never mutates anything. */ + async retireCheck(info, branch, expectedHead, by) { + this.git(['check-ref-format', `refs/heads/${branch}`]); + const reasons = []; + if (!this.directBranch(branch)) reasons.push('symbolic-branch'); + if (branch === 'main' || branch === info.defaultBranch) reasons.push('default-branch'); + const head = this.git(['rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`], this.cwd, true).stdout.trim(); + if (!head) return { branch, reasons: [...reasons, 'missing-branch'] }; + if (!fullSha(expectedHead) || head !== expectedHead) reasons.push('head-mismatch'); + const trees = this.worktrees().filter((tree) => tree.branch === `refs/heads/${branch}`); + if (trees.length > 1) reasons.push('multiple-worktrees'); + const tree = trees[0]; + let ownership; + try { + ownership = await this.getRecord(branch); + if (!ownership || ownership.version !== 1 || !fullSha(ownership.head) || typeof ownership.updatedAt !== 'string' || !Number.isFinite(Date.parse(ownership.updatedAt)) || (ownership.status === 'released' && ownership.head !== head) || !['claimed', 'released'].includes(ownership.status) || ownership.branch !== branch || ownership.path !== (tree?.path ?? null) || typeof by !== 'string' || !by || ownership.owner !== by) reasons.push('owner-authorization-required'); + } catch { reasons.push('invalid-ownership-record'); } + const accepted = []; + if (tree) { + if (tree.path === info.primary) reasons.push('primary'); + for (const reason of this.treeReasons(info, tree)) (WorktreeMaintenance.RETIRABLE.has(reason) ? accepted : reasons).push(reason); + } + if (!reasons.length) { + try { + await this.pullEvidence(info, { branch, head }); + if (tree) { + const active = await this.activity(tree.path); + if (typeof active !== 'boolean') throw new Error('unknown process activity'); + if (active) reasons.push('active-process'); + } + } catch (error) { reasons.push(error.message === 'open-pr' ? 'open-pr' : `observation-failed: ${error.message}`); } + } + const ignored = tree && accepted.includes('unknown-ignored-data') + ? this.git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], tree.path).stdout.split('\0').filter((file) => file && !regenerable(file)) + : []; + return { branch, head, path: tree?.path ?? null, dirty: accepted.includes('dirty'), ownership, ignored, reasons, eligible: reasons.length === 0 }; + } + + // File bytes and modes, without following links. Generated files are retained in + // the original checkout too, but excluded from portable recovery archives. + retirementFiles(root, names = fs.readdirSync(root)) { + const result = []; + const visit = (name) => { + if (name === '.git' || regenerable(`${name.replace(/\/$/, '')}/`)) return; + const file = path.join(root, name), stat = fs.lstatSync(file); + const item = { name, mode: stat.mode & 0o7777 }; + if (stat.isSymbolicLink()) item.link = fs.readlinkSync(file); + else if (stat.isFile()) item.sha256 = sha256(fs.readFileSync(file)); + else if (stat.isDirectory()) item.directory = true; + else throw new Error(`unsupported recovery file type: ${name}`); + result.push(item); + if (stat.isDirectory()) for (const child of fs.readdirSync(file).sort()) visit(`${name}/${child}`); + }; + for (const name of names.map(name => name.replace(/\/$/, '')).sort()) { + // A selected ignored root can be nested. Archive its parent directories + // explicitly so extraction preserves their modes without copying siblings. + for (let parent = path.dirname(name); parent !== '.'; parent = path.dirname(parent)) { + if (result.some(item => item.name === parent)) continue; + const stat = fs.lstatSync(path.join(root, parent)); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`unsupported recovery parent: ${parent}`); + result.push({ name: parent, mode: stat.mode & 0o7777, directory: true }); + } + visit(name); + } + return result.sort((a, b) => a.name.localeCompare(b.name)); + } + + retirementState(location) { + if (!location) return null; + return { files: this.retirementFiles(location), index: this.git(['ls-files', '--stage', '-z'], location).stdout }; + } + + recoveryTar(run, location, name, files) { + const tar = path.join(path.dirname(run.manifest), name); + const list = `${tar}.files`; + atomicFile(list, files.map(file => file.name).join('\0') + (files.length ? '\0' : '')); + try { command('tar', ['-cf', tar, '--no-recursion', '-C', location, '--null', '-T', list]); } + finally { fs.unlinkSync(list); } + fs.chmodSync(tar, 0o600); + const fd = fs.openSync(tar, 'r'); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + const digest = sha256(fs.readFileSync(tar)); + const restore = fs.mkdtempSync(path.join(os.tmpdir(), 'modex-retire-restore-')); + try { + command('tar', ['-xpf', tar, '-C', restore]); + if (JSON.stringify(this.retirementFiles(restore)) !== JSON.stringify(files)) throw new Error(`${name} restored contents mismatch`); + } finally { fs.rmSync(restore, { recursive: true, force: true }); } + return { path: tar, sha256: digest }; + } + + /** Retire an owner-authorized branch while retaining its original checkout forever. */ + async retire(branch, { expectedHead, apply = false, by = null } = {}) { + return this.withLock(async () => { + const info = await this.discover(); + const check = await this.retireCheck(info, branch, expectedHead, by); + if (!apply || !check.eligible) return { ...check, status: check.eligible ? 'eligible' : 'preserved' }; + const state = this.retirementState(check.path); + let snapshot = check.head, indexSnapshot = check.head; + if (check.path && check.dirty) { + const index = assertStatePath(info, path.join(info.stateDir, `retire-index-${randomUUID()}`)); + const env = { ...gitEnvironment(), GIT_INDEX_FILE: index }; + const git = (args) => command(this.executables.git ?? 'git', ['--no-replace-objects', '-C', check.path, ...args], undefined, false, env).stdout.trim(); + const commit = (tree, parent, message) => git(['-c', 'user.name=Modex worktree retire', '-c', 'user.email=retire@modex.invalid', 'commit-tree', tree, '-p', parent, '-m', message]); + try { + fs.copyFileSync(this.text(['rev-parse', '--path-format=absolute', '--git-path', 'index'], check.path), index); + git(['update-index', '--no-split-index']); + indexSnapshot = commit(git(['write-tree']), check.head, `Retire index of ${branch}`); + git(['add', '-A', '--', '.']); + snapshot = commit(git(['write-tree']), indexSnapshot, `Retire working tree of ${branch}`); + } finally { fs.rmSync(index, { force: true }); } + } + const proof = { kind: 'retired', by, tip: check.head, snapshot, indexSnapshot, dirty: check.dirty, ignored: check.ignored }; + const run = this.archive(info, [{ branch, head: snapshot, path: check.path, proof }]); + const entry = run.entries[0]; + Object.assign(entry, { tip: check.head, indexSnapshot, state, ownership: check.ownership }); + const result = { branch, head: check.head, snapshot, status: 'preserved', reasons: [] }; + try { + if (check.path) { + entry.workingTreeTar = this.recoveryTar(run, check.path, 'working-tree.tar', state.files); + entry.recoveryPath = path.join(path.dirname(run.manifest), 'original-checkout'); + entry.restoreWorkingTree = `tar -xpf ${quote(entry.workingTreeTar.path)} -C `; + entry.restoreIndex = `git read-tree ${quote(indexSnapshot)}`; + } + if (check.ignored.length) { + const ignored = this.retirementFiles(check.path, check.ignored); + const archive = this.recoveryTar(run, check.path, 'ignored.tar', ignored); + entry.ignoredTar = archive.path; + entry.ignoredTarSha256 = archive.sha256; + entry.restoreIgnored = `tar -xpf ${quote(archive.path)} -C `; + } + // This durable intent identifies both locations even if interrupted during move. + entry.phase = 'prepared'; + this.persistManifest(run); + const again = await this.retireCheck(await this.discover(), branch, expectedHead, by); + if (!again.eligible || again.path !== check.path) throw new Error(`revalidation: ${again.reasons.join(', ') || 'path changed'}`); + if (JSON.stringify(await this.getRecord(branch)) !== JSON.stringify(check.ownership)) throw new Error('ownership changed'); + this.verifyArchive(run, entry); + if (JSON.stringify(this.retirementState(check.path)) !== JSON.stringify(state)) throw new Error('snapshot changed'); + if (check.path) { + // No reset, clean, rm or forced removal: an open descriptor or late write + // still reaches the same retained inode, even after the final observation. + this.git(['worktree', 'move', check.path, entry.recoveryPath], info.primary); + result.recoveryPath = entry.recoveryPath; + entry.phase = 'moved'; + this.persistManifest(run); + if (JSON.stringify(this.retirementState(entry.recoveryPath)) !== JSON.stringify(state)) throw new Error('snapshot changed during move; original retained at recoveryPath'); + if (JSON.stringify(await this.getRecord(branch)) !== JSON.stringify(check.ownership)) throw new Error('ownership changed during move'); + // Detach HEAD without touching the index or working files. + this.git(['update-ref', '--no-deref', 'HEAD', check.head, check.head], entry.recoveryPath); + entry.phase = 'detached'; + this.persistManifest(run); + } + const finalInfo = await this.discover(); + await this.pullEvidence(finalInfo, { branch, head: check.head }); + if (JSON.stringify(await this.getRecord(branch)) !== JSON.stringify(check.ownership)) throw new Error('ownership changed before branch deletion'); + if (this.worktrees().some((tree) => tree.branch === `refs/heads/${branch}`)) throw new Error('branch still checked out'); + if (!this.directBranch(branch) || branch === 'main' || branch === finalInfo.defaultBranch) throw new Error('default or symbolic branch protected'); + this.verifyArchive(run, entry); + this.git(['update-ref', '--no-deref', '-d', `refs/heads/${branch}`, check.head]); + if (this.git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], this.cwd, true).status !== 1) throw new Error('branch removal not confirmed'); + this.writeRecord(info, { version: 1, status: 'retired', branch, head: check.head, snapshot, path: check.path, owner: check.ownership.owner, by, archive: run.manifest, recoveryPath: entry.recoveryPath, updatedAt: new Date().toISOString() }); + entry.phase = 'retired'; + result.status = 'retired'; + } catch (error) { result.reasons.push(error.message); } + run.results.push(result); + this.persistManifest(run); + return { ...result, run }; + }); + } } diff --git a/scripts/test/worktree-maintenance.test.mjs b/scripts/test/worktree-maintenance.test.mjs index b8f8cfc2..9664fc99 100644 --- a/scripts/test/worktree-maintenance.test.mjs +++ b/scripts/test/worktree-maintenance.test.mjs @@ -533,3 +533,214 @@ for (const seam of ['state', 'archives', 'owners']) { assert.equal(f.exists(w), true); }); } + +test('retire dry run reports and changes nothing', async (t) => { + const f = fixture(t); const w = f.create('abandoned'); await f.core.claim(w.name, { owner: 'val' }); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + const value = await f.core.retire(w.name, { expectedHead: w.head, by: 'val' }); + assert.equal(value.status, 'eligible'); + assert.equal(value.dirty, true); + assert.equal(f.exists(w), true); + assert.match(fs.readFileSync(path.join(w.location, 'code.txt'), 'utf8'), /unfinished/); +}); + +test('retire snapshots dirty, untracked and ignored data, then removes the unmerged worktree', async (t) => { + const f = fixture(t); const w = f.create('abandoned'); await f.core.claim(w.name, { owner: 'val' }); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + git(w.location, 'add', 'code.txt'); + fs.writeFileSync(path.join(w.location, 'notes.txt'), 'untracked note\n'); + fs.writeFileSync(path.join(w.location, '.env'), 'SECRET=1\n'); + fs.mkdirSync(path.join(w.location, '.probes')); fs.writeFileSync(path.join(w.location, '.probes', 'probe.mjs'), 'probe\n'); + const value = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'val' }); + assert.equal(value.status, 'retired', value.reasons.join(', ')); + assert.equal(fs.existsSync(w.location), false); + assert.equal(spawnSync('git', ['-C', f.primary, 'show-ref', '--verify', '--quiet', `refs/heads/${w.name}`]).status, 1); + const entry = value.run.entries[0]; + assert.equal(entry.tip, w.head); + assert.equal(entry.proof.kind, 'retired'); + const restored = path.join(f.dir, 'restored'); + git(f.dir, 'init', '-q', restored); + git(restored, 'fetch', '-q', value.run.bundle, `${entry.archiveRef}:refs/heads/restored`); + assert.equal(git(restored, 'rev-parse', 'restored~2'), w.head); + assert.match(git(restored, 'show', 'restored:code.txt'), /unfinished/); + assert.equal(git(restored, 'show', 'restored:notes.txt'), 'untracked note'); + const listed = execFileSync('tar', ['-tf', entry.ignoredTar], { encoding: 'utf8' }); + assert.match(listed, /\.env/); assert.match(listed, /\.probes\//); + assert.equal(fs.statSync(entry.ignoredTar).mode & 0o777, 0o600); + assert.equal((await f.core.getRecord(w.name)).status, 'retired'); +}); + +test('retire removes a clean unmerged worktree with only the exact tip archived', async (t) => { + const f = fixture(t); const w = f.create('clean-abandoned'); await f.core.claim(w.name, { owner: 'val' }); + const value = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'val' }); + assert.equal(value.status, 'retired', value.reasons.join(', ')); + assert.equal(value.snapshot, w.head); + assert.equal(value.run.entries[0].ignoredTar, undefined); +}); + +for (const kind of ['open-pr', 'head-mismatch', 'active', 'operation', 'locked', 'main']) { + test(`retire preserves work on ${kind}`, async (t) => { + const f = fixture(t); const w = f.create('kept'); await f.core.claim(w.name, { owner: 'val' }); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + let branch = w.name; let head = w.head; + if (kind === 'open-pr') f.pulls = [{ state: 'open', head: { ref: w.name, sha: w.head, repo: { full_name: 'example/modex' } }, base: { ref: 'main' } }]; + if (kind === 'head-mismatch') head = git(f.primary, 'rev-parse', 'main'); + if (kind === 'active') f.active = true; + if (kind === 'operation') fs.writeFileSync(git(w.location, 'rev-parse', '--path-format=absolute', '--git-path', 'MERGE_HEAD'), `${w.head}\n`); + if (kind === 'locked') git(f.primary, 'worktree', 'lock', w.location); + if (kind === 'main') { branch = 'main'; head = git(f.primary, 'rev-parse', 'main'); } + const value = await f.core.retire(branch, { expectedHead: head, apply: true, by: 'val' }); + assert.equal(value.status, 'preserved'); + assert.ok(value.reasons.length > 0); + assert.equal(f.exists(w), true); + assert.match(fs.readFileSync(path.join(w.location, 'code.txt'), 'utf8'), /unfinished/); + }); +} + +for (const owner of [null, 'other-owner', 'broken']) { + test(`retire refuses missing, foreign or malformed owner: ${owner}`, async (t) => { + const f = fixture(t); const w = f.create('owned'); + if (owner) await f.core.claim(w.name, { owner }); + if (owner === 'broken') fs.writeFileSync(f.core.recordPath(await f.core.info(), w.name), '{'); + const value = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'my-session' }); + assert.equal(value.status, 'preserved'); + assert.ok(value.reasons.some(reason => /owner/.test(reason))); + assert.equal(f.exists(w), true); + }); +} + + +test('retire preserves independent index and working tree versions in empty restore and retained checkout', async t => { + const f = fixture(t), w = f.create('versions'); + await f.core.claim(w.name, { owner: 'alice' }); + fs.writeFileSync(path.join(w.location, 'code.txt'), 'staged version'); + fs.writeFileSync(path.join(w.location, 'staged-only'), 'index only'); + git(w.location, 'add', '.'); + fs.unlinkSync(path.join(w.location, 'staged-only')); + fs.writeFileSync(path.join(w.location, 'code.txt'), 'working version'); + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + assert.equal(result.status, 'retired', result.reasons.join(', ')); + const entry = result.run.entries[0]; + const restored = path.join(f.dir, 'restored'); git(f.dir, 'init', '--bare', restored); + git(restored, 'fetch', result.run.bundle, `${entry.archiveRef}:refs/heads/restored`); + assert.equal(git(restored, 'show', `${entry.indexSnapshot}:code.txt`), 'staged version'); + assert.equal(git(restored, 'show', `${entry.indexSnapshot}:staged-only`), 'index only'); + assert.equal(git(restored, 'show', 'restored:code.txt'), 'working version'); + assert.equal(git(entry.recoveryPath, 'show', ':code.txt'), 'staged version'); + assert.equal(fs.readFileSync(path.join(entry.recoveryPath, 'code.txt'), 'utf8'), 'working version'); + assert.equal(git(entry.recoveryPath, 'rev-parse', 'HEAD'), w.head); +}); + +for (const change of ['tracked', 'untracked', 'ignored', 'owner', 'missing-tar', 'corrupt-tar']) { + test(`retire preserves originals after late ${change} change`, async t => { + const f = fixture(t), w = f.create('late'); + await f.core.claim(w.name, { owner: 'alice' }); + fs.writeFileSync(path.join(w.location, '.env'), 'original secret'); + f.onActivity = async location => { + if (f.activityCalls !== 2) return; + if (change === 'tracked') fs.writeFileSync(path.join(location, 'code.txt'), 'late tracked'); + if (change === 'untracked') fs.writeFileSync(path.join(location, 'late.txt'), 'late untracked'); + if (change === 'ignored') fs.writeFileSync(path.join(location, '.env'), 'late ignored'); + if (change === 'owner') await f.core.claim(w.name, { owner: 'bob' }); + if (change.endsWith('tar')) { + const archives = path.join((await f.core.info()).stateDir, 'archives'); + const tar = path.join(archives, fs.readdirSync(archives)[0], 'ignored.tar'); + if (change === 'missing-tar') fs.unlinkSync(tar); else fs.writeFileSync(tar, 'corrupt'); + } + }; + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + assert.equal(result.status, 'preserved', result.reasons.join(', ')); + assert.equal(f.exists(w), true); + assert.equal(fs.readFileSync(path.join(w.location, '.env'), 'utf8'), change === 'ignored' ? 'late ignored' : 'original secret'); + if (change === 'tracked') assert.equal(fs.readFileSync(path.join(w.location, 'code.txt'), 'utf8'), 'late tracked'); + if (change === 'untracked') assert.equal(fs.readFileSync(path.join(w.location, 'late.txt'), 'utf8'), 'late untracked'); + }); +} + +test('retire retains bytes written through an open descriptor after final observation', async t => { + const f = fixture(t), w = f.create('descriptor'); + await f.core.claim(w.name, { owner: 'alice' }); + const fd = fs.openSync(path.join(w.location, 'code.txt'), 'a'); t.after(() => fs.closeSync(fd)); + const originalGit = f.core.git.bind(f.core); + f.core.git = (args, ...rest) => { + if (args[0] === 'worktree' && args[1] === 'move') fs.writeSync(fd, 'late descriptor write'); + return originalGit(args, ...rest); + }; + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + const recovery = result.run.entries[0].recoveryPath; + assert.ok(recovery, 'original checkout must be retained'); + assert.match(fs.readFileSync(path.join(recovery, 'code.txt'), 'utf8'), /late descriptor write/); + fs.writeSync(fd, ' after retirement'); + assert.match(fs.readFileSync(path.join(recovery, 'code.txt'), 'utf8'), /after retirement/); +}); + +for (const interruption of ['move', 'detach', 'branch-delete']) { + test(`retire interruption at ${interruption} retains original checkout and durable recovery intent`, async t => { + const f = fixture(t), w = f.create('interrupted'); + await f.core.claim(w.name, { owner: 'alice' }); + fs.writeFileSync(path.join(w.location, '.env'), 'retained secret'); + fs.chmodSync(path.join(w.location, '.env'), 0o600); + fs.writeFileSync(path.join(w.location, 'code.txt'), 'staged'); git(w.location, 'add', 'code.txt'); + fs.writeFileSync(path.join(w.location, 'code.txt'), 'unstaged'); + let durable; + const originalGit = f.core.git.bind(f.core); + f.core.git = (args, ...rest) => { + if (args[0] === 'worktree' && args[1] === 'move') { + durable = JSON.parse(fs.readFileSync(path.join(path.dirname(args[3]), 'manifest.json'), 'utf8')); + assert.equal(durable.entries[0].phase, 'prepared'); + assert.ok(durable.entries[0].ignoredTarSha256); + assert.ok(durable.entries[0].workingTreeTar.sha256); + assert.ok(durable.entries[0].indexSnapshot); + const result = originalGit(args, ...rest); + if (interruption === 'move') throw new Error('simulated interruption after move'); + return result; + } + if (interruption === 'detach' && args[0] === 'update-ref' && args[2] === 'HEAD') throw new Error('simulated interruption before detach'); + if (interruption === 'branch-delete' && args[0] === 'update-ref' && args[2] === '-d') throw new Error('simulated interruption before branch deletion'); + return originalGit(args, ...rest); + }; + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + assert.equal(result.status, 'preserved'); + const recovery = durable.entries[0].recoveryPath; + assert.equal(fs.existsSync(w.location), false); + assert.equal(fs.readFileSync(path.join(recovery, '.env'), 'utf8'), 'retained secret'); + assert.equal(fs.statSync(path.join(recovery, '.env')).mode & 0o777, 0o600); + assert.equal(fs.readFileSync(path.join(recovery, 'code.txt'), 'utf8'), 'unstaged'); + assert.equal(git(recovery, 'show', ':code.txt'), 'staged'); + assert.equal(git(f.primary, 'rev-parse', w.name), w.head); + assert.ok(f.core.worktrees().some(tree => tree.path === recovery)); + }); +} + +for (const invalid of ['missing-head', 'missing-timestamp', 'stale-release']) { + test(`retire rejects structurally invalid ownership: ${invalid}`, async t => { + const f = fixture(t), w = f.create('invalid-owner'); + await f.core.claim(w.name, { owner: 'alice' }); + const info = await f.core.info(), record = await f.core.getRecord(w.name); + if (invalid === 'missing-head') delete record.head; + if (invalid === 'missing-timestamp') delete record.updatedAt; + if (invalid === 'stale-release') { record.status = 'released'; record.head = git(f.primary, 'rev-parse', 'main'); } + f.core.writeRecord(info, record); + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + assert.equal(result.status, 'preserved'); + assert.equal(f.exists(w), true); + }); +} + +test('retire verifies nested ignored roots with parent directory modes and no sibling data', async t => { + const f = fixture(t), w = f.create('nested-ignored'); + await f.core.claim(w.name, { owner: 'alice' }); + fs.mkdirSync(path.join(w.location, 'apps/desktop/.probes'), { recursive: true }); + fs.chmodSync(path.join(w.location, 'apps'), 0o750); + fs.chmodSync(path.join(w.location, 'apps/desktop'), 0o700); + fs.writeFileSync(path.join(w.location, 'apps/desktop/.probes/data'), 'nested ignored bytes'); + fs.writeFileSync(path.join(w.location, 'apps/desktop/sibling.txt'), 'untracked sibling'); + const result = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'alice' }); + assert.equal(result.status, 'retired', result.reasons.join(', ')); + const restore = path.join(f.dir, 'ignored-restore'); fs.mkdirSync(restore); + execFileSync('tar', ['-xpf', result.run.entries[0].ignoredTar, '-C', restore]); + assert.equal(fs.readFileSync(path.join(restore, 'apps/desktop/.probes/data'), 'utf8'), 'nested ignored bytes'); + assert.equal(fs.statSync(path.join(restore, 'apps')).mode & 0o777, 0o750); + assert.equal(fs.statSync(path.join(restore, 'apps/desktop')).mode & 0o777, 0o700); + assert.equal(fs.existsSync(path.join(restore, 'apps/desktop/sibling.txt')), false); +}); diff --git a/scripts/worktree-maintenance.mjs b/scripts/worktree-maintenance.mjs index b5bd2ced..2ab2a9c3 100644 --- a/scripts/worktree-maintenance.mjs +++ b/scripts/worktree-maintenance.mjs @@ -17,6 +17,8 @@ const usage = `Usage: scripts/worktree.sh prune [--apply] [--branch ] [--json] archive and prune eligible local work remove release, archive, and prune one merged worktree + retire --expect-head [--apply] [--by ] [--json] + snapshot, archive, and remove unmerged work you give up schedule install|status|uninstall install a daily macOS user job or inspect it See docs/worktree-hygiene.md for preservation rules and recovery.`; @@ -111,6 +113,17 @@ export async function run(args = process.argv.slice(2), cwd = process.cwd()) { }); console.log(`removed worktree and archived local branch ${name}`); return 0; } + if (action === 'retire') { + const { positional, options } = parse(rest, { '--expect-head': true, '--apply': false, '--by': true, '--json': false }); count(positional, 1); + const value = await core.retire(positional[0], { expectedHead: options['--expect-head'], apply: !!options['--apply'], by: options['--by'] ?? process.env.CODEX_THREAD_ID ?? null }); + const { run: retireRun, ...row } = value; + if (options['--json']) console.log(JSON.stringify(value, null, 2)); + else { + console.log(`${row.status} ${row.branch}: ${row.reasons.length ? row.reasons.join(', ') : `dirty=${row.dirty ?? false} ignored=${(row.ignored ?? []).length}`}`); + if (retireRun) console.log(`Recovery manifest: ${retireRun.manifest}`); + } + return ['eligible', 'retired'].includes(row.status) ? 0 : 1; + } if (action === 'schedule') { count(rest, 1); if (rest[0] === 'install') {