fix(dashboard): prevent foreign-branch diffs after worktree pool reuse

When the worktree-recycle pool reassigned a path to a new task, the old
task's diff endpoints kept reading the new task's branch state — surfacing
unrelated commits as the original task's "files changed" list.

- Clear task.worktree/branch in the merger after the worktree is released
  to the pool or removed, so the path no longer points anywhere.
- Validate the worktree's current branch matches task.branch in the three
  worktree-backed diff endpoints; on mismatch return empty rather than
  diffing against a foreign branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-27 11:04:42 -07:00
parent 0aa2bf621d
commit 3ff2ef8c8d
4 changed files with 80 additions and 1 deletions

View File

@@ -8,6 +8,28 @@ export interface SessionDiffRouteDeps {
getProjectContext: (req: Request) => Promise<ProjectContext>;
}
/**
* Confirm the worktree's current branch still matches the task's recorded
* branch. Worktrees from the recycle pool can be reassigned to a different
* task after a merge; without this check the diff endpoints would happily
* read another task's branch state and surface its commits as the original
* task's "files changed" list. Returns true when no validation is possible
* (e.g. task.branch was never set) so we don't break tests/legacy tasks.
*/
async function worktreeStillBelongsToTask(
worktree: string,
expectedBranch: string | undefined | null,
): Promise<boolean> {
if (!expectedBranch) return true;
try {
const actual = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], worktree, 5000)).trim();
if (!actual || actual === "HEAD") return true; // detached HEAD — can't validate
return actual === expectedBranch;
} catch {
return true; // best-effort: never block diff just because rev-parse failed
}
}
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
const fileDiffsCache = new Map<
string,
@@ -56,6 +78,12 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
const worktree = task.worktree;
if (!(await worktreeStillBelongsToTask(worktree, task.branch))) {
// Pool likely reassigned this path to another task — return empty
// rather than diffing against a foreign branch's HEAD.
res.json([]);
return;
}
const cached = sessionFilesCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
@@ -206,6 +234,10 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
if (!(await worktreeStillBelongsToTask(resolvedWorktree, task.branch))) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const cwd = resolvedWorktree;
const diffBase = await resolveDiffBase(task, cwd);
@@ -380,6 +412,10 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
const worktree = task.worktree;
if (!(await worktreeStillBelongsToTask(worktree, task.branch))) {
res.json([]);
return;
}
const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);

View File

@@ -47,6 +47,12 @@ export interface ResolveDiffBaseTaskInput {
* 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.
*
* 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,