feat(FN-5419): add stash conflict modal gating and smart pull routing for m

Implements a pull-based merge workflow by wiring the merger pull helpers from the engine, extending the git pull and stash routes, and aligning the `MergeAdvanceNotice` and `StashConflictModal` components to gate dismissal on stash drop. The `run-audit` module is updated with pull mutation documenta

Fusion-Task-Id: FN-5419

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5419
This commit is contained in:
gsxdsm
2026-05-23 12:59:49 -07:00
parent 7a20b95502
commit 14bc63e813
15 changed files with 603 additions and 742 deletions

View File

@@ -150,6 +150,10 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
### Run Audit
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
### Reliability Mechanism Coverage
- FN-5432 backstop: `packages/engine/src/__tests__/reliability-interactions/dependency-cycle-reconcile.test.ts` extends FN-5256 coverage with long-cycle ambiguous sweep, write-boundary/sweep race, self-defeating+cycle non-contradiction across one maintenance flow, and audit-event shape regression; core regression cases (long cycle, self-loop via update, incremental-update closes a loop, moveTask seam invariant, DependencyCycleError shape) live in `packages/core/src/__tests__/store-dependency-cycle.test.ts`. User-facing pull/stash audit event behavior (`pull:fast-forward`, `stash:pop-conflict`) is documented in `docs/dashboard-guide.md` under Merge Advance Notice / Smart Pull.

View File

