feat(FN-5358): add smart pull API route with stash conflict detection and r
Implements stash conflict detection and resolution with a new smart pull API route, resolution endpoints, and a dedicated conflict modal UI — backed by extended git audit taxonomy and route tests. Fusion-Task-Id: FN-5358
This commit is contained in:
committed by
gsxdsm
parent
50a5ddd4fb
commit
921e0e616f
@@ -438,6 +438,9 @@ Every engine mutation is recorded across four domains:
|
||||
- `merge:cwd-integration-fallback-refused` — terminal reuse-handoff refusal path that parks in-review without cwd fallback.
|
||||
- `merge:integration-ref-advance` — typed integration ref update outcome (`succeeded`/`error`) with resolved ref metadata.
|
||||
- `merge:integration-worktree-state` — per-merge snapshot of resolved integration branch checkout and dirty/untracked state.
|
||||
- `pull:fast-forward` — dashboard Smart Pull audit for `git pull --ff-only` attempts (success/failure metadata).
|
||||
- `stash:pop-conflict` — dashboard Smart Pull audit for stash apply/pop conflict surfaces.
|
||||
- **Dashboard Git endpoints** — `POST /api/git/smart-pull`, `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, `POST /api/git/stash-restore`.
|
||||
- **Filesystem** — file:write, prompt:write, attachment:create, `secret:read|create|update|delete|approval-requested|approval-granted|approval-denied|sync-push|sync-pull`, `secret:env-*`, etc.
|
||||
- **Sandbox** — `sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, ApiRequestError } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import StashConflictModal from "./StashConflictModal";
|
||||
import "./MergeAdvanceNotice.css";
|
||||
|
||||
interface MergeAdvanceEvent {
|
||||
@@ -24,9 +25,10 @@ interface MergeAdvanceEventsResponse {
|
||||
events: MergeAdvanceEvent[];
|
||||
}
|
||||
|
||||
interface PullResponse {
|
||||
message?: string;
|
||||
}
|
||||
type SmartPullResponse =
|
||||
| { kind: "clean-pull"; toSha: string }
|
||||
| { kind: "stash-pull-pop"; toSha: string }
|
||||
| { kind: "stash-pop-conflict"; toSha: string; stashSha: string; stashLabel: string; conflictedFiles: string[] };
|
||||
|
||||
interface MergeAdvanceNoticeProps {
|
||||
projectId?: string;
|
||||
@@ -70,6 +72,11 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
const [dismissedShas, setDismissedShas] = useState<string[]>(() => readDismissedShas(projectId));
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pullError, setPullError] = useState<string | null>(null);
|
||||
const [conflictState, setConflictState] = useState<{
|
||||
stashSha: string;
|
||||
stashLabel: string;
|
||||
conflictedFiles: string[];
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedShas(readDismissedShas(projectId));
|
||||
@@ -109,11 +116,7 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
&& event.userCheckout.worktreePath.trim().length > 0
|
||||
)), [events]);
|
||||
|
||||
if (!notice || dismissedShas.includes(notice.toSha)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!notice.userCheckout) {
|
||||
if (!notice || dismissedShas.includes(notice.toSha) || !notice.userCheckout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,10 +134,22 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
setPullError(null);
|
||||
try {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
await api<PullResponse>(`/git/pull${query}`, {
|
||||
const response = await api<SmartPullResponse>(`/git/smart-pull${query}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rebase: false }),
|
||||
body: JSON.stringify({
|
||||
worktreePath: checkout.worktreePath,
|
||||
integrationBranch: notice.integrationBranch,
|
||||
taskId: notice.taskId,
|
||||
}),
|
||||
});
|
||||
if (response.kind === "stash-pop-conflict") {
|
||||
setConflictState({
|
||||
stashSha: response.stashSha,
|
||||
stashLabel: response.stashLabel,
|
||||
conflictedFiles: response.conflictedFiles,
|
||||
});
|
||||
return;
|
||||
}
|
||||
dismiss();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
@@ -150,29 +165,44 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="merge-advance-notice" role="status" aria-live="polite">
|
||||
<div className="merge-advance-notice__content">
|
||||
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
|
||||
Your checked-out copy at {checkout.worktreePath} is behind.
|
||||
{localChangesPreserved ? " (local changes preserved)" : ""}
|
||||
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
|
||||
{pulling ? <span className="merge-advance-notice__hint"> Pulling…</span> : null}
|
||||
</div>
|
||||
<div className="merge-advance-notice__actions">
|
||||
{!localChangesPreserved ? (
|
||||
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
|
||||
Pull
|
||||
<>
|
||||
<div className="merge-advance-notice" role="status" aria-live="polite">
|
||||
<div className="merge-advance-notice__content">
|
||||
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
|
||||
Your checked-out copy at {checkout.worktreePath} is behind.
|
||||
{localChangesPreserved ? " (local changes will be auto-stashed and restored)" : ""}
|
||||
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
|
||||
{pulling ? <span className="merge-advance-notice__hint"> Pulling…</span> : null}
|
||||
</div>
|
||||
<div className="merge-advance-notice__actions">
|
||||
{conflictState ? null : (
|
||||
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
|
||||
Pull
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="merge-advance-notice__dismiss touch-target"
|
||||
aria-label="Dismiss merge advance notice"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="merge-advance-notice__dismiss touch-target"
|
||||
aria-label="Dismiss merge advance notice"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<StashConflictModal
|
||||
open={conflictState !== null}
|
||||
onClose={() => {
|
||||
setConflictState(null);
|
||||
dismiss();
|
||||
}}
|
||||
worktreePath={checkout.worktreePath}
|
||||
integrationBranch={notice.integrationBranch}
|
||||
stashSha={conflictState?.stashSha ?? ""}
|
||||
stashLabel={conflictState?.stashLabel ?? ""}
|
||||
conflictedFiles={conflictState?.conflictedFiles ?? []}
|
||||
taskId={notice.taskId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
71
packages/dashboard/app/components/StashConflictModal.css
Normal file
71
packages/dashboard/app/components/StashConflictModal.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.stash-conflict-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.stash-conflict-modal__summary {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stash-conflict-modal__stash-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.stash-conflict-modal__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.stash-conflict-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.stash-conflict-row__path {
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.stash-conflict-row__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.stash-conflict-modal__hint {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stash-conflict-modal__error {
|
||||
margin: 0;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stash-conflict-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stash-conflict-row__actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
196
packages/dashboard/app/components/StashConflictModal.tsx
Normal file
196
packages/dashboard/app/components/StashConflictModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { ApiRequestError, api } from "../api";
|
||||
import { useFileBrowser } from "../context/FileBrowserContext";
|
||||
import "./StashConflictModal.css";
|
||||
|
||||
interface ResolveResponse {
|
||||
remainingConflicts: string[];
|
||||
}
|
||||
|
||||
interface DropResponse {
|
||||
dropped: boolean;
|
||||
}
|
||||
|
||||
interface RestoreResponse {
|
||||
applied: boolean;
|
||||
conflict: boolean;
|
||||
conflictedFiles: string[];
|
||||
}
|
||||
|
||||
export interface StashConflictModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
worktreePath: string;
|
||||
integrationBranch: string;
|
||||
stashSha: string;
|
||||
stashLabel: string;
|
||||
conflictedFiles: string[];
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
function shortSha(sha: string): string {
|
||||
return sha.length > 7 ? sha.slice(0, 7) : sha;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiRequestError) {
|
||||
return error.message || "Request failed";
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return "Request failed";
|
||||
}
|
||||
|
||||
export default function StashConflictModal({
|
||||
open,
|
||||
onClose,
|
||||
worktreePath,
|
||||
integrationBranch,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
conflictedFiles,
|
||||
taskId,
|
||||
}: StashConflictModalProps) {
|
||||
const fileBrowser = useFileBrowser();
|
||||
const [remainingConflicts, setRemainingConflicts] = useState<string[]>(conflictedFiles);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRemainingConflicts(conflictedFiles);
|
||||
setError(null);
|
||||
setCopyState("idle");
|
||||
}
|
||||
}, [conflictedFiles, open]);
|
||||
|
||||
const stashDescriptor = useMemo(() => `Stash ref: ${shortSha(stashSha)} (${stashLabel})`, [stashLabel, stashSha]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveFile = async (file: string, choice: "ours" | "theirs") => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api<ResolveResponse>("/git/stash-resolve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worktreePath, stashSha, file, choice, taskId }),
|
||||
});
|
||||
setRemainingConflicts(Array.isArray(response.remainingConflicts) ? response.remainingConflicts : []);
|
||||
} catch (resolveError: unknown) {
|
||||
setError(getErrorMessage(resolveError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dropStash = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api<DropResponse>("/git/stash-drop", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worktreePath, stashSha, taskId }),
|
||||
});
|
||||
if (response.dropped) {
|
||||
onClose();
|
||||
}
|
||||
} catch (dropError: unknown) {
|
||||
setError(getErrorMessage(dropError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreStash = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api<RestoreResponse>("/git/stash-restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worktreePath, stashSha, taskId }),
|
||||
});
|
||||
if (response.conflict) {
|
||||
setRemainingConflicts(Array.isArray(response.conflictedFiles) ? response.conflictedFiles : []);
|
||||
}
|
||||
} catch (restoreError: unknown) {
|
||||
setError(getErrorMessage(restoreError));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyRef = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stashSha);
|
||||
setCopyState("copied");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" role="dialog" aria-modal="true" aria-label="Resolve stash conflicts">
|
||||
<div className="modal stash-conflict-modal">
|
||||
<div className="modal-header">
|
||||
<h3>Resolve auto-stash conflicts</h3>
|
||||
</div>
|
||||
<p className="stash-conflict-modal__summary">
|
||||
Pulled <strong>{integrationBranch}</strong>, but restoring local edits from stash produced conflicts.
|
||||
</p>
|
||||
<div className="stash-conflict-modal__stash-row">
|
||||
<span>{stashDescriptor}</span>
|
||||
<button type="button" className="btn btn-sm btn-icon" onClick={copyRef} aria-label="Copy stash reference">
|
||||
<Copy aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{copyState === "copied" ? <p className="stash-conflict-modal__hint">Stash SHA copied.</p> : null}
|
||||
{copyState === "failed" ? <p className="stash-conflict-modal__error">Could not copy stash SHA.</p> : null}
|
||||
<div className="stash-conflict-modal__list" role="list">
|
||||
{remainingConflicts.map((file) => (
|
||||
<div key={file} className="stash-conflict-row" role="listitem">
|
||||
<code className="stash-conflict-row__path">{file}</code>
|
||||
<div className="stash-conflict-row__actions">
|
||||
<button type="button" className="btn btn-sm" disabled={submitting} onClick={() => void resolveFile(file, "ours")}>
|
||||
Keep mine
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={submitting} onClick={() => void resolveFile(file, "theirs")}>
|
||||
Keep incoming
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={submitting}
|
||||
onClick={() => fileBrowser?.openFile(file, { workspace: worktreePath })}
|
||||
>
|
||||
Open in editor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{error ? <p className="stash-conflict-modal__error">{error}</p> : null}
|
||||
<div className="modal-actions">
|
||||
<div className="modal-actions-left">
|
||||
<button type="button" className="btn" disabled={submitting} onClick={() => void restoreStash()}>
|
||||
Restore from stash ref
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-actions-right">
|
||||
<button type="button" className="btn" disabled={submitting} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button type="button" className="btn btn-warning" disabled={submitting || remainingConflicts.length > 0} onClick={() => void dropStash()}>
|
||||
Drop stash
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ApiRequestError } from "../../api";
|
||||
const mocked = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
mergedHandler: undefined as (() => void) | undefined,
|
||||
stashModalProps: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async () => {
|
||||
@@ -23,11 +24,18 @@ vi.mock("../../sse-bus", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../StashConflictModal", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mocked.stashModalProps.push(props);
|
||||
return props.open ? <div data-testid="stash-conflict-modal">stash-conflict-modal</div> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
taskId: "FN-1",
|
||||
integrationBranch: "master",
|
||||
refName: "refs/heads/master",
|
||||
integrationBranch: "release",
|
||||
refName: "refs/heads/release",
|
||||
toSha: "abcdef123456",
|
||||
fromSha: "1234567",
|
||||
advanceMode: "update-ref",
|
||||
@@ -46,6 +54,7 @@ describe("MergeAdvanceNotice", () => {
|
||||
beforeEach(() => {
|
||||
mocked.api.mockReset();
|
||||
mocked.mergedHandler = undefined;
|
||||
mocked.stashModalProps = [];
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -63,32 +72,80 @@ describe("MergeAdvanceNotice", () => {
|
||||
it("renders notice with dynamic branch name", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [makeEvent()] });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
expect(await screen.findByText(/master advanced to abcdef1\./)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/release advanced to abcdef1\./)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your checked-out copy at \/repo is behind\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ dirty: true, untrackedCount: 0 },
|
||||
{ dirty: false, untrackedCount: 2 },
|
||||
])("shows local changes preserved and hides pull when dirty markers present", async ({ dirty, untrackedCount }) => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] });
|
||||
])("shows auto-stash copy and keeps pull visible for dirty state", async ({ dirty, untrackedCount }) => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] })
|
||||
.mockResolvedValueOnce({ kind: "clean-pull", toSha: "abcdef123456" });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
expect(await screen.findByText(/local changes preserved/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
|
||||
|
||||
expect(await screen.findByText(/local changes will be auto-stashed and restored/)).toBeInTheDocument();
|
||||
const pullButton = screen.getByRole("button", { name: "Pull" });
|
||||
fireEvent.click(pullButton);
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/smart-pull?projectId=proj-1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "release",
|
||||
taskId: "FN-1",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("pull posts rebase false and dismisses on success", async () => {
|
||||
it("clean smart-pull dismisses notice", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent()] })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
.mockResolvedValueOnce({ kind: "clean-pull", toSha: "abcdef123456" });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Pull" }));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
it("dirty smart-pull stash-pull-pop dismisses notice", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 0 } })] })
|
||||
.mockResolvedValueOnce({ kind: "stash-pull-pop", toSha: "abcdef123456" });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Pull" }));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
it("stash-pop-conflict opens modal and passes payload", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty: true, untrackedCount: 1 } })] })
|
||||
.mockResolvedValueOnce({
|
||||
kind: "stash-pop-conflict",
|
||||
toSha: "abcdef123456",
|
||||
stashSha: "stashsha123",
|
||||
stashLabel: "fusion-auto-stash-FN-1",
|
||||
conflictedFiles: ["src/a.ts"],
|
||||
});
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Pull" }));
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/pull?projectId=proj-1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rebase: false }),
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
expect(await screen.findByTestId("stash-conflict-modal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
|
||||
|
||||
const props = mocked.stashModalProps.at(-1);
|
||||
expect(props).toMatchObject({
|
||||
open: true,
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "release",
|
||||
stashSha: "stashsha123",
|
||||
stashLabel: "fusion-auto-stash-FN-1",
|
||||
conflictedFiles: ["src/a.ts"],
|
||||
taskId: "FN-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows inline pull failure and keeps notice visible", async () => {
|
||||
@@ -102,7 +159,6 @@ describe("MergeAdvanceNotice", () => {
|
||||
|
||||
expect(await screen.findByText(/Merge conflict detected/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Pull" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("dismisses current sha and stays hidden when same advance is refetched", async () => {
|
||||
@@ -129,7 +185,7 @@ describe("MergeAdvanceNotice", () => {
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
|
||||
mocked.mergedHandler?.();
|
||||
expect(await screen.findByText(/master advanced to bbbbbbb\./)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/release advanced to bbbbbbb\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for failed advance or null checkout", async () => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import StashConflictModal from "../StashConflictModal";
|
||||
import { ApiRequestError } from "../../api";
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
writeText: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
||||
return {
|
||||
...actual,
|
||||
api: mocked.api,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../context/FileBrowserContext", () => ({
|
||||
useFileBrowser: () => ({ openFile: mocked.openFile }),
|
||||
}));
|
||||
|
||||
function renderModal(overrides: Partial<ComponentProps<typeof StashConflictModal>> = {}) {
|
||||
return render(
|
||||
<StashConflictModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
worktreePath="/repo"
|
||||
integrationBranch="release"
|
||||
stashSha="1234567890abcdef"
|
||||
stashLabel="fusion-auto-stash-FN-1"
|
||||
conflictedFiles={["src/a.ts", "src/b.ts"]}
|
||||
taskId="FN-1"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("StashConflictModal", () => {
|
||||
beforeEach(() => {
|
||||
mocked.api.mockReset();
|
||||
mocked.openFile.mockReset();
|
||||
mocked.writeText.mockReset();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: mocked.writeText },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
renderModal({ open: false });
|
||||
expect(screen.queryByText(/Resolve auto-stash conflicts/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one row per conflicted file with actions", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("src/a.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText("src/b.ts")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: "Keep mine" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Keep incoming" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Open in editor" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Keep mine posts ours and removes row from response", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ remainingConflicts: ["src/b.ts"] });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]);
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-resolve", expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worktreePath: "/repo", stashSha: "1234567890abcdef", file: "src/a.ts", choice: "ours", taskId: "FN-1" }),
|
||||
})));
|
||||
expect(screen.queryByText("src/a.ts")).toBeNull();
|
||||
expect(screen.getByText("src/b.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Keep incoming posts theirs", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ remainingConflicts: ["src/a.ts"] });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Keep incoming" })[1]);
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-resolve", expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ worktreePath: "/repo", stashSha: "1234567890abcdef", file: "src/b.ts", choice: "theirs", taskId: "FN-1" }),
|
||||
})));
|
||||
});
|
||||
|
||||
it("Open in editor uses file browser workspace", () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open in editor" })[0]);
|
||||
expect(mocked.openFile).toHaveBeenCalledWith("src/a.ts", { workspace: "/repo" });
|
||||
});
|
||||
|
||||
it("Drop stash disabled until conflicts resolved, then drops and closes", async () => {
|
||||
const onClose = vi.fn();
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ remainingConflicts: [] })
|
||||
.mockResolvedValueOnce({ dropped: true });
|
||||
renderModal({ onClose });
|
||||
|
||||
const drop = screen.getByRole("button", { name: "Drop stash" });
|
||||
expect(drop).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]);
|
||||
await waitFor(() => expect(drop).toBeEnabled());
|
||||
|
||||
fireEvent.click(drop);
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-drop", expect.objectContaining({ method: "POST" })));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Restore from stash ref posts and updates conflicts when returned", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ applied: true, conflict: true, conflictedFiles: ["src/c.ts"] });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Restore from stash ref" }));
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledWith("/git/stash-restore", expect.objectContaining({ method: "POST" })));
|
||||
expect(screen.getByText("src/c.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows inline error for resolve/drop/restore failures", async () => {
|
||||
mocked.api
|
||||
.mockRejectedValueOnce(new ApiRequestError("resolve failed", 500))
|
||||
.mockRejectedValueOnce(new ApiRequestError("restore failed", 500))
|
||||
.mockResolvedValueOnce({ remainingConflicts: [] })
|
||||
.mockRejectedValueOnce(new ApiRequestError("drop failed", 500));
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]);
|
||||
expect(await screen.findByText("resolve failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Restore from stash ref" }));
|
||||
expect(await screen.findByText("restore failed")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Keep mine" })[0]);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Drop stash" })).toBeEnabled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Drop stash" }));
|
||||
expect(await screen.findByText("drop failed")).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stash sha/label and copies sha", async () => {
|
||||
mocked.writeText.mockResolvedValueOnce(undefined);
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText(/Stash ref: 1234567 \(fusion-auto-stash-FN-1\)/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy stash reference" }));
|
||||
|
||||
await waitFor(() => expect(mocked.writeText).toHaveBeenCalledWith("1234567890abcdef"));
|
||||
});
|
||||
});
|
||||
354
packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts
Normal file
354
packages/dashboard/src/__tests__/routes-git-smart-pull.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createServer } from "../server.js";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
runGitCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../routes/resolve-diff-base.js", () => ({
|
||||
runGitCommand: mocked.runGitCommand,
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
recordRunAuditEvent = vi.fn();
|
||||
getRootDir(): string { return "/repo"; }
|
||||
getFusionDir(): string { return "/repo/.fusion"; }
|
||||
getSettings = vi.fn().mockResolvedValue({});
|
||||
getSettingsFast = vi.fn().mockResolvedValue({});
|
||||
getDatabase() {
|
||||
return {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptedResponse = string | Error;
|
||||
|
||||
function gitScript(map: Record<string, ScriptedResponse>) {
|
||||
const calls: string[] = [];
|
||||
mocked.runGitCommand.mockImplementation(async (args: string[]) => {
|
||||
const key = args.join(" ");
|
||||
calls.push(key);
|
||||
const hit = Object.entries(map).find(([prefix]) => key.startsWith(prefix));
|
||||
if (!hit) {
|
||||
throw new Error(`missing mock for ${key}`);
|
||||
}
|
||||
if (hit[1] instanceof Error) {
|
||||
throw hit[1];
|
||||
}
|
||||
return hit[1] as string;
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("smart pull routes", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof createServer>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new MockStore();
|
||||
app = createServer(store as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns clean-pull for clean worktree and emits pull audit", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "bbbb\n",
|
||||
"status --porcelain=v1 --untracked-files=all": "",
|
||||
"pull --ff-only": "Already up to date.\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ kind: "clean-pull", fromSha: "bbbb", toSha: "bbbb" });
|
||||
const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType);
|
||||
expect(events).toEqual(["pull:fast-forward"]);
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "git",
|
||||
mutationType: "pull:fast-forward",
|
||||
taskId: "FN-5358",
|
||||
target: "/repo",
|
||||
metadata: expect.objectContaining({ succeeded: true, integrationBranch: "main" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("runs dirty stash/pull/pop path and emits ordered audits", async () => {
|
||||
const calls = gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "cccc\n",
|
||||
"status --porcelain=v1 --untracked-files=all": " M file.ts\n",
|
||||
"stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved working directory and index state\n",
|
||||
"rev-parse stash@{0}": "stashsha123\n",
|
||||
"pull --ff-only": "Updating cccc..dddd\n",
|
||||
"stash pop": "On branch main\nDropped refs/stash@{0}\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ kind: "stash-pull-pop", stashSha: "stashsha123", stashLabel: "fusion-auto-stash-FN-5358" });
|
||||
const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType);
|
||||
expect(events).toEqual(["stash:push", "pull:fast-forward", "stash:pop"]);
|
||||
const stashPush = store.recordRunAuditEvent.mock.calls[0][0];
|
||||
const stashPop = store.recordRunAuditEvent.mock.calls[2][0];
|
||||
expect(stashPush.metadata.stashSha).toBe("stashsha123");
|
||||
expect(stashPop.metadata.stashSha).toBe("stashsha123");
|
||||
expect(calls).not.toContain("stash drop");
|
||||
});
|
||||
|
||||
it("returns stash-pop-conflict and never drops stash", async () => {
|
||||
const calls = gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "eeee\n",
|
||||
"status --porcelain=v1 --untracked-files=all": " M app.tsx\n",
|
||||
"stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved\n",
|
||||
"rev-parse stash@{0}": "stashsha456\n",
|
||||
"pull --ff-only": "Updating\n",
|
||||
"stash pop": new Error("CONFLICT (content): Merge conflict in app.tsx"),
|
||||
"stash list --format=%H|%gd": "stashsha456|stash@{0}\n",
|
||||
"diff --name-only --diff-filter=U": "app.tsx\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ kind: "stash-pop-conflict", conflictedFiles: ["app.tsx"] });
|
||||
expect(calls.find((call) => call.startsWith("stash drop"))).toBeUndefined();
|
||||
const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType);
|
||||
expect(events).toEqual(["stash:push", "pull:fast-forward", "stash:pop-conflict"]);
|
||||
});
|
||||
|
||||
it("returns 409 on pull rejection, restores stash, emits failed pull audit", async () => {
|
||||
const calls = gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "ffff\n",
|
||||
"status --porcelain=v1 --untracked-files=all": " M a.ts\n",
|
||||
"stash push --include-untracked -m fusion-auto-stash-FN-5358": "Saved\n",
|
||||
"rev-parse stash@{0}": "stashsha789\n",
|
||||
"pull --ff-only": new Error("fatal: Not possible to fast-forward, aborting."),
|
||||
"stash pop": "restored\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(calls).toContain("stash pop");
|
||||
const events = store.recordRunAuditEvent.mock.calls.map(([event]) => event);
|
||||
expect(events.map((event) => event.mutationType)).toEqual(["stash:push", "pull:fast-forward"]);
|
||||
expect(events[1].metadata.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it("uses taskId in stash label, and timestamp fallback when taskId omitted", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "1111\n",
|
||||
"status --porcelain=v1 --untracked-files=all": " M app.ts\n",
|
||||
"stash push --include-untracked -m fusion-auto-stash-": "Saved\n",
|
||||
"rev-parse stash@{0}": "stashsha111\n",
|
||||
"pull --ff-only": "done\n",
|
||||
"stash pop": "done\n",
|
||||
});
|
||||
|
||||
await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(mocked.runGitCommand).toHaveBeenCalledWith(expect.arrayContaining(["-m", "fusion-auto-stash-FN-5358"]), "/repo", 15_000);
|
||||
|
||||
vi.clearAllMocks();
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "main\n",
|
||||
"rev-parse HEAD": "2222\n",
|
||||
"status --porcelain=v1 --untracked-files=all": " M app.ts\n",
|
||||
"stash push --include-untracked -m fusion-auto-stash-": "Saved\n",
|
||||
"rev-parse stash@{0}": "stashsha222\n",
|
||||
"pull --ff-only": "done\n",
|
||||
"stash pop": "done\n",
|
||||
});
|
||||
|
||||
await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
const stashCall = mocked.runGitCommand.mock.calls.find(([args]: [string[]]) => args[0] === "stash" && args[1] === "push");
|
||||
expect(stashCall?.[0][4]).toMatch(/^fusion-auto-stash-\d+$/);
|
||||
});
|
||||
|
||||
it("returns branch mismatch 409 without audits", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"rev-parse --git-dir": ".git\n",
|
||||
"rev-parse --abbrev-ref HEAD": "feature\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
integrationBranch: "main",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.details).toMatchObject({ reason: "branch-mismatch", currentBranch: "feature" });
|
||||
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects path traversal worktree path", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/smart-pull", JSON.stringify({
|
||||
worktreePath: "../../etc",
|
||||
integrationBranch: "main",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stash-resolve uses ours/theirs checkout and rejects invalid choice", async () => {
|
||||
const calls = gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"diff --name-only --diff-filter=U": "app/a.ts\n",
|
||||
"checkout --ours -- app/a.ts": "",
|
||||
"add -- app/a.ts": "",
|
||||
});
|
||||
|
||||
const res = await request(app, "POST", "/api/git/stash-resolve", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
file: "app/a.ts",
|
||||
choice: "ours",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(calls).toContain("checkout --ours -- app/a.ts");
|
||||
expect(calls).toContain("add -- app/a.ts");
|
||||
|
||||
const invalid = await request(app, "POST", "/api/git/stash-resolve", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
file: "app/a.ts",
|
||||
choice: "invalid",
|
||||
}), { "content-type": "application/json" });
|
||||
expect(invalid.status).toBe(400);
|
||||
});
|
||||
|
||||
it("stash-drop blocks unresolved conflicts then succeeds and audits manual resolution", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"diff --name-only --diff-filter=U": "file.ts\n",
|
||||
});
|
||||
|
||||
const blocked = await request(app, "POST", "/api/git/stash-drop", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
expect(blocked.status).toBe(409);
|
||||
|
||||
vi.clearAllMocks();
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"diff --name-only --diff-filter=U": "",
|
||||
"stash list --format=%H|%gd": "stashsha|stash@{1}\n",
|
||||
"stash drop stash@{1}": "Dropped stash@{1}\n",
|
||||
});
|
||||
|
||||
const dropped = await request(app, "POST", "/api/git/stash-drop", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(dropped.status).toBe(200);
|
||||
expect(dropped.body).toMatchObject({ dropped: true });
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "git",
|
||||
mutationType: "stash:pop",
|
||||
taskId: "FN-5358",
|
||||
metadata: expect.objectContaining({ manualResolution: true }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("stash-restore applies stash and re-emits conflict audit when apply conflicts", async () => {
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"stash list --format=%H|%gd": "stashsha|stash@{0}\n",
|
||||
"stash apply stash@{0}": "",
|
||||
});
|
||||
|
||||
const clean = await request(app, "POST", "/api/git/stash-restore", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(clean.status).toBe(200);
|
||||
expect(clean.body).toMatchObject({ applied: true, conflict: false, conflictedFiles: [] });
|
||||
|
||||
vi.clearAllMocks();
|
||||
gitScript({
|
||||
"worktree list --porcelain": "worktree /repo\n",
|
||||
"stash list --format=%H|%gd": "stashsha|stash@{0}\n",
|
||||
"stash apply stash@{0}": new Error("CONFLICT (content): Merge conflict in app.tsx"),
|
||||
"diff --name-only --diff-filter=U": "app.tsx\n",
|
||||
});
|
||||
|
||||
const conflict = await request(app, "POST", "/api/git/stash-restore", JSON.stringify({
|
||||
worktreePath: "/repo",
|
||||
stashSha: "stashsha",
|
||||
taskId: "FN-5358",
|
||||
}), { "content-type": "application/json" });
|
||||
|
||||
expect(conflict.status).toBe(200);
|
||||
expect(conflict.body).toMatchObject({ applied: true, conflict: true, conflictedFiles: ["app.tsx"] });
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "git",
|
||||
mutationType: "stash:pop-conflict",
|
||||
taskId: "FN-5358",
|
||||
target: "/repo",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type NextFunction, type Request, type Response } from "express";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { isAbsolute, resolve, relative } from "node:path";
|
||||
import { exec as execCb, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
@@ -728,6 +728,92 @@ async function dropStashBySha(sha: string, cwd?: string): Promise<void> {
|
||||
await runGitCommand(["stash", "drop", ref], cwd, 10_000);
|
||||
}
|
||||
|
||||
function isPathWithin(parent: string, candidate: string): boolean {
|
||||
const rel = relative(parent, candidate);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
async function listRegisteredWorktreePaths(rootDir: string): Promise<string[]> {
|
||||
const output = await runGitCommand(["worktree", "list", "--porcelain"], rootDir, 10_000);
|
||||
const paths: string[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
if (!line.startsWith("worktree ")) continue;
|
||||
const worktreePath = line.slice("worktree ".length).trim();
|
||||
if (!worktreePath) continue;
|
||||
paths.push(resolve(worktreePath));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function assertWorktreePathSafe(
|
||||
scopedStore: Pick<TaskStore, "getRootDir">,
|
||||
worktreePath: string,
|
||||
cache: Map<string, string[]>,
|
||||
): Promise<string> {
|
||||
if (typeof worktreePath !== "string" || worktreePath.trim().length === 0) {
|
||||
throw badRequest("worktreePath is required");
|
||||
}
|
||||
|
||||
const rootDir = resolve(scopedStore.getRootDir());
|
||||
const resolved = resolve(worktreePath);
|
||||
if (isPathWithin(rootDir, resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
let allowlisted = cache.get(rootDir);
|
||||
if (!allowlisted) {
|
||||
allowlisted = await listRegisteredWorktreePaths(rootDir);
|
||||
cache.set(rootDir, allowlisted);
|
||||
}
|
||||
|
||||
if (allowlisted.some((allowed) => isPathWithin(allowed, resolved))) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
throw badRequest("worktreePath outside project");
|
||||
}
|
||||
|
||||
type DashboardGitMutationType = "stash:push" | "stash:pop" | "pull:fast-forward" | "stash:pop-conflict";
|
||||
|
||||
async function listConflictedFiles(cwd?: string): Promise<string[]> {
|
||||
const output = await runGitCommand(["diff", "--name-only", "--diff-filter=U"], cwd, 10_000);
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function assertRelativeFileSafe(worktreePath: string, file: string): string {
|
||||
if (typeof file !== "string" || file.trim().length === 0) {
|
||||
throw badRequest("file is required");
|
||||
}
|
||||
if (file.split("/").includes("..") || file.split("\\").includes("..")) {
|
||||
throw badRequest("file outside worktree");
|
||||
}
|
||||
const normalized = resolve(worktreePath, file);
|
||||
if (!isPathWithin(worktreePath, normalized)) {
|
||||
throw badRequest("file outside worktree");
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function buildDashboardGitAuditEvent(input: {
|
||||
taskId?: string;
|
||||
mutationType: DashboardGitMutationType;
|
||||
target: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): RunAuditEventInput {
|
||||
return {
|
||||
taskId: input.taskId,
|
||||
agentId: "dashboard-api",
|
||||
runId: `dashboard-git-${Date.now()}`,
|
||||
domain: "git",
|
||||
mutationType: input.mutationType,
|
||||
target: input.target,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async function createPullAutostash(cwd?: string): Promise<PullAutostashHandle | null> {
|
||||
if (!(await hasLocalChangesForPull(cwd))) {
|
||||
return null;
|
||||
@@ -2304,6 +2390,358 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/smart-pull
|
||||
* Pull integration branch with optional auto-stash lifecycle.
|
||||
*/
|
||||
router.post("/git/smart-pull", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { worktreePath, taskId, integrationBranch } = req.body ?? {};
|
||||
if (typeof integrationBranch !== "string" || integrationBranch.trim().length === 0) {
|
||||
throw badRequest("integrationBranch is required");
|
||||
}
|
||||
if (taskId !== undefined && typeof taskId !== "string") {
|
||||
throw badRequest("taskId must be a string");
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, string[]>();
|
||||
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
||||
if (!(await isGitRepo(safeWorktreePath))) {
|
||||
throw badRequest("Not a git repository");
|
||||
}
|
||||
|
||||
const currentBranch = (await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
if (currentBranch !== integrationBranch) {
|
||||
throw new ApiError(409, "Worktree is not on integration branch", { reason: "branch-mismatch", currentBranch });
|
||||
}
|
||||
|
||||
const fromSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
const dirty = await hasLocalChangesForPull(safeWorktreePath);
|
||||
|
||||
const emitGitAudit = (mutationType: DashboardGitMutationType, metadata: Record<string, unknown>) => {
|
||||
if (typeof scopedStore.recordRunAuditEvent !== "function") return;
|
||||
scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({
|
||||
taskId,
|
||||
mutationType,
|
||||
target: safeWorktreePath,
|
||||
metadata,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!dirty) {
|
||||
const pullStart = performance.now();
|
||||
const result = await pullGitBranch(safeWorktreePath, { rebase: false });
|
||||
const pullDurationMs = Math.round(performance.now() - pullStart);
|
||||
if (result.conflict) {
|
||||
throw new ApiError(409, result.message || "Pull failed", { ...result });
|
||||
}
|
||||
const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
emitGitAudit("pull:fast-forward", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
integrationBranch,
|
||||
fromSha,
|
||||
toSha,
|
||||
durationMs: pullDurationMs,
|
||||
succeeded: true,
|
||||
});
|
||||
console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=clean-pull stashSha=none`);
|
||||
res.json({ kind: "clean-pull", message: result.message, fromSha, toSha });
|
||||
return;
|
||||
}
|
||||
|
||||
const stashLabel = `fusion-auto-stash-${taskId ?? Date.now()}`;
|
||||
const stashStart = performance.now();
|
||||
const stashOutput = await runGitCommand(["stash", "push", "--include-untracked", "-m", stashLabel], safeWorktreePath, 15_000);
|
||||
if (stashOutput.includes("No local changes to save")) {
|
||||
const pullStart = performance.now();
|
||||
const result = await pullGitBranch(safeWorktreePath, { rebase: false });
|
||||
const pullDurationMs = Math.round(performance.now() - pullStart);
|
||||
if (result.conflict) {
|
||||
throw new ApiError(409, result.message || "Pull failed", { ...result });
|
||||
}
|
||||
const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
emitGitAudit("pull:fast-forward", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
integrationBranch,
|
||||
fromSha,
|
||||
toSha,
|
||||
durationMs: pullDurationMs,
|
||||
succeeded: true,
|
||||
});
|
||||
console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=clean-pull stashSha=none`);
|
||||
res.json({ kind: "clean-pull", message: result.message, fromSha, toSha });
|
||||
return;
|
||||
}
|
||||
|
||||
const stashDurationMs = Math.round(performance.now() - stashStart);
|
||||
const stashSha = (await runGitCommand(["rev-parse", "stash@{0}"], safeWorktreePath, 5_000)).trim();
|
||||
emitGitAudit("stash:push", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
untrackedIncluded: true,
|
||||
durationMs: stashDurationMs,
|
||||
});
|
||||
|
||||
const pullStart = performance.now();
|
||||
try {
|
||||
await runGitCommand(["pull", "--ff-only"], safeWorktreePath, 30_000);
|
||||
} catch (err: unknown) {
|
||||
const message = getCommandErrorMessage(err);
|
||||
const durationMs = Math.round(performance.now() - pullStart);
|
||||
emitGitAudit("pull:fast-forward", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
integrationBranch,
|
||||
fromSha,
|
||||
toSha: fromSha,
|
||||
durationMs,
|
||||
succeeded: false,
|
||||
error: message,
|
||||
});
|
||||
|
||||
try {
|
||||
await runGitCommand(["stash", "pop"], safeWorktreePath, 20_000);
|
||||
} catch (popErr: unknown) {
|
||||
const popMessage = getCommandErrorMessage(popErr);
|
||||
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
|
||||
const stashRef = await findStashRefBySha(stashSha, safeWorktreePath);
|
||||
if (isGitConflictMessage(popMessage) || stashRef) {
|
||||
emitGitAudit("stash:pop-conflict", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
conflictedFiles,
|
||||
advice: "Resolve conflicts, then drop stash when complete.",
|
||||
});
|
||||
console.warn(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pop-conflict stashSha=${stashSha.slice(0, 7)}`);
|
||||
const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
res.json({
|
||||
kind: "stash-pop-conflict",
|
||||
message: "Pull failed and stash restore conflicted. Resolve conflicts before continuing.",
|
||||
fromSha,
|
||||
toSha,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
conflictedFiles,
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new ApiError(409, "Pull failed and automatic stash restore failed", { stashSha, stashLabel, error: popMessage });
|
||||
}
|
||||
|
||||
throw new ApiError(409, "Pull failed — local changes restored from stash", { stashSha, stashLabel, error: message });
|
||||
}
|
||||
|
||||
const pullDurationMs = Math.round(performance.now() - pullStart);
|
||||
const toSha = (await runGitCommand(["rev-parse", "HEAD"], safeWorktreePath, 5_000)).trim();
|
||||
emitGitAudit("pull:fast-forward", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
integrationBranch,
|
||||
fromSha,
|
||||
toSha,
|
||||
durationMs: pullDurationMs,
|
||||
succeeded: true,
|
||||
});
|
||||
|
||||
const popStart = performance.now();
|
||||
try {
|
||||
await runGitCommand(["stash", "pop"], safeWorktreePath, 20_000);
|
||||
emitGitAudit("stash:pop", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
durationMs: Math.round(performance.now() - popStart),
|
||||
});
|
||||
console.info(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pull-pop stashSha=${stashSha.slice(0, 7)}`);
|
||||
res.json({
|
||||
kind: "stash-pull-pop",
|
||||
message: "Pulled latest changes and restored local edits from stash.",
|
||||
fromSha,
|
||||
toSha,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
stashApplied: true,
|
||||
stashDropped: true,
|
||||
});
|
||||
return;
|
||||
} catch (popErr: unknown) {
|
||||
const popMessage = getCommandErrorMessage(popErr);
|
||||
const stashRef = await findStashRefBySha(stashSha, safeWorktreePath);
|
||||
if (!isGitConflictMessage(popMessage) && !stashRef) {
|
||||
throw popErr;
|
||||
}
|
||||
|
||||
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
|
||||
emitGitAudit("stash:pop-conflict", {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
conflictedFiles,
|
||||
advice: "Resolve conflicts, then drop stash when complete.",
|
||||
});
|
||||
console.warn(`[smart-pull] taskId=${taskId ?? "unknown"} kind=stash-pop-conflict stashSha=${stashSha.slice(0, 7)}`);
|
||||
res.json({
|
||||
kind: "stash-pop-conflict",
|
||||
message: "Pulled latest changes, but stash restore conflicted.",
|
||||
fromSha,
|
||||
toSha,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
conflictedFiles,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/git/stash-resolve", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { worktreePath, stashSha, file, choice } = req.body ?? {};
|
||||
if (typeof stashSha !== "string" || stashSha.trim().length === 0) {
|
||||
throw badRequest("stashSha is required");
|
||||
}
|
||||
if (choice !== "ours" && choice !== "theirs") {
|
||||
throw badRequest("choice must be ours or theirs");
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, string[]>();
|
||||
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
||||
const safeFile = assertRelativeFileSafe(safeWorktreePath, file);
|
||||
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
|
||||
if (!conflictedFiles.includes(safeFile)) {
|
||||
throw badRequest("file is not conflicted");
|
||||
}
|
||||
|
||||
await runGitCommand(["checkout", choice === "ours" ? "--ours" : "--theirs", "--", safeFile], safeWorktreePath, 10_000);
|
||||
await runGitCommand(["add", "--", safeFile], safeWorktreePath, 10_000);
|
||||
const remainingConflicts = await listConflictedFiles(safeWorktreePath);
|
||||
res.json({ resolvedFile: safeFile, choice, remainingConflicts });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/git/stash-drop", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { worktreePath, stashSha, taskId } = req.body ?? {};
|
||||
if (typeof stashSha !== "string" || stashSha.trim().length === 0) {
|
||||
throw badRequest("stashSha is required");
|
||||
}
|
||||
if (taskId !== undefined && typeof taskId !== "string") {
|
||||
throw badRequest("taskId must be a string");
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, string[]>();
|
||||
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
||||
const remainingConflicts = await listConflictedFiles(safeWorktreePath);
|
||||
if (remainingConflicts.length > 0) {
|
||||
throw new ApiError(409, "Resolve conflicts before dropping stash", { remainingConflicts });
|
||||
}
|
||||
|
||||
const ref = await findStashRefBySha(stashSha, safeWorktreePath);
|
||||
if (!ref) {
|
||||
res.json({ dropped: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await runGitCommand(["stash", "drop", ref], safeWorktreePath, 10_000);
|
||||
const stashLabel = ref;
|
||||
if (typeof scopedStore.recordRunAuditEvent === "function") {
|
||||
scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({
|
||||
taskId,
|
||||
mutationType: "stash:pop",
|
||||
target: safeWorktreePath,
|
||||
metadata: {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel,
|
||||
manualResolution: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({ dropped: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/git/stash-restore", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { worktreePath, stashSha, taskId } = req.body ?? {};
|
||||
if (typeof stashSha !== "string" || stashSha.trim().length === 0) {
|
||||
throw badRequest("stashSha is required");
|
||||
}
|
||||
if (taskId !== undefined && typeof taskId !== "string") {
|
||||
throw badRequest("taskId must be a string");
|
||||
}
|
||||
|
||||
const requestCache = new Map<string, string[]>();
|
||||
const safeWorktreePath = await assertWorktreePathSafe(scopedStore, worktreePath, requestCache);
|
||||
const ref = await findStashRefBySha(stashSha, safeWorktreePath);
|
||||
if (!ref) {
|
||||
res.json({ applied: false, conflict: false, conflictedFiles: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runGitCommand(["stash", "apply", ref], safeWorktreePath, 20_000);
|
||||
res.json({ applied: true, conflict: false, conflictedFiles: [] });
|
||||
} catch (err: unknown) {
|
||||
const message = getCommandErrorMessage(err);
|
||||
const conflictedFiles = await listConflictedFiles(safeWorktreePath);
|
||||
if (isGitConflictMessage(message)) {
|
||||
if (typeof scopedStore.recordRunAuditEvent === "function") {
|
||||
scopedStore.recordRunAuditEvent(buildDashboardGitAuditEvent({
|
||||
taskId,
|
||||
mutationType: "stash:pop-conflict",
|
||||
target: safeWorktreePath,
|
||||
metadata: {
|
||||
taskId,
|
||||
worktreePath: safeWorktreePath,
|
||||
stashSha,
|
||||
stashLabel: ref,
|
||||
conflictedFiles,
|
||||
advice: "Resolve conflicts, then drop stash when complete.",
|
||||
},
|
||||
}));
|
||||
}
|
||||
res.json({ applied: true, conflict: true, conflictedFiles });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/push
|
||||
* Push the current branch.
|
||||
|
||||
@@ -12,7 +12,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskStore, RunAuditEventInput } from "@fusion/core";
|
||||
import { createRunAuditor, type DatabaseMutationType } from "../run-audit.js";
|
||||
import { createRunAuditor, type DatabaseMutationType, type GitMutationType } from "../run-audit.js";
|
||||
|
||||
class AuditStoreStub {
|
||||
events: RunAuditEventInput[] = [];
|
||||
@@ -88,6 +88,18 @@ describe("run-audit provisioning mutation types", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts smart-pull git mutation types", async () => {
|
||||
const store = new AuditStoreStub();
|
||||
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5358" });
|
||||
|
||||
const types: GitMutationType[] = ["pull:fast-forward", "stash:pop-conflict"];
|
||||
for (const type of types) {
|
||||
await auditor.git({ type, target: "feature/worktree", metadata: { taskId: "FN-5358" } });
|
||||
}
|
||||
|
||||
expect(store.events.map((event) => event.mutationType)).toEqual(types);
|
||||
});
|
||||
|
||||
it("records merge:scope:auto-widen git events", async () => {
|
||||
const store = new AuditStoreStub();
|
||||
const auditor = createRunAuditor(store as unknown as TaskStore, { runId: "r1", agentId: "a1", taskId: "FN-5226" });
|
||||
|
||||
@@ -228,8 +228,62 @@ export type GitMutationType =
|
||||
// reserved; refusal currently thrown pre-audit
|
||||
| "project:bootstrap-refused-linked-worktree"
|
||||
| "branch:reanchor"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId?: string;
|
||||
* worktreePath: string;
|
||||
* stashSha: string;
|
||||
* stashLabel: string;
|
||||
* untrackedIncluded: true;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "stash:push"
|
||||
| "stash:pop";
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId?: string;
|
||||
* worktreePath: string;
|
||||
* stashSha: string;
|
||||
* stashLabel: string;
|
||||
* manualResolution?: boolean;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "stash:pop"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId?: string;
|
||||
* worktreePath: string;
|
||||
* integrationBranch: string;
|
||||
* fromSha: string;
|
||||
* toSha: string;
|
||||
* durationMs: number;
|
||||
* succeeded: boolean;
|
||||
* error?: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "pull:fast-forward"
|
||||
/**
|
||||
* Metadata shape:
|
||||
* ```ts
|
||||
* {
|
||||
* taskId?: string;
|
||||
* worktreePath: string;
|
||||
* stashSha: string;
|
||||
* stashLabel: string;
|
||||
* conflictedFiles: string[];
|
||||
* advice: string;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
| "stash:pop-conflict";
|
||||
|
||||
// ── Database mutation types ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user