Skip to content

Commit 166862c

Browse files
henrikjeclaude
andcommitted
fix(status): suppress false-positive "base merged" when branch hasn't diverged
The ancestor check in detectBranchMerged trivially passes when the branch and base resolve to the same commit (e.g. a base workspace created from main with no unique commits yet). Add a same-commit guard after the Phase 1 ancestor check to return null instead of { kind: "merge" }. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cb48357 commit 166862c

File tree

2 files changed

+45
-2
lines changed

2 files changed

+45
-2
lines changed

src/lib/analysis/merge-detection.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,44 @@ describe("detectBranchMerged", () => {
125125
expect(result).toBeNull();
126126
}));
127127

128-
test("returns merge for empty branch (no commits ahead of base)", () =>
128+
test("returns null for branch at same commit as base (no divergence)", () =>
129129
withRepo(async ({ repoDir }) => {
130130
const defaultBranch = (await getDefaultBranch(repoDir, "origin")) ?? "main";
131131

132+
// Branch created from default with no additional commits — same SHA as default.
133+
// This is NOT a merge; the branch simply hasn't diverged yet.
132134
Bun.spawnSync(["git", "-C", repoDir, "checkout", "-b", "feature"]);
133135

134136
const result = await detectBranchMerged(repoDir, defaultBranch);
137+
expect(result).toBeNull();
138+
}));
139+
140+
test("returns null for branch at same commit as base via explicit branchRef", () =>
141+
withRepo(async ({ repoDir }) => {
142+
const defaultBranch = (await getDefaultBranch(repoDir, "origin")) ?? "main";
143+
144+
// Same scenario but using an explicit branchRef (like the stacked base detection does)
145+
Bun.spawnSync(["git", "-C", repoDir, "checkout", "-b", "feature"]);
146+
Bun.spawnSync(["git", "-C", repoDir, "checkout", defaultBranch]);
147+
148+
const result = await detectBranchMerged(repoDir, defaultBranch, 200, "feature");
149+
expect(result).toBeNull();
150+
}));
151+
152+
test("returns merge for branch that is strictly behind base (ancestor but different commit)", () =>
153+
withRepo(async ({ repoDir }) => {
154+
const defaultBranch = (await getDefaultBranch(repoDir, "origin")) ?? "main";
155+
156+
// Create branch, then advance default past it — branch is ancestor of default
157+
// but at a different commit. This is the legitimate "branch was merged" or
158+
// "branch fell behind" case.
159+
Bun.spawnSync(["git", "-C", repoDir, "checkout", "-b", "feature"]);
160+
Bun.spawnSync(["git", "-C", repoDir, "checkout", defaultBranch]);
161+
writeFileSync(join(repoDir, "main-work.txt"), "main work");
162+
Bun.spawnSync(["git", "-C", repoDir, "add", "main-work.txt"]);
163+
Bun.spawnSync(["git", "-C", repoDir, "commit", "-m", "advance main"]);
164+
165+
const result = await detectBranchMerged(repoDir, defaultBranch, 200, "feature");
135166
expect(result?.kind).toBe("merge");
136167
}));
137168

src/lib/analysis/merge-detection.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,19 @@ export async function detectBranchMerged(
5353
): Promise<MergeDetectionResult | null> {
5454
// Phase 1: Ancestor check (instant) — detects merge commits and fast-forwards
5555
const ancestor = await gitLocal(repoDir, "merge-base", "--is-ancestor", branchRef, baseBranchRef);
56-
if (ancestor.exitCode === 0) return { kind: "merge" };
56+
if (ancestor.exitCode === 0) {
57+
// Guard: if both refs resolve to the same commit, the branch hasn't diverged —
58+
// it's "equal" to the base, not "merged" into it. This avoids false positives
59+
// for branches that were created from the base but never had unique commits.
60+
const [branchSha, baseSha] = await Promise.all([
61+
gitLocal(repoDir, "rev-parse", branchRef),
62+
gitLocal(repoDir, "rev-parse", baseBranchRef),
63+
]);
64+
if (branchSha.exitCode === 0 && baseSha.exitCode === 0 && branchSha.stdout.trim() === baseSha.stdout.trim()) {
65+
return null;
66+
}
67+
return { kind: "merge" };
68+
}
5769

5870
// Resolve base branch patch-id map (shared across Phase 2 + Phase 3, and across workspaces)
5971
const basePatchIdMap = await resolveBasePatchIdMap(repoDir, baseBranchRef, commitLimit, basePatchIdCache);

0 commit comments

Comments
 (0)