Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .agents/skills/iterate-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,11 @@ that same upstream, so its expectation cannot be inflated by the parent's
superseded commits.

```bash
node ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.cjs --root <branch> [--dry-run] [--no-push]
node ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.cjs --root <branch> [--dry-run] [--no-push] [--no-sign]
```

**Every replayed commit is GPG-signed.** A rebase writes new commits, and git signs them only when told to, so a stack signed with `commit -S` comes back unsigned from a plain rebase. The script passes `--gpg-sign`, and a signing failure (usually a locked key) is reported as one, not as a conflict. `--no-sign` opts out for a repository that does not sign.

**One failure path leaves the repo on another branch.** Normal completion, a
rebase conflict, a balloon guard trip, and a push failure all `checkout` the
branch you started on before returning. The exception is when checking out a
Expand Down
25 changes: 23 additions & 2 deletions .agents/skills/iterate-pr/scripts/propagate_stack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@
* * Push uses --force-with-lease against a freshly fetched origin; the lease
* is only meaningful if remote-tracking refs are current, so we fetch first.
*
* node ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.cjs --root <branch> [--dry-run] [--no-push]
* SIGNING: every replayed commit is GPG-signed (`rebase --gpg-sign`). A rebase
* writes new commits, and without the flag git signs them only if
* `commit.gpgSign` is set, which it is not when a repository signs with an
* explicit `commit -S`. Measured on a six-branch stack: 21 of 25 commits came
* back unsigned after one propagation, every one of them signed before it.
* `--no-sign` opts out, for a repository that does not sign at all.
*
* node ${CLAUDE_SKILL_ROOT}/scripts/propagate_stack.cjs --root <branch> [--dry-run] [--no-push] [--no-sign]
*/

const { parseArgs } = require("node:util");
Expand Down Expand Up @@ -136,6 +143,7 @@ const main = ({
"dry-run": { type: "boolean", default: false },
"no-push": { type: "boolean", default: false },
"max-own": { type: "string", default: "15" },
"no-sign": { type: "boolean", default: false },
},
});

