seat: fetch a role clone that is on another branch or dirty (#1611)

Row 117. At a launch, update() kept a clone that was on another branch
or dirty and ran no git in it, so its refs/remotes/origin/* stayed as
old as the last clean launch. Now those two reasons get the clean
path's fetch, with the same arguments and environment, and no merge.
The result is a new outcome, fetched, with the reason, the clone's
branch and HEAD, and origin { branch, commit } after the fetch. Every
earlier check still keeps the clone with no fetch. The launcher writes
a checkout-fetched launch-log line, and the bundle prompt names a
fetched clone's branch, commit and origin. Nothing in unsafe(),
allowed(), gitEnv() or SAFE changed.

Candidate manifest 9fa839edfb2bd04771bbac1913b142cb16aef227a1265120e0d318fb64d6a502
from Darkwing's round 1 packet (17d801f48), on BASE 559ef2287. Dewey
approved in 28443. Sage reran the full gate at the landing target with
the candidate applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 12:42:29 -05:00
co-authored by Claude Opus 5.5
parent 606b9e5087
commit e62712e7aa
8 changed files with 182 additions and 51 deletions
+16 -8
View File
@@ -259,6 +259,7 @@ outside the sandbox:
|---|---|---|
| doesn't exist | `git clone --no-hardlinks --template=` of the root, at the branch the root has checked out, into `.<project>.clone-<hex>` beside it, then renamed into place | `cloned` |
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
| passes the checks below, but is on another branch or none, or dirty | fetches its origin and doesn't merge, so only `refs/remotes` moves (#1611) | `fetched`, with the reason |
| anything else | nothing | `kept`, with a reason |
The host never resets, cleans, deletes or force-updates a clone, and never
@@ -268,12 +269,19 @@ reach it.
Each result also has the clone's branch and, for `cloned`, `updated` and
`current`, the commit it is at after the launch (`null` when git can't read
it). A kept clone has neither, and the host runs no more git in it to find
one. The bundle's prompt names the branch and commit, says the host
refreshes a clone only at a launch, and tells the session not to set git
config in its clone (#1598).
it). A fetched clone has its own branch (`null` on a detached HEAD), its
commit, its reason, and `origin`: the root's branch and the commit
`refs/remotes/origin/<branch>` is at after the fetch (`null` when git can't
read it). A kept clone has only its reason, and the host runs no more git
in it to find more. The bundle's prompt names the branch and commit, says
why a fetched clone wasn't fast-forwarded and where `origin/<branch>` is,
says the host fetches every clone it can safely run git in at a launch and
fast-forwards only a clean one on the root's branch, and tells the session
not to set git config in its clone (#1598).
A kept clone's reason is one of:
A kept or fetched clone's reason is one of the following. Only
`other-branch` and `dirty` fetch, and only once every check above them has
passed; every other reason runs no fetch.
| Reason | The clone |
|---|---|
@@ -282,9 +290,9 @@ A kept clone's reason is one of:
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
| `other-branch` | is on another branch, or on none |
| `dirty` | has a change, staged or not, or an untracked file |
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail |
| `other-branch` | is on another branch, or on none; `fetched` |
| `dirty` | has a change, staged or not, or an untracked file; `fetched` |
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail; a clone on another branch or dirty whose fetch fails is `kept`, `fetch-failed` |
| `not-fast-forward` | has commits the root's branch doesn't |
The session can write its clone, so the host treats every clone as
+28 -11
View File
@@ -9,6 +9,8 @@
// clone that fails is removed and refuses the launch
// a clean clone on fetched from its origin and fast-forwarded
// the root's branch
// a clone on another fetched from its origin and not merged: only
// branch, or dirty refs/remotes moves, with the reason (#1611)
// anything else left exactly as it is, with the reason
//
// The host never resets, cleans, deletes or force-updates a clone, and
@@ -252,13 +254,25 @@ async function update(root, dest, g) {
const at = await rootBranch(root, g);
if (at.error) return { outcome: "kept", reason: "root-unreadable" };
if (!at.branch) return { outcome: "kept", reason: "root-detached" };
// Row 117 (#1611): a clone on another branch, or dirty, still gets the
// fetch, so origin/<branch> is the root's at every launch. Only the merge
// is left out. symbolic-ref -q fails on a detached HEAD: branch null.
const head = await g([...inClone(dest), "symbolic-ref", "-q", "--short", "HEAD"]);
if (!head.ok || head.out.trim() !== at.branch) return { outcome: "kept", reason: "other-branch" };
const status = await g(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]);
if (!status.ok) return { outcome: "kept", reason: "status-failed" };
if (status.out !== "") return { outcome: "kept", reason: "dirty" };
const before = (await g([...inClone(dest), "rev-parse", "HEAD"])).out.trim();
const branch = head.ok ? head.out.trim() : null;
let reason = null;
if (branch !== at.branch) reason = "other-branch";
else {
const status = await g(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]);
if (!status.ok) return { outcome: "kept", reason: "status-failed" };
if (status.out !== "") reason = "dirty";
}
const first = await g([...inClone(dest), "rev-parse", "HEAD"]);
const before = first.ok ? first.out.trim() : null;
if (!(await g([...inClone(dest), "fetch", "-q", "--no-tags", "--no-recurse-submodules", "origin"])).ok) return { outcome: "kept", reason: "fetch-failed" };
if (reason) {
const origin = await g([...inClone(dest), "rev-parse", "--verify", "-q", `refs/remotes/origin/${at.branch}`]);
return { outcome: "fetched", reason, branch, commit: before, origin: { branch: at.branch, commit: origin.ok ? origin.out.trim() : null } };
}
if (!(await g([...inClone(dest), "merge", "-q", "--ff-only", "--no-edit", `refs/remotes/origin/${at.branch}`])).ok) return { outcome: "kept", reason: "not-fast-forward" };
const read = await g([...inClone(dest), "rev-parse", "HEAD"]);
const after = read.out.trim();
@@ -267,12 +281,15 @@ async function update(root, dest, g) {
// await ensureCheckouts({ workspace, projects, env, limits }) for the
// business file's projects ({ <id>: { root } }). Returns one { project,
// path, outcome, reason?, branch?, commit? } per project, outcome cloned,
// updated, current or kept. branch and commit are the clone's after this
// launch (commit null when git couldn't read it); a kept clone has neither,
// since git may not be safe to run there. Rejects with CheckoutError when a
// missing clone can't be made. `limits` ({ timeoutMs, graceMs }) is for the
// tests.
// path, outcome, reason?, branch?, commit?, origin? } per project, outcome
// cloned, updated, current, fetched or kept. branch and commit are the
// clone's after this launch (commit null when git couldn't read it). A
// fetched clone also has a reason (other-branch or dirty), branch null when
// its HEAD is detached, and origin { branch, commit }: the root's branch and
// refs/remotes/origin/<branch> after the fetch, commit null when unreadable.
// A kept clone has only a reason, since git may not be safe to run there.
// Rejects with CheckoutError when a missing clone can't be made. `limits`
// ({ timeoutMs, graceMs }) is for the tests.
export async function ensureCheckouts({ workspace, projects, env = process.env, limits = GIT_LIMITS }) {
const g = (args, extra) => git(args, env, extra, limits);
const out = [];
+100 -16
View File
@@ -45,11 +45,12 @@ function commit(root, text) {
}
// Every path under `dir` with its type, mode, link count and contents or
// link target.
function fingerprint(dir) {
// link target, but for any `skip` names at its top.
function fingerprint(dir, skip = []) {
const h = createHash("sha256");
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
if (d === dir && skip.includes(name)) continue;
const p = join(d, name);
const st = lstatSync(p);
h.update(`${p}\0${st.mode}\0${st.nlink}\0`);
@@ -131,31 +132,114 @@ test("a clone ahead of the root keeps its commits and counts as current", async
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
});
test("a dirty clone, one on another branch and one diverged are kept as they are", async (t) => {
for (const [name, edit, reason] of [
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"],
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"],
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"],
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"],
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"],
["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"],
// HEAD, every local branch, the index and every path outside .git. A fetch
// may write objects, refs/remotes, FETCH_HEAD and packed-refs, none of these.
function work(clone) {
const h = createHash("sha256");
h.update(readFileSync(join(clone, ".git", "HEAD")));
h.update(git(clone, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads"));
h.update(readFileSync(join(clone, ".git", "index")));
h.update(fingerprint(clone, [".git"]));
return h.digest("hex");
}
// Row 117 (#1611): a clone on another branch, or dirty, is fetched and not
// merged. Only its refs/remotes moves.
test("a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves", async (t) => {
for (const [name, edit, reason, branch] of [
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty", "main"],
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty", "main"],
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty", "main"],
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch", "work"],
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch", null],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
edit(w.clone);
commit(w.root, "two\n");
const two = commit(w.root, "two\n");
const head = git(w.clone, "rev-parse", "HEAD");
const before = reason === "not-fast-forward" ? null : fingerprint(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
const before = work(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "fetched", reason, branch, commit: head, origin: { branch: "main", commit: two } });
assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), two, "the fetch brought the root's commit");
assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move");
// A diverged clone gets the fetch into refs/remotes, and nothing else.
if (before) assert.equal(fingerprint(w.clone), before, "the clone is exactly as it was");
else assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
assert.equal(work(w.clone), before, "HEAD, the branches, the index and the files are exactly as they were");
});
}
});
test("a diverged clone gets the fetch into refs/remotes and is kept, not-fast-forward", async (t) => {
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
const two = commit(w.root, "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason: "not-fast-forward" });
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), two);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
});
// A git on PATH that logs every call to <dir>/calls, and fails a call
// whose subcommand is in <dir>/fail.
function logged(w) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const calls = join(w.dir, "calls");
const fail = join(w.dir, "fail");
writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nif [ -f '${fail}' ]; then case " $* " in *" $(cat '${fail}') "*) exit 1;; esac; fi\nexec '${real}' "$@"\n`, { mode: 0o755 });
return {
env: { PATH: `${bin}:${process.env.PATH}` },
fail: (sub) => writeFileSync(fail, sub),
// The subcommand of each call that ran git in the clone.
inClone: () => (existsSync(calls) ? readFileSync(calls, "utf8").split("\n").filter((l) => l.includes(`--git-dir=${join(w.clone, ".git")}`)).map((l) => l.split(" ").find((a, i, all) => i > 0 && all[i - 1].startsWith("--work-tree="))) : []),
};
}
// Every reason found before the branch check keeps the clone with no fetch,
// and so does a status that fails. The root moves on; the clone's
// refs/remotes doesn't.
test("a clone kept before the branch check, or for a failed status, gets no fetch", async (t) => {
for (const [name, edit, reason, ran] of [
["not-a-clone", (w) => rmSync(join(w.clone, ".git"), { recursive: true }), "not-a-clone", []],
["hooks-present", (w) => (mkdirSync(join(w.clone, ".git", "hooks")), writeFileSync(join(w.clone, ".git", "hooks", "post-merge"), "#!/bin/sh\n")), "hooks-present", []],
["submodule", (w) => git(w.clone, "update-index", "--add", "--cacheinfo", `160000,${git(w.root, "rev-parse", "HEAD")},sub`), "submodule", ["ls-files"]],
["config-changed", (w) => git(w.clone, "config", "user.name", "someone"), "config-changed", []],
["root-unreadable", (w) => rmSync(join(w.root, ".git"), { recursive: true }), "root-unreadable", ["ls-files"]],
["root-detached", (w) => git(w.root, "checkout", "-q", "--detach"), "root-detached", ["ls-files"]],
["status-failed, on the root's branch and clean", (w, l) => l.fail("status"), "status-failed", ["ls-files", "symbolic-ref", "status"]],
["status-failed, dirty", (w, l) => (writeFileSync(join(w.clone, "README"), "mine\n"), l.fail("status")), "status-failed", ["ls-files", "symbolic-ref", "status"]],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
const one = git(w.clone, "rev-parse", "refs/remotes/origin/main");
// Every case but root-unreadable has a newer root commit to fetch.
if (reason !== "root-unreadable") commit(w.root, "two\n");
const l = logged(w);
edit(w, l);
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason }]);
assert.deepEqual(l.inClone(), ran, "no fetch, and no git past the check that kept it");
if (existsSync(join(w.clone, ".git", "refs"))) assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), one, "refs/remotes didn't move");
});
}
});
test("a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was", async (t) => {
const w = world(t);
await once(w);
writeFileSync(join(w.clone, "README"), "mine\n");
commit(w.root, "two\n");
const l = logged(w);
l.fail("fetch");
const before = fingerprint(w.clone);
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason: "fetch-failed" }]);
assert.equal(fingerprint(w.clone), before);
assert.ok(!l.inClone().includes("merge"));
});
// Row 104 (#1598): a session that sets its own git identity in the clone's
// config gets the clone kept at every later launch. The host only reads the
// clone's config file and runs no git in it, so the kept result has no commit.