diff --git a/README.md b/README.md index 7fb6af9a..57ab7ecb 100644 --- a/README.md +++ b/README.md @@ -1247,11 +1247,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 @@ -1309,7 +1310,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 @@ -1318,7 +1319,20 @@ 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`. -A failed creation stays charged until `release` drops it. Records live in +New checkouts initialize their submodules recursively before returning. Restoring +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, 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 +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 @@ -1348,7 +1362,8 @@ number origin has no request for starts from the default base instead of failing. 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. +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 diff --git a/src/cli/main.ts b/src/cli/main.ts index 3316e689..6aeece72 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -715,6 +715,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") @@ -736,7 +748,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") @@ -776,18 +788,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"); @@ -798,10 +799,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, 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 @@ -826,10 +827,10 @@ 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. A name pr-, which Claude gives the worktree for --worktree '#' or a " + + "initializes its submodules recursively, and prints its path. A name pr-, which Claude gives the worktree for --worktree '#' or a " + "pull request's URL, checks out that request's head from origin instead, when origin has one. 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, " + "or a workflow agent's, named for its run and bound to the first subagent that starts in it, " + @@ -837,7 +838,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 cf0e5fcc..19bdbbe9 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. */ @@ -126,13 +131,18 @@ const WORKFLOW_CHECKOUT = /^wf_[0-9a-f]{8}-[0-9a-f]{3}-[0-9]+$/; * it, which is the workflow agent itself; one that later runs there without isolation never matches. */ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise { - const path = toplevel(input.cwd); - const record = path === undefined ? undefined : await waiting(() => worktrees.registered(path)); + let path = toplevel(input.cwd); + let record: WorktreeRecord | undefined; + while (path && !record) { + const checkout = path; + record = await waiting(() => worktrees.registered(checkout)); + path = git(checkout, "rev-parse", "--show-superproject-working-tree") || undefined; + } const agent = field(input, "agent_id"); if (record?.name === `agent-${agent}`) return record; if (record === undefined || !WORKFLOW_CHECKOUT.test(record.name)) return undefined; - const bound = - input.hook_event_name === "SubagentStart" ? await waiting(() => worktrees.bind(record.id, agent)) : record; + const { id } = record; + const bound = input.hook_event_name === "SubagentStart" ? await waiting(() => worktrees.bind(id, agent)) : record; return bound.agent === agent ? bound : undefined; } @@ -141,15 +151,34 @@ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise * out Claude's default base, or a pull request's head for `pr-`, 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 main = mainCheckout(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 && toplevel(project)) { + const main = mainCheckout(project); + 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))); if (existing?.name === name) { // Claude reuses a name, such as a resumed workflow run's, for an agent that binds afresh. @@ -157,7 +186,7 @@ export async function claudeHook(input: unknown, worktrees: Worktrees, merged: M return `${(await waiting(() => worktrees.restore(existing.id))).path}\n`; } const { reserve, scratch } = derivedDefaults(main); - const ref = pullRequest(hook.cwd, name) ?? base(hook.cwd); + const ref = pullRequest(cwd, name) ?? 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 a5750849..98990f6b 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"; @@ -40,6 +41,7 @@ export interface WorktreeRecord { scratch: string[]; runs: Run[]; phase: "creating" | "active" | "released"; + creator?: Owner; failure?: string; /** The Claude Code subagent bound to this checkout by `quest worktree hook`, when Claude's name for it carries none. */ agent?: string; @@ -53,13 +55,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 +153,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"], }); @@ -157,33 +179,119 @@ 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)) + 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 remove the partial checkout with git worktree remove, 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}`, ); } /** 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); } -function clean(root: string): void { +/** 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) + .map(canonical); +} + +/** 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): { 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( + `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()), + ...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, + "--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}`); + } + return { modules, stores }; } /** @@ -236,6 +344,10 @@ export function mainCheckout(path: string): string { 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); } @@ -253,7 +365,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 }; } /** @@ -423,7 +537,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); @@ -467,8 +581,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) @@ -492,6 +612,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. @@ -507,14 +628,56 @@ export class Worktrees { path, ref, ); - record.phase = "active"; return record; } catch (error) { record.failure = String(error); + delete record.creator; throw error; } }); + try { + // Cloning can need the network; the reservation stays charged without holding the shared lock. + 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[]) => + 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") + .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) => { + const current = this.find(registry, record.id); + current.failure = String(error); + delete current.creator; + }); + 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); @@ -549,15 +712,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).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") + .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) @@ -587,7 +754,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. @@ -615,14 +782,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(", ")}`, @@ -630,7 +799,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", + ...(stores.size || modules.length ? ["--force"] : []), + record.path, + ); } catch (error) { try { git(record.repository, "worktree", "lock", "--reason", reason, record.path); @@ -689,7 +866,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/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 ff98441b..9dae8ab6 100644 --- a/test/worktree.run.test.ts +++ b/test/worktree.run.test.ts @@ -11,7 +11,7 @@ import { created, onTerminal, signalled, tempDir } from "./fixture"; const root = resolve(import.meta.dirname, ".."); -function fixture() { +async function fixture() { const dir = tempDir(); const repo = join(dir, "repo"); mkdirSync(repo); @@ -25,7 +25,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 }; } @@ -78,7 +78,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, @@ -95,7 +95,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; @@ -109,7 +109,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"); @@ -128,7 +128,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, @@ -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); diff --git a/test/worktree.test.ts b/test/worktree.test.ts index bcaa06c4..8126a882 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, @@ -80,9 +81,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 +106,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 +157,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 +231,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 +246,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 +263,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,34 +291,261 @@ 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"); 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 = 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"); + 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")); + 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"); + // 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"); + 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("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("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); + 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"); + 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"); + 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, "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; + 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("serializes reservations across independent repositories and preserves failed allocations", () => { + 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 = 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"); + 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"); + 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( + 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", 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); @@ -334,7 +569,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], @@ -348,36 +583,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); @@ -389,9 +624,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); @@ -418,7 +658,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"]); @@ -436,10 +676,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"); @@ -450,13 +690,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({ @@ -476,7 +716,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()); @@ -501,7 +741,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"); @@ -515,7 +755,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: [] }); @@ -527,7 +767,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. @@ -586,7 +826,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"); @@ -602,7 +842,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"); @@ -619,7 +859,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)); @@ -637,9 +877,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(); @@ -663,6 +903,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(); @@ -793,15 +1041,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(