Skip to content

feat: add prunable worktree lifecycle primitives - #127

Merged
kixelated merged 26 commits into
mainfrom
quest/m0/worktree
Oct 21, 2026
Merged

kixelated merged 26 commits into
mainfrom
quest/m0/worktree

Conversation

@kixelated

@kixelated kixelated commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Adds experimental quest worktree commands 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; status prints 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 as node_modules and dist. 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 example just 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 unlock and git worktree remove it (Git refuses modified or untracked files); release then drops the record of a checkout that Git and the disk both lack. A failed creation stays charged until release drops 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.ts drops 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):

  • Release semantics (user):
    • ✅ Prune in place: delete only declared scratch; never force-remove the checkout. Drop the private checkpoint store, bundles, index materialization, submodule reconstruction, and nested-repository scan. Checkout-independent recovery is out of scope.
    • Keep checkpoint-and-remove and fix the open findings. Rejected: every round found new data-loss cases.
  • Scratch safety:
    • ✅ Refuse a path Git does not ignore, one holding tracked files (including submodule gitlinks), or one reached through a symlink or file; unlink symlinks inside rather than following them; skip missing paths.
    • Also refuse nested repositories under scratch. Rejected by the user's scope: scratch is an explicit declaration, and nothing undeclared is deleted.
  • Untracked and ignored files:
    • ✅ Leave them in place and drop --keep and the "classify every untracked file" gate, since nothing outside scratch is deleted.
  • Resume notes:
    • ✅ Drop the checkpoint command and --notes/--check. The checkout's own commits and PR carry unresolved work and check results, as the agent workflow already requires.
    • Store notes on the release record. Rejected: no consumer yet; Worktree process ownership can add metadata its automation needs.
  • Restore:
    • ✅ Admit the reservation again and mark the record active; rebuilding scratch is the caller's job.
    • Run a declared rebuild command. Rejected: running commands in the checkout belongs to the process-ownership quest.
  • Disk accounting (user: keep reservations and admission if they still serve the goal):
    • ✅ Keep shared capacity and reservations. Active records are charged 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.
  • Finished checkouts and stale records:
    • ✅ Quest never removes a checkout. release drops the record once Git and the disk both lack its checkout, which covers manual git worktree remove and failed creations. A remove command is offered as a follow-up.
  • Interrupted creation:
    • ✅ The record stays creating and charged. release drops it once the checkout is gone; otherwise it refuses and asks to inspect and remove the partial checkout.
  • Lifecycle phases:
    • ✅ creating, active, released. The releasing and restoring recovery phases are gone, since each transition is a single step under the registry lock.

Earlier rounds' user-confirmed choices that still apply:

  • ✅ Land manual disk admission, release, and restore here. Plan process ownership, automatic release, and cancellation fixtures separately. That quest brings its own process-ownership backend, restorable from commit 5684307 of kixelated/quest#128, which now does only CPU and memory admission (user, 2026-10-21; supersedes reusing feat: gate checks on host load and memory #128's backend once it merges).
  • ✅ Keep the explicit 20 s budget for real-Git integration fixtures, based on subprocess costs measured on Windows ARM.

Earlier rounds' fixes for checkpoint restoration (split index, submodule HEAD, file/directory replacements, symlink text, nested repositories, dangling --keep symlinks) were removed with the code they fixed. Of the five open Codex findings, the dangling-symlink diskUsage undercount 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 .git entry 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 as cache cache/nested no 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 .git file. 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: create run inside a managed checkout nests another managed checkout under its ignored .worktrees/, so releasing the outer one with --scratch .worktrees deleted 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 .git file 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, so CACHE resolving to a tracked cache on 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.md link to it with matching descriptions. Merged current main.

Validation

  • nix develop -c just check passed, including quest check.
  • nix develop -c just test passed: 297 CLI tests (12 worktree cases), shell regressions, both built CLI lifecycle checks, and 281 Worker tests, after merging current main.
  • Worktree cases cover: pruning scratch, including overlapping paths, while keeping commits, the staged index, unstaged and untracked source, and undeclared ignored files; allocated-block charging of dangling symlinks, sparse files, and hard links; refusal of tracked, force-added, unignored, .., .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

  • Deferred Codex P2 (fails safe): a scratch symlink to another worktree is refused although unlinking it is safe; check the final component by lstat rather than canonical().
  • quest worktree remove for 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.
  • Record scratch declarations at 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)

