Files
fusion/packages/dashboard/src/routes/resolve-diff-base.ts
gsxdsm 327ddd613c fix(dashboard): prefer merge-base over outdated baseCommitSha
When a worktree was rebased onto newer main, baseCommitSha remained an
ancestor of HEAD but the range baseCommitSha..HEAD then swept in upstream
main commits as if they were task changes (FN-2840 showed 33 files for a
4-file task). The previous display-recovery only fired when baseCommitSha
was no longer an ancestor, missing this case.

When enableDisplayRecovery is on and baseBranch is missing, also compute
merge-base(HEAD, main) and prefer it when it's a descendant of
baseCommitSha (tighter fork point). When merge-base is not a descendant
(FN-2855: baseCommitSha is on a deleted feature branch), keep
baseCommitSha so we don't widen to unrelated upstream files.

Also stabilizes the card-vs-Changes-tab divergence: resolution no longer
depends on whether a rebase happened between polls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:29:13 -07:00

175 lines
6.4 KiB
TypeScript

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Execute a git command and return stdout as text.
*/
export async function runGitCommand(args: string[], cwd?: string, timeout = 10000): Promise<string> {
const result = await execFileAsync("git", args, {
cwd,
timeout,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
});
if (typeof result === "string") {
return result;
}
if (Array.isArray(result)) {
return String(result[0] ?? "");
}
if (result && typeof result === "object" && "stdout" in result) {
return String((result as { stdout?: unknown }).stdout ?? "");
}
return "";
}
export interface ResolveDiffBaseTaskInput {
baseCommitSha?: string;
baseBranch?: string;
}
export interface ResolveDiffBaseOptions {
/**
* Display-only recovery: when `baseBranch` is missing, also try
* `merge-base(headRef, "main")` (then `origin/main`) and prefer it over
* `baseCommitSha` if it's a descendant of `baseCommitSha` (i.e. tighter).
* Catches two cases:
*
* 1. **Stale baseCommitSha** (FN-2957): worktree rebased onto `origin/main`
* after `baseCommitSha` was recorded; baseCommitSha is no longer an
* ancestor of HEAD. Without recovery the code falls to `headRef~1`,
* showing only the latest commit's files.
* 2. **Outdated baseCommitSha** (FN-2840): worktree rebased onto newer
* `main`; baseCommitSha is *still* an ancestor of HEAD but the range
* `baseCommitSha..HEAD` now sweeps in upstream main commits as if they
* were task changes (33 files instead of the 4 the task actually
* touched). The merge-base is a tighter, more accurate fork point.
*
* Display-only — the merger never opts in. Its scope checks must stay
* tied to the recorded task base, not a widened display range.
*
* Default: false.
*/
enableDisplayRecovery?: boolean;
}
/**
* Resolve the diff base ref for a task worktree.
*
* IMPORTANT: `packages/engine/src/merger.ts` mirrors this exact ordering for
* merge-time scope warnings. Keep both implementations in sync so dashboard
* changed-files views and merger scope enforcement evaluate the same range.
* The `enableDisplayRecovery` option is *display-only* and intentionally not
* mirrored in the merger.
*
* Strategy (in priority order):
* 1. **Branch merge-base** — Prefer the live merge-base between `headRef` and
* local `{baseBranch}` (fallback: `origin/{baseBranch}`).
* 2. **Task-scoped baseCommitSha** — If merge-base is unavailable or equals
* `headRef`, use `baseCommitSha` when still an ancestor of `headRef`.
* 3. **Display recovery (opt-in)** — `merge-base(headRef, "main")` /
* `origin/main` when steps 1 and 2 yielded nothing.
* 4. **headRef~1** — Last-resort fallback.
*
* Note: callers must validate the worktree still belongs to the task (e.g.
* compare `git rev-parse --abbrev-ref HEAD` to `task.branch`) before invoking
* this. After worktree-pool reassignment the same path may host a foreign
* branch, in which case `baseCommitSha..HEAD` would surface other tasks'
* commits and this function has no way to detect that.
*/
export async function resolveDiffBase(
task: ResolveDiffBaseTaskInput,
cwd: string,
headRef = "HEAD",
runGit: (args: string[], cwd?: string, timeout?: number) => Promise<string> = runGitCommand,
options: ResolveDiffBaseOptions = {},
): Promise<string | undefined> {
// When baseBranch was nulled (e.g., upstream dep merged and its branch was
// deleted) but a task-scoped baseCommitSha is still recorded, skip the
// merge-base step so we don't widen the diff range to merge-base(HEAD, main)
// and surface unrelated history. Only fall back to "main" when neither hint
// is available (legacy tasks).
const baseBranch = task.baseBranch?.trim() || (task.baseCommitSha ? undefined : "main");
let mergeBase: string | undefined;
if (baseBranch) {
try {
try {
mergeBase = (await runGit(["merge-base", headRef, baseBranch], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGit(["merge-base", headRef, `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
}
} catch {
// base branch may no longer exist locally/remotely
}
}
// If merge-base equals headRef, the live merge-base would produce an empty
// diff. Prefer task.baseCommitSha when still valid.
if (mergeBase) {
try {
const head = (await runGit(["rev-parse", headRef], cwd, 5000)).trim();
if (head && head !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
// Display-only recovery: compute merge-base(HEAD, main) when baseBranch is
// missing. Used both to recover from a stale baseCommitSha and to tighten
// an outdated-but-still-ancestor baseCommitSha. See ResolveDiffBaseOptions.
let recoveredBase: string | undefined;
if (options.enableDisplayRecovery && !task.baseBranch?.trim()) {
try {
recoveredBase = (await runGit(["merge-base", headRef, "main"], cwd, 5000)).trim() || undefined;
} catch {
try {
recoveredBase = (await runGit(["merge-base", headRef, "origin/main"], cwd, 5000)).trim() || undefined;
} catch {
// no recovery available
}
}
}
if (task.baseCommitSha) {
let baseShaIsAncestor = false;
try {
await runGit(["merge-base", "--is-ancestor", task.baseCommitSha, headRef], cwd, 5000);
baseShaIsAncestor = true;
} catch {
// stale or unreachable
}
if (baseShaIsAncestor) {
// Prefer recoveredBase only if it's strictly tighter (a descendant of
// baseCommitSha). When baseCommitSha is on a deleted feature branch
// it won't be an ancestor of merge-base(HEAD, main), so we keep the
// task-scoped SHA — preserves FN-2855 behavior.
if (recoveredBase && recoveredBase !== task.baseCommitSha) {
try {
await runGit(["merge-base", "--is-ancestor", task.baseCommitSha, recoveredBase], cwd, 5000);
return recoveredBase;
} catch {
// recoveredBase not a descendant — keep baseCommitSha
}
}
return task.baseCommitSha;
}
}
// baseCommitSha unusable (stale or unset) — use recoveredBase if available.
if (recoveredBase) return recoveredBase;
try {
return (await runGit(["rev-parse", `${headRef}~1`], cwd, 5000)).trim() || undefined;
} catch {
return undefined;
}
}