@@ -140,15 +140,18 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
</div>
<StashConflictModal
open={conflictState !== null}
onClose={() => {
onClose={(stashDropped) => {
setConflictState(null);
dismissWithFocusGuard();
if (stashDropped) {
dismissWithFocusGuard();
}
}}
worktreePath={checkout.worktreePath}
integrationBranch={notice.integrationBranch}
stashSha={conflictState?.stashSha ?? ""}
stashLabel={conflictState?.stashLabel ?? ""}
conflictedFiles={conflictState?.conflictedFiles ?? []}
autostashOutcome={conflictState?.autostashOutcome ?? "conflict-needs-manual"}
taskId={notice.taskId}
/>
</>

View File

@@ -53,6 +53,11 @@
color: var(--text-muted);
}
.stash-conflict-modal__warning {
margin: 0;
color: var(--color-warning);
}
.stash-conflict-modal__error {
margin: 0;
color: var(--color-error);

View File

@@ -20,12 +20,13 @@ interface RestoreResponse {
export interface StashConflictModalProps {
open: boolean;
onClose: () => void;
onClose: (stashDropped?: boolean) => void;
worktreePath: string;
integrationBranch: string;
stashSha: string;
stashLabel: string;
conflictedFiles: string[];
autostashOutcome: "conflict-needs-manual" | "failed";
taskId?: string;
}
@@ -71,23 +72,24 @@ export default function StashConflictModal({
stashSha,
stashLabel,
conflictedFiles,
autostashOutcome,
taskId,
}: StashConflictModalProps) {
const fileBrowser = useFileBrowser();
const modalRef = useRef<HTMLDivElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const [remainingConflicts, setRemainingConflicts] = useState<string[]>(conflictedFiles);
const [remainingConflicts, setRemainingConflicts] = useState<string[]>(autostashOutcome === "failed" ? [] : 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);
setRemainingConflicts(autostashOutcome === "failed" ? [] : conflictedFiles);
setError(null);
setCopyState("idle");
}
}, [conflictedFiles, open]);
}, [autostashOutcome, conflictedFiles, open]);
useEffect(() => {
if (!open) {
@@ -110,7 +112,7 @@ export default function StashConflictModal({
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
onClose(false);
return;
}
@@ -185,7 +187,7 @@ export default function StashConflictModal({
body: JSON.stringify({ worktreePath, stashSha, taskId }),
});
if (response.dropped) {
onClose();
onClose(true);
}
} catch (dropError: unknown) {
setError(getErrorMessage(dropError));
@@ -198,7 +200,7 @@ export default function StashConflictModal({
setSubmitting(true);
setError(null);
try {
const response = await api<RestoreResponse>("/git/stash-restore", {
const response = await api<RestoreResponse>("/git/stash-apply", {
method: "POST",
body: JSON.stringify({ worktreePath, stashSha, taskId }),
});
@@ -230,6 +232,11 @@ export default function StashConflictModal({
<p className="stash-conflict-modal__summary">
Pulled <strong>{integrationBranch}</strong>, but restoring local edits from stash produced conflicts.
</p>
{autostashOutcome === "failed" ? (
<p className="stash-conflict-modal__warning">
Automatic restore failed. Your changes are preserved in the stash above; use <code>git stash apply &lt;ref&gt;</code> to recover manually, or use Retry below.
</p>
) : null}
<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">
@@ -238,38 +245,40 @@ export default function StashConflictModal({
</div>
{copyState === "copied" ? <p className="stash-conflict-modal__hint" role="status">Stash SHA copied.</p> : null}
{copyState === "failed" ? <p className="stash-conflict-modal__error" role="alert">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>
{remainingConflicts.length > 0 ? (
<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>
))}
</div>
))}
</div>
) : null}
{error ? <p className="stash-conflict-modal__error" role="alert">{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
Retry restore
</button>
</div>
<div className="modal-actions-right">
<button type="button" className="btn" disabled={submitting} onClick={onClose}>
<button type="button" className="btn" disabled={submitting} onClick={() => onClose(false)}>
Close
</button>
<button type="button" className="btn btn-warning" disabled={submitting || remainingConflicts.length > 0} onClick={() => void dropStash()}>

View File

@@ -10,10 +10,11 @@ const mocked = vi.hoisted(() => ({
clearPushError: vi.fn(),
setForceWithLease: vi.fn(),
setConflictState: vi.fn(),
stashModal: vi.fn(() => null),
}));
vi.mock("../../hooks/useMergeAdvanceNotice", () => ({ useMergeAdvanceNotice: mocked.useMergeAdvanceNotice }));
vi.mock("../StashConflictModal", () => ({ default: () => null }));
vi.mock("../StashConflictModal", () => ({ default: mocked.stashModal }));
function baseHookState(overrides: Record<string, unknown> = {}) {
return {
@@ -48,6 +49,7 @@ describe("MergeAdvanceNotice push affordance", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocked.stashModal.mockImplementation(() => null);
});
it("renders push section only when aheadCount > 0", () => {
@@ -122,4 +124,40 @@ describe("MergeAdvanceNotice push affordance", () => {
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pull" })).toBeEnabled();
});
it("shows pull and dirty stash copy when checkout is dirty", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ notice: { ...baseHookState().notice, userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 2 } } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pull" })).toBeInTheDocument();
expect(screen.getByText(/local changes will be auto-stashed and restored/)).toBeInTheDocument();
});
it("hides pull button while stash conflict modal is open", () => {
mocked.stashModal.mockImplementation(() => null);
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({
conflictState: {
stashSha: "abc1234",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "conflict-needs-manual",
},
}));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
});
it("does not dismiss notice when modal closes without dropping stash", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({
conflictState: {
stashSha: "abc1234",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "conflict-needs-manual",
},
}));
render(<MergeAdvanceNotice projectId="p1" />);
const modalProps = mocked.stashModal.mock.calls.at(-1)?.[0] as { onClose: (stashDropped?: boolean) => void };
modalProps.onClose(false);
expect(mocked.dismiss).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -33,6 +33,7 @@ function renderModal(overrides: Partial<ComponentProps<typeof StashConflictModal
stashSha="1234567890abcdef"
stashLabel="fusion-auto-stash-FN-1"
conflictedFiles={["src/a.ts", "src/b.ts"]}
autostashOutcome="conflict-needs-manual"
taskId="FN-1"
{...overrides}
/>,
@@ -64,26 +65,28 @@ describe("StashConflictModal", () => {
expect(screen.getAllByRole("button", { name: "Open in editor" })).toHaveLength(2);
});
it("failed autostash shows warning and retry only", () => {
renderModal({ autostashOutcome: "failed" });
expect(screen.getByText(/Automatic restore failed/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Retry restore" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Keep mine" })).toBeNull();
});
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" }),
@@ -98,36 +101,29 @@ describe("StashConflictModal", () => {
it("Drop stash disabled until conflicts resolved, then drops and closes", async () => {
const onClose = vi.fn();
mocked.api
.mockResolvedValueOnce({ remainingConflicts: [] })
.mockResolvedValueOnce({ dropped: true });
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 () => {
it("Retry restore posts stash-apply and updates conflict state", 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" })));
renderModal({ autostashOutcome: "failed", conflictedFiles: [] });
fireEvent.click(screen.getByRole("button", { name: "Retry restore" }));
await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-apply", expect.objectContaining({ method: "POST" })));
expect(screen.getByText("src/c.ts")).toBeInTheDocument();
});
it("shows inline error for resolve/drop/restore failures", async () => {
it("shows inline error for resolve/drop/apply failures", async () => {
mocked.api
.mockRejectedValueOnce(new ApiRequestError("resolve failed", 500))
.mockRejectedValueOnce(new ApiRequestError("restore failed", 500))
.mockRejectedValueOnce(new ApiRequestError("apply failed", 500))
.mockResolvedValueOnce({ remainingConflicts: [] })
.mockRejectedValueOnce(new ApiRequestError("drop failed", 500));
renderModal();
@@ -135,76 +131,32 @@ describe("StashConflictModal", () => {
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.getByRole("button", { name: "Retry restore" }));
expect(await screen.findByText("apply 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" }));
const dropError = await screen.findByText("drop failed");
expect(dropError).toHaveAttribute("role", "alert");
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(await screen.findByText("drop failed")).toHaveAttribute("role", "alert");
});
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"));
expect(screen.getByText("Stash SHA copied.")).toHaveAttribute("role", "status");
});
it("supports Escape close, initial focus, focus return, and tab wrapping", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
const trigger = document.createElement("button");
trigger.textContent = "open modal";
document.body.appendChild(trigger);
trigger.focus();
const view = render(
<StashConflictModal
open
onClose={onClose}
worktreePath="/repo"
integrationBranch="release"
stashSha="1234567890abcdef"
stashLabel="fusion-auto-stash-FN-1"
conflictedFiles={["src/a.ts", "src/b.ts"]}
taskId="FN-1"
/>,
);
const dialog = screen.getByRole("dialog");
const title = screen.getByRole("heading", { name: "Resolve auto-stash conflicts" });
expect(dialog).toHaveAttribute("aria-labelledby", "stash-conflict-modal-title");
expect(title).toHaveAttribute("id", "stash-conflict-modal-title");
const copyButton = screen.getByRole("button", { name: "Copy stash reference" });
expect(document.activeElement).toBe(copyButton);
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true");
const first = focusable[0];
const last = focusable[focusable.length - 1];
first.focus();
await user.keyboard("{Shift>}{Tab}{/Shift}");
expect(document.activeElement).toBe(last);
last.focus();
await user.keyboard("{Tab}");
expect(document.activeElement).toBe(first);
const view = renderModal({ onClose });
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(1);
view.unmount();
expect(document.activeElement).toBe(trigger);
trigger.remove();

View File

@@ -123,4 +123,51 @@ describe("useMergeAdvanceNotice", () => {
act(() => vi.advanceTimersByTime(60_000));
expect(mocked.api.mock.calls.length).toBe(initialCalls);
});
it("pull posts to /git/pull and dismisses on clean outcome", async () => {
const pullEventPayload = { events: [{ ...eventPayload.events[0], toSha: "clean12345" }] };
mocked.api.mockImplementation(async (path: string) => {
if (String(path).includes("merge-advance-events")) return pullEventPayload;
if (String(path).includes("push-status")) return pushStatus;
if (String(path).includes("/git/pull")) return { kind: "pull-clean", toSha: "clean12345" };
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
});
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-clean" }));
await waitFor(() => expect(result.current.notice).not.toBeNull());
await act(async () => { await result.current.pull(); });
expect(mocked.api.mock.calls.some((call) => String(call[0]).startsWith("/git/pull?projectId=p1-pull-clean"))).toBe(true);
expect(result.current.conflictState).toBeNull();
});
it("pull stash-conflict opens conflict state and preserves error visibility", async () => {
const conflictEventPayload = { events: [{ ...eventPayload.events[0], toSha: "conflict12345" }] };
let callIndex = 0;
mocked.api.mockImplementation(async () => {
const current = callIndex;
callIndex += 1;
if (current === 0) return conflictEventPayload;
if (current === 1) return pushStatus;
if (current === 2) {
return {
kind: "stash-conflict",
toSha: "conflict12345",
stashSha: "stashsha",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "failed",
};
}
return pushStatus;
});
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1-pull-conflict" }));
await waitFor(() => expect(result.current.notice).not.toBeNull());
await act(async () => { await result.current.pull(); });
await waitFor(() => expect(result.current.conflictState).not.toBeNull());
expect(result.current.conflictState).toEqual({
stashSha: "stashsha",
stashLabel: "fusion-auto",
conflictedFiles: ["src/a.ts"],
autostashOutcome: "failed",
});
});
});

View File

@@ -23,9 +23,9 @@ interface MergeAdvanceEventsResponse {
}
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[] };
| { kind: "pull-clean"; toSha: string }
| { kind: "pull-restored"; toSha: string }
| { kind: "stash-conflict"; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" };
type PushDisabledReason = "no-remote" | "no-upstream" | "not-ahead" | "merge-locked" | "not-a-git-repo";
@@ -109,7 +109,7 @@ export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { project
const [events, setEvents] = useState<MergeAdvanceEvent[]>([]);
const [dismissedShas, setDismissedShas] = useState<string[]>(() => readDismissedShas(projectId));
const [pullState, setPullState] = useState<"idle" | "pending" | "stashing" | { error: string }>("idle");
const [conflictState, setConflictState] = useState<{ stashSha: string; stashLabel: string; conflictedFiles: string[] } | null>(null);
const [conflictState, setConflictState] = useState<{ stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" } | null>(null);
const [pushStatus, setPushStatus] = useState<PushOriginStatus | null>(null);
const [pushState, setPushState] = useState<PushState>("idle");
const [forceWithLease, setForceWithLeaseState] = useState<boolean>(() => readForceWithLease(projectId));
@@ -183,12 +183,17 @@ export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { project
setPullState(notice.userCheckout.dirty || notice.userCheckout.untrackedCount > 0 ? "stashing" : "pending");
try {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const response = await api<SmartPullResponse>(`/git/smart-pull${query}`, {
const response = await api<SmartPullResponse>(`/git/pull${query}`, {
method: "POST",
body: JSON.stringify({ worktreePath: notice.userCheckout.worktreePath, integrationBranch: notice.integrationBranch, taskId: notice.taskId }),
});
if (response.kind === "stash-pop-conflict") {
setConflictState({ stashSha: response.stashSha, stashLabel: response.stashLabel, conflictedFiles: response.conflictedFiles });
if (response.kind === "stash-conflict") {
setConflictState({
stashSha: response.stashSha,
stashLabel: response.stashLabel,
conflictedFiles: response.conflictedFiles,
autostashOutcome: response.autostashOutcome,
});
} else {
dismiss();
}

View File

@@ -1,354 +0,0 @@
// @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

@@ -156,6 +156,7 @@ vi.mock("@fusion/engine", async () => {
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
import * as engineModule from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
@@ -953,6 +954,123 @@ describe("Git Management endpoints", () => {
});
});
describe("POST /git/pull — integration worktree", () => {
let runGitSpy: ReturnType<typeof vi.spyOn>;
function buildIntegrationApp(store = createMockStore({ getRootDir: vi.fn().mockReturnValue("/repo"), recordRunAuditEvent: vi.fn().mockResolvedValue(undefined) })) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return { app, store };
}
beforeEach(() => {
vi.mocked(engineModule.stashUnrelatedRootDirChanges).mockResolvedValue(null as never);
vi.mocked(engineModule.tryFastForwardFromOrigin).mockResolvedValue(undefined);
vi.mocked(engineModule.restoreUnrelatedRootDirChanges).mockResolvedValue({ status: "restored" } as never);
vi.mocked(engineModule.getConflictedFiles).mockResolvedValue([]);
vi.mocked(engineModule.resolveIntegrationRemote).mockResolvedValue("origin");
runGitSpy = vi.spyOn(resolveDiffBaseModule, "runGitCommand").mockImplementation((async (args: string[]) => {
const cmd = args.join(" ");
if (cmd.startsWith("worktree list --porcelain")) return "worktree /repo\nworktree /outside/worktree\n";
if (cmd.startsWith("rev-parse --git-dir")) return ".git\n";
if (cmd.startsWith("rev-parse --abbrev-ref HEAD")) return "integration\n";
if (cmd.startsWith("rev-parse HEAD")) return "abc123\n";
if (cmd.startsWith("stash list --format=%H|%gd")) return "stashsha|stash@{0}\n";
if (cmd.startsWith("stash drop stash@{0}")) return "Dropped\n";
if (cmd.startsWith("stash apply stash@{0}")) return "Applied\n";
if (cmd.startsWith("checkout --ours -- src/file.ts") || cmd.startsWith("checkout --theirs -- src/file.ts")) return "";
if (cmd.startsWith("add -- src/file.ts")) return "";
return "";
}) as typeof resolveDiffBaseModule.runGitCommand);
});
afterEach(() => {
runGitSpy?.mockRestore();
});
it("returns pull-clean and emits pull audit for clean integration worktree", async () => {
const { app, store } = buildIntegrationApp();
const res = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.kind).toBe("pull-clean");
expect(vi.mocked(engineModule.tryFastForwardFromOrigin)).toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ domain: "git", mutationType: "pull:fast-forward", taskId: "FN-5419", target: "/repo" }));
});
it("returns pull-restored for restored/ai-resolved with ordered audits", async () => {
vi.mocked(engineModule.stashUnrelatedRootDirChanges).mockResolvedValue({ sha: "stashsha", label: "label" } as never);
vi.mocked(engineModule.restoreUnrelatedRootDirChanges).mockResolvedValueOnce({ status: "restored" } as never).mockResolvedValueOnce({ status: "ai-resolved" } as never);
const { app, store } = buildIntegrationApp();
for (const expected of ["restored", "ai-resolved"]) {
vi.mocked(store.recordRunAuditEvent).mockClear();
const res = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.kind).toBe("pull-restored");
const mutationTypes = vi.mocked(store.recordRunAuditEvent).mock.calls.map(([e]) => e.mutationType);
expect(mutationTypes).toEqual(["stash:push", "pull:fast-forward", "stash:pop"]);
expect(vi.mocked(store.recordRunAuditEvent).mock.calls[2]?.[0]?.metadata).toEqual(expect.objectContaining({ autostashOutcome: expected }));
}
});
it.each(["conflict-needs-manual", "failed"] as const)("returns stash-conflict for %s", async (status) => {
vi.mocked(engineModule.stashUnrelatedRootDirChanges).mockResolvedValue({ sha: "stashsha", label: "label" } as never);
vi.mocked(engineModule.restoreUnrelatedRootDirChanges).mockImplementation(async () => ({ status } as never));
vi.mocked(engineModule.getConflictedFiles).mockResolvedValue(["src/file.ts"]);
const { app, store } = buildIntegrationApp();
const res = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(res.status).toBe(200);
expect(res.body.kind).toBe("stash-conflict");
expect(res.body.conflictedFiles).toEqual(["src/file.ts"]);
const mutationTypes = vi.mocked(store.recordRunAuditEvent).mock.calls.map(([e]) => e.mutationType);
expect(mutationTypes).toEqual(["stash:push", "pull:fast-forward", "stash:pop-conflict"]);
});
it("returns 409 on branch mismatch", async () => {
runGitSpy.mockImplementation((async (args: string[]) => {
if (args.join(" ").startsWith("worktree list --porcelain")) return "worktree /repo\n";
if (args.join(" ").startsWith("rev-parse --git-dir")) return ".git\n";
if (args.join(" ").startsWith("rev-parse --abbrev-ref HEAD")) return "other\n";
return "abc\n";
}) as typeof resolveDiffBaseModule.runGitCommand);
const { app, store } = buildIntegrationApp();
const res = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration" }), { "Content-Type": "application/json" });
expect(res.status).toBe(409);
expect(res.body.details).toMatchObject({ reason: "branch-mismatch", currentBranch: "other" });
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("validates path traversal and rebase conflict", async () => {
const { app, store } = buildIntegrationApp();
const traversal = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "../../etc", integrationBranch: "integration" }), { "Content-Type": "application/json" });
expect(traversal.status).toBe(400);
const rebase = await REQUEST(app, "POST", "/api/git/pull", JSON.stringify({ worktreePath: "/repo", integrationBranch: "integration", rebase: true }), { "Content-Type": "application/json" });
expect(rebase.status).toBe(400);
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("supports stash-resolve, stash-drop, and stash-apply", async () => {
vi.mocked(engineModule.getConflictedFiles).mockResolvedValueOnce(["src/file.ts"]).mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce(["src/file.ts"]);
const { app, store } = buildIntegrationApp();
const resolved = await REQUEST(app, "POST", "/api/git/stash-resolve", JSON.stringify({ worktreePath: "/repo", file: "src/file.ts", choice: "ours" }), { "Content-Type": "application/json" });
expect(resolved.status).toBe(200);
const dropped = await REQUEST(app, "POST", "/api/git/stash-drop", JSON.stringify({ worktreePath: "/repo", stashSha: "stashsha", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(dropped.status).toBe(200);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "stash:pop", taskId: "FN-5419" }));
runGitSpy.mockImplementation((async (args: string[]) => {
if (args.join(" ").startsWith("worktree list --porcelain")) return "worktree /repo\n";
if (args.join(" ").startsWith("stash list --format=%H|%gd")) return "stashsha|stash@{0}\n";
if (args.join(" ").startsWith("stash apply stash@{0}")) throw new Error("CONFLICT (content): Merge conflict in src/file.ts");
if (args.join(" ").startsWith("rev-parse --git-dir")) return ".git\n";
return "";
}) as typeof resolveDiffBaseModule.runGitCommand);
vi.mocked(engineModule.getConflictedFiles).mockResolvedValue(["src/file.ts"]);
const applied = await REQUEST(app, "POST", "/api/git/stash-apply", JSON.stringify({ worktreePath: "/repo", stashSha: "stashsha", taskId: "FN-5419" }), { "Content-Type": "application/json" });
expect(applied.status).toBe(200);
expect(applied.body).toMatchObject({ applied: true, conflict: true, conflictedFiles: ["src/file.ts"] });
});
});
describe("POST /git/push", () => {
it("returns result or rejection status", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/git/push", JSON.stringify({}), {

View File

@@ -10,11 +10,22 @@ import type {
IssueInfo,
PrInfo,
RunAuditEventInput,
Settings,
StructuredGhError,
Task,
TaskStore,
} from "@fusion/core";
import { classifyGhError, getCurrentRepo, isGhAuthenticated } from "@fusion/core";
import {
dropAutostashHandle,
generateSyntheticRunId,
getConflictedFiles,
resolveIntegrationRemote,
restoreUnrelatedRootDirChanges,
stashUnrelatedRootDirChanges,
tryFastForwardFromOrigin,
type MergerOptions,
} from "@fusion/engine";
import {
ApiError,
badRequest,
@@ -754,8 +765,14 @@ async function assertWorktreePathSafe(
throw badRequest("worktreePath is required");
}
if (!isAbsolute(worktreePath)) {
throw badRequest("worktreePath must be an absolute path");
}
const rootDir = resolve(scopedStore.getRootDir());
const resolved = resolve(worktreePath);
if (resolved !== worktreePath) {
throw badRequest("worktreePath must be normalized");
}
if (isPathWithin(rootDir, resolved)) {
return resolved;
}
@@ -775,14 +792,6 @@ async function assertWorktreePathSafe(
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");
@@ -855,7 +864,174 @@ async function reapplyPullAutostash(
return { applied: true, conflict: false };
}
export async function pullGitBranch(cwd?: string, options?: { rebase?: boolean }): Promise<GitPullResult> {
export interface PullGitBranchOptions {
rebase?: boolean;
integration?: {
worktreePath: string;
integrationBranch: string;
taskId?: string;
integrationRemote?: string;
store: TaskStore;
settings: Settings;
runId: string;
};
}
export type IntegrationPullResult =
| { kind: "pull-clean"; message: string; fromSha: string; toSha: string }
| { kind: "pull-restored"; message: string; fromSha: string; toSha: string; autostash: { status: "restored" | "ai-resolved" } }
| { kind: "stash-conflict"; message: string; fromSha: string; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[]; autostashOutcome: "conflict-needs-manual" | "failed" };
function emitDashboardGitAuditEvent(
store: TaskStore,
input: {
taskId?: string;
runId: string;
mutationType: DashboardGitMutationType;
target: string;
metadata?: Record<string, unknown>;
},
): void {
Promise.resolve(store.recordRunAuditEvent?.({
taskId: input.taskId,
agentId: "dashboard-api",
runId: input.runId,
domain: "git",
mutationType: input.mutationType,
target: input.target,
metadata: input.metadata,
})).catch(() => undefined);
}
export async function pullGitBranch(cwd?: string, options?: PullGitBranchOptions): Promise<GitPullResult | IntegrationPullResult> {
const integration = options?.integration;
if (integration) {
const taskId = integration.taskId ?? "dashboard-pull";
const rootDir = integration.worktreePath;
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], rootDir, 5_000)).trim();
if (currentBranch !== integration.integrationBranch) {
throw new ApiError(409, "Worktree is not on integration branch", { reason: "branch-mismatch", currentBranch });
}
const fromSha = (await runGitCommand(["rev-parse", "HEAD"], rootDir, 5_000)).trim();
const stashHandle = await stashUnrelatedRootDirChanges(rootDir, taskId);
if (stashHandle) {
emitDashboardGitAuditEvent(integration.store, {
taskId: integration.taskId,
runId: integration.runId,
mutationType: "stash:push",
target: rootDir,
metadata: {
taskId: integration.taskId,
worktreePath: rootDir,
stashSha: stashHandle.sha,
stashLabel: stashHandle.label,
untrackedIncluded: true,
},
});
}
const pullStart = performance.now();
await tryFastForwardFromOrigin(rootDir, taskId, integration.integrationBranch, integration.integrationRemote ?? "origin");
const durationMs = Math.round(performance.now() - pullStart);
const toSha = (await runGitCommand(["rev-parse", "HEAD"], rootDir, 5_000)).trim();
emitDashboardGitAuditEvent(integration.store, {
taskId: integration.taskId,
runId: integration.runId,
mutationType: "pull:fast-forward",
target: rootDir,
metadata: {
taskId: integration.taskId,
worktreePath: rootDir,
integrationBranch: integration.integrationBranch,
remote: integration.integrationRemote ?? "origin",
fromSha,
toSha,
durationMs,
succeeded: true,
...(toSha === fromSha ? { behind: 0 } : {}),
},
});
if (!stashHandle) {
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-clean from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
return { kind: "pull-clean", message: "Pull completed", fromSha, toSha };
}
const mergerOptions = {
taskId,
rootDir,
branch: integration.integrationBranch,
integrationBranch: integration.integrationBranch,
mergeMode: "squash",
} as MergerOptions;
const outcome = await restoreUnrelatedRootDirChanges(rootDir, taskId, stashHandle, {
store: integration.store,
options: mergerOptions,
settings: integration.settings,
});
if (outcome.status === "restored" || outcome.status === "ai-resolved") {
emitDashboardGitAuditEvent(integration.store, {
taskId: integration.taskId,
runId: integration.runId,
mutationType: "stash:pop",
target: rootDir,
metadata: {
taskId: integration.taskId,
worktreePath: rootDir,
stashSha: stashHandle.sha,
stashLabel: stashHandle.label,
autostashOutcome: outcome.status,
},
});
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-restored from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
return { kind: "pull-restored", message: "Pulled latest changes and restored local edits.", fromSha, toSha, autostash: { status: outcome.status } };
}
if (outcome.status === "conflict-needs-manual" || outcome.status === "failed") {
const conflictedFiles = await getConflictedFiles(rootDir);
emitDashboardGitAuditEvent(integration.store, {
taskId: integration.taskId,
runId: integration.runId,
mutationType: "stash:pop-conflict",
target: rootDir,
metadata: {
taskId: integration.taskId,
worktreePath: rootDir,
stashSha: stashHandle.sha,
stashLabel: stashHandle.label,
conflictedFiles,
autostashOutcome: outcome.status,
},
});
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=stash-conflict from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
return {
kind: "stash-conflict",
message: "Pulled latest changes, but restoring local edits needs manual resolution.",
fromSha,
toSha,
stashSha: stashHandle.sha,
stashLabel: stashHandle.label,
conflictedFiles,
autostashOutcome: outcome.status,
};
}
await dropAutostashHandle(rootDir, taskId, stashHandle, {
keepIfLive: false,
store: integration.store,
context: "integration-pull",
}).catch(() => undefined);
console.info(`[integration-pull] taskId=${taskId} worktree=${rootDir.split("/").pop() ?? rootDir} kind=pull-clean from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`);
return { kind: "pull-clean", message: "Pull completed", fromSha, toSha };
}
const rebase = options?.rebase === true;
const autostash = await createPullAutostash(cwd);
try {
@@ -2362,7 +2538,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
/**
* POST /api/git/pull
* Pull the current branch.
* Pull current branch, or integration worktree when provided.
*/
router.post("/git/pull", async (req, res) => {
try {
@@ -2371,12 +2547,48 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
const { rebase } = req.body ?? {};
const requestCache = new Map<string, string[]>();
const { rebase, worktreePath, integrationBranch, taskId } = req.body ?? {};
if (rebase !== undefined && typeof rebase !== "boolean") {
throw badRequest("rebase must be a boolean");
}
if (taskId !== undefined && typeof taskId !== "string") {
throw badRequest("taskId must be a string");
}
if (worktreePath !== undefined) {
if (rebase === true) {
throw badRequest("rebase not supported with worktreePath");
}
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
if (typeof integrationBranch !== "string" || integrationBranch.trim().length === 0) {
throw badRequest("integrationBranch required when worktreePath set");
}
const settings = await scopedStore.getSettings();
const integrationRemote = await resolveIntegrationRemote({
settings,
rootDir: safeWorktreePath,
integrationBranch,
}).catch(() => "origin");
const runId = generateSyntheticRunId("dashboard-pull", taskId ?? "dashboard-pull");
const result = await pullGitBranch(safeWorktreePath, {
rebase: false,
integration: {
worktreePath: safeWorktreePath,
integrationBranch,
taskId,
integrationRemote,
store: scopedStore,
settings,
runId,
},
});
res.json(result);
return;
}
const result = await pullGitBranch(rootDir, { rebase: rebase === true });
if (result.conflict) {
if ("conflict" in result && result.conflict) {
throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", {
...result,
});
@@ -2390,230 +2602,10 @@ 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");
}
const { worktreePath, file, choice } = req.body ?? {};
if (choice !== "ours" && choice !== "theirs") {
throw badRequest("choice must be ours or theirs");
}
@@ -2621,14 +2613,14 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const requestCache = new Map<string, string[]>();
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
const safeFile = assertRelativeFileSafe(safeWorktreePath, file);
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
const conflictedFiles = await getConflictedFiles(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);
const remainingConflicts = await getConflictedFiles(safeWorktreePath);
res.json({ resolvedFile: safeFile, choice, remainingConflicts });
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -2651,7 +2643,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const requestCache = new Map<string, string[]>();
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
const remainingConflicts = await listConflictedFiles(safeWorktreePath);
const remainingConflicts = await getConflictedFiles(safeWorktreePath);
if (remainingConflicts.length > 0) {
throw new ApiError(409, "Resolve conflicts before dropping stash", { remainingConflicts });
}
@@ -2663,21 +2655,17 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
await runGitCommand(["stash", "drop", ref], safeWorktreePath, 10_000);
const stashLabel = ref;
if (typeof scopedStore.recordRunAuditEvent === "function") {
scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({
Promise.resolve(scopedStore.recordRunAuditEvent?.(buildDashboardGitAuditEvent({
taskId,
mutationType: "stash:pop",
target: safeWorktreePath,
metadata: {
taskId,
mutationType: "stash:pop",
target: safeWorktreePath,
metadata: {
taskId,
worktreePath: safeWorktreePath,
stashSha,
stashLabel,
manualResolution: true,
},
}));
}
worktreePath: safeWorktreePath,
stashSha,
manualResolution: true,
},
}))).catch(() => undefined);
res.json({ dropped: true });
} catch (err: unknown) {
@@ -2688,7 +2676,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
}
});
router.post("/git/stash-restore", async (req, res) => {
router.post("/git/stash-apply", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { worktreePath, stashSha, taskId } = req.body ?? {};
@@ -2712,23 +2700,21 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
res.json({ applied: true, conflict: false, conflictedFiles: [] });
} catch (err: unknown) {
const message = getCommandErrorMessage(err);
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
const conflictedFiles = await getConflictedFiles(safeWorktreePath);
if (isGitConflictMessage(message)) {
if (typeof scopedStore.recordRunAuditEvent === "function") {
scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({
Promise.resolve(scopedStore.recordRunAuditEvent?.(buildDashboardGitAuditEvent({
taskId,
mutationType: "stash:pop-conflict",
target: safeWorktreePath,
metadata: {
taskId,
mutationType: "stash:pop-conflict",
target: safeWorktreePath,
metadata: {
taskId,
worktreePath: safeWorktreePath,
stashSha,
stashLabel: ref,
conflictedFiles,
advice: "Resolve conflicts, then drop stash when complete.",
},
}));
}
worktreePath: safeWorktreePath,
stashSha,
stashLabel: ref,
conflictedFiles,
autostashOutcome: "conflict-needs-manual",
},
}))).catch(() => undefined);
res.json({ applied: true, conflict: true, conflictedFiles });
return;
}

View File

@@ -88,16 +88,51 @@ describe("run-audit provisioning mutation types", () => {
]);
});
it("accepts smart-pull git mutation types", async () => {
it("accepts pull:fast-forward metadata shape", async () => {
const store = new AuditStoreStub();
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5358" });
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5419" });
const types: GitMutationType[] = ["pull:fast-forward", "stash:pop-conflict", "push:origin"];
for (const type of types) {
await auditor.git({ type, target: "feature/worktree", metadata: { taskId: "FN-5358" } });
}
const type: GitMutationType = "pull:fast-forward";
await auditor.git({
type,
target: "/repo/.worktrees/integration",
metadata: {
taskId: "FN-5419",
worktreePath: "/repo/.worktrees/integration",
integrationBranch: "main",
remote: "origin",
fromSha: "1111111",
toSha: "2222222",
durationMs: 12,
succeeded: true,
behind: 0,
ahead: 0,
},
});
expect(store.events.map((event) => event.mutationType)).toEqual(types);
expect(store.events[0]?.mutationType).toBe(type);
});
it("accepts stash:pop-conflict metadata shape", async () => {
const store = new AuditStoreStub();
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5419" });
const type: GitMutationType = "stash:pop-conflict";
await auditor.git({
type,
target: "/repo/.worktrees/integration",
metadata: {
taskId: "FN-5419",
worktreePath: "/repo/.worktrees/integration",
stashSha: "abc123",
stashLabel: "fusion-autostash-FN-5419",
conflictedFiles: ["README.md"],
autostashOutcome: "conflict-needs-manual",
advice: "Resolve conflicts and drop stash when complete",
},
});
expect(store.events[0]?.mutationType).toBe(type);
});
it("records merge:scope:auto-widen git events", async () => {

View File

@@ -33,6 +33,12 @@ export {
SquashAuditError,
type MergerOptions,
type AutostashOrphanRecord,
stashUnrelatedRootDirChanges,
dropAutostashHandle,
restoreUnrelatedRootDirChanges,
tryFastForwardFromOrigin,
getConflictedFiles,
type AutostashHandle,
} from "./merger.js";
export {
resolveIntegrationBranch,
@@ -48,6 +54,9 @@ export {
type HandoffResult,
type MergeIntegrationRootResolution,
} from "./merger-integration-worktree.js";
export {
generateSyntheticRunId,
} from "./run-audit.js";
export {
auditSquashMerge,
formatSquashAuditReport,

View File

@@ -1896,7 +1896,7 @@ function resetMergeWithWarn(rootDir: string, taskId: string, label: string): voi
* `rescueShas` lists any race-rescue stashes the autostash captured for
* late-dirty paths (concurrent dev edits during the merger run). They are
* surfaced separately so the caller can log them to the task feed. */
interface AutostashHandle {
export interface AutostashHandle {
sha: string;
label: string;
rescueShas?: { sha: string; label: string }[];
@@ -2461,7 +2461,7 @@ export const __test__ = {
notifyAutostashOrphans,
};
async function stashUnrelatedRootDirChanges(
export async function stashUnrelatedRootDirChanges(
rootDir: string,
taskId: string,
): Promise<AutostashHandle | null> {
@@ -2688,7 +2688,7 @@ async function isAutostashLive(rootDir: string, sha: string): Promise<boolean> {
}
}
async function dropAutostashHandle(
export async function dropAutostashHandle(
rootDir: string,
taskId: string,
handle: AutostashHandle,
@@ -3384,7 +3384,7 @@ async function restoreRescueAutostashes(
return { unresolvedCount };
}
async function restoreUnrelatedRootDirChanges(
export async function restoreUnrelatedRootDirChanges(
rootDir: string,
taskId: string,
handle: AutostashHandle,
@@ -9926,7 +9926,7 @@ export async function aiMergeTask(
* NOTE: This is NOT FN-5350's integration-branch ref advance path. FN-5350
* advances refs/heads/<integration-branch> via compare-and-swap `git update-ref`.
*/
async function tryFastForwardFromOrigin(
export async function tryFastForwardFromOrigin(
rootDir: string,
taskId: string,
integrationBranch: string,

View File

@@ -288,11 +288,14 @@ export type GitMutationType =
* taskId?: string;
* worktreePath: string;
* integrationBranch: string;
* remote?: string;
* fromSha: string;
* toSha: string;
* durationMs: number;
* succeeded: boolean;
* error?: string;
* behind?: number;
* ahead?: number;
* }
* ```
*/
@@ -324,7 +327,8 @@ export type GitMutationType =
* stashSha: string;
* stashLabel: string;
* conflictedFiles: string[];
* advice: string;
* autostashOutcome: "conflict-needs-manual" | "failed";
* advice?: string;
* }
* ```
*/