feat(FN-2490): merge fusion/fn-2490

This commit is contained in:
gsxdsm
2026-04-25 07:59:48 -07:00
parent 43d630114b
commit d7a0cc6dcc
13 changed files with 686 additions and 345 deletions

View File

@@ -0,0 +1,207 @@
.changes-diff-modal {
width: 90vw;
max-width: 1600px;
height: 80vh;
max-height: calc(100vh - 2 * var(--overlay-padding-top, 10vh));
display: flex;
flex-direction: column;
}
.changes-diff-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.changes-diff-modal-header .modal-close {
position: static;
}
.changes-diff-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 15px;
font-weight: 600;
}
.changes-diff-header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.changes-diff-body {
display: flex;
flex: 1;
overflow: hidden;
}
.changes-diff-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
}
.changes-diff-file-list {
flex: 1;
overflow-y: auto;
padding: var(--space-sm) 0;
}
.changes-diff-file-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-left: 3px solid transparent;
color: var(--text);
text-align: left;
cursor: pointer;
font-size: 12px;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.changes-diff-file-item:hover {
background: var(--card-hover);
}
.changes-diff-file-item.selected {
background: var(--card-hover);
border-left-color: var(--todo);
}
.changes-diff-file-path {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-size: 12px;
}
.changes-diff-file-stat {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.changes-diff-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
.changes-diff-file-header-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
min-height: 40px;
}
.changes-diff-file-header-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changes-diff-file-header-stats {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.changes-diff-viewer {
flex: 1;
overflow: auto;
padding: var(--space-sm) 0;
}
.changes-diff-viewer .changes-diff-patch {
border: none;
border-radius: 0;
}
.changes-diff-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
flex: 1;
color: var(--text-muted);
padding: var(--space-xl);
text-align: center;
}
.changes-diff-empty p {
margin: 0;
font-size: 14px;
}
@media (max-width: 768px) {
.changes-diff-modal {
width: 100vw;
height: 100vh;
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.changes-diff-header-title {
flex: 1;
min-width: 0;
}
.changes-diff-header-actions {
flex-wrap: wrap;
gap: var(--space-xs);
}
.changes-diff-body {
flex-direction: column;
}
.changes-diff-sidebar {
width: 100%;
min-width: unset;
max-height: 40%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.changes-diff-file-list {
padding: var(--space-xs) 0;
}
.changes-diff-file-item {
min-height: 36px;
}
.changes-diff-content {
overflow-x: auto;
}
}

View File

@@ -10,6 +10,8 @@ import {
} from "lucide-react";
import type { MergeDetails, Column } from "@fusion/core";
import { highlightDiff } from "../utils/highlightDiff";
import "./TaskDiffShared.css";
import "./ChangesDiffModal.css";
/** Normalized file entry — re-exported from TaskChangesTab for shared use */
export interface NormalizedFile {

View File

@@ -4,6 +4,7 @@ import type { MergeDetails } from "@fusion/core";
import { fetchCommitDiff } from "../api";
import { getErrorMessage } from "@fusion/core";
import { highlightDiff } from "../utils/highlightDiff";
import "./TaskDiffShared.css";
interface CommitDiffTabProps {
commitSha: string;

View File

@@ -623,254 +623,6 @@
position: static;
}
/* Commit metadata for task changes (done tasks with merge details) */
.commit-diff-meta {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 2px;
background: var(--surface);
}
.commit-diff-sha {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.commit-diff-sha code {
font-family: var(--font-mono);
background: var(--card);
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.commit-diff-message {
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-diff-timestamp {
font-size: 11px;
color: var(--text-dim);
}
/* ChangesDiffModal — two-panel diff browser */
.changes-diff-modal {
width: 90vw;
max-width: 1600px;
height: 80vh;
max-height: calc(100vh - 2 * var(--overlay-padding-top, 10vh));
display: flex;
flex-direction: column;
}
.changes-diff-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.changes-diff-modal-header .modal-close {
position: static;
}
.changes-diff-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 15px;
font-weight: 600;
}
.changes-diff-header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.changes-diff-body {
display: flex;
flex: 1;
overflow: hidden;
}
.changes-diff-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--surface);
}
.changes-diff-file-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.changes-diff-file-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-left: 3px solid transparent;
color: var(--text);
text-align: left;
cursor: pointer;
font-size: 12px;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.changes-diff-file-item:hover {
background: var(--card-hover);
}
.changes-diff-file-item.selected {
background: var(--card-hover);
border-left-color: var(--todo);
}
.changes-diff-file-path {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-size: 12px;
}
.changes-diff-file-stat {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.changes-diff-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
.changes-diff-file-header-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
min-height: 40px;
}
.changes-diff-file-header-name {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changes-diff-file-header-stats {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
flex-shrink: 0;
}
.changes-diff-viewer {
flex: 1;
overflow: auto;
padding: var(--space-sm) 0;
}
.changes-diff-viewer .changes-diff-patch {
border: none;
border-radius: 0;
}
.changes-diff-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-md);
flex: 1;
color: var(--text-muted);
padding: var(--space-xl);
text-align: center;
}
.changes-diff-empty p {
margin: 0;
font-size: 14px;
}
/* Mobile responsive for changes-diff-modal */
@media (max-width: 768px) {
.changes-diff-modal {
width: 100vw;
height: 100vh;
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.changes-diff-header-title {
flex: 1;
min-width: 0;
}
.changes-diff-header-actions {
flex-wrap: wrap;
gap: var(--space-xs);
}
.changes-diff-body {
flex-direction: column;
}
.changes-diff-sidebar {
width: 100%;
min-width: unset;
max-height: 40%;
border-right: none;
border-bottom: 1px solid var(--border);
}
.changes-diff-file-list {
padding: 4px 0;
}
.changes-diff-file-item {
min-height: 36px;
}
.changes-diff-content {
overflow-x: auto;
}
}
.file-browser-header-path {
color: var(--text-muted);
font-weight: 400;

View File

@@ -3486,27 +3486,6 @@
padding: 0;
}
/* ── TaskChangesTab compact file-row spacing ──────────────────────────────── */
.task-changes-file-list--compact {
gap: 4px;
}
/* Pull the file list wider to reclaim horizontal space from .detail-body padding */
.task-changes-tab .changes-file-list.task-changes-file-list--compact {
margin-left: -12px;
margin-right: -12px;
}
.task-changes-file-list--compact .changes-file-header {
padding: 6px 6px;
gap: 6px;
}
.task-changes-file-list--compact .changes-file-stat {
margin-left: 4px;
}
/* Syntax highlighting for diff */
.gm-diff-patch .diff-add,
.changes-diff-patch .diff-add,

View File

@@ -0,0 +1,31 @@
.task-changes-file-list--compact {
gap: var(--space-xs);
}
/* Pull the file list wider to reclaim horizontal space from .detail-body padding */
.task-changes-tab .changes-file-list.task-changes-file-list--compact {
margin-left: calc(-1 * var(--space-md));
margin-right: calc(-1 * var(--space-md));
}
.task-changes-file-list--compact .changes-file-header {
padding: calc(var(--space-sm) - var(--space-xs) / 2);
gap: calc(var(--space-sm) - var(--space-xs) / 2);
}
.task-changes-file-list--compact .changes-file-stat {
margin-left: var(--space-xs);
}
.changes-file-header--static {
cursor: default;
}
.changes-file-header--static:hover,
.changes-file-header--static:active {
background: transparent;
}
.changes-file-header--static:focus-visible {
box-shadow: none;
}

View File

@@ -6,6 +6,8 @@ import { fetchTaskDiff, type TaskDiff } from "../api";
import { highlightDiff } from "../utils/highlightDiff";
import { truncateMiddle } from "../utils/truncatePath";
import { ChangesDiffModal } from "./ChangesDiffModal";
import "./TaskDiffShared.css";
import "./TaskChangesTab.css";
interface TaskChangesTabProps {
taskId: string;
@@ -234,7 +236,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
<div className="changes-file-list task-changes-file-list--compact">
{modifiedFiles.map((path) => (
<div key={path} className="changes-file-item">
<div className="changes-file-header">
<div className="changes-file-header changes-file-header--static">
<span
className="changes-file-status changes-file-status--unknown"
title="status unknown"

View File

@@ -572,8 +572,8 @@
.task-changes-state--error {
color: var(--color-error);
background: rgba(248, 81, 73, 0.08);
border-color: rgba(248, 81, 73, 0.25);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
border-color: color-mix(in srgb, var(--color-error) 25%, transparent);
}
.task-changes-state-hint {

View File

@@ -0,0 +1,36 @@
.commit-diff-meta {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
background: var(--surface);
}
.commit-diff-sha {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 12px;
color: var(--text-muted);
}
.commit-diff-sha code {
font-family: var(--font-mono);
background: var(--card);
padding: 1px calc(var(--space-sm) - var(--space-xs) / 2);
border-radius: var(--radius-sm);
}
.commit-diff-message {
font-size: 12px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commit-diff-timestamp {
font-size: 11px;
color: var(--text-dim);
}

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import type { Task } from "@fusion/core";
import { resolveDiffBase } from "../routes.js";
import { resolveTaskDiffBaseRef } from "../../../engine/src/merger.js";
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
@@ -164,3 +170,132 @@ describe("GET /api/tasks/:id/file-diffs", () => {
expect(response.body).toEqual([]);
});
});
describe("resolveDiffBase", () => {
it("prefers merge-base when it differs from head", async () => {
const runGit = vi.fn(async (args: string[]) => {
if (args.join(" ") === "merge-base HEAD main") return "merge-base-123";
if (args.join(" ") === "rev-parse HEAD") return "head-456";
throw new Error(`Unexpected command: ${args.join(" ")}`);
});
const diffBase = await resolveDiffBase(
{ baseBranch: "main", baseCommitSha: "task-base-789" },
"/tmp/worktree",
"HEAD",
runGit,
);
expect(diffBase).toBe("merge-base-123");
expect(runGit).not.toHaveBeenCalledWith(
["merge-base", "--is-ancestor", "task-base-789", "HEAD"],
"/tmp/worktree",
5000,
);
});
it("uses baseCommitSha when merge-base equals head", async () => {
const runGit = vi.fn(async (args: string[]) => {
if (args.join(" ") === "merge-base HEAD main") return "head-456";
if (args.join(" ") === "rev-parse HEAD") return "head-456";
if (args.join(" ") === "merge-base --is-ancestor task-base-789 HEAD") return "";
throw new Error(`Unexpected command: ${args.join(" ")}`);
});
const diffBase = await resolveDiffBase(
{ baseBranch: "main", baseCommitSha: "task-base-789" },
"/tmp/worktree",
"HEAD",
runGit,
);
expect(diffBase).toBe("task-base-789");
});
it("falls back to origin/baseBranch when local base branch is unavailable", async () => {
const runGit = vi.fn(async (args: string[]) => {
if (args.join(" ") === "merge-base HEAD main") {
throw new Error("missing local main");
}
if (args.join(" ") === "merge-base HEAD origin/main") return "origin-merge-base";
if (args.join(" ") === "rev-parse HEAD") return "head-456";
throw new Error(`Unexpected command: ${args.join(" ")}`);
});
const diffBase = await resolveDiffBase(
{ baseBranch: "main", baseCommitSha: "task-base-789" },
"/tmp/worktree",
"HEAD",
runGit,
);
expect(diffBase).toBe("origin-merge-base");
});
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
const runGit = vi.fn(async (args: string[]) => {
if (args.join(" ") === "merge-base HEAD main") {
throw new Error("missing local main");
}
if (args.join(" ") === "merge-base HEAD origin/main") {
throw new Error("missing remote main");
}
if (args.join(" ") === "merge-base --is-ancestor stale-base HEAD") {
throw new Error("stale base sha");
}
if (args.join(" ") === "rev-parse HEAD~1") return "parent-123";
throw new Error(`Unexpected command: ${args.join(" ")}`);
});
const diffBase = await resolveDiffBase(
{ baseBranch: "main", baseCommitSha: "stale-base" },
"/tmp/worktree",
"HEAD",
runGit,
);
expect(diffBase).toBe("parent-123");
});
});
describe("diff-base parity between dashboard and merger", () => {
it("resolves the same effective diff base for identical task metadata", async () => {
const repoDir = mkdtempSync(join(tmpdir(), "fn-diff-base-parity-"));
try {
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.email", "parity@example.com"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.name", "Parity Test"], { stdio: "pipe" });
writeFileSync(join(repoDir, "README.md"), "# parity\n");
execFileSync("git", ["-C", repoDir, "add", "README.md"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "commit", "-m", "initial"], { stdio: "pipe" });
writeFileSync(join(repoDir, "README.md"), "# parity\nsecond\n");
execFileSync("git", ["-C", repoDir, "commit", "-am", "second"], { stdio: "pipe" });
const diffBaseFromDashboard = await resolveDiffBase(
{ baseBranch: "missing-main", baseCommitSha: "stale-base" },
repoDir,
"HEAD",
);
const diffBaseFromMerger = await resolveTaskDiffBaseRef({
cwd: repoDir,
headRef: "HEAD",
baseBranch: "missing-main",
baseCommitSha: "stale-base",
});
const expectedParent = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD~1"], {
encoding: "utf-8",
stdio: "pipe",
}).trim();
expect(diffBaseFromDashboard).toBe(expectedParent);
expect(diffBaseFromMerger).toBe(expectedParent);
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
});

View File

@@ -365,6 +365,71 @@ async function runGitCommand(args: string[], cwd?: string, timeout = 10000): Pro
return "";
}
export interface ResolveDiffBaseTaskInput {
baseCommitSha?: string;
baseBranch?: string;
}
/**
* 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.
*
* 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.
*/
export async function resolveDiffBase(
task: ResolveDiffBaseTaskInput,
cwd: string,
headRef = "HEAD",
runGit: (args: string[], cwd?: string, timeout?: number) => Promise<string> = runGitCommand,
): Promise<string | undefined> {
const baseBranch = task.baseBranch ?? "main";
let mergeBase: string | undefined;
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;
}
}
if (task.baseCommitSha) {
try {
await runGit(["merge-base", "--is-ancestor", task.baseCommitSha, headRef], cwd, 5000);
return task.baseCommitSha;
} catch {
// stale or unreachable — fall through
}
}
try {
return (await runGit(["rev-parse", `${headRef}~1`], cwd, 5000)).trim() || undefined;
} catch {
return undefined;
}
}
function slugifyPresetName(name: string): string {
const slug = name
.trim()
@@ -4654,68 +4719,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* Resolve the diff base ref for a task's worktree.
*
* Strategy (in priority order):
* 1. **Branch merge-base** — Prefer the live merge-base between HEAD and
* the local `{baseBranch}` ref (falling back to `origin/{baseBranch}`
* when the local ref is missing). The local ref reflects the worktree's
* actual fork point regardless of whether merges have been pushed; using
* `origin/{baseBranch}` first would inflate the diff by every commit
* between `origin/main` and a locally-advanced `main`.
* 2. **Task-scoped baseCommitSha** — Only when no merge-base is available
* (e.g. the base branch was deleted), fall back to the stored SHA if it
* is still an ancestor of HEAD.
* 3. **HEAD~1** — Last resort when neither merge-base nor baseCommitSha
* can be resolved.
*
* Why not prefer baseCommitSha: a stored SHA captured at worktree creation
* goes stale as upstream merges land on the feature branch. The ancestor
* check passes but the diff now includes every upstream file that was
* merged in, inflating the changed-files count.
*/
async function resolveDiffBase(task: { baseCommitSha?: string; baseBranch?: string }, cwd: string): Promise<string | undefined> {
const baseBranch = task.baseBranch ?? "main";
let mergeBase: string | undefined;
try {
try {
mergeBase = (await runGitCommand(["merge-base", "HEAD", baseBranch], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGitCommand(["merge-base", "HEAD", `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
}
} catch {
// base branch may no longer exist locally
}
// If the merge-base equals HEAD, we're on the base branch with no feature
// divergence — the live merge-base would give an empty diff, so prefer the
// task-scoped baseCommitSha instead.
if (mergeBase) {
try {
const head = (await runGitCommand(["rev-parse", "HEAD"], cwd, 5000)).trim();
if (head && head !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
if (task.baseCommitSha) {
try {
await runGitCommand(["merge-base", "--is-ancestor", task.baseCommitSha, "HEAD"], cwd, 5000);
return task.baseCommitSha;
} catch {
// stale or unreachable — fall through
}
}
try {
return (await runGitCommand(["rev-parse", "HEAD~1"], cwd, 5000)).trim() || undefined;
} catch {
return undefined;
}
}
router.get("/tasks/:id/session-files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
@@ -17938,8 +17941,8 @@ async function persistImportedSkills(
* GET /api/tasks/:id/file-diffs
* Fetch changed files with individual git diffs for a task worktree.
* Uses the shared resolveDiffBase() helper so the board card count and the
* changed-files viewer always agree. Prefers task.baseCommitSha when valid,
* falling back to branch merge-base / HEAD~1.
* changed-files viewer always agree. Prefers live branch merge-base first,
* then falls back to task.baseCommitSha / HEAD~1.
* Returns: Array<{ path, status, diff, oldPath? }>
*/
router.get("/tasks/:id/file-diffs", async (req, res) => {
@@ -18026,8 +18029,8 @@ async function persistImportedSkills(
const cwd = worktree;
// Resolve a diff base using the shared strategy so both endpoints
// always agree on which files have changed. Prefer task-scoped
// baseCommitSha when it is still valid for the current HEAD.
// always agree on which files have changed. Prefer live branch
// merge-base first, then task-scoped baseCommitSha when needed.
const diffBase = await resolveDiffBase(task, cwd);
// Collect file statuses from committed, staged, unstaged, and untracked changes.

View File

@@ -111,6 +111,7 @@ import {
shouldSyncDependenciesForMerge,
summarizeVerificationOutput,
inferDefaultTestCommand,
resolveTaskDiffBaseRef,
type ConflictCategory,
} from "../merger.js";
import { mergerLog } from "../logger.js";
@@ -3789,6 +3790,108 @@ describe("validateDiffScope", () => {
});
});
describe("resolveTaskDiffBaseRef", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("prefers merge-base when it differs from head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "merge-base-123" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("merge-base-123");
expect(
mockedExecSync.mock.calls.some(([cmd]) =>
String(cmd).includes("merge-base --is-ancestor"),
),
).toBe(false);
});
it("uses baseCommitSha when merge-base equals head", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') return "head-456" as any;
if (cmdStr === 'git rev-parse "HEAD"') return "head-456" as any;
if (cmdStr === 'git merge-base --is-ancestor "task-base-789" "HEAD"') return "" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "task-base-789",
});
expect(diffBase).toBe("task-base-789");
});
it("falls back to HEAD~1 when merge-base is unavailable and baseCommitSha is stale", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') return "parent-123" as any;
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBe("parent-123");
});
it("returns undefined when no merge base or fallback refs are available", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr === 'git merge-base "HEAD" "main"') {
throw new Error("missing local main");
}
if (cmdStr === 'git merge-base "HEAD" "origin/main"') {
throw new Error("missing remote main");
}
if (cmdStr === 'git merge-base --is-ancestor "stale-base" "HEAD"') {
throw new Error("stale base sha");
}
if (cmdStr === 'git rev-parse "HEAD~1"') {
throw new Error("single commit repo");
}
throw new Error(`Unexpected command: ${cmdStr}`);
});
const diffBase = await resolveTaskDiffBaseRef({
cwd: "/tmp/root",
headRef: "HEAD",
baseBranch: "main",
baseCommitSha: "stale-base",
});
expect(diffBase).toBeUndefined();
});
});
describe("aiMergeTask — post-merge workflow steps", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1010,6 +1010,92 @@ export async function validateDiffScope(
return result;
}
interface DiffBaseResolutionInput {
cwd: string;
headRef: string;
baseBranch?: string;
baseCommitSha?: string;
}
/**
* Resolve the commit ref used as diff base for task-scoped changed-file views.
*
* IMPORTANT: This ordering must stay in lockstep with dashboard `resolveDiffBase`
* so merge-time scope warnings evaluate the exact same change set operators see.
*
* Strategy (priority order):
* 1. Live merge-base between `headRef` and `{baseBranch}` (fallback to
* `origin/{baseBranch}` when local ref is missing).
* 2. `baseCommitSha` when merge-base is unavailable or equals `headRef`, and
* the SHA is still an ancestor of `headRef`.
* 3. `headRef~1` as last resort.
*/
export async function resolveTaskDiffBaseRef({
cwd,
headRef,
baseBranch,
baseCommitSha,
}: DiffBaseResolutionInput): Promise<string | undefined> {
const resolvedBaseBranch = baseBranch?.trim() || "main";
const quotedHeadRef = quoteArg(headRef);
let mergeBase: string | undefined;
try {
try {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(resolvedBaseBranch)}`, {
cwd,
encoding: "utf-8",
});
mergeBase = stdout.trim() || undefined;
} catch {
const { stdout } = await execAsync(`git merge-base ${quotedHeadRef} ${quoteArg(`origin/${resolvedBaseBranch}`)}`, {
cwd,
encoding: "utf-8",
});
mergeBase = stdout.trim() || undefined;
}
} catch {
// Base branch may not exist locally/remotely.
}
// Same guard as dashboard routes: when merge-base === headRef, the range
// would be empty, so prefer a still-valid task-scoped baseCommitSha.
if (mergeBase) {
try {
const { stdout } = await execAsync(`git rev-parse ${quotedHeadRef}`, {
cwd,
encoding: "utf-8",
});
const headSha = stdout.trim();
if (headSha && headSha !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
if (baseCommitSha) {
try {
await execAsync(`git merge-base --is-ancestor ${quoteArg(baseCommitSha)} ${quotedHeadRef}`, {
cwd,
encoding: "utf-8",
});
return baseCommitSha;
} catch {
// stale or unreachable — fall through
}
}
try {
const { stdout } = await execAsync(`git rev-parse ${quoteArg(`${headRef}~1`)}`, {
cwd,
encoding: "utf-8",
});
return stdout.trim() || undefined;
} catch {
return undefined;
}
}
/**
* Get list of conflicted files from git.
* Runs `git diff --name-only --diff-filter=U` and returns array of file paths.
@@ -1961,10 +2047,19 @@ export async function aiMergeTask(
}
// 4. Gather context for the agent (used in all attempts)
// Keep this range strategy aligned with dashboard changed-files endpoints.
const diffBaseRef = await resolveTaskDiffBaseRef({
cwd: rootDir,
headRef: branch,
baseBranch: task.baseBranch,
baseCommitSha: task.baseCommitSha,
});
const contextDiffRange = diffBaseRef ? `${diffBaseRef}..${branch}` : `HEAD..${branch}`;
let commitLog = "";
let diffStat = "";
try {
const { stdout: logOutput } = await execAsync(`git log HEAD..${branch} --format="- %s"`, {
const { stdout: logOutput } = await execAsync(`git log ${contextDiffRange} --format="- %s"`, {
cwd: rootDir,
encoding: "utf-8",
});
@@ -1973,12 +2068,7 @@ export async function aiMergeTask(
commitLog = "(unable to read commit log)";
}
try {
const { stdout: mergeBaseOutput } = await execAsync(`git merge-base HEAD ${branch}`, {
cwd: rootDir,
encoding: "utf-8",
});
const mergeBase = mergeBaseOutput.trim();
const { stdout: diffOutput } = await execAsync(`git diff ${mergeBase}..${branch} --stat`, {
const { stdout: diffOutput } = await execAsync(`git diff ${contextDiffRange} --stat`, {
cwd: rootDir,
encoding: "utf-8",
});