Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 25 additions & 18 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,18 @@ export function main(argv: string[], out: Output): number | Promise<number> {
);
const state = () => new Worktrees();
const record = (value: unknown) => print(JSON.stringify(value));
const settled = (result: Promise<unknown>) => {
pending = result.then(
(value) => {
record(value);
return 0;
},
(error: unknown) => {
warn(error instanceof Error ? error.message : String(error));
return 1;
},
);
};
worktree
.command("capacity <bytes>")
.description("Set this user's shared disk budget in bytes; until set, only free disk space limits checkouts")
Expand All @@ -736,7 +748,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
[],
)
.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")
Expand Down Expand Up @@ -776,18 +788,7 @@ export function main(argv: string[], out: Output): number | Promise<number> {
"and drops the record of a removed checkout",
)
.action((id: string) => record(state().release(id)));
const settled = (result: Promise<unknown>) => {
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");
Expand All @@ -798,10 +799,10 @@ export function main(argv: string[], out: Output): number | Promise<number> {
worktree
.command("remove <id>")
.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
Expand All @@ -826,18 +827,24 @@ export function main(argv: string[], out: Output): number | Promise<number> {
".\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-<number>, which Claude gives the worktree for --worktree '#<number>' or a " +
"initializes its submodules recursively, and prints its path. A name pr-<number>, which Claude gives the worktree for --worktree '#<number>' 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-<agent_id> by Claude, " +
"or a workflow agent's, named for its run and bound to the first subagent that starts in it, " +
"when it finishes or stops to wait, and SubagentStart restores it. Other checkouts are left alone. " +
"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;
Expand Down
47 changes: 38 additions & 9 deletions src/cli/worktree-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Worktrees,
canonical,
derivedDefaults,
discoveryEnv,
mainCheckout,
} from "./worktree";

Expand Down Expand Up @@ -55,7 +56,11 @@ async function waiting<T>(action: () => T | Promise<T>): Promise<T> {
}

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. */
Expand Down Expand Up @@ -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<WorktreeRecord | undefined> {
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;
}

Expand All @@ -141,23 +151,42 @@ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise
* out Claude's default base, or a pull request's head for `pr-<number>`, 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<string | undefined> {
export async function claudeHook(
input: unknown,
worktrees: Worktrees,
merged: Merged,
project?: string,
): Promise<string | undefined> {
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.
await waiting(() => worktrees.bind(existing.id));
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": {
Expand Down
Loading
Loading