Expand Down Expand Up @@ -227,10 +235,23 @@ const main = ({
}

const before = gitOut(git, "rev-parse", child);
const rebase = git("rebase", "--onto", parent, upstream);
const sign = values["no-sign"] ? [] : ["--gpg-sign"];
const rebase = git("rebase", ...sign, "--onto", parent, upstream);
if (rebase.code !== 0) {
const conflicts = gitOut(git, "diff", "--name-only", "--diff-filter=U");
git("rebase", "--abort");
if (!conflicts && /failed to sign|gpg failed/i.test(rebase.stderr)) {
// Not a conflict, and reporting it as one sends the reader looking for
// files that do not exist. The key is usually just locked.
emit(
` ✗ SIGNING FAILED rebasing ${child}; nothing was rewritten. ` +
"Unlock the key (`echo test | gpg --sign > /dev/null`) and run " +
"again, or pass --no-sign if this repository does not sign:" +
`\n${rebase.stderr.trim()}`
);
if (start) git("checkout", start);
return 7;
}
emit(
` ✗ CONFLICT: ${child} onto ${parent} (from ${upstream.slice(0, 9)}, ` +
`${source}). Needs manual reconcile:`
Expand Down
56 changes: 45 additions & 11 deletions .agents/skills/iterate-pr/scripts/propagate_stack.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,14 @@ test("a grandchild is rebased from its parent's pre-rebase tip, not from the par

// The fake answers `rev-parse kid` with "sha-of-kid", so that string IS kid's
// tip as it stood before the first rebase below rewrote it.
const rebases = git.calls.filter((c) => c.startsWith("rebase --onto"));
const rebases = git.calls.filter((c) =>
c.startsWith("rebase --gpg-sign --onto")
);
assert.deepEqual(rebases, [
"rebase --onto root kid-forked-at",
// NOT "rebase --onto kid kid" — the upstream is where grandkid forked,
"rebase --gpg-sign --onto root kid-forked-at",
// NOT "rebase --gpg-sign --onto kid kid" — the upstream is where grandkid forked,
// which is kid's tip BEFORE the line above rewrote it.
"rebase --onto kid sha-of-kid",
"rebase --gpg-sign --onto kid sha-of-kid",
]);
});

Expand Down Expand Up @@ -224,7 +226,7 @@ test("--max-own bounds a branch whose expected own-count is unknown", () => {
test("a conflict aborts the rebase, names the files, and stops the cascade", () => {
const git = fakeGit({
overrides: [
["rebase --onto", { code: 1, stderr: "CONFLICT" }],
["rebase --gpg-sign --onto", { code: 1, stderr: "CONFLICT" }],
["diff --name-only", { code: 0, stdout: "src/a.ts\nsrc/b.ts" }],
],
});
Expand All @@ -242,6 +244,38 @@ test("a conflict aborts the rebase, names the files, and stops the cascade", ()
);
});

// A rebase writes new commits, and git signs them only when told to. Without
// the flag, a stack whose commits were all signed came back 21 of 25 unsigned.
test("every replayed commit is signed unless --no-sign is passed", () => {
const signed = run(["--root", "root", "--no-push"], { git: fakeGit() }).git;
assert.ok(signed.calls.some((c) => c.startsWith("rebase --gpg-sign --onto")));

const unsigned = run(["--root", "root", "--no-push", "--no-sign"], {
git: fakeGit(),
}).git;
assert.ok(unsigned.calls.some((c) => c.startsWith("rebase --onto")));
assert.ok(!unsigned.calls.some((c) => c.includes("--gpg-sign")));
});

test("a signing failure is reported as one, not as a conflict", () => {
const git = fakeGit({
overrides: [
[
"rebase --gpg-sign --onto",
{ code: 1, stderr: "error: gpg failed to sign the data" },
],
],
});
const { code, lines } = run(["--root", "root"], { git });

assert.equal(code, 7);
assert.match(lines, /SIGNING FAILED rebasing child/);
assert.doesNotMatch(lines, /CONFLICT/);
assert.ok(git.calls.includes("rebase --abort"));
assert.ok(!git.calls.some((c) => c.startsWith("push")));
assert.equal(git.calls.at(-1), "checkout start");
});

// Continuing past a failed checkout would rebase and force-push whichever
// branch happened to be checked out — the worst outcome available here.
test("a failed checkout aborts before any rebase", () => {
Expand Down Expand Up @@ -275,7 +309,7 @@ test("--no-push skips the fetch and the push, but still rebases", () => {

assert.equal(code, 0);
assert.match(lines, /not pushed \(--no-push\)/);
assert.ok(git.calls.some((c) => c.startsWith("rebase --onto")));
assert.ok(git.calls.some((c) => c.startsWith("rebase --gpg-sign --onto")));
assert.ok(!git.calls.some((c) => c.startsWith("fetch")));
assert.ok(!git.calls.some((c) => c.startsWith("push")));
});
Expand Down Expand Up @@ -346,7 +380,7 @@ test("a branch missing locally is skipped without stopping the cascade", () => {
assert.equal(code, 0);
assert.match(lines, /· skip gone/);
assert.ok(
git.calls.includes("rebase --onto root forked-at"),
git.calls.includes("rebase --gpg-sign --onto root forked-at"),
"the other branch still runs"
);
});
Expand Down Expand Up @@ -399,7 +433,7 @@ test("with no reflog knowledge of a rewrite, the guard agrees with a wrong upstr
assert.equal(code, 0, "the run reports success");
assert.match(lines, /rebased onto root/);
assert.ok(
git.calls.includes("rebase --onto root root"),
git.calls.includes("rebase --gpg-sign --onto root root"),
"the upstream fell back to the parent itself"
);
assert.ok(
Expand Down Expand Up @@ -459,7 +493,7 @@ test("a conflict on a guessed upstream says which side is the superseded one", (
const git = fakeGit({
overrides: [
["merge-base --is-ancestor", { code: 1 }],
["rebase --onto", { code: 1, stderr: "CONFLICT" }],
["rebase --gpg-sign --onto", { code: 1, stderr: "CONFLICT" }],
["diff --name-only", { code: 0, stdout: "parent.txt" }],
],
});
Expand All @@ -476,7 +510,7 @@ test("a conflict on a known fork point carries no guess warning", () => {
const git = fakeGit({
forkPoints: { "root->child": "forked-at" },
overrides: [
["rebase --onto", { code: 1, stderr: "CONFLICT" }],
["rebase --gpg-sign --onto", { code: 1, stderr: "CONFLICT" }],
["diff --name-only", { code: 0, stdout: "parent.txt" }],
],
});
Expand All @@ -490,7 +524,7 @@ test("the conflict line names where the upstream came from", () => {
const git = fakeGit({
forkPoints: { "root->child": "forked-at" },
overrides: [
["rebase --onto", { code: 1, stderr: "CONFLICT" }],
["rebase --gpg-sign --onto", { code: 1, stderr: "CONFLICT" }],
["diff --name-only", { code: 0, stdout: "a.ts" }],
],
});
Expand Down
Loading