fix(dashboard): show only files actually changed by the task

Two fixes for the in-review/in-progress "files changed" count on task
cards and the Changes tab:

- Drop untracked files from /tasks/:id/diff and /tasks/:id/file-diffs.
  At review time these are almost always build artifacts/cache/logs not
  in .gitignore, not real task changes, and they inflated the count.
- Add display-only `enableDisplayRecovery` option to resolveDiffBase.
  When a worktree was rebased onto origin/main after baseCommitSha was
  recorded and baseBranch was not set, the prior code fell through to
  HEAD~1 — undercounting to just the last commit's files (e.g. FN-2957
  showed 2 in review, 6 after merge). Recovery now tries
  merge-base(HEAD, main) / origin/main before HEAD~1.

Routes opt into recovery; the merger's mirrored copy
(resolveTaskDiffBaseRef) is intentionally untouched so merge-time
scope warnings still evaluate the strict task base.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 10:13:39 -07:00
parent a555868b74
commit 7c857c82b5
3 changed files with 173 additions and 38 deletions

View File

@@ -34,19 +34,41 @@ export interface ResolveDiffBaseTaskInput {
baseBranch?: string;
}
export interface ResolveDiffBaseOptions {
/**
* Display-only recovery: when the normal resolution would fall through to
* `headRef~1` (because `baseBranch` is missing AND `baseCommitSha` is no
* longer an ancestor of HEAD — e.g., the worktree was rebased onto
* `origin/main` after `baseCommitSha` was recorded), attempt one final
* `merge-base(headRef, "main")` (then `origin/main`) before giving up to
* `headRef~1`.
*
* This is for the dashboard "files changed" UI 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. **headRef~1** — Last-resort fallback.
* 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
@@ -59,6 +81,7 @@ export async function resolveDiffBase(
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
@@ -100,6 +123,25 @@ export async function resolveDiffBase(
}
}
// Display-only recovery before the HEAD~1 fallback. Only kicks in when the
// caller explicitly opted in AND the original resolution skipped the
// merge-base step (no baseBranch was recorded). This catches the case where
// a worktree got rebased onto origin/main after baseCommitSha was
// recorded, leaving the SHA as a non-ancestor of HEAD.
if (options.enableDisplayRecovery && !task.baseBranch?.trim()) {
try {
const out = (await runGit(["merge-base", headRef, "main"], cwd, 5000)).trim();
if (out) return out;
} catch {
try {
const out = (await runGit(["merge-base", headRef, "origin/main"], cwd, 5000)).trim();
if (out) return out;
} catch {
// no recovery possible — fall through to HEAD~1
}
}
}
try {
return (await runGit(["rev-parse", `${headRef}~1`], cwd, 5000)).trim() || undefined;
} catch {