diff --git a/src/mobile/views/GitView.tsx b/src/mobile/views/GitView.tsx index 11b206bbd..fb8bfadbf 100644 --- a/src/mobile/views/GitView.tsx +++ b/src/mobile/views/GitView.tsx @@ -269,7 +269,6 @@ export function GitView(props: {
({ const setWorktreeStatusMock = vi.hoisted(() => vi.fn<(worktreePath: string, status: GitStatusResult) => void>(), ); +const setStatusMock = vi.hoisted(() => + vi.fn<(projectId: string, status: GitStatusResult) => void>(), +); const toastMock = vi.hoisted(() => ({ danger: vi.fn<(message: string, options?: Record) => void>(), @@ -40,7 +43,7 @@ vi.mock("@/renderer/diagnostics/sentry", () => ({ vi.mock("@/renderer/state/gitStore", () => ({ useGitStore: { - getState: () => ({ setWorktreeStatus: setWorktreeStatusMock }), + getState: () => ({ setStatus: setStatusMock, setWorktreeStatus: setWorktreeStatusMock }), }, })); @@ -166,6 +169,18 @@ describe("gitCommandRunner", () => { }); }); + it("refreshes the project status after pulling the merged base", async () => { + const refreshedStatus = { ...cleanMainStatus, headSha: "updated" }; + bridgeMock.getGitStatus + .mockResolvedValueOnce({ ...cleanMainStatus, behind: 1 }) + .mockResolvedValueOnce(refreshedStatus); + bridgeMock.gitPull.mockResolvedValueOnce(undefined); + + await pullMergedPrBaseIfPossible(projectLocation, "main", "project-1"); + + expect(setStatusMock).toHaveBeenCalledWith("project-1", refreshedStatus); + }); + it.each<[string, Partial]>([ ["another branch", { branch: "feature" }], ["local commits", { ahead: 1 }], diff --git a/src/renderer/actions/gitCommandRunner.ts b/src/renderer/actions/gitCommandRunner.ts index de0ab0409..a702afe05 100644 --- a/src/renderer/actions/gitCommandRunner.ts +++ b/src/renderer/actions/gitCommandRunner.ts @@ -115,6 +115,7 @@ export async function runGitPullFromSource( export async function pullMergedPrBaseIfPossible( projectLocation: ProjectLocation, baseBranch: string, + projectId?: string, ): Promise { try { const status = await readBridge().getGitStatus({ projectLocation, detail: "summary" }); @@ -139,6 +140,17 @@ export async function pullMergedPrBaseIfPossible( }); } catch (error) { console.warn("[git] post-merge pull skipped", error); + return; + } + if (!projectId) return; + try { + const refreshedStatus = await readBridge().getGitStatus({ + projectLocation, + detail: "summary", + }); + useGitStore.getState().setStatus(projectId, refreshedStatus); + } catch (error) { + console.warn("[git] post-merge status refresh failed", error); } } diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index e05660e51..8d601976b 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -456,11 +456,26 @@ export function ThreadDraftComposerArea(props: { const projectStatus = useGitStore((s) => s.statuses[props.project.id]); const hasUncommittedChanges = !!projectStatus && projectStatus.staged.length + projectStatus.unstaged.length > 0; - const worktreeBase = branchSelection?.baseBranch ?? branchSelection?.branch ?? props.gitBranch; + const trackingWorktreeBase = + projectStatus && + props.gitBranch && + projectStatus.branch === props.gitBranch && + projectStatus.behind > 0 && + projectStatus.ahead === 0 && + projectStatus.tracking + ? projectStatus.tracking + : undefined; + const defaultWorktreeBase = trackingWorktreeBase ?? props.gitBranch; + const selectedWorktreeBase = branchSelection?.baseBranch ?? branchSelection?.branch; + const worktreeBase = selectedWorktreeBase ?? defaultWorktreeBase; // The worktree dropdown's "+ changes" choice is offered whenever the current // (dirty) checkout would be the worktree's fork point — independent of whether // worktree mode is already on, since selecting it also turns worktree mode on. - const canBringChanges = hasUncommittedChanges && worktreeBase === props.gitBranch; + const canBringChanges = + hasUncommittedChanges && + (selectedWorktreeBase === undefined || + selectedWorktreeBase === props.gitBranch || + selectedWorktreeBase === trackingWorktreeBase); // Transferring is only meaningful once worktree mode is actually on. const canTransferUncommitted = props.worktreeMode && canBringChanges; const shouldTransferUncommitted = @@ -474,7 +489,7 @@ export function ThreadDraftComposerArea(props: { : "new"; function selectNewWorktree(overrides?: Partial) { - const base = worktreeBase ?? props.gitBranch ?? ""; + const base = overrides?.baseBranch ?? worktreeBase ?? props.gitBranch ?? ""; setBranchSelection({ branch: base, baseBranch: base, isWorktree: true, ...overrides }); } @@ -488,7 +503,11 @@ export function ThreadDraftComposerArea(props: { // Keep an existing worktree selection (e.g. a worktreePath from "New thread // in worktree") intact rather than rebuilding it into a brand-new branch. if (branchSelection?.worktreePath) return; - selectNewWorktree({ transferUncommitted: mode === "new-with-changes" }); + const baseBranch = mode === "new-with-changes" ? props.gitBranch : defaultWorktreeBase; + selectNewWorktree({ + ...(baseBranch ? { baseBranch } : {}), + transferUncommitted: mode === "new-with-changes", + }); } const computerUseScope = @@ -680,8 +699,8 @@ export function ThreadDraftComposerArea(props: { } : { worktreeBranch: generateWorktreeBranch(), - ...(branchSelection?.baseBranch - ? { worktreeBaseBranch: branchSelection.baseBranch } + ...((branchSelection?.baseBranch ?? trackingWorktreeBase) + ? { worktreeBaseBranch: branchSelection?.baseBranch ?? trackingWorktreeBase } : {}), worktreeIsNewBranch: true, ...(shouldTransferUncommitted ? { worktreeTransferUncommitted: true } : {}), @@ -740,7 +759,7 @@ export function ThreadDraftComposerArea(props: { async function runExperiment(allSegments: PromptSegment[], fallbackPrompt = "") { const input = resolveExperimentInput(allSegments, fallbackPrompt); - const baseBranch = experimentBaseBranch ?? props.gitBranch; + const baseBranch = experimentBaseBranch ?? defaultWorktreeBase; if (!input || !baseBranch || experimentCandidates.length < 2 || isSubmitting) return; setIsSubmitting(true); const experimentId = await launchExperiment({ @@ -1124,7 +1143,7 @@ export function ThreadDraftComposerArea(props: { setExperimentBaseBranch( branchSelection?.baseBranch ?? branchSelection?.branch ?? - props.gitBranch ?? + defaultWorktreeBase ?? null, ); } else { @@ -1159,14 +1178,18 @@ export function ThreadDraftComposerArea(props: { currentBranch={props.gitBranch} value={ experimentMode - ? (experimentBaseBranch ?? props.gitBranch) - : (branchSelection?.branch ?? props.gitBranch) + ? (experimentBaseBranch ?? defaultWorktreeBase ?? props.gitBranch) + : worktreeSelected + ? (worktreeBase ?? props.gitBranch) + : (branchSelection?.branch ?? props.gitBranch) } isWorktree={experimentMode ? true : branchSelection?.isWorktree} baseBranch={ experimentMode - ? (experimentBaseBranch ?? props.gitBranch) - : branchSelection?.baseBranch + ? (experimentBaseBranch ?? defaultWorktreeBase ?? props.gitBranch) + : worktreeSelected + ? worktreeBase + : branchSelection?.baseBranch } worktreeMode={experimentMode || props.worktreeMode} {...(!experimentMode ? { onWorktreeModeChange: props.onWorktreeModeChange } : {})} diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index 80ac2bf77..ef3ef9bf9 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -10,8 +10,9 @@ import { useGitStore } from "@/renderer/state/gitStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; -const { composerSpy } = vi.hoisted(() => ({ +const { composerSpy, launchExperimentMock } = vi.hoisted(() => ({ composerSpy: vi.fn<(props: unknown) => void>(), + launchExperimentMock: vi.fn<(input: unknown) => Promise>(), })); vi.mock("./ThreadComposer", () => ({ @@ -34,6 +35,10 @@ vi.mock("./ThreadComposer", () => ({ }, })); +vi.mock("@/renderer/actions/experimentActions", () => ({ + launchExperiment: launchExperimentMock, +})); + import "@/renderer/components/providers/bootstrap"; import { ThreadDraftView } from "./ThreadDraftView"; @@ -409,6 +414,8 @@ const singleEffortMultiContextCursorStatus: AgentStatus = { describe("ThreadDraftView", () => { beforeEach(() => { composerSpy.mockClear(); + launchExperimentMock.mockReset(); + launchExperimentMock.mockResolvedValue("experiment-1"); delete (window as unknown as { poracode?: unknown }).poracode; useAgentStatusesStore.setState({ agentStatuses: [], @@ -524,6 +531,128 @@ describe("ThreadDraftView", () => { expect(container.querySelector("[data-draft-worktree-row]")).toBeInTheDocument(); }); + it("defaults a new worktree to the tracking branch when the local branch is behind", () => { + const onStart = vi.fn<(input: unknown) => void>(); + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 4, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + }, + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeBaseBranch: "origin/main", + worktreeIsNewBranch: true, + }), + ); + }); + + it("keeps the uncommitted-changes worktree option after selecting a tracking branch", async () => { + const onStart = vi.fn<(input: unknown) => void>(); + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 4, + staged: [], + unstaged: [ + { path: "src/file.ts", status: "M", staged: false, insertions: 1, deletions: 0 }, + ], + totalInsertions: 1, + totalDeletions: 0, + }, + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + fireEvent.click(await screen.findByRole("option", { name: /Run in a separate worktree/ })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + + fireEvent.click(screen.getByRole("button", { name: "Worktree mode" })); + fireEvent.click(await screen.findByRole("option", { name: /Worktree \+ changes/ })); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("main"); + + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + expect(onStart).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeBaseBranch: "main", + worktreeIsNewBranch: true, + worktreeTransferUncommitted: true, + }), + ); + }); + + it("defaults experiment worktrees to the tracking branch when the local branch is behind", async () => { + useGitStore.setState({ + statuses: { + [project.id]: { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 4, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + }, + }, + }); + render( {}} />); + + const initialComposer = composerSpy.mock.lastCall?.[0] as { + afterControls: ReactElement<{ experiment?: { onToggle: (enabled: boolean) => void } }>; + }; + act(() => initialComposer.afterControls.props.experiment?.onToggle(true)); + expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main"); + + for (let index = 0; index < 2; index += 1) { + const composer = composerSpy.mock.lastCall?.[0] as { fixedContent: ReactNode }; + const targets = findElementByTypeName(composer.fixedContent, "ExperimentDraftTargets"); + if (!targets) throw new Error("Expected experiment targets"); + act(targets.props.onAdd as () => void); + } + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + + await waitFor(() => + expect(launchExperimentMock).toHaveBeenCalledWith( + expect.objectContaining({ baseBranch: "origin/main" }), + ), + ); + }); + it("reserves the worktree control row for Home drafts", () => { const { container } = render( {}} />, diff --git a/src/renderer/hooks/usePrWriteActions.test.tsx b/src/renderer/hooks/usePrWriteActions.test.tsx new file mode 100644 index 000000000..5a4c4e6aa --- /dev/null +++ b/src/renderer/hooks/usePrWriteActions.test.tsx @@ -0,0 +1,69 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrData } from "@/shared/contracts"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { usePrWriteActions } from "./usePrWriteActions"; + +const { bridgeMock, setPrMergeMethodMock, syncMergedPrBaseMock } = vi.hoisted(() => ({ + bridgeMock: { + ghMergePr: vi.fn<() => Promise>(), + }, + setPrMergeMethodMock: vi.fn<(method: string) => void>(), + syncMergedPrBaseMock: vi.fn<(projectId: string, pr: PrData) => Promise>(), +})); + +vi.mock("@heroui/react", () => ({ + toast: { danger: vi.fn<(message: string) => void>() }, +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridgeMock, +})); + +vi.mock("@/renderer/state/sharedSettingsStore", () => ({ + useSharedSettings: { + getState: () => ({ setPrMergeMethod: setPrMergeMethodMock }), + }, +})); + +vi.mock("@/renderer/state/prMergeBaseSync", () => ({ + syncMergedPrBase: (projectId: string, pr: PrData) => syncMergedPrBaseMock(projectId, pr), +})); + +const openPr: PrData = { + number: 7, + state: "open", + title: "Land the thing", + url: "https://github.com/owner/repo/pull/7", + baseBranch: "main", + isDraft: false, + checksStatus: "SUCCESS", + updatedAt: "2026-07-20T00:00:00.000Z", +}; + +describe("usePrWriteActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + bridgeMock.ghMergePr.mockResolvedValue(undefined); + syncMergedPrBaseMock.mockResolvedValue(undefined); + useGitStore.setState({ prData: { "__branchname:p1:feature": openPr } }); + }); + + it("syncs the base checkout after a direct merge without a matching worktree thread", async () => { + const onRefresh = vi.fn<() => void>(); + const { result } = renderHook(() => + usePrWriteActions({ + projectLocation: { kind: "posix", path: "/repo" }, + projectId: "p1", + prKey: "__branchname:p1:feature", + onRefresh, + }), + ); + + await act(() => result.current.handleMergePr("squash")); + + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", { ...openPr, state: "merged" }); + expect(useGitStore.getState().prData["__branchname:p1:feature"]?.state).toBe("merged"); + expect(onRefresh).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/renderer/hooks/usePrWriteActions.ts b/src/renderer/hooks/usePrWriteActions.ts index de8e0413a..2c0c5e665 100644 --- a/src/renderer/hooks/usePrWriteActions.ts +++ b/src/renderer/hooks/usePrWriteActions.ts @@ -8,19 +8,18 @@ import { i18n } from "@/renderer/i18n/i18n"; import { useGitStore } from "@/renderer/state/gitStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { refreshSinglePr } from "@/renderer/state/gitRefresh"; -import { pullMergedPrBaseIfPossible } from "@/renderer/actions/gitCommandRunner"; +import { syncMergedPrBase } from "@/renderer/state/prMergeBaseSync"; const ADMIN_BYPASS_RX = /--admin|base branch policy|not mergeable/i; export interface UsePrWriteActionsArgs { projectLocation: ProjectLocation; localSyncLocation?: ProjectLocation | undefined; - mergeSyncLocation?: ProjectLocation | undefined; skipLocalSync?: boolean | undefined; prKey: string | undefined; /** PR head branch — required for the on-demand `handleRefreshPr` refetch. */ branch?: string | undefined; - /** Project id used to build the PR-details cache key (`${projectId}#${number}`). */ + /** Project id used for PR-details caching and post-merge base synchronization. */ projectId?: string | undefined; onRefresh: () => void; } @@ -48,16 +47,8 @@ export interface UsePrWriteActionsResult { * stay in lockstep across surfaces. */ export function usePrWriteActions(args: UsePrWriteActionsArgs): UsePrWriteActionsResult { - const { - projectLocation, - localSyncLocation, - mergeSyncLocation, - skipLocalSync, - prKey, - branch, - projectId, - onRefresh, - } = args; + const { projectLocation, localSyncLocation, skipLocalSync, prKey, branch, projectId, onRefresh } = + args; const [pendingAction, setPendingAction] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const prLoading = pendingAction !== null; @@ -100,10 +91,11 @@ export function usePrWriteActions(args: UsePrWriteActionsArgs): UsePrWriteAction method, admin, }); - await pullMergedPrBaseIfPossible(mergeSyncLocation ?? projectLocation, prData.baseBranch); + const mergedPr = { ...prData, state: "merged" as const }; if (prKey) { - useGitStore.getState().setPrData(prKey, { ...prData, state: "merged" }); + useGitStore.getState().setPrData(prKey, mergedPr); } + if (projectId) await syncMergedPrBase(projectId, mergedPr); onRefresh(); } catch (err) { console.error("[git] merge PR failed", err); diff --git a/src/renderer/state/prMergeAutoDone.test.ts b/src/renderer/state/prMergeAutoDone.test.ts index 31ac1811f..d1c5f57bc 100644 --- a/src/renderer/state/prMergeAutoDone.test.ts +++ b/src/renderer/state/prMergeAutoDone.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PrData, Thread } from "@/shared/contracts"; +import type { PrData, Project, Thread } from "@/shared/contracts"; import type { PrWatchMergedEvent } from "@/shared/ipc"; import { useAppStore } from "./appStore"; import { useGitStore } from "./gitStore"; @@ -7,12 +7,17 @@ import { useSharedSettings } from "./sharedSettingsStore"; import { startPrMergeAutoDone } from "./prMergeAutoDone"; const markThreadDoneMock = vi.fn<(threadId: string) => void>(); +const syncMergedPrBaseMock = vi.fn<(projectId: string, pr: PrData) => Promise>(); let prWatchMergedListener: ((event: PrWatchMergedEvent) => void) | undefined; vi.mock("@/renderer/actions/threadActions", () => ({ markThreadDone: (threadId: string) => markThreadDoneMock(threadId), })); +vi.mock("./prMergeBaseSync", () => ({ + syncMergedPrBase: (projectId: string, pr: PrData) => syncMergedPrBaseMock(projectId, pr), +})); + const openPr: PrData = { number: 7, state: "open", @@ -25,6 +30,13 @@ const openPr: PrData = { }; const mergedPr: PrData = { ...openPr, state: "merged" }; +const project: Project = { + id: "p1", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-20T00:00:00.000Z", +}; + const thread: Thread = { id: "t1", projectId: "p1", @@ -48,6 +60,8 @@ let stop: () => void = () => {}; describe("prMergeAutoDone", () => { beforeEach(() => { markThreadDoneMock.mockReset(); + syncMergedPrBaseMock.mockReset(); + syncMergedPrBaseMock.mockResolvedValue(undefined); Object.defineProperty(window, "poracode", { configurable: true, value: { @@ -66,7 +80,7 @@ describe("prMergeAutoDone", () => { }, }); useGitStore.setState({ prData: {} }); - useAppStore.setState({ threads: [thread], view: { kind: "home" } }); + useAppStore.setState({ projects: [project], threads: [thread], view: { kind: "home" } }); useSharedSettings.setState({ autoMarkDoneOnPrMerge: true }); stop = startPrMergeAutoDone(); }); @@ -81,9 +95,11 @@ describe("prMergeAutoDone", () => { useGitStore.getState().setPrData("/repo-wt", mergedPr); expect(markThreadDoneMock).toHaveBeenCalledExactlyOnceWith("t1"); + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", mergedPr); }); it("marks the thread done when the background watcher publishes its merge", () => { + useGitStore.getState().setPrData("/repo-wt", openPr); prWatchMergedListener?.({ projectId: "p1", prNumber: 7, @@ -91,6 +107,7 @@ describe("prMergeAutoDone", () => { }); expect(markThreadDoneMock).toHaveBeenCalledExactlyOnceWith("t1"); + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", openPr); }); it("releases the background merge listener when stopped", () => { @@ -112,14 +129,16 @@ describe("prMergeAutoDone", () => { useGitStore.getState().setPrData("/repo-wt", openPr); useGitStore.getState().setPrData("/repo-wt", mergedPr); expect(markThreadDoneMock).not.toHaveBeenCalled(); + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", mergedPr); }); - it("ignores merges on other worktrees and on the project branch", () => { + it("does not mark merges on other worktrees or the project branch done", () => { useGitStore.getState().setPrData("/other-wt", openPr); useGitStore.getState().setPrData("/other-wt", mergedPr); useGitStore.getState().setPrData("__branch:p1", openPr); useGitStore.getState().setPrData("__branch:p1", mergedPr); expect(markThreadDoneMock).not.toHaveBeenCalled(); + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", mergedPr); }); it("skips archived and already-done threads", () => { diff --git a/src/renderer/state/prMergeAutoDone.ts b/src/renderer/state/prMergeAutoDone.ts index 102559597..6e9757ad7 100644 --- a/src/renderer/state/prMergeAutoDone.ts +++ b/src/renderer/state/prMergeAutoDone.ts @@ -3,7 +3,9 @@ import type { PrWatchMergedEvent } from "@/shared/ipc"; import { markThreadDone } from "@/renderer/actions/threadActions"; import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "./appStore"; +import { buildBranchNamePrKey, buildBranchPrKey } from "./gitSelectors"; import { useGitStore } from "./gitStore"; +import { syncMergedPrBase } from "./prMergeBaseSync"; import { useSharedSettings } from "./sharedSettingsStore"; /** @@ -27,15 +29,42 @@ const pendingThreadIds = new Set(); type PrDataMap = Record; /** PR keys that just went from a known non-merged state to merged. */ -function collectFreshlyMergedKeys(next: PrDataMap, prev: PrDataMap): Set { - const keys = new Set(); +function collectFreshlyMerged( + next: PrDataMap, + prev: PrDataMap, +): Array<{ + key: string; + pr: PrData; +}> { + const merged: Array<{ key: string; pr: PrData }> = []; for (const [key, pr] of Object.entries(next)) { if (pr?.state !== "merged") continue; const before = prev[key]; if (!before || before.state === "merged") continue; - keys.add(key); + merged.push({ key, pr }); } - return keys; + return merged; +} + +function syncObservedBases(merged: ReadonlyArray<{ key: string; pr: PrData }>): void { + const { projects, threads } = useAppStore.getState(); + for (const item of merged) { + const thread = threads.find((candidate) => candidate.worktreePath === item.key); + const projectId = + thread?.projectId ?? + projects.find( + (project) => + item.key === buildBranchPrKey(project.id) || + item.key.startsWith(`${buildBranchNamePrKey(project.id, "")}`), + )?.id; + if (projectId) void syncMergedPrBase(projectId, item.pr); + } +} + +function syncPrWatchBase(merged: PrWatchMergedEvent): void { + if (!merged.worktreePath) return; + const prData = useGitStore.getState().prData[merged.worktreePath]; + if (prData?.number === merged.prNumber) void syncMergedPrBase(merged.projectId, prData); } /** @@ -90,15 +119,18 @@ function flushPendingThreads(): void { /** Starts the watcher. Runtime-owner only, so a remote session never duplicates it. */ export function startPrMergeAutoDone(): () => void { const unsubscribePrWatchMerged = readBridge().onPrWatchMerged((merged) => { + syncPrWatchBase(merged); if (!useSharedSettings.getState().autoMarkDoneOnPrMerge) return; settlePrWatchThreads(merged); }); const unsubscribeGit = useGitStore.subscribe((state, prev) => { if (state.prData === prev.prData) return; + const merged = collectFreshlyMerged(state.prData, prev.prData); + if (merged.length === 0) return; + syncObservedBases(merged); if (!useSharedSettings.getState().autoMarkDoneOnPrMerge) return; - const merged = collectFreshlyMergedKeys(state.prData, prev.prData); - if (merged.size > 0) settleWorktreeThreads(merged); + settleWorktreeThreads(new Set(merged.map((item) => item.key))); }); const unsubscribeThreads = useAppStore.subscribe((state, prev) => { diff --git a/src/renderer/state/prMergeBaseSync.test.ts b/src/renderer/state/prMergeBaseSync.test.ts new file mode 100644 index 000000000..e1554e57a --- /dev/null +++ b/src/renderer/state/prMergeBaseSync.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrData, Project } from "@/shared/contracts"; +import { useAppStore } from "./appStore"; +import { syncMergedPrBase } from "./prMergeBaseSync"; + +const pullMergedPrBaseIfPossibleMock = vi.hoisted(() => + vi.fn< + (projectLocation: Project["location"], baseBranch: string, projectId?: string) => Promise + >(), +); + +vi.mock("@/renderer/actions/gitCommandRunner", () => ({ + pullMergedPrBaseIfPossible: ( + projectLocation: Project["location"], + baseBranch: string, + projectId?: string, + ) => pullMergedPrBaseIfPossibleMock(projectLocation, baseBranch, projectId), +})); + +const project: Project = { + id: "p1", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-07-20T00:00:00.000Z", +}; + +function pr(number: number): PrData { + return { + number, + state: "merged", + title: `PR ${number}`, + url: `https://github.com/owner/repo/pull/${number}`, + baseBranch: "main", + isDraft: false, + checksStatus: "SUCCESS", + updatedAt: "2026-07-20T00:00:00.000Z", + }; +} + +describe("prMergeBaseSync", () => { + beforeEach(() => { + pullMergedPrBaseIfPossibleMock.mockReset(); + useAppStore.setState({ projects: [project] }); + }); + + it("deduplicates the same PR and serializes different PRs for one project", async () => { + let releaseFirst!: () => void; + pullMergedPrBaseIfPossibleMock + .mockImplementationOnce(() => new Promise((resolve) => (releaseFirst = resolve))) + .mockResolvedValueOnce(undefined); + + const first = syncMergedPrBase("p1", pr(7)); + const duplicate = syncMergedPrBase("p1", pr(7)); + const second = syncMergedPrBase("p1", pr(8)); + + await vi.waitFor(() => expect(pullMergedPrBaseIfPossibleMock).toHaveBeenCalledTimes(1)); + expect(duplicate).toBe(first); + const movedLocation: Project["location"] = { kind: "posix", path: "/repo-moved" }; + useAppStore.setState({ projects: [{ ...project, location: movedLocation }] }); + + releaseFirst(); + await Promise.all([first, duplicate, second]); + + expect(pullMergedPrBaseIfPossibleMock).toHaveBeenNthCalledWith( + 1, + project.location, + "main", + "p1", + ); + expect(pullMergedPrBaseIfPossibleMock).toHaveBeenNthCalledWith(2, movedLocation, "main", "p1"); + }); +}); diff --git a/src/renderer/state/prMergeBaseSync.ts b/src/renderer/state/prMergeBaseSync.ts new file mode 100644 index 000000000..198bf961e --- /dev/null +++ b/src/renderer/state/prMergeBaseSync.ts @@ -0,0 +1,31 @@ +import type { PrData } from "@/shared/contracts"; +import { pullMergedPrBaseIfPossible } from "@/renderer/actions/gitCommandRunner"; +import { useAppStore } from "./appStore"; + +const pendingPrSyncs = new Map>(); +const projectSyncTails = new Map>(); + +/** Safely sync one merged PR's base checkout, serialized with other merges for that project. */ +export function syncMergedPrBase(projectId: string, pr: PrData): Promise { + const prKey = `${projectId}#${pr.number}`; + const pending = pendingPrSyncs.get(prKey); + if (pending) return pending; + + const previous = projectSyncTails.get(projectId) ?? Promise.resolve(); + const sync = previous + .catch(() => undefined) + .then(() => { + const project = useAppStore + .getState() + .projects.find((candidate) => candidate.id === projectId); + if (!project) return; + return pullMergedPrBaseIfPossible(project.location, pr.baseBranch, projectId); + }); + pendingPrSyncs.set(prKey, sync); + projectSyncTails.set(projectId, sync); + void sync.finally(() => { + pendingPrSyncs.delete(prKey); + if (projectSyncTails.get(projectId) === sync) projectSyncTails.delete(projectId); + }); + return sync; +} diff --git a/src/renderer/state/prWatchStatusSync.test.ts b/src/renderer/state/prWatchStatusSync.test.ts index 0834fcb87..442275622 100644 --- a/src/renderer/state/prWatchStatusSync.test.ts +++ b/src/renderer/state/prWatchStatusSync.test.ts @@ -7,6 +7,13 @@ import { useGitStore } from "./gitStore"; import { startPrWatchStatusSync } from "./prWatchStatusSync"; let statusListener: ((event: PrWatchStatusEvent) => void) | undefined; +const syncMergedPrBaseMock = vi.hoisted(() => + vi.fn<(projectId: string, pr: PrData) => Promise>(), +); + +vi.mock("./prMergeBaseSync", () => ({ + syncMergedPrBase: (projectId: string, pr: PrData) => syncMergedPrBaseMock(projectId, pr), +})); const openPr: PrData = { number: 7, @@ -85,6 +92,8 @@ let stop: () => void = () => {}; describe("prWatchStatusSync", () => { beforeEach(() => { + syncMergedPrBaseMock.mockReset(); + syncMergedPrBaseMock.mockResolvedValue(undefined); Object.defineProperty(window, "poracode", { configurable: true, value: { @@ -135,6 +144,7 @@ describe("prWatchStatusSync", () => { const state = useGitStore.getState(); expect(state.prData[buildBranchNamePrKey("p1", "feature/wt")]?.state).toBe("merged"); expect(state.prDetails["p1#7"]).toEqual(details); + expect(syncMergedPrBaseMock).toHaveBeenCalledWith("p1", mergedPr); }); it("reaches worktree threads on the head branch when the watch has no worktree path", () => { diff --git a/src/renderer/state/prWatchStatusSync.ts b/src/renderer/state/prWatchStatusSync.ts index c740e8192..03e274139 100644 --- a/src/renderer/state/prWatchStatusSync.ts +++ b/src/renderer/state/prWatchStatusSync.ts @@ -4,6 +4,7 @@ import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "./appStore"; import { buildBranchNamePrKey, buildBranchPrKey } from "./gitSelectors"; import { useGitStore } from "./gitStore"; +import { syncMergedPrBase } from "./prMergeBaseSync"; /** * Keeps the git store's PR snapshot in step with the PR-watch loop. @@ -37,6 +38,7 @@ function collectPrKeys(event: PrWatchStatusEvent): Set { } function applyPrWatchStatus(event: PrWatchStatusEvent): void { + if (event.pr.state === "merged") void syncMergedPrBase(event.projectId, event.pr); const gitStore = useGitStore.getState(); const updates: Record = {}; for (const key of collectPrKeys(event)) { diff --git a/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx b/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx index c058310d1..4ccf3ea42 100644 --- a/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx +++ b/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx @@ -303,7 +303,6 @@ export function GitReviewOverlay(props: { sidebar={ void; onOpenAll: () => void; onDiscard: () => void; + projectSyncBadge?: React.ReactNode; }) { const { t } = useLingui(); const hiddenPanelButtonClass = @@ -67,6 +68,7 @@ export function ExperimentGroupHeader(props: { suffix={ props.isRenaming ? undefined : ( <> + {props.projectSyncBadge} + {projectSyncBadge} {!isRenamingGroup && activeThreads.length >= 2 && ( diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx index c89da3a3a..54b471651 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/SortableThreadItem.test.tsx @@ -65,6 +65,14 @@ vi.mock("@/renderer/views/MainView/parts/Sidebar/parts/GitBadge", () => ({ ), })); +vi.mock("@/renderer/views/MainView/parts/Sidebar/parts/SyncBadge", () => ({ + SyncBadge: (props: { projectId: string; worktreePath?: string }) => ( + + {props.projectId}:{props.worktreePath ?? "project"} + + ), +})); + vi.mock("@/renderer/components/providers/statusTone", () => ({ getStatusTone: getStatusToneMock, })); @@ -309,7 +317,7 @@ describe("SortableThreadItem", () => { }); }); - it("shows the project git badge on a flat-list main-branch thread row", () => { + it("shows project sync and git badges on a flat-list main-branch thread row", () => { render( { />, ); + expect(screen.getByTestId("sync-badge")).toHaveTextContent("project-1:project"); expect(screen.getByRole("button", { name: "Git status for Project" })).toBeInTheDocument(); }); @@ -342,5 +351,6 @@ describe("SortableThreadItem", () => { expect( screen.queryByRole("button", { name: "Git status for Project" }), ).not.toBeInTheDocument(); + expect(screen.queryByTestId("sync-badge")).not.toBeInTheDocument(); }); }); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx index def86ae9c..a91768843 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SortableThreadItem/parts/ThreadItemSuffix.tsx @@ -116,7 +116,11 @@ function ThreadItemStatusBadges(props: ThreadItemSuffixProps) { return ( <> - {worktreePath ? : null} + {worktreePath ? ( + + ) : !thread.worktreePath && props.showProjectBadge ? ( + + ) : null} {showDoneButton ? (
({ @@ -65,6 +66,7 @@ describe("SyncBadge", () => { beforeEach(() => { vi.clearAllMocks(); + resetGitReviewActionStore(); vi.spyOn(console, "error").mockImplementation(() => undefined); useAppStore.setState({ projects: [project] }); useGitStore.setState({ @@ -92,4 +94,36 @@ describe("SyncBadge", () => { expect(toastMock.danger).toHaveBeenCalledWith("remote rejected"); }); }); + + it("shares in-flight state across duplicate project badges", async () => { + let releasePull!: () => void; + bridgeMock.gitPull.mockImplementationOnce( + () => new Promise((resolve) => (releasePull = resolve)), + ); + bridgeMock.getGitStatus.mockResolvedValue(makeStatus({ behind: 0 })); + + render( + <> + + + , + ); + + const badges = screen.getAllByRole("button", { name: "Pull ↓1" }); + fireEvent.click(badges[0]!); + await waitFor(() => { + for (const badge of badges) { + expect(badge).toHaveAttribute("aria-busy", "true"); + expect(badge).toHaveAttribute("aria-disabled", "true"); + } + }); + fireEvent.click(badges[1]!); + expect(bridgeMock.gitPull).toHaveBeenCalledOnce(); + + releasePull(); + await waitFor(() => expect(bridgeMock.getGitStatus).toHaveBeenCalledOnce()); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Pull ↓1" })).not.toBeInTheDocument(), + ); + }); }); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx index 0cf6b0255..cfca65824 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx @@ -1,10 +1,10 @@ -import { useState } from "react"; import { PixelLoader } from "@/renderer/components/common/PixelLoader"; import { Tooltip } from "@heroui/react"; import { useLingui } from "@lingui/react/macro"; import { useShallow } from "zustand/shallow"; import { useGitStore } from "@/renderer/state/gitStore"; import { useAppStore } from "@/renderer/state/appStore"; +import { useGitReviewActionStore } from "@/renderer/state/gitReviewActionStore"; import { readBridge } from "@/renderer/bridge"; import { buildWorktreeLocation } from "@/shared/worktree"; import { handleKeyActivate } from "@/renderer/utils/a11y"; @@ -30,7 +30,10 @@ export function SyncBadge(props: { projectId: string; worktreePath?: string }) { }), ); - const [isSyncing, setIsSyncing] = useState(false); + const syncKey = props.worktreePath ?? props.projectId; + const isSyncing = useGitReviewActionStore((state) => state.panels[syncKey]?.isSyncing ?? false); + const setIsSyncing = (value: boolean) => + useGitReviewActionStore.getState().patch(syncKey, { isSyncing: value }); if (ahead === 0 && behind === 0) return null; if (!hasRemote) return null; @@ -127,6 +130,8 @@ export function SyncBadge(props: { projectId: string; worktreePath?: string }) { role="button" tabIndex={0} aria-label={label} + aria-busy={isSyncing || undefined} + aria-disabled={isSyncing || undefined} className="shrink-0 cursor-default rounded px-1 py-0.5 transition-colors text-muted/60 hover:bg-[var(--row-hover)] hover:text-foreground" onClick={(e) => { e.stopPropagation(); diff --git a/src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx b/src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx index 9b325c764..31b762707 100644 --- a/src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx +++ b/src/renderer/views/PrReviewOverlay/PrReviewOverlay.tsx @@ -296,7 +296,6 @@ export function PrReviewOverlay(props: { loading={loading} projectId={project.id} projectLocation={effectiveLocation} - mergeSyncLocation={project.location} prKey={prKey} worktreePath={worktreePath} {...(skipLocalSync ? { skipLocalSync: true } : {})} diff --git a/src/renderer/views/PrReviewOverlay/parts/PrReviewSidebar.tsx b/src/renderer/views/PrReviewOverlay/parts/PrReviewSidebar.tsx index 8a3cc1ecf..1308d9555 100644 --- a/src/renderer/views/PrReviewOverlay/parts/PrReviewSidebar.tsx +++ b/src/renderer/views/PrReviewOverlay/parts/PrReviewSidebar.tsx @@ -29,7 +29,6 @@ export function PrReviewSidebar(props: { loading: boolean; projectId: string; projectLocation: ProjectLocation; - mergeSyncLocation: ProjectLocation; prKey: string; worktreePath?: string | undefined; skipLocalSync?: boolean; @@ -43,7 +42,6 @@ export function PrReviewSidebar(props: { loading, projectId, projectLocation, - mergeSyncLocation, prKey, worktreePath, skipLocalSync, @@ -63,7 +61,7 @@ export function PrReviewSidebar(props: { handleUpdatePrBranch, } = usePrWriteActions({ projectLocation, - mergeSyncLocation, + projectId, prKey, ...(skipLocalSync ? { skipLocalSync: true } : {}), onRefresh,