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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
257
packages/dashboard/app/hooks/useMergeAdvanceNotice.ts
Normal file
257
packages/dashboard/app/hooks/useMergeAdvanceNotice.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user