d70b8e2363
Backend (git_repositories.py): - get_repository_branches: check for .git dir OR HEAD file (handles bare repos) - When local repo is missing, git ls-remote fallback now uses SSH key auth via _prepare_ssh_env() for repos with ssh_key_id - Cleans up temp SSH key file after ls-remote - Logs ls-remote stderr/exit code for debugging - Returns server's detail message instead of raw axios 404 text Frontend (use-git-repo.ts): - extractError() helper pulls server detail/message from axios responses - User sees 'repository not found on disk — re-clone or re-create' instead of generic 'Request failed with status code 404' Quality gates: ruff clean, tsc --noEmit clean, 11 passed + 1 pre-existing failure
281 lines
7.8 KiB
TypeScript
281 lines
7.8 KiB
TypeScript
/** 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<void>;
|
|
/** Refresh working-directory status. */
|
|
refreshStatus: () => Promise<void>;
|
|
/** Refresh commit history. */
|
|
refreshHistory: (branch?: string, limit?: number) => Promise<void>;
|
|
/** Fetch a single commit's details. */
|
|
loadCommitDetail: (hash: string) => Promise<void>;
|
|
/** Stage + commit changes. */
|
|
commit: (message: string, files?: string[]) => Promise<void>;
|
|
/** Push current branch (or named branch) to remote. */
|
|
push: (branch?: string) => Promise<void>;
|
|
/** Pull from remote. */
|
|
pull: (branch?: string) => Promise<void>;
|
|
/** Fetch from remote. */
|
|
fetch: () => Promise<void>;
|
|
/** Checkout an existing branch. */
|
|
checkout: (branch: string) => Promise<void>;
|
|
/** Create and checkout a new branch. */
|
|
createBranch: (name: string, baseBranch?: string) => Promise<void>;
|
|
/** Delete a branch. */
|
|
deleteBranch: (name: string, force?: boolean) => Promise<void>;
|
|
/** Merge source into current (or target) branch. */
|
|
merge: (
|
|
sourceBranch: string,
|
|
targetBranch?: string,
|
|
message?: string,
|
|
) => Promise<void>;
|
|
/** Clear the current error. */
|
|
clearError: () => void;
|
|
}
|
|
|
|
export function useGitRepo(
|
|
projectId: string | undefined,
|
|
repoId: string | undefined,
|
|
): UseGitRepoResult {
|
|
const [branches, setBranches] = useState<string[]>([]);
|
|
const [defaultBranch, setDefaultBranch] = useState("");
|
|
const [status, setStatus] = useState<GitStatus | null>(null);
|
|
const [history, setHistory] = useState<CommitHistoryResponse | null>(null);
|
|
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const extractError = (err: unknown): string => {
|
|
if (typeof err === "object" && err !== null) {
|
|
const e = err as Record<string, unknown>;
|
|
const response = e.response as Record<string, unknown> | undefined;
|
|
const data = response?.data as Record<string, unknown> | undefined;
|
|
if (typeof data?.detail === "string") return data.detail;
|
|
if (typeof data?.message === "string") return data.message;
|
|
if (typeof e.message === "string") return e.message;
|
|
}
|
|
return "Git operation failed";
|
|
};
|
|
|
|
const withLoading = useCallback(
|
|
async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
setError(extractError(err));
|
|
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),
|
|
};
|
|
}
|