From cfb7e4fdf7ba33745c7286275ce4f1701753edfc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 19:58:51 -0700 Subject: [PATCH 01/17] chore: claim recoverable worktree quest From 890dc7039de0c9e2fe2b7048177237a754a6e1de Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 20:14:31 -0700 Subject: [PATCH 02/17] feat: add recoverable Git worktree primitives --- README.md | 44 ++++ quest/m0/worktree.md | 15 ++ src/cli/main.ts | 52 ++++ src/cli/worktree.ts | 551 ++++++++++++++++++++++++++++++++++++++++++ test/worktree.test.ts | 211 ++++++++++++++++ 5 files changed, 873 insertions(+) create mode 100644 src/cli/worktree.ts create mode 100644 test/worktree.test.ts diff --git a/README.md b/README.md index a26d4718..73ccc946 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,50 @@ There is no deadline unless `--timeout ` is supplied. Activity exits 0, timeout exits 3, invalid arguments exit 2, and other failures exit 1; interrupt with Ctrl-C to stop waiting. +## Recoverable local checkouts + +The experimental `quest worktree` commands reserve disk across this user's +repositories and preserve private recovery data outside disposable checkouts. +They only operate on Git worktrees they create; use the owning application for +Codex, Claude, or other managed checkouts. Foreground process supervision and +crash reconciliation remain unfinished. Do not use these commands to supervise +active agents or commands, and stop all work before taking a checkpoint or +releasing its checkout. + +Set a shared capacity and an explicit reservation in bytes, including the +checkout, expected build growth, and recovery history. Measure representative +jobs before choosing the budget; `status` reports allocated and retained bytes. +Reservations serialize creators and account for concurrent expected growth. +They do not enforce filesystem quotas against arbitrary external writers. + +```sh +quest worktree capacity 20000000000 +quest worktree create example --reserve 3000000000 --ref HEAD +quest worktree status +quest worktree checkpoint example --notes "Implementing the parser" --check "unit tests not run" +quest worktree release example --notes "Awaiting review" --check "just check passed" --keep .env --scratch node_modules dist +quest worktree restore example +``` + +Creation prints JSON with the ID and detached checkout path under `.worktrees/`. +Release always takes a fresh checkpoint. Tracked working files, the staged +index, reachable Git history, and initialized submodules' local history are +preserved. Untracked and ignored files require explicit `--keep` or `--scratch` +classification before release; source cannot be declared scratch. `--keep` +never pushes or publishes its contents. Checkpoints record notes and check +results without running tests or claiming that validation passed. + +Recovery lives in `$QUEST_WORKTREE_HOME`, or +`$XDG_STATE_HOME/quest/worktrees` (default `~/.local/state/quest/worktrees`). It +must be outside the disposable checkout and on the same filesystem for disk +admission. Checkpoint bytes remain charged after checkout removal. There is no +automatic recovery-data deletion or branch deletion, including after a squash +merge. Restore leaves the main checkout detached, preserving existing branches. +A failed creation, restoration, or removal remains charged and visible in +`status`; no live lock is stolen. After a crash, inspect retained state and +ownership before manual reconciliation. This initial interface intentionally +refuses unknown or changed checkout ownership. + ## Work with your agents Open this repository in Claude Code or Codex. The binary carries the skills; diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index a8384906..e2f499ee 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -38,6 +38,21 @@ Agent count is unrestricted by this command; expensive work uses `quest gate`. cancellation, interrupted cleanup, and simultaneous disk admission. Measure retained disk after complete and suspended jobs; update existing docs inline. +Draft implementation status: + +- Draft implementation: explicit disk capacity/reservation, generic Git-owned + create/checkpoint/release/restore, private recovery metadata, full bundles, + tracked/index preservation, explicit keep/scratch classification, initialized + submodule history, retained failed allocations, and filesystem fixtures. +- Remaining before this quest is complete: exact foreground descendant ownership + through cancellation/crashes and automatic reconciliation, interrupted create + and restore recovery, automatic release at lifecycle boundaries, concurrency + and cancellation fixtures, and measured real-job retained footprints. Native + agent lifecycle adoption remains in the related integration quest. +- Process groups and PID files cannot prove detached descendants are stopped + after a supervisor crash. Keep this PR draft until a supported ownership + backend can enforce that boundary; unsupported platforms must fail explicitly. + ## Related - [Expensive command gate](/quest/m0/local-gate.md) - independent CPU admission diff --git a/src/cli/main.ts b/src/cli/main.ts index a9fabce4..87ff798e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -11,6 +11,7 @@ import { init, uninstall } from "./setup"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; import { collect, exists, load, under } from "./tree"; +import { Worktrees } from "./worktree"; import { pullRequest, waitDependencies, waitForActivity, WaitInputError, WaitTimeout } from "./wait"; /** Where the command writes; the process streams, or a buffer under test. */ @@ -139,6 +140,50 @@ export function main(argv: string[], out: Output): number | Promise { ); }); + const worktree = program + .command("worktree") + .description("Reserve disk and checkpoint, release, or restore private Git checkouts"); + const state = () => new Worktrees(); + const record = (value: unknown) => print(JSON.stringify(value)); + worktree + .command("capacity ") + .description("Set this user's shared disk budget in bytes") + .action((bytes: string) => { + state().configure(bytesValue(bytes)); + }); + worktree + .command("status") + .description("Print reservations, retained recovery bytes, and checkouts as JSON") + .action(() => record(state().status())); + worktree + .command("create ") + .description("Create an exclusively owned detached Git worktree under .worktrees/") + .requiredOption("--reserve ", "Checkout, expected build growth, and checkpoint bytes", bytesValue) + .option("--ref ", "Initial Git revision", "HEAD") + .action((name: string, options: { reserve: number; ref: string }) => + record(state().create(root(), name, options.reserve, options.ref)), + ); + for (const operation of ["checkpoint", "release"] as const) { + worktree + .command(`${operation} `) + .description( + operation === "checkpoint" + ? "Preserve source, index, history, and resume notes outside the checkout" + : "Take a fresh checkpoint, then remove an exclusively owned idle checkout", + ) + .requiredOption("--notes ", "Unresolved work, or an explicit completion statement") + .option("--check ", "Checks passed, failed, or not run", []) + .option("--keep ", "Explicit valuable untracked or ignored paths to preserve locally", []) + .option("--scratch ", "Explicit disposable untracked or ignored paths", []) + .action((id: string, options: { notes: string; check: string[]; keep: string[]; scratch: string[] }) => + record(state()[operation](id, { ...options, checks: options.check })), + ); + } + worktree + .command("restore ") + .description("Restore the preserved checkout and source; leave the main worktree detached") + .action((id: string) => record(state().restore(id))); + program .command("guide") .description("Print the quest guide: the format, workflow, and rules agents follow") @@ -226,3 +271,10 @@ function positiveSeconds(value: string): number { } return seconds; } + +function bytesValue(value: string): number { + const bytes = Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(bytes) || bytes <= 0) + throw new InvalidArgumentError("bytes must be a positive safe integer"); + return bytes; +} diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts new file mode 100644 index 00000000..90f4643f --- /dev/null +++ b/src/cli/worktree.ts @@ -0,0 +1,551 @@ +// Recoverable Git checkouts. Recovery data is private, local, and never pushed. +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + cpSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmSync, + statfsSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +export interface WorktreeRecord { + id: string; + name: string; + repository: string; + path: string; + reserve: number; + phase: "creating" | "active" | "releasing" | "released" | "restoring"; + checkpoint?: string; + failure?: string; +} +interface Registry { + version: 1; + capacity: number; + records: WorktreeRecord[]; +} +interface RepositorySnapshot { + path: string; + head: string; + branch: string | null; + files: string[]; + missing: string[]; +} +interface Snapshot { + version: 1; + created: string; + notes: string; + checks: string[]; + keep: string[]; + scratch: string[]; + repositories: RepositorySnapshot[]; + unclassified: string[]; +} +export interface CheckpointOptions { + notes: string; + checks: string[]; + keep: string[]; + scratch: string[]; +} + +function git(root: string, ...args: string[]): string { + return execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); +} +function json(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} +function save(path: string, value: unknown): void { + const temporary = `${path}.${randomUUID()}.tmp`; + writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, path); +} +function inside(root: string, path: string): boolean { + const value = relative(root, path); + return value === "" || (!isAbsolute(value) && value !== ".." && !value.startsWith(`..${sep}`)); +} +function childPath(path: string): string { + if ( + !path || + isAbsolute(path) || + path.split(/[\\/]/).some((part) => part === ".." || part === ".git") || + resolve("/", path) === "/" + ) { + throw new Error(`expected a relative path without .. or .git: ${path}`); + } + return path.replaceAll("\\", "/").replace(/\/$/, ""); +} +function covers(paths: string[], path: string): boolean { + return paths.some((p) => path === p || path.startsWith(`${p}/`)); +} +function list(root: string, ...args: string[]): string[] { + return git(root, "ls-files", "-z", ...args) + .split("\0") + .filter(Boolean); +} + +/** Allocated blocks, including ignored build output; symlinks are never traversed. */ +export function diskUsage(path: string): number { + if (!existsSync(path)) return 0; + const stat = lstatSync(path); + let bytes = Math.max(stat.blocks * 512, stat.size); + if (stat.isDirectory()) for (const entry of readdirSync(path)) bytes += diskUsage(join(path, entry)); + return bytes; +} + +/** All allocations share one registry across repositories for this user/host. */ +export class Worktrees { + readonly home: string; + constructor( + home = process.env.QUEST_WORKTREE_HOME ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "quest", "worktrees"), + ) { + mkdirSync(home, { recursive: true, mode: 0o700 }); + this.home = realpathSync(home); + } + private locked(action: (registry: Registry) => T): T { + const lock = join(this.home, "lock"); + try { + mkdirSync(lock, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") + throw new Error( + `worktree registry is locked: ${lock}; never remove a live lock; after a crash inspect owner.json and reconcile it explicitly`, + ); + throw error; + } + try { + save(join(lock, "owner.json"), { pid: process.pid, started: new Date().toISOString() }); + const file = join(this.home, "registry.json"); + const registry = existsSync(file) + ? json(file) + : { version: 1 as const, capacity: 0, records: [] }; + if (registry.version !== 1) throw new Error("unsupported worktree registry version"); + try { + return action(registry); + } finally { + save(file, registry); + } + } finally { + rmSync(lock, { recursive: true }); + } + } + private find(registry: Registry, id: string): WorktreeRecord { + const record = registry.records.find((item) => item.id === id || item.name === id); + if (!record) throw new Error(`unknown worktree: ${id}`); + return record; + } + private measure(registry: Registry): number { + const retained = diskUsage(join(this.home, "checkpoints")); + return ( + retained + + registry.records.reduce((total, record) => { + if (record.phase === "released") return total; + const checkout = diskUsage(record.path); + const recovery = diskUsage(join(this.home, "checkpoints", record.id)); + return total + checkout + Math.max(0, record.reserve - checkout - recovery); + }, 0) + ); + } + private admit(registry: Registry, reserve: number, root: string): void { + if (lstatSync(this.home).dev !== lstatSync(root).dev) + throw new Error( + "worktree state and checkout must share a filesystem for disk admission; set QUEST_WORKTREE_HOME on the checkout filesystem", + ); + const used = this.measure(registry); + if (used + reserve > registry.capacity) + throw new Error( + `disk capacity exceeded: ${used} reserved/retained + ${reserve} requested > ${registry.capacity}`, + ); + const fs = statfsSync(root); + const future = registry.records.reduce( + (total, record) => + total + + (record.phase === "released" + ? 0 + : Math.max( + 0, + record.reserve - + diskUsage(record.path) - + diskUsage(join(this.home, "checkpoints", record.id)), + )), + 0, + ); + if (Number(fs.bavail) * Number(fs.bsize) < future + reserve) + throw new Error("insufficient free space after accounting for concurrent build reservations"); + } + /** Capacity persists; reducing it below retained allocations is rejected. */ + configure(capacity: number): void { + this.locked((registry) => { + if (!Number.isSafeInteger(capacity) || capacity <= 0) + throw new Error("capacity must be a positive safe integer in bytes"); + if (this.measure(registry) > capacity) + throw new Error("capacity is below existing reserved/retained allocations"); + registry.capacity = capacity; + }); + } + status(): { capacity: number; allocated: number; retained: number; records: WorktreeRecord[] } { + return this.locked((registry) => ({ + capacity: registry.capacity, + allocated: this.measure(registry), + retained: diskUsage(join(this.home, "checkpoints")), + records: registry.records, + })); + } + create(root: string, name: string, reserve: number, ref = "HEAD"): WorktreeRecord { + return this.locked((registry) => { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) + throw new Error("name must contain only letters, digits, dots, underscores, or hyphens"); + if (!Number.isSafeInteger(reserve) || reserve <= 0) + throw new Error( + "reserve must be a positive safe integer in bytes, including expected build and checkpoint growth", + ); + if (registry.records.some((item) => item.name === name)) + throw new Error(`worktree name already exists; use restore or another name: ${name}`); + const repository = realpathSync(git(root, "rev-parse", "--show-toplevel").trim()); + if (inside(repository, this.home)) + throw new Error("recovery state must live outside the disposable checkout"); + const path = join(repository, ".worktrees", name); + mkdirSync(dirname(path), { recursive: true }); + if (existsSync(path)) throw new Error(`refusing to adopt an existing checkout: ${path}`); + this.admit(registry, reserve, dirname(path)); + const record: WorktreeRecord = { id: randomUUID(), name, repository, path, reserve, phase: "creating" }; + registry.records.push(record); + save(join(this.home, "registry.json"), registry); // Reservation survives a crash in git. + try { + git( + repository, + "worktree", + "add", + "--detach", + "--lock", + "--reason", + `quest worktree ${record.id}`, + path, + ref, + ); + record.phase = "active"; + return record; + } catch (error) { + record.failure = String(error); + throw error; + } + }); + } + private owned(record: WorktreeRecord): void { + if (inside(record.path, realpathSync(process.cwd()))) + throw new Error("leave the checkout before checkpointing or releasing it"); + const entries = git(record.repository, "worktree", "list", "--porcelain", "-z").split("\0\0"); + for (const candidate of entries) { + const path = candidate + .split("\0") + .find((line) => line.startsWith("worktree ")) + ?.slice(9); + if (path && path !== record.path && inside(record.path, path)) + throw new Error(`nested checkout is retained: ${path}`); + } + const entry = entries.find((entry) => entry.split("\0").includes(`worktree ${record.path}`)); + if (!entry || !entry.split("\0").includes(`locked quest worktree ${record.id}`)) + throw new Error( + "checkout ownership/lock changed; use the owning tool, never adopt or remove a shared checkout", + ); + } + checkpoint(id: string, options: CheckpointOptions): WorktreeRecord { + return this.locked((registry) => this.checkpointLocked(this.find(registry, id), options)); + } + private checkpointLocked(record: WorktreeRecord, options: CheckpointOptions): WorktreeRecord { + if (record.phase !== "active") throw new Error(`cannot checkpoint a ${record.phase} checkout`); + this.owned(record); + if (!options.notes.trim()) + throw new Error("checkpoint notes must describe unresolved work, or state that it is complete"); + const keep = options.keep.map(childPath); + const scratch = options.scratch.map(childPath); + if (keep.some((k) => scratch.some((s) => covers([k], s) || covers([s], k)))) + throw new Error("keep and scratch paths overlap"); + const directory = join(this.home, "checkpoints", record.id, randomUUID()); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const snapshot: Snapshot = { + version: 1, + created: new Date().toISOString(), + notes: options.notes, + checks: options.checks, + keep, + scratch, + repositories: [], + unclassified: [], + }; + try { + const visit = (path: string) => { + const root = join(record.path, path); + const number = snapshot.repositories.length; + const target = join(directory, String(number)); + mkdirSync(join(target, "files"), { recursive: true }); + const entries = list(root, "--stage").map((entry) => { + const tab = entry.indexOf("\t"); + return { mode: entry.slice(0, 6), path: entry.slice(tab + 1) }; + }); + if (entries.some((entry) => !entry.path || entry.path.includes("\0"))) + throw new Error("invalid tracked path"); + if (git(root, "ls-files", "--unmerged").trim()) + throw new Error("resolve the unmerged index before checkpointing"); + const committed = git(root, "ls-tree", "-r", "-z", "HEAD") + .split("\0") + .filter(Boolean) + .map((entry) => ({ mode: entry.slice(0, 6), path: entry.slice(entry.indexOf("\t") + 1) })); + const files = [ + ...new Set( + [...entries, ...committed] + .filter((entry) => entry.mode !== "160000") + .map((entry) => entry.path), + ), + ]; + for (const file of files) + if (covers(scratch, path ? `${path}/${file}` : file)) + throw new Error(`scratch includes tracked source: ${file}`); + const missing: string[] = []; + for (const file of files) { + const source = join(root, file); + try { + lstatSync(source); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + missing.push(file); + continue; + } + throw error; + } + mkdirSync(dirname(join(target, "files", file)), { recursive: true }); + cpSync(source, join(target, "files", file), { dereference: false }); + } + let branch: string | null = null; + try { + branch = git(root, "symbolic-ref", "--quiet", "HEAD").trim(); + } catch { + /* Detached HEAD is supported. */ + } + const head = git(root, "rev-parse", "HEAD").trim(); + const index = git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(); + cpSync(index, join(target, "index")); + if (path) { + const config = git(root, "rev-parse", "--path-format=absolute", "--git-path", "config").trim(); + cpSync(config, join(target, "config")); + } + // A recovery commit makes staged blobs reachable without changing HEAD/index. + const tree = git(root, "write-tree").trim(); + const commit = git( + root, + "-c", + "user.name=Quest recovery", + "-c", + "user.email=recovery@localhost", + "commit-tree", + tree, + "-p", + head, + "-m", + `Local worktree recovery ${record.id}`, + ).trim(); + git(root, "update-ref", `refs/quest/worktrees/${record.id}/${number}`, commit); + git(root, "bundle", "create", join(target, "history.bundle"), "--all", "--reflog", "HEAD"); + git(root, "bundle", "verify", join(target, "history.bundle")); + snapshot.repositories.push({ path, head, branch, files, missing }); + for (const file of [ + ...list(root, "--others", "--exclude-standard"), + ...list(root, "--others", "--ignored", "--exclude-standard"), + ]) { + const relative = path ? `${path}/${file}` : file; + if (!covers(keep, relative) && !covers(scratch, relative)) snapshot.unclassified.push(relative); + } + for (const entry of [ + ...new Map( + [...entries, ...committed] + .filter((entry) => entry.mode === "160000") + .map((entry) => [entry.path, entry]), + ).values(), + ]) { + const child = path ? `${path}/${entry.path}` : entry.path; + if (existsSync(join(record.path, child, ".git"))) visit(child); + else if (existsSync(join(record.path, child)) && readdirSync(join(record.path, child)).length) + throw new Error(`unrecognized submodule contents: ${child}`); + } + }; + visit(""); + for (const path of keep) { + const source = join(record.path, path); + if (!existsSync(source)) throw new Error(`keep path does not exist: ${path}`); + if (!inside(record.path, realpathSync(source))) throw new Error(`keep path escapes checkout: ${path}`); + if (lstatSync(source).isDirectory() && existsSync(join(source, ".git"))) + throw new Error( + `keep directories cannot contain a repository: ${path}; tracked submodules are preserved automatically`, + ); + mkdirSync(dirname(join(directory, "kept", path)), { recursive: true }); + cpSync(source, join(directory, "kept", path), { recursive: true, dereference: false }); + } + save(join(directory, "snapshot.json"), snapshot); + const previous = record.checkpoint; + record.checkpoint = directory; + record.failure = undefined; + // Publish the new recovery pointer before collecting the old checkpoint. + // A crash can retain extra data, but can never invalidate the saved pointer. + const registry = json(join(this.home, "registry.json")); + const current = this.find(registry, record.id); + Object.assign(current, record); + save(join(this.home, "registry.json"), registry); + if (previous) rmSync(previous, { recursive: true }); + return record; + } catch (error) { + if (record.checkpoint !== directory) rmSync(directory, { recursive: true }); + throw error; + } + } + /** Checkpoint and deletion occur under the same lock; stale snapshots cannot lose edits. */ + release(id: string, options: CheckpointOptions): WorktreeRecord { + return this.locked((registry) => { + const record = this.find(registry, id); + if (record.phase === "released") return record; + if (record.phase === "releasing" && !existsSync(record.path)) { + record.phase = "released"; + record.failure = undefined; + return record; + } + if (record.phase === "releasing") record.phase = "active"; + this.checkpointLocked(record, options); + const snapshot = json(join(record.checkpoint!, "snapshot.json")); + if (snapshot.unclassified.length) + throw new Error( + `classify untracked/ignored files with --keep or --scratch before release: ${snapshot.unclassified.slice(0, 20).join(", ")}`, + ); + record.phase = "releasing"; + save(join(this.home, "registry.json"), registry); + try { + git(record.repository, "worktree", "unlock", record.path); + // Git itself removes the owned checkout and its administrative directory. + git(record.repository, "worktree", "remove", "--force", record.path); + if (existsSync(record.path)) throw new Error("Git reported cleanup success but checkout is retained"); + record.phase = "released"; + record.failure = undefined; + return record; + } catch (error) { + record.failure = String(error); + if (existsSync(record.path)) { + try { + git( + record.repository, + "worktree", + "lock", + "--reason", + `quest worktree ${record.id}`, + record.path, + ); + } catch { + /* Preserve allocation even when re-locking fails. */ + } + } + throw new Error(`cleanup failed; allocation retained: ${record.path}: ${String(error)}`); + } + }); + } + restore(id: string): WorktreeRecord { + return this.locked((registry) => { + const record = this.find(registry, id); + if (record.phase === "active") { + this.owned(record); + return record; + } + if (record.phase !== "released" || !record.checkpoint) + throw new Error( + `cannot restore a ${record.phase} allocation; inspect retained checkout before recovery`, + ); + if (existsSync(record.path)) + throw new Error("restore destination exists; refusing to adopt or overwrite it"); + this.admit(registry, record.reserve, dirname(record.path)); + const snapshot = json(join(record.checkpoint, "snapshot.json")); + if (snapshot.version !== 1) throw new Error("unsupported checkpoint version"); + record.phase = "restoring"; + save(join(this.home, "registry.json"), registry); + try { + for (let i = 0; i < snapshot.repositories.length; i++) { + const saved = snapshot.repositories[i]; + const root = join(record.path, saved.path); + const source = join(record.checkpoint, String(i)); + if (i === 0) { + git( + record.repository, + "fetch", + "--no-write-fetch-head", + join(source, "history.bundle"), + `refs/quest/worktrees/${record.id}/0:refs/quest/worktrees/${record.id}/0`, + ); + git( + record.repository, + "worktree", + "add", + "--detach", + "--lock", + "--reason", + `quest worktree ${record.id}`, + root, + saved.head, + ); + } else { + mkdirSync(root, { recursive: true }); + git(root, "init"); + cpSync(join(source, "config"), join(root, ".git", "config")); + // Embedded restored gitdirs have a different relative worktree path. + try { + execFileSync( + "git", + ["config", "--file", join(root, ".git", "config"), "--unset-all", "core.worktree"], + { cwd: this.home, stdio: "pipe" }, + ); + } catch (error) { + if ((error as { status?: number }).status !== 5) throw error; + } + git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); + git(root, "checkout", "--detach", saved.head); + } + if (saved.branch) { + const current = git(root, "rev-parse", "--verify", saved.branch).trim(); + if (current !== saved.head) + throw new Error( + `branch advanced since checkpoint; restored files retained detached: ${saved.branch}`, + ); + if (i > 0) git(root, "symbolic-ref", "HEAD", saved.branch); + // Main checkout remains detached so another checkout's branch is never stolen. + } + for (const file of saved.missing) rmSync(join(root, file), { force: true }); + for (const file of saved.files.filter((file) => !saved.missing.includes(file))) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + rmSync(join(root, file), { force: true }); + cpSync(join(source, "files", file), join(root, file), { dereference: false }); + } + cpSync( + join(source, "index"), + git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(), + ); + } + if (existsSync(join(record.checkpoint, "kept"))) + cpSync(join(record.checkpoint, "kept"), record.path, { recursive: true, dereference: false }); + record.phase = "active"; + record.failure = undefined; + return record; + } catch (error) { + record.failure = String(error); + throw new Error( + `restore incomplete; allocation and recovery retained: ${record.path}: ${String(error)}`, + ); + } + }); + } +} diff --git a/test/worktree.test.ts b/test/worktree.test.ts new file mode 100644 index 00000000..7d0a5b7b --- /dev/null +++ b/test/worktree.test.ts @@ -0,0 +1,211 @@ +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Worktrees } from "../src/cli/worktree"; +import { run } from "./fixture"; + +const roots: string[] = []; +function git(root: string, ...args: string[]): string { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} +function repository(path: string): string { + mkdirSync(path, { recursive: true }); + git(path, "init", "-b", "main"); + git(path, "config", "user.name", "Fixture"); + git(path, "config", "user.email", "fixture@localhost"); + writeFileSync(join(path, "file"), "original\n"); + writeFileSync(join(path, ".gitignore"), "cache/\n.env\n.worktrees/\n"); + git(path, "add", "."); + git(path, "commit", "-m", "initial"); + return path; +} +function fixture() { + const root = mkdtempSync(join(tmpdir(), "quest-worktree-test-")); + roots.push(root); + const repo = repository(join(root, "repo")); + const home = join(root, "state"); + const worktrees = new Worktrees(home); + worktrees.configure(100_000_000); + return { root, repo, home, worktrees }; +} +const notes = { + notes: "Remaining work: finish implementation", + checks: ["unit tests not run"], + keep: [] as string[], + scratch: [] as string[], +}; +afterEach(() => { + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("recoverable worktrees", () => { + it("preserves the staged index, unstaged source, additions, and deletions", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "file"), "staged\n"); + git(record.path, "add", "file"); + writeFileSync(join(record.path, "file"), "unstaged\n"); + git(record.path, "rm", ".gitignore"); + writeFileSync(join(record.path, "added"), "staged addition"); + git(record.path, "add", "added"); + writeFileSync(join(record.path, "added"), "modified addition"); + worktrees.release(record.id, notes); + worktrees.restore(record.id); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe("unstaged\n"); + expect(git(record.path, "show", ":file")).toBe("staged"); + expect(existsSync(join(record.path, ".gitignore"))).toBe(false); + expect(git(record.path, "show", ":added")).toBe("staged addition"); + expect(readFileSync(join(record.path, "added"), "utf8")).toBe("modified addition"); + }); + it("retains unique committed history independently of the released checkout", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "file"), "unique committed\n"); + git(record.path, "add", "file"); + git(record.path, "commit", "-m", "local only"); + const unique = git(record.path, "rev-parse", "HEAD"); + worktrees.release(record.id, notes); + expect(existsSync(record.path)).toBe(false); + const released = worktrees.status(); + expect(released.records[0].phase).toBe("released"); + expect(released.retained).toBeGreaterThan(0); + const snapshot = JSON.parse(readFileSync(join(released.records[0].checkpoint!, "snapshot.json"), "utf8")); + expect(snapshot.notes).toBe(notes.notes); + expect(snapshot.checks).toEqual(notes.checks); + worktrees.restore(record.id); + expect(git(record.path, "rev-parse", "HEAD")).toBe(unique); + }); + it("preserves explicitly valuable ignored and untracked files while removing disposable output", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, ".env"), "valuable private data"); + writeFileSync(join(record.path, "new-source"), "untracked source"); + mkdirSync(join(record.path, "cache")); + writeFileSync(join(record.path, "cache", "artifact"), "disposable"); + worktrees.release(record.id, { ...notes, keep: [".env", "new-source"], scratch: ["cache"] }); + worktrees.restore(record.id); + expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("valuable private data"); + expect(readFileSync(join(record.path, "new-source"), "utf8")).toBe("untracked source"); + expect(existsSync(join(record.path, "cache"))).toBe(false); + }); + + it("never discards unclassified files or tracked source marked scratch", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "new"), "source"); + expect(() => worktrees.release(record.id, notes)).toThrow("classify untracked/ignored"); + expect(readFileSync(join(record.path, "new"), "utf8")).toBe("source"); + expect(() => worktrees.release(record.id, { ...notes, scratch: ["file", "new"] })).toThrow( + "scratch includes tracked source", + ); + expect(() => worktrees.checkpoint(record.id, { ...notes, keep: ["../outside"] })).toThrow("relative path"); + expect(() => worktrees.checkpoint(record.id, { ...notes, keep: ["new"], scratch: ["new"] })).toThrow("overlap"); + }); + it("serializes reservations across independent repositories and preserves failed allocations", () => { + const { root, repo, home, worktrees } = fixture(); + const second = repository(join(root, "second")); + worktrees.configure(20_000_000); + worktrees.create(repo, "first", 12_000_000); + expect(() => new Worktrees(home).create(second, "second", 12_000_000)).toThrow("disk capacity exceeded"); + expect(() => worktrees.configure(1_000_000)).toThrow("below existing"); + mkdirSync(join(home, "lock")); + expect(() => worktrees.status()).toThrow("never remove a live lock"); + rmSync(join(home, "lock"), { recursive: true }); + expect(() => worktrees.create(second, "failed", 1_000_000, "nonexistent-ref")).toThrow(); + expect(worktrees.status().records.find((record) => record.name === "failed")?.phase).toBe("creating"); + }); + it("admits simultaneous creators atomically without oversubscribing disk", async () => { + const { root, repo, home, worktrees } = fixture(); + const second = repository(join(root, "second")); + worktrees.configure(15_000_000); + const module = new URL("../src/cli/worktree.ts", import.meta.url).href; + const create = (repository: string, name: string) => + new Promise((resolve, reject) => { + const source = `import { Worktrees } from ${JSON.stringify(module)}; new Worktrees(${JSON.stringify(home)}).create(${JSON.stringify(repository)}, ${JSON.stringify(name)}, 10_000_000);`; + const child = spawn( + process.execPath, + ["--experimental-strip-types", "--input-type=module", "-e", source], + { stdio: "ignore" }, + ); + child.on("error", reject); + child.on("exit", resolve); + }); + const results = await Promise.all([create(repo, "one"), create(second, "two")]); + expect(results.filter((result) => result === 0)).toHaveLength(1); + expect(worktrees.status().records).toHaveLength(1); + expect(worktrees.status().allocated).toBeLessThanOrEqual(15_000_000); + }); + + it("refuses foreign checkouts, changed ownership, and current checkouts", () => { + const { repo, worktrees } = fixture(); + mkdirSync(join(repo, ".worktrees", "foreign"), { recursive: true }); + expect(() => worktrees.create(repo, "foreign", 10_000_000)).toThrow("refusing to adopt"); + const record = worktrees.create(repo, "work", 10_000_000); + const cwd = process.cwd(); + try { + process.chdir(record.path); + expect(() => worktrees.release(record.id, notes)).toThrow("leave the checkout"); + } finally { + process.chdir(cwd); + } + git(repo, "worktree", "unlock", record.path); + git(repo, "worktree", "lock", "--reason", "another owner", record.path); + expect(() => worktrees.release(record.id, notes)).toThrow("ownership/lock changed"); + }); + it("restores local-only submodule history and both staged and unstaged submodule edits", () => { + const { root, repo, worktrees } = fixture(); + const upstream = repository(join(root, "upstream")); + git(repo, "-c", "protocol.file.allow=always", "submodule", "add", upstream, "module"); + git(repo, "commit", "-m", "add module"); + const record = worktrees.create(repo, "work", 20_000_000); + git(record.path, "-c", "protocol.file.allow=always", "submodule", "update", "--init"); + const module = join(record.path, "module"); + git(module, "config", "user.name", "Fixture"); + git(module, "config", "user.email", "fixture@localhost"); + writeFileSync(join(module, "file"), "local commit\n"); + git(module, "add", "file"); + git(module, "commit", "-m", "local-only module commit"); + const unique = git(module, "rev-parse", "HEAD"); + writeFileSync(join(module, "file"), "staged module\n"); + git(module, "add", "file"); + writeFileSync(join(module, "file"), "unstaged module\n"); + worktrees.release(record.id, notes); + worktrees.restore(record.id); + expect(git(module, "rev-parse", "HEAD")).toBe(unique); + expect(git(module, "config", "user.name")).toBe("Fixture"); + expect(git(module, "remote", "get-url", "origin")).toBe(upstream); + expect(git(module, "show", ":file")).toBe("staged module"); + expect(readFileSync(join(module, "file"), "utf8")).toBe("unstaged module\n"); + }); + it("wires capacity, reservation, and checkpoint notes through the command line", () => { + const { repo, home } = fixture(); + vi.stubEnv("QUEST_WORKTREE_HOME", home); + expect(run("worktree", "create", "cli", "--root", repo).code).toBe(2); + expect(run("worktree", "capacity", "0").code).toBe(2); + const created = run("--root", repo, "worktree", "create", "cli", "--reserve", "10000000"); + expect(created.code).toBe(0); + expect(JSON.parse(created.stdout).name).toBe("cli"); + expect( + run("worktree", "checkpoint", "cli", "--notes", "Awaiting review", "--check", "tests not run").code, + ).toBe(0); + const status = run("worktree", "status"); + expect(status.code).toBe(0); + const checkpoint = JSON.parse(status.stdout).records[0].checkpoint; + expect(JSON.parse(readFileSync(join(checkpoint, "snapshot.json"), "utf8")).checks).toEqual(["tests not run"]); + }); + + it("records incomplete cleanup as retained capacity instead of success", () => { + const { repo, home, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + worktrees.checkpoint(record.id, notes); + const registry = JSON.parse(readFileSync(join(home, "registry.json"), "utf8")); + registry.records[0].phase = "releasing"; + writeFileSync(join(home, "registry.json"), JSON.stringify(registry)); + expect(worktrees.status().allocated).toBeGreaterThanOrEqual(record.reserve); + worktrees.release(record.id, notes); + expect(worktrees.status().records[0].phase).toBe("released"); + }); +}); From 6de784f8c3c5a1f149b133a5c73acf090afe4245 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:01:06 -0700 Subject: [PATCH 03/17] fix(worktree): canonicalize Windows paths and recover interrupted transitions 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 --- README.md | 9 ++- quest/m0/worktree.md | 14 +++-- src/cli/worktree.ts | 124 +++++++++++++++++++++--------------------- test/worktree.test.ts | 52 ++++++++++++++++-- 4 files changed, 126 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 73ccc946..f4e19112 100644 --- a/README.md +++ b/README.md @@ -214,9 +214,12 @@ admission. Checkpoint bytes remain charged after checkout removal. There is no automatic recovery-data deletion or branch deletion, including after a squash merge. Restore leaves the main checkout detached, preserving existing branches. A failed creation, restoration, or removal remains charged and visible in -`status`; no live lock is stolen. After a crash, inspect retained state and -ownership before manual reconciliation. This initial interface intentionally -refuses unknown or changed checkout ownership. +`status`. Run `restore` again to retry an interrupted restoration from its +checkpoint, or `release` to finish an interrupted removal or abandon an +interrupted creation or restoration; neither checkpoints the partial checkout. +No live lock is stolen: after a crash, confirm the registry lock's owner has +exited before removing it. This initial interface intentionally refuses unknown +or changed checkout ownership. ## Work with your agents diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index e2f499ee..ac32c20a 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -44,11 +44,17 @@ Draft implementation status: create/checkpoint/release/restore, private recovery metadata, full bundles, tracked/index preservation, explicit keep/scratch classification, initialized submodule history, retained failed allocations, and filesystem fixtures. + Interrupted create, restore, and release recover through `restore`/`release` + without checkpointing partial checkouts. +- Measured 2026-10-15 on this repository: a fresh checkout is 1.6 MB, 376 MB + after `just install` and `just build` (node_modules), and a released + checkpoint retains 4.4 MB (2.2 MB history bundle). Restore takes ~0.6 s. - Remaining before this quest is complete: exact foreground descendant ownership - through cancellation/crashes and automatic reconciliation, interrupted create - and restore recovery, automatic release at lifecycle boundaries, concurrency - and cancellation fixtures, and measured real-job retained footprints. Native - agent lifecycle adoption remains in the related integration quest. + through cancellation/crashes and automatic reconciliation (including a stale + registry lock), automatic release at lifecycle boundaries, and cancellation + fixtures. Native agent lifecycle adoption remains in the related integration + quest. Whether that remainder splits into a quest that reuses the + [quest gate](/quest/m0/local-gate.md) ownership backend awaits a decision. - Process groups and PID files cannot prove detached descendants are stopped after a supervisor crash. Keep this PR draft until a supported ownership backend can enforce that boundary; unsupported platforms must fail explicitly. diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 90f4643f..35063f7d 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -71,6 +71,21 @@ function save(path: string, value: unknown): void { writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); renameSync(temporary, path); } +/** One spelling per path: Git prints `C:/...`, Node `C:\...`, and Windows may use 8.3 names. */ +function canonical(path: string): string { + return existsSync(path) ? realpathSync.native(path) : resolve(path); +} +/** Git's registered checkouts, with canonical paths and lock reasons. */ +function checkouts(repository: string): { path: string; locked?: string }[] { + return git(repository, "worktree", "list", "--porcelain", "-z") + .split("\0\0") + .flatMap((entry) => { + const lines = entry.split("\0"); + const path = lines.find((line) => line.startsWith("worktree "))?.slice(9); + const locked = lines.find((line) => line === "locked" || line.startsWith("locked "))?.slice(7); + return path ? [{ path: canonical(path), locked }] : []; + }); +} function inside(root: string, path: string): boolean { const value = relative(root, path); return value === "" || (!isAbsolute(value) && value !== ".." && !value.startsWith(`..${sep}`)); @@ -213,7 +228,7 @@ export class Worktrees { ); if (registry.records.some((item) => item.name === name)) throw new Error(`worktree name already exists; use restore or another name: ${name}`); - const repository = realpathSync(git(root, "rev-parse", "--show-toplevel").trim()); + const repository = canonical(git(root, "rev-parse", "--show-toplevel").trim()); if (inside(repository, this.home)) throw new Error("recovery state must live outside the disposable checkout"); const path = join(repository, ".worktrees", name); @@ -243,30 +258,35 @@ export class Worktrees { } }); } - private owned(record: WorktreeRecord): void { - if (inside(record.path, realpathSync(process.cwd()))) + /** Refuses unless this record exclusively owns the checkout; false once Git and the disk both lack it. */ + private owned(record: WorktreeRecord): boolean { + const path = canonical(record.path); + if (inside(path, canonical(process.cwd()))) throw new Error("leave the checkout before checkpointing or releasing it"); - const entries = git(record.repository, "worktree", "list", "--porcelain", "-z").split("\0\0"); - for (const candidate of entries) { - const path = candidate - .split("\0") - .find((line) => line.startsWith("worktree ")) - ?.slice(9); - if (path && path !== record.path && inside(record.path, path)) - throw new Error(`nested checkout is retained: ${path}`); - } - const entry = entries.find((entry) => entry.split("\0").includes(`worktree ${record.path}`)); - if (!entry || !entry.split("\0").includes(`locked quest worktree ${record.id}`)) + 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}`); + const entry = entries.find((entry) => entry.path === path); + if (!entry && !existsSync(path)) return false; + if (entry?.locked !== `quest worktree ${record.id}`) throw new Error( "checkout ownership/lock changed; use the owning tool, never adopt or remove a shared checkout", ); + return true; + } + /** Git removes the owned checkout and its administrative directory, even when the checkout is missing. */ + private discard(record: WorktreeRecord): void { + if (!this.owned(record)) return; + git(record.repository, "worktree", "remove", "--force", "--force", record.path); + if (existsSync(record.path)) throw new Error("Git reported cleanup success but checkout is retained"); } checkpoint(id: string, options: CheckpointOptions): WorktreeRecord { return this.locked((registry) => this.checkpointLocked(this.find(registry, id), options)); } private checkpointLocked(record: WorktreeRecord, options: CheckpointOptions): WorktreeRecord { if (record.phase !== "active") throw new Error(`cannot checkpoint a ${record.phase} checkout`); - this.owned(record); + if (!this.owned(record)) throw new Error(`active checkout is missing: ${record.path}`); if (!options.notes.trim()) throw new Error("checkpoint notes must describe unresolved work, or state that it is complete"); const keep = options.keep.map(childPath); @@ -414,59 +434,45 @@ export class Worktrees { return this.locked((registry) => { const record = this.find(registry, id); if (record.phase === "released") return record; - if (record.phase === "releasing" && !existsSync(record.path)) { - record.phase = "released"; - record.failure = undefined; - return record; + if (record.phase === "active") { + this.checkpointLocked(record, options); + const snapshot = json(join(record.checkpoint!, "snapshot.json")); + if (snapshot.unclassified.length) + throw new Error( + `classify untracked/ignored files with --keep or --scratch before release: ${snapshot.unclassified.slice(0, 20).join(", ")}`, + ); + record.phase = "releasing"; + save(join(this.home, "registry.json"), registry); } - if (record.phase === "releasing") record.phase = "active"; - this.checkpointLocked(record, options); - const snapshot = json(join(record.checkpoint!, "snapshot.json")); - if (snapshot.unclassified.length) - throw new Error( - `classify untracked/ignored files with --keep or --scratch before release: ${snapshot.unclassified.slice(0, 20).join(", ")}`, - ); - record.phase = "releasing"; - save(join(this.home, "registry.json"), registry); + // An interrupted create, restore, or release never handed out new work, so the last + // checkpoint stays authoritative; checkpointing a partial checkout could corrupt it. try { - git(record.repository, "worktree", "unlock", record.path); - // Git itself removes the owned checkout and its administrative directory. - git(record.repository, "worktree", "remove", "--force", record.path); - if (existsSync(record.path)) throw new Error("Git reported cleanup success but checkout is retained"); - record.phase = "released"; - record.failure = undefined; - return record; + this.discard(record); } catch (error) { record.failure = String(error); - if (existsSync(record.path)) { - try { - git( - record.repository, - "worktree", - "lock", - "--reason", - `quest worktree ${record.id}`, - record.path, - ); - } catch { - /* Preserve allocation even when re-locking fails. */ - } - } throw new Error(`cleanup failed; allocation retained: ${record.path}: ${String(error)}`); } + record.phase = "released"; + record.failure = undefined; + // An abandoned creation has nothing to restore; free its name and reservation. + if (!record.checkpoint) registry.records.splice(registry.records.indexOf(record), 1); + return record; }); } restore(id: string): WorktreeRecord { return this.locked((registry) => { const record = this.find(registry, id); if (record.phase === "active") { - this.owned(record); + if (!this.owned(record)) throw new Error(`active checkout is missing: ${record.path}`); return record; } + if (record.phase === "restoring") { + // Retry an interrupted restore from the untouched checkpoint. + this.discard(record); + record.phase = "released"; + } if (record.phase !== "released" || !record.checkpoint) - throw new Error( - `cannot restore a ${record.phase} allocation; inspect retained checkout before recovery`, - ); + 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)); @@ -515,15 +521,9 @@ export class Worktrees { git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); git(root, "checkout", "--detach", saved.head); } - if (saved.branch) { - const current = git(root, "rev-parse", "--verify", saved.branch).trim(); - if (current !== saved.head) - throw new Error( - `branch advanced since checkpoint; restored files retained detached: ${saved.branch}`, - ); - if (i > 0) git(root, "symbolic-ref", "HEAD", saved.branch); - // Main checkout remains detached so another checkout's branch is never stolen. - } + // The main checkout stays detached so a moved, deleted, or checked-out branch never blocks + // restore; submodule gitdirs are private copies, so their branches return as saved. + if (saved.branch && i > 0) git(root, "symbolic-ref", "HEAD", saved.branch); for (const file of saved.missing) rmSync(join(root, file), { force: true }); for (const file of saved.files.filter((file) => !saved.missing.includes(file))) { mkdirSync(dirname(join(root, file)), { recursive: true }); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 7d0a5b7b..447ab776 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -30,6 +30,12 @@ function fixture() { worktrees.configure(100_000_000); return { root, repo, home, worktrees }; } +/** Simulates a crash that left the only record mid-transition. */ +function setPhase(home: string, phase: string): void { + const registry = JSON.parse(readFileSync(join(home, "registry.json"), "utf8")); + registry.records[0].phase = phase; + writeFileSync(join(home, "registry.json"), JSON.stringify(registry)); +} const notes = { notes: "Remaining work: finish implementation", checks: ["unit tests not run"], @@ -78,6 +84,19 @@ describe("recoverable worktrees", () => { worktrees.restore(record.id); expect(git(record.path, "rev-parse", "HEAD")).toBe(unique); }); + it("restores detached even after the saved branch moves", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + git(record.path, "switch", "-c", "topic"); + writeFileSync(join(record.path, "file"), "topic\n"); + git(record.path, "commit", "-am", "topic"); + const unique = git(record.path, "rev-parse", "HEAD"); + worktrees.release(record.id, notes); + git(repo, "branch", "-f", "topic", "main"); + worktrees.restore(record.id); + expect(git(record.path, "rev-parse", "HEAD")).toBe(unique); + expect(() => git(record.path, "symbolic-ref", "-q", "HEAD")).toThrow(); + }); it("preserves explicitly valuable ignored and untracked files while removing disposable output", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); @@ -116,6 +135,8 @@ describe("recoverable worktrees", () => { rmSync(join(home, "lock"), { recursive: true }); expect(() => worktrees.create(second, "failed", 1_000_000, "nonexistent-ref")).toThrow(); expect(worktrees.status().records.find((record) => record.name === "failed")?.phase).toBe("creating"); + worktrees.release("failed", notes); + expect(worktrees.status().records.map((record) => record.name)).toEqual(["first"]); }); it("admits simultaneous creators atomically without oversubscribing disk", async () => { const { root, repo, home, worktrees } = fixture(); @@ -197,15 +218,38 @@ describe("recoverable worktrees", () => { expect(JSON.parse(readFileSync(join(checkpoint, "snapshot.json"), "utf8")).checks).toEqual(["tests not run"]); }); - it("records incomplete cleanup as retained capacity instead of success", () => { + it("finishes an interrupted release without checkpointing the partial checkout", () => { const { repo, home, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); worktrees.checkpoint(record.id, notes); - const registry = JSON.parse(readFileSync(join(home, "registry.json"), "utf8")); - registry.records[0].phase = "releasing"; - writeFileSync(join(home, "registry.json"), JSON.stringify(registry)); + setPhase(home, "releasing"); + rmSync(join(record.path, "file")); // Removal stopped partway. expect(worktrees.status().allocated).toBeGreaterThanOrEqual(record.reserve); worktrees.release(record.id, notes); expect(worktrees.status().records[0].phase).toBe("released"); + expect(existsSync(record.path)).toBe(false); + worktrees.restore(record.id); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe("original\n"); + }); + it("retries or abandons an interrupted restore from the intact checkpoint", () => { + const { repo, home, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "file"), "saved\n"); + worktrees.release(record.id, notes); + const partial = () => { + git(repo, "worktree", "add", "--detach", "--lock", "--reason", `quest worktree ${record.id}`, record.path); + writeFileSync(join(record.path, "file"), "partial\n"); + setPhase(home, "restoring"); + }; + partial(); + expect(() => worktrees.checkpoint(record.id, notes)).toThrow("cannot checkpoint a restoring"); + worktrees.restore(record.id); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe("saved\n"); + worktrees.release(record.id, notes); + partial(); + expect(worktrees.release(record.id, notes).phase).toBe("released"); + expect(existsSync(record.path)).toBe(false); + worktrees.restore(record.id); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe("saved\n"); }); }); From 53fe0c51c13a3f7ffbea2561a0ceaf04d652aaab Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:16:14 -0700 Subject: [PATCH 04/17] test(worktree): compare restored file with its checked-out line endings Co-Authored-By: Claude Opus 5.5 --- test/worktree.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 447ab776..6990a3c5 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -223,13 +223,14 @@ describe("recoverable worktrees", () => { const record = worktrees.create(repo, "work", 10_000_000); worktrees.checkpoint(record.id, notes); setPhase(home, "releasing"); + const original = readFileSync(join(record.path, "file"), "utf8"); // CRLF under Windows autocrlf. rmSync(join(record.path, "file")); // Removal stopped partway. expect(worktrees.status().allocated).toBeGreaterThanOrEqual(record.reserve); worktrees.release(record.id, notes); expect(worktrees.status().records[0].phase).toBe("released"); expect(existsSync(record.path)).toBe(false); worktrees.restore(record.id); - expect(readFileSync(join(record.path, "file"), "utf8")).toBe("original\n"); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe(original); }); it("retries or abandons an interrupted restore from the intact checkpoint", () => { const { repo, home, worktrees } = fixture(); From 432d9dcdef86b214710be1bcca422c2ff63f3dff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:30:18 -0700 Subject: [PATCH 05/17] perf(worktree): batch Git queries and skip redundant restore checkouts 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 --- src/cli/worktree.ts | 56 ++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 35063f7d..ebff9c25 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -311,13 +311,14 @@ export class Worktrees { const number = snapshot.repositories.length; const target = join(directory, String(number)); mkdirSync(join(target, "files"), { recursive: true }); + // Each Git process costs ~100 ms on Windows, so every query here is batched. const entries = list(root, "--stage").map((entry) => { const tab = entry.indexOf("\t"); - return { mode: entry.slice(0, 6), path: entry.slice(tab + 1) }; + return { mode: entry.slice(0, 6), stage: entry[tab - 1], path: entry.slice(tab + 1) }; }); if (entries.some((entry) => !entry.path || entry.path.includes("\0"))) throw new Error("invalid tracked path"); - if (git(root, "ls-files", "--unmerged").trim()) + if (entries.some((entry) => entry.stage !== "0")) throw new Error("resolve the unmerged index before checkpointing"); const committed = git(root, "ls-tree", "-r", "-z", "HEAD") .split("\0") @@ -348,19 +349,23 @@ export class Worktrees { mkdirSync(dirname(join(target, "files", file)), { recursive: true }); cpSync(source, join(target, "files", file), { dereference: false }); } - let branch: string | null = null; - try { - branch = git(root, "symbolic-ref", "--quiet", "HEAD").trim(); - } catch { - /* Detached HEAD is supported. */ - } - const head = git(root, "rev-parse", "HEAD").trim(); - const index = git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(); + const [head, index, config, symbolic] = git( + root, + "rev-parse", + "HEAD", + "--path-format=absolute", + "--git-path", + "index", + "--git-path", + "config", + "--symbolic-full-name", + "HEAD", + ) + .trim() + .split("\n"); + const branch = symbolic === "HEAD" ? null : symbolic; // Detached HEAD is supported. cpSync(index, join(target, "index")); - if (path) { - const config = git(root, "rev-parse", "--path-format=absolute", "--git-path", "config").trim(); - cpSync(config, join(target, "config")); - } + if (path) cpSync(config, join(target, "config")); // A recovery commit makes staged blobs reachable without changing HEAD/index. const tree = git(root, "write-tree").trim(); const commit = git( @@ -377,13 +382,11 @@ export class Worktrees { `Local worktree recovery ${record.id}`, ).trim(); git(root, "update-ref", `refs/quest/worktrees/${record.id}/${number}`, commit); + // A full bundle has no prerequisites, so `bundle verify` would only repeat its creation. git(root, "bundle", "create", join(target, "history.bundle"), "--all", "--reflog", "HEAD"); - git(root, "bundle", "verify", join(target, "history.bundle")); snapshot.repositories.push({ path, head, branch, files, missing }); - for (const file of [ - ...list(root, "--others", "--exclude-standard"), - ...list(root, "--others", "--ignored", "--exclude-standard"), - ]) { + // Without exclude patterns, --others lists untracked and ignored files alike. + for (const file of list(root, "--others")) { const relative = path ? `${path}/${file}` : file; if (!covers(keep, relative) && !covers(scratch, relative)) snapshot.unclassified.push(relative); } @@ -497,6 +500,7 @@ export class Worktrees { record.repository, "worktree", "add", + "--no-checkout", "--detach", "--lock", "--reason", @@ -519,11 +523,13 @@ export class Worktrees { if ((error as { status?: number }).status !== 5) throw error; } git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); - git(root, "checkout", "--detach", saved.head); + // Submodule gitdirs are private copies, so their branches return as saved. + writeFileSync( + join(root, ".git", "HEAD"), + `${saved.branch ? `ref: ${saved.branch}` : saved.head}\n`, + ); } - // The main checkout stays detached so a moved, deleted, or checked-out branch never blocks - // restore; submodule gitdirs are private copies, so their branches return as saved. - if (saved.branch && i > 0) git(root, "symbolic-ref", "HEAD", saved.branch); + // Every tracked file and the index come from the checkpoint, so nothing is checked out. for (const file of saved.missing) rmSync(join(root, file), { force: true }); for (const file of saved.files.filter((file) => !saved.missing.includes(file))) { mkdirSync(dirname(join(root, file)), { recursive: true }); @@ -532,7 +538,9 @@ export class Worktrees { } cpSync( join(source, "index"), - git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(), + i === 0 + ? git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim() + : join(root, ".git", "index"), ); } if (existsSync(join(record.checkpoint, "kept"))) From 38e92f1964d2cf9107e06d44b66e90966f287b3d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:34:51 -0700 Subject: [PATCH 06/17] test(worktree): budget Git-integration fixtures for slow Windows ARM spawns Co-Authored-By: Claude Opus 5.5 --- test/worktree.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 6990a3c5..03f96ea0 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -47,7 +47,9 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -describe("recoverable worktrees", () => { +// Each case runs 25-60 real Git processes. Process creation on the Windows ARM runner +// costs ~80-250 ms while other test files start, so the 5 s unit-test default is too short. +describe("recoverable worktrees", { timeout: 20_000 }, () => { it("preserves the staged index, unstaged source, additions, and deletions", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); From 36e4ebc21f973a9b59b559a305b372c7be850530 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:45:51 -0700 Subject: [PATCH 07/17] fix(worktree): preserve recovery state across checkpoint roundtrips --- src/cli/worktree.ts | 63 +++++++++++++++++------------- test/worktree.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 27 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index ebff9c25..a2e3444d 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -36,8 +36,6 @@ interface RepositorySnapshot { path: string; head: string; branch: string | null; - files: string[]; - missing: string[]; } interface Snapshot { version: 1; @@ -71,6 +69,11 @@ function save(path: string, value: unknown): void { writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); renameSync(temporary, path); } +/** Copy file trees without changing symbolic-link text. */ +function copy(source: string, destination: string): void { + mkdirSync(dirname(destination), { recursive: true }); + cpSync(source, destination, { recursive: true, dereference: false, verbatimSymlinks: true }); +} /** One spelling per path: Git prints `C:/...`, Node `C:\...`, and Windows may use 8.3 names. */ function canonical(path: string): string { return existsSync(path) ? realpathSync.native(path) : resolve(path); @@ -161,8 +164,15 @@ export class Worktrees { if (!record) throw new Error(`unknown worktree: ${id}`); return record; } + private retained(): number { + const directory = join(this.home, "checkpoints"); + // Shared registry/container metadata is outside per-checkout reservations. + return existsSync(directory) + ? readdirSync(directory).reduce((total, entry) => total + diskUsage(join(directory, entry)), 0) + : 0; + } private measure(registry: Registry): number { - const retained = diskUsage(join(this.home, "checkpoints")); + const retained = this.retained(); return ( retained + registry.records.reduce((total, record) => { @@ -214,7 +224,7 @@ export class Worktrees { return this.locked((registry) => ({ capacity: registry.capacity, allocated: this.measure(registry), - retained: diskUsage(join(this.home, "checkpoints")), + retained: this.retained(), records: registry.records, })); } @@ -334,20 +344,17 @@ export class Worktrees { for (const file of files) if (covers(scratch, path ? `${path}/${file}` : file)) throw new Error(`scratch includes tracked source: ${file}`); - const missing: string[] = []; for (const file of files) { const source = join(root, file); try { - lstatSync(source); + // HEAD may name a file replaced by a directory in the index. + if (lstatSync(source).isDirectory()) continue; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - missing.push(file); - continue; - } + // A missing descendant can be underneath a replacement file. + if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; throw error; } - mkdirSync(dirname(join(target, "files", file)), { recursive: true }); - cpSync(source, join(target, "files", file), { dereference: false }); + copy(source, join(target, "files", file)); } const [head, index, config, symbolic] = git( root, @@ -364,7 +371,14 @@ export class Worktrees { .trim() .split("\n"); const branch = symbolic === "HEAD" ? null : symbolic; // Detached HEAD is supported. - cpSync(index, join(target, "index")); + const savedIndex = join(target, "index"); + copy(index, savedIndex); + // Materialize a private full index while its split-index backing file still exists. + // The live index and its flags stay intact. + execFileSync("git", ["-C", root, "update-index", "--no-split-index"], { + env: { ...process.env, GIT_INDEX_FILE: savedIndex }, + stdio: "pipe", + }); if (path) cpSync(config, join(target, "config")); // A recovery commit makes staged blobs reachable without changing HEAD/index. const tree = git(root, "write-tree").trim(); @@ -384,7 +398,7 @@ export class Worktrees { git(root, "update-ref", `refs/quest/worktrees/${record.id}/${number}`, commit); // A full bundle has no prerequisites, so `bundle verify` would only repeat its creation. git(root, "bundle", "create", join(target, "history.bundle"), "--all", "--reflog", "HEAD"); - snapshot.repositories.push({ path, head, branch, files, missing }); + snapshot.repositories.push({ path, head, branch }); // Without exclude patterns, --others lists untracked and ignored files alike. for (const file of list(root, "--others")) { const relative = path ? `${path}/${file}` : file; @@ -412,8 +426,7 @@ export class Worktrees { throw new Error( `keep directories cannot contain a repository: ${path}; tracked submodules are preserved automatically`, ); - mkdirSync(dirname(join(directory, "kept", path)), { recursive: true }); - cpSync(source, join(directory, "kept", path), { recursive: true, dereference: false }); + copy(source, join(directory, "kept", path)); } save(join(directory, "snapshot.json"), snapshot); const previous = record.checkpoint; @@ -478,7 +491,9 @@ export class Worktrees { 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)); + // Its retained recovery already counts toward the shared capacity and free-space usage. + const retained = diskUsage(join(this.home, "checkpoints", record.id)); + this.admit(registry, Math.max(0, record.reserve - retained), dirname(record.path)); const snapshot = json(join(record.checkpoint, "snapshot.json")); if (snapshot.version !== 1) throw new Error("unsupported checkpoint version"); record.phase = "restoring"; @@ -522,6 +537,8 @@ export class Worktrees { } catch (error) { if ((error as { status?: number }).status !== 5) throw error; } + // Detach before fetching branches, including git init's initial branch. + writeFileSync(join(root, ".git", "HEAD"), `${saved.head}\n`); git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); // Submodule gitdirs are private copies, so their branches return as saved. writeFileSync( @@ -529,13 +546,8 @@ export class Worktrees { `${saved.branch ? `ref: ${saved.branch}` : saved.head}\n`, ); } - // Every tracked file and the index come from the checkpoint, so nothing is checked out. - for (const file of saved.missing) rmSync(join(root, file), { force: true }); - for (const file of saved.files.filter((file) => !saved.missing.includes(file))) { - mkdirSync(dirname(join(root, file)), { recursive: true }); - rmSync(join(root, file), { force: true }); - cpSync(join(source, "files", file), join(root, file), { dereference: false }); - } + // The checkout starts empty, so the saved tree itself describes present files. + copy(join(source, "files"), root); cpSync( join(source, "index"), i === 0 @@ -543,8 +555,7 @@ export class Worktrees { : join(root, ".git", "index"), ); } - if (existsSync(join(record.checkpoint, "kept"))) - cpSync(join(record.checkpoint, "kept"), record.path, { recursive: true, dereference: false }); + if (existsSync(join(record.checkpoint, "kept"))) copy(join(record.checkpoint, "kept"), record.path); record.phase = "active"; record.failure = undefined; return record; diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 03f96ea0..cbe58eae 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,5 +1,14 @@ import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -68,6 +77,80 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { expect(git(record.path, "show", ":added")).toBe("staged addition"); expect(readFileSync(join(record.path, "added"), "utf8")).toBe("modified addition"); }); + it("restores within the original reservation while charging other retained checkpoints", () => { + const { repo, worktrees } = fixture(); + const reserve = 1_000_000; + worktrees.configure(reserve); + const record = worktrees.create(repo, "work", reserve); + worktrees.release(record.id, notes); + worktrees.restore(record.id); + expect(worktrees.status().allocated).toBe(reserve); + worktrees.release(record.id, notes); + const retained = worktrees.status().allocated; + expect(retained).toBeGreaterThan(0); + worktrees.create(repo, "other", reserve - retained); + expect(() => worktrees.restore(record.id)).toThrow("disk capacity exceeded"); + expect(worktrees.status().records[0].phase).toBe("released"); + }); + it("preserves split-index entries after the disposable Git directory is gone", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "file"), "staged\n"); + git(record.path, "add", "file"); + writeFileSync(join(record.path, "file"), "unstaged\n"); + git(record.path, "update-index", "--split-index"); + const backing = git(record.path, "rev-parse", "--path-format=absolute", "--shared-index-path"); + expect(backing).not.toBe(""); + expect(existsSync(backing)).toBe(true); + worktrees.checkpoint(record.id, notes); + expect(git(record.path, "rev-parse", "--path-format=absolute", "--shared-index-path")).toBe(backing); + const status = git(record.path, "status", "--porcelain=v1"); + worktrees.release(record.id, notes); + expect(existsSync(backing)).toBe(false); + worktrees.restore(record.id); + expect(git(record.path, "status", "--porcelain=v1")).toBe(status); + expect(git(record.path, "show", ":file")).toBe("staged"); + expect(readFileSync(join(record.path, "file"), "utf8")).toBe("unstaged\n"); + }); + it.each(["file to directory", "directory to file"])("roundtrips staged %s replacements", (direction) => { + const { repo, worktrees } = fixture(); + if (direction === "directory to file") { + git(repo, "rm", "file"); + mkdirSync(join(repo, "file")); + writeFileSync(join(repo, "file", "child"), "original\n"); + git(repo, "add", "."); + git(repo, "commit", "-m", "start with directory"); + } + const record = worktrees.create(repo, "work", 10_000_000); + git(record.path, "rm", "-r", "file"); + const path = direction === "file to directory" ? "file/child" : "file"; + if (direction === "file to directory") mkdirSync(join(record.path, "file")); + writeFileSync(join(record.path, path), "staged replacement\n"); + git(record.path, "add", "."); + writeFileSync(join(record.path, path), "unstaged replacement\n"); + const status = git(record.path, "status", "--porcelain=v1"); + worktrees.release(record.id, notes); + worktrees.restore(record.id); + expect(git(record.path, "status", "--porcelain=v1")).toBe(status); + expect(git(record.path, "show", `:${path}`)).toBe("staged replacement"); + expect(readFileSync(join(record.path, path), "utf8")).toBe("unstaged replacement\n"); + }); + it.skipIf(process.platform === "win32")("preserves tracked and retained relative symlink text", () => { + const { repo, worktrees } = fixture(); + symlinkSync("file", join(repo, "link")); + git(repo, "add", "link"); + git(repo, "commit", "-m", "track relative symlink"); + const record = worktrees.create(repo, "work", 10_000_000); + symlinkSync("file", join(record.path, "kept-link")); + mkdirSync(join(record.path, "kept-directory")); + symlinkSync("../file", join(record.path, "kept-directory", "link")); + worktrees.release(record.id, { ...notes, keep: ["kept-link", "kept-directory"] }); + worktrees.restore(record.id); + expect(readlinkSync(join(record.path, "link"))).toBe("file"); + expect(readlinkSync(join(record.path, "kept-link"))).toBe("file"); + expect(readlinkSync(join(record.path, "kept-directory", "link"))).toBe("../file"); + expect(git(record.path, "diff", "HEAD")).toBe(""); + }); it("retains unique committed history independently of the released checkout", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); @@ -180,12 +263,16 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { }); it("restores local-only submodule history and both staged and unstaged submodule edits", () => { const { root, repo, worktrees } = fixture(); + const globalConfig = join(root, "gitconfig"); + writeFileSync(globalConfig, "[init]\n\tdefaultBranch = master\n"); + vi.stubEnv("GIT_CONFIG_GLOBAL", globalConfig); const upstream = repository(join(root, "upstream")); git(repo, "-c", "protocol.file.allow=always", "submodule", "add", upstream, "module"); git(repo, "commit", "-m", "add module"); const record = worktrees.create(repo, "work", 20_000_000); git(record.path, "-c", "protocol.file.allow=always", "submodule", "update", "--init"); const module = join(record.path, "module"); + git(module, "switch", "-c", "master"); git(module, "config", "user.name", "Fixture"); git(module, "config", "user.email", "fixture@localhost"); writeFileSync(join(module, "file"), "local commit\n"); @@ -198,6 +285,7 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { worktrees.release(record.id, notes); worktrees.restore(record.id); expect(git(module, "rev-parse", "HEAD")).toBe(unique); + expect(git(module, "symbolic-ref", "HEAD")).toBe("refs/heads/master"); expect(git(module, "config", "user.name")).toBe("Fixture"); expect(git(module, "remote", "get-url", "origin")).toBe(upstream); expect(git(module, "show", ":file")).toBe("staged module"); From 28240444ea608ce56c3d5ae6cc27e1fde10300e0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:26:46 -0700 Subject: [PATCH 08/17] fix(worktree): refuse nested repositories and handle replaced submodules 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 --- README.md | 4 ++- src/cli/worktree.ts | 58 +++++++++++++++++++++++++++++++------------ test/worktree.test.ts | 40 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8f6a8353..96983837 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,9 @@ Creation prints JSON with the ID and detached checkout path under `.worktrees/`. Release always takes a fresh checkpoint. Tracked working files, the staged index, reachable Git history, and initialized submodules' local history are preserved. Untracked and ignored files require explicit `--keep` or `--scratch` -classification before release; source cannot be declared scratch. `--keep` +classification before release; source cannot be declared scratch. Release +refuses while the checkout holds any nested repository or worktree other than +its initialized submodules. `--keep` never pushes or publishes its contents. Checkpoints record notes and check results without running tests or claiming that validation passed. diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index a2e3444d..86180dbb 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -113,6 +113,15 @@ function list(root: string, ...args: string[]): string[] { .filter(Boolean); } +/** Nested `.git` entries beneath `root`, relative to it; symlinks are never traversed. */ +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] : []; + return entry.isDirectory() ? repositories(root, child) : []; + }); +} + /** Allocated blocks, including ignored build output; symlinks are never traversed. */ export function diskUsage(path: string): number { if (!existsSync(path)) return 0; @@ -273,11 +282,7 @@ export class Worktrees { const path = canonical(record.path); if (inside(path, canonical(process.cwd()))) throw new Error("leave the checkout before checkpointing or releasing it"); - 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}`); - const entry = entries.find((entry) => entry.path === path); + const entry = checkouts(record.repository).find((entry) => entry.path === path); if (!entry && !existsSync(path)) return false; if (entry?.locked !== `quest worktree ${record.id}`) throw new Error( @@ -285,9 +290,23 @@ export class Worktrees { ); return true; } + /** + * Refuses removal while the checkout holds a repository its checkpoint did not save, whichever + * repository registered it: forced removal would delete its uncommitted work and history. + */ + private refuseNested(record: WorktreeRecord): void { + if (!existsSync(record.path)) return; + const saved = record.checkpoint + ? json(join(record.checkpoint, "snapshot.json")).repositories.map((repository) => repository.path) + : []; + const nested = repositories(record.path).filter((path) => !saved.includes(path)); + if (nested.length) + throw new Error(`nested repository would be removed; move it out of the checkout: ${nested.join(", ")}`); + } /** Git removes the owned checkout and its administrative directory, even when the checkout is missing. */ private discard(record: WorktreeRecord): void { if (!this.owned(record)) return; + this.refuseNested(record); git(record.repository, "worktree", "remove", "--force", "--force", record.path); if (existsSync(record.path)) throw new Error("Git reported cleanup success but checkout is retained"); } @@ -334,6 +353,9 @@ export class Worktrees { .split("\0") .filter(Boolean) .map((entry) => ({ mode: entry.slice(0, 6), path: entry.slice(entry.indexOf("\t") + 1) })); + // The index decides which paths are submodules, including staged replacements of HEAD's. + // HEAD adds the files the index deleted or replaced. + const submodules = entries.filter((entry) => entry.mode === "160000").map((entry) => entry.path); const files = [ ...new Set( [...entries, ...committed] @@ -404,14 +426,8 @@ export class Worktrees { const relative = path ? `${path}/${file}` : file; if (!covers(keep, relative) && !covers(scratch, relative)) snapshot.unclassified.push(relative); } - for (const entry of [ - ...new Map( - [...entries, ...committed] - .filter((entry) => entry.mode === "160000") - .map((entry) => [entry.path, entry]), - ).values(), - ]) { - const child = path ? `${path}/${entry.path}` : entry.path; + for (const submodule of submodules) { + const child = path ? `${path}/${submodule}` : submodule; if (existsSync(join(record.path, child, ".git"))) visit(child); else if (existsSync(join(record.path, child)) && readdirSync(join(record.path, child)).length) throw new Error(`unrecognized submodule contents: ${child}`); @@ -420,9 +436,18 @@ export class Worktrees { visit(""); for (const path of keep) { const source = join(record.path, path); - if (!existsSync(source)) throw new Error(`keep path does not exist: ${path}`); - if (!inside(record.path, realpathSync(source))) throw new Error(`keep path escapes checkout: ${path}`); - if (lstatSync(source).isDirectory() && existsSync(join(source, ".git"))) + // The entry itself, which may be a dangling symlink; copies keep its link text. + let stat: ReturnType; + try { + stat = lstatSync(source); + } catch (error) { + if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw new Error(`keep path does not exist: ${path}`); + throw error; + } + if (!inside(canonical(record.path), canonical(dirname(source)))) + throw new Error(`keep path escapes checkout: ${path}`); + if (stat.isDirectory() && existsSync(join(source, ".git"))) throw new Error( `keep directories cannot contain a repository: ${path}; tracked submodules are preserved automatically`, ); @@ -457,6 +482,7 @@ export class Worktrees { throw new Error( `classify untracked/ignored files with --keep or --scratch before release: ${snapshot.unclassified.slice(0, 20).join(", ")}`, ); + this.refuseNested(record); // Refuse while still active; discard checks again after a crash. record.phase = "releasing"; save(join(this.home, "registry.json"), registry); } diff --git a/test/worktree.test.ts b/test/worktree.test.ts index cbe58eae..de84748c 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -151,6 +151,29 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { expect(readlinkSync(join(record.path, "kept-directory", "link"))).toBe("../file"); expect(git(record.path, "diff", "HEAD")).toBe(""); }); + it.skipIf(process.platform === "win32")("keeps dangling untracked symlinks", () => { + const { repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + symlinkSync("missing", join(record.path, "dangling")); + worktrees.release(record.id, { ...notes, keep: ["dangling"] }); + worktrees.restore(record.id); + expect(readlinkSync(join(record.path, "dangling"))).toBe("missing"); + }); + it.each(["independent repository", "another repository's worktree", "same repository's worktree"])( + "refuses to release a nested %s under scratch", + (kind) => { + const { root, repo, worktrees } = fixture(); + const record = worktrees.create(repo, "work", 10_000_000); + const nested = join(record.path, "cache", "nested"); + if (kind === "independent repository") repository(nested); + else if (kind === "same repository's worktree") git(repo, "worktree", "add", "--detach", nested); + else git(repository(join(root, "other")), "worktree", "add", "--detach", nested); + writeFileSync(join(nested, "file"), "uncommitted\n"); + expect(() => worktrees.release(record.id, { ...notes, scratch: ["cache"] })).toThrow("nested repository"); + expect(readFileSync(join(nested, "file"), "utf8")).toBe("uncommitted\n"); + expect(worktrees.status().records[0].phase).toBe("active"); + }, + ); it("retains unique committed history independently of the released checkout", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); @@ -291,6 +314,23 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { expect(git(module, "show", ":file")).toBe("staged module"); expect(readFileSync(join(module, "file"), "utf8")).toBe("unstaged module\n"); }); + it.each(["file", "directory"])("roundtrips a staged submodule replaced by a %s", (kind) => { + const { root, repo, worktrees } = fixture(); + const upstream = repository(join(root, "upstream")); + git(repo, "-c", "protocol.file.allow=always", "submodule", "add", upstream, "module"); + git(repo, "commit", "-m", "add module"); + const record = worktrees.create(repo, "work", 20_000_000); + git(record.path, "rm", "module"); + const path = kind === "file" ? "module" : "module/child"; + if (kind === "directory") mkdirSync(join(record.path, "module")); + writeFileSync(join(record.path, path), "replacement\n"); + git(record.path, "add", path); + const status = git(record.path, "status", "--porcelain=v1"); + worktrees.release(record.id, notes); + worktrees.restore(record.id); + expect(git(record.path, "status", "--porcelain=v1")).toBe(status); + expect(git(record.path, "show", `:${path}`)).toBe("replacement"); + }); it("wires capacity, reservation, and checkpoint notes through the command line", () => { const { repo, home } = fixture(); vi.stubEnv("QUEST_WORKTREE_HOME", home); From ff2f1a79faea84b2174c7340da2177366e753f13 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 10:46:58 -0700 Subject: [PATCH 09/17] quest: rewrite worktree quest to the process-ownership remainder 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 --- quest/m0/README.md | 2 +- quest/m0/kix/runner.md | 2 +- quest/m0/local-gate.md | 2 +- quest/m0/worktree-agents.md | 2 +- quest/m0/worktree.md | 89 ++++++++++++++----------------------- 5 files changed, 38 insertions(+), 59 deletions(-) diff --git a/quest/m0/README.md b/quest/m0/README.md index 83d87b6e..28b9a254 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -38,7 +38,7 @@ ownership-manifest design: - [Finished milestone links](/quest/m0/milestone-links.md) - `quest check` flags a Required link to a milestone with no Required left, so its dependents get unblocked - [Accurate landed quests](/quest/m0/landed-quests.md) - a quest-branch PR deletes or rewrites its quest, and `quest check --base` refuses one that does neither - [Expensive command gate](/quest/m0/local-gate.md) - shared CPU admission across worktrees and repositories -- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission, checkpoints, release, and restore +- [Worktree process ownership](/quest/m0/worktree.md) - supervise checkout commands and release checkouts automatically - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended checkouts through their harness - [Agent workflow helpers](/quest/m0/helpers/README.md) - CLI commands for CI waits, PR status, claims, quest moves, and serialized merges diff --git a/quest/m0/kix/runner.md b/quest/m0/kix/runner.md index f829587b..cbc64b61 100644 --- a/quest/m0/kix/runner.md +++ b/quest/m0/kix/runner.md @@ -88,4 +88,4 @@ replacing the hosted Agent SDK runtime: ## Required -- [Recoverable worktrees](/quest/m0/worktree.md) - reuse checkout supervision, checkpoints, and disk admission +- [Worktree process ownership](/quest/m0/worktree.md) - reuse checkout supervision, checkpoints, and disk admission diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index ea43672f..f3396aa4 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -29,5 +29,5 @@ remain useful without multiplying full-machine builds and test stacks. ## Related -- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission and checkout lifetime +- [Worktree process ownership](/quest/m0/worktree.md) - reuses this ownership backend for checkout commands - [Own-PC runner](/quest/m0/kix/runner.md) - reuse local command supervision diff --git a/quest/m0/worktree-agents.md b/quest/m0/worktree-agents.md index 57ac09c7..27f6a139 100644 --- a/quest/m0/worktree-agents.md +++ b/quest/m0/worktree-agents.md @@ -38,7 +38,7 @@ checkout without waiting for the parent chat to end or the PR to merge. ## Required -- [Recoverable worktrees](/quest/m0/worktree.md) - checkpoint, release, and restore +- [Worktree process ownership](/quest/m0/worktree.md) - supervised commands and automatic checkpoint and release - [Expensive command gate](/quest/m0/local-gate.md) - resource control before removing agent-count throttles ## Related diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index ac32c20a..486d3e0a 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -1,65 +1,44 @@ -# [L] Checkpoint and release worktrees with quest worktree +# [M] Own worktree processes and release checkouts automatically ## Goal -`quest worktree` owns a recoverable checkout lifecycle: admit disk use, create -or reuse a checkout, run work, checkpoint, release, and restore on resume. -Waiting for a decision or PR review does not require retaining a large checkout. -Agent count is unrestricted by this command; expensive work uses `quest gate`. +`quest worktree` supervises the commands that run in a checkout it created, +then checkpoints and releases that checkout automatically once they stop. +A crash or cancellation is reconciled without losing work, stealing a live +lock, or removing a checkout whose processes may still be running. ## Plan -- Decided 2026-10-15: disk capacity is the admission constraint. Account for - concurrent allocations and expected build growth, with configurable limits - informed by measured footprints. A free-space check alone cannot reserve - space against simultaneous creators or subsequent builds. -- Checkpoint meaningful edits before a blocking handoff and at completion, - then release the idle checkout. An asynchronous question with independent - work still running is not a release boundary. Checkpoints are recoverable - WIP, not claims of passing tests, and do not run the validation suite. -- Preserve source, unique commits, and resume metadata independently of the - checkout before removing it. Preserve local-only submodule history outside - its disposable gitdir. Keep valuable untracked or ignored files explicitly; - never publish secrets or treat every ignored file as disposable. -- Record unresolved work and which checks passed or were not run with each - checkpoint. Recovery metadata lives outside the disposable checkout. -- Delete owned disposable scratch at handoff or exit. If scratch must live in - a checkout, ignore its declared paths and teach cleanup those same paths. - Unstaged source changes are work to preserve, not scratch to delete. -- Own foreground child processes until they stop; only then release capacity - and remove their checkout. Record ownership so crashes can be reconciled. - Reuse the owning tool for managed worktrees and report failed cleanup as a - retained allocation, not success. Do not steal live locks or remove shared - or current checkouts underneath their users. -- Preserve branches/recovery refs independently of checkout retention. A - squash-merged branch may not be an ancestor of main; exact PR-head evidence - matters when deciding whether a published result is finished. -- Verify checkpoint/restore across dirty files, unique commits, submodules, - cancellation, interrupted cleanup, and simultaneous disk admission. Measure - retained disk after complete and suspended jobs; update existing docs inline. - -Draft implementation status: - -- Draft implementation: explicit disk capacity/reservation, generic Git-owned - create/checkpoint/release/restore, private recovery metadata, full bundles, - tracked/index preservation, explicit keep/scratch classification, initialized - submodule history, retained failed allocations, and filesystem fixtures. - Interrupted create, restore, and release recover through `restore`/`release` - without checkpointing partial checkouts. -- Measured 2026-10-15 on this repository: a fresh checkout is 1.6 MB, 376 MB - after `just install` and `just build` (node_modules), and a released - checkpoint retains 4.4 MB (2.2 MB history bundle). Restore takes ~0.6 s. -- Remaining before this quest is complete: exact foreground descendant ownership - through cancellation/crashes and automatic reconciliation (including a stale - registry lock), automatic release at lifecycle boundaries, and cancellation - fixtures. Native agent lifecycle adoption remains in the related integration - quest. Whether that remainder splits into a quest that reuses the - [quest gate](/quest/m0/local-gate.md) ownership backend awaits a decision. -- Process groups and PID files cannot prove detached descendants are stopped - after a supervisor crash. Keep this PR draft until a supported ownership - backend can enforce that boundary; unsupported platforms must fail explicitly. +- Landed in [kixelated/quest#127](https://github.com/kixelated/quest/pull/127): + manual disk capacity/reservation, create, checkpoint, release, and restore of + Git-owned checkouts, with private recovery data, interrupted-transition + recovery through `restore`/`release`, and filesystem fixtures. The README + section "Recoverable local checkouts" documents it as experimental and + tells users to stop all work before checkpointing or releasing. +- Decided (user): reuse the Linux systemd scope ownership backend from + [kixelated/quest#128](https://github.com/kixelated/quest/pull/128) rather than + adding another one; start once that PR merges. Process groups and PID files + cannot prove detached descendants stopped after a supervisor crash, so hosts + without a supported backend fail explicitly instead of guessing. +- Own foreground child processes and their descendants until they stop; only + then release capacity and remove the checkout. Record ownership so a crash + can be reconciled, including a stale registry lock whose owner has exited. + Report failed cleanup as a retained allocation, not success. +- Release automatically at lifecycle boundaries: checkpoint meaningful edits + before a blocking handoff and at completion, then release the idle checkout. + An asynchronous question with independent work still running is not a release + boundary. Delete declared scratch at handoff or exit; unstaged source changes + are work to preserve, not scratch. +- Keep branches and recovery data independent of checkout retention. A + squash-merged branch may not be an ancestor of main; only exact PR-head + evidence shows a published result is finished. +- Add cancellation and crash fixtures: interrupt a supervised command with live + descendants, kill the supervisor, and reconcile. Measure retained disk after + complete and suspended jobs (2026-10-15 baseline on this repository: 1.6 MB + fresh, 376 MB built, 4.4 MB released checkpoint, ~0.6 s restore). +- Native agent lifecycle adoption stays in the agent integration quest. ## Related -- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU admission +- [Expensive command gate](/quest/m0/local-gate.md) - its Linux ownership backend is reused here - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - native agents and workflow adoption From 60735a8f0170703f3e737aa34aab63d1e9b5b2e2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:06:01 -0700 Subject: [PATCH 10/17] refactor(worktree): release checkouts by pruning scratch in place 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 --- README.md | 72 +++--- quest/m0/kix/runner.md | 2 +- quest/m0/worktree-agents.md | 9 +- quest/m0/worktree.md | 41 +-- src/cli/main.ts | 33 +-- src/cli/worktree.ts | 501 +++++++----------------------------- test/worktree.test.ts | 356 ++++++------------------- 7 files changed, 245 insertions(+), 769 deletions(-) diff --git a/README.md b/README.md index 96983837..c1178045 100644 --- a/README.md +++ b/README.md @@ -201,54 +201,46 @@ a cursor, or the given `--since` (else the head SHA) if setup had not finished. Retry with it, not the original command, so activity that landed during the first wait is still reported. -## Recoverable local checkouts - -The experimental `quest worktree` commands reserve disk across this user's -repositories and preserve private recovery data outside disposable checkouts. -They only operate on Git worktrees they create; use the owning application for -Codex, Claude, or other managed checkouts. Foreground process supervision and -crash reconciliation remain unfinished. Do not use these commands to supervise -active agents or commands, and stop all work before taking a checkpoint or -releasing its checkout. - -Set a shared capacity and an explicit reservation in bytes, including the -checkout, expected build growth, and recovery history. Measure representative -jobs before choosing the budget; `status` reports allocated and retained bytes. -Reservations serialize creators and account for concurrent expected growth. -They do not enforce filesystem quotas against arbitrary external writers. +## Prunable local checkouts + +The experimental `quest worktree` commands share one disk budget across this +user's repositories and release a checkout's disk by deleting its declared +scratch in place. They only operate on Git worktrees they create; use the +owning application for Codex, Claude, or other managed checkouts. Process +supervision remains unfinished: stop agents and commands in a checkout before +releasing it. + +Set a shared capacity and an explicit reservation in bytes, covering the +checkout and its expected build output. Measure representative jobs before +choosing the budget. Reservations serialize creators and account for concurrent +expected growth. They do not enforce filesystem quotas against arbitrary +writers, and Git objects stored in the shared repository are not charged. ```sh quest worktree capacity 20000000000 quest worktree create example --reserve 3000000000 --ref HEAD quest worktree status -quest worktree checkpoint example --notes "Implementing the parser" --check "unit tests not run" -quest worktree release example --notes "Awaiting review" --check "just check passed" --keep .env --scratch node_modules dist +quest worktree release example --scratch node_modules dist quest worktree restore example ``` -Creation prints JSON with the ID and detached checkout path under `.worktrees/`. -Release always takes a fresh checkpoint. Tracked working files, the staged -index, reachable Git history, and initialized submodules' local history are -preserved. Untracked and ignored files require explicit `--keep` or `--scratch` -classification before release; source cannot be declared scratch. Release -refuses while the checkout holds any nested repository or worktree other than -its initialized submodules. `--keep` -never pushes or publishes its contents. Checkpoints record notes and check -results without running tests or claiming that validation passed. - -Recovery lives in `$QUEST_WORKTREE_HOME`, or -`$XDG_STATE_HOME/quest/worktrees` (default `~/.local/state/quest/worktrees`). It -must be outside the disposable checkout and on the same filesystem for disk -admission. Checkpoint bytes remain charged after checkout removal. There is no -automatic recovery-data deletion or branch deletion, including after a squash -merge. Restore leaves the main checkout detached, preserving existing branches. -A failed creation, restoration, or removal remains charged and visible in -`status`. Run `restore` again to retry an interrupted restoration from its -checkpoint, or `release` to finish an interrupted removal or abandon an -interrupted creation or restoration; neither checkpoints the partial checkout. -No live lock is stolen: after a crash, confirm the registry lock's owner has -exited before removing it. This initial interface intentionally refuses unknown -or changed checkout ownership. +Creation prints JSON with the ID and the detached, locked checkout path under +`.worktrees/`. Release deletes only its `--scratch` paths, and refuses one that +Git does not ignore, that holds tracked files, or that is reached through a +symbolic link; links inside scratch are removed, never followed. Missing paths +are skipped, so release can be rerun. The checkout, its Git state, and +untracked files stay in place, and `status` charges what remains. Restore +reserves the checkout's disk again; then reinstall or rebuild its scratch, for +example with `just install`. + +Quest never removes a checkout. Once one is finished, remove it with +`git worktree unlock` and `git worktree remove`, which refuses modified or +untracked files but deletes ignored ones; `release` then drops its record. A +failed creation stays charged until `release` drops it. Records live in +`$QUEST_WORKTREE_HOME`, or `$XDG_STATE_HOME/quest/worktrees` (default +`~/.local/state/quest/worktrees`). No live lock is stolen: after a crash, +confirm the registry lock's owner has exited before removing it. This initial +interface refuses unknown or changed checkout ownership. ## Work with your agents diff --git a/quest/m0/kix/runner.md b/quest/m0/kix/runner.md index cbc64b61..0d3c6462 100644 --- a/quest/m0/kix/runner.md +++ b/quest/m0/kix/runner.md @@ -88,4 +88,4 @@ replacing the hosted Agent SDK runtime: ## Required -- [Worktree process ownership](/quest/m0/worktree.md) - reuse checkout supervision, checkpoints, and disk admission +- [Worktree process ownership](/quest/m0/worktree.md) - reuse checkout supervision, scratch release, and disk admission diff --git a/quest/m0/worktree-agents.md b/quest/m0/worktree-agents.md index 27f6a139..804ff316 100644 --- a/quest/m0/worktree-agents.md +++ b/quest/m0/worktree-agents.md @@ -2,9 +2,10 @@ ## Goal -Quest workflows use recoverable worktree ownership through supported Codex and -Claude lifecycle integrations. A completed or suspended worker releases its -checkout without waiting for the parent chat to end or the PR to merge. +Quest workflows use `quest worktree` checkout ownership through supported +Codex and Claude lifecycle integrations. A completed or suspended worker +releases its checkout without waiting for the parent chat to end or the PR to +merge. ## Plan @@ -38,7 +39,7 @@ checkout without waiting for the parent chat to end or the PR to merge. ## Required -- [Worktree process ownership](/quest/m0/worktree.md) - supervised commands and automatic checkpoint and release +- [Worktree process ownership](/quest/m0/worktree.md) - supervised commands and automatic scratch release - [Expensive command gate](/quest/m0/local-gate.md) - resource control before removing agent-count throttles ## Related diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index 486d3e0a..c4e82bb1 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -3,39 +3,42 @@ ## Goal `quest worktree` supervises the commands that run in a checkout it created, -then checkpoints and releases that checkout automatically once they stop. -A crash or cancellation is reconciled without losing work, stealing a live -lock, or removing a checkout whose processes may still be running. +then releases that checkout automatically once they stop by pruning its +declared scratch in place. A crash or cancellation is reconciled without losing +work, stealing a live lock, or deleting scratch a running process still uses. ## Plan - Landed in [kixelated/quest#127](https://github.com/kixelated/quest/pull/127): - manual disk capacity/reservation, create, checkpoint, release, and restore of - Git-owned checkouts, with private recovery data, interrupted-transition - recovery through `restore`/`release`, and filesystem fixtures. The README - section "Recoverable local checkouts" documents it as experimental and - tells users to stop all work before checkpointing or releasing. + manual disk capacity and reservations, `create`, `release` (delete declared + scratch in place), and `restore` (reserve again; the caller rebuilds the + scratch) of Git-owned checkouts. The README section "Prunable local + checkouts" documents it as experimental and tells users to stop all work + before releasing. +- Decided (user, 2026-10-21): prune in place. Release deletes only declared + scratch that Git ignores, that holds no tracked files, and that is not reached + through a symlink; it never removes the checkout or its Git state. + Checkout-independent recovery is out of scope: four review rounds of the + checkpoint-and-remove design kept finding data-loss edge cases, while the + disk cost is scratch (2026-10-15 on this repository: 1.6 MB fresh source, + 376 MB built). - Decided (user): reuse the Linux systemd scope ownership backend from [kixelated/quest#128](https://github.com/kixelated/quest/pull/128) rather than adding another one; start once that PR merges. Process groups and PID files cannot prove detached descendants stopped after a supervisor crash, so hosts without a supported backend fail explicitly instead of guessing. - Own foreground child processes and their descendants until they stop; only - then release capacity and remove the checkout. Record ownership so a crash + then prune scratch and release the reservation. Record ownership so a crash can be reconciled, including a stale registry lock whose owner has exited. Report failed cleanup as a retained allocation, not success. -- Release automatically at lifecycle boundaries: checkpoint meaningful edits - before a blocking handoff and at completion, then release the idle checkout. - An asynchronous question with independent work still running is not a release - boundary. Delete declared scratch at handoff or exit; unstaged source changes - are work to preserve, not scratch. -- Keep branches and recovery data independent of checkout retention. A - squash-merged branch may not be an ancestor of main; only exact PR-head - evidence shows a published result is finished. +- Release automatically at lifecycle boundaries: before a blocking handoff and + at completion, once meaningful edits are committed. An asynchronous question + with independent work still running is not a release boundary. Automatic + release needs the scratch declarations without a caller; recording them at + `create` is the expected route. - Add cancellation and crash fixtures: interrupt a supervised command with live descendants, kill the supervisor, and reconcile. Measure retained disk after - complete and suspended jobs (2026-10-15 baseline on this repository: 1.6 MB - fresh, 376 MB built, 4.4 MB released checkpoint, ~0.6 s restore). + complete and suspended jobs. - Native agent lifecycle adoption stays in the agent integration quest. ## Related diff --git a/src/cli/main.ts b/src/cli/main.ts index 817bea78..b1800764 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -172,7 +172,7 @@ export function main(argv: string[], out: Output): number | Promise { const worktree = program .command("worktree") - .description("Reserve disk and checkpoint, release, or restore private Git checkouts"); + .description("Reserve disk for Git checkouts and release it by pruning their scratch in place"); const state = () => new Worktrees(); const record = (value: unknown) => print(JSON.stringify(value)); worktree @@ -183,39 +183,24 @@ export function main(argv: string[], out: Output): number | Promise { }); worktree .command("status") - .description("Print reservations, retained recovery bytes, and checkouts as JSON") + .description("Print the capacity, allocated bytes, and checkouts as JSON") .action(() => record(state().status())); worktree .command("create ") .description("Create an exclusively owned detached Git worktree under .worktrees/") - .requiredOption( - "--reserve ", - "Checkout, expected build growth, and checkpoint bytes", - positiveInteger("bytes"), - ) + .requiredOption("--reserve ", "Checkout and expected build growth in bytes", positiveInteger("bytes")) .option("--ref ", "Initial Git revision", "HEAD") .action((name: string, options: { reserve: number; ref: string }) => record(state().create(root(), name, options.reserve, options.ref)), ); - for (const operation of ["checkpoint", "release"] as const) { - worktree - .command(`${operation} `) - .description( - operation === "checkpoint" - ? "Preserve source, index, history, and resume notes outside the checkout" - : "Take a fresh checkpoint, then remove an exclusively owned idle checkout", - ) - .requiredOption("--notes ", "Unresolved work, or an explicit completion statement") - .option("--check ", "Checks passed, failed, or not run", []) - .option("--keep ", "Explicit valuable untracked or ignored paths to preserve locally", []) - .option("--scratch ", "Explicit disposable untracked or ignored paths", []) - .action((id: string, options: { notes: string; check: string[]; keep: string[]; scratch: string[] }) => - record(state()[operation](id, { ...options, checks: options.check })), - ); - } + worktree + .command("release ") + .description("Delete declared scratch and keep the checkout; drop the record of a removed checkout") + .option("--scratch ", "Ignored paths without tracked files, such as dependencies and build output", []) + .action((id: string, options: { scratch: string[] }) => record(state().release(id, options.scratch))); worktree .command("restore ") - .description("Restore the preserved checkout and source; leave the main worktree detached") + .description("Reserve a released checkout's disk again before rebuilding its scratch") .action((id: string) => record(state().restore(id))); program diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 86180dbb..0be0ff5e 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -1,8 +1,8 @@ -// Recoverable Git checkouts. Recovery data is private, local, and never pushed. +// Git checkouts under a shared disk budget. Release prunes declared scratch in place. import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { - cpSync, + type Stats, existsSync, lstatSync, mkdirSync, @@ -15,7 +15,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; export interface WorktreeRecord { id: string; @@ -23,8 +23,7 @@ export interface WorktreeRecord { repository: string; path: string; reserve: number; - phase: "creating" | "active" | "releasing" | "released" | "restoring"; - checkpoint?: string; + phase: "creating" | "active" | "released"; failure?: string; } interface Registry { @@ -32,27 +31,6 @@ interface Registry { capacity: number; records: WorktreeRecord[]; } -interface RepositorySnapshot { - path: string; - head: string; - branch: string | null; -} -interface Snapshot { - version: 1; - created: string; - notes: string; - checks: string[]; - keep: string[]; - scratch: string[]; - repositories: RepositorySnapshot[]; - unclassified: string[]; -} -export interface CheckpointOptions { - notes: string; - checks: string[]; - keep: string[]; - scratch: string[]; -} function git(root: string, ...args: string[]): string { return execFileSync("git", ["-C", root, ...args], { @@ -61,18 +39,19 @@ function git(root: string, ...args: string[]): string { stdio: ["ignore", "pipe", "pipe"], }); } -function json(path: string): T { - return JSON.parse(readFileSync(path, "utf8")) as T; -} function save(path: string, value: unknown): void { const temporary = `${path}.${randomUUID()}.tmp`; writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); renameSync(temporary, path); } -/** Copy file trees without changing symbolic-link text. */ -function copy(source: string, destination: string): void { - mkdirSync(dirname(destination), { recursive: true }); - cpSync(source, destination, { recursive: true, dereference: false, verbatimSymlinks: true }); +/** The entry itself, without following a final symlink; undefined when it is missing. */ +function entry(path: string): Stats | undefined { + try { + return lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } } /** One spelling per path: Git prints `C:/...`, Node `C:\...`, and Windows may use 8.3 names. */ function canonical(path: string): string { @@ -89,10 +68,6 @@ function checkouts(repository: string): { path: string; locked?: string }[] { return path ? [{ path: canonical(path), locked }] : []; }); } -function inside(root: string, path: string): boolean { - const value = relative(root, path); - return value === "" || (!isAbsolute(value) && value !== ".." && !value.startsWith(`..${sep}`)); -} function childPath(path: string): string { if ( !path || @@ -104,30 +79,38 @@ function childPath(path: string): string { } return path.replaceAll("\\", "/").replace(/\/$/, ""); } -function covers(paths: string[], path: string): boolean { - return paths.some((p) => path === p || path.startsWith(`${p}/`)); -} -function list(root: string, ...args: string[]): string[] { - return git(root, "ls-files", "-z", ...args) - .split("\0") - .filter(Boolean); +/** Whether a scratch path exists; refuses one reached through a symlink, which could leave the checkout. */ +function present(root: string, path: string): boolean { + const parts = path.split("/"); + for (let i = 1; i <= parts.length; i++) { + const stat = entry(join(root, ...parts.slice(0, i))); + if (!stat) return false; + if (i < parts.length && !stat.isDirectory()) + throw new Error(`scratch path is reached through a symlink or file: ${path}`); + } + return true; } - -/** Nested `.git` entries beneath `root`, relative to it; symlinks are never traversed. */ -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] : []; - return entry.isDirectory() ? repositories(root, child) : []; - }); +/** The paths Git ignores; check-ignore exits 1 when it matches none. */ +function ignored(root: string, paths: string[]): Set { + try { + const output = execFileSync("git", ["-C", root, "check-ignore", "-z", "--stdin"], { + encoding: "utf8", + input: `${paths.join("\0")}\0`, + stdio: ["pipe", "pipe", "pipe"], + }); + return new Set(output.split("\0")); + } catch (error) { + if ((error as { status?: number }).status === 1) return new Set(); + throw error; + } } /** Allocated blocks, including ignored build output; symlinks are never traversed. */ export function diskUsage(path: string): number { - if (!existsSync(path)) return 0; - const stat = lstatSync(path); + const stat = entry(path); + if (!stat) return 0; let bytes = Math.max(stat.blocks * 512, stat.size); - if (stat.isDirectory()) for (const entry of readdirSync(path)) bytes += diskUsage(join(path, entry)); + if (stat.isDirectory()) for (const child of readdirSync(path)) bytes += diskUsage(join(path, child)); return bytes; } @@ -155,9 +138,9 @@ export class Worktrees { try { save(join(lock, "owner.json"), { pid: process.pid, started: new Date().toISOString() }); const file = join(this.home, "registry.json"); - const registry = existsSync(file) - ? json(file) - : { version: 1 as const, capacity: 0, records: [] }; + const registry: Registry = existsSync(file) + ? JSON.parse(readFileSync(file, "utf8")) + : { version: 1, capacity: 0, records: [] }; if (registry.version !== 1) throw new Error("unsupported worktree registry version"); try { return action(registry); @@ -173,83 +156,52 @@ export class Worktrees { if (!record) throw new Error(`unknown worktree: ${id}`); return record; } - private retained(): number { - const directory = join(this.home, "checkpoints"); - // Shared registry/container metadata is outside per-checkout reservations. - return existsSync(directory) - ? readdirSync(directory).reduce((total, entry) => total + diskUsage(join(directory, entry)), 0) - : 0; - } - private measure(registry: Registry): number { - const retained = this.retained(); - return ( - retained + - registry.records.reduce((total, record) => { - if (record.phase === "released") return total; - const checkout = diskUsage(record.path); - const recovery = diskUsage(join(this.home, "checkpoints", record.id)); - return total + checkout + Math.max(0, record.reserve - checkout - recovery); - }, 0) - ); + /** Bytes on disk, plus the growth active reservations still expect. Released checkouts keep only what remains. */ + private usage(registry: Registry): { used: number; growth: number } { + let used = 0; + let growth = 0; + for (const record of registry.records) { + const bytes = diskUsage(record.path); + used += bytes; + if (record.phase !== "released") growth += Math.max(0, record.reserve - bytes); + } + return { used, growth }; } - private admit(registry: Registry, reserve: number, root: string): void { - if (lstatSync(this.home).dev !== lstatSync(root).dev) + private admit(registry: Registry, reserve: number, parent: string): void { + const { used, growth } = this.usage(registry); + if (used + growth + reserve > registry.capacity) throw new Error( - "worktree state and checkout must share a filesystem for disk admission; set QUEST_WORKTREE_HOME on the checkout filesystem", + `disk capacity exceeded: ${used + growth} allocated + ${reserve} requested > ${registry.capacity}`, ); - const used = this.measure(registry); - if (used + reserve > registry.capacity) - throw new Error( - `disk capacity exceeded: ${used} reserved/retained + ${reserve} requested > ${registry.capacity}`, - ); - const fs = statfsSync(root); - const future = registry.records.reduce( - (total, record) => - total + - (record.phase === "released" - ? 0 - : Math.max( - 0, - record.reserve - - diskUsage(record.path) - - diskUsage(join(this.home, "checkpoints", record.id)), - )), - 0, - ); - if (Number(fs.bavail) * Number(fs.bsize) < future + reserve) + const fs = statfsSync(parent); + if (Number(fs.bavail) * Number(fs.bsize) < growth + reserve) throw new Error("insufficient free space after accounting for concurrent build reservations"); } - /** Capacity persists; reducing it below retained allocations is rejected. */ + /** Capacity persists; reducing it below existing allocations is rejected. */ configure(capacity: number): void { this.locked((registry) => { if (!Number.isSafeInteger(capacity) || capacity <= 0) throw new Error("capacity must be a positive safe integer in bytes"); - if (this.measure(registry) > capacity) - throw new Error("capacity is below existing reserved/retained allocations"); + const { used, growth } = this.usage(registry); + if (used + growth > capacity) throw new Error("capacity is below existing allocations"); registry.capacity = capacity; }); } - status(): { capacity: number; allocated: number; retained: number; records: WorktreeRecord[] } { - return this.locked((registry) => ({ - capacity: registry.capacity, - allocated: this.measure(registry), - retained: this.retained(), - records: registry.records, - })); + status(): { capacity: number; allocated: number; records: WorktreeRecord[] } { + return this.locked((registry) => { + const { used, growth } = this.usage(registry); + return { capacity: registry.capacity, allocated: used + growth, records: registry.records }; + }); } create(root: string, name: string, reserve: number, ref = "HEAD"): WorktreeRecord { return this.locked((registry) => { if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) throw new Error("name must contain only letters, digits, dots, underscores, or hyphens"); if (!Number.isSafeInteger(reserve) || reserve <= 0) - throw new Error( - "reserve must be a positive safe integer in bytes, including expected build and checkpoint growth", - ); + throw new Error("reserve must be a positive safe integer in bytes, including expected build growth"); if (registry.records.some((item) => item.name === name)) throw new Error(`worktree name already exists; use restore or another name: ${name}`); const repository = canonical(git(root, "rev-parse", "--show-toplevel").trim()); - if (inside(repository, this.home)) - throw new Error("recovery state must live outside the disposable checkout"); const path = join(repository, ".worktrees", name); mkdirSync(dirname(path), { recursive: true }); if (existsSync(path)) throw new Error(`refusing to adopt an existing checkout: ${path}`); @@ -280,8 +232,6 @@ export class Worktrees { /** Refuses unless this record exclusively owns the checkout; false once Git and the disk both lack it. */ private owned(record: WorktreeRecord): boolean { const path = canonical(record.path); - if (inside(path, canonical(process.cwd()))) - throw new Error("leave the checkout before checkpointing or releasing it"); const entry = checkouts(record.repository).find((entry) => entry.path === path); if (!entry && !existsSync(path)) return false; if (entry?.locked !== `quest worktree ${record.id}`) @@ -291,306 +241,49 @@ export class Worktrees { return true; } /** - * Refuses removal while the checkout holds a repository its checkpoint did not save, whichever - * repository registered it: forced removal would delete its uncommitted work and history. + * Deletes only the declared scratch, which Git must ignore and which holds no tracked files. + * The checkout, its Git state, and untracked source stay in place. A checkout that Git and the + * disk both lack, such as one removed with `git worktree remove`, drops its record instead. */ - private refuseNested(record: WorktreeRecord): void { - if (!existsSync(record.path)) return; - const saved = record.checkpoint - ? json(join(record.checkpoint, "snapshot.json")).repositories.map((repository) => repository.path) - : []; - const nested = repositories(record.path).filter((path) => !saved.includes(path)); - if (nested.length) - throw new Error(`nested repository would be removed; move it out of the checkout: ${nested.join(", ")}`); - } - /** Git removes the owned checkout and its administrative directory, even when the checkout is missing. */ - private discard(record: WorktreeRecord): void { - if (!this.owned(record)) return; - this.refuseNested(record); - git(record.repository, "worktree", "remove", "--force", "--force", record.path); - if (existsSync(record.path)) throw new Error("Git reported cleanup success but checkout is retained"); - } - checkpoint(id: string, options: CheckpointOptions): WorktreeRecord { - return this.locked((registry) => this.checkpointLocked(this.find(registry, id), options)); - } - private checkpointLocked(record: WorktreeRecord, options: CheckpointOptions): WorktreeRecord { - if (record.phase !== "active") throw new Error(`cannot checkpoint a ${record.phase} checkout`); - if (!this.owned(record)) throw new Error(`active checkout is missing: ${record.path}`); - if (!options.notes.trim()) - throw new Error("checkpoint notes must describe unresolved work, or state that it is complete"); - const keep = options.keep.map(childPath); - const scratch = options.scratch.map(childPath); - if (keep.some((k) => scratch.some((s) => covers([k], s) || covers([s], k)))) - throw new Error("keep and scratch paths overlap"); - const directory = join(this.home, "checkpoints", record.id, randomUUID()); - mkdirSync(directory, { recursive: true, mode: 0o700 }); - const snapshot: Snapshot = { - version: 1, - created: new Date().toISOString(), - notes: options.notes, - checks: options.checks, - keep, - scratch, - repositories: [], - unclassified: [], - }; - try { - const visit = (path: string) => { - const root = join(record.path, path); - const number = snapshot.repositories.length; - const target = join(directory, String(number)); - mkdirSync(join(target, "files"), { recursive: true }); - // Each Git process costs ~100 ms on Windows, so every query here is batched. - const entries = list(root, "--stage").map((entry) => { - const tab = entry.indexOf("\t"); - return { mode: entry.slice(0, 6), stage: entry[tab - 1], path: entry.slice(tab + 1) }; - }); - if (entries.some((entry) => !entry.path || entry.path.includes("\0"))) - throw new Error("invalid tracked path"); - if (entries.some((entry) => entry.stage !== "0")) - throw new Error("resolve the unmerged index before checkpointing"); - const committed = git(root, "ls-tree", "-r", "-z", "HEAD") - .split("\0") - .filter(Boolean) - .map((entry) => ({ mode: entry.slice(0, 6), path: entry.slice(entry.indexOf("\t") + 1) })); - // The index decides which paths are submodules, including staged replacements of HEAD's. - // HEAD adds the files the index deleted or replaced. - const submodules = entries.filter((entry) => entry.mode === "160000").map((entry) => entry.path); - const files = [ - ...new Set( - [...entries, ...committed] - .filter((entry) => entry.mode !== "160000") - .map((entry) => entry.path), - ), - ]; - for (const file of files) - if (covers(scratch, path ? `${path}/${file}` : file)) - throw new Error(`scratch includes tracked source: ${file}`); - for (const file of files) { - const source = join(root, file); - try { - // HEAD may name a file replaced by a directory in the index. - if (lstatSync(source).isDirectory()) continue; - } catch (error) { - // A missing descendant can be underneath a replacement file. - if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue; - throw error; - } - copy(source, join(target, "files", file)); - } - const [head, index, config, symbolic] = git( - root, - "rev-parse", - "HEAD", - "--path-format=absolute", - "--git-path", - "index", - "--git-path", - "config", - "--symbolic-full-name", - "HEAD", - ) - .trim() - .split("\n"); - const branch = symbolic === "HEAD" ? null : symbolic; // Detached HEAD is supported. - const savedIndex = join(target, "index"); - copy(index, savedIndex); - // Materialize a private full index while its split-index backing file still exists. - // The live index and its flags stay intact. - execFileSync("git", ["-C", root, "update-index", "--no-split-index"], { - env: { ...process.env, GIT_INDEX_FILE: savedIndex }, - stdio: "pipe", - }); - if (path) cpSync(config, join(target, "config")); - // A recovery commit makes staged blobs reachable without changing HEAD/index. - const tree = git(root, "write-tree").trim(); - const commit = git( - root, - "-c", - "user.name=Quest recovery", - "-c", - "user.email=recovery@localhost", - "commit-tree", - tree, - "-p", - head, - "-m", - `Local worktree recovery ${record.id}`, - ).trim(); - git(root, "update-ref", `refs/quest/worktrees/${record.id}/${number}`, commit); - // A full bundle has no prerequisites, so `bundle verify` would only repeat its creation. - git(root, "bundle", "create", join(target, "history.bundle"), "--all", "--reflog", "HEAD"); - snapshot.repositories.push({ path, head, branch }); - // Without exclude patterns, --others lists untracked and ignored files alike. - for (const file of list(root, "--others")) { - const relative = path ? `${path}/${file}` : file; - if (!covers(keep, relative) && !covers(scratch, relative)) snapshot.unclassified.push(relative); - } - for (const submodule of submodules) { - const child = path ? `${path}/${submodule}` : submodule; - if (existsSync(join(record.path, child, ".git"))) visit(child); - else if (existsSync(join(record.path, child)) && readdirSync(join(record.path, child)).length) - throw new Error(`unrecognized submodule contents: ${child}`); - } - }; - visit(""); - for (const path of keep) { - const source = join(record.path, path); - // The entry itself, which may be a dangling symlink; copies keep its link text. - let stat: ReturnType; - try { - stat = lstatSync(source); - } catch (error) { - if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) - throw new Error(`keep path does not exist: ${path}`); - throw error; - } - if (!inside(canonical(record.path), canonical(dirname(source)))) - throw new Error(`keep path escapes checkout: ${path}`); - if (stat.isDirectory() && existsSync(join(source, ".git"))) - throw new Error( - `keep directories cannot contain a repository: ${path}; tracked submodules are preserved automatically`, - ); - copy(source, join(directory, "kept", path)); - } - save(join(directory, "snapshot.json"), snapshot); - const previous = record.checkpoint; - record.checkpoint = directory; - record.failure = undefined; - // Publish the new recovery pointer before collecting the old checkpoint. - // A crash can retain extra data, but can never invalidate the saved pointer. - const registry = json(join(this.home, "registry.json")); - const current = this.find(registry, record.id); - Object.assign(current, record); - save(join(this.home, "registry.json"), registry); - if (previous) rmSync(previous, { recursive: true }); - return record; - } catch (error) { - if (record.checkpoint !== directory) rmSync(directory, { recursive: true }); - throw error; - } - } - /** Checkpoint and deletion occur under the same lock; stale snapshots cannot lose edits. */ - release(id: string, options: CheckpointOptions): WorktreeRecord { + release(id: string, scratch: string[]): WorktreeRecord { return this.locked((registry) => { const record = this.find(registry, id); - if (record.phase === "released") return record; - if (record.phase === "active") { - this.checkpointLocked(record, options); - const snapshot = json(join(record.checkpoint!, "snapshot.json")); - if (snapshot.unclassified.length) - throw new Error( - `classify untracked/ignored files with --keep or --scratch before release: ${snapshot.unclassified.slice(0, 20).join(", ")}`, - ); - this.refuseNested(record); // Refuse while still active; discard checks again after a crash. - record.phase = "releasing"; - save(join(this.home, "registry.json"), registry); + if (!this.owned(record)) { + registry.records.splice(registry.records.indexOf(record), 1); + return record; } - // An interrupted create, restore, or release never handed out new work, so the last - // checkpoint stays authoritative; checkpointing a partial checkout could corrupt it. - try { - this.discard(record); - } catch (error) { - record.failure = String(error); - throw new Error(`cleanup failed; allocation retained: ${record.path}: ${String(error)}`); + if (record.phase === "creating") + throw new Error( + `creation was interrupted; inspect and remove the partial checkout with git worktree remove, then release again: ${record.path}`, + ); + const root = canonical(record.path); + const paths = scratch.map(childPath).filter((path) => present(root, path)); // Reruns skip pruned paths. + if (paths.length) { + const tracked = git(root, "--literal-pathspecs", "ls-files", "-z", "--", ...paths) + .split("\0") + .filter(Boolean); + if (tracked.length) + throw new Error(`scratch includes tracked files: ${tracked.slice(0, 20).join(", ")}`); + const matched = ignored(root, paths); + const unignored = paths.filter((path) => !matched.has(path)); + if (unignored.length) throw new Error(`scratch must be ignored by Git: ${unignored.join(", ")}`); + // Removal unlinks symlinks rather than following them. + for (const path of paths) rmSync(join(root, path), { recursive: true }); } record.phase = "released"; - record.failure = undefined; - // An abandoned creation has nothing to restore; free its name and reservation. - if (!record.checkpoint) registry.records.splice(registry.records.indexOf(record), 1); return record; }); } + /** Reserves the released checkout's disk again; the caller then reinstalls or rebuilds its scratch. */ restore(id: string): WorktreeRecord { return this.locked((registry) => { const record = this.find(registry, id); - if (record.phase === "active") { - if (!this.owned(record)) throw new Error(`active checkout is missing: ${record.path}`); - return record; - } - if (record.phase === "restoring") { - // Retry an interrupted restore from the untouched checkpoint. - this.discard(record); - record.phase = "released"; - } - if (record.phase !== "released" || !record.checkpoint) - 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"); - // Its retained recovery already counts toward the shared capacity and free-space usage. - const retained = diskUsage(join(this.home, "checkpoints", record.id)); - this.admit(registry, Math.max(0, record.reserve - retained), dirname(record.path)); - const snapshot = json(join(record.checkpoint, "snapshot.json")); - if (snapshot.version !== 1) throw new Error("unsupported checkpoint version"); - record.phase = "restoring"; - save(join(this.home, "registry.json"), registry); - try { - for (let i = 0; i < snapshot.repositories.length; i++) { - const saved = snapshot.repositories[i]; - const root = join(record.path, saved.path); - const source = join(record.checkpoint, String(i)); - if (i === 0) { - git( - record.repository, - "fetch", - "--no-write-fetch-head", - join(source, "history.bundle"), - `refs/quest/worktrees/${record.id}/0:refs/quest/worktrees/${record.id}/0`, - ); - git( - record.repository, - "worktree", - "add", - "--no-checkout", - "--detach", - "--lock", - "--reason", - `quest worktree ${record.id}`, - root, - saved.head, - ); - } else { - mkdirSync(root, { recursive: true }); - git(root, "init"); - cpSync(join(source, "config"), join(root, ".git", "config")); - // Embedded restored gitdirs have a different relative worktree path. - try { - execFileSync( - "git", - ["config", "--file", join(root, ".git", "config"), "--unset-all", "core.worktree"], - { cwd: this.home, stdio: "pipe" }, - ); - } catch (error) { - if ((error as { status?: number }).status !== 5) throw error; - } - // Detach before fetching branches, including git init's initial branch. - writeFileSync(join(root, ".git", "HEAD"), `${saved.head}\n`); - git(root, "fetch", "--no-write-fetch-head", join(source, "history.bundle"), "+refs/*:refs/*"); - // Submodule gitdirs are private copies, so their branches return as saved. - writeFileSync( - join(root, ".git", "HEAD"), - `${saved.branch ? `ref: ${saved.branch}` : saved.head}\n`, - ); - } - // The checkout starts empty, so the saved tree itself describes present files. - copy(join(source, "files"), root); - cpSync( - join(source, "index"), - i === 0 - ? git(root, "rev-parse", "--path-format=absolute", "--git-path", "index").trim() - : join(root, ".git", "index"), - ); - } - if (existsSync(join(record.checkpoint, "kept"))) copy(join(record.checkpoint, "kept"), record.path); - record.phase = "active"; - record.failure = undefined; - return record; - } catch (error) { - record.failure = String(error); - throw new Error( - `restore incomplete; allocation and recovery retained: ${record.path}: ${String(error)}`, - ); - } + if (!this.owned(record)) throw new Error(`checkout is missing; release drops its record: ${record.path}`); + if (record.phase === "active") return record; + if (record.phase !== "released") throw new Error(`cannot restore a ${record.phase} checkout`); + this.admit(registry, Math.max(0, record.reserve - diskUsage(record.path)), dirname(record.path)); + record.phase = "active"; + return record; }); } } diff --git a/test/worktree.test.ts b/test/worktree.test.ts index de84748c..5242c9cb 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,18 +1,9 @@ import { execFileSync, spawn } from "node:child_process"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readlinkSync, - rmSync, - symlinkSync, - writeFileSync, -} from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Worktrees } from "../src/cli/worktree"; +import { Worktrees, diskUsage } from "../src/cli/worktree"; import { run } from "./fixture"; const roots: string[] = []; @@ -39,197 +30,99 @@ function fixture() { worktrees.configure(100_000_000); return { root, repo, home, worktrees }; } -/** Simulates a crash that left the only record mid-transition. */ -function setPhase(home: string, phase: string): void { - const registry = JSON.parse(readFileSync(join(home, "registry.json"), "utf8")); - registry.records[0].phase = phase; - writeFileSync(join(home, "registry.json"), JSON.stringify(registry)); -} -const notes = { - notes: "Remaining work: finish implementation", - checks: ["unit tests not run"], - keep: [] as string[], - scratch: [] as string[], -}; afterEach(() => { vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -// Each case runs 25-60 real Git processes. Process creation on the Windows ARM runner +// Cases run up to 20 real Git processes. Process creation on the Windows ARM runner // costs ~80-250 ms while other test files start, so the 5 s unit-test default is too short. -describe("recoverable worktrees", { timeout: 20_000 }, () => { - it("preserves the staged index, unstaged source, additions, and deletions", () => { - const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - writeFileSync(join(record.path, "file"), "staged\n"); - git(record.path, "add", "file"); - writeFileSync(join(record.path, "file"), "unstaged\n"); - git(record.path, "rm", ".gitignore"); - writeFileSync(join(record.path, "added"), "staged addition"); - git(record.path, "add", "added"); - writeFileSync(join(record.path, "added"), "modified addition"); - worktrees.release(record.id, notes); - worktrees.restore(record.id); - expect(readFileSync(join(record.path, "file"), "utf8")).toBe("unstaged\n"); - expect(git(record.path, "show", ":file")).toBe("staged"); - expect(existsSync(join(record.path, ".gitignore"))).toBe(false); - expect(git(record.path, "show", ":added")).toBe("staged addition"); - expect(readFileSync(join(record.path, "added"), "utf8")).toBe("modified addition"); - }); - it("restores within the original reservation while charging other retained checkpoints", () => { - const { repo, worktrees } = fixture(); - const reserve = 1_000_000; - worktrees.configure(reserve); - const record = worktrees.create(repo, "work", reserve); - worktrees.release(record.id, notes); - worktrees.restore(record.id); - expect(worktrees.status().allocated).toBe(reserve); - worktrees.release(record.id, notes); - const retained = worktrees.status().allocated; - expect(retained).toBeGreaterThan(0); - worktrees.create(repo, "other", reserve - retained); - expect(() => worktrees.restore(record.id)).toThrow("disk capacity exceeded"); - expect(worktrees.status().records[0].phase).toBe("released"); - }); - it("preserves split-index entries after the disposable Git directory is gone", () => { +describe("worktrees", { timeout: 20_000 }, () => { + it("prunes declared scratch in place and keeps source, Git state, and untracked files", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); + writeFileSync(join(record.path, "file"), "committed\n"); + git(record.path, "commit", "-am", "local only"); + const head = git(record.path, "rev-parse", "HEAD"); writeFileSync(join(record.path, "file"), "staged\n"); git(record.path, "add", "file"); writeFileSync(join(record.path, "file"), "unstaged\n"); - git(record.path, "update-index", "--split-index"); - const backing = git(record.path, "rev-parse", "--path-format=absolute", "--shared-index-path"); - expect(backing).not.toBe(""); - expect(existsSync(backing)).toBe(true); - worktrees.checkpoint(record.id, notes); - expect(git(record.path, "rev-parse", "--path-format=absolute", "--shared-index-path")).toBe(backing); + writeFileSync(join(record.path, "new-source"), "untracked source"); + writeFileSync(join(record.path, ".env"), "ignored and undeclared"); + mkdirSync(join(record.path, "cache", "nested"), { recursive: true }); + writeFileSync(join(record.path, "cache", "nested", "artifact"), "disposable"); const status = git(record.path, "status", "--porcelain=v1"); - worktrees.release(record.id, notes); - expect(existsSync(backing)).toBe(false); - worktrees.restore(record.id); + expect(worktrees.release(record.id, ["cache/", "missing"]).phase).toBe("released"); + expect(existsSync(join(record.path, "cache"))).toBe(false); expect(git(record.path, "status", "--porcelain=v1")).toBe(status); + expect(git(record.path, "rev-parse", "HEAD")).toBe(head); expect(git(record.path, "show", ":file")).toBe("staged"); expect(readFileSync(join(record.path, "file"), "utf8")).toBe("unstaged\n"); + expect(readFileSync(join(record.path, "new-source"), "utf8")).toBe("untracked source"); + expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("ignored and undeclared"); + expect(worktrees.restore(record.id).phase).toBe("active"); }); - it.each(["file to directory", "directory to file"])("roundtrips staged %s replacements", (direction) => { - const { repo, worktrees } = fixture(); - if (direction === "directory to file") { - git(repo, "rm", "file"); - mkdirSync(join(repo, "file")); - writeFileSync(join(repo, "file", "child"), "original\n"); - git(repo, "add", "."); - git(repo, "commit", "-m", "start with directory"); - } - const record = worktrees.create(repo, "work", 10_000_000); - git(record.path, "rm", "-r", "file"); - const path = direction === "file to directory" ? "file/child" : "file"; - if (direction === "file to directory") mkdirSync(join(record.path, "file")); - writeFileSync(join(record.path, path), "staged replacement\n"); - git(record.path, "add", "."); - writeFileSync(join(record.path, path), "unstaged replacement\n"); - const status = git(record.path, "status", "--porcelain=v1"); - worktrees.release(record.id, notes); - worktrees.restore(record.id); - expect(git(record.path, "status", "--porcelain=v1")).toBe(status); - expect(git(record.path, "show", `:${path}`)).toBe("staged replacement"); - expect(readFileSync(join(record.path, path), "utf8")).toBe("unstaged replacement\n"); - }); - it.skipIf(process.platform === "win32")("preserves tracked and retained relative symlink text", () => { - const { repo, worktrees } = fixture(); - symlinkSync("file", join(repo, "link")); - git(repo, "add", "link"); - git(repo, "commit", "-m", "track relative symlink"); - const record = worktrees.create(repo, "work", 10_000_000); - symlinkSync("file", join(record.path, "kept-link")); - mkdirSync(join(record.path, "kept-directory")); - symlinkSync("../file", join(record.path, "kept-directory", "link")); - worktrees.release(record.id, { ...notes, keep: ["kept-link", "kept-directory"] }); - worktrees.restore(record.id); - expect(readlinkSync(join(record.path, "link"))).toBe("file"); - expect(readlinkSync(join(record.path, "kept-link"))).toBe("file"); - expect(readlinkSync(join(record.path, "kept-directory", "link"))).toBe("../file"); - expect(git(record.path, "diff", "HEAD")).toBe(""); - }); - it.skipIf(process.platform === "win32")("keeps dangling untracked symlinks", () => { + it("refuses scratch that is tracked, not ignored, or outside the checkout", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); - symlinkSync("missing", join(record.path, "dangling")); - worktrees.release(record.id, { ...notes, keep: ["dangling"] }); - worktrees.restore(record.id); - expect(readlinkSync(join(record.path, "dangling"))).toBe("missing"); + writeFileSync(join(record.path, "new"), "source"); + mkdirSync(join(record.path, "cache")); + writeFileSync(join(record.path, "cache", "forced"), "tracked despite the ignore rule"); + git(record.path, "add", "-f", "cache/forced"); + expect(() => worktrees.release(record.id, ["file"])).toThrow("tracked files: file"); + expect(() => worktrees.release(record.id, ["cache"])).toThrow("tracked files: cache/forced"); + expect(() => worktrees.release(record.id, ["new"])).toThrow("ignored by Git: new"); + expect(() => worktrees.release(record.id, ["../outside"])).toThrow("relative path"); + expect(() => worktrees.release(record.id, [".git"])).toThrow("relative path"); + expect(readFileSync(join(record.path, "new"), "utf8")).toBe("source"); + expect(readFileSync(join(record.path, "cache", "forced"), "utf8")).toBe("tracked despite the ignore rule"); + expect(worktrees.status().records[0].phase).toBe("active"); }); - it.each(["independent repository", "another repository's worktree", "same repository's worktree"])( - "refuses to release a nested %s under scratch", - (kind) => { - const { root, repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - const nested = join(record.path, "cache", "nested"); - if (kind === "independent repository") repository(nested); - else if (kind === "same repository's worktree") git(repo, "worktree", "add", "--detach", nested); - else git(repository(join(root, "other")), "worktree", "add", "--detach", nested); - writeFileSync(join(nested, "file"), "uncommitted\n"); - expect(() => worktrees.release(record.id, { ...notes, scratch: ["cache"] })).toThrow("nested repository"); - expect(readFileSync(join(nested, "file"), "utf8")).toBe("uncommitted\n"); - expect(worktrees.status().records[0].phase).toBe("active"); - }, - ); - it("retains unique committed history independently of the released checkout", () => { - const { repo, worktrees } = fixture(); + it.skipIf(process.platform === "win32")("never follows symlinks out of the checkout", () => { + const { root, repo, worktrees } = fixture(); + const outside = join(root, "outside"); + mkdirSync(join(outside, "cache"), { recursive: true }); + writeFileSync(join(outside, "cache", "keep"), "outside"); const record = worktrees.create(repo, "work", 10_000_000); - writeFileSync(join(record.path, "file"), "unique committed\n"); - git(record.path, "add", "file"); - git(record.path, "commit", "-m", "local only"); - const unique = git(record.path, "rev-parse", "HEAD"); - worktrees.release(record.id, notes); - expect(existsSync(record.path)).toBe(false); - const released = worktrees.status(); - expect(released.records[0].phase).toBe("released"); - expect(released.retained).toBeGreaterThan(0); - const snapshot = JSON.parse(readFileSync(join(released.records[0].checkpoint!, "snapshot.json"), "utf8")); - expect(snapshot.notes).toBe(notes.notes); - expect(snapshot.checks).toEqual(notes.checks); - worktrees.restore(record.id); - expect(git(record.path, "rev-parse", "HEAD")).toBe(unique); + symlinkSync(outside, join(record.path, "linked")); + expect(() => worktrees.release(record.id, ["linked/cache"])).toThrow("reached through a symlink"); + mkdirSync(join(record.path, "cache")); + symlinkSync(outside, join(record.path, "cache", "link")); + symlinkSync(outside, join(record.path, ".env")); + worktrees.release(record.id, ["cache", ".env"]); + expect(existsSync(join(record.path, "cache"))).toBe(false); + expect(existsSync(join(record.path, ".env"))).toBe(false); + expect(readFileSync(join(outside, "cache", "keep"), "utf8")).toBe("outside"); }); - it("restores detached even after the saved branch moves", () => { - const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - git(record.path, "switch", "-c", "topic"); - writeFileSync(join(record.path, "file"), "topic\n"); - git(record.path, "commit", "-am", "topic"); - const unique = git(record.path, "rev-parse", "HEAD"); - worktrees.release(record.id, notes); - git(repo, "branch", "-f", "topic", "main"); - worktrees.restore(record.id); - expect(git(record.path, "rev-parse", "HEAD")).toBe(unique); - expect(() => git(record.path, "symbolic-ref", "-q", "HEAD")).toThrow(); + it.skipIf(process.platform === "win32")("counts dangling symlinks", () => { + const { root } = fixture(); + symlinkSync("missing", join(root, "dangling")); + expect(diskUsage(join(root, "dangling"))).toBeGreaterThan(0); }); - it("preserves explicitly valuable ignored and untracked files while removing disposable output", () => { + it("charges active reservations and the footprint released checkouts keep", () => { const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - writeFileSync(join(record.path, ".env"), "valuable private data"); - writeFileSync(join(record.path, "new-source"), "untracked source"); - mkdirSync(join(record.path, "cache")); - writeFileSync(join(record.path, "cache", "artifact"), "disposable"); - worktrees.release(record.id, { ...notes, keep: [".env", "new-source"], scratch: ["cache"] }); - worktrees.restore(record.id); - expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("valuable private data"); - expect(readFileSync(join(record.path, "new-source"), "utf8")).toBe("untracked source"); - expect(existsSync(join(record.path, "cache"))).toBe(false); + const reserve = 1_000_000; + worktrees.configure(reserve); + const record = worktrees.create(repo, "work", reserve); + expect(worktrees.status().allocated).toBe(reserve); + worktrees.release(record.id, []); + const retained = worktrees.status().allocated; + expect(retained).toBeGreaterThan(0); + expect(retained).toBeLessThan(reserve); + worktrees.create(repo, "other", reserve - retained); + expect(() => worktrees.restore(record.id)).toThrow("disk capacity exceeded"); + expect(worktrees.status().records[0].phase).toBe("released"); }); - - it("never discards unclassified files or tracked source marked scratch", () => { + it("drops the record once its checkout is removed", () => { const { repo, worktrees } = fixture(); const record = worktrees.create(repo, "work", 10_000_000); - writeFileSync(join(record.path, "new"), "source"); - expect(() => worktrees.release(record.id, notes)).toThrow("classify untracked/ignored"); - expect(readFileSync(join(record.path, "new"), "utf8")).toBe("source"); - expect(() => worktrees.release(record.id, { ...notes, scratch: ["file", "new"] })).toThrow( - "scratch includes tracked source", - ); - expect(() => worktrees.checkpoint(record.id, { ...notes, keep: ["../outside"] })).toThrow("relative path"); - expect(() => worktrees.checkpoint(record.id, { ...notes, keep: ["new"], scratch: ["new"] })).toThrow("overlap"); + expect(() => worktrees.release(record.id, ["missing"])).not.toThrow(); + git(repo, "worktree", "unlock", record.path); + git(repo, "worktree", "remove", record.path); + worktrees.release(record.id, []); + expect(worktrees.status().records).toEqual([]); + expect(() => worktrees.restore(record.id)).toThrow("unknown worktree"); + expect(worktrees.create(repo, "work", 10_000_000).phase).toBe("active"); }); it("serializes reservations across independent repositories and preserves failed allocations", () => { const { root, repo, home, worktrees } = fixture(); @@ -243,7 +136,7 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { rmSync(join(home, "lock"), { recursive: true }); expect(() => worktrees.create(second, "failed", 1_000_000, "nonexistent-ref")).toThrow(); expect(worktrees.status().records.find((record) => record.name === "failed")?.phase).toBe("creating"); - worktrees.release("failed", notes); + worktrees.release("failed", []); expect(worktrees.status().records.map((record) => record.name)).toEqual(["first"]); }); it("admits simultaneous creators atomically without oversubscribing disk", async () => { @@ -267,120 +160,29 @@ describe("recoverable worktrees", { timeout: 20_000 }, () => { expect(worktrees.status().records).toHaveLength(1); expect(worktrees.status().allocated).toBeLessThanOrEqual(15_000_000); }); - - it("refuses foreign checkouts, changed ownership, and current checkouts", () => { + it("refuses foreign checkouts and changed ownership", () => { const { repo, worktrees } = fixture(); mkdirSync(join(repo, ".worktrees", "foreign"), { recursive: true }); expect(() => worktrees.create(repo, "foreign", 10_000_000)).toThrow("refusing to adopt"); const record = worktrees.create(repo, "work", 10_000_000); - const cwd = process.cwd(); - try { - process.chdir(record.path); - expect(() => worktrees.release(record.id, notes)).toThrow("leave the checkout"); - } finally { - process.chdir(cwd); - } git(repo, "worktree", "unlock", record.path); git(repo, "worktree", "lock", "--reason", "another owner", record.path); - expect(() => worktrees.release(record.id, notes)).toThrow("ownership/lock changed"); - }); - it("restores local-only submodule history and both staged and unstaged submodule edits", () => { - const { root, repo, worktrees } = fixture(); - const globalConfig = join(root, "gitconfig"); - writeFileSync(globalConfig, "[init]\n\tdefaultBranch = master\n"); - vi.stubEnv("GIT_CONFIG_GLOBAL", globalConfig); - const upstream = repository(join(root, "upstream")); - git(repo, "-c", "protocol.file.allow=always", "submodule", "add", upstream, "module"); - git(repo, "commit", "-m", "add module"); - const record = worktrees.create(repo, "work", 20_000_000); - git(record.path, "-c", "protocol.file.allow=always", "submodule", "update", "--init"); - const module = join(record.path, "module"); - git(module, "switch", "-c", "master"); - git(module, "config", "user.name", "Fixture"); - git(module, "config", "user.email", "fixture@localhost"); - writeFileSync(join(module, "file"), "local commit\n"); - git(module, "add", "file"); - git(module, "commit", "-m", "local-only module commit"); - const unique = git(module, "rev-parse", "HEAD"); - writeFileSync(join(module, "file"), "staged module\n"); - git(module, "add", "file"); - writeFileSync(join(module, "file"), "unstaged module\n"); - worktrees.release(record.id, notes); - worktrees.restore(record.id); - expect(git(module, "rev-parse", "HEAD")).toBe(unique); - expect(git(module, "symbolic-ref", "HEAD")).toBe("refs/heads/master"); - expect(git(module, "config", "user.name")).toBe("Fixture"); - expect(git(module, "remote", "get-url", "origin")).toBe(upstream); - expect(git(module, "show", ":file")).toBe("staged module"); - expect(readFileSync(join(module, "file"), "utf8")).toBe("unstaged module\n"); - }); - it.each(["file", "directory"])("roundtrips a staged submodule replaced by a %s", (kind) => { - const { root, repo, worktrees } = fixture(); - const upstream = repository(join(root, "upstream")); - git(repo, "-c", "protocol.file.allow=always", "submodule", "add", upstream, "module"); - git(repo, "commit", "-m", "add module"); - const record = worktrees.create(repo, "work", 20_000_000); - git(record.path, "rm", "module"); - const path = kind === "file" ? "module" : "module/child"; - if (kind === "directory") mkdirSync(join(record.path, "module")); - writeFileSync(join(record.path, path), "replacement\n"); - git(record.path, "add", path); - const status = git(record.path, "status", "--porcelain=v1"); - worktrees.release(record.id, notes); - worktrees.restore(record.id); - expect(git(record.path, "status", "--porcelain=v1")).toBe(status); - expect(git(record.path, "show", `:${path}`)).toBe("replacement"); + expect(() => worktrees.release(record.id, [])).toThrow("ownership/lock changed"); + expect(() => worktrees.restore(record.id)).toThrow("ownership/lock changed"); }); - it("wires capacity, reservation, and checkpoint notes through the command line", () => { + it("wires capacity, reservation, and scratch through the command line", () => { const { repo, home } = fixture(); vi.stubEnv("QUEST_WORKTREE_HOME", home); expect(run("worktree", "create", "cli", "--root", repo).code).toBe(2); expect(run("worktree", "capacity", "0").code).toBe(2); const created = run("--root", repo, "worktree", "create", "cli", "--reserve", "10000000"); expect(created.code).toBe(0); - expect(JSON.parse(created.stdout).name).toBe("cli"); - expect( - run("worktree", "checkpoint", "cli", "--notes", "Awaiting review", "--check", "tests not run").code, - ).toBe(0); + const { path } = JSON.parse(created.stdout); + mkdirSync(join(path, "cache")); + expect(run("worktree", "release", "cli", "--scratch", "cache").code).toBe(0); + expect(existsSync(join(path, "cache"))).toBe(false); const status = run("worktree", "status"); expect(status.code).toBe(0); - const checkpoint = JSON.parse(status.stdout).records[0].checkpoint; - expect(JSON.parse(readFileSync(join(checkpoint, "snapshot.json"), "utf8")).checks).toEqual(["tests not run"]); - }); - - it("finishes an interrupted release without checkpointing the partial checkout", () => { - const { repo, home, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - worktrees.checkpoint(record.id, notes); - setPhase(home, "releasing"); - const original = readFileSync(join(record.path, "file"), "utf8"); // CRLF under Windows autocrlf. - rmSync(join(record.path, "file")); // Removal stopped partway. - expect(worktrees.status().allocated).toBeGreaterThanOrEqual(record.reserve); - worktrees.release(record.id, notes); - expect(worktrees.status().records[0].phase).toBe("released"); - expect(existsSync(record.path)).toBe(false); - worktrees.restore(record.id); - expect(readFileSync(join(record.path, "file"), "utf8")).toBe(original); - }); - it("retries or abandons an interrupted restore from the intact checkpoint", () => { - const { repo, home, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); - writeFileSync(join(record.path, "file"), "saved\n"); - worktrees.release(record.id, notes); - const partial = () => { - git(repo, "worktree", "add", "--detach", "--lock", "--reason", `quest worktree ${record.id}`, record.path); - writeFileSync(join(record.path, "file"), "partial\n"); - setPhase(home, "restoring"); - }; - partial(); - expect(() => worktrees.checkpoint(record.id, notes)).toThrow("cannot checkpoint a restoring"); - worktrees.restore(record.id); - expect(readFileSync(join(record.path, "file"), "utf8")).toBe("saved\n"); - worktrees.release(record.id, notes); - partial(); - expect(worktrees.release(record.id, notes).phase).toBe("released"); - expect(existsSync(record.path)).toBe(false); - worktrees.restore(record.id); - expect(readFileSync(join(record.path, "file"), "utf8")).toBe("saved\n"); + expect(JSON.parse(status.stdout).records[0].phase).toBe("released"); }); }); From d00b93d93ec9bffbb45a4019eefc5ff6f373284e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:36:02 -0700 Subject: [PATCH 11/17] quest: give worktree process ownership its own backend kixelated/quest#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 --- quest/m0/local-gate.md | 2 +- quest/m0/worktree.md | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index f3396aa4..1977ccd7 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -29,5 +29,5 @@ remain useful without multiplying full-machine builds and test stacks. ## Related -- [Worktree process ownership](/quest/m0/worktree.md) - reuses this ownership backend for checkout commands +- [Worktree process ownership](/quest/m0/worktree.md) - owns checkout command descendants, with its own process-ownership backend - [Own-PC runner](/quest/m0/kix/runner.md) - reuse local command supervision diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index c4e82bb1..68803885 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -22,11 +22,15 @@ work, stealing a live lock, or deleting scratch a running process still uses. checkpoint-and-remove design kept finding data-loss edge cases, while the disk cost is scratch (2026-10-15 on this repository: 1.6 MB fresh source, 376 MB built). -- Decided (user): reuse the Linux systemd scope ownership backend from - [kixelated/quest#128](https://github.com/kixelated/quest/pull/128) rather than - adding another one; start once that PR merges. Process groups and PID files - cannot prove detached descendants stopped after a supervisor crash, so hosts - without a supported backend fail explicitly instead of guessing. +- Decided 2026-10-21 (user): this quest brings its own process-ownership + backend; `quest gate` admits on CPU load and memory only and owns no + descendants. The Linux systemd scope backend (scopes, slot handshake, + `cgroup.kill` inode pinning, prerequisite probes, opt-in systemd tests) was + dropped from the gate and is restorable from commit 5684307 in + [kixelated/quest#128](https://github.com/kixelated/quest/pull/128). Process + groups and PID files cannot prove detached descendants stopped after a + supervisor crash, so hosts without a supported backend fail explicitly + instead of guessing. - Own foreground child processes and their descendants until they stop; only then prune scratch and release the reservation. Record ownership so a crash can be reconciled, including a stale registry lock whose owner has exited. @@ -43,5 +47,5 @@ work, stealing a live lock, or deleting scratch a running process still uses. ## Related -- [Expensive command gate](/quest/m0/local-gate.md) - its Linux ownership backend is reused here +- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU and memory admission - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - native agents and workflow adoption From 132dc5e3d01d4400520e35efdc83dc336f5d32bf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:36:02 -0700 Subject: [PATCH 12/17] fix(worktree): refuse .git aliases, scope free space per filesystem, 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 --- src/cli/worktree.ts | 43 ++++++++++++++++++++++++++++++++++--------- test/worktree.test.ts | 17 +++++++++++++++-- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 0be0ff5e..f7d91123 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -11,6 +11,7 @@ import { realpathSync, renameSync, rmSync, + statSync, statfsSync, writeFileSync, } from "node:fs"; @@ -53,6 +54,15 @@ function entry(path: string): Stats | undefined { throw error; } } +/** The filesystem holding a path, or its nearest existing ancestor. */ +function device(path: string): number { + try { + return statSync(path).dev; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(path) === path) throw error; + return device(dirname(path)); + } +} /** One spelling per path: Git prints `C:/...`, Node `C:\...`, and Windows may use 8.3 names. */ function canonical(path: string): string { return existsSync(path) ? realpathSync.native(path) : resolve(path); @@ -72,7 +82,7 @@ function childPath(path: string): string { if ( !path || isAbsolute(path) || - path.split(/[\\/]/).some((part) => part === ".." || part === ".git") || + path.split(/[\\/]/).some((part) => part === ".." || part.toLowerCase() === ".git") || resolve("/", path) === "/" ) { throw new Error(`expected a relative path without .. or .git: ${path}`); @@ -156,25 +166,33 @@ export class Worktrees { if (!record) throw new Error(`unknown worktree: ${id}`); return record; } - /** Bytes on disk, plus the growth active reservations still expect. Released checkouts keep only what remains. */ - private usage(registry: Registry): { used: number; growth: number } { + /** + * Bytes on disk, plus the growth active reservations still expect. Released checkouts keep only + * what remains. `local` is the expected growth on the filesystem holding `parent`. + */ + private usage(registry: Registry, parent?: string): { used: number; growth: number; local: number } { + const target = parent === undefined ? undefined : device(parent); let used = 0; let growth = 0; + let local = 0; for (const record of registry.records) { const bytes = diskUsage(record.path); used += bytes; - if (record.phase !== "released") growth += Math.max(0, record.reserve - bytes); + if (record.phase === "released") continue; + const expected = Math.max(0, record.reserve - bytes); + growth += expected; + if (target !== undefined && device(record.path) === target) local += expected; } - return { used, growth }; + return { used, growth, local }; } private admit(registry: Registry, reserve: number, parent: string): void { - const { used, growth } = this.usage(registry); + const { used, growth, local } = this.usage(registry, parent); if (used + growth + reserve > registry.capacity) throw new Error( `disk capacity exceeded: ${used + growth} allocated + ${reserve} requested > ${registry.capacity}`, ); const fs = statfsSync(parent); - if (Number(fs.bavail) * Number(fs.bsize) < growth + reserve) + if (Number(fs.bavail) * Number(fs.bsize) < local + reserve) throw new Error("insufficient free space after accounting for concurrent build reservations"); } /** Capacity persists; reducing it below existing allocations is rejected. */ @@ -258,6 +276,13 @@ export class Worktrees { ); const root = canonical(record.path); const paths = scratch.map(childPath).filter((path) => present(root, path)); // Reruns skip pruned paths. + const admin = entry(join(root, ".git")); + for (const path of paths) { + // Case folding and other platform aliases can still name the checkout's .git entry. + const stat = entry(join(root, path)); + if (admin && stat?.dev === admin.dev && stat.ino === admin.ino) + throw new Error(`scratch path is the checkout's .git entry: ${path}`); + } if (paths.length) { const tracked = git(root, "--literal-pathspecs", "ls-files", "-z", "--", ...paths) .split("\0") @@ -267,8 +292,8 @@ export class Worktrees { const matched = ignored(root, paths); const unignored = paths.filter((path) => !matched.has(path)); if (unignored.length) throw new Error(`scratch must be ignored by Git: ${unignored.join(", ")}`); - // Removal unlinks symlinks rather than following them. - for (const path of paths) rmSync(join(root, path), { recursive: true }); + // Removal unlinks symlinks rather than following them; force skips paths an overlapping parent removed. + for (const path of paths) rmSync(join(root, path), { recursive: true, force: true }); } record.phase = "released"; return record; diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 5242c9cb..db930f7c 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,5 +1,14 @@ import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -52,7 +61,7 @@ describe("worktrees", { timeout: 20_000 }, () => { mkdirSync(join(record.path, "cache", "nested"), { recursive: true }); writeFileSync(join(record.path, "cache", "nested", "artifact"), "disposable"); const status = git(record.path, "status", "--porcelain=v1"); - expect(worktrees.release(record.id, ["cache/", "missing"]).phase).toBe("released"); + expect(worktrees.release(record.id, ["cache/", "cache/nested", "missing"]).phase).toBe("released"); expect(existsSync(join(record.path, "cache"))).toBe(false); expect(git(record.path, "status", "--porcelain=v1")).toBe(status); expect(git(record.path, "rev-parse", "HEAD")).toBe(head); @@ -74,6 +83,10 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(() => worktrees.release(record.id, ["new"])).toThrow("ignored by Git: new"); expect(() => worktrees.release(record.id, ["../outside"])).toThrow("relative path"); expect(() => worktrees.release(record.id, [".git"])).toThrow("relative path"); + expect(() => worktrees.release(record.id, [".GIT"])).toThrow("relative path"); + // Any other alias of the .git entry, here a hard link that an ignore rule matches. + linkSync(join(record.path, ".git"), join(record.path, ".env")); + expect(() => worktrees.release(record.id, [".env"])).toThrow(".git entry: .env"); expect(readFileSync(join(record.path, "new"), "utf8")).toBe("source"); expect(readFileSync(join(record.path, "cache", "forced"), "utf8")).toBe("tracked despite the ignore rule"); expect(worktrees.status().records[0].phase).toBe("active"); From 419c98ebb3b73af7ec95f9baac6e240546d690e9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:45:10 -0700 Subject: [PATCH 13/17] fix(worktree): refuse a checkout root replaced by another checkout 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 --- src/cli/worktree.ts | 22 +++++++++++++++++++--- test/worktree.test.ts | 10 ++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index f7d91123..db5075a3 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -115,6 +115,20 @@ function ignored(root: string, paths: string[]): Set { } } +/** + * Git's lock reason for the checkout actually at `path`, read through its own `.git` file and + * administrative directory, so a root replaced by another checkout reports that checkout's lock. + */ +function lockReason(path: string): string | undefined { + try { + const admin = /^gitdir: (.+)$/m.exec(readFileSync(join(path, ".git"), "utf8"))?.[1]; + return admin && readFileSync(resolve(path, admin.trim(), "locked"), "utf8").replace(/\r?\n$/, ""); + } catch (error) { + if (["ENOENT", "ENOTDIR", "EISDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return undefined; + throw error; + } +} + /** Allocated blocks, including ignored build output; symlinks are never traversed. */ export function diskUsage(path: string): number { const stat = entry(path); @@ -250,9 +264,11 @@ export class Worktrees { /** Refuses unless this record exclusively owns the checkout; false once Git and the disk both lack it. */ private owned(record: WorktreeRecord): boolean { const path = canonical(record.path); - const entry = checkouts(record.repository).find((entry) => entry.path === path); - if (!entry && !existsSync(path)) return false; - if (entry?.locked !== `quest worktree ${record.id}`) + const registered = checkouts(record.repository).find((entry) => entry.path === path); + if (!registered && !existsSync(path)) return false; + // Canonical paths follow a replaced root to its target, so ask the checkout on disk for its lock. + const lock = existsSync(path) ? lockReason(record.path) : registered?.locked; + if (lock !== `quest worktree ${record.id}`) throw new Error( "checkout ownership/lock changed; use the owning tool, never adopt or remove a shared checkout", ); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index db930f7c..ac5633b5 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -183,6 +183,16 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(() => worktrees.release(record.id, [])).toThrow("ownership/lock changed"); expect(() => worktrees.restore(record.id)).toThrow("ownership/lock changed"); }); + it.skipIf(process.platform === "win32")("refuses a root replaced by a link to another checkout", () => { + const { repo, worktrees } = fixture(); + const replaced = worktrees.create(repo, "first", 10_000_000); + const other = worktrees.create(repo, "second", 10_000_000); + mkdirSync(join(other.path, "cache")); + rmSync(replaced.path, { recursive: true }); + symlinkSync(other.path, replaced.path); + expect(() => worktrees.release(replaced.id, ["cache"])).toThrow("ownership/lock changed"); + expect(existsSync(join(other.path, "cache"))).toBe(true); + }); it("wires capacity, reservation, and scratch through the command line", () => { const { repo, home } = fixture(); vi.stubEnv("QUEST_WORKTREE_HOME", home); From 42ba20d66c58c0e57fab9657167d49843ff9695c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:56:11 -0700 Subject: [PATCH 14/17] fix(worktree): charge allocated blocks, not sparse-file length 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 --- src/cli/worktree.ts | 3 ++- test/worktree.test.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index db5075a3..d2f68ff4 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -133,7 +133,8 @@ function lockReason(path: string): string | undefined { export function diskUsage(path: string): number { const stat = entry(path); if (!stat) return 0; - let bytes = Math.max(stat.blocks * 512, stat.size); + // Allocated blocks, not logical length, so sparse files are not overcharged. + let bytes = Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size; if (stat.isDirectory()) for (const child of readdirSync(path)) bytes += diskUsage(join(path, child)); return bytes; } diff --git a/test/worktree.test.ts b/test/worktree.test.ts index ac5633b5..dc6cd95f 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -2,11 +2,13 @@ import { execFileSync, spawn } from "node:child_process"; import { existsSync, linkSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, + truncateSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -107,10 +109,15 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(existsSync(join(record.path, ".env"))).toBe(false); expect(readFileSync(join(outside, "cache", "keep"), "utf8")).toBe("outside"); }); - it.skipIf(process.platform === "win32")("counts dangling symlinks", () => { + it.skipIf(process.platform === "win32")("charges allocated blocks of dangling symlinks and sparse files", () => { const { root } = fixture(); - symlinkSync("missing", join(root, "dangling")); - expect(diskUsage(join(root, "dangling"))).toBeGreaterThan(0); + const dangling = join(root, "dangling"); + symlinkSync("missing", dangling); + expect(diskUsage(dangling)).toBe(lstatSync(dangling).blocks * 512); + const sparse = join(root, "sparse"); + writeFileSync(sparse, ""); + truncateSync(sparse, 1 << 30); + expect(diskUsage(sparse)).toBeLessThan(1 << 20); }); it("charges active reservations and the footprint released checkouts keep", () => { const { repo, worktrees } = fixture(); From 862a6773b90c0790ce914cc496fb9f34171083ad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:08:13 -0700 Subject: [PATCH 15/17] fix(worktree): charge file size on Windows, which reports no blocks Co-Authored-By: Claude Opus 5.5 --- src/cli/worktree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index d2f68ff4..8b782e86 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -133,8 +133,8 @@ function lockReason(path: string): string | undefined { export function diskUsage(path: string): number { const stat = entry(path); if (!stat) return 0; - // Allocated blocks, not logical length, so sparse files are not overcharged. - let bytes = Number.isFinite(stat.blocks) ? stat.blocks * 512 : stat.size; + // Allocated blocks, not logical length, so sparse files are not overcharged. Windows reports no blocks. + let bytes = process.platform === "win32" ? stat.size : stat.blocks * 512; if (stat.isDirectory()) for (const child of readdirSync(path)) bytes += diskUsage(join(path, child)); return bytes; } From dec5ec02049467ffcedbd18b82a7b762d0eac801 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:19:44 -0700 Subject: [PATCH 16/17] fix(worktree): refuse scratch that contains another Git worktree 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 --- README.md | 5 +++-- quest/m0/worktree.md | 5 +++-- src/cli/worktree.ts | 24 +++++++++++++++++++++++- test/worktree.test.ts | 15 +++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ac2a4dd8..38cb44ae 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,9 @@ quest worktree restore example Creation prints JSON with the ID and the detached, locked checkout path under `.worktrees/`. Release deletes only its `--scratch` paths, and refuses one that -Git does not ignore, that holds tracked files, or that is reached through a -symbolic link; links inside scratch are removed, never followed. Missing paths +Git does not ignore, that holds tracked files or another Git worktree, or that +is reached through a symbolic link; links inside scratch are removed, never +followed. Missing paths are skipped, so release can be rerun. The checkout, its Git state, and untracked files stay in place, and `status` charges what remains. Restore reserves the checkout's disk again; then reinstall or rebuild its scratch, for diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index 68803885..554ca376 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -16,8 +16,9 @@ work, stealing a live lock, or deleting scratch a running process still uses. checkouts" documents it as experimental and tells users to stop all work before releasing. - Decided (user, 2026-10-21): prune in place. Release deletes only declared - scratch that Git ignores, that holds no tracked files, and that is not reached - through a symlink; it never removes the checkout or its Git state. + scratch that Git ignores, that holds no tracked files or other Git worktree, + and that is not reached through a symlink; it never removes the checkout or + its Git state. Checkout-independent recovery is out of scope: four review rounds of the checkpoint-and-remove design kept finding data-loss edge cases, while the disk cost is scratch (2026-10-15 on this repository: 1.6 MB fresh source, diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 8b782e86..afe90a1e 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -16,7 +16,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; export interface WorktreeRecord { id: string; @@ -115,6 +115,17 @@ function ignored(root: string, paths: string[]): Set { } } +/** The first linked worktree of any repository, marked by a `.git` file, at or under `path`; symlinks are never followed. */ +function linkedWorktree(path: string): string | undefined { + if (!entry(path)?.isDirectory()) return undefined; + if (entry(join(path, ".git"))?.isFile()) return path; + for (const child of readdirSync(path)) { + const found = child === ".git" ? undefined : linkedWorktree(join(path, child)); + if (found) return found; + } + return undefined; +} + /** * Git's lock reason for the checkout actually at `path`, read through its own `.git` file and * administrative directory, so a root replaced by another checkout reports that checkout's lock. @@ -309,6 +320,17 @@ export class Worktrees { const matched = ignored(root, paths); const unignored = paths.filter((path) => !matched.has(path)); if (unignored.length) throw new Error(`scratch must be ignored by Git: ${unignored.join(", ")}`); + // Another checkout, such as one created inside this one, is work rather than scratch. + const registered = checkouts(record.repository) + .map((checkout) => checkout.path) + .filter((path) => path !== root); + for (const path of paths) { + const target = canonical(join(root, path)); + const nested = + registered.find((checkout) => checkout === target || checkout.startsWith(target + sep)) ?? + linkedWorktree(join(root, path)); + if (nested) throw new Error(`scratch contains a Git worktree: ${path} holds ${nested}`); + } // Removal unlinks symlinks rather than following them; force skips paths an overlapping parent removed. for (const path of paths) rmSync(join(root, path), { recursive: true, force: true }); } diff --git a/test/worktree.test.ts b/test/worktree.test.ts index dc6cd95f..05dbbccf 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -190,6 +190,21 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(() => worktrees.release(record.id, [])).toThrow("ownership/lock changed"); expect(() => worktrees.restore(record.id)).toThrow("ownership/lock changed"); }); + it("refuses scratch that contains another Git worktree", () => { + const { root, repo, worktrees } = fixture(); + const outer = worktrees.create(repo, "outer", 10_000_000); + // Created from inside the outer checkout, so it lands in the outer checkout's ignored .worktrees/. + const inner = worktrees.create(outer.path, "inner", 10_000_000); + expect(inner.path).toBe(join(outer.path, ".worktrees", "inner")); + expect(() => worktrees.release(outer.id, [".worktrees"])).toThrow("scratch contains a Git worktree"); + expect(existsSync(join(inner.path, "file"))).toBe(true); + // A linked worktree of an unrelated repository, such as an agent's checkout under an ignored directory. + const foreign = repository(join(root, "foreign")); + git(foreign, "worktree", "add", "--detach", join(outer.path, "cache", "agent")); + expect(() => worktrees.release(outer.id, ["cache"])).toThrow("scratch contains a Git worktree: cache"); + expect(existsSync(join(outer.path, "cache", "agent", "file"))).toBe(true); + expect(worktrees.status().records.map((record) => record.phase)).toEqual(["active", "active"]); + }); it.skipIf(process.platform === "win32")("refuses a root replaced by a link to another checkout", () => { const { repo, worktrees } = fixture(); const replaced = worktrees.create(repo, "first", 10_000_000); From 61d9e7223f16edbf8c4b401d37e44a5a416ef280 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:31:01 -0700 Subject: [PATCH 17/17] fix(worktree): match tracked scratch case-insensitively and count hard 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 --- src/cli/worktree.ts | 15 +++++++++++---- test/worktree.test.ts | 10 ++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index afe90a1e..7b669f2d 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -140,13 +140,18 @@ function lockReason(path: string): string | undefined { } } -/** Allocated blocks, including ignored build output; symlinks are never traversed. */ -export function diskUsage(path: string): number { +/** Allocated blocks, including ignored build output; symlinks are never traversed and hard links count once. */ +export function diskUsage(path: string, seen = new Set()): number { const stat = entry(path); if (!stat) return 0; + if (stat.nlink > 1 && !stat.isDirectory()) { + const inode = `${stat.dev}:${stat.ino}`; + if (seen.has(inode)) return 0; + seen.add(inode); + } // Allocated blocks, not logical length, so sparse files are not overcharged. Windows reports no blocks. let bytes = process.platform === "win32" ? stat.size : stat.blocks * 512; - if (stat.isDirectory()) for (const child of readdirSync(path)) bytes += diskUsage(join(path, child)); + if (stat.isDirectory()) for (const child of readdirSync(path)) bytes += diskUsage(join(path, child), seen); return bytes; } @@ -312,7 +317,9 @@ export class Worktrees { throw new Error(`scratch path is the checkout's .git entry: ${path}`); } if (paths.length) { - const tracked = git(root, "--literal-pathspecs", "ls-files", "-z", "--", ...paths) + // icase, because a case-insensitive filesystem resolves `CACHE` to a tracked `cache`. + const pathspecs = paths.map((path) => `:(literal,icase)${path}`); + const tracked = git(root, "ls-files", "-z", "--", ...pathspecs) .split("\0") .filter(Boolean); if (tracked.length) diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 05dbbccf..77c7727e 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,5 +1,6 @@ import { execFileSync, spawn } from "node:child_process"; import { + appendFileSync, existsSync, linkSync, lstatSync, @@ -82,6 +83,10 @@ describe("worktrees", { timeout: 20_000 }, () => { git(record.path, "add", "-f", "cache/forced"); expect(() => worktrees.release(record.id, ["file"])).toThrow("tracked files: file"); expect(() => worktrees.release(record.id, ["cache"])).toThrow("tracked files: cache/forced"); + // Another spelling, which a case-insensitive filesystem resolves to the tracked directory. + mkdirSync(join(record.path, "CACHE"), { recursive: true }); + appendFileSync(join(repo, ".git", "info", "exclude"), "/CACHE/\n"); + expect(() => worktrees.release(record.id, ["CACHE"])).toThrow("tracked files: cache/forced"); expect(() => worktrees.release(record.id, ["new"])).toThrow("ignored by Git: new"); expect(() => worktrees.release(record.id, ["../outside"])).toThrow("relative path"); expect(() => worktrees.release(record.id, [".git"])).toThrow("relative path"); @@ -118,6 +123,11 @@ describe("worktrees", { timeout: 20_000 }, () => { writeFileSync(sparse, ""); truncateSync(sparse, 1 << 30); expect(diskUsage(sparse)).toBeLessThan(1 << 20); + const linked = join(root, "linked"); + mkdirSync(linked); + writeFileSync(join(linked, "a"), Buffer.alloc(1 << 16, 1)); + linkSync(join(linked, "a"), join(linked, "b")); + expect(diskUsage(linked)).toBe((lstatSync(linked).blocks + lstatSync(join(linked, "a")).blocks) * 512); }); it("charges active reservations and the footprint released checkouts keep", () => { const { repo, worktrees } = fixture();