diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts new file mode 100644 index 00000000..59fcee24 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { GitPullPreflight } from "../types/git.types"; + +const invoke = mock(async (_command: string, _args?: unknown): Promise => null); +const emitGitChanged = mock((_change: unknown) => {}); +const resolveRepositoryPath = mock(async (repoPath: string) => repoPath); +const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath); +const getOperationState = mock(async () => null); +const getBranches = mock(async () => []); +const getGitHistory = mock(async () => null); +const getGitStatus = mock(async () => null); + +mock.module("@/platform/tauri-core", () => ({ invoke })); +mock.module("../events/git-events", () => ({ emitGitChanged })); +mock.module("./git-repo-api", () => ({ + isNotGitRepositoryError: () => false, + resolveRepositoryPath, + resolveRepositoryPathOrThrow, +})); +mock.module("./git-integration-api", () => ({ getOperationState })); +mock.module("./git-branches-api", () => ({ getBranches })); +mock.module("./git-commits-api", () => ({ getGitHistory })); +mock.module("./git-status-api", () => ({ getGitStatus })); + +const { executePullChanges, fetchChanges, getGitPullWorkflow, getPullPreflight, pullChanges } = + await import("./git-remotes-api"); + +beforeEach(() => { + invoke.mockReset(); + emitGitChanged.mockReset(); + resolveRepositoryPath.mockReset(); + resolveRepositoryPathOrThrow.mockReset(); + getBranches.mockClear(); + getGitHistory.mockClear(); + getGitStatus.mockClear(); + resolveRepositoryPath.mockImplementation(async (repoPath: string) => repoPath); + resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath); +}); + +describe("Git remote Pull API", () => { + test("shares a Pull workflow only within the same repository", () => { + expect(getGitPullWorkflow("C:/repo")).toBe(getGitPullWorkflow("C:/repo")); + expect(getGitPullWorkflow("C:/repo")).not.toBe(getGitPullWorkflow("C:/other-repo")); + }); + + test("calls the shared git.pullPreflight contract for the current branch", async () => { + const preflight: GitPullPreflight = { + upstream: "origin/main", + ahead: 1, + behind: 2, + diverged: true, + hasLocalChanges: false, + }; + invoke.mockResolvedValue(preflight); + + await expect(getPullPreflight("C:/repo")).resolves.toEqual(preflight); + expect(invoke).toHaveBeenCalledWith("git.pullPreflight", { repoPath: "C:/repo" }); + }); + + test("executes Pull with only the selected Core mode", async () => { + invoke.mockResolvedValue(null); + + await expect(executePullChanges("C:/repo", "rebase")).resolves.toEqual({ success: true }); + expect(invoke).toHaveBeenCalledWith("git_pull", { + repoPath: "C:/repo", + mode: "rebase", + }); + }); + + test("fetches the repository without an ignored remote parameter", async () => { + invoke.mockResolvedValue(null); + + await expect(fetchChanges("C:/repo")).resolves.toEqual({ success: true }); + expect(invoke).toHaveBeenCalledWith("git_fetch", { repoPath: "C:/repo" }); + }); + + test("keeps a headless divergent Pull on the safe Cancel default", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git.pullPreflight") { + return { + upstream: "origin/main", + ahead: 1, + behind: 1, + diverged: true, + hasLocalChanges: false, + } satisfies GitPullPreflight; + } + return null; + }); + + await expect(pullChanges("C:/repo")).resolves.toEqual({ + success: false, + error: "Branches have diverged. Open Source Control and choose Merge or Rebase.", + }); + expect(invoke).not.toHaveBeenCalledWith("git_pull", expect.anything()); + expect(getGitStatus).toHaveBeenCalledWith("C:/repo"); + expect(getGitHistory).toHaveBeenCalledWith("C:/repo", 50); + expect(getBranches).toHaveBeenCalledWith("C:/repo"); + expect(invoke).toHaveBeenCalledWith("git_get_remotes", { repoPath: "C:/repo" }); + expect(emitGitChanged).toHaveBeenLastCalledWith({ + repoPath: "C:/repo", + scopes: ["working-tree", "history", "refs", "remotes"], + source: "pull-finished", + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.ts b/windows/tauri/src/features/git/api/git-remotes-api.ts index 52cf8b2f..50b4ddb8 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.ts @@ -1,7 +1,12 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; -import type { GitRemote } from "../types/git.types"; +import type { GitPullPreflight, GitRemote, PullStrategy } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; +import { GitPullWorkflow } from "../hooks/git-pull-workflow"; import { runGitRead } from "../runtime/git-read-coordinator"; +import { getBranches } from "./git-branches-api"; +import { getGitHistory } from "./git-commits-api"; +import { getOperationState } from "./git-integration-api"; +import { getGitStatus } from "./git-status-api"; import { isNotGitRepositoryError, resolveRepositoryPath, @@ -88,19 +93,20 @@ export const pushChanges = async ( } }; -export const pullChanges = async ( +export const getPullPreflight = async (repoPath: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + return tauriInvoke("git.pullPreflight", { + repoPath: resolvedRepoPath, + }); +}; + +export const executePullChanges = async ( repoPath: string, - branch?: string, - remote: string = "origin", + strategy: PullStrategy, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - await tauriInvoke("git_pull", { repoPath: resolvedRepoPath, branch, remote }); - emitGitChanged({ - repoPath: resolvedRepoPath, - scopes: ["working-tree", "history", "refs", "remotes"], - source: "pull", - }); + await tauriInvoke("git_pull", { repoPath: resolvedRepoPath, mode: strategy }); return { success: true }; } catch (error) { console.error("Failed to pull changes:", error); @@ -111,13 +117,10 @@ export const pullChanges = async ( } }; -export const fetchChanges = async ( - repoPath: string, - remote?: string, -): Promise => { +export const fetchChanges = async (repoPath: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); - await tauriInvoke("git_fetch", { repoPath: resolvedRepoPath, remote }); + await tauriInvoke("git_fetch", { repoPath: resolvedRepoPath }); emitGitChanged({ repoPath: resolvedRepoPath, scopes: ["refs", "remotes"], @@ -132,3 +135,65 @@ export const fetchChanges = async ( }; } }; + +const pullWorkflows = new Map(); + +/** Returns the shared Pull coordinator for one repository. */ +export const getGitPullWorkflow = (repoPath: string): GitPullWorkflow => { + const existingWorkflow = pullWorkflows.get(repoPath); + if (existingWorkflow) return existingWorkflow; + + const workflow = new GitPullWorkflow({ + fetch: fetchChanges, + preflight: getPullPreflight, + pull: executePullChanges, + operationState: getOperationState, + }); + pullWorkflows.set(repoPath, workflow); + return workflow; +}; + +/** + * Safe compatibility entry point for non-Source-Control callers. Divergence + * cancels because only the Source Control workflow can present the choice UI. + */ +export const pullChanges = async (repoPath: string): Promise => { + const workflow = getGitPullWorkflow(repoPath); + const unsubscribe = workflow.subscribe(() => { + if (workflow.getSnapshot().pendingPreflight) { + workflow.chooseStrategy(null); + } + }); + const resultPromise = workflow.run(repoPath, { + refresh: async () => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + // Cache invalidation must precede the explicit reads below; otherwise a + // failed or successful Pull could refresh the UI from stale snapshots. + emitGitChanged({ + repoPath: resolvedRepoPath, + scopes: ["working-tree", "history", "refs", "remotes"], + source: "pull-finished", + }); + await Promise.all([ + getGitStatus(resolvedRepoPath), + getGitHistory(resolvedRepoPath, 50), + getBranches(resolvedRepoPath), + getRemotes(resolvedRepoPath), + ]); + }, + }); + try { + const result = await resultPromise; + return result.status === "pulled" + ? { success: true } + : { + success: false, + error: + result.status === "cancelled" + ? "Branches have diverged. Open Source Control and choose Merge or Rebase." + : result.message, + }; + } finally { + unsubscribe(); + } +}; diff --git a/windows/tauri/src/features/git/components/git-actions-menu.tsx b/windows/tauri/src/features/git/components/git-actions-menu.tsx index 141225aa..339c9a43 100644 --- a/windows/tauri/src/features/git/components/git-actions-menu.tsx +++ b/windows/tauri/src/features/git/components/git-actions-menu.tsx @@ -18,12 +18,7 @@ import { Spinner } from "@/ui/spinner"; import { showConfirmDialog } from "@/ui/dialog"; import { toast } from "sonner"; import { useTranslation } from "@/i18n/locale-provider"; -import { - fetchChanges, - pullChanges, - pushChanges, - type GitRemoteActionResult, -} from "../api/git-remotes-api"; +import { fetchChanges, pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; import { discardAllChanges, initRepository } from "../api/git-status-api"; import { useGitStore } from "../stores/git.store"; import { type GitActionsMenuAnchorRect } from "../utils/git-actions-menu-position"; @@ -35,6 +30,8 @@ interface GitActionsMenuProps { hasGitRepo: boolean; repoPath?: string; onRefresh?: () => void; + onPull?: () => Promise | void; + isPulling?: boolean; onOpenBranchManager?: () => void; onShowBranchDiff?: () => void; onOpenRemoteManager?: () => void; @@ -53,6 +50,8 @@ const GitActionsMenu = ({ hasGitRepo, repoPath, onRefresh, + onPull, + isPulling = false, onOpenBranchManager, onShowBranchDiff, onOpenRemoteManager, @@ -122,11 +121,8 @@ const GitActionsMenu = ({ }; const handlePull = () => { - handleAction(() => pullChanges(repoPath!), "Pull", { - loading: "Pulling changes...", - success: "Changes pulled successfully.", - error: "Failed to pull changes.", - }); + void onPull?.(); + onClose(); }; const handleFetch = () => { @@ -226,7 +222,7 @@ const GitActionsMenu = ({ id: "push", label: t("git.pushChanges"), icon: , - disabled: isLoading, + disabled: isLoading || isPulling, onClick: handlePush, }, { id: "sep-2", label: "", separator: true, onClick: () => {} }, @@ -234,14 +230,14 @@ const GitActionsMenu = ({ id: "pull", label: t("git.pullChanges"), icon: , - disabled: isLoading, + disabled: isLoading || isPulling, onClick: handlePull, }, { id: "fetch", label: t("git.fetch"), icon: , - disabled: isLoading, + disabled: isLoading || isPulling, onClick: handleFetch, }, { id: "sep-3", label: "", separator: true, onClick: () => {} }, diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index d51e02f4..ce2052c5 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -26,7 +26,7 @@ import { import { getFileDiff } from "../api/git-diff-api"; import { commitChanges, getGitLog } from "../api/git-commits-api"; import { getConflictMarkerPaths } from "../api/git-integration-api"; -import { pullChanges, pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; +import { pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; import { useGitBlameStore } from "../stores/git-blame.store"; import { useGitStore } from "../stores/git.store"; import type { GitDiff, GitFile } from "../types/git.types"; @@ -39,6 +39,8 @@ interface GitCommitPanelProps { ahead?: number; behind?: number; onCommitSuccess?: () => void; + onPull?: () => Promise | void; + isPulling?: boolean; } const MAX_STAGED_FILES_FOR_AI_CONTEXT = 120; @@ -191,6 +193,8 @@ const GitCommitPanel = ({ ahead = 0, behind = 0, onCommitSuccess, + onPull, + isPulling = false, }: GitCommitPanelProps) => { const { t } = useTranslation(); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -201,7 +205,7 @@ const GitCommitPanel = ({ const [isGenerating, setIsGenerating] = useState(false); const [commitMessageMode, setCommitMessageMode] = useState("title"); const [isGenerateModeMenuOpen, setIsGenerateModeMenuOpen] = useState(false); - const [remoteAction, setRemoteAction] = useState<"push" | "pull" | null>(null); + const [remoteAction, setRemoteAction] = useState<"push" | null>(null); const [error, setError] = useState(null); const generateMenuAnchorRef = useRef(null); const commitTextareaRef = useRef(null); @@ -331,42 +335,33 @@ const GitCommitPanel = ({ } }; - const handleRemoteAction = async ( - action: "push" | "pull", - run: () => Promise, - ) => { + const handlePush = async (run: () => Promise) => { if (!repoPath) return; - const label = action === "push" ? "Push" : "Pull"; let toastId: string | number | null = null; - setRemoteAction(action); + setRemoteAction("push"); setError(null); try { - toastId = toast.info(`${label}ing changes...`, { + toastId = toast.info("Pushing changes...", { duration: 0, }); const result = await run(); if (result.success) { - if (action === "pull") { - useGitBlameStore.getState().actions.clearAllBlame(); - } toast.dismiss(toastId); - toast.success( - action === "push" ? "Changes pushed successfully." : "Changes pulled successfully.", - ); + toast.success("Changes pushed successfully."); onCommitSuccess?.(); return; } - const errorMessage = result.error || `Failed to ${action} changes.`; + const errorMessage = result.error || "Failed to push changes."; toast.dismiss(toastId); toast.error(errorMessage); setError(errorMessage); } catch (remoteError) { const errorMessage = - remoteError instanceof Error ? remoteError.message : `Failed to ${action} changes.`; + remoteError instanceof Error ? remoteError.message : "Failed to push changes."; if (toastId) toast.dismiss(toastId); toast.error(errorMessage); setError(errorMessage); @@ -451,8 +446,8 @@ const GitCommitPanel = ({ {ahead > 0 && ( + + + + + + ); +}; + +export default GitPullStrategyDialog; diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index ca914ef6..cb6961be 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -36,11 +36,13 @@ import { matchesSearchQuery } from "@/utils/search-match"; import { getBranches } from "../api/git-branches-api"; import { getStatusDiffStats } from "../api/git-diff-api"; import { clearRepositoryDiscoveryCache, resolveRepositoryPath } from "../api/git-repo-api"; -import { fetchChanges, pullChanges, pushChanges } from "../api/git-remotes-api"; +import { fetchChanges, getRemotes, pushChanges } from "../api/git-remotes-api"; import { applyStash, dropStash, popStash } from "../api/git-stash-api"; import { getGitStatus, initRepository } from "../api/git-status-api"; import { useGitDataController } from "../hooks/use-git-data-controller"; import { useGitDiffActions } from "../hooks/use-git-diff-actions"; +import { useGitPullWorkflow } from "../hooks/use-git-pull-workflow"; +import { useGitBlameStore } from "../stores/git-blame.store"; import { useRepositoryStore } from "../stores/git-repository.store"; import { useGitStore } from "../stores/git.store"; import type { GitFile } from "../types/git.types"; @@ -60,6 +62,7 @@ import GitCommandSurface from "./git-command-surface"; import GitRemoteManager from "./git-remote-manager"; import GitTagManager from "./git-tag-manager"; import GitOperationBanner from "./git-operation-banner"; +import GitPullStrategyDialog from "./git-pull-strategy-dialog"; import GitStatusPanel from "./status/git-status-panel"; interface GitViewProps { @@ -108,6 +111,24 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { workspacePath: repoPath, isActive, }); + const refreshPullState = useCallback(async () => { + if (!activeRepoPath) return; + await Promise.all([handleManualRefresh(), getRemotes(activeRepoPath)]); + }, [activeRepoPath, handleManualRefresh]); + const pullWorkflow = useGitPullWorkflow({ + repoPath: activeRepoPath ?? "", + refresh: refreshPullState, + }); + const handlePull = useCallback(async () => { + if (!activeRepoPath) { + toast.error("No repository open"); + return; + } + const result = await pullWorkflow.pull(); + if (result.status === "pulled") { + useGitBlameStore.getState().actions.clearAllBlame(); + } + }, [activeRepoPath, pullWorkflow.pull]); const [showGitActionsMenu, setShowGitActionsMenu] = useState(false); const [showStashList, setShowStashList] = useState(false); const [isSelectingRepo, setIsSelectingRepo] = useState(false); @@ -294,6 +315,10 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { } setIsSyncMenuOpen(false); + if (action === "pull") { + await handlePull(); + return; + } setRemoteAction(action); const label = REMOTE_ACTION_LABELS[action]; const toastId = toast.info(`${label.present} changes...`, { @@ -304,9 +329,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const result = action === "push" ? await pushChanges(activeRepoPath) - : action === "pull" - ? await pullChanges(activeRepoPath) - : await fetchChanges(activeRepoPath); + : await fetchChanges(activeRepoPath); toast.dismiss(toastId); @@ -324,22 +347,23 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { setRemoteAction(null); } }, - [activeRepoPath, handleManualRefresh], + [activeRepoPath, handleManualRefresh, handlePull], ); const aheadCount = gitStatus?.ahead ?? 0; const behindCount = gitStatus?.behind ?? 0; const primaryRemoteAction: GitRemoteAction = aheadCount > 0 ? "push" : behindCount > 0 ? "pull" : "fetch"; - const syncActionLabel = - remoteAction !== null + const syncActionLabel = pullWorkflow.isPulling + ? t("git.pulling") + : remoteAction !== null ? t(`git.${remoteAction}ing`) : primaryRemoteAction === "push" ? t("git.pushCount", { count: aheadCount }) : primaryRemoteAction === "pull" ? t("git.pullCount", { count: behindCount }) : t("git.fetch"); - const isRemoteActionLoading = remoteAction !== null; + const isRemoteActionLoading = remoteAction !== null || pullWorkflow.isPulling; const syncMenuItems = useMemo( () => [ @@ -660,6 +684,8 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { hasGitRepo={hasGitRepo} repoPath={activeRepoPath ?? repoPath} onRefresh={onRefresh} + onPull={handlePull} + isPulling={pullWorkflow.isPulling} onOpenBranchManager={handleOpenBranchManager} onShowBranchDiff={() => void handleShowBranchDiffList()} onOpenRemoteManager={() => setShowRemoteManager(true)} @@ -912,6 +938,8 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { ahead={gitStatus.ahead} behind={gitStatus.behind} onCommitSuccess={refreshAfterAction} + onPull={handlePull} + isPulling={pullWorkflow.isPulling} /> @@ -919,6 +947,10 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { {renderGitActionsMenu({ hasGitRepo: !!gitStatus, onRefresh: refreshAfterAction })} + { diff --git a/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts b/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts new file mode 100644 index 00000000..ac12be3f --- /dev/null +++ b/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test"; +import type { GitOperationState, GitPullPreflight, PullStrategy } from "../types/git.types"; +import { GitPullWorkflow, type GitPullWorkflowDependencies } from "./git-pull-workflow"; + +const cleanPreflight = (overrides: Partial = {}): GitPullPreflight => ({ + upstream: "origin/main", + ahead: 0, + behind: 1, + diverged: false, + hasLocalChanges: false, + ...overrides, +}); + +const operation = (kind: GitOperationState["kind"]): GitOperationState => ({ + kind, + reference: "origin/main", + step: null, + total: null, + conflictedPaths: ["src/conflict.ts"], +}); + +const createHarness = (overrides: Partial = {}) => { + const pullStrategies: PullStrategy[] = []; + let refreshCount = 0; + const dependencies: GitPullWorkflowDependencies = { + fetch: async () => ({ success: true }), + preflight: async () => cleanPreflight(), + pull: async (_repoPath, strategy) => { + pullStrategies.push(strategy); + return { success: true }; + }, + operationState: async () => null, + ...overrides, + }; + const workflow = new GitPullWorkflow(dependencies); + const options = { + refresh: async () => { + refreshCount += 1; + }, + }; + return { + workflow, + options, + pullStrategies, + refreshCount: () => refreshCount, + }; +}; + +const waitForStrategyDialog = async (workflow: GitPullWorkflow) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (workflow.getSnapshot().pendingPreflight) return; + await Promise.resolve(); + } + throw new Error("Strategy dialog was not requested"); +}; + +describe("GitPullWorkflow", () => { + test("stops when Fetch fails and still refreshes", async () => { + let preflightCalls = 0; + const harness = createHarness({ + fetch: async () => ({ success: false, error: "offline" }), + preflight: async () => { + preflightCalls += 1; + return cleanPreflight(); + }, + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toEqual({ + status: "failed", + stage: "fetch", + message: "Fetch failed: offline", + }); + expect(preflightCalls).toBe(0); + expect(harness.refreshCount()).toBe(1); + }); + + test("blocks a branch without an upstream", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ upstream: null, behind: 0 }), + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result.status).toBe("blocked"); + expect(result).toMatchObject({ reason: "no-upstream" }); + expect(harness.pullStrategies).toEqual([]); + expect(harness.refreshCount()).toBe(1); + }); + + test("fails closed when preflight cannot inspect the branch", async () => { + const harness = createHarness({ + preflight: async () => { + throw new Error("Core unavailable"); + }, + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toEqual({ + status: "failed", + stage: "preflight", + message: "Pull safety check failed: Core unavailable", + }); + expect(harness.pullStrategies).toEqual([]); + expect(harness.refreshCount()).toBe(1); + }); + + test("does not pull when the upstream has nothing new", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ ahead: 2, behind: 0 }), + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toMatchObject({ status: "blocked", reason: "up-to-date" }); + expect(harness.pullStrategies).toEqual([]); + }); + + test("uses ffOnly when only the remote is ahead", async () => { + const harness = createHarness(); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toMatchObject({ status: "pulled", strategy: "ffOnly" }); + expect(harness.pullStrategies).toEqual(["ffOnly"]); + }); + + test("blocks a dirty working tree before choosing or pulling", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ hasLocalChanges: true }), + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toMatchObject({ status: "blocked", reason: "dirty" }); + expect(result.message).toContain("Commit or stash"); + expect(harness.pullStrategies).toEqual([]); + }); + + test("uses Merge only after an explicit divergent-history choice", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ ahead: 2, behind: 3, diverged: true }), + }); + + const resultPromise = harness.workflow.run("C:/repo", harness.options); + await waitForStrategyDialog(harness.workflow); + harness.workflow.chooseStrategy("merge"); + const result = await resultPromise; + + expect(result).toMatchObject({ status: "pulled", strategy: "merge" }); + expect(harness.pullStrategies).toEqual(["merge"]); + }); + + test("uses Rebase only after an explicit divergent-history choice", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ ahead: 1, behind: 4, diverged: true }), + }); + + const resultPromise = harness.workflow.run("C:/repo", harness.options); + await waitForStrategyDialog(harness.workflow); + harness.workflow.chooseStrategy("rebase"); + const result = await resultPromise; + + expect(result).toMatchObject({ status: "pulled", strategy: "rebase" }); + expect(harness.pullStrategies).toEqual(["rebase"]); + }); + + test("blocks when the working tree becomes dirty while choosing a strategy", async () => { + let preflightCalls = 0; + const harness = createHarness({ + preflight: async () => { + preflightCalls += 1; + return cleanPreflight({ + ahead: 1, + behind: 1, + diverged: true, + hasLocalChanges: preflightCalls > 1, + }); + }, + }); + + const resultPromise = harness.workflow.run("C:/repo", harness.options); + await waitForStrategyDialog(harness.workflow); + harness.workflow.chooseStrategy("merge"); + const result = await resultPromise; + + expect(result).toMatchObject({ status: "blocked", reason: "dirty" }); + expect(preflightCalls).toBe(2); + expect(harness.pullStrategies).toEqual([]); + }); + + test("blocks when the upstream state changes while choosing a strategy", async () => { + let preflightCalls = 0; + const harness = createHarness({ + preflight: async () => { + preflightCalls += 1; + return cleanPreflight({ + upstream: preflightCalls === 1 ? "origin/main" : "origin/release", + ahead: 1, + behind: 1, + diverged: true, + }); + }, + }); + + const resultPromise = harness.workflow.run("C:/repo", harness.options); + await waitForStrategyDialog(harness.workflow); + harness.workflow.chooseStrategy("rebase"); + const result = await resultPromise; + + expect(result).toMatchObject({ status: "blocked", reason: "state-changed" }); + expect(preflightCalls).toBe(2); + expect(harness.pullStrategies).toEqual([]); + }); + + test("Cancel is the safe divergent-history default", async () => { + const harness = createHarness({ + preflight: async () => cleanPreflight({ ahead: 1, behind: 1, diverged: true }), + }); + + const resultPromise = harness.workflow.run("C:/repo", harness.options); + await waitForStrategyDialog(harness.workflow); + harness.workflow.chooseStrategy(null); + const result = await resultPromise; + + expect(result.status).toBe("cancelled"); + expect(harness.pullStrategies).toEqual([]); + expect(harness.refreshCount()).toBe(1); + }); + + test("reports an ordinary Pull failure after confirming no operation remains", async () => { + let operationStateCalls = 0; + const harness = createHarness({ + pull: async () => ({ success: false, error: "authentication failed" }), + operationState: async () => { + operationStateCalls += 1; + return null; + }, + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toEqual({ + status: "failed", + stage: "pull", + message: "Pull failed: authentication failed", + }); + expect(operationStateCalls).toBe(1); + expect(harness.refreshCount()).toBe(1); + }); + + test("hands a conflicted Pull to the existing operation banner", async () => { + const mergeOperation = operation("merge"); + const harness = createHarness({ + pull: async () => ({ success: false, error: "CONFLICT" }), + operationState: async () => mergeOperation, + }); + + const result = await harness.workflow.run("C:/repo", harness.options); + + expect(result).toEqual({ + status: "conflict", + operation: mergeOperation, + message: "Merge stopped for conflict resolution.", + }); + expect(harness.refreshCount()).toBe(1); + }); + + test("ignores a duplicate click while the first Pull is running", async () => { + let releaseFetch: ((result: { success: boolean }) => void) | undefined; + const harness = createHarness({ + fetch: () => + new Promise((resolve) => { + releaseFetch = resolve; + }), + preflight: async () => cleanPreflight({ behind: 0 }), + }); + + const first = harness.workflow.run("C:/repo", harness.options); + const duplicate = await harness.workflow.run("C:/repo", harness.options); + + expect(duplicate.status).toBe("duplicate"); + releaseFetch?.({ success: true }); + await first; + expect(harness.refreshCount()).toBe(1); + }); +}); diff --git a/windows/tauri/src/features/git/hooks/git-pull-workflow.ts b/windows/tauri/src/features/git/hooks/git-pull-workflow.ts new file mode 100644 index 00000000..85922c9c --- /dev/null +++ b/windows/tauri/src/features/git/hooks/git-pull-workflow.ts @@ -0,0 +1,205 @@ +import type { + GitOperationState, + GitPullPreflight, + GitPullResult, + PullStrategy, +} from "../types/git.types"; + +interface RemoteActionResult { + success: boolean; + error?: string; +} + +export interface GitPullWorkflowDependencies { + fetch: (repoPath: string) => Promise; + preflight: (repoPath: string) => Promise; + pull: (repoPath: string, strategy: PullStrategy) => Promise; + operationState: (repoPath: string) => Promise; +} + +export interface GitPullWorkflowOptions { + refresh: () => Promise; +} + +export interface GitPullWorkflowSnapshot { + isPulling: boolean; + pendingPreflight: GitPullPreflight | null; +} + +const IDLE_SNAPSHOT: GitPullWorkflowSnapshot = { + isPulling: false, + pendingPreflight: null, +}; + +const errorText = (error: unknown, fallback: string) => { + const message = error instanceof Error ? error.message : String(error); + return message.trim() || fallback; +}; + +/** Coordinates one safe pull path, including an explicit divergent-history choice. */ +export class GitPullWorkflow { + private snapshot: GitPullWorkflowSnapshot = IDLE_SNAPSHOT; + private readonly listeners = new Set<() => void>(); + private resolveStrategy: ((strategy: PullStrategy | null) => void) | null = null; + + constructor(private readonly dependencies: GitPullWorkflowDependencies) {} + + readonly subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + readonly getSnapshot = () => this.snapshot; + + chooseStrategy(strategy: Exclude | null) { + this.resolveStrategy?.(strategy); + } + + async run(repoPath: string, options: GitPullWorkflowOptions): Promise { + if (this.snapshot.isPulling) { + return { status: "duplicate", message: "A pull is already in progress." }; + } + + this.update({ isPulling: true, pendingPreflight: null }); + try { + const fetched = await this.dependencies.fetch(repoPath); + if (!fetched.success) { + return { + status: "failed", + stage: "fetch", + message: `Fetch failed: ${fetched.error || "Unable to update remote references."}`, + }; + } + + let preflight: GitPullPreflight; + try { + preflight = await this.dependencies.preflight(repoPath); + } catch (error) { + return { + status: "failed", + stage: "preflight", + message: `Pull safety check failed: ${errorText(error, "Unable to inspect the branch.")}`, + }; + } + + if (!preflight.upstream) { + return { + status: "blocked", + reason: "no-upstream", + message: "The current branch has no upstream. Set an upstream before pulling.", + }; + } + + if (preflight.hasLocalChanges) { + return { + status: "blocked", + reason: "dirty", + message: "Commit or stash local changes before pulling.", + }; + } + + if (preflight.behind === 0 && !preflight.diverged) { + return { + status: "blocked", + reason: "up-to-date", + message: "The current branch is already up to date.", + }; + } + + let strategy: PullStrategy = "ffOnly"; + if (preflight.diverged) { + const selectedStrategy = await this.waitForStrategy(preflight); + if (!selectedStrategy) { + return { status: "cancelled", message: "Pull cancelled." }; + } + + let currentPreflight: GitPullPreflight; + try { + currentPreflight = await this.dependencies.preflight(repoPath); + } catch (error) { + return { + status: "failed", + stage: "preflight", + message: `Pull safety check failed: ${errorText(error, "Unable to inspect the branch.")}`, + }; + } + + if (currentPreflight.hasLocalChanges) { + return { + status: "blocked", + reason: "dirty", + message: "Commit or stash local changes before pulling.", + }; + } + + if ( + currentPreflight.upstream !== preflight.upstream || + currentPreflight.ahead !== preflight.ahead || + currentPreflight.behind !== preflight.behind || + currentPreflight.diverged !== preflight.diverged + ) { + return { + status: "blocked", + reason: "state-changed", + message: "The branch changed while choosing a pull strategy. Review it and try again.", + }; + } + strategy = selectedStrategy; + } + + const pulled = await this.dependencies.pull(repoPath, strategy); + if (pulled.success) { + const message = + strategy === "merge" + ? "Merged upstream changes successfully." + : strategy === "rebase" + ? "Rebased onto upstream successfully." + : "Pulled changes successfully."; + return { status: "pulled", strategy, message }; + } + + try { + const operation = await this.dependencies.operationState(repoPath); + if (operation?.kind === "merge" || operation?.kind === "rebase") { + return { + status: "conflict", + operation, + message: `${operation.kind === "merge" ? "Merge" : "Rebase"} stopped for conflict resolution.`, + }; + } + } catch (stateError) { + console.error("Failed to inspect Git operation state after pull failed:", stateError); + } + + return { + status: "failed", + stage: "pull", + message: `Pull failed: ${pulled.error || "Git rejected the pull."}`, + }; + } finally { + this.resolveStrategy = null; + try { + await options.refresh(); + } catch (refreshError) { + console.error("Failed to refresh Git data after pull:", refreshError); + } + this.update(IDLE_SNAPSHOT); + } + } + + private waitForStrategy(preflight: GitPullPreflight): Promise<"merge" | "rebase" | null> { + return new Promise((resolve) => { + this.resolveStrategy = (strategy) => { + this.resolveStrategy = null; + this.update({ isPulling: true, pendingPreflight: null }); + resolve(strategy === "merge" || strategy === "rebase" ? strategy : null); + }; + this.update({ isPulling: true, pendingPreflight: preflight }); + }); + } + + private update(snapshot: GitPullWorkflowSnapshot) { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} diff --git a/windows/tauri/src/features/git/hooks/use-git-pull-workflow.ts b/windows/tauri/src/features/git/hooks/use-git-pull-workflow.ts new file mode 100644 index 00000000..e878f28f --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-pull-workflow.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { toast } from "sonner"; +import { getGitPullWorkflow } from "../api/git-remotes-api"; +import { emitGitChanged } from "../events/git-events"; +import type { GitPullResult, PullStrategy } from "../types/git.types"; + +interface UseGitPullWorkflowOptions { + repoPath: string; + refresh: () => Promise; +} + +const reportResult = (result: GitPullResult) => { + if (result.status === "duplicate" || result.status === "cancelled") return; + if (result.status === "pulled") { + toast.success(result.message); + } else if (result.status === "conflict") { + toast.warning(result.message); + } else if (result.status === "blocked") { + result.reason === "up-to-date" ? toast.info(result.message) : toast.warning(result.message); + } else { + toast.error(result.message); + } +}; + +export function useGitPullWorkflow({ repoPath, refresh }: UseGitPullWorkflowOptions) { + const workflow = useMemo(() => getGitPullWorkflow(repoPath), [repoPath]); + const snapshot = useSyncExternalStore( + workflow.subscribe, + workflow.getSnapshot, + workflow.getSnapshot, + ); + + const pull = useCallback(async () => { + const result = await workflow.run(repoPath, { + refresh: async () => { + // Invalidate read caches before the owning controller re-reads every + // Pull-related surface, including after a rejected or cancelled attempt. + emitGitChanged({ + repoPath, + scopes: ["working-tree", "history", "refs", "remotes"], + source: "pull-finished", + }); + await refresh(); + }, + }); + reportResult(result); + return result; + }, [refresh, repoPath, workflow]); + + const chooseStrategy = useCallback( + (strategy: Exclude | null) => workflow.chooseStrategy(strategy), + [workflow], + ); + + useEffect( + () => () => { + workflow.chooseStrategy(null); + }, + [repoPath, workflow], + ); + + return { + ...snapshot, + pull, + chooseStrategy, + }; +} diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 392f12d8..2bfe165f 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -95,6 +95,34 @@ export interface GitRemote { url: string; } +/** The current branch's relationship with its configured upstream. */ +export interface GitPullPreflight { + upstream: string | null; + ahead: number; + behind: number; + diverged: boolean; + hasLocalChanges: boolean; +} + +/** A pull policy accepted by the shared Rust Core. */ +export type PullStrategy = "ffOnly" | "merge" | "rebase"; + +export type GitPullResult = + | { status: "pulled"; strategy: PullStrategy; message: string } + | { status: "cancelled"; message: string } + | { + status: "blocked"; + reason: "no-upstream" | "up-to-date" | "dirty" | "state-changed"; + message: string; + } + | { + status: "failed"; + stage: "fetch" | "preflight" | "pull"; + message: string; + } + | { status: "conflict"; operation: GitOperationState; message: string } + | { status: "duplicate"; message: string }; + export interface GitStash { index: number; message: string;