From e335a1d3edb994daa24e36c61dc34c7c92a8f99a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 22:59:18 -0800 Subject: [PATCH 01/11] fix(worktree): initialize submodules and preserve their local work Co-Authored-By: GPT-6.1 Sol --- README.md | 8 ++- src/cli/main.ts | 6 +-- src/cli/worktree-hook.ts | 8 +-- src/cli/worktree.ts | 77 ++++++++++++++++++++++++++-- test/worktree.test.ts | 105 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 188 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 31c9c53c..675db6c7 100644 --- a/README.md +++ b/README.md @@ -1200,7 +1200,7 @@ Once a checkout is finished, remove it and its record: quest worktree remove example ``` -`remove` never forces. It refuses while supervised runs are live, and refuses +`remove` checks files and commits recursively before deleting a checkout. It refuses while supervised runs are live, and refuses modified or untracked files and any commit, at `HEAD` or under the checkout's own refs such as `refs/bisect/`, that no branch or remote-tracking ref reaches. A squash-merged change leaves its commits unreached, so they count as @@ -1209,6 +1209,12 @@ PR whose head is exactly that commit; other commits need no forge or token. It then prunes the scratch and refuses ignored files that remain, leaving the checkout released, before `git worktree remove` deletes it. A checkout already removed with `git worktree remove` only drops its record, as with `release`. +New checkouts initialize their submodules recursively before returning. Restoring +one preserves its existing submodule pins and local work. Removal also checks +submodules' ignored files and every module store, including deinitialized ones; +commits at their HEAD or refs must be reachable from a remote-tracking ref, +since module stores and their local branches are deleted with the checkout. +Only after those checks does removal use the force Git requires for submodules. 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`). The registry lock of a process that exited on diff --git a/src/cli/main.ts b/src/cli/main.ts index e616d616..3283e411 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -784,10 +784,10 @@ export function main(argv: string[], out: Output): number | Promise { worktree .command("remove ") .description( - "Delete a finished checkout and drop its record, never forcing. Refuses while supervised runs are live, " + + "Delete a finished checkout and drop its record after recursive safety checks. Refuses while supervised runs are live, " + "and refuses modified, untracked, or ignored files beyond the declared scratch, and commits no branch " + "or remote-tracking ref reaches, unless the GitHub upstream that holds origin's claims merged a PR whose head is exactly " + - "that commit. Prunes the scratch first, so a refusal over ignored files leaves the checkout released.", + "that commit. Submodules must have no changed or ignored files and no commits their remote refs do not reach, including in deinitialized module stores. Prunes the scratch first, so a refusal over ignored files leaves the checkout released.", ) .action((id: string) => settled(state().remove(id, merged))); worktree @@ -812,7 +812,7 @@ export function main(argv: string[], out: Output): number | Promise { ".\n\n" + "WorktreeCreate checks out the remote's default branch as last fetched (origin/HEAD), as Claude does by " + "default, or the session's commit without one, as a quest worktree under the main checkout's .worktrees/, " + - "and prints its path. Its scratch is the main checkout's top-level ignored entries, except " + + "initializes its submodules recursively, and prints its path. Its scratch is the main checkout's top-level ignored entries, except " + ".worktrees/, .claude/, and entries holding a nested repository; its reserve is the main checkout's disk " + "usage without .git and those checkouts. An existing checkout of that name in the repository is " + "restored instead. WorktreeRemove removes the checkout as `remove` does, so Claude keeps one holding " + diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 189e7c9e..8b7b1042 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -12,6 +12,7 @@ import { canonical, derivedDefaults, mainCheckout, + superprojectCheckout, } from "./worktree"; /** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ @@ -61,7 +62,7 @@ function git(path: string, ...args: string[]): string { /** The top of the checkout holding `cwd`, or undefined outside any. */ function toplevel(cwd: string): string | undefined { try { - return canonical(git(cwd, "rev-parse", "--show-toplevel")); + return superprojectCheckout(cwd); } catch { return undefined; } @@ -101,11 +102,12 @@ export async function claudeHook(input: unknown, worktrees: Worktrees, merged: M switch (hook.hook_event_name) { case "WorktreeCreate": { const name = field(hook, "name"); - const main = mainCheckout(hook.cwd); + const cwd = superprojectCheckout(hook.cwd); + const main = mainCheckout(cwd); const existing = await waiting(() => worktrees.registered(join(main, ".worktrees", name))); if (existing?.name === name) return `${(await waiting(() => worktrees.restore(existing.id))).path}\n`; const { reserve, scratch } = derivedDefaults(main); - const ref = base(hook.cwd); + const ref = base(cwd); return `${(await waiting(() => worktrees.create(main, name, reserve, ref, scratch))).path}\n`; } case "WorktreeRemove": { diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 8c877995..22010c14 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -174,14 +174,63 @@ function interrupted(record: WorktreeRecord): void { /** Git status entries, plus ignored paths (collapsed to their directories) when `ignored` is set. */ function changes(root: string, ignored: boolean): string[] { - const args = ["status", "--porcelain=v1", "-z", "--untracked-files=normal", ...(ignored ? ["--ignored"] : [])]; + const args = [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=normal", + "--ignore-submodules=none", + ...(ignored ? ["--ignored"] : []), + ]; return git(root, ...args) .split("\0") .filter(Boolean); } +/** Initialized submodule checkouts, including nested ones. NULs preserve spaces and newlines. */ +function submodules(root: string): string[] { + return git(root, "submodule", "foreach", "--quiet", "--recursive", 'printf "%s\\0" "$toplevel/$sm_path"') + .split("\0") + .filter(Boolean); +} + +/** Module stores survive deinit; removing this worktree deletes their refs too. */ +function moduleStores(path: string): string[] { + if (!entry(path)?.isDirectory()) return []; + if (entry(join(path, "HEAD")) && entry(join(path, "config"))) return [path, ...moduleStores(join(path, "modules"))]; + return readdirSync(path).flatMap((name) => moduleStores(join(path, name))); +} + function clean(root: string): void { const found = changes(root, false); if (found.length) throw new Error(`checkout has modified or untracked files: ${found.slice(0, 20).join(", ")}`); + for (const path of submodules(root)) { + const left = changes(path, true); + if (left.length) + throw new Error( + `submodule ${path} has modified, untracked, or ignored files: ${left.slice(0, 20).join(", ")}`, + ); + } + const stores = new Set([ + ...moduleStores(git(root, "rev-parse", "--path-format=absolute", "--git-path", "modules").trim()), + ...submodules(root).map((path) => git(path, "rev-parse", "--absolute-git-dir").trim()), + ]); + for (const store of stores) { + // Local branches and tags in a module store disappear with the worktree. Only remote refs preserve them. + const commit = git( + root, + "--git-dir", + store, + "--work-tree", + root, + "rev-list", + "-n1", + "HEAD", + "--all", + "--not", + "--remotes", + ).trim(); + if (commit) throw new Error(`submodule ${store} has commits no remote-tracking ref reaches: ${commit}`); + } } /** @@ -228,9 +277,20 @@ function repositoryWithin(path: string): string | undefined { return undefined; } +/** The enclosing checkout, climbing out of initialized submodules. */ +export function superprojectCheckout(path: string): string { + path = git(path, "rev-parse", "--show-toplevel").trim(); + let parent = git(path, "rev-parse", "--show-superproject-working-tree").trim(); + while (parent) { + path = parent; + parent = git(path, "rev-parse", "--show-superproject-working-tree").trim(); + } + return canonical(path); +} + /** The repository's main checkout, which every linked worktree of it shares, from any path inside one. */ export function mainCheckout(path: string): string { - const [main] = git(path, "worktree", "list", "--porcelain", "-z").split("\0\0"); + const [main] = git(superprojectCheckout(path), "worktree", "list", "--porcelain", "-z").split("\0\0"); const lines = main?.split("\0") ?? []; const found = lines.find((line) => line.startsWith("worktree "))?.slice(9); if (!found || lines.includes("bare")) throw new Error(`repository has no main checkout: ${path}`); @@ -505,6 +565,7 @@ export class Worktrees { path, ref, ); + git(path, "submodule", "update", "--init", "--recursive"); record.phase = "active"; return record; } catch (error) { @@ -585,7 +646,7 @@ export class Worktrees { }); } /** - * Deletes a finished checkout and drops its record, never forcing. Refuses while supervised runs + * Deletes a finished checkout and drops its record after checking files and commits recursively. Refuses while supervised runs * are live, and refuses a checkout holding modified, untracked, or ignored files beyond its * declared scratch, or a commit no branch or remote-tracking ref reaches. Such a commit counts as * preserved only when `merged` reports a merged change whose head is exactly that commit. @@ -628,7 +689,15 @@ export class Worktrees { const reason = `quest worktree ${record.id}`; git(record.repository, "worktree", "unlock", record.path); try { - git(record.repository, "worktree", "remove", record.path); + // Git requires --force for initialized submodules. The recursive checks above + // cover their files and every module store, including deinitialized modules. + git( + record.repository, + "worktree", + "remove", + ...(submodules(root).length ? ["--force"] : []), + record.path, + ); } catch (error) { try { git(record.repository, "worktree", "lock", "--reason", reason, record.path); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 99a3ee13..745a9121 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -19,7 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type Owner, Retained, type Scopes, currentOwner, kill, killable } from "../src/cli/supervise"; import { GitHub, mergedHead } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { Worktrees, canonical, derivedDefaults, diskUsage } from "../src/cli/worktree"; +import { Worktrees, canonical, derivedDefaults, diskUsage, mainCheckout } from "../src/cli/worktree"; import { claudeHook } from "../src/cli/worktree-hook"; import { initRepository, run, template } from "./fixture"; @@ -289,14 +289,109 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("secret"); expect(worktrees.status().records[0].phase).toBe("released"); rmSync(join(record.path, ".env")); - // Git refuses a checkout with submodules; the checkout keeps its lock and record. + // A clean submodule whose commits its remote has does not prevent removal. const sub = repository(join(root, "sub")); git(record.path, "-c", "protocol.file.allow=always", "submodule", "add", "--quiet", sub, "sub"); git(record.path, "commit", "-m", "submodule"); git(record.path, "update-ref", "refs/remotes/origin/work", "HEAD"); - await expect(worktrees.remove(record.id)).rejects.toThrow("submodules"); - kept(); - expect(worktrees.release(record.id).phase).toBe("released"); + await worktrees.remove(record.id); + expect(existsSync(record.path)).toBe(false); + }); + it("initializes nested submodules before returning a new checkout and preserves them on restore", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const inner = repository(join(root, "inner")); + const sub = repository(join(root, "sub")); + git(sub, "submodule", "add", "--quiet", inner, "nested/inner"); + git(sub, "commit", "-am", "nested submodule"); + git(repo, "submodule", "add", "--quiet", sub, "sub"); + git(repo, "commit", "-am", "submodule"); + const record = worktrees.create(repo, "nested", 10_000_000); + const nested = join(record.path, "sub", "nested", "inner"); + expect(readFileSync(join(nested, "file"), "utf8")).toBe("original\n"); + expect(mainCheckout(nested)).toBe(canonical(repo)); + const child = (await claudeHook( + { hook_event_name: "WorktreeCreate", cwd: nested, name: "agent-child" }, + worktrees, + async () => false, + ))!.trim(); + expect(child).toBe(join(canonical(repo), ".worktrees", "agent-child")); + expect(git(child, "rev-parse", "HEAD")).toBe(git(repo, "rev-parse", "HEAD")); + await claudeHook( + { hook_event_name: "SubagentStop", cwd: join(child, "sub", "nested", "inner"), agent_id: "child" }, + worktrees, + async () => false, + ); + expect(worktrees.status().records.find((record) => record.name === "agent-child")?.phase).toBe("released"); + await claudeHook( + { hook_event_name: "SubagentStart", cwd: join(child, "sub"), agent_id: "child" }, + worktrees, + async () => false, + ); + expect(worktrees.status().records.find((record) => record.name === "agent-child")?.phase).toBe("active"); + writeFileSync(join(nested, "file"), "local work"); + worktrees.release(record.id); + worktrees.restore(record.id); + expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); + }); + it("keeps nested submodule files and commits, including branches whose HEAD is back at the pin", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const inner = repository(join(root, "inner")); + const sub = repository(join(root, "sub")); + git(sub, "submodule", "add", "--quiet", inner, "nested/inner"); + git(sub, "commit", "-am", "nested submodule"); + git(repo, "submodule", "add", "--quiet", sub, "sub"); + git(repo, "commit", "-am", "submodule"); + const record = worktrees.create(repo, "nested", 10_000_000); + // Populate explicitly too, so the removal protection can be tested independently of creation. + git(record.path, "submodule", "update", "--init", "--recursive"); + const nested = join(record.path, "sub", "nested", "inner"); + writeFileSync(join(nested, "file"), "local work"); + await expect(worktrees.remove(record.id)).rejects.toThrow("modified or untracked"); + git(nested, "checkout", "--", "file"); + writeFileSync(join(nested, ".env"), "ignored work"); + await expect(worktrees.remove(record.id)).rejects.toThrow("ignored files"); + expect(readFileSync(join(nested, ".env"), "utf8")).toBe("ignored work"); + rmSync(join(nested, ".env")); + git( + nested, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "local", + ); + const local = git(nested, "rev-parse", "HEAD"); + git(nested, "branch", "local-only", local); + git(nested, "checkout", "--detach", "HEAD~1"); + await expect(worktrees.remove(record.id)).rejects.toThrow(local); + // Deinitializing does not make the repository's local refs disposable. + git(record.path, "submodule", "deinit", "--all"); + await expect(worktrees.remove(record.id)).rejects.toThrow(local); + expect(worktrees.status().records).toHaveLength(1); + const store = git( + record.path, + "rev-parse", + "--path-format=absolute", + "--git-path", + "modules/sub/modules/nested/inner", + ); + git( + record.path, + "--git-dir", + store, + "--work-tree", + record.path, + "update-ref", + "refs/remotes/origin/local-only", + local, + ); + await worktrees.remove(record.id); + expect(existsSync(record.path)).toBe(false); }); it("serializes reservations across independent repositories and preserves failed allocations", () => { const { root, repo, home, worktrees } = fixture(); From d15d03fd36326207d99796c93e6571d122e14918 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:02:04 -0800 Subject: [PATCH 02/11] fix(worktree): remove checked deinitialized module stores Co-Authored-By: GPT-6.1 Sol --- src/cli/worktree.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 22010c14..0e227e3e 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -695,7 +695,10 @@ export class Worktrees { record.repository, "worktree", "remove", - ...(submodules(root).length ? ["--force"] : []), + ...(existsSync(git(root, "rev-parse", "--path-format=absolute", "--git-path", "modules").trim()) || + submodules(root).length + ? ["--force"] + : []), record.path, ); } catch (error) { From 9ea30784c52c08472e3c354cf21b5d59106bb307 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:16:37 -0800 Subject: [PATCH 03/11] fix(worktree): complete recursive lifecycle safety checks Co-Authored-By: GPT-6.1 Sol --- README.md | 5 +- src/cli/main.ts | 35 ++++--- src/cli/worktree-hook.ts | 34 +++++-- src/cli/worktree.ts | 114 ++++++++++++++-------- test/agent.test.ts | 18 ++-- test/worktree.run.test.ts | 12 +-- test/worktree.test.ts | 199 ++++++++++++++++++++++---------------- 7 files changed, 251 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 675db6c7..edee1eaf 100644 --- a/README.md +++ b/README.md @@ -1211,10 +1211,11 @@ checkout released, before `git worktree remove` deletes it. A checkout already removed with `git worktree remove` only drops its record, as with `release`. New checkouts initialize their submodules recursively before returning. Restoring one preserves its existing submodule pins and local work. Removal also checks -submodules' ignored files and every module store, including deinitialized ones; +submodules' files outside declared scratch and every module store, including deinitialized ones; commits at their HEAD or refs must be reachable from a remote-tracking ref, since module stores and their local branches are deleted with the checkout. -Only after those checks does removal use the force Git requires for submodules. +Scratch paths inside submodules use that submodule's ignore rules. Only after +those checks does removal use the force Git requires for submodules. 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`). The registry lock of a process that exited on diff --git a/src/cli/main.ts b/src/cli/main.ts index 3283e411..e2575cdb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -701,6 +701,18 @@ export function main(argv: string[], out: Output): number | Promise { ); const state = () => new Worktrees(); const record = (value: unknown) => print(JSON.stringify(value)); + const settled = (result: Promise) => { + pending = result.then( + (value) => { + record(value); + return 0; + }, + (error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; + }, + ); + }; worktree .command("capacity ") .description("Set this user's shared disk budget in bytes; until set, only free disk space limits checkouts") @@ -722,7 +734,7 @@ export function main(argv: string[], out: Output): number | Promise { [], ) .action((name: string, options: { reserve: number; ref: string; scratch: string[] }) => - record(state().create(root(), name, options.reserve, options.ref, options.scratch)), + settled(state().create(root(), name, options.reserve, options.ref, options.scratch)), ); worktree .command("run") @@ -762,18 +774,7 @@ export function main(argv: string[], out: Output): number | Promise { "and drops the record of a removed checkout", ) .action((id: string) => record(state().release(id))); - const settled = (result: Promise) => { - pending = result.then( - (value) => { - record(value); - return 0; - }, - (error: unknown) => { - warn(error instanceof Error ? error.message : String(error)); - return 1; - }, - ); - }; + // Only a commit no local ref reaches asks the forge, so other removals need no token. const merged = async (repository: string, commit: string) => { const origin = readRemote(repository, "origin"); @@ -821,7 +822,13 @@ export function main(argv: string[], out: Output): number | Promise { "Exits 1 on failure, never 2, which would keep a subagent running.", ) .action(() => { - pending = (async () => claudeHook(JSON.parse(readFileSync(0, "utf8")), state(), merged))().then( + pending = (async () => + claudeHook( + JSON.parse(readFileSync(0, "utf8")), + state(), + merged, + program.getOptionValueSource("root") === "cli" ? root() : process.env.CLAUDE_PROJECT_DIR, + ))().then( (output) => { if (output) out.stdout(output); return 0; diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 8b7b1042..09a6c24d 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -12,7 +12,6 @@ import { canonical, derivedDefaults, mainCheckout, - superprojectCheckout, } from "./worktree"; /** The Claude Code events `quest worktree hook` handles, in the order `quest init` installs them. */ @@ -62,7 +61,7 @@ function git(path: string, ...args: string[]): string { /** The top of the checkout holding `cwd`, or undefined outside any. */ function toplevel(cwd: string): string | undefined { try { - return superprojectCheckout(cwd); + return canonical(git(cwd, "rev-parse", "--show-toplevel")); } catch { return undefined; } @@ -85,24 +84,43 @@ function base(cwd: string): string { * so a subagent that runs without isolation in another agent's checkout never matches it. */ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise { - const path = toplevel(input.cwd); - const record = path === undefined ? undefined : await waiting(() => worktrees.registered(path)); - return record?.name === `agent-${field(input, "agent_id")}` ? record : undefined; + let path = toplevel(input.cwd); + while (path) { + const record = await waiting(() => worktrees.registered(path!)); + if (record) return record.name === `agent-${field(input, "agent_id")}` ? record : undefined; + path = git(path, "rev-parse", "--show-superproject-working-tree") || undefined; + } + return undefined; } /** * Handle one hook event and return what it prints on stdout, if anything. WorktreeCreate checks * out Claude's default base under the main checkout's `.worktrees/`, with derived defaults, and * prints its path; an existing checkout of that name in the repository is restored instead. - * WorktreeRemove removes the checkout, never forcing. SubagentStop releases the subagent's own + * WorktreeRemove removes the checkout after recursive safety checks. SubagentStop releases the subagent's own * checkout, and SubagentStart restores it, telling the subagent when its scratch was deleted. */ -export async function claudeHook(input: unknown, worktrees: Worktrees, merged: Merged): Promise { +export async function claudeHook( + input: unknown, + worktrees: Worktrees, + merged: Merged, + project?: string, +): Promise { const hook = parse(input); switch (hook.hook_event_name) { case "WorktreeCreate": { const name = field(hook, "name"); - const cwd = superprojectCheckout(hook.cwd); + let cwd = toplevel(hook.cwd)!; + // The hook's project anchors ownership when cwd is inside a submodule, without + // changing which repository a session opened directly in a vendored project owns. + if (project) { + const main = mainCheckout(project); + while (mainCheckout(cwd) !== main) { + const parent = git(cwd, "rev-parse", "--show-superproject-working-tree"); + if (!parent) throw new Error(`hook cwd is outside its project repository: ${hook.cwd}`); + cwd = parent; + } + } const main = mainCheckout(cwd); const existing = await waiting(() => worktrees.registered(join(main, ".worktrees", name))); if (existing?.name === name) return `${(await waiting(() => worktrees.restore(existing.id))).path}\n`; diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 0e227e3e..21d12fa1 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -1,6 +1,6 @@ // Git checkouts under a shared disk budget. Release prunes declared scratch in place, by hand or // once the last supervised run in the checkout stops. Remove deletes a checkout that holds no unpreserved work. -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { type Stats, @@ -19,7 +19,8 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { promisify } from "node:util"; import { setTimeout as sleep } from "node:timers/promises"; import { type Owner, Retained, type RunOptions, type Scopes, alive, currentOwner, systemdScopes } from "./supervise"; @@ -190,7 +191,8 @@ function changes(root: string, ignored: boolean): string[] { function submodules(root: string): string[] { return git(root, "submodule", "foreach", "--quiet", "--recursive", 'printf "%s\\0" "$toplevel/$sm_path"') .split("\0") - .filter(Boolean); + .filter(Boolean) + .map(canonical); } /** Module stores survive deinit; removing this worktree deletes their refs too. */ @@ -200,19 +202,28 @@ function moduleStores(path: string): string[] { return readdirSync(path).flatMap((name) => moduleStores(join(path, name))); } -function clean(root: string): void { - const found = changes(root, false); - if (found.length) throw new Error(`checkout has modified or untracked files: ${found.slice(0, 20).join(", ")}`); - for (const path of submodules(root)) { - const left = changes(path, true); - if (left.length) +function clean(root: string): { modules: string[]; stores: Set } { + const modules = submodules(root); + for (const path of [...modules].reverse()) { + const changed = changes(path, false); + if (changed.length) throw new Error( - `submodule ${path} has modified, untracked, or ignored files: ${left.slice(0, 20).join(", ")}`, + `checkout has modified or untracked files in submodule ${path}: ${changed.slice(0, 20).join(", ")}`, ); } + const found = changes(root, false); + if (found.length) throw new Error(`checkout has modified or untracked files: ${found.slice(0, 20).join(", ")}`); + for (const repository of [root, ...modules]) { + for (const record of git(repository, "ls-files", "--stage", "-z").split("\0")) { + if (!record.startsWith("160000 ")) continue; + const path = join(repository, record.slice(record.indexOf("\t") + 1)); + if (entry(path)?.isDirectory() && !existsSync(join(path, ".git")) && readdirSync(path).length) + throw new Error(`uninitialized submodule contains files: ${path}`); + } + } const stores = new Set([ ...moduleStores(git(root, "rev-parse", "--path-format=absolute", "--git-path", "modules").trim()), - ...submodules(root).map((path) => git(path, "rev-parse", "--absolute-git-dir").trim()), + ...modules.map((path) => git(path, "rev-parse", "--absolute-git-dir").trim()), ]); for (const store of stores) { // Local branches and tags in a module store disappear with the worktree. Only remote refs preserve them. @@ -231,6 +242,7 @@ function clean(root: string): void { ).trim(); if (commit) throw new Error(`submodule ${store} has commits no remote-tracking ref reaches: ${commit}`); } + return { modules, stores }; } /** @@ -277,23 +289,16 @@ function repositoryWithin(path: string): string | undefined { return undefined; } -/** The enclosing checkout, climbing out of initialized submodules. */ -export function superprojectCheckout(path: string): string { - path = git(path, "rev-parse", "--show-toplevel").trim(); - let parent = git(path, "rev-parse", "--show-superproject-working-tree").trim(); - while (parent) { - path = parent; - parent = git(path, "rev-parse", "--show-superproject-working-tree").trim(); - } - return canonical(path); -} - /** The repository's main checkout, which every linked worktree of it shares, from any path inside one. */ export function mainCheckout(path: string): string { - const [main] = git(superprojectCheckout(path), "worktree", "list", "--porcelain", "-z").split("\0\0"); + const [main] = git(path, "worktree", "list", "--porcelain", "-z").split("\0\0"); const lines = main?.split("\0") ?? []; const found = lines.find((line) => line.startsWith("worktree "))?.slice(9); if (!found || lines.includes("bare")) throw new Error(`repository has no main checkout: ${path}`); + // Git lists an absorbed submodule's store as its main worktree; core.worktree + // still names the actual checkout relative to that store. + if (canonical(found) === canonical(git(path, "rev-parse", "--path-format=absolute", "--git-common-dir").trim())) + return canonical(git(path, "--git-dir", found, "rev-parse", "--show-toplevel").trim()); return canonical(found); } @@ -525,8 +530,14 @@ export class Worktrees { }); } /** `scratch` names the ignored paths release deletes, such as dependencies and build output. */ - create(root: string, name: string, reserve: number, ref = "HEAD", scratch: string[] = []): WorktreeRecord { - return this.locked((registry) => { + async create( + root: string, + name: string, + reserve: number, + ref = "HEAD", + scratch: string[] = [], + ): Promise { + const record = await this.lockedWait((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) @@ -565,15 +576,31 @@ export class Worktrees { path, ref, ); - git(path, "submodule", "update", "--init", "--recursive"); - record.phase = "active"; return record; } catch (error) { record.failure = String(error); throw error; } }); + try { + // Cloning can need the network; the reservation stays charged without holding the shared lock. + await promisify(execFile)("git", ["-C", record.path, "submodule", "update", "--init", "--recursive"], { + maxBuffer: 64 * 1024 * 1024, + }); + return await this.lockedWait((registry) => { + const current = this.find(registry, record.id); + if (!this.owned(current)) throw new Error(`checkout disappeared during creation: ${record.path}`); + current.phase = "active"; + return current; + }); + } catch (error) { + await this.lockedWait((registry) => { + this.find(registry, record.id).failure = String(error); + }); + throw error; + } } + /** 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); @@ -608,15 +635,19 @@ export class Worktrees { throw new Error(`scratch path is the checkout's .git entry: ${path}`); } if (paths.length) { - // 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) 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(", ")}`); + const modules = submodules(root).sort((a, b) => b.length - a.length); + for (const path of paths) { + const absolute = join(root, path); + const owner = modules.find((module) => absolute.startsWith(module + sep)) ?? root; + const local = relative(owner, absolute); + // icase covers filesystem aliases of tracked paths on case-insensitive hosts. + const tracked = git(owner, "ls-files", "-z", "--", `:(literal,icase)${local}`) + .split("\0") + .filter(Boolean); + if (tracked.length) + throw new Error(`scratch includes tracked files: ${tracked.slice(0, 20).join(", ")}`); + if (!ignored(owner, [local]).has(local)) throw new Error(`scratch must be ignored by Git: ${path}`); + } // Another checkout, such as one created inside this one, is work rather than scratch. const registered = checkouts(record.repository) .map((checkout) => checkout.path) @@ -674,14 +705,16 @@ export class Worktrees { } interrupted(record); const root = canonical(record.path); - clean(root); + const { modules, stores } = clean(root); const kept = unreached(root).filter((commit) => !landed.has(commit)); if (kept.length) throw new Error( `commits that no branch or remote-tracking ref reaches, and no merged change has as its head: ${kept.join(", ")}`, ); this.prune(registry, record); - const left = changes(root, true); + const left = [root, ...modules].flatMap((repository) => + changes(repository, true).map((change) => `${repository}: ${change}`), + ); if (left.length) throw new Error( `ignored files outside the declared scratch remain, so the checkout was only released: ${left.slice(0, 20).join(", ")}`, @@ -695,10 +728,7 @@ export class Worktrees { record.repository, "worktree", "remove", - ...(existsSync(git(root, "rev-parse", "--path-format=absolute", "--git-path", "modules").trim()) || - submodules(root).length - ? ["--force"] - : []), + ...(stores.size || modules.length ? ["--force"] : []), record.path, ); } catch (error) { diff --git a/test/agent.test.ts b/test/agent.test.ts index 7a27c24f..0f98a33c 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -184,7 +184,7 @@ function setup(keys = "sk-literal op://vault/item/second"): Setup { } /** Real registry and release/restore lifecycle; only the process-ownership backend and CLI are fake. */ -function managed() { +async function managed() { const s = setup(); const home = join(s.runtime, "worktrees"); vi.stubEnv("QUEST_WORKTREE_HOME", home); @@ -218,7 +218,7 @@ function managed() { ]); const worktrees = new Worktrees(home); worktrees.configure(100_000_000); - const record = worktrees.create(s.worktree, "agent", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(s.worktree, "agent", 10_000_000, "HEAD", ["cache"]); s.worktree = record.path; return { ...s, worktrees, record }; } @@ -413,7 +413,7 @@ test("a key with no credit exits 3 so the spawn resumes on the next key", async }); posix("codex runs with its key variable and reports its thread and last message", async () => { - const s = managed(); + const s = await managed(); const started = await quest("agent", "start", ...launch("codex", "1"), s.worktree, "go"); expect(started).toEqual({ code: 0, stdout: "session thread-1\noutcome finished\n\nfinal reply\n", stderr: "" }); expect(s.calls()[0].argv).toEqual([ @@ -510,7 +510,7 @@ test("a finished turn whose reply mentions credit is not an exhausted key", asyn }); posix("a retried codex stream error does not fail a completed turn", async () => { - const s = managed(); + const s = await managed(); vi.stubEnv("FAKE_MODE", "reconnect"); const result = await quest("agent", "start", ...launch("codex", "1"), s.worktree, "go"); expect(result).toEqual({ code: 0, stdout: "session thread-1\noutcome finished\n\nfinal reply\n", stderr: "" }); @@ -552,7 +552,7 @@ test("a launch refuses while stop --all is ending the spawn", async () => { }); posix("an agent whose record is lost is killed rather than left running untracked", async () => { - const s = managed(); + const s = await managed(); vi.stubEnv("FAKE_MODE", "late-thread"); const running = quest("agent", "start", ...launch("codex", "1"), s.worktree, "go"); await vi.waitFor(() => expect(s.calls()).toHaveLength(1)); @@ -728,7 +728,7 @@ test("missing keys, positions past the list, and unknown references are usage er for (const mode of ["finished", "decision", "crash"]) { posix(`Codex ${mode} releases scratch and resume restores the registered checkout`, async () => { - const s = managed(); + const s = await managed(); vi.stubEnv("FAKE_MODE", mode); mkdirSync(join(s.worktree, "cache")); writeFileSync(join(s.worktree, "cache", "build"), "scratch"); @@ -768,7 +768,7 @@ posix("Codex refuses an unmanaged checkout before resolving credentials or writi }); posix("Codex refuses unsupported supervision before resolving credentials", async () => { - const s = managed(); + const s = await managed(); vi.mocked(systemdScopes.check).mockImplementation(() => { throw new Error("unsupported host"); }); @@ -781,7 +781,7 @@ posix("Codex refuses unsupported supervision before resolving credentials", asyn }); posix("Codex rejects a registered path whose Git ownership was replaced before credentials", async () => { - const s = managed(); + const s = await managed(); rmSync(join(s.worktree, ".git")); execFileSync("git", ["-C", s.worktree, "init", "-q"]); const result = await quest("agent", "start", ...launch("codex", "1"), s.worktree, "go"); @@ -792,7 +792,7 @@ posix("Codex rejects a registered path whose Git ownership was replaced before c }); posix("a Codex release failure is reported instead of a finished outcome", async () => { - const s = managed(); + const s = await managed(); mkdirSync(join(s.worktree, "cache")); writeFileSync(join(s.worktree, "cache", "tracked"), "keep"); execFileSync("git", ["-C", s.worktree, "add", "-f", "cache/tracked"]); diff --git a/test/worktree.run.test.ts b/test/worktree.run.test.ts index 1a5c6271..5bf57765 100644 --- a/test/worktree.run.test.ts +++ b/test/worktree.run.test.ts @@ -10,7 +10,7 @@ import { onTerminal, tempDir } from "./fixture"; const root = resolve(import.meta.dirname, ".."); -function fixture() { +async function fixture() { const dir = tempDir(); const repo = join(dir, "repo"); mkdirSync(repo); @@ -24,7 +24,7 @@ function fixture() { const home = join(dir, "state"); const worktrees = new Worktrees(home); worktrees.configure(100_000_000); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); return { dir, home, worktrees, record }; } @@ -77,7 +77,7 @@ function stopped(pid: number): boolean { describe.runIf(process.platform === "linux")("supervised runs under systemd", { timeout: 30_000 }, () => { it("forwards arguments and status, stops orphaned descendants, then releases", async () => { - const { dir, home, worktrees, record } = fixture(); + const { dir, home, worktrees, record } = await fixture(); const pidFile = join(dir, "orphan"); const run = supervise( home, @@ -94,7 +94,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { }); it("forwards cancellation, stops live descendants, then releases", async () => { - const { dir, home, worktrees, record } = fixture(); + const { dir, home, worktrees, record } = await fixture(); const pidFile = join(dir, "descendant"); const run = supervise(home, `sleep 1000 & echo $! > ${JSON.stringify(pidFile)}; echo ready; wait`); await run.ready; @@ -108,7 +108,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { }); it("keeps a crashed supervisor's scratch until its scope is empty", async () => { - const { home, worktrees, record } = fixture(); + const { home, worktrees, record } = await fixture(); const run = supervise(home, "echo ready; exec sleep 1000"); await run.ready; run.child.kill("SIGKILL"); @@ -127,7 +127,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { }); it("lets a terminal Ctrl-C reach the command once", async () => { - const { dir, home } = fixture(); + const { dir, home } = await fixture(); const child = join(dir, "child.js"); writeFileSync( child, diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 745a9121..1604c454 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -19,7 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { type Owner, Retained, type Scopes, currentOwner, kill, killable } from "../src/cli/supervise"; import { GitHub, mergedHead } from "../src/cli/github"; import { main } from "../src/cli/main"; -import { Worktrees, canonical, derivedDefaults, diskUsage, mainCheckout } from "../src/cli/worktree"; +import { Worktrees, canonical, derivedDefaults, diskUsage } from "../src/cli/worktree"; import { claudeHook } from "../src/cli/worktree-hook"; import { initRepository, run, template } from "./fixture"; @@ -80,9 +80,9 @@ afterEach(() => { // 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("worktrees", { timeout: 20_000 }, () => { - it("prunes declared scratch in place and keeps source, Git state, and untracked files", () => { + it("prunes declared scratch in place and keeps source, Git state, and untracked files", async () => { const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache/", "cache/nested", "missing"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache/", "cache/nested", "missing"]); expect(record.scratch).toEqual(["cache", "cache/nested", "missing"]); writeFileSync(join(record.path, "file"), "committed\n"); git(record.path, "commit", "-am", "local only"); @@ -105,45 +105,49 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("ignored and undeclared"); expect(worktrees.restore(record.id).phase).toBe("active"); }); - it("refuses scratch that is tracked, not ignored, or outside the checkout", () => { + it("refuses scratch that is tracked, not ignored, or outside the checkout", async () => { const { repo, worktrees } = fixture(); for (const path of ["../outside", ".git", ".GIT", "/absolute", ".", "./", ""]) - expect(() => worktrees.create(repo, "invalid", 10_000_000, "HEAD", [path])).toThrow("relative path"); + await expect(worktrees.create(repo, "invalid", 10_000_000, "HEAD", [path])).rejects.toThrow( + "relative path", + ); let count = 0; - const declare = (scratch: string) => { - const record = worktrees.create(repo, `case-${count++}`, 1_000_000, "HEAD", [scratch]); + const declare = async (scratch: string) => { + const record = await worktrees.create(repo, `case-${count++}`, 1_000_000, "HEAD", [scratch]); 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"); return record; }; - expect(() => worktrees.release(declare("file").id)).toThrow("tracked files: file"); - expect(() => worktrees.release(declare("cache").id)).toThrow("tracked files: cache/forced"); + const tracked = await declare("file"); + expect(() => worktrees.release(tracked.id)).toThrow("tracked files: file"); + const cached = await declare("cache"); + expect(() => worktrees.release(cached.id)).toThrow("tracked files: cache/forced"); // Another spelling, which a case-insensitive filesystem resolves to the tracked directory. - const aliased = declare("CACHE"); + const aliased = await declare("CACHE"); mkdirSync(join(aliased.path, "CACHE"), { recursive: true }); appendFileSync(join(repo, ".git", "info", "exclude"), "/CACHE/\n"); expect(() => worktrees.release(aliased.id)).toThrow("tracked files: cache/forced"); - const unignored = declare("new"); + const unignored = await declare("new"); expect(() => worktrees.release(unignored.id)).toThrow("ignored by Git: new"); // Any other alias of the .git entry, here a hard link that an ignore rule matches. - const linked = declare(".env"); + const linked = await declare(".env"); linkSync(join(linked.path, ".git"), join(linked.path, ".env")); expect(() => worktrees.release(linked.id)).toThrow(".git entry: .env"); expect(readFileSync(join(unignored.path, "new"), "utf8")).toBe("source"); expect(readFileSync(join(unignored.path, "cache", "forced"), "utf8")).toBe("tracked despite the ignore rule"); expect(worktrees.status().records.every((record) => record.phase === "active")).toBe(true); }); - it.skipIf(process.platform === "win32")("never follows symlinks out of the checkout", () => { + it.skipIf(process.platform === "win32")("never follows symlinks out of the checkout", async () => { const { root, repo, worktrees } = fixture(); const outside = join(root, "outside"); mkdirSync(join(outside, "cache"), { recursive: true }); writeFileSync(join(outside, "cache", "keep"), "outside"); - const through = worktrees.create(repo, "through", 10_000_000, "HEAD", ["linked/cache"]); + const through = await worktrees.create(repo, "through", 10_000_000, "HEAD", ["linked/cache"]); symlinkSync(outside, join(through.path, "linked")); expect(() => worktrees.release(through.id)).toThrow("reached through a symlink"); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache", ".env"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache", ".env"]); mkdirSync(join(record.path, "cache")); symlinkSync(outside, join(record.path, "cache", "link")); symlinkSync(outside, join(record.path, ".env")); @@ -152,59 +156,62 @@ 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")("unlinks a scratch symlink to another worktree", () => { + it.skipIf(process.platform === "win32")("unlinks a scratch symlink to another worktree", async () => { const { repo, worktrees } = fixture(); - const other = worktrees.create(repo, "other", 10_000_000); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", [".env"]); + const other = await worktrees.create(repo, "other", 10_000_000); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", [".env"]); symlinkSync(other.path, join(record.path, ".env")); expect(worktrees.release(record.id).phase).toBe("released"); expect(existsSync(join(record.path, ".env"))).toBe(false); expect(existsSync(join(other.path, "file"))).toBe(true); }); - it.skipIf(process.platform === "win32")("charges allocated blocks of dangling symlinks and sparse files", () => { - const { root } = fixture(); - 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); - 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", () => { + it.skipIf(process.platform === "win32")( + "charges allocated blocks of dangling symlinks and sparse files", + async () => { + const { root } = fixture(); + 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); + 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", async () => { const { repo, worktrees } = fixture(); const reserve = 1_000_000; worktrees.configure(reserve); - const record = worktrees.create(repo, "work", reserve); + const record = await 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); + await worktrees.create(repo, "other", reserve - retained); expect(() => worktrees.restore(record.id)).toThrow("disk capacity exceeded"); expect(worktrees.status().records[0].phase).toBe("released"); }); - it("drops the record once its checkout is removed", () => { + it("drops the record once its checkout is removed", async () => { const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["missing"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["missing"]); expect(() => worktrees.release(record.id)).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"); + expect((await worktrees.create(repo, "work", 10_000_000)).phase).toBe("active"); }); it("removes a finished checkout once a branch or remote-tracking ref reaches its commits", async () => { const { repo, worktrees } = fixture(); const forge = vi.fn(async () => false); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); writeFileSync(join(record.path, "cache", "artifact"), "disposable"); writeFileSync(join(record.path, "file"), "pushed\n"); @@ -223,14 +230,14 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(worktrees.status().records).toEqual([]); await expect(worktrees.remove(record.id)).rejects.toThrow("unknown worktree"); // A branch made in the checkout outlives it, so its commits are preserved. - const branched = worktrees.create(repo, "branched", 10_000_000); + const branched = await worktrees.create(repo, "branched", 10_000_000); git(branched.path, "switch", "-c", "topic"); git(branched.path, "commit", "--allow-empty", "-m", "on a branch"); const tip = git(branched.path, "rev-parse", "HEAD"); await worktrees.remove(branched.id); expect(git(repo, "rev-parse", "topic")).toBe(tip); // A checkout already removed by hand only drops its record. - const gone = worktrees.create(repo, "gone", 10_000_000); + const gone = await worktrees.create(repo, "gone", 10_000_000); git(repo, "worktree", "unlock", gone.path); git(repo, "worktree", "remove", gone.path); await worktrees.remove(gone.id); @@ -238,7 +245,7 @@ describe("worktrees", { timeout: 20_000 }, () => { }); it("removes a squash-merged checkout only when a merged change's head is exactly its commit", async () => { const { repo, worktrees } = fixture(); - const record = worktrees.create(repo, "work", 10_000_000); + const record = await worktrees.create(repo, "work", 10_000_000); git(record.path, "commit", "--allow-empty", "-m", "first"); const first = git(record.path, "rev-parse", "HEAD"); git(record.path, "commit", "--allow-empty", "-m", "second"); @@ -255,7 +262,7 @@ describe("worktrees", { timeout: 20_000 }, () => { it("refuses to remove a checkout holding work that is not preserved", async () => { const { live, scopes } = fakeScopes(); const { root, repo, home, worktrees } = fixture(scopes); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); const kept = () => { expect(existsSync(join(record.path, "file"))).toBe(true); expect(worktrees.status().records).toHaveLength(1); @@ -283,7 +290,7 @@ describe("worktrees", { timeout: 20_000 }, () => { mkdirSync(join(record.path, "cache")); writeFileSync(join(record.path, ".env"), "secret"); await expect(worktrees.remove(record.id)).rejects.toThrow( - "declared scratch remain, so the checkout was only released: !! .env", + "declared scratch remain, so the checkout was only released:", ); expect(existsSync(join(record.path, "cache"))).toBe(false); expect(readFileSync(join(record.path, ".env"), "utf8")).toBe("secret"); @@ -306,14 +313,21 @@ describe("worktrees", { timeout: 20_000 }, () => { git(sub, "commit", "-am", "nested submodule"); git(repo, "submodule", "add", "--quiet", sub, "sub"); git(repo, "commit", "-am", "submodule"); - const record = worktrees.create(repo, "nested", 10_000_000); + const record = await worktrees.create(repo, "nested", 10_000_000, "HEAD", ["sub/nested/inner/cache"]); const nested = join(record.path, "sub", "nested", "inner"); expect(readFileSync(join(nested, "file"), "utf8")).toBe("original\n"); - expect(mainCheckout(nested)).toBe(canonical(repo)); + const direct = (await claudeHook( + { hook_event_name: "WorktreeCreate", cwd: nested, name: "direct" }, + worktrees, + async () => false, + nested, + ))!.trim(); + expect(direct).toBe(join(canonical(nested), ".worktrees", "direct")); const child = (await claudeHook( { hook_event_name: "WorktreeCreate", cwd: nested, name: "agent-child" }, worktrees, async () => false, + repo, ))!.trim(); expect(child).toBe(join(canonical(repo), ".worktrees", "agent-child")); expect(git(child, "rev-parse", "HEAD")).toBe(git(repo, "rev-parse", "HEAD")); @@ -330,8 +344,11 @@ describe("worktrees", { timeout: 20_000 }, () => { ); expect(worktrees.status().records.find((record) => record.name === "agent-child")?.phase).toBe("active"); writeFileSync(join(nested, "file"), "local work"); + mkdirSync(join(nested, "cache")); + writeFileSync(join(nested, "cache", "output"), "build output"); worktrees.release(record.id); worktrees.restore(record.id); + expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); }); it("keeps nested submodule files and commits, including branches whose HEAD is back at the pin", async () => { @@ -343,7 +360,7 @@ describe("worktrees", { timeout: 20_000 }, () => { git(sub, "commit", "-am", "nested submodule"); git(repo, "submodule", "add", "--quiet", sub, "sub"); git(repo, "commit", "-am", "submodule"); - const record = worktrees.create(repo, "nested", 10_000_000); + const record = await worktrees.create(repo, "nested", 10_000_000); // Populate explicitly too, so the removal protection can be tested independently of creation. git(record.path, "submodule", "update", "--init", "--recursive"); const nested = join(record.path, "sub", "nested", "inner"); @@ -366,11 +383,16 @@ describe("worktrees", { timeout: 20_000 }, () => { "local", ); const local = git(nested, "rev-parse", "HEAD"); + expect(() => git(inner, "cat-file", "-e", local)).toThrow(); git(nested, "branch", "local-only", local); git(nested, "checkout", "--detach", "HEAD~1"); await expect(worktrees.remove(record.id)).rejects.toThrow(local); // Deinitializing does not make the repository's local refs disposable. git(record.path, "submodule", "deinit", "--all"); + writeFileSync(join(record.path, "sub", "untracked"), "kept after deinit"); + await expect(worktrees.remove(record.id)).rejects.toThrow("uninitialized submodule contains files"); + expect(readFileSync(join(record.path, "sub", "untracked"), "utf8")).toBe("kept after deinit"); + rmSync(join(record.path, "sub", "untracked")); await expect(worktrees.remove(record.id)).rejects.toThrow(local); expect(worktrees.status().records).toHaveLength(1); const store = git( @@ -393,19 +415,21 @@ describe("worktrees", { timeout: 20_000 }, () => { await worktrees.remove(record.id); expect(existsSync(record.path)).toBe(false); }); - it("serializes reservations across independent repositories and preserves failed allocations", () => { + it("serializes reservations across independent repositories and preserves failed allocations", async () => { 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"); + await worktrees.create(repo, "first", 12_000_000); + await expect(new Worktrees(home).create(second, "second", 12_000_000)).rejects.toThrow( + "disk capacity exceeded", + ); expect(() => worktrees.configure(1_000_000)).toThrow("below existing"); - expect(() => worktrees.create(second, "failed", 1_000_000, "nonexistent-ref")).toThrow(); + await expect(worktrees.create(second, "failed", 1_000_000, "nonexistent-ref")).rejects.toThrow(); expect(worktrees.status().records.find((record) => record.name === "failed")?.phase).toBe("creating"); worktrees.release("failed"); expect(worktrees.status().records.map((record) => record.name)).toEqual(["first"]); }); - it("never steals a live registry lock and reclaims one whose owner exited", () => { + it("never steals a live registry lock and reclaims one whose owner exited", async () => { const { home, worktrees } = fixture(); const lock = join(home, "lock"); mkdirSync(lock); @@ -429,7 +453,7 @@ describe("worktrees", { timeout: 20_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 source = `import { Worktrees } from ${JSON.stringify(module)}; await 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], @@ -443,36 +467,36 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(worktrees.status().records).toHaveLength(1); expect(worktrees.status().allocated).toBeLessThanOrEqual(15_000_000); }); - it("refuses foreign checkouts and changed ownership", () => { + it("refuses foreign checkouts and changed ownership", async () => { 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); + await expect(worktrees.create(repo, "foreign", 10_000_000)).rejects.toThrow("refusing to adopt"); + const record = await worktrees.create(repo, "work", 10_000_000); git(repo, "worktree", "unlock", record.path); git(repo, "worktree", "lock", "--reason", "another owner", record.path); 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", () => { + it("refuses scratch that contains another Git worktree", async () => { const { root, repo, worktrees } = fixture(); - const outer = worktrees.create(repo, "outer", 10_000_000, "HEAD", [".worktrees"]); + const outer = await worktrees.create(repo, "outer", 10_000_000, "HEAD", [".worktrees"]); // 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); + const inner = await worktrees.create(outer.path, "inner", 10_000_000); expect(inner.path).toBe(join(outer.path, ".worktrees", "inner")); expect(() => worktrees.release(outer.id)).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 other = worktrees.create(repo, "other", 10_000_000, "HEAD", ["cache"]); + const other = await worktrees.create(repo, "other", 10_000_000, "HEAD", ["cache"]); const foreign = repository(join(root, "foreign")); git(foreign, "worktree", "add", "--detach", join(other.path, "cache", "agent")); expect(() => worktrees.release(other.id)).toThrow("scratch contains a Git worktree: cache"); expect(existsSync(join(other.path, "cache", "agent", "file"))).toBe(true); expect(worktrees.status().records.map((record) => record.phase)).toEqual(["active", "active", "active"]); }); - it.skipIf(process.platform === "win32")("refuses a root replaced by a link to another checkout", () => { + it.skipIf(process.platform === "win32")("refuses a root replaced by a link to another checkout", async () => { const { repo, worktrees } = fixture(); - const replaced = worktrees.create(repo, "first", 10_000_000, "HEAD", ["cache"]); - const other = worktrees.create(repo, "second", 10_000_000); + const replaced = await worktrees.create(repo, "first", 10_000_000, "HEAD", ["cache"]); + const other = await worktrees.create(repo, "second", 10_000_000); mkdirSync(join(other.path, "cache")); rmSync(replaced.path, { recursive: true }); symlinkSync(other.path, replaced.path); @@ -484,9 +508,14 @@ describe("worktrees", { timeout: 20_000 }, () => { 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", "--scratch", "cache"); - expect(created.code).toBe(0); - const { path } = JSON.parse(created.stdout); + let created = ""; + expect( + await main(["--root", repo, "worktree", "create", "cli", "--reserve", "10000000", "--scratch", "cache"], { + stdout: (text) => (created += text), + stderr: () => {}, + }), + ).toBe(0); + const { path } = JSON.parse(created); mkdirSync(join(path, "cache")); expect(run("worktree", "release", "cli").code).toBe(0); expect(existsSync(join(path, "cache"))).toBe(false); @@ -513,7 +542,7 @@ describe("supervised runs", { timeout: 20_000 }, () => { it("keeps scratch while any run is live and prunes it once the last one stops", async () => { const { finish, scopes } = fakeScopes(); const { repo, worktrees } = fixture(scopes); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); worktrees.release(record.id); // A run restores a released checkout before it starts. const first = worktrees.run(record.id, ["first"]); @@ -531,10 +560,10 @@ describe("supervised runs", { timeout: 20_000 }, () => { expect(existsSync(join(record.path, "cache"))).toBe(false); expect(worktrees.status().records[0]).toMatchObject({ phase: "released", runs: [] }); }); - it("reconciles a crashed supervisor only once its scope is empty", () => { + it("reconciles a crashed supervisor only once its scope is empty", async () => { const { live, scopes } = fakeScopes(); const { repo, home, worktrees } = fixture(scopes); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); orphan(home, "orphan.scope"); live.add("orphan.scope"); @@ -545,13 +574,13 @@ describe("supervised runs", { timeout: 20_000 }, () => { expect(worktrees.status().records[0]).toMatchObject({ phase: "released", runs: [] }); expect(existsSync(join(record.path, "cache"))).toBe(false); }); - it("treats a run it cannot inspect as live until an inspection succeeds", () => { + it("treats a run it cannot inspect as live until an inspection succeeds", async () => { const { repo, home } = fixture(); let inspect = (): boolean => { throw new Error("no user manager"); }; const worktrees = new Worktrees(home, stubScopes({ live: () => inspect() })); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); orphan(home, "orphan.scope"); expect(worktrees.status().records[0]).toMatchObject({ @@ -571,7 +600,7 @@ describe("supervised runs", { timeout: 20_000 }, () => { const { live, scopes } = fakeScopes(); const stop = vi.spyOn(scopes, "stop"); const { repo, home, worktrees } = fixture(scopes); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); orphan(home, "crashed.scope"); orphan(home, "supervised.scope", currentOwner()); @@ -596,7 +625,7 @@ describe("supervised runs", { timeout: 20_000 }, () => { it("reports a failed release as a retained reservation", async () => { const { finish, scopes } = fakeScopes(); const { repo, worktrees } = fixture(scopes); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); const running = worktrees.run(record.id, ["build"]); mkdirSync(join(record.path, "cache")); writeFileSync(join(record.path, "cache", "forced"), "tracked"); @@ -610,7 +639,7 @@ describe("supervised runs", { timeout: 20_000 }, () => { it("keeps scratch when the command never started", async () => { const { repo, home } = fixture(); const worktrees = new Worktrees(home, stubScopes({ run: () => Promise.reject(new Error("no user manager")) })); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); await expect(worktrees.run(record.id, ["build"])).rejects.toThrow("no user manager"); expect(worktrees.status().records[0]).toMatchObject({ phase: "active", runs: [] }); @@ -622,7 +651,7 @@ describe("supervised runs", { timeout: 20_000 }, () => { home, stubScopes({ run: () => Promise.reject(new Retained("cannot confirm that the scope stopped")) }), ); - const record = worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); mkdirSync(join(record.path, "cache")); await expect(worktrees.run(record.id, ["build"])).rejects.toThrow("cannot confirm"); // This process owns the run until it exits; then reconciliation asks the scope. @@ -681,7 +710,7 @@ describe("merged PR heads on GitHub", () => { }); describe("cgroup kill", () => { - it("requires cgroup.kill in this process's unified cgroup", () => { + it("requires cgroup.kill in this process's unified cgroup", async () => { const root = mkdtempSync(join(tmpdir(), "quest-cgroup-test-")); roots.push(root); const self = join(root, "self"); @@ -697,7 +726,7 @@ describe("cgroup kill", () => { expect(killable(cgroups, self)).toBe(false); }); - it("ignores a missing cgroup.kill only once the cgroup is gone", () => { + it("ignores a missing cgroup.kill only once the cgroup is gone", async () => { const root = mkdtempSync(join(tmpdir(), "quest-cgroup-test-")); roots.push(root); const group = join(root, "run.scope"); @@ -714,7 +743,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { /** A subagent's hook input, as Claude sends it from inside the subagent's checkout. */ const subagent = (event: string, cwd: string, agent: string) => ({ hook_event_name: event, cwd, agent_id: agent }); - it("derives scratch from the main checkout's ignored entries and its size as the reserve", () => { + it("derives scratch from the main checkout's ignored entries and its size as the reserve", async () => { const { repo } = fixture(); mkdirSync(join(repo, "cache", "deep"), { recursive: true }); writeFileSync(join(repo, "cache", "deep", "artifact"), "x".repeat(100_000)); @@ -732,9 +761,9 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { expect(reserve).toBeGreaterThan(100_000); expect(reserve).toBeLessThan(5_000_000); }); - it("admits by free space alone while no capacity is set", () => { + it("admits by free space alone while no capacity is set", async () => { const { root, repo } = fixture(); - expect(new Worktrees(join(root, "fresh")).create(repo, "work", 1_000_000).phase).toBe("active"); + expect((await new Worktrees(join(root, "fresh")).create(repo, "work", 1_000_000)).phase).toBe("active"); }); it("creates under the main checkout at the remote's default branch, or the session's commit without one, and restores a name it already holds", async () => { const { root, repo, worktrees } = fixture(); @@ -829,15 +858,15 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { it("keeps names unique per repository, and asks for the ID where several share one", async () => { const { root, repo, worktrees } = fixture(); const other = repository(join(root, "other")); - const first = worktrees.create(repo, "feature", 1_000_000); - const second = worktrees.create(other, "feature", 1_000_000); - expect(() => worktrees.create(repo, "feature", 1_000_000)).toThrow("already exists in this repository"); + const first = await worktrees.create(repo, "feature", 1_000_000); + const second = await worktrees.create(other, "feature", 1_000_000); + await expect(worktrees.create(repo, "feature", 1_000_000)).rejects.toThrow("already exists in this repository"); expect(() => worktrees.release("feature")).toThrow("give the ID instead"); expect(worktrees.release(second.id).phase).toBe("released"); await worktrees.remove(first.id); expect(worktrees.release("feature").id).toBe(second.id); }); - it("reads the event on stdin and exits 1, never 2, on failure", () => { + it("reads the event on stdin and exits 1, never 2, on failure", async () => { const { repo, home } = fixture(); const cli = (input: string) => spawnSync( From 4f519c78ec351fffea714f35124755a0220b6f77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:22:11 -0800 Subject: [PATCH 04/11] fix(worktree): explain recovery of partial submodule clones Co-Authored-By: GPT-6.1 Sol --- README.md | 4 +++- src/cli/worktree.ts | 2 +- test/worktree.test.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index edee1eaf..52e86cb4 100644 --- a/README.md +++ b/README.md @@ -1216,7 +1216,9 @@ commits at their HEAD or refs must be reachable from a remote-tracking ref, since module stores and their local branches are deleted with the checkout. Scratch paths inside submodules use that submodule's ignore rules. Only after those checks does removal use the force Git requires for submodules. -A failed creation stays charged until `release` drops it. Records live in +A failed creation stays charged until its partial checkout is inspected and +removed, then `release` drops its record. Unlock it before removing it; Git +requires `--force` for submodules, so preserve their files and commits first. Records live in `$QUEST_WORKTREE_HOME`, or `$XDG_STATE_HOME/quest/worktrees` (default `~/.local/state/quest/worktrees`). The registry lock of a process that exited on this host is reclaimed; a live lock, or one held from another host, is never diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 21d12fa1..c3abc919 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -169,7 +169,7 @@ function lockReason(path: string): string | undefined { function interrupted(record: WorktreeRecord): void { 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}`, + `creation was interrupted; inspect and preserve the partial checkout, including submodule work; use git worktree unlock then git worktree remove (with --force for submodules), then release again: ${record.path}`, ); } diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 1604c454..3f008c48 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -351,6 +351,23 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); }); + it("keeps a partly initialized creation charged and gives recovery advice for its lock and submodules", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const sub = repository(join(root, "sub")); + git(repo, "submodule", "add", "--quiet", sub, "first"); + git(repo, "submodule", "add", "--quiet", sub, "second"); + git(repo, "config", "-f", ".gitmodules", "submodule.second.url", join(root, "missing")); + git(repo, "commit", "-am", "submodules"); + await expect(worktrees.create(repo, "partial", 10_000_000)).rejects.toThrow(); + const [record] = worktrees.status().records; + expect(record.phase).toBe("creating"); + expect(existsSync(join(record.path, "first", ".git"))).toBe(true); + expect(() => worktrees.release(record.id)).toThrow("inspect and preserve"); + expect(() => worktrees.release(record.id)).toThrow("git worktree unlock"); + expect(() => worktrees.release(record.id)).toThrow("--force for submodules"); + expect(worktrees.status().allocated).toBe(10_000_000); + }); it("keeps nested submodule files and commits, including branches whose HEAD is back at the pin", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); From 5e6b104fa43a11f0863f00e00f695ad5377d5992 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:25:53 -0800 Subject: [PATCH 05/11] test(worktree): isolate the partial-clone URL failure Co-Authored-By: GPT-6.1 Sol --- test/worktree.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 3f008c48..430b5d07 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -358,6 +358,7 @@ describe("worktrees", { timeout: 20_000 }, () => { git(repo, "submodule", "add", "--quiet", sub, "first"); git(repo, "submodule", "add", "--quiet", sub, "second"); git(repo, "config", "-f", ".gitmodules", "submodule.second.url", join(root, "missing")); + git(repo, "config", "submodule.second.url", join(root, "missing")); git(repo, "commit", "-am", "submodules"); await expect(worktrees.create(repo, "partial", 10_000_000)).rejects.toThrow(); const [record] = worktrees.status().records; From d87b70c597ef1ca2918c6077f0cd19db5b68775d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:41:53 -0800 Subject: [PATCH 06/11] fix(worktree): retain remote preservation evidence for fetched module tips Co-Authored-By: GPT-6.1 Sol --- README.md | 5 ++++- src/cli/worktree.ts | 24 ++++++++++++++++++++++++ test/worktree.test.ts | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 52e86cb4..ea2c3d35 100644 --- a/README.md +++ b/README.md @@ -1210,7 +1210,10 @@ It then prunes the scratch and refuses ignored files that remain, leaving the checkout released, before `git worktree remove` deletes it. A checkout already removed with `git worktree remove` only drops its record, as with `release`. New checkouts initialize their submodules recursively before returning. Restoring -one preserves its existing submodule pins and local work. Removal also checks +one preserves its existing submodule pins and local work. Creation verifies any +new module tips not reached by remote-tracking branches (such as SHA-fetched +pins or cloned tags) against origin and records them under +`refs/remotes/quest-pins/`, outside origin's branch-prune namespace. Removal also checks submodules' files outside declared scratch and every module store, including deinitialized ones; commits at their HEAD or refs must be reachable from a remote-tracking ref, since module stores and their local branches are deleted with the checkout. diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index c3abc919..b0826d0b 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -587,6 +587,30 @@ export class Worktrees { await promisify(execFile)("git", ["-C", record.path, "submodule", "update", "--init", "--recursive"], { maxBuffer: 64 * 1024 * 1024, }); + for (const module of submodules(record.path)) { + const tips = new Set([ + git(module, "rev-parse", "HEAD").trim(), + ...git(module, "for-each-ref", "--format=%(objectname)").split("\n").filter(Boolean), + ]); + for (const tip of tips) { + if (!git(module, "rev-list", "-n1", tip, "--not", "--remotes").trim()) continue; + // SHA fetches and cloned tags need a durable record of remote preservation. + // Keep it outside origin's branch-prune refspec; later local tips still fail removal. + await promisify(execFile)( + "git", + [ + "-C", + module, + "fetch", + "--no-tags", + "--no-write-fetch-head", + "origin", + `${tip}:refs/remotes/quest-pins/${tip}`, + ], + { maxBuffer: 64 * 1024 * 1024 }, + ); + } + } return await this.lockedWait((registry) => { const current = this.find(registry, record.id); if (!this.owned(current)) throw new Error(`checkout disappeared during creation: ${record.path}`); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 430b5d07..d4a76f2d 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -351,6 +351,27 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); }); + it("records remotely fetched module pins and tags outside the branch-prune namespace", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const sub = repository(join(root, "sub")); + git(sub, "commit", "--allow-empty", "-m", "published pin"); + const pin = git(sub, "rev-parse", "HEAD"); + git(sub, "tag", "-a", "published", "-m", "remote tag", pin); + git(sub, "reset", "--hard", "HEAD~1"); + for (const name of ["pinned", "tagged"]) git(repo, "submodule", "add", "--quiet", sub, name); + git(join(repo, "pinned"), "checkout", "--detach", pin); + git(repo, "commit", "-am", "modules"); + const record = await worktrees.create(repo, "published", 10_000_000); + for (const name of ["pinned", "tagged"]) { + const module = join(record.path, name); + expect(git(module, "rev-list", "HEAD", "--all", "--not", "--remotes")).toBe(""); + git(module, "fetch", "--prune", "origin"); + expect(git(module, "rev-list", "HEAD", "--all", "--not", "--remotes")).toBe(""); + } + await worktrees.remove(record.id); + expect(existsSync(record.path)).toBe(false); + }); it("keeps a partly initialized creation charged and gives recovery advice for its lock and submodules", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); From c30efe23dd17bada5561d7c6a0d850548ee9a5c6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 23:56:06 -0800 Subject: [PATCH 07/11] fix(worktree): protect live creation and verify named module refs Co-Authored-By: GPT-6.1 Sol --- src/cli/worktree-hook.ts | 15 ++++++----- src/cli/worktree.ts | 57 ++++++++++++++++++++++++---------------- test/worktree.test.ts | 17 ++++++++++++ 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index 09a6c24d..d5913cc0 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -110,16 +110,19 @@ export async function claudeHook( switch (hook.hook_event_name) { case "WorktreeCreate": { const name = field(hook, "name"); - let cwd = toplevel(hook.cwd)!; + let cwd = toplevel(hook.cwd); + if (!cwd) throw new Error(`hook cwd is not in a Git checkout: ${hook.cwd}`); // The hook's project anchors ownership when cwd is inside a submodule, without // changing which repository a session opened directly in a vendored project owns. - if (project) { + if (project && toplevel(project)) { const main = mainCheckout(project); - while (mainCheckout(cwd) !== main) { - const parent = git(cwd, "rev-parse", "--show-superproject-working-tree"); - if (!parent) throw new Error(`hook cwd is outside its project repository: ${hook.cwd}`); - cwd = parent; + let enclosing = cwd; + while (mainCheckout(enclosing) !== main) { + const parent = git(enclosing, "rev-parse", "--show-superproject-working-tree"); + if (!parent) break; + enclosing = parent; } + if (mainCheckout(enclosing) === main) cwd = enclosing; } const main = mainCheckout(cwd); const existing = await waiting(() => worktrees.registered(join(main, ".worktrees", name))); diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index b0826d0b..0523b44e 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -41,6 +41,7 @@ export interface WorktreeRecord { scratch: string[]; runs: Run[]; phase: "creating" | "active" | "released"; + creator?: Owner; failure?: string; } interface Registry { @@ -167,9 +168,13 @@ function lockReason(path: string): string | undefined { } function interrupted(record: WorktreeRecord): void { + if (record.phase === "creating" && record.creator && alive(record.creator)) + throw new Error( + `creation is still running in process ${record.creator.pid}; wait for it to finish: ${record.path}`, + ); if (record.phase === "creating") throw new Error( - `creation was interrupted; inspect and preserve the partial checkout, including submodule work; use git worktree unlock then git worktree remove (with --force for submodules), then release again: ${record.path}`, + `creation was interrupted; confirm its Git processes have stopped, then inspect and preserve the partial checkout, including submodule work; use git worktree unlock then git worktree remove (with --force for submodules), then release again: ${record.path}`, ); } @@ -561,6 +566,7 @@ export class Worktrees { scratch: declared, runs: [], phase: "creating", + creator: currentOwner(), }; registry.records.push(record); save(join(this.home, "registry.json"), registry); // Reservation survives a crash in git. @@ -579,6 +585,7 @@ export class Worktrees { return record; } catch (error) { record.failure = String(error); + delete record.creator; throw error; } }); @@ -588,38 +595,44 @@ export class Worktrees { maxBuffer: 64 * 1024 * 1024, }); for (const module of submodules(record.path)) { - const tips = new Set([ - git(module, "rev-parse", "HEAD").trim(), - ...git(module, "for-each-ref", "--format=%(objectname)").split("\n").filter(Boolean), - ]); - for (const tip of tips) { - if (!git(module, "rev-list", "-n1", tip, "--not", "--remotes").trim()) continue; - // SHA fetches and cloned tags need a durable record of remote preservation. - // Keep it outside origin's branch-prune refspec; later local tips still fail removal. - await promisify(execFile)( + const untracked = (ref: string) => git(module, "rev-list", "-n1", ref, "--not", "--remotes").trim(); + const fetch = (refs: string[]) => + promisify(execFile)( "git", - [ - "-C", - module, - "fetch", - "--no-tags", - "--no-write-fetch-head", - "origin", - `${tip}:refs/remotes/quest-pins/${tip}`, - ], + ["-C", module, "fetch", "--no-tags", "--no-write-fetch-head", "origin", ...refs], { maxBuffer: 64 * 1024 * 1024 }, ); - } + // Named tag refs work even when the server refuses unadvertised SHA wants. + // Keep preservation refs outside origin's branch-prune namespace. + const tags = git(module, "for-each-ref", "--format=%(refname)", "refs/tags") + .split("\n") + .filter(Boolean) + .filter(untracked); + if (tags.length) + await fetch( + tags.map((tag) => `${tag}:refs/remotes/quest-pins/tags/${tag.slice("refs/tags/".length)}`), + ); + const tips = [ + ...new Set([ + git(module, "rev-parse", "HEAD").trim(), + ...git(module, "for-each-ref", "--format=%(objectname)").split("\n").filter(Boolean), + ]), + ].filter(untracked); + // A pin update may have fetched only a SHA, without an advertised branch or tag. + if (tips.length) await fetch(tips.map((tip) => `${tip}:refs/remotes/quest-pins/commits/${tip}`)); } return await this.lockedWait((registry) => { const current = this.find(registry, record.id); if (!this.owned(current)) throw new Error(`checkout disappeared during creation: ${record.path}`); current.phase = "active"; + delete current.creator; return current; }); } catch (error) { await this.lockedWait((registry) => { - this.find(registry, record.id).failure = String(error); + const current = this.find(registry, record.id); + current.failure = String(error); + delete current.creator; }); throw error; } @@ -663,7 +676,7 @@ export class Worktrees { for (const path of paths) { const absolute = join(root, path); const owner = modules.find((module) => absolute.startsWith(module + sep)) ?? root; - const local = relative(owner, absolute); + const local = relative(owner, absolute).split(sep).join("/"); // icase covers filesystem aliases of tracked paths on case-insensitive hosts. const tracked = git(owner, "ls-files", "-z", "--", `:(literal,icase)${local}`) .split("\0") diff --git a/test/worktree.test.ts b/test/worktree.test.ts index d4a76f2d..464e0659 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -351,6 +351,15 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); }); + it("protects a live creation without giving interrupted-checkout removal advice", async () => { + const { repo, worktrees } = fixture(); + const creation = worktrees.create(repo, "live", 10_000_000); + const [record] = worktrees.status().records; + expect(record.phase).toBe("creating"); + expect(() => worktrees.release(record.id)).toThrow("creation is still running"); + await creation; + expect(worktrees.status().records[0].creator).toBeUndefined(); + }); it("records remotely fetched module pins and tags outside the branch-prune namespace", async () => { const { root, repo, worktrees } = fixture(); vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); @@ -826,6 +835,14 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"); const fresh = (await claudeHook({ ...created, name: "agent-2" }, worktrees, noForge))!.trim(); expect(git(fresh, "rev-parse", "HEAD")).toBe(initial); + for (const project of [root, repository(join(root, "unrelated"))]) { + const name = project === root ? "nonrepo" : "unrelated"; + const path = (await claudeHook({ ...created, name }, worktrees, noForge, project))!.trim(); + expect(path).toBe(join(canonical(repo), ".worktrees", name)); + } + await expect(claudeHook({ ...created, cwd: root }, worktrees, noForge)).rejects.toThrow( + "not in a Git checkout", + ); }); it("releases a stopped subagent's own checkout and restores it on resume", async () => { const { repo, worktrees } = fixture(); From 24cc9ebf2521cf73284627a014e5abfee0f0716b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 07:02:50 -0800 Subject: [PATCH 08/11] fix(worktree): ignore inherited Git location variables A Git hook or `git submodule foreach` exports GIT_DIR, GIT_WORK_TREE, and the other repository-local variables, which override discovery from `-C`. With them naming a submodule, the WorktreeCreate hook resolved the project to that submodule and created the checkout there, and removal checks inspected the wrong repository. Drop Git's own `--local-env-vars` list, keeping `-c` config as Git does for other repositories. Co-Authored-By: Claude Opus 5.5 --- src/cli/worktree-hook.ts | 7 ++++++- src/cli/worktree.ts | 30 ++++++++++++++++++++++-------- test/worktree.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index d5913cc0..e0cf5227 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -11,6 +11,7 @@ import { Worktrees, canonical, derivedDefaults, + discoveryEnv, mainCheckout, } from "./worktree"; @@ -55,7 +56,11 @@ async function waiting(action: () => T | Promise): Promise { } function git(path: string, ...args: string[]): string { - return execFileSync("git", ["-C", path, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + return execFileSync("git", ["-C", path, ...args], { + encoding: "utf8", + env: discoveryEnv(), + stdio: ["ignore", "pipe", "pipe"], + }).trim(); } /** The top of the checkout holding `cwd`, or undefined outside any. */ diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 0523b44e..2bea239f 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -53,13 +53,32 @@ interface Registry { /** Another live process holds the registry lock. */ export class Busy extends Error {} +let located: string[] | undefined; +/** + * The environment for Git run on an explicit `-C` path. The repository-local variables a Git hook or + * `git submodule foreach` exports, such as GIT_DIR and GIT_WORK_TREE, override discovery from that + * path, so they are dropped as Git drops them when it runs in another repository, keeping `-c` config. + */ +export function discoveryEnv(): NodeJS.ProcessEnv { + located ??= execFileSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }) + .split("\n") + .filter((name) => name && name !== "GIT_CONFIG_PARAMETERS" && name !== "GIT_CONFIG_COUNT"); + const env = { ...process.env }; + for (const name of located) delete env[name]; + return env; +} function git(root: string, ...args: string[]): string { return execFileSync("git", ["-C", root, ...args], { encoding: "utf8", + env: discoveryEnv(), maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); } +/** Git that may wait on the network, run without blocking concurrent hooks and commands. */ +function gitAsync(root: string, ...args: string[]): Promise { + return promisify(execFile)("git", ["-C", root, ...args], { env: discoveryEnv(), maxBuffer: 64 * 1024 * 1024 }); +} /** Write `value` as JSON, user-only, replacing `path` atomically. */ export function save(path: string, value: unknown): void { const temporary = `${path}.${randomUUID()}.tmp`; @@ -132,6 +151,7 @@ function ignored(root: string, paths: string[]): Set { try { const output = execFileSync("git", ["-C", root, "check-ignore", "-z", "--stdin"], { encoding: "utf8", + env: discoveryEnv(), input: `${paths.join("\0")}\0`, stdio: ["pipe", "pipe", "pipe"], }); @@ -591,17 +611,11 @@ export class Worktrees { }); try { // Cloning can need the network; the reservation stays charged without holding the shared lock. - await promisify(execFile)("git", ["-C", record.path, "submodule", "update", "--init", "--recursive"], { - maxBuffer: 64 * 1024 * 1024, - }); + await gitAsync(record.path, "submodule", "update", "--init", "--recursive"); for (const module of submodules(record.path)) { const untracked = (ref: string) => git(module, "rev-list", "-n1", ref, "--not", "--remotes").trim(); const fetch = (refs: string[]) => - promisify(execFile)( - "git", - ["-C", module, "fetch", "--no-tags", "--no-write-fetch-head", "origin", ...refs], - { maxBuffer: 64 * 1024 * 1024 }, - ); + gitAsync(module, "fetch", "--no-tags", "--no-write-fetch-head", "origin", ...refs); // Named tag refs work even when the server refuses unadvertised SHA wants. // Keep preservation refs outside origin's branch-prune namespace. const tags = git(module, "for-each-ref", "--format=%(refname)", "refs/tags") diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 464e0659..4a5298be 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -351,6 +351,33 @@ describe("worktrees", { timeout: 20_000 }, () => { expect(existsSync(join(nested, "cache"))).toBe(false); expect(readFileSync(join(nested, "file"), "utf8")).toBe("local work"); }); + it("ignores inherited Git location variables that select a submodule", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const sub = repository(join(root, "sub")); + git(repo, "submodule", "add", "--quiet", sub, "sub"); + git(repo, "commit", "-am", "submodule"); + const module = join(repo, "sub"); + // A Git hook or `git submodule foreach` running the session exports these for the submodule. + const store = git(module, "rev-parse", "--absolute-git-dir"); + vi.stubEnv("GIT_DIR", store); + vi.stubEnv("GIT_WORK_TREE", module); + vi.stubEnv("GIT_INDEX_FILE", join(store, "index")); + const path = (await claudeHook( + { hook_event_name: "WorktreeCreate", cwd: module, name: "agent-located" }, + worktrees, + async () => false, + repo, + ))!.trim(); + expect(path).toBe(join(canonical(repo), ".worktrees", "agent-located")); + expect(readFileSync(join(path, "sub", "file"), "utf8")).toBe("original\n"); + const [record] = worktrees.status().records; + expect(record.repository).toBe(canonical(repo)); + // Removal inspects the checkout itself, not the submodule the variables name. + writeFileSync(join(path, "file"), "local work"); + await expect(worktrees.remove(record.id)).rejects.toThrow("checkout has modified or untracked files: M file"); + expect(readFileSync(join(path, "file"), "utf8")).toBe("local work"); + }); it("protects a live creation without giving interrupted-checkout removal advice", async () => { const { repo, worktrees } = fixture(); const creation = worktrees.create(repo, "live", 10_000_000); From 0a54dd00ba5b0361fa3d4e08d003f5f3ff1fd8be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 07:09:49 -0800 Subject: [PATCH 09/11] fix(worktree): charge module stores and keep ones other worktrees use Submodule initialization clones module stores into the checkout's own Git directory, outside its path, so the disk budget never charged them, and the derived reserve skipped the main checkout's stores. Count them in each checkout's footprint and in the reserve. Removal deletes those stores, breaking any linked worktree a store serves outside the checkout. Refuse removal while one has any. Co-Authored-By: Claude Opus 5.5 --- README.md | 11 +++++++---- src/cli/main.ts | 4 ++-- src/cli/worktree.ts | 34 +++++++++++++++++++++++++++++----- test/worktree.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ea2c3d35..3203019b 100644 --- a/README.md +++ b/README.md @@ -1143,11 +1143,12 @@ checkouts are Quest's own once `quest init` installs its hooks (see [Claude Code checkouts](#claude-code-checkouts)). Set a shared capacity and an explicit reservation in bytes, covering the -checkout and its expected build output. Measure representative jobs before +checkout, the module stores its submodules clone, and its expected build output. Measure representative jobs before choosing the budget. Until a capacity is set, only free disk space limits new reservations. 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. +writers, and Git objects stored in the shared repository are not charged; each +checkout's own submodule stores are. ```sh quest worktree capacity 20000000000 @@ -1216,7 +1217,8 @@ pins or cloned tags) against origin and records them under `refs/remotes/quest-pins/`, outside origin's branch-prune namespace. Removal also checks submodules' files outside declared scratch and every module store, including deinitialized ones; commits at their HEAD or refs must be reachable from a remote-tracking ref, -since module stores and their local branches are deleted with the checkout. +since module stores and their local branches are deleted with the checkout, and +a module store must have no linked worktrees of its own. Scratch paths inside submodules use that submodule's ignore rules. Only after those checks does removal use the force Git requires for submodules. A failed creation stays charged until its partial checkout is inspected and @@ -1243,7 +1245,8 @@ default branch as last fetched (`origin/HEAD`), or from the session's commit when that is unknown; Claude's `worktree.baseRef` setting no longer applies. With only a name to go on, it declares the main checkout's top-level ignored entries as scratch, except `.worktrees/`, `.claude/`, and entries holding a nested repository, and -reserves the main checkout's disk usage without `.git` and those checkouts. A +reserves the main checkout's disk usage without `.git` and those checkouts, plus +its submodule stores, which the new checkout clones again. A subagent's checkout is released when the subagent finishes or stops to wait for an answer, and restored when it resumes, with a note telling it to reinstall its scratch. Another `SubagentStop` hook that keeps a subagent running defeats diff --git a/src/cli/main.ts b/src/cli/main.ts index e2575cdb..657b61a5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -788,7 +788,7 @@ export function main(argv: string[], out: Output): number | Promise { "Delete a finished checkout and drop its record after recursive safety checks. Refuses while supervised runs are live, " + "and refuses modified, untracked, or ignored files beyond the declared scratch, and commits no branch " + "or remote-tracking ref reaches, unless the GitHub upstream that holds origin's claims merged a PR whose head is exactly " + - "that commit. Submodules must have no changed or ignored files and no commits their remote refs do not reach, including in deinitialized module stores. Prunes the scratch first, so a refusal over ignored files leaves the checkout released.", + "that commit. Submodules must have no changed or ignored files and no commits their remote refs do not reach, including in deinitialized module stores, and module stores must have no linked worktrees of their own. Prunes the scratch first, so a refusal over ignored files leaves the checkout released.", ) .action((id: string) => settled(state().remove(id, merged))); worktree @@ -815,7 +815,7 @@ export function main(argv: string[], out: Output): number | Promise { "default, or the session's commit without one, as a quest worktree under the main checkout's .worktrees/, " + "initializes its submodules recursively, and prints its path. Its scratch is the main checkout's top-level ignored entries, except " + ".worktrees/, .claude/, and entries holding a nested repository; its reserve is the main checkout's disk " + - "usage without .git and those checkouts. An existing checkout of that name in the repository is " + + "usage without .git and those checkouts, plus its submodule stores. An existing checkout of that name in the repository is " + "restored instead. WorktreeRemove removes the checkout as `remove` does, so Claude keeps one holding " + "unpreserved work. SubagentStop releases the subagent's own checkout, named agent- by Claude, " + "when it finishes or stops to wait, and SubagentStart restores it. Other checkouts are left alone. " + diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index 2bea239f..9d0d62ee 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -177,15 +177,31 @@ function linkedWorktree(path: string): string | 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. */ -function lockReason(path: string): string | undefined { +/** A linked worktree's own Git directory, named by its `.git` file; undefined without one. */ +function adminDir(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$/, ""); + return admin && resolve(path, admin.trim()); } catch (error) { if (["ENOENT", "ENOTDIR", "EISDIR"].includes(code(error) ?? "")) return undefined; throw error; } } +function lockReason(path: string): string | undefined { + const admin = adminDir(path); + try { + return admin && readFileSync(join(admin, "locked"), "utf8").replace(/\r?\n$/, ""); + } catch (error) { + if (["ENOENT", "ENOTDIR", "EISDIR"].includes(code(error) ?? "")) return undefined; + throw error; + } +} +/** A checkout's disk usage, including the module stores its submodules cloned into its own Git directory. */ +function footprint(path: string): number { + const admin = adminDir(path); + const seen = new Set(); + return diskUsage(path, seen) + (admin ? diskUsage(join(admin, "modules"), seen) : 0); +} function interrupted(record: WorktreeRecord): void { if (record.phase === "creating" && record.creator && alive(record.creator)) @@ -251,6 +267,12 @@ function clean(root: string): { modules: string[]; stores: Set } { ...modules.map((path) => git(path, "rev-parse", "--absolute-git-dir").trim()), ]); for (const store of stores) { + // Removal deletes the store, and with it the Git directories of the store's own linked worktrees. + const linked = entry(join(store, "worktrees"))?.isDirectory() ? readdirSync(join(store, "worktrees")) : []; + if (linked.length) + throw new Error( + `submodule ${store} has linked worktrees that removal would break; remove or prune them first: ${linked.join(", ")}`, + ); // Local branches and tags in a module store disappear with the worktree. Only remote refs preserve them. const commit = git( root, @@ -341,7 +363,9 @@ export function derivedDefaults(main: string): { reserve: number; scratch: strin const matched = names.length ? ignored(main, names) : new Set(); const scratch = names.filter((name) => matched.has(name) && !repositoryWithin(join(main, name))).sort(); const skip = new Set([join(main, ".git"), join(main, ".worktrees"), join(main, ".claude", "worktrees")]); - return { reserve: Math.max(1, diskUsage(main, new Set(), skip)), scratch }; + // A new checkout clones its own module stores, about the size of the main checkout's. + const modules = git(main, "rev-parse", "--path-format=absolute", "--git-path", "modules").trim(); + return { reserve: Math.max(1, diskUsage(main, new Set(), skip) + diskUsage(modules)), scratch }; } /** @@ -511,7 +535,7 @@ export class Worktrees { let growth = 0; let local = 0; for (const record of registry.records) { - const bytes = diskUsage(record.path); + const bytes = footprint(record.path); used += bytes; if (record.phase === "released") continue; const expected = Math.max(0, record.reserve - bytes); @@ -828,7 +852,7 @@ export class Worktrees { private activate(registry: Registry, record: WorktreeRecord): void { if (record.phase === "active") return; if (record.phase !== "released") throw new Error(`cannot use a ${record.phase} checkout`); - this.admit(registry, Math.max(0, record.reserve - diskUsage(record.path)), dirname(record.path)); + this.admit(registry, Math.max(0, record.reserve - footprint(record.path)), dirname(record.path)); record.phase = "active"; } /** diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 4a5298be..4a9aaf9e 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -1,4 +1,5 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { appendFileSync, existsSync, @@ -378,6 +379,28 @@ describe("worktrees", { timeout: 20_000 }, () => { await expect(worktrees.remove(record.id)).rejects.toThrow("checkout has modified or untracked files: M file"); expect(readFileSync(join(path, "file"), "utf8")).toBe("local work"); }); + it("charges cloned module stores and keeps one that serves another worktree", async () => { + const { root, repo, worktrees } = fixture(); + vi.stubEnv("GIT_ALLOW_PROTOCOL", "file"); + const sub = repository(join(root, "sub")); + // Incompressible, so the module store holds about as much as the checked-out file. + writeFileSync(join(sub, "blob"), randomBytes(1 << 20)); + git(sub, "add", "blob"); + git(sub, "commit", "-q", "-m", "blob"); + git(repo, "submodule", "add", "--quiet", sub, "sub"); + git(repo, "commit", "-am", "submodule"); + expect(derivedDefaults(repo).reserve).toBeGreaterThan(2 << 20); + const record = await worktrees.create(repo, "stores", 1); + worktrees.release(record.id); + expect(worktrees.status().allocated).toBeGreaterThan(2 << 20); + const elsewhere = join(root, "elsewhere"); + git(join(record.path, "sub"), "worktree", "add", "--quiet", "--detach", elsewhere); + await expect(worktrees.remove(record.id)).rejects.toThrow("has linked worktrees that removal would break"); + expect(git(elsewhere, "rev-parse", "HEAD")).toBe(git(join(record.path, "sub"), "rev-parse", "HEAD")); + git(join(record.path, "sub"), "worktree", "remove", elsewhere); + await worktrees.remove(record.id); + expect(existsSync(record.path)).toBe(false); + }); it("protects a live creation without giving interrupted-checkout removal advice", async () => { const { repo, worktrees } = fixture(); const creation = worktrees.create(repo, "live", 10_000_000); From 261a70dbdff7b409cda0c38150ccab47f14decd8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 09:14:13 -0800 Subject: [PATCH 10/11] test(worktree): await the async run fixture in main's gate-scope tests --- test/worktree.run.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/worktree.run.test.ts b/test/worktree.run.test.ts index e7d73a34..9dae8ab6 100644 --- a/test/worktree.run.test.ts +++ b/test/worktree.run.test.ts @@ -167,7 +167,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { } it("still forwards a signal to its own scope from inside a gate's, once", async () => { - const { dir, home } = fixture(); + const { dir, home } = await fixture(); const leaf = signalled(dir, ["SIGTERM"], 200); const { stop, result } = gated(dir, home, leaf.script); await created(leaf.ready); @@ -179,7 +179,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { }); it("is killed with the gate's scope it runs in", async () => { - const { dir, home, worktrees, record } = fixture(); + const { dir, home, worktrees, record } = await fixture(); const leaf = signalled(dir, ["SIGTERM"]); const { stop, kill, result } = gated(dir, home, leaf.script); await created(leaf.ready); From 66bdaab61e9525b7cefb5126e34d0b854096fc21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 09:15:19 -0800 Subject: [PATCH 11/11] test(worktree): bind a workflow agent that starts inside a submodule of its checkout --- test/worktree.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/worktree.test.ts b/test/worktree.test.ts index 244760bc..8126a882 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -344,6 +344,24 @@ describe("worktrees", { timeout: 20_000 }, () => { async () => false, ); expect(worktrees.status().records.find((record) => record.name === "agent-child")?.phase).toBe("active"); + // A workflow agent that starts inside a submodule of its checkout binds it there too. + const flow = "wf_04c8d372-2e7-1"; + const workflow = (await claudeHook( + { hook_event_name: "WorktreeCreate", cwd: nested, name: flow }, + worktrees, + async () => false, + repo, + ))!.trim(); + for (const event of ["SubagentStart", "SubagentStop"]) + await claudeHook( + { hook_event_name: event, cwd: join(workflow, "sub", "nested", "inner"), agent_id: "flow" }, + worktrees, + async () => false, + ); + expect(worktrees.status().records.find((record) => record.name === flow)).toMatchObject({ + phase: "released", + agent: "flow", + }); writeFileSync(join(nested, "file"), "local work"); mkdirSync(join(nested, "cache")); writeFileSync(join(nested, "cache", "output"), "build output");