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:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <ref></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()}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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({}), {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user