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:
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user