Skip to content

Commit 18b5d72

Browse files
claudehenrikje
authored andcommitted
feat(rebase): reset fully-merged branches to base instead of skipping
When a branch is fully merged (rebase-merged or squash-merged) into main with no new local work, rebase now resets the branch to origin/main instead of skipping with "already merged." Merge mode fast-forwards. https://claude.ai/code/session_01JwYwdYdscozHuZVPdrM9pq
1 parent 1da75b7 commit 18b5d72

4 files changed

Lines changed: 218 additions & 8 deletions

File tree

src/lib/render/integrate-cells.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,18 @@ export function integrateActionCell(desc: IntegrateActionDesc): Cell {
2323
if (desc.kind === "retarget-merged") {
2424
const n = desc.replayCount ?? 0;
2525
const merged = desc.skipCount ?? 0;
26-
const commitWord = n === 1 ? "commit" : "commits";
27-
let text = `rebase onto ${desc.baseRef} (merged) \u2014 rebase ${n} new ${commitWord}`;
28-
if (merged > 0) text += `, skip ${merged} already merged`;
29-
result = cell(text);
26+
if (n === 0) {
27+
const text =
28+
merged > 0
29+
? `reset to ${desc.baseRef} (all ${merged} commits merged)`
30+
: `reset to ${desc.baseRef} (merged)`;
31+
result = cell(text);
32+
} else {
33+
const commitWord = n === 1 ? "commit" : "commits";
34+
let text = `rebase onto ${desc.baseRef} (merged) \u2014 rebase ${n} new ${commitWord}`;
35+
if (merged > 0) text += `, skip ${merged} already merged`;
36+
result = cell(text);
37+
}
3038
} else if (desc.kind === "retarget-config") {
3139
let text = `rebase onto ${desc.baseRef} from ${desc.retargetFrom} (retarget)`;
3240
if (desc.skipCount != null && desc.skipCount > 0) {

src/lib/sync/classify-integrate.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,4 +548,140 @@ describe("assessIntegrateRepo", () => {
548548
expect(a.retarget?.alreadyOnTarget).toBe(3);
549549
expect(a.retarget?.reason).toBe("branch-merged");
550550
});
551+
552+
// ── fully-merged (no new work) ──
553+
554+
test("fully-merged in rebase mode with behind > 0 returns will-operate with retarget (replayCount=0)", async () => {
555+
const status = makeRepo({
556+
base: {
557+
remote: "origin",
558+
ref: "main",
559+
configuredRef: null,
560+
resolvedVia: "remote",
561+
ahead: 5,
562+
behind: 3,
563+
merge: { kind: "merge" },
564+
baseMergedIntoDefault: null,
565+
},
566+
});
567+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps());
568+
expect(a.outcome).toBe("will-operate");
569+
expect(a.retarget?.replayCount).toBe(0);
570+
expect(a.retarget?.alreadyOnTarget).toBe(5);
571+
expect(a.retarget?.reason).toBe("branch-merged");
572+
expect(a.behind).toBe(3);
573+
expect(a.ahead).toBe(0);
574+
});
575+
576+
test("fully-merged squash in rebase mode with behind > 0 returns will-operate with retarget", async () => {
577+
const status = makeRepo({
578+
base: {
579+
remote: "origin",
580+
ref: "main",
581+
configuredRef: null,
582+
resolvedVia: "remote",
583+
ahead: 3,
584+
behind: 2,
585+
merge: { kind: "squash" },
586+
baseMergedIntoDefault: null,
587+
},
588+
});
589+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps());
590+
expect(a.outcome).toBe("will-operate");
591+
expect(a.retarget?.replayCount).toBe(0);
592+
expect(a.retarget?.alreadyOnTarget).toBe(3);
593+
expect(a.retarget?.reason).toBe("branch-merged");
594+
});
595+
596+
test("fully-merged in merge mode with behind > 0 returns will-operate (standard merge)", async () => {
597+
const status = makeRepo({
598+
base: {
599+
remote: "origin",
600+
ref: "main",
601+
configuredRef: null,
602+
resolvedVia: "remote",
603+
ahead: 5,
604+
behind: 3,
605+
merge: { kind: "merge" },
606+
baseMergedIntoDefault: null,
607+
},
608+
});
609+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "merge" }), mockDeps());
610+
expect(a.outcome).toBe("will-operate");
611+
expect(a.behind).toBe(3);
612+
expect(a.ahead).toBe(5);
613+
expect(a.retarget).toBeUndefined();
614+
});
615+
616+
test("fully-merged with behind=0 and ahead=0 returns up-to-date", async () => {
617+
const status = makeRepo({
618+
base: {
619+
remote: "origin",
620+
ref: "main",
621+
configuredRef: null,
622+
resolvedVia: "remote",
623+
ahead: 0,
624+
behind: 0,
625+
merge: { kind: "merge" },
626+
baseMergedIntoDefault: null,
627+
},
628+
});
629+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "rebase" }), mockDeps());
630+
expect(a.outcome).toBe("up-to-date");
631+
});
632+
633+
test("fully-merged in merge mode with behind=0 returns up-to-date", async () => {
634+
const status = makeRepo({
635+
base: {
636+
remote: "origin",
637+
ref: "main",
638+
configuredRef: null,
639+
resolvedVia: "remote",
640+
ahead: 3,
641+
behind: 0,
642+
merge: { kind: "squash" },
643+
baseMergedIntoDefault: null,
644+
},
645+
});
646+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ mode: "merge" }), mockDeps());
647+
expect(a.outcome).toBe("up-to-date");
648+
});
649+
650+
test("fully-merged with dirty worktree and no autostash returns skip (dirty)", async () => {
651+
const status = makeRepo({
652+
base: {
653+
remote: "origin",
654+
ref: "main",
655+
configuredRef: null,
656+
resolvedVia: "remote",
657+
ahead: 3,
658+
behind: 2,
659+
merge: { kind: "merge" },
660+
baseMergedIntoDefault: null,
661+
},
662+
local: { staged: 1, modified: 0, untracked: 0, conflicts: 0 },
663+
});
664+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ autostash: false }), mockDeps());
665+
expect(a.outcome).toBe("skip");
666+
expect(a.skipFlag).toBe("dirty");
667+
});
668+
669+
test("fully-merged with dirty worktree and autostash returns will-operate with needsStash", async () => {
670+
const status = makeRepo({
671+
base: {
672+
remote: "origin",
673+
ref: "main",
674+
configuredRef: null,
675+
resolvedVia: "remote",
676+
ahead: 3,
677+
behind: 2,
678+
merge: { kind: "merge" },
679+
baseMergedIntoDefault: null,
680+
},
681+
local: { staged: 1, modified: 0, untracked: 0, conflicts: 0 },
682+
});
683+
const a = await assessIntegrateRepo(status, DIR, "feature", [], defaultOptions({ autostash: true }), mockDeps());
684+
expect(a.outcome).toBe("will-operate");
685+
expect(a.needsStash).toBe(true);
686+
});
551687
});

