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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1616,7 +1616,7 @@ supervisors left in their scopes, waits until they are empty, and so releases
the checkout; it leaves runs whose supervisor is alive to that supervisor.

Release deletes only declared scratch, and refuses a path that Git does not
ignore, that holds tracked files or another Git worktree, or that is reached
ignore, that holds tracked files or another repository or worktree, or that is reached
through a symbolic link; links at or inside scratch are removed, never followed.
Missing paths are skipped, so release can be rerun. The checkout, its Git state,
and untracked files stay in place, and `status` charges what remains. A failed
Expand All @@ -1643,16 +1643,18 @@ 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
preserved only when the GitHub upstream that holds origin's claims has a merged
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
PR whose head is that commit or descends from it, as when `quest land` merged
the base into the head; other commits need no forge or token. Every ignored
path is build output that removal deletes, declared as scratch or not, except
one holding a nested repository or worktree, which it refuses. Only then does
`git worktree remove` delete the checkout. 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. 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;
submodules' 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, and
a module store must have no linked worktrees of its own.
Expand Down
40 changes: 0 additions & 40 deletions quest/m0/worktree-ignored-scratch.md

This file was deleted.

22 changes: 18 additions & 4 deletions src/cli/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,10 +555,10 @@ export async function requirements(github: GitHub, repository: Repository, branc
}

