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:
+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` |
|
||||
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
|
||||
| passes the checks below, but is on another branch or none, or dirty | fetches its origin and doesn't merge, so only `refs/remotes` moves (#1611) | `fetched`, with the reason |
|
||||
| anything else | nothing | `kept`, with a reason |
|
||||
|
||||
The host never resets, cleans, deletes or force-updates a clone, and never
|
||||
@@ -268,12 +269,19 @@ reach it.
|
||||
|
||||
Each result also has the clone's branch and, for `cloned`, `updated` and
|
||||
`current`, the commit it is at after the launch (`null` when git can't read
|
||||
it). A kept clone has neither, and the host runs no more git in it to find
|
||||
one. The bundle's prompt names the branch and commit, says the host
|
||||
refreshes a clone only at a launch, and tells the session not to set git
|
||||
config in its clone (#1598).
|
||||
it). A fetched clone has its own branch (`null` on a detached HEAD), its
|
||||
commit, its reason, and `origin`: the root's branch and the commit
|
||||
`refs/remotes/origin/<branch>` is at after the fetch (`null` when git can't
|
||||
read it). A kept clone has only its reason, and the host runs no more git
|
||||
in it to find more. The bundle's prompt names the branch and commit, says
|
||||
why a fetched clone wasn't fast-forwarded and where `origin/<branch>` is,
|
||||
says the host fetches every clone it can safely run git in at a launch and
|
||||
fast-forwards only a clean one on the root's branch, and tells the session
|
||||
not to set git config in its clone (#1598).
|
||||
|
||||
A kept clone's reason is one of:
|
||||
A kept or fetched clone's reason is one of the following. Only
|
||||
`other-branch` and `dirty` fetch, and only once every check above them has
|
||||
passed; every other reason runs no fetch.
|
||||
|
||||
| Reason | The clone |
|
||||
|---|---|
|
||||
@@ -282,9 +290,9 @@ A kept clone's reason is one of:
|
||||
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
|
||||
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
|
||||
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
|
||||
| `other-branch` | is on another branch, or on none |
|
||||
| `dirty` | has a change, staged or not, or an untracked file |
|
||||
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail |
|
||||
| `other-branch` | is on another branch, or on none; `fetched` |
|
||||
| `dirty` | has a change, staged or not, or an untracked file; `fetched` |
|
||||
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail; a clone on another branch or dirty whose fetch fails is `kept`, `fetch-failed` |
|
||||
| `not-fast-forward` | has commits the root's branch doesn't |
|
||||
|
||||
The session can write its clone, so the host treats every clone as
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user