From 921e0e616f55b1b3652077206b71b8e9d7d07f13 Mon Sep 17 00:00:00 2001 From: "Fusion (runfusion.ai)" Date: Thu, 21 May 2026 17:46:10 -0700 Subject: [PATCH] feat(FN-5358): add smart pull API route with stash conflict detection and r MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements stash conflict detection and resolution with a new smart pull API route, resolution endpoints, and a dedicated conflict modal UI — backed by extended git audit taxonomy and route tests. Fusion-Task-Id: FN-5358 --- AGENTS.md | 3 + .../app/components/MergeAdvanceNotice.tsx | 94 ++-- .../app/components/StashConflictModal.css | 71 +++ .../app/components/StashConflictModal.tsx | 196 ++++++++ .../__tests__/MergeAdvanceNotice.test.tsx | 88 +++- .../__tests__/StashConflictModal.test.tsx | 156 +++++++ .../__tests__/routes-git-smart-pull.test.ts | 354 ++++++++++++++ .../src/routes/register-git-github.ts | 440 +++++++++++++++++- packages/dashboard/vitest.config.ts | 2 +- .../engine/src/__tests__/run-audit.test.ts | 14 +- packages/engine/src/run-audit.ts | 56 ++- 11 files changed, 1422 insertions(+), 52 deletions(-) create mode 100644 packages/dashboard/app/components/StashConflictModal.css create mode 100644 packages/dashboard/app/components/StashConflictModal.tsx create mode 100644 packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx create mode 100644 packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts diff --git a/AGENTS.md b/AGENTS.md index c0507f2b8..d6982f353 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -438,6 +438,9 @@ Every engine mutation is recorded across four domains: - `merge:cwd-integration-fallback-refused` — terminal reuse-handoff refusal path that parks in-review without cwd fallback. - `merge:integration-ref-advance` — typed integration ref update outcome (`succeeded`/`error`) with resolved ref metadata. - `merge:integration-worktree-state` — per-merge snapshot of resolved integration branch checkout and dirty/untracked state. + - `pull:fast-forward` — dashboard Smart Pull audit for `git pull --ff-only` attempts (success/failure metadata). + - `stash:pop-conflict` — dashboard Smart Pull audit for stash apply/pop conflict surfaces. +- **Dashboard Git endpoints** — `POST /api/git/smart-pull`, `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, `POST /api/git/stash-restore`. - **Filesystem** — file:write, prompt:write, attachment:create, `secret:read|create|update|delete|approval-requested|approval-granted|approval-denied|sync-push|sync-pull`, `secret:env-*`, etc. - **Sandbox** — `sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`. diff --git a/packages/dashboard/app/components/MergeAdvanceNotice.tsx b/packages/dashboard/app/components/MergeAdvanceNotice.tsx index 1cc70be03..e08df5bd8 100644 --- a/packages/dashboard/app/components/MergeAdvanceNotice.tsx +++ b/packages/dashboard/app/components/MergeAdvanceNotice.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { X } from "lucide-react"; import { api, ApiRequestError } from "../api"; import { subscribeSse } from "../sse-bus"; +import StashConflictModal from "./StashConflictModal"; import "./MergeAdvanceNotice.css"; interface MergeAdvanceEvent { @@ -24,9 +25,10 @@ interface MergeAdvanceEventsResponse { events: MergeAdvanceEvent[]; } -interface PullResponse { - message?: string; -} +type SmartPullResponse = + | { kind: "clean-pull"; toSha: string } + | { kind: "stash-pull-pop"; toSha: string } + | { kind: "stash-pop-conflict"; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[] }; interface MergeAdvanceNoticeProps { projectId?: string; @@ -70,6 +72,11 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg const [dismissedShas, setDismissedShas] = useState(() => readDismissedShas(projectId)); const [pulling, setPulling] = useState(false); const [pullError, setPullError] = useState(null); + const [conflictState, setConflictState] = useState<{ + stashSha: string; + stashLabel: string; + conflictedFiles: string[]; + } | null>(null); useEffect(() => { setDismissedShas(readDismissedShas(projectId)); @@ -109,11 +116,7 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg && event.userCheckout.worktreePath.trim().length > 0 )), [events]); - if (!notice || dismissedShas.includes(notice.toSha)) { - return null; - } - - if (!notice.userCheckout) { + if (!notice || dismissedShas.includes(notice.toSha) || !notice.userCheckout) { return null; } @@ -131,10 +134,22 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg setPullError(null); try { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; - await api(`/git/pull${query}`, { + const response = await api(`/git/smart-pull${query}`, { method: "POST", - body: JSON.stringify({ rebase: false }), + body: JSON.stringify({ + worktreePath: checkout.worktreePath, + integrationBranch: notice.integrationBranch, + taskId: notice.taskId, + }), }); + if (response.kind === "stash-pop-conflict") { + setConflictState({ + stashSha: response.stashSha, + stashLabel: response.stashLabel, + conflictedFiles: response.conflictedFiles, + }); + return; + } dismiss(); } catch (error: unknown) { if (error instanceof ApiRequestError) { @@ -150,29 +165,44 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg }; return ( -
-
- {notice.integrationBranch} advanced to {shortSha(notice.toSha)}.{" "} - Your checked-out copy at {checkout.worktreePath} is behind. - {localChangesPreserved ? " (local changes preserved)" : ""} - {pullError ? {pullError} : null} - {pulling ? Pulling… : null} -
-
- {!localChangesPreserved ? ( - + )} + - ) : null} - +
- + { + setConflictState(null); + dismiss(); + }} + worktreePath={checkout.worktreePath} + integrationBranch={notice.integrationBranch} + stashSha={conflictState?.stashSha ?? ""} + stashLabel={conflictState?.stashLabel ?? ""} + conflictedFiles={conflictState?.conflictedFiles ?? []} + taskId={notice.taskId} + /> + ); } diff --git a/packages/dashboard/app/components/StashConflictModal.css b/packages/dashboard/app/components/StashConflictModal.css new file mode 100644 index 000000000..41b619b21 --- /dev/null +++ b/packages/dashboard/app/components/StashConflictModal.css @@ -0,0 +1,71 @@ +.stash-conflict-modal { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.stash-conflict-modal__summary { + margin: 0; + color: var(--text); +} + +.stash-conflict-modal__stash-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.stash-conflict-modal__list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.stash-conflict-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--card); +} + +.stash-conflict-row__path { + color: var(--text); + word-break: break-word; +} + +.stash-conflict-row__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.stash-conflict-modal__hint { + margin: 0; + color: var(--text-muted); +} + +.stash-conflict-modal__error { + margin: 0; + color: var(--color-error); +} + +@media (max-width: 768px) { + .stash-conflict-row { + align-items: flex-start; + flex-direction: column; + } + + .stash-conflict-row__actions { + width: 100%; + flex-direction: column; + } +} diff --git a/packages/dashboard/app/components/StashConflictModal.tsx b/packages/dashboard/app/components/StashConflictModal.tsx new file mode 100644 index 000000000..cb54315a3 --- /dev/null +++ b/packages/dashboard/app/components/StashConflictModal.tsx @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useState } from "react"; +import { Copy } from "lucide-react"; +import { ApiRequestError, api } from "../api"; +import { useFileBrowser } from "../context/FileBrowserContext"; +import "./StashConflictModal.css"; + +interface ResolveResponse { + remainingConflicts: string[]; +} + +interface DropResponse { + dropped: boolean; +} + +interface RestoreResponse { + applied: boolean; + conflict: boolean; + conflictedFiles: string[]; +} + +export interface StashConflictModalProps { + open: boolean; + onClose: () => void; + worktreePath: string; + integrationBranch: string; + stashSha: string; + stashLabel: string; + conflictedFiles: string[]; + taskId?: string; +} + +function shortSha(sha: string): string { + return sha.length > 7 ? sha.slice(0, 7) : sha; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof ApiRequestError) { + return error.message || "Request failed"; + } + if (error instanceof Error && error.message) { + return error.message; + } + return "Request failed"; +} + +export default function StashConflictModal({ + open, + onClose, + worktreePath, + integrationBranch, + stashSha, + stashLabel, + conflictedFiles, + taskId, +}: StashConflictModalProps) { + const fileBrowser = useFileBrowser(); + const [remainingConflicts, setRemainingConflicts] = useState(conflictedFiles); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); + + useEffect(() => { + if (open) { + setRemainingConflicts(conflictedFiles); + setError(null); + setCopyState("idle"); + } + }, [conflictedFiles, open]); + + const stashDescriptor = useMemo(() => `Stash ref: ${shortSha(stashSha)} (${stashLabel})`, [stashLabel, stashSha]); + + if (!open) { + return null; + } + + const resolveFile = async (file: string, choice: "ours" | "theirs") => { + setSubmitting(true); + setError(null); + try { + const response = await api("/git/stash-resolve", { + method: "POST", + body: JSON.stringify({ worktreePath, stashSha, file, choice, taskId }), + }); + setRemainingConflicts(Array.isArray(response.remainingConflicts) ? response.remainingConflicts : []); + } catch (resolveError: unknown) { + setError(getErrorMessage(resolveError)); + } finally { + setSubmitting(false); + } + }; + + const dropStash = async () => { + setSubmitting(true); + setError(null); + try { + const response = await api("/git/stash-drop", { + method: "POST", + body: JSON.stringify({ worktreePath, stashSha, taskId }), + }); + if (response.dropped) { + onClose(); + } + } catch (dropError: unknown) { + setError(getErrorMessage(dropError)); + } finally { + setSubmitting(false); + } + }; + + const restoreStash = async () => { + setSubmitting(true); + setError(null); + try { + const response = await api("/git/stash-restore", { + method: "POST", + body: JSON.stringify({ worktreePath, stashSha, taskId }), + }); + if (response.conflict) { + setRemainingConflicts(Array.isArray(response.conflictedFiles) ? response.conflictedFiles : []); + } + } catch (restoreError: unknown) { + setError(getErrorMessage(restoreError)); + } finally { + setSubmitting(false); + } + }; + + const copyRef = async () => { + try { + await navigator.clipboard.writeText(stashSha); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + return ( +
+
+
+

Resolve auto-stash conflicts

+
+

+ Pulled {integrationBranch}, but restoring local edits from stash produced conflicts. +

+
+ {stashDescriptor} + +
+ {copyState === "copied" ?

Stash SHA copied.

: null} + {copyState === "failed" ?

Could not copy stash SHA.

: null} +
+ {remainingConflicts.map((file) => ( +
+ {file} +
+ + + +
+
+ ))} +
+ {error ?

{error}

: null} +
+
+ +
+
+ + +
+
+
+
+ ); +} diff --git a/packages/dashboard/app/components/__tests__/MergeAdvanceNotice.test.tsx b/packages/dashboard/app/components/__tests__/MergeAdvanceNotice.test.tsx index 7cab2bd7b..695e6392e 100644 --- a/packages/dashboard/app/components/__tests__/MergeAdvanceNotice.test.tsx +++ b/packages/dashboard/app/components/__tests__/MergeAdvanceNotice.test.tsx @@ -6,6 +6,7 @@ import { ApiRequestError } from "../../api"; const mocked = vi.hoisted(() => ({ api: vi.fn(), mergedHandler: undefined as (() => void) | undefined, + stashModalProps: [] as Array>, })); vi.mock("../../api", async () => { @@ -23,11 +24,18 @@ vi.mock("../../sse-bus", () => ({ }), })); +vi.mock("../StashConflictModal", () => ({ + default: (props: Record) => { + mocked.stashModalProps.push(props); + return props.open ?
stash-conflict-modal
: null; + }, +})); + function makeEvent(overrides: Partial> = {}) { return { taskId: "FN-1", - integrationBranch: "master", - refName: "refs/heads/master", + integrationBranch: "release", + refName: "refs/heads/release", toSha: "abcdef123456", fromSha: "1234567", advanceMode: "update-ref", @@ -46,6 +54,7 @@ describe("MergeAdvanceNotice", () => { beforeEach(() => { mocked.api.mockReset(); mocked.mergedHandler = undefined; + mocked.stashModalProps = []; window.localStorage.clear(); }); @@ -63,32 +72,80 @@ describe("MergeAdvanceNotice", () => { it("renders notice with dynamic branch name", async () => { mocked.api.mockResolvedValueOnce({ events: [makeEvent()] }); render(); - expect(await screen.findByText(/master advanced to abcdef1\./)).toBeInTheDocument(); + expect(await screen.findByText(/release advanced to abcdef1\./)).toBeInTheDocument(); expect(screen.getByText(/Your checked-out copy at \/repo is behind\./)).toBeInTheDocument(); }); it.each([ { dirty: true, untrackedCount: 0 }, { dirty: false, untrackedCount: 2 }, - ])("shows local changes preserved and hides pull when dirty markers present", async ({ dirty, untrackedCount }) => { - mocked.api.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] }); + ])("shows auto-stash copy and keeps pull visible for dirty state", async ({ dirty, untrackedCount }) => { + mocked.api + .mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] }) + .mockResolvedValueOnce({ kind: "clean-pull", toSha: "abcdef123456" }); render(); - expect(await screen.findByText(/local changes preserved/)).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Pull" })).toBeNull(); + + expect(await screen.findByText(/local changes will be auto-stashed and restored/)).toBeInTheDocument(); + const pullButton = screen.getByRole("button", { name: "Pull" }); + fireEvent.click(pullButton); + + await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/smart-pull?projectId=proj-1", { + method: "POST", + body: JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "release", + taskId: "FN-1", + }), + })); }); - it("pull posts rebase false and dismisses on success", async () => { + it("clean smart-pull dismisses notice", async () => { mocked.api .mockResolvedValueOnce({ events: [makeEvent()] }) - .mockResolvedValueOnce({ ok: true }); + .mockResolvedValueOnce({ kind: "clean-pull", toSha: "abcdef123456" }); render(); + + fireEvent.click(await screen.findByRole("button", { name: "Pull" })); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + }); + + it("dirty smart-pull stash-pull-pop dismisses notice", async () => { + mocked.api + .mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 0 } })] }) + .mockResolvedValueOnce({ kind: "stash-pull-pop", toSha: "abcdef123456" }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Pull" })); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + }); + + it("stash-pop-conflict opens modal and passes payload", async () => { + mocked.api + .mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 1 } })] }) + .mockResolvedValueOnce({ + kind: "stash-pop-conflict", + toSha: "abcdef123456", + stashSha: "stashsha123", + stashLabel: "fusion-auto-stash-FN-1", + conflictedFiles: ["src/a.ts"], + }); + render(); + fireEvent.click(await screen.findByRole("button", { name: "Pull" })); - await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/pull?projectId=proj-1", { - method: "POST", - body: JSON.stringify({ rebase: false }), - })); - await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + expect(await screen.findByTestId("stash-conflict-modal")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Pull" })).toBeNull(); + + const props = mocked.stashModalProps.at(-1); + expect(props).toMatchObject({ + open: true, + worktreePath: "/repo", + integrationBranch: "release", + stashSha: "stashsha123", + stashLabel: "fusion-auto-stash-FN-1", + conflictedFiles: ["src/a.ts"], + taskId: "FN-1", + }); }); it("shows inline pull failure and keeps notice visible", async () => { @@ -102,7 +159,6 @@ describe("MergeAdvanceNotice", () => { expect(await screen.findByText(/Merge conflict detected/)).toBeInTheDocument(); expect(screen.getByRole("status")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Pull" })).not.toBeDisabled(); }); it("dismisses current sha and stays hidden when same advance is refetched", async () => { @@ -129,7 +185,7 @@ describe("MergeAdvanceNotice", () => { await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); mocked.mergedHandler?.(); - expect(await screen.findByText(/master advanced to bbbbbbb\./)).toBeInTheDocument(); + expect(await screen.findByText(/release advanced to bbbbbbb\./)).toBeInTheDocument(); }); it("renders nothing for failed advance or null checkout", async () => { diff --git a/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx new file mode 100644 index 000000000..7c2766c52 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import StashConflictModal from "../StashConflictModal"; +import { ApiRequestError } from "../../api"; + +const mocked = vi.hoisted(() => ({ + api: vi.fn(), + openFile: vi.fn(), + writeText: vi.fn(), +})); + +vi.mock("../../api", async () => { + const actual = await vi.importActual("../../api"); + return { + ...actual, + api: mocked.api, + }; +}); + +vi.mock("../../context/FileBrowserContext", () => ({ + useFileBrowser: () => ({ openFile: mocked.openFile }), +})); + +function renderModal(overrides: Partial> = {}) { + return render( + , + ); +} + +describe("StashConflictModal", () => { + beforeEach(() => { + mocked.api.mockReset(); + mocked.openFile.mockReset(); + mocked.writeText.mockReset(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mocked.writeText }, + configurable: true, + }); + }); + + it("does not render when closed", () => { + renderModal({ open: false }); + expect(screen.queryByText(/Resolve auto-stash conflicts/)).toBeNull(); + }); + + it("renders one row per conflicted file with actions", () => { + renderModal(); + expect(screen.getByText("src/a.ts")).toBeInTheDocument(); + expect(screen.getByText("src/b.ts")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Keep mine" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Keep incoming" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Open in editor" })).toHaveLength(2); + }); + + it("Keep mine posts ours and removes row from response", async () => { + mocked.api.mockResolvedValueOnce({ remainingConflicts: ["src/b.ts"] }); + renderModal(); + + fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]); + + await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-resolve", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ worktreePath: "/repo", stashSha: "1234567890abcdef", file: "src/a.ts", choice: "ours", taskId: "FN-1" }), + }))); + expect(screen.queryByText("src/a.ts")).toBeNull(); + expect(screen.getByText("src/b.ts")).toBeInTheDocument(); + }); + + it("Keep incoming posts theirs", async () => { + mocked.api.mockResolvedValueOnce({ remainingConflicts: ["src/a.ts"] }); + renderModal(); + + fireEvent.click(screen.getAllByRole("button", { name: "Keep incoming" })[1]); + + await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-resolve", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ worktreePath: "/repo", stashSha: "1234567890abcdef", file: "src/b.ts", choice: "theirs", taskId: "FN-1" }), + }))); + }); + + it("Open in editor uses file browser workspace", () => { + renderModal(); + fireEvent.click(screen.getAllByRole("button", { name: "Open in editor" })[0]); + expect(mocked.openFile).toHaveBeenCalledWith("src/a.ts", { workspace: "/repo" }); + }); + + it("Drop stash disabled until conflicts resolved, then drops and closes", async () => { + const onClose = vi.fn(); + mocked.api + .mockResolvedValueOnce({ remainingConflicts: [] }) + .mockResolvedValueOnce({ dropped: true }); + renderModal({ onClose }); + + const drop = screen.getByRole("button", { name: "Drop stash" }); + expect(drop).toBeDisabled(); + + fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]); + await waitFor(() => expect(drop).toBeEnabled()); + + fireEvent.click(drop); + await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-drop", expect.objectContaining({ method: "POST" }))); + expect(onClose).toHaveBeenCalled(); + }); + + it("Restore from stash ref posts and updates conflicts when returned", async () => { + mocked.api.mockResolvedValueOnce({ applied: true, conflict: true, conflictedFiles: ["src/c.ts"] }); + renderModal(); + + fireEvent.click(screen.getByRole("button", { name: "Restore from stash ref" })); + + await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-restore", expect.objectContaining({ method: "POST" }))); + expect(screen.getByText("src/c.ts")).toBeInTheDocument(); + }); + + it("shows inline error for resolve/drop/restore failures", async () => { + mocked.api + .mockRejectedValueOnce(new ApiRequestError("resolve failed", 500)) + .mockRejectedValueOnce(new ApiRequestError("restore failed", 500)) + .mockResolvedValueOnce({ remainingConflicts: [] }) + .mockRejectedValueOnce(new ApiRequestError("drop failed", 500)); + renderModal(); + + fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]); + expect(await screen.findByText("resolve failed")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Restore from stash ref" })); + expect(await screen.findByText("restore failed")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]); + await waitFor(() => expect(screen.getByRole("button", { name: "Drop stash" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Drop stash" })); + expect(await screen.findByText("drop failed")).toBeInTheDocument(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("shows stash sha/label and copies sha", async () => { + mocked.writeText.mockResolvedValueOnce(undefined); + renderModal(); + + expect(screen.getByText(/Stash ref: 1234567 \(fusion-auto-stash-FN-1\)/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Copy stash reference" })); + + await waitFor(() => expect(mocked.writeText).toHaveBeenCalledWith("1234567890abcdef")); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts b/packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts new file mode 100644 index 000000000..c46af249b --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts @@ -0,0 +1,354 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { createServer } from "../server.js"; +import { request } from "../test-request.js"; + +const mocked = vi.hoisted(() => ({ + runGitCommand: vi.fn(), +})); + +vi.mock("../routes/resolve-diff-base.js", () => ({ + runGitCommand: mocked.runGitCommand, +})); + +class MockStore extends EventEmitter { + recordRunAuditEvent = vi.fn(); + getRootDir(): string { return "/repo"; } + getFusionDir(): string { return "/repo/.fusion"; } + getSettings = vi.fn().mockResolvedValue({}); + getSettingsFast = vi.fn().mockResolvedValue({}); + getDatabase() { + return { + exec: vi.fn(), + prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }), + }; + } +} + +type ScriptedResponse = string | Error; + +function gitScript(map: Record) { + const calls: string[] = []; + mocked.runGitCommand.mockImplementation(async (args: string[]) => { + const key = args.join(" "); + calls.push(key); + const hit = Object.entries(map).find(([prefix]) => key.startsWith(prefix)); + if (!hit) { + throw new Error(`missing mock for ${key}`); + } + if (hit[1] instanceof Error) { + throw hit[1]; + } + return hit[1] as string; + }); + return calls; +} + +describe("smart pull routes", () => { + let store: MockStore; + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + store = new MockStore(); + app = createServer(store as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns clean-pull for clean worktree and emits pull audit", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "bbbb\n", + "status --porcelain=v1 --untracked-files=all": "", + "pull --ff-only": "Already up to date.\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ kind: "clean-pull", fromSha: "bbbb", toSha: "bbbb" }); + const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType); + expect(events).toEqual(["pull:fast-forward"]); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + domain: "git", + mutationType: "pull:fast-forward", + taskId: "FN-5358", + target: "/repo", + metadata: expect.objectContaining({ succeeded: true, integrationBranch: "main" }), + })); + }); + + it("runs dirty stash/pull/pop path and emits ordered audits", async () => { + const calls = gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "cccc\n", + "status --porcelain=v1 --untracked-files=all": " M file.ts\n", + "stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved working directory and index state\n", + "rev-parse stash@{0}": "stashsha123\n", + "pull --ff-only": "Updating cccc..dddd\n", + "stash pop": "On branch main\nDropped refs/stash@{0}\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ kind: "stash-pull-pop", stashSha: "stashsha123", stashLabel: "fusion-auto-stash-FN-5358" }); + const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType); + expect(events).toEqual(["stash:push", "pull:fast-forward", "stash:pop"]); + const stashPush = store.recordRunAuditEvent.mock.calls[0][0]; + const stashPop = store.recordRunAuditEvent.mock.calls[2][0]; + expect(stashPush.metadata.stashSha).toBe("stashsha123"); + expect(stashPop.metadata.stashSha).toBe("stashsha123"); + expect(calls).not.toContain("stash drop"); + }); + + it("returns stash-pop-conflict and never drops stash", async () => { + const calls = gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "eeee\n", + "status --porcelain=v1 --untracked-files=all": " M app.tsx\n", + "stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved\n", + "rev-parse stash@{0}": "stashsha456\n", + "pull --ff-only": "Updating\n", + "stash pop": new Error("CONFLICT (content): Merge conflict in app.tsx"), + "stash list --format=%H|%gd": "stashsha456|stash@{0}\n", + "diff --name-only --diff-filter=U": "app.tsx\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ kind: "stash-pop-conflict", conflictedFiles: ["app.tsx"] }); + expect(calls.find((call) => call.startsWith("stash drop"))).toBeUndefined(); + const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType); + expect(events).toEqual(["stash:push", "pull:fast-forward", "stash:pop-conflict"]); + }); + + it("returns 409 on pull rejection, restores stash, emits failed pull audit", async () => { + const calls = gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "ffff\n", + "status --porcelain=v1 --untracked-files=all": " M a.ts\n", + "stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved\n", + "rev-parse stash@{0}": "stashsha789\n", + "pull --ff-only": new Error("fatal: Not possible to fast-forward, aborting."), + "stash pop": "restored\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(409); + expect(calls).toContain("stash pop"); + const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event); + expect(events.map((event) => event.mutationType)).toEqual(["stash:push", "pull:fast-forward"]); + expect(events[1].metadata.succeeded).toBe(false); + }); + + it("uses taskId in stash label, and timestamp fallback when taskId omitted", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "1111\n", + "status --porcelain=v1 --untracked-files=all": " M app.ts\n", + "stash push --include-untracked -m fusion-auto-stash-": "Saved\n", + "rev-parse stash@{0}": "stashsha111\n", + "pull --ff-only": "done\n", + "stash pop": "done\n", + }); + + await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(mocked.runGitCommand).toHaveBeenCalledWith(expect.arrayContaining(["-m", "fusion-auto-stash-FN-5358"]), "/repo", 15_000); + + vi.clearAllMocks(); + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "main\n", + "rev-parse HEAD": "2222\n", + "status --porcelain=v1 --untracked-files=all": " M app.ts\n", + "stash push --include-untracked -m fusion-auto-stash-": "Saved\n", + "rev-parse stash@{0}": "stashsha222\n", + "pull --ff-only": "done\n", + "stash pop": "done\n", + }); + + await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + }), { "content-type": "application/json" }); + + const stashCall = mocked.runGitCommand.mock.calls.find(([args]: [string[]]) => args[0] === "stash" && args[1] === "push"); + expect(stashCall?.[0][4]).toMatch(/^fusion-auto-stash-\d+$/); + }); + + it("returns branch mismatch 409 without audits", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "rev-parse --git-dir": ".git\n", + "rev-parse --abbrev-ref HEAD": "feature\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "/repo", + integrationBranch: "main", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(409); + expect(res.body.details).toMatchObject({ reason: "branch-mismatch", currentBranch: "feature" }); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + }); + + it("rejects path traversal worktree path", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + }); + + const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({ + worktreePath: "../../etc", + integrationBranch: "main", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(400); + expect(store.recordRunAuditEvent).not.toHaveBeenCalled(); + }); + + it("stash-resolve uses ours/theirs checkout and rejects invalid choice", async () => { + const calls = gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "diff --name-only --diff-filter=U": "app/a.ts\n", + "checkout --ours -- app/a.ts": "", + "add -- app/a.ts": "", + }); + + const res = await request(app, "POST", "/api/git/stash-resolve", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + file: "app/a.ts", + choice: "ours", + }), { "content-type": "application/json" }); + + expect(res.status).toBe(200); + expect(calls).toContain("checkout --ours -- app/a.ts"); + expect(calls).toContain("add -- app/a.ts"); + + const invalid = await request(app, "POST", "/api/git/stash-resolve", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + file: "app/a.ts", + choice: "invalid", + }), { "content-type": "application/json" }); + expect(invalid.status).toBe(400); + }); + + it("stash-drop blocks unresolved conflicts then succeeds and audits manual resolution", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "diff --name-only --diff-filter=U": "file.ts\n", + }); + + const blocked = await request(app, "POST", "/api/git/stash-drop", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + expect(blocked.status).toBe(409); + + vi.clearAllMocks(); + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "diff --name-only --diff-filter=U": "", + "stash list --format=%H|%gd": "stashsha|stash@{1}\n", + "stash drop stash@{1}": "Dropped stash@{1}\n", + }); + + const dropped = await request(app, "POST", "/api/git/stash-drop", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(dropped.status).toBe(200); + expect(dropped.body).toMatchObject({ dropped: true }); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + domain: "git", + mutationType: "stash:pop", + taskId: "FN-5358", + metadata: expect.objectContaining({ manualResolution: true }), + })); + }); + + it("stash-restore applies stash and re-emits conflict audit when apply conflicts", async () => { + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "stash list --format=%H|%gd": "stashsha|stash@{0}\n", + "stash apply stash@{0}": "", + }); + + const clean = await request(app, "POST", "/api/git/stash-restore", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(clean.status).toBe(200); + expect(clean.body).toMatchObject({ applied: true, conflict: false, conflictedFiles: [] }); + + vi.clearAllMocks(); + gitScript({ + "worktree list --porcelain": "worktree /repo\n", + "stash list --format=%H|%gd": "stashsha|stash@{0}\n", + "stash apply stash@{0}": new Error("CONFLICT (content): Merge conflict in app.tsx"), + "diff --name-only --diff-filter=U": "app.tsx\n", + }); + + const conflict = await request(app, "POST", "/api/git/stash-restore", JSON.stringify({ + worktreePath: "/repo", + stashSha: "stashsha", + taskId: "FN-5358", + }), { "content-type": "application/json" }); + + expect(conflict.status).toBe(200); + expect(conflict.body).toMatchObject({ applied: true, conflict: true, conflictedFiles: ["app.tsx"] }); + expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ + domain: "git", + mutationType: "stash:pop-conflict", + taskId: "FN-5358", + target: "/repo", + })); + }); +}); diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index 529510456..940ff2e96 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -1,5 +1,5 @@ import { type NextFunction, type Request, type Response } from "express"; -import { isAbsolute } from "node:path"; +import { isAbsolute, resolve, relative } from "node:path"; import { exec as execCb, spawn } from "node:child_process"; import { promisify } from "node:util"; import type { @@ -728,6 +728,92 @@ async function dropStashBySha(sha: string, cwd?: string): Promise { await runGitCommand(["stash", "drop", ref], cwd, 10_000); } +function isPathWithin(parent: string, candidate: string): boolean { + const rel = relative(parent, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +async function listRegisteredWorktreePaths(rootDir: string): Promise { + const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000); + const paths: string[] = []; + for (const line of output.split("\n")) { + if (!line.startsWith("worktree ")) continue; + const worktreePath = line.slice("worktree ".length).trim(); + if (!worktreePath) continue; + paths.push(resolve(worktreePath)); + } + return paths; +} + +async function assertWorktreePathSafe( + scopedStore: Pick, + worktreePath: string, + cache: Map, +): Promise { + if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) { + throw badRequest("worktreePath is required"); + } + + const rootDir = resolve(scopedStore.getRootDir()); + const resolved = resolve(worktreePath); + if (isPathWithin(rootDir, resolved)) { + return resolved; + } + + let allowlisted = cache.get(rootDir); + if (!allowlisted) { + allowlisted = await listRegisteredWorktreePaths(rootDir); + cache.set(rootDir, allowlisted); + } + + if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) { + return resolved; + } + + throw badRequest("worktreePath outside project"); +} + +type DashboardGitMutationType = "stash:push" | "stash:pop" | "pull:fast-forward" | "stash:pop-conflict"; + +async function listConflictedFiles(cwd?: string): Promise { + const output = await runGitCommand(["diff", "--name-only", "--diff-filter=U"], cwd, 10_000); + return output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function assertRelativeFileSafe(worktreePath: string, file: string): string { + if (typeof file !== "string" || file.trim().length === 0) { + throw badRequest("file is required"); + } + if (file.split("/").includes("..") || file.split("\\").includes("..")) { + throw badRequest("file outside worktree"); + } + const normalized = resolve(worktreePath, file); + if (!isPathWithin(worktreePath, normalized)) { + throw badRequest("file outside worktree"); + } + return file; +} + +function buildDashboardGitAuditEvent(input: { + taskId?: string; + mutationType: DashboardGitMutationType; + target: string; + metadata?: Record; +}): RunAuditEventInput { + return { + taskId: input.taskId, + agentId: "dashboard-api", + runId: `dashboard-git-${Date.now()}`, + domain: "git", + mutationType: input.mutationType, + target: input.target, + metadata: input.metadata, + }; +} + async function createPullAutostash(cwd?: string): Promise { if (!(await hasLocalChangesForPull(cwd))) { return null; @@ -2304,6 +2390,358 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { } }); + /** + * POST /api/git/smart-pull + * Pull integration branch with optional auto-stash lifecycle. + */ + router.post("/git/smart-pull", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { worktreePath, taskId, integrationBranch } = req.body ?? {}; + if (typeof integrationBranch !== "string" || integrationBranch.trim().length === 0) { + throw badRequest("integrationBranch is required"); + } + if (taskId !== undefined && typeof taskId !== "string") { + throw badRequest("taskId must be a string"); + } + + const requestCache = new Map(); + const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache); + if (!(await isGitRepo(safeWorktreePath))) { + throw badRequest("Not a git repository"); + } + + const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], safeWorktreePath, 5_000)).trim(); + if (currentBranch !== integrationBranch) { + throw new ApiError(409, "Worktree is not on integration branch", { reason: "branch-mismatch", currentBranch }); + } + + const fromSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim(); + const dirty = await hasLocalChangesForPull(safeWorktreePath); + + const emitGitAudit = (mutationType: DashboardGitMutationType, metadata: Record) => { + if (typeof scopedStore.recordRunAuditEvent !== "function") return; + scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({ + taskId, + mutationType, + target: safeWorktreePath, + metadata, + })); + }; + + if (!dirty) { + const pullStart = performance.now(); + const result = await pullGitBranch(safeWorktreePath, { rebase: false }); + const pullDurationMs = Math.round(performance.now() - pullStart); + if (result.conflict) { + throw new ApiError(409, result.message || "Pull failed", { ...result }); + } + const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim(); + emitGitAudit("pull:fast-forward", { + taskId, + worktreePath: safeWorktreePath, + integrationBranch, + fromSha, + toSha, + durationMs: pullDurationMs, + succeeded: true, + }); + console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=clean-pull stashSha=none`); + res.json({ kind: "clean-pull", message: result.message, fromSha, toSha }); + return; + } + + const stashLabel = `fusion-auto-stash-${taskId ?? Date.now()}`; + const stashStart = performance.now(); + const stashOutput = await runGitCommand(["stash", "push", "--include-untracked", "-m", stashLabel], safeWorktreePath, 15_000); + if (stashOutput.includes("No local changes to save")) { + const pullStart = performance.now(); + const result = await pullGitBranch(safeWorktreePath, { rebase: false }); + const pullDurationMs = Math.round(performance.now() - pullStart); + if (result.conflict) { + throw new ApiError(409, result.message || "Pull failed", { ...result }); + } + const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim(); + emitGitAudit("pull:fast-forward", { + taskId, + worktreePath: safeWorktreePath, + integrationBranch, + fromSha, + toSha, + durationMs: pullDurationMs, + succeeded: true, + }); + console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=clean-pull stashSha=none`); + res.json({ kind: "clean-pull", message: result.message, fromSha, toSha }); + return; + } + + const stashDurationMs = Math.round(performance.now() - stashStart); + const stashSha = (await runGitCommand(["rev-parse", "stash@{0}"], safeWorktreePath, 5_000)).trim(); + emitGitAudit("stash:push", { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel, + untrackedIncluded: true, + durationMs: stashDurationMs, + }); + + const pullStart = performance.now(); + try { + await runGitCommand(["pull", "--ff-only"], safeWorktreePath, 30_000); + } catch (err: unknown) { + const message = getCommandErrorMessage(err); + const durationMs = Math.round(performance.now() - pullStart); + emitGitAudit("pull:fast-forward", { + taskId, + worktreePath: safeWorktreePath, + integrationBranch, + fromSha, + toSha: fromSha, + durationMs, + succeeded: false, + error: message, + }); + + try { + await runGitCommand(["stash", "pop"], safeWorktreePath, 20_000); + } catch (popErr: unknown) { + const popMessage = getCommandErrorMessage(popErr); + const conflictedFiles = await listConflictedFiles(safeWorktreePath); + const stashRef = await findStashRefBySha(stashSha, safeWorktreePath); + if (isGitConflictMessage(popMessage) || stashRef) { + emitGitAudit("stash:pop-conflict", { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel, + conflictedFiles, + advice: "Resolve conflicts, then drop stash when complete.", + }); + console.warn(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pop-conflict stashSha=${stashSha.slice(0, 7)}`); + const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim(); + res.json({ + kind: "stash-pop-conflict", + message: "Pull failed and stash restore conflicted. Resolve conflicts before continuing.", + fromSha, + toSha, + stashSha, + stashLabel, + conflictedFiles, + }); + return; + } + throw new ApiError(409, "Pull failed and automatic stash restore failed", { stashSha, stashLabel, error: popMessage }); + } + + throw new ApiError(409, "Pull failed — local changes restored from stash", { stashSha, stashLabel, error: message }); + } + + const pullDurationMs = Math.round(performance.now() - pullStart); + const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim(); + emitGitAudit("pull:fast-forward", { + taskId, + worktreePath: safeWorktreePath, + integrationBranch, + fromSha, + toSha, + durationMs: pullDurationMs, + succeeded: true, + }); + + const popStart = performance.now(); + try { + await runGitCommand(["stash", "pop"], safeWorktreePath, 20_000); + emitGitAudit("stash:pop", { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel, + durationMs: Math.round(performance.now() - popStart), + }); + console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pull-pop stashSha=${stashSha.slice(0, 7)}`); + res.json({ + kind: "stash-pull-pop", + message: "Pulled latest changes and restored local edits from stash.", + fromSha, + toSha, + stashSha, + stashLabel, + stashApplied: true, + stashDropped: true, + }); + return; + } catch (popErr: unknown) { + const popMessage = getCommandErrorMessage(popErr); + const stashRef = await findStashRefBySha(stashSha, safeWorktreePath); + if (!isGitConflictMessage(popMessage) && !stashRef) { + throw popErr; + } + + const conflictedFiles = await listConflictedFiles(safeWorktreePath); + emitGitAudit("stash:pop-conflict", { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel, + conflictedFiles, + advice: "Resolve conflicts, then drop stash when complete.", + }); + console.warn(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pop-conflict stashSha=${stashSha.slice(0, 7)}`); + res.json({ + kind: "stash-pop-conflict", + message: "Pulled latest changes, but stash restore conflicted.", + fromSha, + toSha, + stashSha, + stashLabel, + conflictedFiles, + }); + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + router.post("/git/stash-resolve", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { worktreePath, stashSha, file, choice } = req.body ?? {}; + if (typeof stashSha !== "string" || stashSha.trim().length === 0) { + throw badRequest("stashSha is required"); + } + if (choice !== "ours" && choice !== "theirs") { + throw badRequest("choice must be ours or theirs"); + } + + const requestCache = new Map(); + const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache); + const safeFile = assertRelativeFileSafe(safeWorktreePath, file); + const conflictedFiles = await listConflictedFiles(safeWorktreePath); + if (!conflictedFiles.includes(safeFile)) { + throw badRequest("file is not conflicted"); + } + + await runGitCommand(["checkout", choice === "ours" ? "--ours" : "--theirs", "--", safeFile], safeWorktreePath, 10_000); + await runGitCommand(["add", "--", safeFile], safeWorktreePath, 10_000); + const remainingConflicts = await listConflictedFiles(safeWorktreePath); + res.json({ resolvedFile: safeFile, choice, remainingConflicts }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + router.post("/git/stash-drop", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { worktreePath, stashSha, taskId } = req.body ?? {}; + if (typeof stashSha !== "string" || stashSha.trim().length === 0) { + throw badRequest("stashSha is required"); + } + if (taskId !== undefined && typeof taskId !== "string") { + throw badRequest("taskId must be a string"); + } + + const requestCache = new Map(); + const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache); + const remainingConflicts = await listConflictedFiles(safeWorktreePath); + if (remainingConflicts.length > 0) { + throw new ApiError(409, "Resolve conflicts before dropping stash", { remainingConflicts }); + } + + const ref = await findStashRefBySha(stashSha, safeWorktreePath); + if (!ref) { + res.json({ dropped: false }); + return; + } + + await runGitCommand(["stash", "drop", ref], safeWorktreePath, 10_000); + const stashLabel = ref; + if (typeof scopedStore.recordRunAuditEvent === "function") { + scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({ + taskId, + mutationType: "stash:pop", + target: safeWorktreePath, + metadata: { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel, + manualResolution: true, + }, + })); + } + + res.json({ dropped: true }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + + router.post("/git/stash-restore", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const { worktreePath, stashSha, taskId } = req.body ?? {}; + if (typeof stashSha !== "string" || stashSha.trim().length === 0) { + throw badRequest("stashSha is required"); + } + if (taskId !== undefined && typeof taskId !== "string") { + throw badRequest("taskId must be a string"); + } + + const requestCache = new Map(); + const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache); + const ref = await findStashRefBySha(stashSha, safeWorktreePath); + if (!ref) { + res.json({ applied: false, conflict: false, conflictedFiles: [] }); + return; + } + + try { + await runGitCommand(["stash", "apply", ref], safeWorktreePath, 20_000); + res.json({ applied: true, conflict: false, conflictedFiles: [] }); + } catch (err: unknown) { + const message = getCommandErrorMessage(err); + const conflictedFiles = await listConflictedFiles(safeWorktreePath); + if (isGitConflictMessage(message)) { + if (typeof scopedStore.recordRunAuditEvent === "function") { + scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({ + taskId, + mutationType: "stash:pop-conflict", + target: safeWorktreePath, + metadata: { + taskId, + worktreePath: safeWorktreePath, + stashSha, + stashLabel: ref, + conflictedFiles, + advice: "Resolve conflicts, then drop stash when complete.", + }, + })); + } + res.json({ applied: true, conflict: true, conflictedFiles }); + return; + } + throw err; + } + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + /** * POST /api/git/push * Push the current branch. diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 38383c0e5..deca1b475 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -12,7 +12,7 @@ const qualityAppTests = [ "app/api/**/*.test.ts", // Representative workflow/component coverage. Exhaustive modal/view suites // stay available in the full `dashboard-app` project. - "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", + "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", // Hooks and utilities are fast, user-visible state/formatting behavior. "app/context/**/*.test.tsx", "app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", diff --git a/packages/engine/src/__tests__/run-audit.test.ts b/packages/engine/src/__tests__/run-audit.test.ts index 0e5730405..05d27edf9 100644 --- a/packages/engine/src/__tests__/run-audit.test.ts +++ b/packages/engine/src/__tests__/run-audit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { TaskStore, RunAuditEventInput } from "@fusion/core"; -import { createRunAuditor, type DatabaseMutationType } from "../run-audit.js"; +import { createRunAuditor, type DatabaseMutationType, type GitMutationType } from "../run-audit.js"; class AuditStoreStub { events: RunAuditEventInput[] = []; @@ -88,6 +88,18 @@ describe("run-audit provisioning mutation types", () => { ]); }); + it("accepts smart-pull git mutation types", async () => { + const store = new AuditStoreStub(); + const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5358" }); + + const types: GitMutationType[] = ["pull:fast-forward", "stash:pop-conflict"]; + for (const type of types) { + await auditor.git({ type, target: "feature/worktree", metadata: { taskId: "FN-5358" } }); + } + + expect(store.events.map((event) => event.mutationType)).toEqual(types); + }); + it("records merge:scope:auto-widen git events", async () => { const store = new AuditStoreStub(); const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5226" }); diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 93dfc32a2..b7c724510 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -228,8 +228,62 @@ export type GitMutationType = // reserved; refusal currently thrown pre-audit | "project:bootstrap-refused-linked-worktree" | "branch:reanchor" + /** + * Metadata shape: + * ```ts + * { + * taskId?: string; + * worktreePath: string; + * stashSha: string; + * stashLabel: string; + * untrackedIncluded: true; + * } + * ``` + */ | "stash:push" - | "stash:pop"; + /** + * Metadata shape: + * ```ts + * { + * taskId?: string; + * worktreePath: string; + * stashSha: string; + * stashLabel: string; + * manualResolution?: boolean; + * } + * ``` + */ + | "stash:pop" + /** + * Metadata shape: + * ```ts + * { + * taskId?: string; + * worktreePath: string; + * integrationBranch: string; + * fromSha: string; + * toSha: string; + * durationMs: number; + * succeeded: boolean; + * error?: string; + * } + * ``` + */ + | "pull:fast-forward" + /** + * Metadata shape: + * ```ts + * { + * taskId?: string; + * worktreePath: string; + * stashSha: string; + * stashLabel: string; + * conflictedFiles: string[]; + * advice: string; + * } + * ``` + */ + | "stash:pop-conflict"; // ── Database mutation types ────────────────────────────────────────────────────