diff --git a/src/lib/render/integrate-cells.ts b/src/lib/render/integrate-cells.ts index b97ff203..0eb52bb5 100644 --- a/src/lib/render/integrate-cells.ts +++ b/src/lib/render/integrate-cells.ts @@ -23,10 +23,16 @@ export function integrateActionCell(desc: IntegrateActionDesc): Cell { if (desc.kind === "retarget-merged") { const n = desc.replayCount ?? 0; const merged = desc.skipCount ?? 0; - const commitWord = n === 1 ? "commit" : "commits"; - let text = `rebase onto ${desc.baseRef} (merged) \u2014 rebase ${n} new ${commitWord}`; - if (merged > 0) text += `, skip ${merged} already merged`; - result = cell(text); + if (n === 0) { + const text = + merged > 0 ? `reset to ${desc.baseRef} (all ${merged} commits merged)` : `reset to ${desc.baseRef} (merged)`; + result = cell(text); + } else { + const commitWord = n === 1 ? "commit" : "commits"; + let text = `rebase onto ${desc.baseRef} (merged) \u2014 rebase ${n} new ${commitWord}`; + if (merged > 0) text += `, skip ${merged} already merged`; + result = cell(text); + } } else if (desc.kind === "retarget-config") { let text = `rebase onto ${desc.baseRef} from ${desc.retargetFrom} (retarget)`; if (desc.skipCount != null && desc.skipCount > 0) { diff --git a/src/lib/sync/classify-integrate.test.ts b/src/lib/sync/classify-integrate.test.ts index d4c86cb9..1a997e49 100644 --- a/src/lib/sync/classify-integrate.test.ts +++ b/src/lib/sync/classify-integrate.test.ts @@ -548,4 +548,140 @@ describe("assessIntegrateRepo", () => { expect(a.retarget?.alreadyOnTarget).toBe(3); expect(a.retarget?.reason).toBe("branch-merged"); }); + + // ── fully-merged (no new work) ── + + test("fully-merged in rebase mode with behind > 0 returns will-operate with retarget (replayCount=0)", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 5, + behind: 3, + merge: { kind: "merge" }, + baseMergedIntoDefault: null, + }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps()); + expect(a.outcome).toBe("will-operate"); + expect(a.retarget?.replayCount).toBe(0); + expect(a.retarget?.alreadyOnTarget).toBe(5); + expect(a.retarget?.reason).toBe("branch-merged"); + expect(a.behind).toBe(3); + expect(a.ahead).toBe(0); + }); + + test("fully-merged squash in rebase mode with behind > 0 returns will-operate with retarget", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 3, + behind: 2, + merge: { kind: "squash" }, + baseMergedIntoDefault: null, + }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps()); + expect(a.outcome).toBe("will-operate"); + expect(a.retarget?.replayCount).toBe(0); + expect(a.retarget?.alreadyOnTarget).toBe(3); + expect(a.retarget?.reason).toBe("branch-merged"); + }); + + test("fully-merged in merge mode with behind > 0 returns will-operate (standard merge)", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 5, + behind: 3, + merge: { kind: "merge" }, + baseMergedIntoDefault: null, + }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "merge" }), mockDeps()); + expect(a.outcome).toBe("will-operate"); + expect(a.behind).toBe(3); + expect(a.ahead).toBe(5); + expect(a.retarget).toBeUndefined(); + }); + + test("fully-merged with behind=0 and ahead=0 returns up-to-date", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 0, + behind: 0, + merge: { kind: "merge" }, + baseMergedIntoDefault: null, + }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps()); + expect(a.outcome).toBe("up-to-date"); + }); + + test("fully-merged in merge mode with behind=0 returns up-to-date", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 3, + behind: 0, + merge: { kind: "squash" }, + baseMergedIntoDefault: null, + }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "merge" }), mockDeps()); + expect(a.outcome).toBe("up-to-date"); + }); + + test("fully-merged with dirty worktree and no autostash returns skip (dirty)", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 3, + behind: 2, + merge: { kind: "merge" }, + baseMergedIntoDefault: null, + }, + local: { staged: 1, modified: 0, untracked: 0, conflicts: 0 }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ autostash: false }), mockDeps()); + expect(a.outcome).toBe("skip"); + expect(a.skipFlag).toBe("dirty"); + }); + + test("fully-merged with dirty worktree and autostash returns will-operate with needsStash", async () => { + const status = makeRepo({ + base: { + remote: "origin", + ref: "main", + configuredRef: null, + resolvedVia: "remote", + ahead: 3, + behind: 2, + merge: { kind: "merge" }, + baseMergedIntoDefault: null, + }, + local: { staged: 1, modified: 0, untracked: 0, conflicts: 0 }, + }); + const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ autostash: true }), mockDeps()); + expect(a.outcome).toBe("will-operate"); + expect(a.needsStash).toBe(true); + }); }); diff --git a/src/lib/sync/classify-integrate.ts b/src/lib/sync/classify-integrate.ts index 754f4294..9597f28d 100644 --- a/src/lib/sync/classify-integrate.ts +++ b/src/lib/sync/classify-integrate.ts @@ -164,8 +164,9 @@ export async function assessIntegrateRepo( const base = status.base; const isMergedNewWork = classified.skipFlag === "already-merged" && base?.merge?.newCommitsAfter != null && base.merge.newCommitsAfter > 0; + const isFullyMerged = classified.skipFlag === "already-merged" && !isMergedNewWork; - if (classified.outcome === "skip" && !isMergedNewWork) { + if (classified.outcome === "skip" && !isMergedNewWork && !isFullyMerged) { return classified; } @@ -179,6 +180,62 @@ export async function assessIntegrateRepo( }); if (mergedNewWorkAssessment) return mergedNewWorkAssessment; + // Fully-merged branches: reset to base instead of skipping. + // For rebase mode, use --onto to move the branch pointer to the base (0 commits replayed). + // For merge mode, fast-forward merge to the base. + if (isFullyMerged && base) { + const behind = base.behind; + const ahead = base.ahead; + + if (behind === 0 && ahead === 0) { + return { ...withoutSkipFields(classified), outcome: "up-to-date", baseBranch: base.ref, behind: 0, ahead: 0 }; + } + + const flags = computeFlags(status, branch); + if (flags.isDirty && !options.autostash) { + return { + ...classified, + outcome: "skip" as const, + skipReason: "uncommitted changes (use --autostash)", + skipFlag: "dirty" as const, + }; + } + const needsStash = + flags.isDirty && options.autostash && (status.local.staged > 0 || status.local.modified > 0) ? true : undefined; + + if (options.mode === "rebase") { + return { + ...withoutSkipFields(classified), + outcome: "will-operate", + baseBranch: base.ref, + behind, + ahead: 0, + needsStash, + retarget: { + from: headSha, + to: base.ref, + replayCount: 0, + alreadyOnTarget: ahead, + reason: "branch-merged", + }, + }; + } + + // Merge mode + if (behind > 0) { + return { + ...withoutSkipFields(classified), + outcome: "will-operate", + baseBranch: base.ref, + behind, + ahead, + needsStash, + }; + } + + return { ...withoutSkipFields(classified), outcome: "up-to-date", baseBranch: base.ref, behind: 0, ahead }; + } + // Auto-replay-plan optimization: when the base branch has squash-merged some of // the feature branch's commits, detect contiguous replay plans and use --onto // to skip already-merged commits during a normal rebase. diff --git a/src/lib/sync/integrate.ts b/src/lib/sync/integrate.ts index 1a369af8..7dc68d96 100644 --- a/src/lib/sync/integrate.ts +++ b/src/lib/sync/integrate.ts @@ -240,8 +240,12 @@ export async function integrate( if (a.retarget?.from) { // Branch-merged replay: use --onto to skip already-merged commits const n = a.retarget.replayCount ?? a.ahead; - const progressMsg = `rebasing ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`; - inlineStart(a.repo, progressMsg); + if (n === 0) { + inlineStart(a.repo, `resetting to ${ref} (merged)`); + } else { + const progressMsg = `rebasing ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`; + inlineStart(a.repo, progressMsg); + } const rebaseArgs = ["rebase"]; if (a.needsStash) rebaseArgs.push("--autostash"); rebaseArgs.push("--onto", ref, a.retarget.from); @@ -276,7 +280,10 @@ export async function integrate( let doneMsg: string; if (a.retarget?.from) { const n = a.retarget.replayCount ?? a.ahead; - doneMsg = `rebased ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`; + doneMsg = + n === 0 + ? `reset to ${ref} (merged)` + : `rebased ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`; } else { doneMsg = mode === "rebase" ? `rebased ${a.branch} onto ${ref}` : `merged ${ref} into ${a.branch}`; } diff --git a/test/integration/integrate.test.ts b/test/integration/integrate.test.ts index 9c36e2ed..7d0c18c4 100644 --- a/test/integration/integrate.test.ts +++ b/test/integration/integrate.test.ts @@ -572,7 +572,7 @@ describe("--verbose", () => { // ── diverged commit matching in plan ───────────────────────────── describe("diverged commit matching in plan", () => { - test("arb rebase --verbose --dry-run skips cherry-picked commit (detected as squash-merged)", () => + test("arb rebase --verbose --dry-run plans reset for cherry-picked commit (detected as squash-merged)", () => withEnv(async (env) => { await arb(env, ["create", "my-feature", "repo-a"]); await write(join(env.projectDir, "my-feature/repo-a/feature.txt"), "feature"); @@ -594,10 +594,11 @@ describe("diverged commit matching in plan", () => { cwd: join(env.projectDir, "my-feature"), }); expect(result.exitCode).toBe(0); - expect(result.output).toContain("already squash-merged into main"); + expect(result.output).toContain("reset to origin/main"); + expect(result.output).toContain("merged"); })); - test("arb rebase skips repo that was squash-merged onto base", () => + test("arb rebase plans reset for repo that was squash-merged onto base", () => withEnv(async (env) => { await arb(env, ["create", "my-feature", "repo-a"]); await write(join(env.projectDir, "my-feature/repo-a/first.txt"), "first"); @@ -619,10 +620,11 @@ describe("diverged commit matching in plan", () => { cwd: join(env.projectDir, "my-feature"), }); expect(result.exitCode).toBe(0); - expect(result.output).toContain("already squash-merged into main"); + expect(result.output).toContain("reset to origin/main"); + expect(result.output).toContain("commits merged"); })); - test("arb rebase skips squash-merged repo and rebases others", () => + test("arb rebase resets squash-merged repo and rebases others", () => withEnv(async (env) => { await arb(env, ["create", "my-feature", "repo-a", "repo-b"]); @@ -650,12 +652,16 @@ describe("diverged commit matching in plan", () => { cwd: join(env.projectDir, "my-feature"), }); expect(result.exitCode).toBe(0); - expect(result.output).toContain("already squash-merged into main"); + expect(result.output).toContain("reset to origin/main (merged)"); expect(result.output).toContain("rebased my-feature onto origin/main"); // Verify repo-b actually has the upstream commit after rebase const logB = await git(join(env.projectDir, "my-feature/repo-b"), ["log", "--oneline"]); expect(logB).toContain("upstream change"); + + // Verify repo-a was reset to origin/main (squash commit is in history) + const logA = await git(join(env.projectDir, "my-feature/repo-a"), ["log", "--oneline"]); + expect(logA).toContain("squash: feature"); })); test("arb rebase --verbose --dry-run shows no match annotations for genuinely different commits", () => diff --git a/test/integration/sync.test.ts b/test/integration/sync.test.ts index 87514866..8b327882 100644 --- a/test/integration/sync.test.ts +++ b/test/integration/sync.test.ts @@ -1611,7 +1611,7 @@ describe("--where filtering", () => { expect(result.output).not.toContain("already merged"); })); - test("rebase skips when all local commits are already squash-equivalent on base", () => + test("rebase resets when all local commits are already squash-equivalent on base", () => withEnv(async (env) => { await arb(env, ["create", "rebase-squash-equivalent-test", "repo-a"]); const wt = join(env.projectDir, "rebase-squash-equivalent-test/repo-a"); @@ -1638,8 +1638,12 @@ describe("--where filtering", () => { cwd: join(env.projectDir, "rebase-squash-equivalent-test"), }); expect(rebaseResult.exitCode).toBe(0); - expect(rebaseResult.output).toContain("already squash-merged"); + expect(rebaseResult.output).toContain("reset to origin/main (merged)"); expect(rebaseResult.output).not.toContain("conflict"); + + // Verify the branch was reset — squash-equivalent commit is in history + const log = await git(wt, ["log", "--oneline"]); + expect(log).toContain("squash-equivalent on main"); })); });