Files
stack/agents/darkwing/work/queue-117/r1/build.patch
T
jason.woltjeandClaude Opus 5.5 17d801f482 work: row 117 round 1 packet, fetch a clone on another branch or dirty (#1611)
Candidate manifest, patch against 559ef2287, build notes, five killed
mutants and the targeted gate (seat, cli, harness, two scripts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:31:50 -05:00

414 lines
29 KiB
Diff

diff --git a/packages/cli/README.md b/packages/cli/README.md
index e23d8fb0d..4c05b4213 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -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
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
index 8eb6d902a..574614a64 100644
--- a/packages/cli/src/launcher.mjs
+++ b/packages/cli/src/launcher.mjs
@@ -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 });
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
index 80d945dc2..b7fef78ad 100644
--- a/packages/cli/tests/launcher.test.mjs
+++ b/packages/cli/tests/launcher.test.mjs
@@ -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.
diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs
index 478ef0a03..0e4d63148 100644
--- a/packages/harness/src/bundle.mjs
+++ b/packages/harness/src/bundle.mjs
@@ -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 }) {
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
index 51555415f..b9330030e 100644
--- a/packages/harness/tests/bundle.test.mjs
+++ b/packages/harness/tests/bundle.test.mjs
@@ -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([]) })), []);
diff --git a/packages/seat/README.md b/packages/seat/README.md
index 7ef200400..ee7afbeb6 100644
--- a/packages/seat/README.md
+++ b/packages/seat/README.md
@@ -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).
-
-A kept clone's reason is one of:
+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 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
diff --git a/packages/seat/src/checkout.mjs b/packages/seat/src/checkout.mjs
index 20c767082..03081f5b5 100644
--- a/packages/seat/src/checkout.mjs
+++ b/packages/seat/src/checkout.mjs
@@ -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 = [];
diff --git a/packages/seat/tests/checkout.test.mjs b/packages/seat/tests/checkout.test.mjs
index 6e06f23a8..a3751df01 100644
--- a/packages/seat/tests/checkout.test.mjs
+++ b/packages/seat/tests/checkout.test.mjs
@@ -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.