src/lib/sync/classify-integrate.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,9 @@ export async function assessIntegrateRepo(
164164
const base = status.base;
165165
const isMergedNewWork =
166166
classified.skipFlag === "already-merged" && base?.merge?.newCommitsAfter != null && base.merge.newCommitsAfter > 0;
167+
const isFullyMerged = classified.skipFlag === "already-merged" && !isMergedNewWork;
167168

168-
if (classified.outcome === "skip" && !isMergedNewWork) {
169+
if (classified.outcome === "skip" && !isMergedNewWork && !isFullyMerged) {
169170
return classified;
170171
}
171172

@@ -179,6 +180,64 @@ export async function assessIntegrateRepo(
179180
});
180181
if (mergedNewWorkAssessment) return mergedNewWorkAssessment;
181182

183+
// Fully-merged branches: reset to base instead of skipping.
184+
// For rebase mode, use --onto to move the branch pointer to the base (0 commits replayed).
185+
// For merge mode, fast-forward merge to the base.
186+
if (isFullyMerged && base) {
187+
const behind = base.behind;
188+
const ahead = base.ahead;
189+
190+
if (behind === 0 && ahead === 0) {
191+
return { ...withoutSkipFields(classified), outcome: "up-to-date", baseBranch: base.ref, behind: 0, ahead: 0 };
192+
}
193+
194+
const flags = computeFlags(status, branch);
195+
if (flags.isDirty && !options.autostash) {
196+
return {
197+
...classified,
198+
outcome: "skip" as const,
199+
skipReason: "uncommitted changes (use --autostash)",
200+
skipFlag: "dirty" as const,
201+
};
202+
}
203+
const needsStash =
204+
flags.isDirty && options.autostash && (status.local.staged > 0 || status.local.modified > 0)
205+
? true
206+
: undefined;
207+
208+
if (options.mode === "rebase") {
209+
return {
210+
...withoutSkipFields(classified),
211+
outcome: "will-operate",
212+
baseBranch: base.ref,
213+
behind,
214+
ahead: 0,
215+
needsStash,
216+
retarget: {
217+
from: headSha,
218+
to: base.ref,
219+
replayCount: 0,
220+
alreadyOnTarget: ahead,
221+
reason: "branch-merged",
222+
},
223+
};
224+
}
225+
226+
// Merge mode
227+
if (behind > 0) {
228+
return {
229+
...withoutSkipFields(classified),
230+
outcome: "will-operate",
231+
baseBranch: base.ref,
232+
behind,
233+
ahead,
234+
needsStash,
235+
};
236+
}
237+
238+
return { ...withoutSkipFields(classified), outcome: "up-to-date", baseBranch: base.ref, behind: 0, ahead };
239+
}
240+
182241
// Auto-replay-plan optimization: when the base branch has squash-merged some of
183242
// the feature branch's commits, detect contiguous replay plans and use --onto
184243
// to skip already-merged commits during a normal rebase.

src/lib/sync/integrate.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,12 @@ export async function integrate(
240240
if (a.retarget?.from) {
241241
// Branch-merged replay: use --onto to skip already-merged commits
242242
const n = a.retarget.replayCount ?? a.ahead;
243-
const progressMsg = `rebasing ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`;
244-
inlineStart(a.repo, progressMsg);
243+
if (n === 0) {
244+
inlineStart(a.repo, `resetting to ${ref} (merged)`);
245+
} else {
246+
const progressMsg = `rebasing ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`;
247+
inlineStart(a.repo, progressMsg);
248+
}
245249
const rebaseArgs = ["rebase"];
246250
if (a.needsStash) rebaseArgs.push("--autostash");
247251
rebaseArgs.push("--onto", ref, a.retarget.from);
@@ -276,7 +280,10 @@ export async function integrate(
276280
let doneMsg: string;
277281
if (a.retarget?.from) {
278282
const n = a.retarget.replayCount ?? a.ahead;
279-
doneMsg = `rebased ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`;
283+
doneMsg =
284+
n === 0
285+
? `reset to ${ref} (merged)`
286+
: `rebased ${n} new ${n === 1 ? "commit" : "commits"} onto ${ref} (merged)`;
280287
} else {
281288
doneMsg = mode === "rebase" ? `rebased ${a.branch} onto ${ref}` : `merged ${ref} into ${a.branch}`;
282289
}

0 commit comments

Comments
 (0)