From b02cd978c302469358cc9cd4cb90634c9e6fc9f8 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 1 Jun 2026 18:45:53 +0200 Subject: [PATCH] feat: unified git repo hook + fix branch dropdown in workspace creation - New useGitRepo hook: centralizes all git operations (branches, status, history, commit, push, pull, fetch, checkout, create/delete branch, merge) for a given project+repo. Auto-refreshes after mutating ops. - Fix WorkspaceCreateForm branch dropdown: - Always renders setSelectedProject(e.target.value)} + onChange={(e) => { + setSelectedProject(e.target.value); + setSelectedBranch(""); + }} required > @@ -195,7 +197,10 @@ export function WorkspaceCreateForm({ handleBranchChange(e.target.value)} - required - disabled={!selectedRepo || submitting} - > - - {branches.map((b) => ( - - ))} - - - {isNewBranch && ( - setNewBranchName(e.target.value)} - placeholder="new-branch-name" - required - style={{ marginTop: "0.5rem" }} - disabled={submitting} - /> - )} - - ) : ( + + + {/* New branch text input */} + {isNewBranch && ( { - setIsNewBranch(true); - setNewBranchName(e.target.value); - setSelectedBranch("__new__"); - }} - placeholder="main" + value={newBranchName} + onChange={(e) => setNewBranchName(e.target.value)} + placeholder="new-branch-name" required - disabled={!selectedRepo || submitting} + style={{ marginTop: "0.5rem" }} + disabled={submitting} /> )} - {error && ( + {displayError && (
- {error} + {displayError}
)} diff --git a/apps/web/src/hooks/use-git-repo.ts b/apps/web/src/hooks/use-git-repo.ts new file mode 100644 index 0000000..e24900c --- /dev/null +++ b/apps/web/src/hooks/use-git-repo.ts @@ -0,0 +1,267 @@ +/** Unified hook for git repository operations. + * + * Centralizes branch fetching, status, history, and git actions + * so components don't duplicate this logic. + */ + +import { useState, useEffect, useCallback } from "react"; +import { + listRepositoryBranches, + getRepositoryStatus, + getRepositoryHistory, + getCommitDetail, + commitChanges, + pushRepository, + pullRepository, + fetchRepository, + checkoutBranch, + createBranch, + deleteBranch, + mergeBranches, + type Branch, + type CommitHistoryResponse, + type CommitDetail, +} from "../api/git_repositories"; + +export interface GitStatus { + branch: string; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + renamed: string[]; + ahead: number; + behind: number; +} + +export interface UseGitRepoResult { + /** Available branch names. */ + branches: string[]; + /** The repo's default branch. */ + defaultBranch: string; + /** Current working-directory status. */ + status: GitStatus | null; + /** Commit history. */ + history: CommitHistoryResponse | null; + /** Selected commit detail. */ + commitDetail: CommitDetail | null; + /** True while any async operation is in flight. */ + loading: boolean; + /** Error message from the last failed operation. */ + error: string | null; + /** Refresh branches list. */ + refreshBranches: () => Promise; + /** Refresh working-directory status. */ + refreshStatus: () => Promise; + /** Refresh commit history. */ + refreshHistory: (branch?: string, limit?: number) => Promise; + /** Fetch a single commit's details. */ + loadCommitDetail: (hash: string) => Promise; + /** Stage + commit changes. */ + commit: (message: string, files?: string[]) => Promise; + /** Push current branch (or named branch) to remote. */ + push: (branch?: string) => Promise; + /** Pull from remote. */ + pull: (branch?: string) => Promise; + /** Fetch from remote. */ + fetch: () => Promise; + /** Checkout an existing branch. */ + checkout: (branch: string) => Promise; + /** Create and checkout a new branch. */ + createBranch: (name: string, baseBranch?: string) => Promise; + /** Delete a branch. */ + deleteBranch: (name: string, force?: boolean) => Promise; + /** Merge source into current (or target) branch. */ + merge: (sourceBranch: string, targetBranch?: string, message?: string) => Promise; + /** Clear the current error. */ + clearError: () => void; +} + +export function useGitRepo( + projectId: string | undefined, + repoId: string | undefined, +): UseGitRepoResult { + const [branches, setBranches] = useState([]); + const [defaultBranch, setDefaultBranch] = useState(""); + const [status, setStatus] = useState(null); + const [history, setHistory] = useState(null); + const [commitDetail, setCommitDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const withLoading = useCallback( + async (fn: () => Promise): Promise => { + setLoading(true); + setError(null); + try { + return await fn(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Git operation failed"; + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, + [], + ); + + const refreshBranches = useCallback(async () => { + if (!projectId || !repoId) return; + const data = await withLoading(() => + listRepositoryBranches(projectId, repoId), + ); + setBranches(data.branches.map((b: Branch) => b.name)); + setDefaultBranch(data.default_branch ?? ""); + }, [projectId, repoId, withLoading]); + + const refreshStatus = useCallback(async () => { + if (!projectId || !repoId) return; + const data = await withLoading(() => + getRepositoryStatus(projectId, repoId), + ); + setStatus({ + branch: data.branch, + modified: data.modified, + added: data.added, + deleted: data.deleted, + untracked: data.untracked, + renamed: data.renamed ?? [], + ahead: data.ahead, + behind: data.behind, + }); + }, [projectId, repoId, withLoading]); + + const refreshHistory = useCallback( + async (branch?: string, limit = 50) => { + if (!projectId || !repoId) return; + const data = await withLoading(() => + getRepositoryHistory(projectId, repoId, branch, limit), + ); + setHistory(data); + }, + [projectId, repoId, withLoading], + ); + + const loadCommitDetail = useCallback( + async (hash: string) => { + if (!projectId || !repoId) return; + const data = await withLoading(() => + getCommitDetail(projectId, repoId, hash), + ); + setCommitDetail(data); + }, + [projectId, repoId, withLoading], + ); + + const commit = useCallback( + async (message: string, files?: string[]) => { + if (!projectId || !repoId) return; + await withLoading(() => + commitChanges(projectId, repoId, message, files), + ); + await refreshStatus(); + }, + [projectId, repoId, withLoading, refreshStatus], + ); + + const push = useCallback( + async (branch?: string) => { + if (!projectId || !repoId) return; + await withLoading(() => pushRepository(projectId, repoId, branch)); + await refreshStatus(); + }, + [projectId, repoId, withLoading, refreshStatus], + ); + + const pull = useCallback( + async (branch?: string) => { + if (!projectId || !repoId) return; + await withLoading(() => pullRepository(projectId, repoId, branch)); + await refreshStatus(); + }, + [projectId, repoId, withLoading, refreshStatus], + ); + + const fetch = useCallback(async () => { + if (!projectId || !repoId) return; + await withLoading(() => fetchRepository(projectId, repoId)); + await refreshStatus(); + }, [projectId, repoId, withLoading, refreshStatus]); + + const checkout = useCallback( + async (branch: string) => { + if (!projectId || !repoId) return; + await withLoading(() => checkoutBranch(projectId, repoId, branch)); + await refreshStatus(); + await refreshBranches(); + }, + [projectId, repoId, withLoading, refreshStatus, refreshBranches], + ); + + const createBranchFn = useCallback( + async (name: string, baseBranch = "HEAD") => { + if (!projectId || !repoId) return; + await withLoading(() => + createBranch(projectId, repoId, name, baseBranch), + ); + await refreshBranches(); + await refreshStatus(); + }, + [projectId, repoId, withLoading, refreshBranches, refreshStatus], + ); + + const deleteBranchFn = useCallback( + async (name: string, force = false) => { + if (!projectId || !repoId) return; + await withLoading(() => deleteBranch(projectId, repoId, name, force)); + await refreshBranches(); + }, + [projectId, repoId, withLoading, refreshBranches], + ); + + const merge = useCallback( + async (sourceBranch: string, targetBranch?: string, message?: string) => { + if (!projectId || !repoId) return; + await withLoading(() => + mergeBranches(projectId, repoId, sourceBranch, targetBranch, message), + ); + await refreshStatus(); + await refreshHistory(); + }, + [projectId, repoId, withLoading, refreshStatus, refreshHistory], + ); + + // Auto-refresh branches when projectId/repoId become valid + useEffect(() => { + if (projectId && repoId) { + void refreshBranches(); + } else { + setBranches([]); + setDefaultBranch(""); + } + }, [projectId, repoId, refreshBranches]); + + return { + branches, + defaultBranch, + status, + history, + commitDetail, + loading, + error, + refreshBranches, + refreshStatus, + refreshHistory, + loadCommitDetail, + commit, + push, + pull, + fetch, + checkout, + createBranch: createBranchFn, + deleteBranch: deleteBranchFn, + merge, + clearError: () => setError(null), + }; +} diff --git a/apps/web/src/pages/projects.tsx b/apps/web/src/pages/projects.tsx index 2eb3762..103d510 100644 --- a/apps/web/src/pages/projects.tsx +++ b/apps/web/src/pages/projects.tsx @@ -10,10 +10,7 @@ import { type ProjectCreateInput, type ProjectUpdateInput, } from "../api/projects"; -import { - deleteWorkspace, - syncWorkspace, -} from "../api/workspaces"; +import { deleteWorkspace, syncWorkspace } from "../api/workspaces"; import { EmptyState, ErrorState, @@ -111,8 +108,6 @@ export const ProjectsPage = () => { } }; - - const handleSyncWorkspace = async ( projectId: string, repoId: string,