diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index ea1e310dd..6551bebb2 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -148,6 +148,41 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { ); "git.checkoutPreflight" } + "git_merge" | "git_rebase" => { + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); + payload.insert( + "operation".into(), + json!(if command == "git_merge" { + "merge" + } else { + "rebase" + }), + ); + "git.write" + } + "git_integration_preflight" => { + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); + "git.integrationPreflight" + } + "git_operation_state" => "git.operationState", + "git_operation_continue" | "git_operation_abort" | "git_operation_skip" => { + let operation = match command { + "git_operation_continue" => "operationContinue", + "git_operation_abort" => "operationAbort", + _ => "operationSkip", + }; + payload.insert("operation".into(), json!(operation)); + "git.write" + } + "git_conflict_markers" => "git.conflictMarkers", "git_create_stash" => { payload.insert("operation".into(), json!("stashPush")); "git.write" @@ -500,6 +535,89 @@ mod tests { ); } + #[test] + fn translates_merge_and_rebase_to_qualified_references() { + let (merge_command, merge_payload) = translate( + "git_merge", + json!({ "repoPath": "C:/work", "branchName": "feature/demo" }), + ) + .unwrap(); + assert_eq!(merge_command, "git.write"); + assert_eq!( + merge_payload, + json!({ + "root": "C:/work", + "operation": "merge", + "reference": "refs/heads/feature/demo" + }) + ); + + let (rebase_command, rebase_payload) = translate( + "git_rebase", + json!({ "repoPath": "C:/work", "branchName": "main" }), + ) + .unwrap(); + assert_eq!(rebase_command, "git.write"); + assert_eq!( + rebase_payload, + json!({ + "root": "C:/work", + "operation": "rebase", + "reference": "refs/heads/main" + }) + ); + } + + #[test] + fn translates_integration_preflight_operation() { + let (command, payload) = translate( + "git_integration_preflight", + json!({ "repoPath": "C:/work", "branchName": "main", "operation": "rebase" }), + ) + .unwrap(); + + assert_eq!(command, "git.integrationPreflight"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "reference": "refs/heads/main", + "operation": "rebase" + }) + ); + } + + #[test] + fn translates_operation_state_and_resolution_commands() { + let (state_command, state_payload) = + translate("git_operation_state", json!({ "repoPath": "C:/work" })).unwrap(); + assert_eq!(state_command, "git.operationState"); + assert_eq!(state_payload, json!({ "root": "C:/work" })); + + for (compat, operation) in [ + ("git_operation_continue", "operationContinue"), + ("git_operation_abort", "operationAbort"), + ("git_operation_skip", "operationSkip"), + ] { + let (command, payload) = + translate(compat, json!({ "repoPath": "C:/work" })).unwrap(); + assert_eq!(command, "git.write"); + assert_eq!( + payload, + json!({ "root": "C:/work", "operation": operation }) + ); + } + } + + #[test] + fn translates_conflict_markers_request() { + let (command, payload) = + translate("git_conflict_markers", json!({ "repoPath": "C:/work" })).unwrap(); + + assert_eq!(command, "git.conflictMarkers"); + assert_eq!(payload, json!({ "root": "C:/work" })); + } + #[test] fn translates_delete_branch_to_qualified_reference() { let (command, payload) = translate( diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts new file mode 100644 index 000000000..6731ea69b --- /dev/null +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { GitOperationState } from "../types/git.types"; + +const invoke = mock(async (_command: string, _args?: unknown): Promise => null); +const emitGitChanged = mock((_change: unknown) => {}); +const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath); + +mock.module("@/platform/tauri-core", () => ({ invoke })); +mock.module("../events/git-events", () => ({ emitGitChanged })); +mock.module("./git-repo-api", () => ({ resolveRepositoryPathOrThrow })); + +const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import( + "./git-integration-api" +); + +const operationState = ( + kind: GitOperationState["kind"], + conflictedPaths: string[] = [], +): GitOperationState => ({ + kind, + reference: null, + conflictedPaths, + step: null, + total: null, +}); + +beforeEach(() => { + invoke.mockReset(); + emitGitChanged.mockReset(); + resolveRepositoryPathOrThrow.mockReset(); + resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath); +}); + +describe("Git integration state", () => { + test("reports a stopped rebase even when no conflicted paths remain", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_integration_preflight") { + return { blockingPaths: [], blocksEntirely: false }; + } + if (command === "git_rebase") throw new Error("rebase stopped"); + if (command === "git_operation_state") return operationState("rebase"); + return null; + }); + + await expect(rebaseOntoBranch("C:/repo", "main")).resolves.toEqual({ status: "stopped" }); + expect(emitGitChanged).toHaveBeenCalledWith({ + repoPath: "C:/repo", + scopes: ["working-tree", "history", "refs"], + source: "rebase-rejected", + }); + }); + + test("reports conflicted paths when a merge stops on conflicts", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_integration_preflight") { + return { blockingPaths: [], blocksEntirely: false }; + } + if (command === "git_merge") throw new Error("merge stopped"); + if (command === "git_operation_state") { + return operationState("merge", ["src/app.ts"]); + } + return null; + }); + + await expect(mergeBranch("C:/repo", "feature")).resolves.toEqual({ + status: "conflicts", + conflictedPaths: ["src/app.ts"], + }); + }); + + test("propagates operation state query failures", async () => { + invoke.mockRejectedValue(new Error("Core unavailable")); + + await expect(getOperationState("C:/repo")).rejects.toThrow("Core unavailable"); + }); + + test("propagates conflict marker query failures so commits fail closed", async () => { + invoke.mockRejectedValue(new Error("Core unavailable")); + + await expect(getConflictMarkerPaths("C:/repo")).rejects.toThrow("Core unavailable"); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-integration-api.ts b/windows/tauri/src/features/git/api/git-integration-api.ts new file mode 100644 index 000000000..2fea725ec --- /dev/null +++ b/windows/tauri/src/features/git/api/git-integration-api.ts @@ -0,0 +1,142 @@ +import { invoke as tauriInvoke } from "@/platform/tauri-core"; +import { emitGitChanged } from "../events/git-events"; +import { resolveRepositoryPathOrThrow } from "./git-repo-api"; +import type { GitOperationState } from "../types/git.types"; + +type IntegrationOperation = "merge" | "rebase"; + +interface IntegrationPreflightResult { + blockingPaths: string[]; + blocksEntirely: boolean; +} + +export type IntegrationOutcome = + | { status: "clean" } + | { status: "conflicts"; conflictedPaths: string[] } + | { status: "stopped" } + | { status: "blocked"; blockingPaths: string[]; blocksEntirely: boolean } + | { status: "error"; message: string }; + +export interface OperationResolution { + ok: boolean; + message: string; +} + +const errorMessage = (error: unknown): string => { + const message = error instanceof Error ? error.message : String(error); + return message.trim() || "Git operation failed"; +}; + +const notifyOperationChanged = (repoPath: string, source: string) => { + emitGitChanged({ + repoPath, + scopes: ["working-tree", "history", "refs"], + source, + }); +}; + +export const getOperationState = async ( + repoPath: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + return tauriInvoke("git_operation_state", { + repoPath: resolvedRepoPath, + }); +}; + +export const getConflictMarkerPaths = async (repoPath: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + const result = await tauriInvoke<{ paths: string[] }>("git_conflict_markers", { + repoPath: resolvedRepoPath, + }); + return result.paths; +}; + +const runIntegration = async ( + repoPath: string, + branchName: string, + operation: IntegrationOperation, +): Promise => { + const command = operation === "merge" ? "git_merge" : "git_rebase"; + try { + await tauriInvoke(command, { repoPath, branchName }); + notifyOperationChanged(repoPath, `${operation}-completed`); + return { status: "clean" }; + } catch (error) { + // A conflict stop exits non-zero like a real failure; the authoritative + // distinction is whether Git left an operation state behind. + notifyOperationChanged(repoPath, `${operation}-rejected`); + try { + const state = await getOperationState(repoPath); + if (state?.kind === operation) { + if (state.conflictedPaths.length > 0) { + return { status: "conflicts", conflictedPaths: state.conflictedPaths }; + } + return { status: "stopped" }; + } + } catch (stateError) { + console.error(`Failed to read ${operation} state after Git rejected the operation:`, stateError); + } + return { status: "error", message: errorMessage(error) }; + } +}; + +const startIntegration = async ( + repoPath: string, + branchName: string, + operation: IntegrationOperation, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + + let preflight: IntegrationPreflightResult | null = null; + try { + preflight = await tauriInvoke( + "git_integration_preflight", + { repoPath: resolvedRepoPath, branchName, operation }, + ); + } catch { + // A failed preflight must not block the operation itself; Git will still + // refuse safely when the tree is dirty. + } + + if (preflight && preflight.blockingPaths.length > 0) { + return { + status: "blocked", + blockingPaths: preflight.blockingPaths, + blocksEntirely: preflight.blocksEntirely, + }; + } + + return runIntegration(resolvedRepoPath, branchName, operation); +}; + +export const mergeBranch = (repoPath: string, branchName: string) => + startIntegration(repoPath, branchName, "merge"); + +export const rebaseOntoBranch = (repoPath: string, branchName: string) => + startIntegration(repoPath, branchName, "rebase"); + +const resolveOperation = async ( + repoPath: string, + action: "git_operation_continue" | "git_operation_abort" | "git_operation_skip", +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + try { + await tauriInvoke(action, { repoPath: resolvedRepoPath }); + notifyOperationChanged(resolvedRepoPath, "operation-resolved"); + return { ok: true, message: "Git operation finished" }; + } catch (error) { + // Even a rejected continue can change repository state, so refresh anyway. + notifyOperationChanged(resolvedRepoPath, "operation-resolution-rejected"); + return { ok: false, message: errorMessage(error) }; + } +}; + +export const continueOperation = (repoPath: string) => + resolveOperation(repoPath, "git_operation_continue"); + +export const abortOperation = (repoPath: string) => + resolveOperation(repoPath, "git_operation_abort"); + +export const skipOperationStep = (repoPath: string) => + resolveOperation(repoPath, "git_operation_skip"); diff --git a/windows/tauri/src/features/git/components/git-branch-manager.tsx b/windows/tauri/src/features/git/components/git-branch-manager.tsx index fcc2b813e..dd8e4d5c7 100644 --- a/windows/tauri/src/features/git/components/git-branch-manager.tsx +++ b/windows/tauri/src/features/git/components/git-branch-manager.tsx @@ -19,12 +19,20 @@ import { CommandTabs, useCommandListNavigation, } from "@/ui/command"; -import { GitBranchIcon, FolderOpenIcon, NodesIcon } from "@/ui/icons"; +import { GitBranchIcon, FolderOpenIcon, GitMergeIcon, NodesIcon, DotsThreeIcon } from "@/ui/icons"; import { showConfirmDialog } from "@/ui/dialog"; import { cn } from "@/utils/cn"; import { getFolderName, getRelativePath } from "@/utils/path-helpers"; import { matchesSearchQuery } from "@/utils/search-match"; import { checkoutBranch, createBranch, deleteBranch, getBranches } from "../api/git-branches-api"; +import { mergeBranch, rebaseOntoBranch, type IntegrationOutcome } from "../api/git-integration-api"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/ui/dropdown"; import { resolveRepositoryPath } from "../api/git-repo-api"; import { createStash } from "../api/git-stash-api"; import { addWorktree, getWorktrees } from "../api/git-worktrees-api"; @@ -343,6 +351,76 @@ const GitBranchManager = ({ } }; + const reportIntegrationOutcome = (branchName: string, outcome: IntegrationOutcome) => { + if (outcome.status === "clean") { + showToast({ message: `Merged ${branchName} successfully.`, type: "success" }); + } else if (outcome.status === "conflicts") { + showToast({ + message: `Stopped with ${outcome.conflictedPaths.length} conflicted file${ + outcome.conflictedPaths.length === 1 ? "" : "s" + }. Resolve them in the changes list, then continue.`, + type: "warning", + duration: 6000, + }); + } else if (outcome.status === "stopped") { + showToast({ + message: `${branchName} stopped before completion. Continue, skip, or abort from the changes list.`, + type: "warning", + duration: 6000, + }); + } else if (outcome.status === "blocked") { + const listed = outcome.blockingPaths.slice(0, 3).join(", "); + const remaining = outcome.blockingPaths.length - Math.min(outcome.blockingPaths.length, 3); + showToast({ + message: `Uncommitted changes would be overwritten: ${listed}${ + remaining > 0 ? ` (+${remaining} more)` + : ""}. Stash or commit them first.`, + type: "warning", + duration: 6000, + }); + } else { + showToast({ message: outcome.message, type: "error" }); + } + }; + + const handleIntegration = async (branchName: string, operation: "merge" | "rebase") => { + if (!repoPath || !branchName || branchName === currentBranch) return; + + const action = operation === "merge" ? "Merge" : "Rebase"; + const message = + operation === "merge" + ? `Merge branch "${branchName}" into "${currentBranch}"? Conflicts may require resolution.` + : `Rebase "${currentBranch}" onto "${branchName}"? Conflicts may require resolution.`; + const confirmed = await showConfirmDialog(message, { + title: `${action} Branch`, + confirmLabel: action, + }); + if (!confirmed) return; + + setIsLoading(true); + try { + const outcome = + operation === "merge" + ? await mergeBranch(repoPath, branchName) + : await rebaseOntoBranch(repoPath, branchName); + reportIntegrationOutcome(branchName, outcome); + if ( + outcome.status === "clean" || + outcome.status === "conflicts" || + outcome.status === "stopped" + ) { + onBranchChange?.(); + } + } catch (error) { + showToast({ + message: error instanceof Error ? error.message : `${action} failed.`, + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + const handleCreateBranch = async (branchName: string) => { if (!repoPath || !branchName.trim()) return; @@ -623,6 +701,8 @@ const GitBranchManager = ({ onMouseEnter={() => setSelectedIndex(index + (createBranchName ? 1 : 0))} onSelect={() => void handleBranchChange(branch)} onDelete={() => void handleDeleteBranch(branch)} + onMerge={() => void handleIntegration(branch, "merge")} + onRebase={() => void handleIntegration(branch, "rebase")} /> ))} @@ -772,6 +852,8 @@ function BranchRow({ onMouseEnter, onSelect, onDelete, + onMerge, + onRebase, }: { branch: string; isCurrent: boolean; @@ -780,6 +862,8 @@ function BranchRow({ onMouseEnter: () => void; onSelect: () => void; onDelete: () => void; + onMerge: () => void; + onRebase: () => void; }) { return ( current : null} action={ !isCurrent ? ( - + + + } + > + + + + + + Merge into Current Branch + + + + Rebase Current Branch onto This + + + + + Delete Branch + + + + ) : null } /> 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 2a4b51189..a080e6bce 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -24,8 +24,10 @@ import { } from "@/features/editor/services/editor-inline-edit-service"; 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 { useGitBlameStore } from "../stores/git-blame.store"; +import { useGitStore } from "../stores/git.store"; import type { GitDiff, GitFile } from "../types/git.types"; interface GitCommitPanelProps { @@ -287,6 +289,27 @@ const GitCommitPanel = ({ const handleCommit = async () => { if (!repoPath || !commitMessage.trim() || stagedFilesCount === 0) return; + // A conflicted merge/rebase must be resolved before the merge commit can + // be finalized; guard here so Git's raw refusal never reaches the user. + const conflictedPaths = useGitStore.getState().operationState?.conflictedPaths ?? []; + if (conflictedPaths.length > 0) { + setError(`Resolve the conflicts first: ${conflictedPaths.join(", ")}`); + return; + } + + let markerPaths: string[]; + try { + markerPaths = await getConflictMarkerPaths(repoPath); + } catch (markerError) { + console.error("Failed to check staged files for conflict markers:", markerError); + setError("Unable to verify conflict markers. Retry before committing."); + return; + } + if (markerPaths.length > 0) { + setError(`Conflict markers remain in: ${markerPaths.join(", ")}`); + return; + } + setIsCommitting(true); setError(null); diff --git a/windows/tauri/src/features/git/components/git-operation-banner.tsx b/windows/tauri/src/features/git/components/git-operation-banner.tsx new file mode 100644 index 000000000..96a0153e7 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-operation-banner.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/ui/button"; +import { WarningIcon } from "@/ui/icons"; +import { + abortOperation, + continueOperation, + skipOperationStep, + type OperationResolution, +} from "../api/git-integration-api"; +import { useGitStore } from "../stores/git.store"; +import { cn } from "@/utils/cn"; +import type { GitOperationKind } from "../types/git.types"; + +const IN_PROGRESS_TITLES: Record = { + merge: "Merge in progress", + rebase: "Rebase in progress", + cherryPick: "Cherry-pick in progress", + revert: "Revert in progress", +}; + +const CONTINUE_TITLES: Record = { + merge: "Continue Merge", + rebase: "Continue Rebase", + cherryPick: "Continue Cherry-pick", + revert: "Continue Revert", +}; + +interface GitOperationBannerProps { + repoPath: string; +} + +/** + * Pins the state of an in-progress merge/rebase/cherry-pick/revert above the + * changes list. Deliberately not a dialog: the controls must stay reachable + * while the user edits conflicted files, mirroring the macOS sidebar banner. + */ +const GitOperationBanner = ({ repoPath }: GitOperationBannerProps) => { + const operation = useGitStore((state) => state.operationState); + + const [isResolving, setIsResolving] = useState(false); + + const resolve = useCallback( + async (action: (repoPath: string) => Promise) => { + setIsResolving(true); + try { + const result = await action(repoPath); + // A rejected continue can still change state; the API already + // requested a refresh either way, so only surface the message here. + if (!result.ok) { + toast.error(result.message); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Git operation failed"); + } finally { + setIsResolving(false); + } + }, + [repoPath, toast], + ); + + if (!operation) { + return null; + } + + const hasConflicts = operation.conflictedPaths.length > 0; + const hasProgress = operation.step != null && operation.total != null; + + return ( +
+
+ + {IN_PROGRESS_TITLES[operation.kind]} + {operation.reference ? ( + + — {operation.reference.slice(0, 7)} + + ) : null} +
+ +
+ {hasProgress ? Step {operation.step} of {operation.total}. : null} + {hasConflicts ? ( + + Resolve {operation.conflictedPaths.length} conflicted file + {operation.conflictedPaths.length === 1 ? "" : "s"}, stage them, then continue. + + ) : ( + All conflicts resolved. Continue to finish, or abort to undo. + )} +
+ +
+ + {operation.kind === "rebase" ? ( + + ) : null} + +
+
+ ); +}; + +export default GitOperationBanner; diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index d70012b27..c8993cbf0 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -57,6 +57,7 @@ import GitCommitPanel from "./git-commit-panel"; 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 GitStatusPanel from "./status/git-status-panel"; interface GitViewProps { @@ -834,21 +835,24 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { { id: "changes", content: ( - void handleViewWorkingTreeDiff(scope)} - onShowCommitDiffPicker={handleShowCommitDiffList} - onShowBranchDiffPicker={() => void handleShowBranchDiffList()} - onShowStashDiffPicker={() => { - setShowStashList(true); - setStashSearchQuery(""); - }} - onRefresh={refreshAfterAction} - repoPath={activeRepoPath} - /> +
+ + void handleViewWorkingTreeDiff(scope)} + onShowCommitDiffPicker={handleShowCommitDiffList} + onShowBranchDiffPicker={() => void handleShowBranchDiffList()} + onShowStashDiffPicker={() => { + setShowStashList(true); + setStashSearchQuery(""); + }} + onRefresh={refreshAfterAction} + repoPath={activeRepoPath} + /> +
), }, { diff --git a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts index 1966e7d2a..c891d5e88 100644 --- a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts +++ b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { getBranches } from "../api/git-branches-api"; import { getGitHistory } from "../api/git-commits-api"; +import { getOperationState } from "../api/git-integration-api"; import { getStashes } from "../api/git-stash-api"; import { getGitStatus } from "../api/git-status-api"; import { @@ -40,11 +41,17 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.setIsLoadingGitData(true); try { - const [status, history, branches, stashes] = await Promise.all([ + const [status, history, branches, stashes, operationStateResult] = await Promise.all([ getGitStatus(repoPath), getGitHistory(repoPath, 50), getBranches(repoPath), getStashes(repoPath), + getOperationState(repoPath) + .then((value) => ({ ok: true as const, value })) + .catch((error) => { + console.error("Failed to load Git operation state:", error); + return { ok: false as const }; + }), ]); if ( @@ -60,6 +67,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl hasMoreCommits: history?.hasMore ?? false, branches, stashes, + operationState: operationStateResult.ok ? operationStateResult.value : null, repoPath, }); } catch (error) { @@ -91,11 +99,19 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl refreshAll || scopes.includes("refs") || scopes.includes("repository"); const shouldRefreshStashes = refreshAll || scopes.includes("stashes") || scopes.includes("repository"); - const [status, branches, stashes, history] = await Promise.all([ + const [status, branches, stashes, history, operationStateResult] = await Promise.all([ getGitStatus(repoPath), shouldRefreshRefs ? getBranches(repoPath) : Promise.resolve(undefined), shouldRefreshStashes ? getStashes(repoPath) : Promise.resolve(undefined), shouldRefreshHistory ? getGitHistory(repoPath, 50) : Promise.resolve(undefined), + // Operation state rides along on every refresh: staging a file or + // an external Git command can end a conflict at any moment. + getOperationState(repoPath) + .then((value) => ({ ok: true as const, value })) + .catch((error) => { + console.error("Failed to refresh Git operation state:", error); + return { ok: false as const }; + }), ]); if ( @@ -110,6 +126,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl branches, commits: history?.commits, hasMoreCommits: history?.hasMore, + operationState: operationStateResult.ok ? operationStateResult.value : undefined, repoPath, }); diff --git a/windows/tauri/src/features/git/stores/git.store.test.ts b/windows/tauri/src/features/git/stores/git.store.test.ts index 70d18e330..e62b7dff0 100644 --- a/windows/tauri/src/features/git/stores/git.store.test.ts +++ b/windows/tauri/src/features/git/stores/git.store.test.ts @@ -31,6 +31,7 @@ const loadInitialHistory = ( hasMoreCommits: true, branches: [], stashes: [], + operationState: null, repoPath, }); }; @@ -97,3 +98,38 @@ describe("Git history pagination", () => { expect(store.getState().isLoadingMoreCommits).toBe(false); }); }); + +describe("Git operation state refresh", () => { + test("keeps the last operation state when a refresh omits a failed query", () => { + const store = createGitStore(); + store.getState().actions.prepareRepositoryLoad("C:/repo"); + store.getState().actions.loadFreshGitData({ + gitStatus: null, + commits: [], + hasMoreCommits: false, + branches: [], + stashes: [], + operationState: { + kind: "rebase", + reference: "refs/heads/main", + step: 2, + total: 4, + conflictedPaths: [], + }, + repoPath: "C:/repo", + }); + + store.getState().actions.refreshGitData({ + gitStatus: null, + repoPath: "C:/repo", + }); + + expect(store.getState().operationState).toEqual({ + kind: "rebase", + reference: "refs/heads/main", + step: 2, + total: 4, + conflictedPaths: [], + }); + }); +}); diff --git a/windows/tauri/src/features/git/stores/git.store.ts b/windows/tauri/src/features/git/stores/git.store.ts index 7223fd477..fdd324fc4 100644 --- a/windows/tauri/src/features/git/stores/git.store.ts +++ b/windows/tauri/src/features/git/stores/git.store.ts @@ -2,7 +2,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { getGitHistory } from "../api/git-commits-api"; import { getGitStatus } from "../api/git-status-api"; -import type { GitCommit, GitStash, GitStatus } from "../types/git.types"; +import type { GitCommit, GitOperationState, GitStash, GitStatus } from "../types/git.types"; interface GitState { gitStatus: GitStatus | null; @@ -10,6 +10,7 @@ interface GitState { commits: GitCommit[]; branches: string[]; stashes: GitStash[]; + operationState: GitOperationState | null; hasMoreCommits: boolean; isLoadingMoreCommits: boolean; isLoadingGitData: boolean; @@ -26,6 +27,7 @@ interface GitState { hasMoreCommits: boolean; branches: string[]; stashes: GitStash[]; + operationState: GitOperationState | null; repoPath: string; }) => void; refreshGitData: (data: { @@ -33,6 +35,7 @@ interface GitState { branches?: string[]; commits?: GitCommit[]; hasMoreCommits?: boolean; + operationState?: GitOperationState | null; repoPath: string; }) => void; refreshWorkspaceGitStatus: (repoPath: string) => Promise; @@ -58,6 +61,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, isLoadingGitData: false, @@ -76,6 +80,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, currentRepoPath: repoPath, @@ -88,6 +93,7 @@ export const createGitStore = () => hasMoreCommits, branches, stashes, + operationState, repoPath, }) => { if (get().currentRepoPath !== repoPath) { @@ -99,18 +105,20 @@ export const createGitStore = () => commits, branches, stashes, + operationState, hasMoreCommits, currentRepoPath: repoPath, }); }, - refreshGitData: ({ gitStatus, branches, commits, hasMoreCommits, repoPath }) => { + refreshGitData: ({ gitStatus, branches, commits, hasMoreCommits, operationState, repoPath }) => { if (get().currentRepoPath !== repoPath) { return; } set({ gitStatus, + ...(operationState !== undefined ? { operationState } : {}), ...(branches ? { branches } : {}), ...(commits ? { @@ -183,6 +191,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, isLoadingGitData: false, diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index d9646703b..e19ede84f 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -116,3 +116,17 @@ export interface GitBlameLine { time: number; commit: string; } + +export type GitOperationKind = "merge" | "rebase" | "cherryPick" | "revert"; + +/** + * An in-progress merge/rebase/cherry-pick/revert detected from the repository's + * Git marker files, so operations started outside the app are reported too. + */ +export interface GitOperationState { + kind: GitOperationKind; + reference: string | null; + step: number | null; + total: number | null; + conflictedPaths: string[]; +} diff --git a/windows/tauri/src/platform/core-result-adapter.operation.test.ts b/windows/tauri/src/platform/core-result-adapter.operation.test.ts new file mode 100644 index 000000000..f58285785 --- /dev/null +++ b/windows/tauri/src/platform/core-result-adapter.operation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { adaptCoreResult } from "./core-result-adapter"; + +describe("git operation state adaptation", () => { + test("maps an in-progress rebase with progress counters", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { + kind: "rebase", + reference: "abc123", + step: 3, + total: 7, + conflictedPaths: ["src/main.rs", "README.md"], + }, + ); + + expect(state).toEqual({ + kind: "rebase", + reference: "abc123", + step: 3, + total: 7, + conflictedPaths: ["src/main.rs", "README.md"], + }); + }); + + test("returns null when no operation is in progress", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { kind: "", reference: null, step: null, total: null, conflictedPaths: [] }, + ); + + expect(state).toBeNull(); + }); + + test("keeps merge state without optional counters", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { kind: "merge", reference: null, step: null, total: null, conflictedPaths: ["a.txt"] }, + ); + + expect(state).toEqual({ + kind: "merge", + reference: null, + step: null, + total: null, + conflictedPaths: ["a.txt"], + }); + }); +}); + +describe("git integration preflight adaptation", () => { + test("passes through blocking paths and the blocks-entirely flag", () => { + const preflight = adaptCoreResult( + "git_integration_preflight", + { repoPath: "C:/work", branchName: "main", operation: "rebase" }, + { blockingPaths: ["a.txt", "b.txt"], blocksEntirely: true }, + ); + + expect(preflight).toEqual({ + blockingPaths: ["a.txt", "b.txt"], + blocksEntirely: true, + }); + }); +}); + +describe("git conflict marker adaptation", () => { + test("passes through staged files that still contain markers", () => { + const markers = adaptCoreResult( + "git_conflict_markers", + { repoPath: "C:/work" }, + { paths: ["src/conflicted.rs"] }, + ); + + expect(markers).toEqual({ paths: ["src/conflicted.rs"] }); + }); +}); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index 016ff75fb..1b946ec83 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -219,6 +219,37 @@ export function adaptCoreResult( } case "git_checkout_tag": return { success: true, hasChanges: false, message: "" } as T; + case "git_operation_state": { + const kind = typeof data.kind === "string" ? data.kind : ""; + if (!kind) { + return null as T; + } + const conflictedPaths = Array.isArray(data.conflictedPaths) + ? data.conflictedPaths.map((path: unknown) => String(path)) + : []; + return { + kind, + reference: typeof data.reference === "string" ? data.reference : null, + step: typeof data.step === "number" ? data.step : null, + total: typeof data.total === "number" ? data.total : null, + conflictedPaths, + } as T; + } + case "git_integration_preflight": { + const blockingPaths = Array.isArray(data.blockingPaths) + ? data.blockingPaths.map((path: unknown) => String(path)) + : []; + return { + blockingPaths, + blocksEntirely: Boolean(data.blocksEntirely), + } as T; + } + case "git_conflict_markers": { + const paths = Array.isArray(data.paths) + ? data.paths.map((path: unknown) => String(path)) + : []; + return { paths } as T; + } default: return value as T; }