feat(FN-5358): add smart pull API route with stash conflict detection and r

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
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 17:46:10 -07:00
committed by gsxdsm
parent 50a5ddd4fb
commit 921e0e616f
11 changed files with 1422 additions and 52 deletions

View File

@@ -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`.

View File

@@ -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<string[]>(() => readDismissedShas(projectId));
const [pulling, setPulling] = useState(false);
const [pullError, setPullError] = useState<string | null>(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<PullResponse>(`/git/pull${query}`, {
const response = await api<SmartPullResponse>(`/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 (
<div className="merge-advance-notice" role="status" aria-live="polite">
<div className="merge-advance-notice__content">
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
Your checked-out copy at {checkout.worktreePath} is behind.
{localChangesPreserved ? " (local changes preserved)" : ""}
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
{pulling ? <span className="merge-advance-notice__hint"> Pulling</span> : null}
</div>
<div className="merge-advance-notice__actions">
{!localChangesPreserved ? (
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
Pull
<>
<div className="merge-advance-notice" role="status" aria-live="polite">
<div className="merge-advance-notice__content">
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
Your checked-out copy at {checkout.worktreePath} is behind.
{localChangesPreserved ? " (local changes will be auto-stashed and restored)" : ""}
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
{pulling ? <span className="merge-advance-notice__hint"> Pulling</span> : null}
</div>
<div className="merge-advance-notice__actions">
{conflictState ? null : (
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
Pull
</button>
)}
<button
type="button"
className="merge-advance-notice__dismiss touch-target"
aria-label="Dismiss merge advance notice"
onClick={dismiss}
>
<X aria-hidden="true" />
</button>
) : null}
<button
type="button"
className="merge-advance-notice__dismiss touch-target"
aria-label="Dismiss merge advance notice"
onClick={dismiss}
>
<X aria-hidden="true" />
</button>
</div>
</div>
</div>
<StashConflictModal
open={conflictState !== null}
onClose={() => {
setConflictState(null);
dismiss();
}}
worktreePath={checkout.worktreePath}
integrationBranch={notice.integrationBranch}
stashSha={conflictState?.stashSha ?? ""}
stashLabel={conflictState?.stashLabel ?? ""}
conflictedFiles={conflictState?.conflictedFiles ?? []}
taskId={notice.taskId}
/>
</>
);
}

View File

@@ -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;
}
}

View File

@@ -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<string[]>(conflictedFiles);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<ResolveResponse>("/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<DropResponse>("/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<RestoreResponse>("/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 (
<div className="modal-overlay open" role="dialog" aria-modal="true" aria-label="Resolve stash conflicts">
<div className="modal stash-conflict-modal">
<div className="modal-header">
<h3>Resolve auto-stash conflicts</h3>
</div>
<p className="stash-conflict-modal__summary">
Pulled <strong>{integrationBranch}</strong>, but restoring local edits from stash produced conflicts.
</p>
<div className="stash-conflict-modal__stash-row">
<span>{stashDescriptor}</span>
<button type="button" className="btn btn-sm btn-icon" onClick={copyRef} aria-label="Copy stash reference">
<Copy aria-hidden="true" />
</button>
</div>
{copyState === "copied" ? <p className="stash-conflict-modal__hint">Stash SHA copied.</p> : null}
{copyState === "failed" ? <p className="stash-conflict-modal__error">Could not copy stash SHA.</p> : null}
<div className="stash-conflict-modal__list" role="list">
{remainingConflicts.map((file) => (
<div key={file} className="stash-conflict-row" role="listitem">
<code className="stash-conflict-row__path">{file}</code>
<div className="stash-conflict-row__actions">
<button type="button" className="btn btn-sm" disabled={submitting} onClick={() => void resolveFile(file, "ours")}>
Keep mine
</button>
<button type="button" className="btn btn-sm" disabled={submitting} onClick={() => void resolveFile(file, "theirs")}>
Keep incoming
</button>
<button
type="button"
className="btn btn-sm"
disabled={submitting}
onClick={() => fileBrowser?.openFile(file, { workspace: worktreePath })}
>
Open in editor
</button>
</div>
</div>
))}
</div>
{error ? <p className="stash-conflict-modal__error">{error}</p> : null}
<div className="modal-actions">
<div className="modal-actions-left">
<button type="button" className="btn" disabled={submitting} onClick={() => void restoreStash()}>
Restore from stash ref
</button>
</div>
<div className="modal-actions-right">
<button type="button" className="btn" disabled={submitting} onClick={onClose}>
Close
</button>
<button type="button" className="btn btn-warning" disabled={submitting || remainingConflicts.length > 0} onClick={() => void dropStash()}>
Drop stash
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { ApiRequestError } from "../../api";
const mocked = vi.hoisted(() => ({
api: vi.fn(),
mergedHandler: undefined as (() => void) | undefined,
stashModalProps: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../api", async () => {
@@ -23,11 +24,18 @@ vi.mock("../../sse-bus", () => ({
}),
}));
vi.mock("../StashConflictModal", () => ({
default: (props: Record<string, unknown>) => {
mocked.stashModalProps.push(props);
return props.open ? <div data-testid="stash-conflict-modal">stash-conflict-modal</div> : null;
},
}));
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
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(<MergeAdvanceNotice projectId="proj-1" />);
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(<MergeAdvanceNotice projectId="proj-1" />);
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(<MergeAdvanceNotice projectId="proj-1" />);
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(<MergeAdvanceNotice projectId="proj-1" />);
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(<MergeAdvanceNotice projectId="proj-1" />);
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 () => {

View File

@@ -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<typeof import("../../api")>("../../api");
return {
...actual,
api: mocked.api,
};
});
vi.mock("../../context/FileBrowserContext", () => ({
useFileBrowser: () => ({ openFile: mocked.openFile }),
}));
function renderModal(overrides: Partial<ComponentProps<typeof StashConflictModal>> = {}) {
return render(
<StashConflictModal
open
onClose={vi.fn()}
worktreePath="/repo"
integrationBranch="release"
stashSha="1234567890abcdef"
stashLabel="fusion-auto-stash-FN-1"
conflictedFiles={["src/a.ts", "src/b.ts"]}
taskId="FN-1"
{...overrides}
/>,
);
}
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"));
});
});

View File

@@ -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<string, ScriptedResponse>) {
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<typeof createServer>;
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",
}));
});
});

View File

@@ -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<void> {
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<string[]> {
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<TaskStore, "getRootDir">,
worktreePath: string,
cache: Map<string, string[]>,
): Promise<string> {
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<string[]> {
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<string, unknown>;
}): 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<PullAutostashHandle | null> {
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<string, string[]>();
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<string, unknown>) => {
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<string, string[]>();
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<string, string[]>();
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<string, string[]>();
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.

View File

@@ -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}",

View File

@@ -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" });

View File

@@ -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 ────────────────────────────────────────────────────