From 69f02c72c0bfb3c3cc7be3abe3a29b145b35b108 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 24 May 2026 16:31:45 -0700 Subject: [PATCH] refactor(git): centralize git command execution and error handling - Extract git command runners and sync action logic to a unified `gitCommandRunner` helper - Standardize toast notifications and exception capture for git actions - Add unit tests for the command runner and `SyncBadge` component --- src/renderer/actions/gitActions.ts | 60 +++++++---- src/renderer/actions/gitCommandRunner.test.ts | 66 ++++++++++++ src/renderer/actions/gitCommandRunner.ts | 100 ++++++++++++++++++ .../parts/CommitSyncPanel.tsx | 7 +- .../parts/useGitReviewActions.ts | 68 ++++++------ .../MainView/parts/PullFromSourceDialog.tsx | 9 +- .../parts/Sidebar/parts/SyncBadge.test.tsx | 94 ++++++++++++++++ .../parts/Sidebar/parts/SyncBadge.tsx | 29 +++-- .../parts/Sidebar/parts/useWorktreeActions.ts | 16 +-- 9 files changed, 350 insertions(+), 99 deletions(-) create mode 100644 src/renderer/actions/gitCommandRunner.test.ts create mode 100644 src/renderer/actions/gitCommandRunner.ts create mode 100644 src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.test.tsx diff --git a/src/renderer/actions/gitActions.ts b/src/renderer/actions/gitActions.ts index 6383dce1..19623af0 100644 --- a/src/renderer/actions/gitActions.ts +++ b/src/renderer/actions/gitActions.ts @@ -1,16 +1,22 @@ import { buildWorktreeLocation } from "@/shared/worktree"; import { readBridge } from "@/renderer/bridge"; -import { captureRendererException } from "@/renderer/diagnostics/sentry"; import { useAppStore } from "@/renderer/state/appStore"; import { usePanelStore } from "@/renderer/state/panelStore"; import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { resolveWorktreeBranch } from "@/renderer/utils/gitHelpers"; import { closeThreads } from "@/renderer/utils/shellUtils"; +import { + runGitMergeToSource, + runGitPullFromSource, + runGitSyncCommand, + showGitActionError, + showGitOperationFailure, +} from "./gitCommandRunner"; import { performWorktreeRemoval } from "./worktreeActions"; function captureGitActionError(error: unknown): void { - captureRendererException(error, { featureArea: "git" }); + showGitActionError(error, { capture: true }); } export function openGitReviewForWorktree(projectId: string, worktreePath: string): void { @@ -30,7 +36,9 @@ export function gitSync(projectId: string, worktreePath?: string): void { const location = worktreePath ? buildWorktreeLocation(project.location, worktreePath) : project.location; - void readBridge().gitSync({ projectLocation: location }).catch(captureGitActionError); + void runGitSyncCommand({ command: "sync", projectLocation: location }).catch( + captureGitActionError, + ); } export function gitSyncRebase(projectId: string, worktreePath?: string): void { @@ -39,7 +47,9 @@ export function gitSyncRebase(projectId: string, worktreePath?: string): void { const location = worktreePath ? buildWorktreeLocation(project.location, worktreePath) : project.location; - void readBridge().gitSyncRebase({ projectLocation: location }).catch(captureGitActionError); + void runGitSyncCommand({ command: "syncRebase", projectLocation: location }).catch( + captureGitActionError, + ); } export function gitPush(projectId: string, worktreePath: string): void { @@ -48,32 +58,35 @@ export function gitPush(projectId: string, worktreePath: string): void { const worktreeBranch = resolveWorktreeBranch(projectId, worktreePath); if (!worktreeBranch) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - void readBridge() - .gitPush({ - projectLocation: worktreeLocation, - remote: "origin", - branch: worktreeBranch, - setUpstream: true, - }) - .catch(captureGitActionError); + void runGitSyncCommand({ + command: "push", + projectLocation: worktreeLocation, + remote: "origin", + branch: worktreeBranch, + setUpstream: true, + }).catch(captureGitActionError); } export function gitPull(projectId: string, worktreePath: string): void { const project = useAppStore.getState().projects.find((p) => p.id === projectId); if (!project) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - void readBridge() - .gitPull({ projectLocation: worktreeLocation, remote: "origin" }) - .catch(captureGitActionError); + void runGitSyncCommand({ + command: "pull", + projectLocation: worktreeLocation, + remote: "origin", + }).catch(captureGitActionError); } export function gitPullRebase(projectId: string, worktreePath: string): void { const project = useAppStore.getState().projects.find((p) => p.id === projectId); if (!project) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - void readBridge() - .gitPullRebase({ projectLocation: worktreeLocation, remote: "origin" }) - .catch(captureGitActionError); + void runGitSyncCommand({ + command: "pullRebase", + projectLocation: worktreeLocation, + remote: "origin", + }).catch(captureGitActionError); } export function gitMergeToSource(projectId: string, worktreePath: string): void { @@ -89,7 +102,7 @@ export function gitMergeToSource(projectId: string, worktreePath: string): void }); if (!sourceBranch) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - await readBridge().gitMergeToSource({ + await runGitMergeToSource({ projectLocation: project.location, worktreeLocation, worktreeBranch, @@ -115,7 +128,7 @@ export function gitMergeAndRemove(projectId: string, worktreePath: string): void }); if (!sourceBranch) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - const result = await readBridge().gitMergeToSource({ + const result = await runGitMergeToSource({ projectLocation: project.location, worktreeLocation, worktreeBranch, @@ -150,7 +163,7 @@ export function gitPullFromSource(projectId: string, worktreePath: string): void }); if (!sourceBranch) return; const worktreeLocation = buildWorktreeLocation(project.location, worktreePath); - const result = await readBridge().gitPullFromSource({ + const result = await runGitPullFromSource({ worktreeLocation, sourceBranch, preserveLocalChanges: false, @@ -165,10 +178,13 @@ export function gitPullFromSource(projectId: string, worktreePath: string): void } if (result.conflicting) { openGitReviewForWorktree(projectId, worktreePath); + return; + } + if (!result.merged) { + showGitOperationFailure(result); } } catch (error) { captureGitActionError(error); - // ignored — user can open git review for details } })(); } diff --git a/src/renderer/actions/gitCommandRunner.test.ts b/src/renderer/actions/gitCommandRunner.test.ts new file mode 100644 index 00000000..a41742a8 --- /dev/null +++ b/src/renderer/actions/gitCommandRunner.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runGitSyncCommand, showGitActionError } from "./gitCommandRunner"; + +const bridgeMock = vi.hoisted(() => ({ + gitPull: vi.fn<() => Promise>(), + gitPullRebase: vi.fn<() => Promise>(), + gitPush: vi.fn<() => Promise>(), + gitSync: vi.fn<() => Promise>(), + gitSyncRebase: vi.fn<() => Promise>(), +})); + +const toastMock = vi.hoisted(() => ({ + danger: vi.fn<(message: string) => void>(), +})); + +const captureRendererExceptionMock = vi.hoisted(() => vi.fn<() => void>()); + +vi.mock("@heroui/react", () => ({ + toast: toastMock, +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridgeMock, +})); + +vi.mock("@/renderer/diagnostics/sentry", () => ({ + captureRendererException: captureRendererExceptionMock, +})); + +const projectLocation = { kind: "posix" as const, path: "/repo" }; + +describe("gitCommandRunner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes push commands through the shared bridge payload builder", async () => { + bridgeMock.gitPush.mockResolvedValueOnce(undefined); + + await runGitSyncCommand({ + command: "push", + projectLocation, + remote: "origin", + branch: "feature/a", + setUpstream: true, + }); + + expect(bridgeMock.gitPush).toHaveBeenCalledWith({ + projectLocation, + remote: "origin", + branch: "feature/a", + setUpstream: true, + }); + }); + + it("shows shared git action errors and captures only when requested", () => { + const error = new Error("pull failed"); + + showGitActionError(error, { capture: true }); + + expect(toastMock.danger).toHaveBeenCalledWith("pull failed"); + expect(captureRendererExceptionMock).toHaveBeenCalledWith(error, { + featureArea: "git", + }); + }); +}); diff --git a/src/renderer/actions/gitCommandRunner.ts b/src/renderer/actions/gitCommandRunner.ts new file mode 100644 index 00000000..23059a35 --- /dev/null +++ b/src/renderer/actions/gitCommandRunner.ts @@ -0,0 +1,100 @@ +import { toast } from "@heroui/react"; +import type { + GitMergeToSourcePayload, + GitMergeToSourceResult, + GitPullFromSourcePayload, + GitPullFromSourceResult, + GitPushPayload, + GitSyncPayload, + ProjectLocation, +} from "@/shared/contracts"; +import { friendlyError, msg } from "@/shared/messages"; +import { readBridge } from "@/renderer/bridge"; +import { captureRendererException } from "@/renderer/diagnostics/sentry"; + +export type GitSyncCommand = "pull" | "pullRebase" | "push" | "sync" | "syncRebase"; +export type SyncAction = "push" | "pull" | "sync"; + +export function deriveSyncAction(hasTracking: boolean, ahead: number, behind: number): SyncAction { + if (!hasTracking) return "push"; + if (ahead > 0 && behind === 0) return "push"; + if (behind > 0 && ahead === 0) return "pull"; + return "sync"; +} + +export function showGitActionError( + error: unknown, + options?: { logPrefix?: string; capture?: boolean }, +): void { + if (options?.logPrefix) { + console.error(options.logPrefix, error); + } + if (options?.capture) { + captureRendererException(error, { featureArea: "git" }); + } + toast.danger(friendlyError(error)); +} + +export function showGitOperationFailure(result: { error?: string }): void { + toast.danger(result.error ?? msg("git.merge.failed")); +} + +export async function runGitSyncCommand(input: { + command: GitSyncCommand; + projectLocation: ProjectLocation; + remote?: string; + branch?: string; + setUpstream?: boolean; +}): Promise { + const { command, projectLocation } = input; + switch (command) { + case "pull": + await readBridge().gitPull(buildGitSyncPayload(projectLocation, input.remote)); + return; + case "pullRebase": + await readBridge().gitPullRebase(buildGitSyncPayload(projectLocation, input.remote)); + return; + case "push": + await readBridge().gitPush(buildGitPushPayload(input)); + return; + case "sync": + await readBridge().gitSync(buildGitSyncPayload(projectLocation, input.remote)); + return; + case "syncRebase": + await readBridge().gitSyncRebase(buildGitSyncPayload(projectLocation, input.remote)); + return; + } +} + +export async function runGitMergeToSource( + payload: GitMergeToSourcePayload, +): Promise { + return readBridge().gitMergeToSource(payload); +} + +export async function runGitPullFromSource( + payload: GitPullFromSourcePayload, +): Promise { + return readBridge().gitPullFromSource(payload); +} + +function buildGitSyncPayload(projectLocation: ProjectLocation, remote?: string): GitSyncPayload { + return { + projectLocation, + ...(remote ? { remote } : {}), + }; +} + +function buildGitPushPayload(input: { + projectLocation: ProjectLocation; + remote?: string; + branch?: string; + setUpstream?: boolean; +}): GitPushPayload { + return { + projectLocation: input.projectLocation, + ...(input.remote ? { remote: input.remote } : {}), + ...(input.branch ? { branch: input.branch } : {}), + ...(input.setUpstream !== undefined ? { setUpstream: input.setUpstream } : {}), + }; +} diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx index 5a60884d..b3f98ea4 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/CommitSyncPanel.tsx @@ -1,6 +1,7 @@ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, Lock, Sparkles } from "lucide-react"; import { Button, ButtonGroup, Dropdown, Label, Tooltip } from "@heroui/react"; import { PixelLoader, TextArea } from "@/renderer/components/common"; +import type { GitSyncCommand } from "@/renderer/actions/gitCommandRunner"; import { GitReviewSection } from "./GitReviewSection"; export function CommitSyncPanel(props: { @@ -25,7 +26,7 @@ export function CommitSyncPanel(props: { handleCommit: (addAll: boolean, pushAfter?: boolean) => Promise; handleGenerateMessage: () => Promise; handleSyncOrPush: () => Promise; - handleSyncAction: (key: "pull" | "pullRebase" | "push" | "sync" | "syncRebase") => Promise; + handleSyncAction: (key: GitSyncCommand) => Promise; handlePullFromSource: () => Promise; }) { const { @@ -257,9 +258,7 @@ export function CommitSyncPanel(props: { void handlePullFromSource(); return; } - void handleSyncAction( - key as "pull" | "pullRebase" | "push" | "sync" | "syncRebase", - ); + void handleSyncAction(key as GitSyncCommand); }} > {showPull ? ( diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts index a2156d06..ef11c6f6 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts @@ -16,6 +16,14 @@ import { resolveCommitGenConfig, } from "@/renderer/components/providers"; import { usePrWriteActions } from "@/renderer/hooks/usePrWriteActions"; +import { + runGitMergeToSource, + runGitPullFromSource, + runGitSyncCommand, + showGitActionError, + showGitOperationFailure, + type GitSyncCommand, +} from "@/renderer/actions/gitCommandRunner"; export interface UseGitReviewActionsArgs { project: Project; @@ -174,7 +182,8 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { if (pushAfter && hasRemote) { setIsSyncing(true); try { - await readBridge().gitPush({ + await runGitSyncCommand({ + command: "push", projectLocation: project.location, setUpstream: !hasTracking, }); @@ -245,7 +254,8 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_tracking: hasTracking, has_worktree: Boolean(worktreePath), }); - await readBridge().gitPush({ + await runGitSyncCommand({ + command: "push", projectLocation: project.location, setUpstream: !hasTracking, }); @@ -263,20 +273,17 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_tracking: hasTracking, has_worktree: Boolean(worktreePath), }); - await readBridge().gitSync({ projectLocation: project.location }); + await runGitSyncCommand({ command: "sync", projectLocation: project.location }); onRefresh(); } } catch (err) { - console.error("[git] sync/push failed", err); - toast.danger(friendlyError(err)); + showGitActionError(err, { logPrefix: "[git] sync/push failed" }); } finally { setIsSyncing(false); } } - async function handleSyncAction( - key: "pull" | "pullRebase" | "push" | "sync" | "syncRebase", - ): Promise { + async function handleSyncAction(key: GitSyncCommand): Promise { setIsSyncing(true); try { captureProductEvent("git.sync_action", { @@ -285,32 +292,19 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { has_tracking: hasTracking, has_worktree: Boolean(worktreePath), }); - switch (key) { - case "pull": - await readBridge().gitPull({ projectLocation: project.location }); - break; - case "pullRebase": - await readBridge().gitPullRebase({ projectLocation: project.location }); - break; - case "push": - await readBridge().gitPush({ - projectLocation: project.location, - setUpstream: !hasTracking, - }); - applyStatusOptimistic((s) => ({ ...s, ahead: 0 })); - setTimeout(() => onRefresh(), 1500); - return; - case "sync": - await readBridge().gitSync({ projectLocation: project.location }); - break; - case "syncRebase": - await readBridge().gitSyncRebase({ projectLocation: project.location }); - break; + await runGitSyncCommand({ + command: key, + projectLocation: project.location, + ...(key === "push" ? { setUpstream: !hasTracking } : {}), + }); + if (key === "push") { + applyStatusOptimistic((s) => ({ ...s, ahead: 0 })); + setTimeout(() => onRefresh(), 1500); + return; } onRefresh(); } catch (err) { - console.error("[git] sync action failed", err); - toast.danger(friendlyError(err)); + showGitActionError(err, { logPrefix: "[git] sync action failed" }); } finally { setIsSyncing(false); } @@ -320,7 +314,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { if (!sourceBranch || !worktreeBranch || !worktreePath) return false; setIsMerging(true); try { - const result = await readBridge().gitMergeToSource({ + const result = await runGitMergeToSource({ projectLocation: project.location, worktreeLocation: getWorktreeLocation(), worktreeBranch, @@ -335,8 +329,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { } return true; } catch (err) { - console.error("[git] merge failed", err); - toast.danger(friendlyError(err)); + showGitActionError(err, { logPrefix: "[git] merge failed" }); return false; } finally { setIsMerging(false); @@ -355,7 +348,7 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { if (!sourceBranch) return; setIsPullingFromSource(true); try { - const result = await readBridge().gitPullFromSource({ + const result = await runGitPullFromSource({ worktreeLocation: getWorktreeLocation(), sourceBranch, preserveLocalChanges: false, @@ -374,13 +367,12 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { return; } if (!result.merged) { - toast.danger(result.error ?? msg("git.merge.failed")); + showGitOperationFailure(result); return; } onRefresh(); } catch (err) { - console.error("[git] pull from source failed", err); - toast.danger(friendlyError(err)); + showGitActionError(err, { logPrefix: "[git] pull from source failed" }); } finally { setIsPullingFromSource(false); } diff --git a/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx b/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx index 37619d82..6f73a70d 100644 --- a/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx +++ b/src/renderer/views/MainView/parts/PullFromSourceDialog.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { AlertDialog, toast } from "@heroui/react"; import { buildWorktreeLocation } from "@/shared/worktree"; -import { friendlyError, msg } from "@/shared/messages"; -import { readBridge } from "@/renderer/bridge"; +import { msg } from "@/shared/messages"; import { openGitReviewForWorktree } from "@/renderer/actions/gitActions"; +import { runGitPullFromSource, showGitActionError } from "@/renderer/actions/gitCommandRunner"; import { Button } from "@/renderer/components/common/Button"; import { useAppStore } from "@/renderer/state/appStore"; import { usePullFromSourceDialogStore } from "@/renderer/state/pullFromSourceDialogStore"; @@ -27,7 +27,7 @@ export function PullFromSourceDialog() { setIsPulling(true); try { const stashPreservedMessage = msg("git.pull.stashPreserved"); - const result = await readBridge().gitPullFromSource({ + const result = await runGitPullFromSource({ worktreeLocation: buildWorktreeLocation(activeProject.location, activeDialog.worktreePath), sourceBranch: activeDialog.sourceBranch, preserveLocalChanges: true, @@ -55,8 +55,7 @@ export function PullFromSourceDialog() { } activeDialog.onComplete?.(); } catch (error) { - console.error("[git] stash pull from source failed", error); - toast.danger(friendlyError(error)); + showGitActionError(error, { logPrefix: "[git] stash pull from source failed" }); } finally { setIsPulling(false); } diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.test.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.test.tsx new file mode 100644 index 00000000..7f427a5a --- /dev/null +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitStatusResult, Project } from "@/shared/contracts"; +import { useAppStore } from "@/renderer/state/appStore"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { SyncBadge } from "./SyncBadge"; + +const bridgeMock = vi.hoisted(() => ({ + gitPull: vi.fn<() => Promise>(), + gitPush: vi.fn<() => Promise>(), + gitSync: vi.fn<() => Promise>(), + getGitStatus: vi.fn<() => Promise>(), +})); + +const toastMock = vi.hoisted(() => ({ + danger: vi.fn<(message: string) => void>(), +})); + +vi.mock("@heroui/react", () => { + const Tooltip = Object.assign((props: { children: ReactNode }) =>
{props.children}
, { + Trigger: (props: { children: ReactNode }) => <>{props.children}, + Content: (props: { children: ReactNode }) =>
{props.children}
, + }); + + return { + Tooltip, + toast: toastMock, + }; +}); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridgeMock, +})); + +const project: Project = { + id: "project-1", + name: "Project", + location: { kind: "posix", path: "/repo" }, + createdAt: "2026-05-23T00:00:00.000Z", +}; + +function makeStatus(overrides: Partial = {}): GitStatusResult { + return { + isRepo: true, + branch: "main", + tracking: "origin/main", + hasRemote: true, + remoteInfo: null, + ahead: 0, + behind: 1, + staged: [], + unstaged: [], + totalInsertions: 0, + totalDeletions: 0, + ...overrides, + }; +} + +describe("SyncBadge", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + useAppStore.setState({ projects: [project] }); + useGitStore.setState({ + statuses: { [project.id]: makeStatus() }, + worktreeStatuses: {}, + worktrees: {}, + branches: {}, + ghAvailable: {}, + prData: {}, + worktreeSourceInfo: {}, + prFiles: {}, + prDiffs: {}, + prDetails: {}, + }); + }); + + it("shows a toast when pulling from the sidebar badge fails", async () => { + bridgeMock.gitPull.mockRejectedValueOnce(new Error("remote rejected")); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Pull ↓1" })); + + await waitFor(() => { + expect(toastMock.danger).toHaveBeenCalledWith("remote rejected"); + }); + }); +}); diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx index b4ae460d..3eaff059 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SyncBadge.tsx @@ -7,14 +7,11 @@ import { useAppStore } from "@/renderer/state/appStore"; import { readBridge } from "@/renderer/bridge"; import { buildWorktreeLocation } from "@/shared/worktree"; import { handleKeyActivate } from "@/renderer/utils/a11y"; -import type { SyncAction } from "./useWorktreeActions"; - -function deriveSyncAction(hasTracking: boolean, ahead: number, behind: number): SyncAction { - if (!hasTracking) return "push"; - if (ahead > 0 && behind === 0) return "push"; - if (behind > 0 && ahead === 0) return "pull"; - return "sync"; -} +import { + deriveSyncAction, + runGitSyncCommand, + showGitActionError, +} from "@/renderer/actions/gitCommandRunner"; export function SyncBadge(props: { projectId: string; worktreePath?: string }) { const { ahead, behind, hasTracking, hasRemote } = useGitStore( @@ -61,22 +58,24 @@ export function SyncBadge(props: { projectId: string; worktreePath?: string }) { const thread = useAppStore .getState() .threads.find((t) => t.worktreePath === props.worktreePath && t.worktreeBranch); - await readBridge().gitPush({ + await runGitSyncCommand({ + command: "push", projectLocation: location, remote: "origin", - branch: thread?.worktreeBranch ?? undefined, + ...(thread?.worktreeBranch ? { branch: thread.worktreeBranch } : {}), setUpstream: true, }); } else { - await readBridge().gitPush({ + await runGitSyncCommand({ + command: "push", projectLocation: location, setUpstream: !hasTracking, }); } } else if (syncAction === "pull") { - await readBridge().gitPull({ projectLocation: location }); + await runGitSyncCommand({ command: "pull", projectLocation: location }); } else { - await readBridge().gitSync({ projectLocation: location }); + await runGitSyncCommand({ command: "sync", projectLocation: location }); } // Eagerly refresh git status so the badge updates immediately. @@ -88,8 +87,8 @@ export function SyncBadge(props: { projectId: string; worktreePath?: string }) { } else { useGitStore.getState().setProjectSnapshot(props.projectId, { status: newStatus }); } - } catch { - // Errors will be visible via git status refresh + } catch (error) { + showGitActionError(error, { logPrefix: "[git] sidebar sync failed" }); } finally { setIsSyncing(false); } diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/useWorktreeActions.ts b/src/renderer/views/MainView/parts/Sidebar/parts/useWorktreeActions.ts index 4bcd4098..5ca366a8 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/useWorktreeActions.ts +++ b/src/renderer/views/MainView/parts/Sidebar/parts/useWorktreeActions.ts @@ -1,13 +1,6 @@ import { useShallow } from "zustand/shallow"; import { useGitStore } from "@/renderer/state/gitStore"; - -/** - * Which remote action to show in the git menu: - * - "push" — no tracking branch yet (first push with --set-upstream), OR tracked but only ahead - * - "pull" — tracked and only behind - * - "sync" — tracked and both ahead+behind, or tracked and up-to-date (nothing to do, but Sync is the safe default) - */ -export type SyncAction = "push" | "pull" | "sync"; +import { deriveSyncAction, type SyncAction } from "@/renderer/actions/gitCommandRunner"; export type GitMenuIcons = { review: React.ReactNode; @@ -127,13 +120,6 @@ export function buildWorktreeGitItems( // ── Internal ───────────────────────────────────────────── -function deriveSyncAction(hasTracking: boolean, ahead: number, behind: number): SyncAction { - if (!hasTracking) return "push"; - if (ahead > 0 && behind === 0) return "push"; - if (behind > 0 && ahead === 0) return "pull"; - return "sync"; -} - function derive( s: ReturnType, projectId: string,