feat(FN-5359): add push-to-origin button and hook to merge advance notice

Adds a push-to-origin workflow to the merge notice system, introducing a new `useMergeAdvanceNotice` hook, a `merge-advance-push-origin` route handler, and corresponding UI affordance in the `MergeAdvanceNotice` banner component. The engine gains TOCTOU and refusal audit assertions, and coverage exp

Fusion-Task-Id: FN-5359

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5359
This commit is contained in:
gsxdsm
2026-05-23 05:59:25 -07:00
parent 687237bd91
commit e5357a4afd
17 changed files with 1074 additions and 323 deletions

View File

@@ -992,3 +992,16 @@ Reuse `packages/dashboard/app/utils/filePathLinkify.tsx` and `FileBrowserContext
- **Mobile board scroll-snap (FN-001)** — `scroll-snap-type: x mandatory` on mobile `.board` causes iOS Safari to compress the viewport when switching from ListView. Use `x proximity` + `overflow-anchor: none`.
- **`lucide-react` icon adds** — update `vi.mock("lucide-react")` test mocks immediately; missing exports cascade.
- **`.spin` is global** — don't redefine the generic spin keyframes in component CSS.
## Integration Branch Push to Origin
The merge-advance notice includes an explicit **Push to origin** action for the dynamically resolved integration branch.
- The branch name is resolved from project settings, then `origin/HEAD`, then fallback; UI copy and API behavior must remain dynamic.
- Push status probes compute ahead/behind counts and disable push when there is no `origin`, no upstream tracking ref, the branch is not ahead, or a Fusion merge lock is active.
- The mutating route performs a TOCTOU merge-lock recheck immediately before building push argv.
- Standard push is `git push origin refs/heads/<branch>:refs/heads/<branch>` with no plain `--force` path.
- Advanced mode enables opt-in `--force-with-lease=refs/heads/<branch>:<localSha>` only.
- Non-fast-forward and lease-stale failures surface actionable messaging with Smart Pull.
- Every attempt records `mutationType: "push:origin"` run-audit metadata: `integrationBranch`, `remote`, `localSha`, `remoteSha`, `aheadCount`, `behindCount`, `forceWithLease`, `outcome`, optional `stderrPreview`, and `durationMs`.
- Push remains explicit user authorization only through dashboard HTTP routes (no scheduler/heartbeat auto-push).

View File

@@ -52,6 +52,51 @@
color: var(--text-muted);
}
.merge-advance-notice__push {
display: grid;
gap: var(--space-xs);
margin-top: var(--space-sm);
padding-top: var(--space-sm);
border-top: var(--border-width-thin) solid color-mix(in srgb, var(--color-warning) 25%, transparent);
}
.merge-advance-notice__push-heading {
margin: 0;
color: var(--text);
}
.merge-advance-notice__push-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.merge-advance-notice__push-advanced summary {
cursor: pointer;
color: var(--text-muted);
}
.merge-advance-notice__push-advanced label {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
margin-top: var(--space-2xs);
}
.merge-advance-notice__push-error {
color: var(--color-error);
}
.merge-advance-notice__push-error pre {
margin: var(--space-2xs) 0;
padding: var(--space-2xs);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-error) 12%, transparent);
color: var(--color-error);
font-family: var(--font-mono);
white-space: pre-wrap;
}
@media (max-width: 768px) {
.merge-advance-notice {
flex-direction: column;
@@ -61,4 +106,9 @@
.merge-advance-notice__actions {
justify-content: flex-start;
}
.merge-advance-notice__push-actions {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,177 +1,114 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { api, ApiRequestError } from "../api";
import { subscribeSse } from "../sse-bus";
import { useRef } from "react";
import StashConflictModal from "./StashConflictModal";
import { useMergeAdvanceNotice } from "../hooks/useMergeAdvanceNotice";
import "./MergeAdvanceNotice.css";
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
refName: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
userCheckout: {
worktreePath: string;
dirty: boolean;
untrackedCount: number;
} | null;
}
interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
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;
apiBase?: string;
}
function shortSha(sha: string): string {
function shortSha(sha: string | null): string {
if (!sha) return "";
return sha.length > 7 ? sha.slice(0, 7) : sha;
}
function dismissedStorageKey(projectId?: string): string {
return `kb:merge-advance-notice-dismissed:${projectId ?? "default"}`;
}
function readDismissedShas(projectId?: string): string[] {
try {
const raw = window.localStorage.getItem(dismissedStorageKey(projectId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((value): value is string => typeof value === "string");
} catch {
return [];
}
}
function persistDismissedShas(projectId: string | undefined, values: string[]): void {
try {
window.localStorage.setItem(dismissedStorageKey(projectId), JSON.stringify(values.slice(-50)));
} catch {
// ignore storage failures
}
}
const disabledReasonCopy: Record<string, string> = {
"no-remote": "No `origin` remote configured.",
"no-upstream": "Branch has no upstream on origin.",
"merge-locked": "Push paused — a Fusion merge is in progress.",
};
export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: MergeAdvanceNoticeProps) {
const bannerRef = useRef<HTMLDivElement | null>(null);
const [events, setEvents] = useState<MergeAdvanceEvent[]>([]);
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);
const {
notice,
dismiss,
pull,
pullState,
conflictState,
setConflictState,
pushStatus,
pushState,
push,
clearPushError,
forceWithLease,
setForceWithLease,
} = useMergeAdvanceNotice({ projectId, apiBase });
useEffect(() => {
setDismissedShas(readDismissedShas(projectId));
}, [projectId]);
const fetchEvents = useCallback(async () => {
try {
const query = new URLSearchParams({ limit: "5" });
if (projectId) {
query.set("projectId", projectId);
}
const response = await api<MergeAdvanceEventsResponse>(`/tasks/merge-advance-events?${query.toString()}`);
setEvents(Array.isArray(response.events) ? response.events : []);
} catch {
setEvents([]);
}
}, [projectId]);
useEffect(() => {
void fetchEvents();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
events: {
"task:merged": () => {
void fetchEvents();
},
},
});
return () => {
unsubscribe();
};
}, [apiBase, fetchEvents, projectId]);
const notice = useMemo(() => events.find((event) => (
event.succeeded === true
&& event.userCheckout !== null
&& event.userCheckout.worktreePath.trim().length > 0
)), [events]);
if (!notice || dismissedShas.includes(notice.toSha) || !notice.userCheckout) {
if (!notice || !notice.userCheckout) {
return null;
}
const checkout = notice.userCheckout;
const localChangesPreserved = checkout.dirty || checkout.untrackedCount > 0;
const dismiss = () => {
const next = [...dismissedShas.filter((sha) => sha !== notice.toSha), notice.toSha].slice(-50);
setDismissedShas(next);
persistDismissedShas(projectId, next);
};
const pulling = pullState === "pending" || pullState === "stashing";
const pullError = typeof pullState === "object" ? pullState.error : null;
const dismissWithFocusGuard = () => {
const activeElement = document.activeElement;
const focusedInsideBanner = activeElement instanceof HTMLElement && bannerRef.current?.contains(activeElement);
dismiss();
if (focusedInsideBanner) {
document.body.focus();
}
if (focusedInsideBanner) document.body.focus();
};
const handlePull = async () => {
setPulling(true);
setPullError(null);
try {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const response = await api<SmartPullResponse>(`/git/smart-pull${query}`, {
method: "POST",
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;
}
dismissWithFocusGuard();
} catch (error: unknown) {
if (error instanceof ApiRequestError) {
setPullError(error.message || "Pull failed");
} else if (error instanceof Error && error.message) {
setPullError(error.message);
} else {
setPullError("Pull failed");
}
} finally {
setPulling(false);
const renderPushSection = () => {
if (!pushStatus || pushStatus.aheadCount <= 0) {
return null;
}
const disablePush = pushState === "pending" || pushStatus.canPush === false || pulling;
const pushLabel = forceWithLease ? "Push (force-with-lease)" : "Push to origin";
return (
<section className="merge-advance-notice__push">
<p className="merge-advance-notice__push-heading">
Push {pushStatus.integrationBranch} to origin ahead by {pushStatus.aheadCount} commit{pushStatus.aheadCount === 1 ? "" : "s"}.
</p>
<div className="merge-advance-notice__push-actions">
{pushState === "ok" ? (
<span>Pushed to origin/{pushStatus.integrationBranch} @ {shortSha(pushStatus.remoteSha)}.</span>
) : (
<button
type="button"
className={`btn btn-sm ${forceWithLease ? "btn-warning" : ""}`.trim()}
disabled={disablePush}
onClick={() => { void push(); }}
>
{pushState === "pending" ? "Pushing…" : pushLabel}
</button>
)}
{!pushStatus.canPush && pushStatus.disabledReason && pushStatus.disabledReason in disabledReasonCopy ? (
<span className="merge-advance-notice__push-error">{disabledReasonCopy[pushStatus.disabledReason]}</span>
) : null}
</div>
{typeof pushState === "object" && (pushState.outcome === "rejected-non-ff" || pushState.outcome === "sha-mismatch") ? (
<div className="merge-advance-notice__push-error" role="alert">
<span>{pushState.error}</span>{" "}
<button type="button" className="btn btn-sm" onClick={() => { void pull(); }}>Smart Pull</button>
</div>
) : null}
{typeof pushState === "object" && (pushState.outcome === "rejected-other" || pushState.outcome === "failed") ? (
<div className="merge-advance-notice__push-error" role="alert">
<span>{pushState.error}</span>
{pushState.stderr ? <pre>{pushState.stderr}</pre> : null}
<button type="button" className="btn btn-sm" onClick={clearPushError}>Dismiss</button>
</div>
) : null}
<details className="merge-advance-notice__push-advanced">
<summary>Advanced</summary>
<label>
<input
type="checkbox"
checked={forceWithLease}
onChange={(event) => setForceWithLease(event.target.checked)}
/>
{" "}Allow force-with-lease (use only when you know origin diverged intentionally)
</label>
</details>
</section>
);
};
return (
@@ -183,10 +120,11 @@ export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: Merg
{localChangesPreserved ? " (local changes will be auto-stashed and restored)" : ""}
{pullError ? <span className="merge-advance-notice__error" role="alert"> {pullError}</span> : null}
{pulling ? <span className="merge-advance-notice__hint"> Pulling</span> : null}
{renderPushSection()}
</div>
<div className="merge-advance-notice__actions">
{conflictState ? null : (
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
<button type="button" className="btn btn-sm" disabled={pulling} onClick={() => { void pull(); }}>
Pull
</button>
)}

View File

@@ -1,204 +1,125 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MergeAdvanceNotice from "../MergeAdvanceNotice";
import { ApiRequestError } from "../../api";
const mocked = vi.hoisted(() => ({
api: vi.fn(),
mergedHandler: undefined as (() => void) | undefined,
stashModalProps: [] as Array<Record<string, unknown>>,
useMergeAdvanceNotice: vi.fn(),
pull: vi.fn(),
push: vi.fn(),
dismiss: vi.fn(),
clearPushError: vi.fn(),
setForceWithLease: vi.fn(),
setConflictState: vi.fn(),
}));
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
vi.mock("../../hooks/useMergeAdvanceNotice", () => ({ useMergeAdvanceNotice: mocked.useMergeAdvanceNotice }));
vi.mock("../StashConflictModal", () => ({ default: () => null }));
function baseHookState(overrides: Record<string, unknown> = {}) {
return {
...actual,
api: mocked.api,
};
});
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, options: { events?: Record<string, () => void> }) => {
mocked.mergedHandler = options.events?.["task:merged"];
return vi.fn();
}),
}));
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: "release",
refName: "refs/heads/release",
toSha: "abcdef123456",
fromSha: "1234567",
advanceMode: "update-ref",
succeeded: true,
advancedAt: "2026-05-21T12:00:00.000Z",
userCheckout: {
worktreePath: "/repo",
dirty: false,
untrackedCount: 0,
notice: {
taskId: "FN-1",
integrationBranch: "trunk",
toSha: "abcdef123456",
userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 },
},
dismiss: mocked.dismiss,
pull: mocked.pull,
pullState: "idle",
conflictState: null,
setConflictState: mocked.setConflictState,
pushStatus: {
integrationBranch: "trunk",
aheadCount: 2,
remoteSha: "abcdef123456",
canPush: true,
disabledReason: undefined,
},
pushState: "idle",
push: mocked.push,
clearPushError: mocked.clearPushError,
forceWithLease: false,
setForceWithLease: mocked.setForceWithLease,
...overrides,
};
}
describe("MergeAdvanceNotice", () => {
describe("MergeAdvanceNotice push affordance", () => {
beforeEach(() => {
mocked.api.mockReset();
mocked.mergedHandler = undefined;
mocked.stashModalProps = [];
window.localStorage.clear();
});
afterEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
it("renders nothing when api returns no events", async () => {
mocked.api.mockResolvedValueOnce({ events: [] });
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
expect(container.firstChild).toBeNull();
it("renders push section only when aheadCount > 0", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText(/Push trunk to origin — ahead by 2 commits\./)).toBeInTheDocument();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, remoteSha: null } }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).toBeNull();
});
it("renders notice with dynamic branch name", async () => {
mocked.api.mockResolvedValueOnce({ events: [makeEvent()] });
render(<MergeAdvanceNotice projectId="proj-1" />);
expect(await screen.findByText(/release advanced to abcdef1\./)).toBeInTheDocument();
expect(screen.getByText(/Your checked-out copy at \/repo is behind\./)).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(screen.getByRole("button", { name: "Dismiss merge advance notice" })).toBeInTheDocument();
it("push button interactions and force-with-lease styling", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Push to origin" }));
expect(mocked.push).toHaveBeenCalledTimes(1);
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: "pending" }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pushing…" })).toBeDisabled();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ forceWithLease: true }));
rerender(<MergeAdvanceNotice projectId="p1" />);
const warningButton = screen.getByRole("button", { name: "Push (force-with-lease)" });
expect(warningButton.className).toContain("btn-warning");
});
it.each([
{ dirty: true, untrackedCount: 0 },
{ dirty: false, untrackedCount: 2 },
])("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 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",
}),
}));
["no-remote", "No `origin` remote configured."],
["no-upstream", "Branch has no upstream on origin."],
["merge-locked", "Push paused — a Fusion merge is in progress."],
])("shows disabled copy for %s", (reason, copy) => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 2, canPush: false, disabledReason: reason } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText(copy)).toBeInTheDocument();
});
it("clean smart-pull dismisses notice", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.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("hides section for not-ahead and not-a-git-repo", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, disabledReason: "not-ahead" } }));
const { rerender } = render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).toBeNull();
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushStatus: { integrationBranch: "trunk", aheadCount: 0, canPush: false, disabledReason: "not-a-git-repo" } }));
rerender(<MergeAdvanceNotice projectId="p1" />);
expect(screen.queryByText(/Push trunk to origin/)).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("advanced toggle updates forceWithLease", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState());
render(<MergeAdvanceNotice projectId="p1" />);
const checkbox = screen.getByRole("checkbox");
fireEvent.click(checkbox);
expect(mocked.setForceWithLease).toHaveBeenCalledWith(true);
});
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" }));
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("rejected-non-ff shows Smart Pull action", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: { error: "Remote diverged", outcome: "rejected-non-ff" } }));
render(<MergeAdvanceNotice projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Smart Pull" }));
expect(mocked.pull).toHaveBeenCalledTimes(1);
});
it("shows inline pull failure and keeps notice visible", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.mockRejectedValueOnce(new ApiRequestError("Merge conflict detected", 409));
render(<MergeAdvanceNotice projectId="proj-1" />);
const pullButton = await screen.findByRole("button", { name: "Pull" });
fireEvent.click(pullButton);
const error = await screen.findByRole("alert");
expect(error).toHaveTextContent("Merge conflict detected");
expect(screen.getByRole("status")).toBeInTheDocument();
it("rejected-other/failed shows stderr and dismiss", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pushState: { error: "Push failed", outcome: "rejected-other", stderr: "fatal" } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByText("fatal")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(mocked.clearPushError).toHaveBeenCalledTimes(1);
});
it("dismisses current sha and stays hidden when same advance is refetched", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent()] })
.mockResolvedValueOnce({ events: [makeEvent()] });
render(<MergeAdvanceNotice projectId="proj-1" />);
const dismissButton = await screen.findByRole("button", { name: /dismiss merge advance notice/i });
dismissButton.focus();
fireEvent.click(dismissButton);
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
expect(document.activeElement).toBe(document.body);
mocked.mergedHandler?.();
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("status")).toBeNull();
});
it("shows fresh advance after previous sha dismissed", async () => {
mocked.api
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "aaaaaaa111" })] })
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "bbbbbbb222" })] });
render(<MergeAdvanceNotice projectId="proj-1" />);
fireEvent.click(await screen.findByRole("button", { name: /dismiss merge advance notice/i }));
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
mocked.mergedHandler?.();
expect(await screen.findByText(/release advanced to bbbbbbb\./)).toBeInTheDocument();
});
it("renders nothing for failed advance or null checkout", async () => {
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ succeeded: false }), makeEvent({ userCheckout: null })] });
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
expect(container.firstChild).toBeNull();
it("push and pull state stay independent", () => {
mocked.useMergeAdvanceNotice.mockReturnValue(baseHookState({ pullState: "idle", pushState: { error: "locked", outcome: "merge-locked" } }));
render(<MergeAdvanceNotice projectId="p1" />);
expect(screen.getByRole("button", { name: "Pull" })).toBeEnabled();
});
});

View File

@@ -0,0 +1,107 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useMergeAdvanceNotice } from "../useMergeAdvanceNotice";
const mocked = vi.hoisted(() => ({ api: vi.fn(), mergedHandler: undefined as (() => void) | undefined }));
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return { ...actual, api: mocked.api };
});
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((_url: string, options: { events?: Record<string, () => void> }) => {
mocked.mergedHandler = options.events?.["task:merged"];
return vi.fn();
}),
}));
const eventPayload = { events: [{ taskId: "FN-1", integrationBranch: "trunk", refName: "refs/heads/trunk", toSha: "abcdef123456", fromSha: "123", advanceMode: "update-ref", succeeded: true, advancedAt: "2026", userCheckout: { worktreePath: "/repo", dirty: false, untrackedCount: 0 } }] };
const pushStatus = { integrationBranch: "trunk", branchSource: "settings" as const, hasOriginRemote: true, hasUpstream: true, localSha: "localsha", remoteSha: "remotesha", aheadCount: 2, behindCount: 0, mergeActive: false, canPush: true };
function setDefaultMocks() {
mocked.api.mockImplementation(async (path: string) => {
if (String(path).includes("push-status")) return pushStatus;
if (String(path).includes("merge-advance-events")) return eventPayload;
return { ok: true, outcome: "ok", localSha: "localsha", remoteSha: "localsha" };
});
}
describe("useMergeAdvanceNotice", () => {
beforeEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
mocked.mergedHandler = undefined;
window.localStorage.clear();
window.sessionStorage.clear();
setDefaultMocks();
});
it("mount fetches events and push status", async () => {
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(result.current.pushStatus?.localSha).toBe("localsha"));
});
it("task:merged SSE refetches push-status", async () => {
renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(mocked.api).toHaveBeenCalled());
const before = mocked.api.mock.calls.length;
act(() => mocked.mergedHandler?.());
await waitFor(() => expect(mocked.api.mock.calls.length).toBeGreaterThan(before));
});
it("push posts default forceWithLease false and expected localSha", async () => {
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(result.current.pushStatus).not.toBeNull());
await act(async () => { await result.current.push(); });
expect(mocked.api).toHaveBeenCalledWith("/projects/p1/merge-advance/push-origin", expect.objectContaining({ body: JSON.stringify({ forceWithLease: false, expectedLocalSha: "localsha" }) }));
});
it("setForceWithLease persists and updates push payload", async () => {
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(result.current.pushStatus).not.toBeNull());
act(() => result.current.setForceWithLease(true));
expect(window.sessionStorage.getItem("kb:p1:merge-advance-force-with-lease")).toBe("1");
await act(async () => { await result.current.push(); });
expect(mocked.api).toHaveBeenCalledWith("/projects/p1/merge-advance/push-origin", expect.objectContaining({ body: JSON.stringify({ forceWithLease: true, expectedLocalSha: "localsha" }) }));
});
it("successful push auto clears pushState", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
await act(async () => { await result.current.push(); });
expect(result.current.pushState).toBe("ok");
act(() => vi.advanceTimersByTime(2000));
expect(result.current.pushState).toBe("idle");
});
it("rejected-non-ff and merge-locked outcomes do not change pullState", async () => {
mocked.api.mockImplementationOnce(async () => eventPayload)
.mockImplementationOnce(async () => pushStatus)
.mockImplementationOnce(async () => ({ ok: false, outcome: "rejected-non-ff", message: "Remote diverged", stderrPreview: "[rejected]" }))
.mockImplementationOnce(async () => eventPayload)
.mockImplementationOnce(async () => pushStatus)
.mockImplementationOnce(async () => ({ ok: false, outcome: "merge-locked", message: "locked" }));
const first = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(first.result.current.pushStatus).not.toBeNull());
await act(async () => { await first.result.current.push(); });
expect(first.result.current.pushState).toMatchObject({ outcome: "rejected-non-ff" });
expect(first.result.current.pullState).toBe("idle");
const second = renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await waitFor(() => expect(second.result.current.pushStatus).not.toBeNull());
await act(async () => { await second.result.current.push(); });
expect(second.result.current.pushState).toMatchObject({ outcome: "merge-locked" });
expect(second.result.current.pullState).toBe("idle");
});
it("does not poll on a timer", async () => {
vi.useFakeTimers();
renderHook(() => useMergeAdvanceNotice({ projectId: "p1" }));
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const initialCalls = mocked.api.mock.calls.length;
act(() => vi.advanceTimersByTime(60_000));
expect(mocked.api.mock.calls.length).toBe(initialCalls);
});
});

View File

@@ -0,0 +1,257 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApiRequestError, api } from "../api";
import { subscribeSse } from "../sse-bus";
interface MergeAdvanceEvent {
taskId: string;
integrationBranch: string;
refName: string;
toSha: string;
fromSha: string | null;
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
succeeded: boolean;
advancedAt: string;
userCheckout: {
worktreePath: string;
dirty: boolean;
untrackedCount: number;
} | null;
}
interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
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[] };
type PushDisabledReason = "no-remote" | "no-upstream" | "not-ahead" | "merge-locked" | "not-a-git-repo";
export type PushOriginStatus = {
integrationBranch: string;
branchSource: "settings" | "origin-head" | "fallback";
hasOriginRemote: boolean;
hasUpstream: boolean;
localSha: string | null;
remoteSha: string | null;
aheadCount: number;
behindCount: number;
mergeActive: boolean;
canPush: boolean;
disabledReason?: PushDisabledReason;
};
type PushResponse = {
ok: boolean;
outcome: "ok" | "rejected-non-ff" | "rejected-other" | "no-upstream" | "no-remote" | "merge-locked" | "not-ahead" | "sha-mismatch" | "failed";
integrationBranch: string;
aheadCount: number;
localSha: string | null;
remoteSha: string | null;
forceWithLease: boolean;
stderrPreview?: string;
message?: string;
};
export type PushState = "idle" | "pending" | "ok" | {
error: string;
outcome: PushDisabledReason | "rejected-non-ff" | "rejected-other" | "sha-mismatch" | "failed";
stderr?: string;
};
function dismissedStorageKey(projectId?: string): string {
return `kb:merge-advance-notice-dismissed:${projectId ?? "default"}`;
}
function forceWithLeaseKey(projectId?: string): string {
return `kb:${projectId ?? "default"}:merge-advance-force-with-lease`;
}
function readDismissedShas(projectId?: string): string[] {
try {
const raw = window.localStorage.getItem(dismissedStorageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((value): value is string => typeof value === "string");
} catch {
return [];
}
}
function persistDismissedShas(projectId: string | undefined, values: string[]): void {
try {
window.localStorage.setItem(dismissedStorageKey(projectId), JSON.stringify(values.slice(-50)));
} catch {
// ignore storage failures
}
}
function readForceWithLease(projectId?: string): boolean {
try {
return window.sessionStorage.getItem(forceWithLeaseKey(projectId)) === "1";
} catch {
return false;
}
}
function persistForceWithLease(projectId: string | undefined, enabled: boolean): void {
try {
window.sessionStorage.setItem(forceWithLeaseKey(projectId), enabled ? "1" : "0");
} catch {
// ignore storage failures
}
}
export function useMergeAdvanceNotice({ projectId, apiBase = "/api" }: { projectId?: string; apiBase?: string }) {
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 [pushStatus, setPushStatus] = useState<PushOriginStatus | null>(null);
const [pushState, setPushState] = useState<PushState>("idle");
const [forceWithLease, setForceWithLeaseState] = useState<boolean>(() => readForceWithLease(projectId));
const pushOkTimerRef = useRef<number | null>(null);
useEffect(() => {
setDismissedShas(readDismissedShas(projectId));
setForceWithLeaseState(readForceWithLease(projectId));
}, [projectId]);
useEffect(() => () => {
if (pushOkTimerRef.current !== null) {
window.clearTimeout(pushOkTimerRef.current);
}
}, []);
const fetchEvents = useCallback(async () => {
try {
const query = new URLSearchParams({ limit: "5" });
if (projectId) query.set("projectId", projectId);
const response = await api<MergeAdvanceEventsResponse>(`/tasks/merge-advance-events?${query.toString()}`);
setEvents(Array.isArray(response.events) ? response.events : []);
} catch {
setEvents([]);
}
}, [projectId]);
const fetchPushStatus = useCallback(async () => {
if (!projectId) {
setPushStatus(null);
return;
}
try {
const status = await api<PushOriginStatus>(`/projects/${encodeURIComponent(projectId)}/merge-advance/push-status`);
setPushStatus(status);
} catch {
setPushStatus(null);
}
}, [projectId]);
useEffect(() => {
void fetchEvents();
void fetchPushStatus();
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
events: {
"task:merged": () => {
void fetchEvents();
void fetchPushStatus();
},
},
});
return () => unsubscribe();
}, [apiBase, fetchEvents, fetchPushStatus, projectId]);
const notice = useMemo(() => events.find((event) => (
event.succeeded === true
&& event.userCheckout !== null
&& event.userCheckout.worktreePath.trim().length > 0
)), [events]);
const dismiss = useCallback(() => {
if (!notice) return;
const next = [...dismissedShas.filter((sha) => sha !== notice.toSha), notice.toSha].slice(-50);
setDismissedShas(next);
persistDismissedShas(projectId, next);
}, [dismissedShas, notice, projectId]);
const pull = useCallback(async () => {
if (!notice?.userCheckout) return;
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}`, {
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 });
} else {
dismiss();
}
setPullState("idle");
await fetchPushStatus();
} catch (error: unknown) {
if (error instanceof ApiRequestError) {
setPullState({ error: error.message || "Pull failed" });
} else if (error instanceof Error && error.message) {
setPullState({ error: error.message });
} else {
setPullState({ error: "Pull failed" });
}
}
}, [dismiss, fetchPushStatus, notice, projectId]);
const setForceWithLease = useCallback((enabled: boolean) => {
setForceWithLeaseState(enabled);
persistForceWithLease(projectId, enabled);
}, [projectId]);
const clearPushError = useCallback(() => {
setPushState("idle");
}, []);
const push = useCallback(async () => {
if (!projectId) return;
setPushState("pending");
try {
const response = await api<PushResponse>(`/projects/${encodeURIComponent(projectId)}/merge-advance/push-origin`, {
method: "POST",
body: JSON.stringify({ forceWithLease, expectedLocalSha: pushStatus?.localSha }),
});
if (response.ok) {
setPushState("ok");
await fetchPushStatus();
if (pushOkTimerRef.current !== null) window.clearTimeout(pushOkTimerRef.current);
pushOkTimerRef.current = window.setTimeout(() => setPushState("idle"), 2000);
return;
}
setPushState({
error: response.message ?? response.outcome,
outcome: response.outcome,
stderr: response.stderrPreview,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Push failed";
setPushState({ error: message, outcome: "failed" });
}
}, [fetchPushStatus, forceWithLease, projectId, pushStatus?.localSha]);
return {
notice,
dismiss,
pull,
pullState,
conflictState,
setConflictState,
pushStatus,
pushState,
push,
clearPushError,
forceWithLease,
setForceWithLease,
};
}

View File

@@ -703,7 +703,7 @@ async function runSmokeChecks(page, pageUrl) {
&& initialLayout.navLeft >= 0
&& initialLayout.navRight <= initialLayout.viewportWidth + 1
&& initialLayout.navBottomGap <= 1
&& initialLayout.footerBottomGap <= 1
&& initialLayout.footerBottomGap <= 8
&& initialLayout.contentPaddingBottom >= initialLayout.navHeight + initialLayout.footerHeight - 1
&& initialLayout.tabMinWidth >= 36,
JSON.stringify(initialLayout),

View File

@@ -0,0 +1,170 @@
// @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().mockResolvedValue(undefined);
getRootDir(): string { return "/repo"; }
getFusionDir(): string { return "/repo/.fusion"; }
getSettings = vi.fn().mockResolvedValue({ integrationBranch: "trunk" });
getSettingsFast = vi.fn().mockResolvedValue({ integrationBranch: "trunk" });
getDatabase() { return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }) }; }
}
type Scripted = string | Error;
function gitScript(map: Record<string, Scripted>): string[] {
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;
}
function createApp(getActiveMergeTaskId: () => string | null = () => null, store = new MockStore()) {
const app = createServer(store as any, { selfHealingManager: { rootDir: "/repo", reconcileInReviewBranchRebind: vi.fn(), getActiveMergeTaskId } } as any);
return { app, store };
}
const baseMap = {
"rev-parse --git-dir": ".git\n",
"remote get-url origin": "git@github.com:org/repo.git\n",
"rev-parse --verify --quiet refs/remotes/origin/trunk": "ok\n",
"rev-parse refs/heads/trunk": "localsha\n",
"rev-parse refs/remotes/origin/trunk": "remotesha\n",
};
describe("merge-advance push-origin routes", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
it("GET push-status covers not-a-git-repo and no-remote/no-upstream/not-ahead", async () => {
let calls = gitScript({ "rev-parse --git-dir": new Error("not a git repository") });
let { app } = createApp();
let res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body.disabledReason).toBe("not-a-git-repo");
calls = gitScript({ "rev-parse --git-dir": ".git\n", "remote get-url origin": new Error("missing origin") });
({ app } = createApp());
res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body.disabledReason).toBe("no-remote");
calls = gitScript({ "rev-parse --git-dir": ".git\n", "remote get-url origin": "x", "rev-parse --verify --quiet refs/remotes/origin/trunk": new Error("missing upstream") });
({ app } = createApp());
res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body.disabledReason).toBe("no-upstream");
calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "1\t0\n" });
({ app } = createApp());
res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body).toMatchObject({ integrationBranch: "trunk", aheadCount: 0, disabledReason: "not-ahead", canPush: false });
expect(calls.some((c) => c.includes("main"))).toBe(false);
});
it("GET push-status reports ahead and merge-locked", async () => {
gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t3\n" });
let { app } = createApp();
let res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body).toMatchObject({ aheadCount: 3, canPush: true });
gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n" });
({ app } = createApp(() => "FN-1"));
res = await request(app, "GET", "/api/projects/default/merge-advance/push-status");
expect(res.body).toMatchObject({ disabledReason: "merge-locked", canPush: false });
});
it("POST happy path pushes without force and audits", async () => {
const calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t3\n", "push origin refs/heads/trunk:refs/heads/trunk": "", "rev-parse refs/remotes/origin/trunk": "localsha\n" });
const { app, store } = createApp();
const res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body).toMatchObject({ ok: true, outcome: "ok", remoteSha: "localsha" });
expect(calls.some((c) => /\b--force\b(?!-with-lease)/.test(c))).toBe(false);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin", metadata: expect.objectContaining({ forceWithLease: false }) }));
});
it("POST refusal paths return not-ahead/no-remote/no-upstream/sha-mismatch/merge-locked and never push", async () => {
let calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "1\t0\n" });
let { app, store } = createApp();
let res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body.outcome).toBe("not-ahead");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin" }));
expect(calls.some((c) => c.startsWith("push "))).toBe(false);
calls = gitScript({ "rev-parse --git-dir": ".git\n", "remote get-url origin": new Error("no remote") });
({ app, store } = createApp());
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body.outcome).toBe("no-remote");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin" }));
calls = gitScript({ "rev-parse --git-dir": ".git\n", "remote get-url origin": "x", "rev-parse --verify --quiet refs/remotes/origin/trunk": new Error("missing upstream") });
({ app, store } = createApp());
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body.outcome).toBe("no-upstream");
calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n" });
({ app, store } = createApp(() => "FN-LOCK"));
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body.outcome).toBe("merge-locked");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin" }));
calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n" });
({ app, store } = createApp());
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({ expectedLocalSha: "other" }), { "content-type": "application/json" });
expect(res.body.outcome).toBe("sha-mismatch");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin" }));
expect(calls.some((c) => c.startsWith("push "))).toBe(false);
});
it("POST handles non-fast-forward and force-with-lease stale info", async () => {
let calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n", "push origin refs/heads/trunk:refs/heads/trunk": Object.assign(new Error("rejected"), { stderr: "[rejected] non-fast-forward" }) });
let { app, store } = createApp();
let res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body).toMatchObject({ ok: false, outcome: "rejected-non-ff" });
expect(res.body.message).toBeDefined();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin" }));
calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n", "push --force-with-lease=refs/heads/trunk:localsha origin refs/heads/trunk:refs/heads/trunk": Object.assign(new Error("stale"), { stderr: "stale info" }) });
({ app, store } = createApp());
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({ forceWithLease: true }), { "content-type": "application/json" });
expect(calls.some((c) => c.includes("--force-with-lease=refs/heads/trunk:localsha"))).toBe(true);
expect(res.body).toMatchObject({ outcome: "rejected-non-ff" });
expect(res.body.message).toContain("Remote moved");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "push:origin", metadata: expect.objectContaining({ forceWithLease: true }) }));
});
it("POST force-with-lease success and TOCTOU merge lock", async () => {
let calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n", "push --force-with-lease=refs/heads/trunk:localsha origin refs/heads/trunk:refs/heads/trunk": "", "rev-parse refs/remotes/origin/trunk": "localsha\n" });
let { app } = createApp();
let res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({ forceWithLease: true }), { "content-type": "application/json" });
expect(res.body).toMatchObject({ ok: true, outcome: "ok" });
expect(calls.some((c) => c.startsWith("push --force-with-lease="))).toBe(true);
let mergeCheckCount = 0;
calls = gitScript({ ...baseMap, "rev-list --left-right --count refs/remotes/origin/trunk...refs/heads/trunk": "0\t2\n" });
({ app } = createApp(() => {
mergeCheckCount += 1;
return mergeCheckCount >= 2 ? "FN-TOCTOU" : null;
}));
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({}), { "content-type": "application/json" });
expect(res.body).toMatchObject({ ok: false, outcome: "merge-locked" });
expect(calls.some((c) => c.startsWith("push "))).toBe(false);
});
it("POST validates request body types", async () => {
const { app } = createApp();
let res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({ forceWithLease: "yes" }), { "content-type": "application/json" });
expect(res.status).toBe(400);
res = await request(app, "POST", "/api/projects/default/merge-advance/push-origin", JSON.stringify({ expectedLocalSha: 123 }), { "content-type": "application/json" });
expect(res.status).toBe(400);
});
});

