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 (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 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 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 on another branch or dirty is fetched and not merged, a `checkout-fetched`
project and the reason, and the launch goes on. The bundle's prompt names 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 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`, 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 "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 // action.refused event; step 9's refusals the broker records itself. Then
// the host makes sure the workspace has a clone of each project // the host makes sure the workspace has a clone of each project
// (packages/seat/src/checkout.mjs): one it can't make refuses // (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 // checkout-failed, one it fetches but doesn't fast-forward is a
// launch log. Then it builds the bundle, spawns the runner in its sandbox // checkout-fetched record in the launch log, and one it leaves alone is a
// (packages/seat/src/sandbox.mjs) with the paths it may see, binds the run's // checkout-kept record. Then it builds the bundle, spawns the runner in its
// process identity (the outer bwrap pid) and writes the new capability on // sandbox (packages/seat/src/sandbox.mjs) with the paths it may see, binds
// the runner's stdin. When the runner exits, the host ends the launch. // 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 // `bus start <business> --pm` launches launch.by the same way with
// launchedBy = the business's human and no caller capability. // 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}`); if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
throw e; 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 { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = join(runDir, "session"); const sessionDir = join(runDir, "session");
mkdirSync(sessionDir, { mode: 0o700 }); 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. // 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 given = prepare(t);
const project = given.f.doc.projects.stack.root; const project = given.f.doc.projects.stack.root;
const clone = join(workspaceDir(given.f.dataRoot, "acme", "pm"), "stack"); 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 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 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. // The first launch clones the root's branch, with the root as origin.
const first = await setup(t, undefined, given); 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.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.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(kept(), []);
assert.deepEqual(fetched(), []);
await second.client.call("role.release"); await second.client.call("role.release");
assert.equal(await second.close(), 0); 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"); writeFileSync(join(clone, "notes.txt"), "mine\n");
const three = commit(project, "three"); const three = commit(project, "three");
const third = await setup(t, undefined, given); const third = await setup(t, undefined, given);
const pm3 = await third.launcher.launchPm(); const pm3 = await third.launcher.launchPm();
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two); 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.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.deepEqual(fetched().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(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); assert.equal(await third.close(), 0);
// The root was never written: its branch, head and tree are the test's. // 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 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 // 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. // 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) { function checkoutLines(checkouts) {
if (!checkouts.length) return []; if (!checkouts.length) return [];
const at = (c) => (c.commit ? `commit ${c.commit}` : "a commit the host couldn't read"); 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`)); const one = (c) => {
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.`]; 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 }) { 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 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 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) => { 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 line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => l.startsWith("- Project clones: "));
const plain = build(t, resolvedFor(t, "pm")); 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: "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: "site", path: join(dir, "ws", "site"), outcome: "kept", reason: "dirty" },
{ project: "docs", path: join(dir, "ws", "docs"), outcome: "current", branch: "dev", commit: null }, { 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."; 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. ${after}`]); 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"); const prompt = readFileSync(two.files.prompt, "utf8");
assert.ok(prompt.indexOf("- Workspace: ") < prompt.indexOf("- Project clones: ")); assert.ok(prompt.indexOf("- Workspace: ") < prompt.indexOf("- Project clones: "));
assert.deepEqual(line(build(t, resolvedFor(t, "pm"), { session: session([]) })), []); 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` | | 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 | | 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 | | anything else | nothing | `kept`, with a reason |
The host never resets, cleans, deletes or force-updates a clone, and never 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 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 `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 it). A fetched clone has its own branch (`null` on a detached HEAD), its
one. The bundle's prompt names the branch and commit, says the host commit, its reason, and `origin`: the root's branch and the commit
refreshes a clone only at a launch, and tells the session not to set git `refs/remotes/origin/<branch>` is at after the fetch (`null` when git can't
config in its clone (#1598). 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 | | 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 | | `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` | | `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 | | `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 | | `other-branch` | is on another branch, or on none; `fetched` |
| `dirty` | has a change, staged or not, or an untracked file | | `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 | | `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 | | `not-fast-forward` | has commits the root's branch doesn't |
The session can write its clone, so the host treats every clone as 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 // clone that fails is removed and refuses the launch
// a clean clone on fetched from its origin and fast-forwarded // a clean clone on fetched from its origin and fast-forwarded
// the root's branch // 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 // anything else left exactly as it is, with the reason
// //
// The host never resets, cleans, deletes or force-updates a clone, and // 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); const at = await rootBranch(root, g);
if (at.error) return { outcome: "kept", reason: "root-unreadable" }; if (at.error) return { outcome: "kept", reason: "root-unreadable" };
if (!at.branch) return { outcome: "kept", reason: "root-detached" }; 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"]); 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 branch = head.ok ? head.out.trim() : null;
const status = await g(["--no-optional-locks", ...inClone(dest), "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=all"]); let reason = null;
if (!status.ok) return { outcome: "kept", reason: "status-failed" }; if (branch !== at.branch) reason = "other-branch";
if (status.out !== "") return { outcome: "kept", reason: "dirty" }; else {
const before = (await g([...inClone(dest), "rev-parse", "HEAD"])).out.trim(); 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 (!(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" }; 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 read = await g([...inClone(dest), "rev-parse", "HEAD"]);
const after = read.out.trim(); const after = read.out.trim();
@@ -267,12 +281,15 @@ async function update(root, dest, g) {
// await ensureCheckouts({ workspace, projects, env, limits }) for the // await ensureCheckouts({ workspace, projects, env, limits }) for the
// business file's projects ({ <id>: { root } }). Returns one { project, // business file's projects ({ <id>: { root } }). Returns one { project,
// path, outcome, reason?, branch?, commit? } per project, outcome cloned, // path, outcome, reason?, branch?, commit?, origin? } per project, outcome
// updated, current or kept. branch and commit are the clone's after this // cloned, updated, current, fetched or kept. branch and commit are the
// launch (commit null when git couldn't read it); a kept clone has neither, // clone's after this launch (commit null when git couldn't read it). A
// since git may not be safe to run there. Rejects with CheckoutError when a // fetched clone also has a reason (other-branch or dirty), branch null when
// missing clone can't be made. `limits` ({ timeoutMs, graceMs }) is for the // its HEAD is detached, and origin { branch, commit }: the root's branch and
// tests. // 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 }) { export async function ensureCheckouts({ workspace, projects, env = process.env, limits = GIT_LIMITS }) {
const g = (args, extra) => git(args, env, extra, limits); const g = (args, extra) => git(args, env, extra, limits);
const out = []; 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 // Every path under `dir` with its type, mode, link count and contents or
// link target. // link target, but for any `skip` names at its top.
function fingerprint(dir) { function fingerprint(dir, skip = []) {
const h = createHash("sha256"); const h = createHash("sha256");
const walk = (d) => { const walk = (d) => {
for (const name of readdirSync(d).sort()) { for (const name of readdirSync(d).sort()) {
if (d === dir && skip.includes(name)) continue;
const p = join(d, name); const p = join(d, name);
const st = lstatSync(p); const st = lstatSync(p);
h.update(`${p}\0${st.mode}\0${st.nlink}\0`); 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); 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) => { // HEAD, every local branch, the index and every path outside .git. A fetch
for (const [name, edit, reason] of [ // may write objects, refs/remotes, FETCH_HEAD and packed-refs, none of these.
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty"], function work(clone) {
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty"], const h = createHash("sha256");
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty"], h.update(readFileSync(join(clone, ".git", "HEAD")));
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch"], h.update(git(clone, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads"));
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch"], h.update(readFileSync(join(clone, ".git", "index")));
["a diverged branch", (c) => commit(c, "mine\n"), "not-fast-forward"], 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) => { await t.test(name, async (t) => {
const w = world(t); const w = world(t);
await once(w); await once(w);
edit(w.clone); edit(w.clone);
commit(w.root, "two\n"); const two = commit(w.root, "two\n");
const head = git(w.clone, "rev-parse", "HEAD"); const head = git(w.clone, "rev-parse", "HEAD");
const before = reason === "not-fast-forward" ? null : fingerprint(w.clone); const before = work(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason }); 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"); 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. assert.equal(work(w.clone), before, "HEAD, the branches, the index and the files are exactly as they were");
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");
}); });
} }
}); });
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 // 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 // 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. // clone's config file and runs no git in it, so the kept result has no commit.