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
+4 -2
View File
@@ -273,8 +273,10 @@ the launcher reads the instance's credential status from the broker
(metadata only) and makes sure the instance's workspace has a clone of each
project in the business file (`packages/seat/README.md`, "The project
clones"). It makes a missing clone and fast-forwards a clean one. A clone
it leaves alone is a `checkout-kept` line in the launch log with the
project and the reason, and the launch goes on. The bundle's prompt names
on another branch or dirty is fetched and not merged, a `checkout-fetched`
line in the launch log with the project and the reason (#1611). A clone it
leaves alone is a `checkout-kept` line with the project and the reason.
Either way the launch goes on. The bundle's prompt names
each clone's path. Then the launcher builds the bundle in the run
directory, spawns the runner in its bwrap sandbox (`packages/seat/README.md`,
"The sandbox"), binds the outer bwrap process's pid and start time, adds the
+10 -6
View File
@@ -26,11 +26,12 @@
// action.refused event; step 9's refusals the broker records itself. Then
// the host makes sure the workspace has a clone of each project
// (packages/seat/src/checkout.mjs): one it can't make refuses
// checkout-failed, and one it leaves alone is a checkout-kept record in the
// launch log. Then it builds the bundle, spawns the runner in its sandbox
// (packages/seat/src/sandbox.mjs) with the paths it may see, binds the run's
// process identity (the outer bwrap pid) and writes the new capability on
// the runner's stdin. When the runner exits, the host ends the launch.
// checkout-failed, one it fetches but doesn't fast-forward is a
// checkout-fetched record in the launch log, and one it leaves alone is a
// checkout-kept record. Then it builds the bundle, spawns the runner in its
// sandbox (packages/seat/src/sandbox.mjs) with the paths it may see, binds
// the run's process identity (the outer bwrap pid) and writes the new
// capability on the runner's stdin. When the runner exits, the host ends the launch.
//
// `bus start <business> --pm` launches launch.by the same way with
// launchedBy = the business's human and no caller capability.
@@ -196,7 +197,10 @@ export function createLauncher({
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
throw e;
}
for (const c of checkouts) if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
for (const c of checkouts) {
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 });
+10 -4
View File
@@ -281,12 +281,14 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
});
// Row 91 (#1585): the project clone in each workspace.
test("each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or kept and logged", { skip, timeout: 60000 }, async (t) => {
// Row 117 (#1611): and a dirty one is fetched, not merged, and logged.
test("each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged", { skip, timeout: 60000 }, async (t) => {
const given = prepare(t);
const project = given.f.doc.projects.stack.root;
const clone = join(workspaceDir(given.f.dataRoot, "acme", "pm"), "stack");
const prompt = (run) => readFileSync(join(given.f.dataRoot, "launches", "acme", run, "bundle", "prompt.md"), "utf8");
const kept = () => log(given.f).filter((e) => e.event === "checkout-kept");
const fetched = () => log(given.f).filter((e) => e.event === "checkout-fetched");
// The first launch clones the root's branch, with the root as origin.
const first = await setup(t, undefined, given);
@@ -311,18 +313,22 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
assert.ok(prompt(pm2.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${two} when this session started. `));
assert.deepEqual(kept(), []);
assert.deepEqual(fetched(), []);
await second.client.call("role.release");
assert.equal(await second.close(), 0);
// A clone with the session's own work in it is kept, and the log says why.
// A clone with the session's own work in it is fetched and not merged,
// and the log says why.
writeFileSync(join(clone, "notes.txt"), "mine\n");
const three = commit(project, "three");
const third = await setup(t, undefined, given);
const pm3 = await third.launcher.launchPm();
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
assert.equal(gitIn(clone, "rev-parse", "refs/remotes/origin/main"), three);
assert.equal(readFileSync(join(clone, "notes.txt"), "utf8"), "mine\n");
assert.deepEqual(kept().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "pm", project: "stack", reason: "dirty" }]);
assert.ok(prompt(pm3.run).includes(`- Project clones: stack at ${clone}, not updated at this launch (dirty). `));
assert.deepEqual(fetched().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "pm", project: "stack", reason: "dirty" }]);
assert.deepEqual(kept(), []);
assert.ok(prompt(pm3.run).includes(`- Project clones: stack at ${clone}, on branch main at commit ${two} when this session started, fetched but not fast-forwarded at this launch (dirty), with origin/main at commit ${three}. `));
assert.equal(await third.close(), 0);
// The root was never written: its branch, head and tree are the test's.
+9 -2
View File
@@ -58,11 +58,18 @@ export function sessionModel(resolved) {
// Row 91 (#1585): the project clones the host made or kept in the workspace.
// Row 104 (#1598): the commit each was at when the session started, when it
// refreshes, and why the session must not set git config in it.
// Row 117 (#1611): a fetched clone's branch, why it wasn't fast-forwarded,
// and where origin/<branch> is.
function checkoutLines(checkouts) {
if (!checkouts.length) return [];
const at = (c) => (c.commit ? `commit ${c.commit}` : "a commit the host couldn't read");
const each = checkouts.map((c) => (c.outcome === "kept" ? `${c.project} at ${c.path}, not updated at this launch (${c.reason})` : `${c.project} at ${c.path}, on branch ${c.branch} at ${at(c)} when this session started`));
return [`- Project clones: ${each.join("; ")}. Each is a git clone of the project. The host fetches from its origin and fast-forwards a clean clone only at a launch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
const one = (c) => {
if (c.outcome === "kept") return `${c.project} at ${c.path}, not updated at this launch (${c.reason})`;
const started = `${c.project} at ${c.path}, ${c.branch ? `on branch ${c.branch}` : "on no branch"} at ${at(c)} when this session started`;
if (c.outcome !== "fetched") return started;
return `${started}, fetched but not fast-forwarded at this launch (${c.reason}), with origin/${c.origin.branch} at ${at(c.origin)}`;
};
return [`- Project clones: ${checkouts.map(one).join("; ")}. Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that \`git clone\` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.`];
}
function prompt({ contract, resolved, session, tools, untyped }) {
+5 -2
View File
@@ -105,6 +105,7 @@ test("the prompt's built-in tools line is the harness's own names, as the manife
// Row 91 (#1585): the prompt names each project clone, and why a kept one wasn't updated.
// Row 104 (#1598): and the commit each was at, when it refreshes, and the git config warning.
// Row 117 (#1611): and a fetched clone's branch, reason and origin/<branch>.
test("the prompt names the project clones in the workspace, and none when there are none", (t) => {
const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => l.startsWith("- Project clones: "));
const plain = build(t, resolvedFor(t, "pm"));
@@ -115,9 +116,11 @@ test("the prompt names the project clones in the workspace, and none when there
{ project: "stack", path: join(dir, "ws", "stack"), outcome: "updated", branch: "main", commit: "a".repeat(40) },
{ project: "site", path: join(dir, "ws", "site"), outcome: "kept", reason: "dirty" },
{ project: "docs", path: join(dir, "ws", "docs"), outcome: "current", branch: "dev", commit: null },
{ project: "lib", path: join(dir, "ws", "lib"), outcome: "fetched", reason: "dirty", branch: "main", commit: "b".repeat(40), origin: { branch: "main", commit: "c".repeat(40) } },
{ project: "app", path: join(dir, "ws", "app"), outcome: "fetched", reason: "other-branch", branch: null, commit: "d".repeat(40), origin: { branch: "main", commit: null } },
]) });
const after = "Each is a git clone of the project. The host fetches from its origin and fast-forwards a clean clone only at a launch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that `git clone` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.";
assert.deepEqual(line(two), [`- Project clones: stack at ${join(dir, "ws", "stack")}, on branch main at commit ${"a".repeat(40)} when this session started; site at ${join(dir, "ws", "site")}, not updated at this launch (dirty); docs at ${join(dir, "ws", "docs")}, on branch dev at a commit the host couldn't read when this session started. ${after}`]);
const after = "Each is a git clone of the project. At a launch the host fetches each clone it can safely run git in from its origin, and fast-forwards only a clean clone on the root's branch, never during a session; the origin is outside this sandbox. Don't set git config in a clone. Any key that `git clone` didn't write, user.name and user.email included, makes every later launch keep that clone as it is. Your commits already carry your role instance's name and address from the environment.";
assert.deepEqual(line(two), [`- Project clones: stack at ${join(dir, "ws", "stack")}, on branch main at commit ${"a".repeat(40)} when this session started; site at ${join(dir, "ws", "site")}, not updated at this launch (dirty); docs at ${join(dir, "ws", "docs")}, on branch dev at a commit the host couldn't read when this session started; lib at ${join(dir, "ws", "lib")}, on branch main at commit ${"b".repeat(40)} when this session started, fetched but not fast-forwarded at this launch (dirty), with origin/main at commit ${"c".repeat(40)}; app at ${join(dir, "ws", "app")}, on no branch at commit ${"d".repeat(40)} when this session started, fetched but not fast-forwarded at this launch (other-branch), with origin/main at a commit the host couldn't read. ${after}`]);
const prompt = readFileSync(two.files.prompt, "utf8");
assert.ok(prompt.indexOf("- Workspace: ") < prompt.indexOf("- Project clones: "));
assert.deepEqual(line(build(t, resolvedFor(t, "pm"), { session: session([]) })), []);
+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.