fix(task): auto-skipped empty commits during task-branch cherry-pick

An intermediate commit whose net effect is already on HEAD (redundant
change, or 3-way merged to HEAD by "theirs == ours") stopped the
sequencer with "The previous cherry-pick is now empty" and was
treated as a hard conflict. mergeTaskBranches aborted the whole range,
marked the branch failed, and dropped every remaining non-overlapping
commit.

Add cherryPick.skip and cherryPick.isEmptyError to the git namespace,
then in mergeTaskBranches' catch classify the failure before aborting:
loop --skip while the error stderr matches the "now empty" phrase so
consecutive empties advance the sequencer; fall through to abort/fail
on the first non-empty error (genuine conflict with unmerged files).

Fixes #4438
This commit is contained in:
roboomp
2026-07-03 14:19:16 +00:00
parent d0c1890a6c
commit 12acf7e645
4 changed files with 153 additions and 6 deletions
@@ -398,6 +398,104 @@ describe("worktree isolation helpers", () => {
expect(delta.rootPatch).not.toContain("baseline dirty change");
expect(delta.rootPatch).not.toContain("preexisting.txt");
});
// Regression for #4438: cherry-picking a range where an intermediate
// commit becomes empty (redundant with HEAD, or 3-way merged to HEAD)
// used to abort the whole range and mark the branch failed, dropping
// every remaining commit. The fixup here restores merged.txt to the
// content the shared fixture branch already established on HEAD, so
// the sequencer stops with "The previous cherry-pick is now empty".
// The follow-up commit contains real, non-overlapping work that MUST
// still land.
it("auto-skips empty cherry-picks so remaining commits in the range still land", async () => {
const REDUNDANT_BRANCH = "task/redundant-then-real";
await runGit(repo, ["checkout", "-q", "-b", REDUNDANT_BRANCH, initialSha]);
const branchBase = await runGit(repo, ["rev-parse", "HEAD"]);
// This commit sets merged.txt to the exact content TASK_BRANCH
// establishes on HEAD. Once TASK_BRANCH is cherry-picked, the
// 3-way merge for this commit sees theirs == ours == target,
// resolves to HEAD, and stops the sequencer with the "The
// previous cherry-pick is now empty" message.
await fs.writeFile(path.join(repo, "merged.txt"), "task branch change\n");
await runGit(repo, ["commit", "-q", "-am", "redundant: same as task branch tip"]);
// A non-overlapping follow-up that MUST land even though the
// preceding commit collapsed to empty.
await fs.writeFile(path.join(repo, "downstream.txt"), "unrelated follow-up\n");
await runGit(repo, ["add", "downstream.txt"]);
await runGit(repo, ["commit", "-q", "-m", "unrelated follow-up commit"]);
await runGit(repo, ["checkout", "-q", BASE_BRANCH]);
try {
const result = await mergeTaskBranches(repo, [
{ branchName: TASK_BRANCH, taskId: "task-1" },
{ branchName: REDUNDANT_BRANCH, taskId: "task-2", baseSha: branchBase },
]);
const [status, unmerged, mergedContent, downstreamContent, log] = await Promise.all([
runGit(repo, ["status", "--porcelain=v1"]),
runGit(repo, ["ls-files", "--unmerged"]),
fs.readFile(path.join(repo, "merged.txt"), "utf8"),
fs.readFile(path.join(repo, "downstream.txt"), "utf8"),
runGit(repo, ["log", "--pretty=%s", `${initialSha}..HEAD`]),
]);
expect(result).toEqual({ failed: [], merged: [TASK_BRANCH, REDUNDANT_BRANCH] });
// No cherry-pick sequencer state, no unmerged entries: the
// skip advanced cleanly.
expect(status).toBe("");
expect(unmerged).toBe("");
// TASK_BRANCH landed and the empty commit did NOT re-land it
// as a duplicate.
expect(mergedContent).toBe("task branch change\n");
expect(downstreamContent).toBe("unrelated follow-up\n");
const subjects = log.split("\n").filter(Boolean);
expect(subjects).toContain("unrelated follow-up commit");
expect(subjects).toContain("task-change");
// The redundant commit MUST NOT survive in history as a
// duplicate no-op.
expect(subjects).not.toContain("redundant: same as task branch tip");
} finally {
await cleanupTaskBranches(repo, [REDUNDANT_BRANCH]);
}
});
// A genuine content conflict (unmerged files, no "now empty" hint)
// must NOT be misclassified as empty. The branch stays in `failed`
// and the sequencer aborts cleanly — no lingering cherry-pick state,
// no unmerged index entries.
it("still aborts on genuine cherry-pick conflicts", async () => {
const CONFLICT_BRANCH = "task/genuine-conflict";
await runGit(repo, ["checkout", "-q", "-b", CONFLICT_BRANCH, initialSha]);
const branchBase = await runGit(repo, ["rev-parse", "HEAD"]);
await fs.writeFile(path.join(repo, "merged.txt"), "incompatible edit\n");
await runGit(repo, ["commit", "-q", "-am", "incompatible merged.txt edit"]);
await runGit(repo, ["checkout", "-q", BASE_BRANCH]);
try {
const result = await mergeTaskBranches(repo, [
{ branchName: TASK_BRANCH, taskId: "task-1" },
{ branchName: CONFLICT_BRANCH, taskId: "task-2", baseSha: branchBase },
]);
const [status, unmerged, cherryPickHeadExit] = await Promise.all([
runGit(repo, ["status", "--porcelain=v1"]),
runGit(repo, ["ls-files", "--unmerged"]),
runGit(repo, ["rev-parse", "--verify", "--quiet", "CHERRY_PICK_HEAD"]).then(
() => 0,
() => 1,
),
]);
expect(result.merged).toEqual([TASK_BRANCH]);
expect(result.failed).toEqual([CONFLICT_BRANCH]);
expect(result.conflict).toContain(CONFLICT_BRANCH);
// No stuck sequencer, no unmerged entries: the abort cleaned
// up after itself.
expect(status).toBe("");
expect(unmerged).toBe("");
expect(cherryPickHeadExit).toBe(1);
} finally {
await cleanupTaskBranches(repo, [CONFLICT_BRANCH]);
}
});
});
});
});