/**
* Whether a merged PR in `repository` had exactly `commit` as its head, as when it was squash-merged;
* false for a commit GitHub does not have.
* Whether a merged PR in `repository` had `commit` as its head or below it, as when `quest land` merged
* the base into the head before squashing; false for a commit GitHub does not have.
*/
export async function mergedHead(github: GitHub, repository: Repository, commit: string): Promise<boolean> {
export async function mergedHolds(github: GitHub, repository: Repository, commit: string): Promise<boolean> {
const data = await github.graphql(
"query($owner: String!, $name: String!, $oid: GitObjectID!) { repository(owner: $owner, name: $name) { " +
"object(oid: $oid) { ... on Commit { associatedPullRequests(first: 100) { nodes { merged headRefOid } } } } } }",
Expand All @@ -567,7 +567,21 @@ export async function mergedHead(github: GitHub, repository: Repository, commit:
const found = object(data.repository).object;
const pulls = found === null || found === undefined ? undefined : object(found).associatedPullRequests;
if (pulls === null || pulls === undefined) return false; // Unknown, or not a commit.
return array(object(pulls).nodes).some((pull) => pull.merged === true && pull.headRefOid === commit);
for (const pull of array(object(pulls).nodes)) {
if (pull.merged !== true) continue;
const head = string(pull.headRefOid);
if (head === commit) return true;
// Association alone is no proof the merged head still reaches the commit, such as after a force push.
// Comparing from the head leaves no commits or files to send when it does.
const response = await github.request(
`/repos/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.repo)}/compare/${head}...${commit}`,
{
allow: [404],
},
);
if (response.ok && Number(object(await response.json()).ahead_by) === 0) return true;
}
return false;
}

/** A login, a committer name without spaces, or `-` for a deleted account. */
Expand Down
10 changes: 5 additions & 5 deletions src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { gate, QUICK_MS } from "./gate";
import {
gitHubDependencies,
GitHubInputError,
mergedHead,
mergedHolds,
pullRequest,
readRemote,
remoteRepository,
Expand Down Expand Up @@ -905,15 +905,15 @@ export function main(argv: string[], out: Output): number | Promise<number> {
const origin = readRemote(repository, "origin");
if (origin === null) return false; // No forge to ask.
const up = await upstream(repository, origin, { github: () => gitHubDependencies(warn), warn });
return up.github !== null && mergedHead(up.github.client, up.github.repository, commit);
return up.github !== null && mergedHolds(up.github.client, up.github.repository, commit);
};
worktree
.command("remove <id>")
.description(
"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, 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.",
"and refuses modified or untracked files, an ignored path holding a nested repository or worktree, and commits no branch " +
"or remote-tracking ref reaches, unless the GitHub upstream that holds origin's claims merged a PR whose head is " +
"that commit or descends from it. Every other ignored path, declared as scratch or not, is deleted with the checkout. Submodules must have no changed 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.",
)
.action((id: string) => settled(state().remove(id, merged)));
worktree
Expand Down
52 changes: 23 additions & 29 deletions src/cli/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +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.
// once the last supervised run in the checkout stops. Remove deletes a checkout that holds no unpreserved work,
// counting every ignored path as scratch.
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
Expand Down Expand Up @@ -153,17 +154,6 @@ function exclude(repository: string): void {
writeFileSync(file, `${old}${old && !old.endsWith("\n") ? "\n" : ""}/.worktrees/\n`);
}

/** The first linked worktree of any repository, marked by a `.git` file, at or under `path`; symlinks are never followed. */
function linkedWorktree(path: string): string | undefined {
if (!entry(path)?.isDirectory()) return undefined;
if (entry(join(path, ".git"))?.isFile()) return path;
for (const child of readdirSync(path)) {
const found = child === ".git" ? undefined : linkedWorktree(join(path, child));
if (found) return found;
}
return undefined;
}

/**
* Git's lock reason for the checkout actually at `path`, read through its own `.git` file and
* administrative directory, so a root replaced by another checkout reports that checkout's lock.
Expand Down Expand Up @@ -307,7 +297,7 @@ function unreached(root: string): string[] {
return [...tips].filter((tip) => git(root, ["rev-list", "-n1", tip, "--not", "--branches", "--remotes"]));
}

/** Whether the forge merged a change, such as a squash-merged PR, whose head is exactly `commit`. */
/** Whether the forge merged a change, such as a squash-merged PR, whose head is or descends from `commit`. */
export type Merged = (repository: string, commit: string) => Promise<boolean>;

/**
Expand All @@ -329,11 +319,17 @@ export function diskUsage(path: string, seen = new Set<string>(), skip = new Set
return bytes;
}

/** The first repository or linked worktree, marked by a `.git` entry, at or under `path`; symlinks are never followed. */
/**
* The first repository or linked worktree, marked by a `.git` entry, or bare repository, holding `HEAD`,
* `objects/`, and `refs/`, at or under `path`; symlinks are never followed.
*/
function repositoryWithin(path: string): string | undefined {
if (!entry(path)?.isDirectory()) return undefined;
const children = readdirSync(path, { withFileTypes: true });
const has = (name: string, directory: boolean) =>
children.some((child) => child.name === name && (directory ? child.isDirectory() : child.isFile()));
if (children.some((child) => child.name === ".git")) return path;
if (has("HEAD", false) && has("objects", true) && has("refs", true)) return path;
for (const child of children) {
const found = child.isDirectory() ? repositoryWithin(join(path, child.name)) : undefined;
if (found) return found;
Expand Down Expand Up @@ -731,7 +727,7 @@ export class Worktrees {
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.
// Another repository or checkout, such as one created inside this one, is work rather than scratch.
const registered = checkouts(record.repository)
.map((checkout) => checkout.path)
.filter((path) => path !== root);
Expand All @@ -741,8 +737,8 @@ export class Worktrees {
const target = canonical(join(root, path));
const nested =
registered.find((checkout) => checkout === target || checkout.startsWith(target + sep)) ??
linkedWorktree(join(root, path));
if (nested) throw new Error(`scratch contains a Git worktree: ${path} holds ${nested}`);
repositoryWithin(join(root, path));
if (nested) throw new Error(`scratch contains a Git repository or worktree: ${path} holds ${nested}`);
}
// Removal unlinks symlinks rather than following them; force skips paths an overlapping parent removed.
for (const path of paths) rmSync(join(root, path), { recursive: true, force: true });
Expand All @@ -764,9 +760,9 @@ export class Worktrees {
}
/**
* 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.
* are live, and refuses a checkout holding modified or untracked files, an ignored path holding a
* repository or worktree, or a commit no branch or remote-tracking ref reaches. Such a commit counts as
* preserved only when `merged` reports a merged change that holds it. Every other ignored path is scratch.
*/
async remove(id: string, merged: Merged = async () => false): Promise<WorktreeRecord> {
// The forge is asked outside the lock; the locked pass below inspects the checkout again.
Expand Down Expand Up @@ -795,16 +791,14 @@ export class Worktrees {
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 = [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(", ")}`,
`commits that no branch or remote-tracking ref reaches, and no merged change holds: ${kept.join(", ")}`,
);
// Ignored paths are build output, except a nested repository or worktree, whose commits and files are work.
for (const repository of [root, ...modules])
for (const change of changes(repository, true)) {
const nested = repositoryWithin(join(repository, change.slice(3)));
if (nested) throw new Error(`ignored path holds a Git repository or worktree: ${nested}`);
}
const reason = `quest worktree ${record.id}`;
git(record.repository, ["worktree", "unlock", record.path]);
try {
Expand Down
Loading
Loading