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
6 changes: 1 addition & 5 deletions .agents/skills/iterate-pr/scripts/fetch_pr_feedback.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,7 @@ test("NEGATORS recognizes contractions and bare negative words, not just 'not'/'
"medium",
"doesn't"
);
assert.equal(
categorizeComment(human, "This can't fail."),
"medium",
"can't"
);
assert.equal(categorizeComment(human, "This can't fail."), "medium", "can't");
assert.equal(
categorizeComment(human, "This cannot break the build."),
"medium",
Expand Down
90 changes: 70 additions & 20 deletions .agents/skills/iterate-pr/scripts/propagate_stack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const {
UsageError,
countRange,
gitOut,
isAncestor,
lineage,
orderedDescendants,
parseIntegerOption,
Expand Down Expand Up @@ -71,31 +72,57 @@ const {
* restacked by hand, or in an earlier run).
* 3. The parent itself — correct whenever the parent was only appended to.
*
* TIER 3 IS ALSO WHERE THE #220 FAILURE CAN STILL REACH YOU, and it is worth
* knowing before you trust a clean run. `--fork-point` reads the PARENT'S LOCAL
* REFLOG, so it only knows about a rewrite this checkout performed or observed.
* In a fresh clone or a new worktree that fetched an already-rewritten parent
* from origin — the pattern CLAUDE.md recommends for background agents — the
* reflog has no record of the old tip, tier 2 finds nothing, and the upstream
* falls back to the parent: the same wrong upstream `git rebase <parent>` picks.
* WHAT TIER 3 ACTUALLY COSTS, measured rather than reasoned about, because an
* earlier version of this comment had it wrong. `--fork-point` reads the
* PARENT'S LOCAL REFLOG, and a WORKTREE SHARES REFS AND REFLOGS WITH ITS CLONE.
* So the pattern CLAUDE.md recommends for background agents — several agents in
* worktrees of one repository — keeps tier 2 working: one worktree amends the
* parent, another reads the pre-amend tip out of the shared reflog and replays
* the child from exactly the right place. Verified end to end.
*
* The balloon guard does not catch that one, because `expectedOwn` is computed
* from this same upstream, so the expectation and the outcome agree. Two agents
* in separate worktrees is the shape to watch: one rewrites the parent, the
* other propagates and silently drops what the rewrite carried.
* Reaching tier 3 with a rewritten parent therefore needs a separate CLONE that
* never saw the old tip: a fresh CI checkout, a second machine, someone else's
* copy. There, two things can happen, and NEITHER is a silent loss:
*
* This is inherited behaviour, not new — the Python this replaced did the same
* — and closing it needs a source of truth the reflog cannot provide (the
* parent's pre-rewrite tip, recorded where both agents can see it).
* - the child's stale copies are patch-compatible with the new parent (a pure
* rebase, or an amend that only adds), so `git rebase` drops them and the
* result is correct;
* - they genuinely diverge, and the rebase CONFLICTS. This script aborts it,
* leaves the child untouched, pushes nothing, and exits 2.
*
* The residual risk is the person, not the tool. That conflict lands in files
* the child never touched, so it reads as inexplicable, and resolving it toward
* the child's copy is what restores whatever the parent's rewrite fixed. That is
* why this reports WHERE the upstream came from, and why a guessed one is
* announced in the plan and again in the conflict: the tool cannot know which
* resolution is right, but it can say that it was guessing.
*
* Failing closed instead was considered and rejected: refusing whenever tier 3
* fires and the parent is not an ancestor would also refuse the case tier 3
* exists for, a parent that was only appended to, where the fallback is correct.
*/
const forkUpstream = (git, parent, child, rewritten) => {
const known = rewritten[parent];
if (known) return known;
if (known) return { upstream: known, source: "recorded" };
const probe = git("merge-base", "--fork-point", parent, child);
if (probe.code === 0 && probe.stdout.trim()) return probe.stdout.trim();
return parent;
if (probe.code === 0 && probe.stdout.trim()) {
return { upstream: probe.stdout.trim(), source: "fork-point" };
}
return { upstream: parent, source: "guessed" };
};

/**
* Whether a guessed upstream is one to warn about.
*
* A guess is only interesting when the parent is NOT already an ancestor of the
* child. When it is, the child contains the current parent, `parent..child` is
* exactly the child's own commits, and replaying from the parent is right by
* construction — there is nothing to warn about, and saying so every time would
* train the reader to skip the line that matters.
*/
const guessIsRisky = (git, parent, child, source) =>
source === "guessed" && !isAncestor(git, parent, child);

const main = ({
argv = process.argv.slice(2),
git = runGit,
Expand Down Expand Up @@ -172,7 +199,17 @@ const main = ({
continue;
}

const upstream = forkUpstream(git, parent, child, rewritten);
const { upstream, source } = forkUpstream(git, parent, child, rewritten);
const risky = guessIsRisky(git, parent, child, source);
if (risky) {
// Said BEFORE the rebase, not only after it fails. If this replays
// cleanly the reader still wants to know the upstream was inferred.
emit(
` ! ${child}: no fork point is known, so the upstream is a GUESS ` +
`(${parent}). That is correct if ${parent} was only appended to, and ` +
`wrong if it was rewritten somewhere this clone never saw.`
);
}
// The guard counts from the SAME upstream the rebase replays from. Counting
// from a merge-base a rewritten parent has invalidated inflates the
// expectation with the parent's own superseded commits, which makes the
Expand All @@ -195,12 +232,25 @@ const main = ({
const conflicts = gitOut(git, "diff", "--name-only", "--diff-filter=U");
git("rebase", "--abort");
emit(
` ✗ CONFLICT: ${child} onto ${parent} (from ${upstream.slice(0, 9)}). ` +
"Needs manual reconcile:"
` ✗ CONFLICT: ${child} onto ${parent} (from ${upstream.slice(0, 9)}, ` +
`${source}). Needs manual reconcile:`
);
for (const file of conflicts.split("\n").filter(Boolean)) {
emit(` ${file}`);
}
if (risky) {
// The dangerous moment is the manual reconcile, not the abort. A
// conflict here lands in files the child never touched, which reads as
// inexplicable, and "take mine" is what restores whatever the parent's
// rewrite fixed.
emit(
` ^ the upstream above was a GUESS. A conflict in files ${child} ` +
`never touched usually means ${parent} was rewritten elsewhere and ` +
`this clone cannot see where ${child} forked. Do NOT resolve toward ` +
`${child}'s copy without checking what ${parent} changed: that side ` +
`is the superseded one, and taking it puts the old work back.`
);
}
if (start) git("checkout", start);
return 2;
}
Expand Down
112 changes: 107 additions & 5 deletions .agents/skills/iterate-pr/scripts/propagate_stack.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,26 +96,38 @@ const run = (

test("forkUpstream prefers the pre-rebase tip of a parent this run rewrote", () => {
const git = fakeGit({ forkPoints: { "parent->child": "from-reflog" } });
assert.equal(
assert.deepEqual(
forkUpstream(git, "parent", "child", { parent: "pre-rebase-tip" }),
"pre-rebase-tip"
{
upstream: "pre-rebase-tip",
source: "recorded",
}
);
});

test("forkUpstream falls back to the reflog fork-point for a rewrite it did not perform", () => {
const git = fakeGit({ forkPoints: { "parent->child": "from-reflog" } });
assert.equal(forkUpstream(git, "parent", "child", {}), "from-reflog");
assert.deepEqual(forkUpstream(git, "parent", "child", {}), {
upstream: "from-reflog",
source: "fork-point",
});
});

test("forkUpstream falls back to the parent when no fork point is known", () => {
assert.equal(forkUpstream(fakeGit(), "parent", "child", {}), "parent");
assert.deepEqual(forkUpstream(fakeGit(), "parent", "child", {}), {
upstream: "parent",
source: "guessed",
});
});

test("forkUpstream ignores an empty fork-point answer", () => {
const git = fakeGit({
overrides: [["merge-base --fork-point", { code: 0, stdout: " \n" }]],
});
assert.equal(forkUpstream(git, "parent", "child", {}), "parent");
assert.deepEqual(forkUpstream(git, "parent", "child", {}), {
upstream: "parent",
source: "guessed",
});
});

/**
Expand Down Expand Up @@ -395,3 +407,93 @@ test("with no reflog knowledge of a rewrite, the guard agrees with a wrong upstr
"and the guard cannot see it: expectation and outcome share the upstream"
);
});

// ---------------------------------------------------------------------------
// Saying when the upstream was a guess (taskless/cli#301)
// ---------------------------------------------------------------------------

/**
* The tool cannot know whether a guessed upstream is the right one, so it says
* that it guessed. Announced BEFORE the rebase, because a clean replay is still
* worth knowing about, and repeated at a conflict, because that is the moment
* somebody is about to choose a side by hand.
*/
test("a guessed upstream is announced in the plan", () => {
// No fork point, and the parent is not an ancestor: the ambiguous case.
const git = fakeGit({
overrides: [["merge-base --is-ancestor", { code: 1 }]],
});
const { lines } = run(["--root", "root", "--no-push"], { git });
assert.match(lines, /no fork point is known, so the upstream is a GUESS/);
assert.match(lines, /correct if root was only appended to/);
});

// A parent already contained by the child makes `parent..child` exactly the
// child's own commits, so the fallback is right by construction. Warning there
// would train the reader to skip the line that matters.
test("a guess is not announced when the parent is already an ancestor", () => {
const git = fakeGit({
overrides: [["merge-base --is-ancestor", { code: 0 }]],
});
const { lines } = run(["--root", "root", "--no-push"], { git });
assert.doesNotMatch(lines, /GUESS/);
});

test("a known fork point is never announced as a guess", () => {
const git = fakeGit({
forkPoints: { "root->child": "forked-at" },
overrides: [["merge-base --is-ancestor", { code: 1 }]],
});
const { lines } = run(["--root", "root", "--no-push"], { git });
assert.doesNotMatch(lines, /GUESS/);
});

/**
* THE CONFLICT MESSAGE IS THE WHOLE POINT OF #301. What reproduces is not a
* silent loss — the rebase is aborted and the child left untouched — but a
* conflict in files the child never touched, with nothing saying the upstream
* was inferred. Resolving toward the child's copy is what puts the parent's
* superseded work back.
*/
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" }],
["diff --name-only", { code: 0, stdout: "parent.txt" }],
],
});
const { code, lines } = run(["--root", "root"], { git });

assert.equal(code, 2);
assert.match(lines, /the upstream above was a GUESS/);
assert.match(lines, /rewritten elsewhere/);
assert.match(lines, /Do NOT resolve toward child's copy/);
assert.match(lines, /taking it puts the old work back/);
});

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" }],
["diff --name-only", { code: 0, stdout: "parent.txt" }],
],
});
const { code, lines } = run(["--root", "root"], { git });
assert.equal(code, 2);
assert.match(lines, /CONFLICT/);
assert.doesNotMatch(lines, /GUESS/);
});

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" }],
["diff --name-only", { code: 0, stdout: "a.ts" }],
],
});
const { lines } = run(["--root", "root"], { git });
assert.match(lines, /from forked-at, fork-point/);
});
5 changes: 5 additions & 0 deletions .agents/skills/iterate-pr/scripts/shared.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ const lineage = ({ run = runProcess } = {}) => {
const refExists = (run, ref) =>
run("rev-parse", "--verify", "--quiet", ref).code === 0;

/** Whether `ancestor` is reachable from `descendant`. */
const isAncestor = (run, ancestor, descendant) =>
run("merge-base", "--is-ancestor", ancestor, descendant).code === 0;

/** Commit count for a range expression, or -1 when git could not answer. */
const countRange = (run, rangeExpression) => {
const out = gitOut(run, "rev-list", "--count", rangeExpression);
Expand Down Expand Up @@ -208,6 +212,7 @@ module.exports = {
UsageError,
countRange,
gitOut,
isAncestor,
lineage,
orderedDescendants,
parseIntegerOption,
Expand Down
34 changes: 34 additions & 0 deletions .agents/skills/iterate-pr/scripts/shared.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
UsageError,
countRange,
gitOut,
isAncestor,
lineage,
orderedDescendants,
parseIntegerOption,
Expand Down Expand Up @@ -258,3 +259,36 @@ test("runProcess reports timedOut false for a command that simply fails", () =>
assert.equal(result.code, 3);
assert.equal(result.timedOut, false);
});

// Shared because both propagate_stack (deciding whether a guessed upstream is
// worth warning about) and stack_status (deciding whether a branch is cleanly
// stacked) ask the same question, and a second copy is one more thing to keep
// in sync by hand.
test("isAncestor follows git's exit code", () => {
assert.equal(
isAncestor(() => ({ code: 0, stdout: "", stderr: "" }), "a", "b"),
true
);
assert.equal(
isAncestor(() => ({ code: 1, stdout: "", stderr: "" }), "a", "b"),
false
);
});

test("isAncestor passes the refs in ancestor-then-descendant order", () => {
const calls = [];
isAncestor(
(...args) => {
calls.push(args);
return { code: 0, stdout: "", stderr: "" };
},
"parent",
"child"
);
assert.deepEqual(calls[0], [
"merge-base",
"--is-ancestor",
"parent",
"child",
]);
});
4 changes: 1 addition & 3 deletions .agents/skills/iterate-pr/scripts/stack_status.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
UsageError,
countRange,
gitOut,
isAncestor,
lineage,
orderedDescendants,
refExists,
Expand All @@ -44,9 +45,6 @@ const aheadBehind = (git, a, b) => {
return [Number(left), Number(right)];
};

const isAncestor = (git, ancestor, descendant) =>
git("merge-base", "--is-ancestor", ancestor, descendant).code === 0;

/**
* Every branch reachable below `root`.
*
Expand Down
Loading