Repository navigation
feat: add prunable worktree lifecycle primitives - #127
Conversation
…nsitions Git prints worktree paths with forward slashes on Windows, so every owned checkout looked nested inside itself. Compare canonical native paths. Interrupted create, restore, and release now recover through restore and release without checkpointing a partial checkout, and restore no longer fails when the saved branch moved since the main checkout stays detached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/cli/main.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Git process costs ~100 ms on Windows. Checkpoints now read the index once, combine rev-parse queries, list untracked and ignored files together, and skip verifying a full bundle. Restores write every tracked file and the index from the checkpoint anyway, so they no longer check out first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…spawns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 38e92f1964
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| throw new Error(`cannot restore a ${record.phase} allocation; release finishes or abandons it`); | ||
| if (existsSync(record.path)) | ||
| throw new Error("restore destination exists; refusing to adopt or overwrite it"); | ||
| this.admit(registry, record.reserve, dirname(record.path)); |
There was a problem hiding this comment.
Exclude retained checkpoint bytes when admitting a restore
When a released record is restored under a capacity equal to its reservation, measure() already includes that record's retained checkpoint and this call then admits the full reservation again. For example, a 1,000,000-byte reservation with a 36,864-byte checkpoint is rejected as 36,864 + 1,000,000 > 1,000,000, even though checkpoint storage is explicitly included in the reservation. Account for the released record's retained bytes during admission so a valid reservation can actually be restored.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 36e4ebc: restore admits only the reservation beyond this record's retained checkpoint, while other records' retained recovery stays charged. Covered by restores within the original reservation while charging other retained checkpoints.
(Written by Claude Opus 5.5)
| } catch (error) { | ||
| if ((error as { status?: number }).status !== 5) throw error; | ||
| } | ||
| git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); |
There was a problem hiding this comment.
Move submodule HEAD before fetching its saved branches
When an initialized submodule's saved branch matches the initial branch chosen by git init—commonly master—this refspec attempts to update the currently checked-out branch. Git refuses the fetch, leaving every such restore incomplete; I reproduced the failure with a submodule on master. Git's fetch -h describes --update-head-ok as allowing updates of the HEAD ref, confirming the restriction. Detach or otherwise move HEAD before fetching the saved refs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 36e4ebc: restore writes a detached HEAD into the private submodule gitdir before fetching +refs/*:refs/*, then points HEAD back at the saved branch. The submodule regression sets init.defaultBranch = master and restores a module on master, which reproduced the refused fetch before the fix.
(Written by Claude Opus 5.5)
| .trim() | ||
| .split("\n"); | ||
| const branch = symbolic === "HEAD" ? null : symbolic; // Detached HEAD is supported. | ||
| cpSync(index, join(target, "index")); |
There was a problem hiding this comment.
Preserve split-index backing files in checkpoints
If the checkout uses Git's supported split-index mode (git update-index --split-index), the copied index references a sharedindex.* file in the linked worktree's administrative directory. discard() deletes that directory, but the checkpoint stores only index, so restoration appears successful and subsequent commands such as git status fail because the shared index is missing. Preserve the backing file or materialize a non-split index before copying it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 36e4ebc: the checkpoint copies the index and runs update-index --no-split-index on the private copy, so it no longer depends on the disposable sharedindex.* file; the live index is untouched. Covered by preserves split-index entries after the disposable Git directory is gone.
(Written by Claude Opus 5.5)
| mkdirSync(dirname(join(target, "files", file)), { recursive: true }); | ||
| cpSync(source, join(target, "files", file), { dereference: false }); |
There was a problem hiding this comment.
Handle file-to-directory transitions while copying source
When a staged change replaces a tracked file such as a with a/b, the union of HEAD and index paths still includes both names; this call reaches the old a, finds a directory, and throws because recursive copying is disabled. The reverse directory-to-file transition similarly fails while examining the obsolete descendant. Git explicitly supports these replacements (git update-index -h lists “let files replace directories and vice-versa”), so valid staged changes currently cannot be checkpointed or released.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 36e4ebc: the saved file tree skips HEAD paths that became directories or sit under a replacement file, and restore copies that tree into an empty checkout. Covered by roundtrips staged file to directory replacements and its reverse.
(Written by Claude Opus 5.5)
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 38e92f1
One additional P2 finding: checkpoint/restore rewrites relative symlink text, producing unintended tracked-source changes (inline).
Also confirmed the existing restore admission double-counting and split-index preservation issues; reference the independent findings rather than duplicate comments:
- #127 (comment)
- #127 (comment)
The independent submodule HEAD/fetch and file/directory-transition findings also remain relevant: 4291331074 and 4291331081.
Direction: the bounded manual lifecycle and explicit interrupted-transition phases are sensible. Centralize lossless copying, make index snapshots self-contained, and admit only incremental storage; these fixes do not require expanding into the deferred process-supervision work.
Verification: static source review; no tests or Git recovery scenarios executed. Existing fixtures do not cover relative-symlink fidelity, split indexes, or restoring at the original tight capacity.
(written by OpenAI)
| throw error; | ||
| } | ||
| mkdirSync(dirname(join(target, "files", file)), { recursive: true }); | ||
| cpSync(source, join(target, "files", file), { dereference: false }); |
There was a problem hiding this comment.
[P2] Preserve symbolic-link text during checkpoint and restore
dereference: false preserves a symlink as a symlink, but cpSync without verbatimSymlinks: true resolves relative link text against the source directory. A clean committed link -> file returns from release/restore as link -> /repo/.worktrees/work/file. The target may still resolve because the checkout path is reused, but tracked source has silently changed and Git reports an unintended diff. Set verbatimSymlinks: true consistently here and in the kept-file/restore copies (lines 416, 537, 547). Add a round-trip assertion for readlink() and clean git diff, including a retained untracked relative link.
There was a problem hiding this comment.
Fixed in 36e4ebc: one copy helper uses verbatimSymlinks: true for tracked files, kept paths, and restore. Covered by preserves tracked and retained relative symlink text, which checks readlink() and a clean git diff HEAD.
(Written by Claude Opus 5.5)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 36e4ebc21f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const entries = checkouts(record.repository); | ||
| for (const entry of entries) | ||
| if (entry.path !== path && inside(path, entry.path)) | ||
| throw new Error(`nested checkout is retained: ${entry.path}`); |
There was a problem hiding this comment.
Refuse nested repositories before removing the checkout
When an independent repository or another repository's linked worktree sits beneath a path classified with --scratch, this lookup only enumerates worktrees registered in record.repository, so the nested checkout is not detected and discard() removes it with the parent. The inspected Git 2.43 help documents --force as forcing removal even when the worktree is dirty or locked; reproducing this with a nested independent repository showed that the double-force removal recursively deleted its uncommitted contents. Detect nested .git repositories/worktrees regardless of which repository registered them before permitting release.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2824044. Release now walks the checkout (without following symlinks) for any nested .git entry that its fresh checkpoint did not save as an initialized submodule, and refuses before entering releasing; discard() repeats the check for interrupted transitions. This replaces the narrower registered-worktree check. Regressions cover an independent repository, another repository's worktree, and this repository's worktree under a --scratch path; each keeps its uncommitted file and the record stays active.
(Written by Claude Opus 5.5)
| [...entries, ...committed] | ||
| .filter((entry) => entry.mode === "160000") | ||
| .map((entry) => [entry.path, entry]), |
There was a problem hiding this comment.
Let index replacements override obsolete submodule entries
When a staged change replaces a HEAD submodule with a regular file or directory, filtering for gitlinks before deduplicating retains the obsolete committed gitlink even though the index now has a non-gitlink at that path. The subsequent submodule check calls readdirSync() on a replacement file (raising ENOTDIR) or rejects a replacement directory as unrecognized, so this valid staged transition cannot be checkpointed or released. Deduplicate HEAD and index entries with index precedence before deciding which paths remain submodules.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2824044. Only index gitlinks are treated as submodules now; HEAD contributes only the files the index deleted or replaced. Regressions roundtrip a staged replacement of a HEAD submodule by a file and by a directory.
(Written by Claude Opus 5.5)
| visit(""); | ||
| for (const path of keep) { | ||
| const source = join(record.path, path); | ||
| if (!existsSync(source)) throw new Error(`keep path does not exist: ${path}`); |
There was a problem hiding this comment.
Use lstat when retaining dangling symlinks
When an untracked or ignored symlink is dangling, Git lists it as unclassified but existsSync(source) follows the link and returns false, so selecting that exact path with --keep always fails with “keep path does not exist.” The user therefore cannot release the checkout while preserving a valid filesystem entry, despite the copy path explicitly preserving symlink text; check the directory entry with lstatSync instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 2824044. Keep paths are checked with lstat, and the escape check now resolves the entry's parent directory, so a dangling symlink is kept with its link text. Regression: keeps dangling untracked symlinks (Unix only, like the other symlink fixture).
(Written by Claude Opus 5.5)
Release scans the checkout for any `.git` entry its checkpoint did not save, so a forced removal never deletes an independent repository or another repository's worktree under a scratch path. The index alone decides which paths are submodules, so staged replacements of a HEAD submodule checkpoint. Keep paths are checked with lstat so dangling symlinks can be retained. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28240444ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function repositories(root: string, path = ""): string[] { | ||
| return readdirSync(join(root, path), { withFileTypes: true }).flatMap((entry) => { | ||
| const child = path ? `${path}/${entry.name}` : entry.name; | ||
| if (entry.name === ".git") return path ? [path] : []; |
There was a problem hiding this comment.
Detect bare repositories before forced removal
When a path classified with --scratch contains a bare repository, such as a cached local mirror, this scan cannot recognize it because bare repositories store HEAD, objects, and refs at their root rather than beneath a .git entry. The fresh bare-repository case is therefore not covered by the new .git scan: release proceeds to the forced worktree removal and recursively deletes the repository, including any unpushed history; I reproduced this with a bare repository under an ignored cache/ directory.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Moot after 60735a8, which redesigns release as prune-in-place. Release no longer removes the checkout, so there is no forced removal and no nested-repository scan. It deletes only the paths the caller passes as --scratch, and refuses any that Git does not ignore, that hold tracked files, or that are reached through a symlink. A bare repository is deleted only if the caller explicitly declares the path holding it to be scratch; anything else under the checkout, including an ignored cache/ that was not declared, stays in place.
(Written by Claude Opus 5.5)
| ); | ||
| } | ||
| private admit(registry: Registry, reserve: number, root: string): void { | ||
| if (lstatSync(this.home).dev !== lstatSync(root).dev) |
There was a problem hiding this comment.
Follow the worktree parent when comparing devices
When .worktrees is a symlink to another mounted filesystem, lstatSync(root) reports the device containing the symlink rather than the device of its target, so this check passes even though recovery state and the checkout are on different filesystems. I reproduced this with a repository and state under /tmp and .worktrees targeting /dev/shm; creation succeeded, invalidating the single-filesystem admission guarantee and leaving checkpoint storage on a filesystem whose free space was not checked. Compare the resolved target's device instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Moot after 60735a8. Nothing large lives in the state directory anymore (no checkpoints), so the same-filesystem requirement and this lstat device comparison are removed. The free-space check uses statfs on the checkout's parent, which follows a symlinked .worktrees to its target filesystem.
(Written by Claude Opus 5.5)
|
|
||
| /** Allocated blocks, including ignored build output; symlinks are never traversed. */ | ||
| export function diskUsage(path: string): number { | ||
| if (!existsSync(path)) return 0; |
There was a problem hiding this comment.
Count dangling symlinks in disk usage
When a tracked or retained symlink is dangling, existsSync() follows its target and returns false, so this function reports zero bytes without reaching lstatSync(). A checkout or checkpoint containing many dangling links is therefore underreported by status and capacity admission, allowing actual retained allocation to exceed the configured budget. Attempt lstatSync() directly and treat only ENOENT as zero.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 60735a8: diskUsage now calls lstat directly and treats only ENOENT as zero bytes, so a dangling symlink is counted. Covered by counts dangling symlinks (Unix only, like the other symlink fixtures).
(Written by Claude Opus 5.5)
The PR lands manual capacity, checkpoint, release, and restore. The quest now covers only supervised process ownership on the quest gate's Linux backend, automatic reconciliation and release, and cancellation fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff2f1a79fa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const checkout = diskUsage(record.path); | ||
| const recovery = diskUsage(join(this.home, "checkpoints", record.id)); | ||
| return total + checkout + Math.max(0, record.reserve - checkout - recovery); |
There was a problem hiding this comment.
Charge linked-worktree objects against capacity
When a linked worktree stages or commits a large new blob, Git stores it in the common repository's .git/objects, not below record.path; the recovery ref created during checkpointing then keeps those objects reachable after release. This calculation counts only the checkout and checkpoint directories, so status and later admission can underreport retained storage by an arbitrarily large amount and exceed the configured capacity. Charge attributable common-object allocations, or remove the extra retained objects and refs once the bundle owns recovery.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The retained-after-release part is moot after 60735a8: release no longer writes recovery commits, refs, or bundles, and leaves Git state untouched. Objects a linked worktree writes to the shared object store are ordinary repository storage, like commits made in the main checkout, so they stay outside per-checkout reservations; the README now states that Git objects in the shared repository are not charged. Declining to attribute common objects to individual checkouts, since Git shares and deduplicates them across worktrees.
(Written by Claude Opus 5.5)
| if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; | ||
| throw error; | ||
| } | ||
| copy(source, join(target, "files", file)); |
There was a problem hiding this comment.
Skip obsolete descendants beneath replacement symlinks
The new file/directory replacement handling still fails when a HEAD directory such as a/child is staged as a symlink a to an existing directory: lstat(a/child) follows the intermediate symlink and succeeds, so the loop first saves a and then tries to copy the obsolete descendant through it. I reproduced this as ERR_FS_CP_EINVAL because source and destination resolved to the same external file, preventing a valid staged directory-to-symlink transition from being checkpointed or released. Skip committed descendants whose prefix has become an index file or symlink.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Moot after 60735a8: release no longer copies tracked source anywhere, so HEAD/index replacement transitions, including directory-to-symlink, never reach a copy step. Tracked files stay in place and release refuses scratch that holds any of them.
(Written by Claude Opus 5.5)
Release now deletes only the declared --scratch paths and leaves the checkout, its Git state, and untracked source in place. Each scratch path must be ignored by Git, hold no tracked files, and not be reached through a symlink; removal unlinks symlinks inside it rather than following them. Restore reserves the checkout's disk again and the caller rebuilds the scratch. This drops the private checkpoint store, bundles, index materialization, submodule reconstruction, the nested-repository scan, the checkpoint command, and --keep/--notes/--check. Nothing outside declared scratch is deleted, so the forced checkout removal and its data-loss edge cases are gone. Shared capacity and reservations remain: active records are charged their reservation, released ones what remains on disk. Release drops the record of a checkout that Git and the disk both lack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b9be286ff4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # README.md # src/cli/main.ts
…allow nested scratch - Refuse a scratch path that is the checkout's .git entry under any alias (case folding, platform normalization), by spelling and by file identity. - Compare free space only with the growth reservations expect on the target filesystem. - Tolerate overlapping scratch paths whose parent was removed first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 132dc5e3d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Canonical paths follow a root replaced by a symlink to another checkout, so the replaced record's registration could match the other checkout and prune its scratch. Read the lock reason through the on-disk checkout's .git file and administrative directory instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 419c98ebb3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Taking the larger of allocated blocks and logical size charged sparse files their full length, rejecting valid reservations. Use allocated blocks, and fall back to size only where the platform reports none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 42ba20d66c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/cli/main.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9592691b02
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Running create inside a managed checkout nests another managed checkout under its ignored .worktrees/, so releasing the outer one with that scratch deleted the inner one. Refuse scratch that holds a worktree registered to the repository, or any linked worktree of another repository, found by its .git file without following symlinks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d links once - A case-insensitive filesystem resolves CACHE to a tracked cache, while a literal pathspec is case-sensitive; match tracked files with icase. - Hard links to one inode were charged once per name; count each inode once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/cli/main.ts
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 235b2538d1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Merge summaryAdds experimental Since the prune-in-place redesign (60735a8), Codex reviewed six heads; every finding is fixed except one fail-safe P2, which is deferred:
Also aligned the worktree quest with kixelated/quest#128: process ownership brings its own backend, restorable from 5684307, and the gate only admits on CPU and memory. Merged current main. Follow-ups: the deferred symlink over-refusal above; (Written by Claude Opus 5.5) |
Adds experimental
quest worktreecommands that share one disk budget across this user's repositories and release a checkout's disk by deleting its declared scratch in place:capacity <bytes>sets the shared budget;statusprints capacity, allocated bytes, and records as JSON.create <name> --reserve <bytes> [--ref]admits the reservation (capacity and free space, counting other active reservations' expected growth), then creates a detached Git worktree under.worktrees/, locked with a reason that names the record.release <id> --scratch <path...>deletes only the declared scratch, such asnode_modulesanddist. The checkout, its Git state, and untracked source stay in place, and the record is charged for what remains on disk instead of its reservation.restore <id>admits the reservation again; the caller then reinstalls or rebuilds the scratch (for examplejust install).Scratch deletion cannot escape: each path must be relative without
..or.git, ignored by Git (check-ignore), and free of tracked files (ls-files), and no ancestor may be a symlink or file. Symlinks at or inside a scratch path are unlinked, never followed (checked under both Node and Bun). Missing paths are skipped, so an interrupted release can be rerun.Quest never removes a checkout. Once one is finished,
git worktree unlockandgit worktree removeit (Git refuses modified or untracked files);releasethen drops the record of a checkout that Git and the disk both lack. A failed creation stays charged untilreleasedrops it. Foreign, re-locked, and pre-existing checkouts are refused, and no live registry lock is stolen.Why the redesign
The previous design checkpointed each checkout to a private store and then force-removed it. In each of four review rounds Codex found about three new edge cases, including data-loss P1s: nested repositories, and then bare repositories, deleted by the forced removal. The disk cost is scratch: on this repository a built checkout is 376 MB, its source 1.6 MB, and a released checkpoint was 4.4 MB. Pruning only declared scratch in place removes the forced removal and every recovery format, at the cost of checkout-independent recovery, which the user accepted.
src/cli/worktree.tsdrops from 596 to 289 lines and its tests from 386 to 188; this round is +245/−769.Settled decisions
Fourth iteration (user feedback from /quest-complete; sub-agent, recommended options taken without prompting):
--keepand the "classify every untracked file" gate, since nothing outside scratch is deleted.checkpointcommand and--notes/--check. The checkout's own commits and PR carry unresolved work and check results, as the agent workflow already requires.max(reserve, usage), released ones their remaining usage. Drop the same-filesystem requirement, since no recovery data lives in the state directory. Git objects in the shared repository stay uncharged and documented.releasedrops the record once Git and the disk both lack its checkout, which covers manualgit worktree removeand failed creations. Aremovecommand is offered as a follow-up.creatingand charged.releasedrops it once the checkout is gone; otherwise it refuses and asks to inspect and remove the partial checkout.creating,active,released. Thereleasingandrestoringrecovery phases are gone, since each transition is a single step under the registry lock.Earlier rounds' user-confirmed choices that still apply:
Earlier rounds' fixes for checkpoint restoration (split index, submodule HEAD, file/directory replacements, symlink text, nested repositories, dangling
--keepsymlinks) were removed with the code they fixed. Of the five open Codex findings, the dangling-symlinkdiskUsageundercount is fixed; the other four are moot and answered inline.Codex's first review of the prune-in-place head (b9be286) found three P2s, all fixed in 132dc5e: scratch naming the
.gitentry through case folding or another alias is refused by spelling and by file identity; free space is compared only with growth expected on the target filesystem; and overlapping scratch such ascache cache/nestedno longer fails after removing the parent. Its second review (132dc5e) found a P1, fixed in 419c98e: a checkout root replaced by a symlink to another managed checkout matched through canonical paths, so release could prune the other checkout's scratch. Ownership now reads Git's lock reason through the on-disk checkout's.gitfile. Its third review (419c98e) found sparse files charged at their logical length; 42ba20d and 862a677 charge allocated blocks, and file size on Windows, which reports no blocks.Design checkpoint
Codex's fourth review of this design (42ba20d) found a P1:
createrun inside a managed checkout nests another managed checkout under its ignored.worktrees/, so releasing the outer one with--scratch .worktreesdeleted the inner one. At the checkpoint the user chose to continue with prune-in-place (findings had narrowed to edge cases of scratch deletion) and to fix it without anchoring creation at the main checkout. dec5ec0 refuses scratch that contains a Git worktree registered to the repository, or any linked worktree of another repository, found by its.gitfile while walking the scratch without following symlinks. One more Codex round is addressed; further minor edge cases become follow-ups.That round (Codex's review of 9592691) found a P1 and a P2, both fixed in 61d9e72: the tracked-file check uses
:(literal,icase)pathspecs, soCACHEresolving to a trackedcacheon a case-insensitive filesystem is refused; and disk usage charges each hard-linked inode once per measurement./quest/m0/worktree.md("Worktree process ownership") now records the prune-in-place decision, describes automatic release as scratch pruning, and says it brings its own process-ownership backend instead of reusing the gate's./quest/m0/local-gate.md,/quest/m0/worktree-agents.md, and/quest/m0/kix/runner.mdlink to it with matching descriptions. Merged currentmain.Validation
nix develop -c just checkpassed, includingquest check.nix develop -c just testpassed: 297 CLI tests (12 worktree cases), shell regressions, both built CLI lifecycle checks, and 281 Worker tests, after merging currentmain...,.git,.GIT, hard-linked.git, and case-aliased tracked scratch; symlinked ancestors, plus symlinks at and inside scratch pointing outside the checkout; reservation versus released-footprint accounting; dropping a removed checkout's record and reusing its name; cross-repository and concurrent admission; failed creation; scratch holding a nested managed checkout or a foreign linked worktree; ownership refusal, including a root replaced by a link to another checkout; and the CLI wiring. Symlink fixtures run on Unix only. The per-filesystem free-space check has no fixture, since it needs two mounts.Follow-ups for /quest-plan
canonical().quest worktree removefor finished checkouts (recommended later, after process ownership): non-forced, refusing modified, untracked, or undeclared ignored files, with exact PR-head evidence before treating a squash-merged branch as finished.create, or in repository config, so automatic release needs no caller (recommended inside Worktree process ownership, not a separate quest).🤖 Generated with Claude Code
(Written by Claude Opus 5.5)