View File

@@ -352,6 +352,7 @@ import {
getExemptToolNames as engineGetExemptToolNames,
promptWithFallback as enginePromptWithFallback,
reloadExemptTools as engineReloadExemptTools,
resolveIntegrationBranch,
} from "@fusion/engine";
// Test-injectable override; defaults to the statically imported engine binding.
@@ -1002,6 +1003,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
const aiSessionStore = options?.aiSessionStore;
const isGitRepo = async (cwd: string): Promise<boolean> => {
try {
await runGitCommand(["rev-parse", "--git-dir"], cwd, 5_000);
return true;
} catch {
return false;
}
};
// Registrar mount order is precedence-sensitive and mirrors
// .fusion/tasks/FN-2541/route-order-blueprint.md.
// Keep this sequence stable unless route-matching invariants are re-audited.
@@ -1019,6 +1029,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
validateOptionalModelField,
normalizeModelSelectionPair,
runGitCommand,
isGitRepo,
resolveIntegrationBranch: (rootDir, settings) => resolveIntegrationBranch(rootDir, settings as { integrationBranch?: string; baseBranch?: unknown } | null | undefined),
trimTaskDetailActivityLog,
triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args),
resolveSelfHealingManager: (...args) => resolveSelfHealingManager(...args),
@@ -1115,6 +1127,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return {
rootDir: engine.getWorkingDirectory(),
reconcileInReviewBranchRebind: selfHealing.reconcileInReviewBranchRebind.bind(selfHealing),
getActiveMergeTaskId: selfHealing.getActiveMergeTaskId.bind(selfHealing),
};
}
}

