Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions scripts/lastcode-main-write-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ function fixture() {
};
}

function fakeGh(f: ReturnType<typeof fixture>, script: string) {
const bin = NodePath.join(f.root, "bin");
NodeFS.mkdirSync(bin);
NodeFS.writeFileSync(NodePath.join(bin, "gh"), `#!/bin/sh\n${script}\n`, { mode: 0o755 });
vi.stubEnv("PATH", `${bin}${NodePath.delimiter}${process.env.PATH ?? ""}`);
}

function mergeArgs(head: string) {
return ["pr", "merge", "1", "--repo", "example/repository", "--match-head-commit", head];
}

describe("main write lock", () => {
it("acquires the lock without resending already published source objects", () => {
const f = fixture();
Expand Down Expand Up @@ -283,4 +294,157 @@ describe("main write lock", () => {
}
},
);
it.each([
["malformed JSON", "echo invalid", "Unexpected token"],
["missing head", `echo '{"state":"MERGED"}'`, "has not completed"],
["wrong head", `echo '{"state":"MERGED","headRefOid":"wrong"}'`, "has not completed"],
["failed confirmation", "exit 1", "Cannot confirm"],
["signalled confirmation", "kill -TERM $$", "Cannot confirm"],
])("retains exact ownership after %s", (_name, script, error) => {
const f = fixture();
fakeGh(f, `if [ "$2" = view ]; then ${script}; fi`);
const lock = acquireMainWriteLock(f.first, "origin", f.source, "merge");
const owner = f.owner();
expect(() => lock.merge(mergeArgs(f.source))).toThrow(error);
expect(() => lock.release()).toThrow("Retained main write lock");
expect(() => lock.merge(mergeArgs(f.source))).toThrow("not available");
expect(f.owner()).toBe(owner);
});

it("executes merge before rejecting unusable confirmation metadata", () => {
const f = fixture();
const log = NodePath.join(f.root, "commands");
fakeGh(f, `echo "$*" >> "${log}"`);
const lock = acquireMainWriteLock(f.first, "origin", f.source, "merge");
expect(() => lock.merge(["pr", "merge"])).toThrow("Cannot confirm");
expect(NodeFS.readFileSync(log, "utf8")).toBe("pr merge\n");
expect(() => lock.release()).toThrow("Retained main write lock");
});

it("keeps the existing missing-option interpretation and exact confirmation arguments", () => {
const f = fixture();
const log = NodePath.join(f.root, "commands");
fakeGh(
f,
`echo "$*" >> "${log}"\nif [ "$2" = view ]; then echo '{"state":"MERGED","headRefOid":"pr"}'; fi`,
);
const lock = acquireMainWriteLock(f.first, "origin", f.source, "merge");
lock.merge(["pr", "merge", "1"]);
expect(NodeFS.readFileSync(log, "utf8")).toBe(
"pr merge 1\npr view 1 --repo pr --json state,headRefOid\n",
);
lock.release();
lock.release();
expect(() => lock.merge(mergeArgs(f.source))).toThrow("not available");
});

it.each(["exit 127", "kill -TERM $$"])(
"retains ownership after merge child failure: %s",
(script) => {
const f = fixture();
fakeGh(f, script);
const lock = acquireMainWriteLock(f.first, "origin", f.source, "merge");
const owner = f.owner();
expect(() => lock.merge(mergeArgs(f.source))).toThrow("outcome is uncertain");
expect(() => lock.release()).toThrow("Retained main write lock");
expect(f.owner()).toBe(owner);
},
);

it.each([
["only rejected", "!\tHEAD:refs/heads/lastcode/main\t[rejected]", 1, true],
["remote rejected", "!\tHEAD:refs/heads/lastcode/main\t[remote rejected]", 1, true],
["other rejected ref", "!\tHEAD:refs/heads/other\t[rejected]", 1, true],
[
"unchanged and rejected",
"=\tHEAD:refs/heads/other\t[up to date]\n!\tHEAD:refs/heads/lastcode/main\t[rejected]",
1,
true,
],
[
"updated and rejected",
"*\tHEAD:refs/heads/other\t[new branch]\n!\tHEAD:refs/heads/lastcode/main\t[rejected]",
1,
false,
],
["no report", "", 1, false],
["transport status with rejection", "!\tHEAD:refs/heads/lastcode/main\t[rejected]", 2, false],
] as const)("requires definitive rejected-only evidence: %s", (_name, stdout, status, safe) => {
const f = fixture();
const lock = acquireMainWriteLock(f.first, "origin", f.source, "checkpoint");
const owner = f.owner();
const bin = NodePath.join(f.root, "bin");
NodeFS.mkdirSync(bin);
NodeFS.writeFileSync(
NodePath.join(bin, "git"),
`#!/bin/sh\nprintf '%s\\n' '${stdout}'\nexit ${status}\n`,
{ mode: 0o755 },
);
const originalPath = process.env.PATH ?? "";
vi.stubEnv("PATH", bin);
expect(() =>
lock.push(["push", "--no-verify", "origin", "HEAD:refs/heads/lastcode/main"]),
).toThrow("Checkpoint push failed");
vi.stubEnv("PATH", originalPath);
if (safe) {
lock.release();
acquireMainWriteLock(f.second, "origin", f.source, "checkpoint").release();
} else {
expect(() => lock.release()).toThrow("Retained main write lock");
expect(f.owner()).toBe(owner);
}
});

it.each(["push", "merge"])("retains ownership when %s cannot spawn", (operation) => {
const f = fixture();
const lock = acquireMainWriteLock(f.first, "origin", f.source, "checkpoint");
const owner = f.owner();
const originalPath = process.env.PATH ?? "";
vi.stubEnv("PATH", f.root);
expect(() =>
operation === "push"
? lock.push(["push", "--no-verify", "origin"])
: lock.merge(mergeArgs(f.source)),
).toThrow(operation === "push" ? TypeError : /ENOENT/u);
expect(() => lock.release()).toThrow("Retained main write lock");
vi.stubEnv("PATH", originalPath);
expect(f.owner()).toBe(owner);
});

it("does not reuse a rejected write's certainty for a later unknown push", () => {
const f = fixture();
const lock = acquireMainWriteLock(f.first, "origin", f.source, "checkpoint");
const owner = f.owner();
f.git(f.first, [
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"commit",
"--allow-empty",
"-m",
"next main",
]);
const candidate = f.git(f.first, ["rev-parse", "HEAD"]);

expect(() =>
lock.push([
"push",
"--no-verify",
`--force-with-lease=refs/heads/lastcode/main:${"0".repeat(40)}`,
"origin",
`${candidate}:refs/heads/lastcode/main`,
]),
).toThrow("Checkpoint push failed");
expect(() =>
lock.push([
"push",
"--no-verify",
NodePath.join(f.root, "missing.git"),
`${candidate}:refs/heads/lastcode/main`,
]),
).toThrow("Checkpoint push failed");
expect(() => lock.release()).toThrow("Retained main write lock");
expect(f.owner()).toBe(owner);
});
});
26 changes: 14 additions & 12 deletions scripts/lastcode-main-write-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import * as NodePath from "node:path";

export const MAIN_WRITE_LOCK_REF = "refs/lastcode/main-write-lock";

// Preserve native result ordering: parse the report before classifying failed writes.
// Only a hook receipt or a rejected-only ref report proves no write landed.
function isRejectedPush(result: NodeChildProcess.SpawnSyncReturns<string>, hookRejected: boolean) {
const updates = result.stdout.split("\n").filter((line) => /^[!=*+ -]\t/u.test(line));
if (result.error) return false;
if (result.signal) return false;
if (result.status !== 1) return false;
if (hookRejected) return true;
if (!updates.some((line) => line.startsWith("!\t"))) return false;
return updates.every((line) => /^(?:!\t[^\n\t]*\t\[(?:remote )?rejected\]|=\t)/u.test(line));
}

export function acquireMainWriteLock(
repoRoot: string,
remote: string,
Expand Down Expand Up @@ -99,18 +111,7 @@ export function acquireMainWriteLock(
// Missing proof leaves the write outcome uncertain.
}
}
const updates = result.stdout.split("\n").filter((line) => /^[!=*+ -]\t/u.test(line));
// A rejected local hook or rejected-only ref report proves no write landed.
if (
!result.error &&
!result.signal &&
result.status === 1 &&
(hookRejected ||
(updates.some((line) => line.startsWith("!\t")) &&
updates.every((line) =>
/^(?:!\t[^\n\t]*\t\[(?:remote )?rejected\]|=\t)/u.test(line),
)))
) {
if (isRejectedPush(result, hookRejected)) {
uncertain = false;
}
throw new Error(
Expand All @@ -129,6 +130,7 @@ export function acquireMainWriteLock(
`PR merge outcome is uncertain under ${identity}.\n${result.error?.message ?? result.stderr.trim()}`,
);
}
// Decode metadata after the merge command; only exact terminal confirmation clears uncertainty.
const repository = args[args.indexOf("--repo") + 1];
const expectedHead = args[args.indexOf("--match-head-commit") + 1];
const number = args[2];
Expand Down