kixelated and others added 5 commits October 15, 2026 22:01
…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>
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>
@kixelated
kixelated marked this pull request as ready for review October 16, 2026 05:43
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T20:37:55.558354Z 235b253 New commits
🔒 Security Review ✅ Completed 2026-10-21T20:38:37.491956Z 235b253 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Outdated
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
} catch (error) {
if ((error as { status?: number }).status !== 5) throw error;
}
git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
.trim()
.split("\n");
const branch = symbolic === "HEAD" ? null : symbolic; // Detached HEAD is supported.
cpSync(index, join(target, "index"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
Comment on lines +349 to +350
mkdirSync(dirname(join(target, "files", file)), { recursive: true });
cpSync(source, join(target, "files", file), { dereference: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
throw error;
}
mkdirSync(dirname(join(target, "files", file)), { recursive: true });
cpSync(source, join(target, "files", file), { dereference: false });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@kixelated kixelated changed the title feat: prototype recoverable worktree lifecycle feat: add recoverable worktree lifecycle primitives Oct 16, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Outdated
Comment on lines +276 to +279
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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
Comment on lines +409 to +411
[...entries, ...committed]
.filter((entry) => entry.mode === "160000")
.map((entry) => [entry.path, entry]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
visit("");
for (const path of keep) {
const source = join(record.path, path);
if (!existsSync(source)) throw new Error(`keep path does not exist: ${path}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kixelated and others added 2 commits October 21, 2026 10:21
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Outdated
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] : [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
);
}
private admit(registry: Registry, reserve: number, root: string): void {
if (lstatSync(this.home).dev !== lstatSync(root).dev)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated

/** Allocated blocks, including ignored build output; symlinks are never traversed. */
export function diskUsage(path: string): number {
if (!existsSync(path)) return 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Outdated
Comment on lines +189 to +191
const checkout = diskUsage(record.path);
const recovery = diskUsage(join(this.home, "checkpoints", record.id));
return total + checkout + Math.max(0, record.reserve - checkout - recovery);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread src/cli/worktree.ts Outdated
if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue;
throw error;
}
copy(source, join(target, "files", file));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

kixelated and others added 2 commits October 21, 2026 12:06
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>
@kixelated kixelated changed the title feat: add recoverable worktree lifecycle primitives feat: add prunable worktree lifecycle primitives Oct 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
Comment thread src/cli/worktree.ts Resolved
Comment thread src/cli/worktree.ts Resolved
kixelated and others added 3 commits October 21, 2026 12:35
# Conflicts:
#	README.md
#	src/cli/main.ts
#128 drops its systemd ownership backend and keeps only CPU
and memory admission. The worktree quest now brings its own backend,
restorable from 5684307, instead of reusing the gate's or waiting for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
kixelated and others added 2 commits October 21, 2026 13:08
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
Comment thread src/cli/worktree.ts Resolved
kixelated and others added 4 commits October 21, 2026 13:17
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/cli/worktree.ts Resolved
@kixelated

Copy link
Copy Markdown
Collaborator Author

Merge summary

Adds experimental quest worktree commands (capacity, status, create, release, restore) that share one disk budget across this user's repositories and release a checkout by pruning its declared scratch in place. Quest never removes a checkout.

Since the prune-in-place redesign (60735a8), Codex reviewed six heads; every finding is fixed except one fail-safe P2, which is deferred:

  • b9be286: aliases of the administrative entry are refused by spelling and file identity; free space is scoped to the target filesystem; overlapping scratch is tolerated (132dc5e).
  • 132dc5e: a checkout root replaced by a link to another checkout is refused by reading the lock through the on-disk checkout (419c98e).
  • 419c98e: allocated blocks are charged instead of sparse-file length, and file size on Windows (42ba20d, 862a677).
  • 42ba20d (design checkpoint; the user chose to continue): scratch that contains a worktree, registered to this repository or linked from any other, is refused (dec5ec0).
  • 9592691: tracked-file matching is case-insensitive, and hard links count once (61d9e72).
  • 235b253: deferred P2. A scratch symlink to another worktree is refused although unlinking it would be safe. This fails safe.

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. just check and just test passed locally, and all 14 CI checks pass on 235b253.

Follow-ups: the deferred symlink over-refusal above; quest worktree remove for finished checkouts; recording scratch declarations at create (inside the process-ownership quest).

(Written by Claude Opus 5.5)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant