feat(FN-2490): merge fusion/fn-2490
This commit is contained in:
207
packages/dashboard/app/components/ChangesDiffModal.css
Normal file
207
packages/dashboard/app/components/ChangesDiffModal.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
31
packages/dashboard/app/components/TaskChangesTab.css
Normal file
31
packages/dashboard/app/components/TaskChangesTab.css
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
packages/dashboard/app/components/TaskDiffShared.css
Normal file
36
packages/dashboard/app/components/TaskDiffShared.css
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user