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
14 changes: 10 additions & 4 deletions src/lib/render/integrate-cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
136 changes: 136 additions & 0 deletions src/lib/sync/classify-integrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
59 changes: 58 additions & 1 deletion src/lib/sync/classify-integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions src/lib/sync/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}`;
}
Expand Down
18 changes: 12 additions & 6 deletions test/integration/integrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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"]);

Expand Down Expand Up @@ -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", () =>
Expand Down
8 changes: 6 additions & 2 deletions test/integration/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
}));
});

Expand Down
Loading