"use client"; import { useState, useCallback, useMemo } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore } from "@/components/state-provider"; import { t } from "@/lib/i18n"; // GitOperationResult matches the backend response export interface GitOperationResult { success: boolean; operation: string; output: string; error?: string; error_code?: string; conflict_files?: string[]; recovery_branch?: string; } // PRCreateResult matches the backend PR creation response export interface PRCreateResult { success: boolean; branch_pushed?: boolean; pr_url?: string; provider?: string; output?: string; error?: string; error_code?: string; linked?: boolean; association_error?: string; } export function getLocalizedGitOperationError( errorCode?: string, fallback?: string, ): string | undefined { switch (errorCode) { case "empty_remote_remote_changed": return t("common:emptyRemoteRemoteChanged"); case "empty_remote_base_publish_failed": return t("common:emptyRemoteBasePublishFailed"); case "empty_remote_branch_publish_failed": return t("common:emptyRemoteBranchPublishFailed"); default: return fallback; } } export function getChangeRequestTerminology(provider?: string) { return provider?.toLowerCase() === "gitlab" ? { longName: "Merge Request", shortName: "MR" } : { longName: "Pull Request", shortName: "PR" }; } export function resolveChangeRequestTerminology( provider: string | undefined, fallback: ReturnType, ) { return provider ? getChangeRequestTerminology(provider) : fallback; } export function useChangeRequestTerminology(sessionId?: string | null, repoName?: string) { const provider = useAppStore((state) => { const taskId = sessionId ? state.taskSessions.items[sessionId]?.task_id : undefined; const task = taskId ? state.kanban.tasks.find((candidate: { id: string }) => candidate.id === taskId) : undefined; if (!task) return undefined; const repositories = Object.values(state.repositories.itemsByWorkspaceId).flat(); const linkedRepositoryIds = new Set( task.repositories?.map((repository) => repository.repository_id) ?? [], ); if (repoName) { return repositories.find( (repository) => linkedRepositoryIds.has(repository.id) && repository.name === repoName, )?.provider; } const primaryRepositoryId = task.repositories?.[0]?.repository_id; return repositories.find((repository) => repository.id === primaryRepositoryId)?.provider; }); return useMemo(() => getChangeRequestTerminology(provider), [provider]); } interface UseGitOperationsReturn { // Operation methods. The optional `repo` parameter is the multi-repo subpath // (e.g. "kandev"); pass empty/undefined for single-repo workspaces. Multi-repo // workspaces MUST scope each call to one repo — bulk callers fan out themselves. pull: (rebase?: boolean, repo?: string) => Promise; push: ( options?: { force?: boolean; setUpstream?: boolean }, repo?: string, ) => Promise; replaceRemoteContribution: ( expectedRemoteHead: string, repo?: string, ) => Promise; useRemoteContribution: (expectedRemoteHead: string, repo?: string) => Promise; rebase: (baseBranch: string, repo?: string) => Promise; merge: (baseBranch: string, repo?: string) => Promise; abort: (operation: "merge" | "rebase", repo?: string) => Promise; commit: ( message: string, stageAll?: boolean, amend?: boolean, repo?: string, ) => Promise; stage: (paths?: string[], repo?: string) => Promise; unstage: (paths?: string[], repo?: string) => Promise; discard: (paths?: string[], repo?: string) => Promise; revertCommit: (commitSHA: string, repo?: string) => Promise; renameBranch: (newName: string, repo?: string) => Promise; reset: (commitSHA: string, mode: "soft" | "hard", repo?: string) => Promise; createPR: ( title: string, body: string, baseBranch?: string, draft?: boolean, repo?: string, ) => Promise; // State isLoading: boolean; loadingOperation: string | null; error: string | null; lastResult: GitOperationResult | null; } type ExecuteOperation = ( action: string, payload: Record, ) => Promise; function buildContributionCallbacks(executeOperation: ExecuteOperation) { const replaceRemoteContribution = async (expectedRemoteHead: string, repo?: string) => executeOperation("worktree.replace_contribution", { expected_remote_head: expectedRemoteHead, ...repositoryScopePayload(repo), }); const useRemoteContribution = async (expectedRemoteHead: string, repo?: string) => executeOperation("worktree.use_contribution", { expected_remote_head: expectedRemoteHead, ...repositoryScopePayload(repo), }); return { replaceRemoteContribution, useRemoteContribution }; } /** Preserve an explicitly selected workspace-root scope (`repo === ""`). */ export function repositoryScopePayload(repo?: string): { repo?: string } { return repo === undefined ? {} : { repo }; } export function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { const { replaceRemoteContribution, useRemoteContribution } = buildContributionCallbacks(executeOperation); const pull = async (rebase = false, repo?: string) => executeOperation("worktree.pull", { rebase, ...repositoryScopePayload(repo), }); const push = async (options?: { force?: boolean; setUpstream?: boolean }, repo?: string) => executeOperation("worktree.push", { force: options?.force ?? false, set_upstream: options?.setUpstream ?? false, ...repositoryScopePayload(repo), }); const rebase = async (baseBranch: string, repo?: string) => executeOperation("worktree.rebase", { base_branch: baseBranch, ...repositoryScopePayload(repo), }); const merge = async (baseBranch: string, repo?: string) => executeOperation("worktree.merge", { base_branch: baseBranch, ...repositoryScopePayload(repo), }); const abort = async (operation: "merge" | "rebase", repo?: string) => executeOperation("worktree.abort", { operation, ...repositoryScopePayload(repo), }); const commit = async (message: string, stageAll = true, amend = false, repo?: string) => executeOperation("worktree.commit", { message, stage_all: stageAll, amend, ...repositoryScopePayload(repo), }); const stage = async (paths?: string[], repo?: string) => executeOperation("worktree.stage", { paths: paths ?? [], ...repositoryScopePayload(repo), }); const unstage = async (paths?: string[], repo?: string) => executeOperation("worktree.unstage", { paths: paths ?? [], ...repositoryScopePayload(repo), }); const discard = async (paths?: string[], repo?: string) => executeOperation("worktree.discard", { paths: paths ?? [], ...repositoryScopePayload(repo), }); const revertCommit = async (commitSHA: string, repo?: string) => executeOperation("worktree.revert_commit", { commit_sha: commitSHA, ...repositoryScopePayload(repo), }); const renameBranch = async (newName: string, repo?: string) => executeOperation("worktree.rename_branch", { new_name: newName, ...repositoryScopePayload(repo), }); const reset = async (commitSHA: string, mode: "soft" | "hard", repo?: string) => executeOperation("worktree.reset", { commit_sha: commitSHA, mode, ...repositoryScopePayload(repo), }); const createPR = async ( title: string, body: string, baseBranch?: string, draft?: boolean, repo?: string, ): Promise => executeOperation("worktree.create_pr", { title, body, base_branch: baseBranch ?? "", draft: draft ?? true, ...repositoryScopePayload(repo), }); return { pull, push, replaceRemoteContribution, useRemoteContribution, rebase, merge, abort, commit, stage, unstage, discard, revertCommit, renameBranch, reset, createPR, }; } export function useGitOperations(sessionId: string | null): UseGitOperationsReturn { const [isLoading, setIsLoading] = useState(false); const [loadingOperation, setLoadingOperation] = useState(null); const [error, setError] = useState(null); const [lastResult, setLastResult] = useState(null); const executeOperation = useCallback( async ( action: string, payload: Record, ): Promise => { if (!sessionId) throw new Error("No session ID provided"); const client = getWebSocketClient(); if (!client) throw new Error("WebSocket not connected"); setIsLoading(true); setLoadingOperation(action.replace("worktree.", "")); setError(null); const timeout = action === "worktree.create_pr" ? 120000 : 60000; try { const result = await client.request( action, { session_id: sessionId, ...payload }, timeout, ); setLastResult(result); if (!result.success && result.error) setError(result.error); return result; } catch (e) { const errorMessage = e instanceof Error ? e.message : t("task:gitOperationFailedGeneric"); setError(errorMessage); throw e; } finally { setIsLoading(false); setLoadingOperation(null); } }, [sessionId], ); const ops = useMemo(() => buildGitOperationCallbacks(executeOperation), [executeOperation]); return { ...ops, isLoading, loadingOperation, error, lastResult, }; }