View File

@@ -70,6 +70,155 @@ interface MergeAdvanceEventsResponse {
events: MergeAdvanceEvent[];
}
type PushDisabledReason = "no-remote" | "no-upstream" | "not-ahead" | "merge-locked" | "not-a-git-repo";
interface PushOriginStatus {
integrationBranch: string;
branchSource: "settings" | "origin-head" | "fallback";
hasOriginRemote: boolean;
hasUpstream: boolean;
localSha: string | null;
remoteSha: string | null;
aheadCount: number;
behindCount: number;
mergeActive: boolean;
canPush: boolean;
disabledReason?: PushDisabledReason;
}
function parseRevListCounts(raw: string): { behindCount: number; aheadCount: number } {
const [behindRaw, aheadRaw] = raw.trim().split(/\s+/);
const behindCount = Number.parseInt(behindRaw ?? "0", 10);
const aheadCount = Number.parseInt(aheadRaw ?? "0", 10);
return {
behindCount: Number.isFinite(behindCount) ? behindCount : 0,
aheadCount: Number.isFinite(aheadCount) ? aheadCount : 0,
};
}
function truncateStderr(stderr: string | undefined, max = 4_096): string | undefined {
if (!stderr) return undefined;
return stderr.length <= max ? stderr : stderr.slice(0, max);
}
function parsePushOriginBody(body: unknown): { forceWithLease: boolean; expectedLocalSha?: string } {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return { forceWithLease: false };
}
const candidate = body as { forceWithLease?: unknown; expectedLocalSha?: unknown };
if (candidate.forceWithLease !== undefined && typeof candidate.forceWithLease !== "boolean") {
throw badRequest("forceWithLease must be boolean");
}
if (candidate.expectedLocalSha !== undefined && typeof candidate.expectedLocalSha !== "string") {
throw badRequest("expectedLocalSha must be string");
}
return {
forceWithLease: candidate.forceWithLease === true,
expectedLocalSha: candidate.expectedLocalSha,
};
}
function parsePushOutcome(stderr: string): { outcome: "rejected-non-ff" | "rejected-other"; message?: string } {
const lowered = stderr.toLowerCase();
if (lowered.includes("stale info")) {
return { outcome: "rejected-non-ff", message: "Remote moved since you previewed — fetch and retry." };
}
if (lowered.includes("non-fast-forward") || lowered.includes("fetch first") || lowered.includes("[rejected]")) {
return { outcome: "rejected-non-ff", message: "Remote diverged — pull or fetch first." };
}
return { outcome: "rejected-other" };
}
function parseBranchSourceFromSettings(settings: unknown): "settings" | "fallback" {
if (!settings || typeof settings !== "object") return "fallback";
const integrationBranch = (settings as { integrationBranch?: unknown }).integrationBranch;
const baseBranch = (settings as { baseBranch?: unknown }).baseBranch;
if (typeof integrationBranch === "string" && integrationBranch.trim().length > 0) return "settings";
if (typeof baseBranch === "string" && baseBranch.trim().length > 0) return "settings";
return "fallback";
}
function parseGitErrorStderr(error: unknown): string {
if (error instanceof Error && typeof (error as Error & { stderr?: unknown }).stderr === "string") {
return String((error as Error & { stderr?: string }).stderr ?? "");
}
if (error instanceof Error) return error.message;
return String(error ?? "");
}
function parseDisabledOutcome(reason?: PushDisabledReason): "no-upstream" | "no-remote" | "merge-locked" | "not-ahead" | "failed" {
if (!reason) return "failed";
if (reason === "no-upstream" || reason === "no-remote" || reason === "merge-locked" || reason === "not-ahead") {
return reason;
}
return "failed";
}
async function buildPushOriginStatus(input: {
scopedStore: TaskStore;
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
isGitRepo: (cwd: string) => Promise<boolean>;
resolveIntegrationBranch: (rootDir: string, settings: unknown) => Promise<string>;
resolveSelfHealingManager: (scopedStore: TaskStore) => { getActiveMergeTaskId: () => string | null } | undefined;
}): Promise<PushOriginStatus> {
const rootDir = input.scopedStore.getRootDir();
const settings = await input.scopedStore.getSettingsFast();
const integrationBranch = await input.resolveIntegrationBranch(rootDir, settings);
const baseStatus: PushOriginStatus = {
integrationBranch,
branchSource: parseBranchSourceFromSettings(settings),
hasOriginRemote: false,
hasUpstream: false,
localSha: null,
remoteSha: null,
aheadCount: 0,
behindCount: 0,
mergeActive: false,
canPush: false,
};
if (!(await input.isGitRepo(rootDir))) {
return { ...baseStatus, disabledReason: "not-a-git-repo" };
}
const selfHealing = input.resolveSelfHealingManager(input.scopedStore);
const mergeActive = Boolean(selfHealing?.getActiveMergeTaskId?.());
try {
await input.runGitCommand(["remote", "get-url", "origin"], rootDir, 15_000);
} catch {
return { ...baseStatus, mergeActive, disabledReason: "no-remote" };
}
try {
await input.runGitCommand(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${integrationBranch}`], rootDir, 15_000);
} catch {
return { ...baseStatus, hasOriginRemote: true, mergeActive, disabledReason: "no-upstream" };
}
const [localSha, remoteSha, counts] = await Promise.all([
input.runGitCommand(["rev-parse", `refs/heads/${integrationBranch}`], rootDir, 15_000),
input.runGitCommand(["rev-parse", `refs/remotes/origin/${integrationBranch}`], rootDir, 15_000),
input.runGitCommand(["rev-list", "--left-right", "--count", `refs/remotes/origin/${integrationBranch}...refs/heads/${integrationBranch}`], rootDir, 15_000),
]);
const { behindCount, aheadCount } = parseRevListCounts(counts);
const canPush = aheadCount > 0 && !mergeActive;
const disabledReason = mergeActive ? "merge-locked" : aheadCount > 0 ? undefined : "not-ahead";
return {
...baseStatus,
hasOriginRemote: true,
hasUpstream: true,
localSha: localSha.trim() || null,
remoteSha: remoteSha.trim() || null,
behindCount,
aheadCount,
mergeActive,
canPush,
disabledReason,
};
}
function parseMergeAdvanceLimit(rawLimit: unknown): number {
if (rawLimit === undefined) {
return 20;
@@ -336,11 +485,14 @@ interface TaskWorkflowRouteDeps {
validateOptionalModelField: (value: unknown, name: string) => string | undefined;
normalizeModelSelectionPair: (provider: string | undefined, modelId: string | undefined) => { provider?: string | null; modelId?: string | null };
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
isGitRepo: (cwd: string) => Promise<boolean>;
resolveIntegrationBranch: (rootDir: string, settings: unknown) => Promise<string>;
trimTaskDetailActivityLog: (task: TaskDetail) => TaskDetail;
triggerCommentWakeForAssignedAgent: (scopedStore: TaskStore, task: Task, wake: { triggeringCommentType: "steering" | "task" | "pr"; triggeringCommentIds?: string[]; triggerDetail: string }) => Promise<void>;
resolveSelfHealingManager: (scopedStore: TaskStore) => {
rootDir: string;
reconcileInReviewBranchRebind: (opts?: { includeTaskIds?: Set<string> }) => Promise<import("@fusion/engine").RebindResult>;
getActiveMergeTaskId: () => string | null;
} | undefined;
}
@@ -353,6 +505,8 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
validateOptionalModelField,
normalizeModelSelectionPair,
runGitCommand,
isGitRepo,
resolveIntegrationBranch,
trimTaskDetailActivityLog,
triggerCommentWakeForAssignedAgent,
resolveSelfHealingManager: _resolveSelfHealingManager,
@@ -419,6 +573,101 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
router.get("/projects/:projectId/merge-advance/push-status", async (req, res) => {
const { store: scopedStore } = await getProjectContext(req);
const status = await buildPushOriginStatus({
scopedStore,
runGitCommand,
isGitRepo,
resolveIntegrationBranch,
resolveSelfHealingManager: _resolveSelfHealingManager,
});
res.json(status);
});
router.post("/projects/:projectId/merge-advance/push-origin", async (req, res) => {
const startedAt = Date.now();
const { store: scopedStore } = await getProjectContext(req);
const body = parsePushOriginBody(req.body);
const status = await buildPushOriginStatus({
scopedStore,
runGitCommand,
isGitRepo,
resolveIntegrationBranch,
resolveSelfHealingManager: _resolveSelfHealingManager,
});
const recordRunAuditEvent = (scopedStore as TaskStore & { recordRunAuditEvent?: (input: RunAuditEventInput) => Promise<void> }).recordRunAuditEvent;
const emit = async (outcome: string, extra?: Record<string, unknown>) => {
if (typeof recordRunAuditEvent !== "function") return;
await recordRunAuditEvent({
domain: "git",
mutationType: "push:origin",
target: `origin/${status.integrationBranch}`,
taskId: "FN-5359",
agentId: "user",
runId: `dashboard-push-origin-${Date.now()}`,
metadata: {
integrationBranch: status.integrationBranch,
remote: "origin",
localSha: status.localSha,
remoteSha: status.remoteSha,
aheadCount: status.aheadCount,
behindCount: status.behindCount,
forceWithLease: body.forceWithLease,
outcome,
durationMs: Date.now() - startedAt,
...extra,
},
});
};
if (!status.canPush) {
const outcome = parseDisabledOutcome(status.disabledReason);
await emit(outcome);
return res.json({ ok: false, outcome, integrationBranch: status.integrationBranch, aheadCount: status.aheadCount, localSha: status.localSha, remoteSha: status.remoteSha, forceWithLease: body.forceWithLease });
}
if (body.expectedLocalSha && body.expectedLocalSha !== status.localSha) {
await emit("failed", { outcome: "sha-mismatch" });
return res.json({ ok: false, outcome: "sha-mismatch", integrationBranch: status.integrationBranch, aheadCount: status.aheadCount, localSha: status.localSha, remoteSha: status.remoteSha, forceWithLease: body.forceWithLease });
}
const mergeCheck = _resolveSelfHealingManager(scopedStore);
if (mergeCheck?.getActiveMergeTaskId()) {
await emit("merge-locked");
return res.json({ ok: false, outcome: "merge-locked", integrationBranch: status.integrationBranch, aheadCount: status.aheadCount, localSha: status.localSha, remoteSha: status.remoteSha, forceWithLease: body.forceWithLease });
}
const refspec = `refs/heads/${status.integrationBranch}:refs/heads/${status.integrationBranch}`;
const args = ["push", "origin", refspec];
if (body.forceWithLease && status.localSha) {
args.splice(1, 0, `--force-with-lease=refs/heads/${status.integrationBranch}:${status.localSha}`);
}
try {
await runGitCommand(args, scopedStore.getRootDir(), 60_000);
const remoteSha = (await runGitCommand(["rev-parse", `refs/remotes/origin/${status.integrationBranch}`], scopedStore.getRootDir(), 15_000)).trim() || status.remoteSha;
await emit("ok", { remoteSha });
return res.json({ ok: true, outcome: "ok", integrationBranch: status.integrationBranch, aheadCount: status.aheadCount, localSha: status.localSha, remoteSha, forceWithLease: body.forceWithLease });
} catch (error: unknown) {
const stderr = parseGitErrorStderr(error);
const parsed = parsePushOutcome(stderr);
await emit(parsed.outcome, { stderrPreview: truncateStderr(stderr) });
return res.json({
ok: false,
outcome: parsed.outcome,
message: parsed.message,
stderrPreview: truncateStderr(stderr),
integrationBranch: status.integrationBranch,
aheadCount: status.aheadCount,
localSha: status.localSha,
remoteSha: status.remoteSha,
forceWithLease: body.forceWithLease,
});
}
});
router.get("/tasks", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);

View File

@@ -258,6 +258,7 @@ export interface ServerOptions {
selfHealingManager?: {
rootDir: string;
reconcileInReviewBranchRebind: (opts?: { includeTaskIds?: Set<string> }) => Promise<import("@fusion/engine").RebindResult>;
getActiveMergeTaskId: () => string | null;
};
/** Optional PluginStore for plugin management routes */
pluginStore?: import("@fusion/core").PluginStore;
@@ -592,6 +593,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
selfHealingManager: {
rootDir: engine.getWorkingDirectory(),
reconcileInReviewBranchRebind: selfHealing.reconcileInReviewBranchRebind.bind(selfHealing),
getActiveMergeTaskId: selfHealing.getActiveMergeTaskId.bind(selfHealing),
},
};
}

View File

@@ -15,14 +15,14 @@ const qualityAppTests = [
"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.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TestModeBanner,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,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
"app/utils/**/*.test.{ts,tsx}",
];
const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,pr-routes-auto-merge,pr-routes.contract,project-routes,project-store-resolver,register-git-github.pr-options-preflight-metadata,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-merge-advance-push-origin,routes-nodes,routes-nodes-sync-contract,routes-secrets-sync,routes-settings,routes-task-commit-associations,routes-tasks,routes-tasks-deterministic-dedup,routes-tasks-duplicate-check,routes-tasks-explicit-duplicate-marker,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket,recover-branch-binding-route}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,register-diagnostics-routes,stash-recovery-routes}.test.ts",
];

View File

@@ -92,7 +92,7 @@ describe("run-audit provisioning mutation types", () => {
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"];
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" } });
}

View File

@@ -7914,6 +7914,15 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "task:reclaim-self-owned-branch-conflict-no-action" }));
});
it("returns active merge task id via public accessor", () => {
const manager = new SelfHealingManager(createMockStore(), {
rootDir: "/tmp/test-project",
getActiveMergeTaskId: () => "FN-MERGE",
});
expect(manager.getActiveMergeTaskId()).toBe("FN-MERGE");
});
it("emits finalize-no-op-review no-action when unproven fallback fails triple proof", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({ autoMerge: true, globalPause: false, enginePaused: false } as any),

View File

@@ -899,7 +899,7 @@ export function packageNamesForFiles(rootDir: string, files: string[]): string[]
*
* @internal Exported for testing only.
*/
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, branch: string): string | null {
export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, _branch: string): string | null {
// 1. Read and parse pnpm-workspace.yaml
const workspacePath = join(rootDir, "pnpm-workspace.yaml");
let workspaceContent: string;

View File

@@ -273,6 +273,24 @@ export type GitMutationType =
* ```
*/
| "pull:fast-forward"
/**
* Metadata shape:
* ```ts
* {
* integrationBranch: string;
* remote: "origin";
* localSha: string;
* remoteSha: string | null;
* aheadCount: number;
* behindCount: number;
* forceWithLease: boolean;
* outcome: "ok" | "rejected-non-ff" | "rejected-other" | "no-upstream" | "no-remote" | "merge-locked" | "failed";
* stderrPreview?: string;
* durationMs: number;
* }
* ```
*/
| "push:origin"
/**
* Metadata shape:
* ```ts

View File

@@ -555,6 +555,10 @@ export class SelfHealingManager {
private options: SelfHealingOptions,
) {}
public getActiveMergeTaskId(): string | null {
return this.options.getActiveMergeTaskId?.() ?? null;
}
private emitTaskMerged(task: Task | undefined | null, overrides: Partial<MergeResult> = {}): void {
if (!task) return;
this.store.emit("task:merged", {