diff --git a/electron/src/ipc/files.ts b/electron/src/ipc/files.ts index 7689e33..66b78c2 100644 --- a/electron/src/ipc/files.ts +++ b/electron/src/ipc/files.ts @@ -15,7 +15,7 @@ function listFilesGit(cwd: string): Promise { execFile( "git", ["ls-files", "--cached", "--others", "--exclude-standard"], - { cwd, maxBuffer: 10 * 1024 * 1024 }, + { cwd, maxBuffer: 50 * 1024 * 1024, timeout: 30000 }, // 50MB buffer, 30s timeout (err, stdout) => { if (err) return reject(err); resolve(stdout.split("\n").filter((f) => f.trim()).sort()); diff --git a/electron/src/ipc/git.ts b/electron/src/ipc/git.ts index 8faac70..abe857e 100644 --- a/electron/src/ipc/git.ts +++ b/electron/src/ipc/git.ts @@ -1,6 +1,7 @@ import { ipcMain } from "electron"; import path from "path"; import fs from "fs"; +import { execFile } from "child_process"; import { gitExec, ALWAYS_SKIP } from "../lib/git-exec"; import { captureEvent } from "../lib/posthog"; import { reportError } from "../lib/error-utils"; @@ -69,6 +70,34 @@ function validateRef(ref: string): void { } } +/** + * Get approximate size of .git directory in bytes. + * Returns -1 if unable to determine size. + */ +async function getGitDirSize(repoPath: string): Promise { + try { + const gitDir = path.join(repoPath, ".git"); + const stat = await fs.promises.stat(gitDir); + if (!stat.isDirectory()) return -1; + + // Use du command for faster size estimation (sampling approach) + // For very large repos, we just need an order of magnitude, not exact size + return new Promise((resolve) => { + execFile("du", ["-sk", gitDir], { timeout: 5000 }, (err, stdout) => { + if (err) { + resolve(-1); + return; + } + // du -sk returns size in KB as first token + const sizeKB = parseInt(stdout.split(/\s+/)[0], 10); + resolve(sizeKB * 1024); // Convert to bytes + }); + }); + } catch { + return -1; + } +} + export function register(): void { ipcMain.handle("git:discover-repos", async (_event, projectPath: string) => { const normalizedProjectPath = normalizePath(projectPath); @@ -470,4 +499,13 @@ export function register(): void { return { error: reportError("GIT_LOG_ERR", err) }; } }); + + ipcMain.handle("git:get-git-dir-size", async (_event, cwd: string) => { + try { + const sizeBytes = await getGitDirSize(cwd); + return { sizeBytes }; + } catch (err) { + return { error: reportError("GIT_GET_GIT_DIR_SIZE_ERR", err), sizeBytes: -1 }; + } + }); } diff --git a/electron/src/lib/git-exec.ts b/electron/src/lib/git-exec.ts index 90a8e93..4ef7dc9 100644 --- a/electron/src/lib/git-exec.ts +++ b/electron/src/lib/git-exec.ts @@ -8,9 +8,12 @@ export const ALWAYS_SKIP = new Set([ ".gradle", ".idea", ".vs", ".vscode", "target", "out", "bin", "obj", ]); -export function gitExec(args: string[], cwd: string): Promise { +export function gitExec(args: string[], cwd: string, options?: { timeout?: number; maxBuffer?: number }): Promise { return new Promise((resolve, reject) => { - execFile("git", args, { cwd, maxBuffer: 5 * 1024 * 1024 }, (err, stdout, stderr) => { + const timeout = options?.timeout ?? 30000; // 30s default timeout + const maxBuffer = options?.maxBuffer ?? 50 * 1024 * 1024; // 50MB default buffer (up from 5MB) + + execFile("git", args, { cwd, maxBuffer, timeout }, (err, stdout, stderr) => { if (err) return reject(new Error(stderr?.trim() || err.message)); resolve(stdout); }); diff --git a/electron/src/preload.ts b/electron/src/preload.ts index f66e703..b16f36a 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -128,6 +128,7 @@ contextBridge.exposeInMainWorld("claude", { diffFile: (cwd: string, file: string, staged: boolean) => ipcRenderer.invoke("git:diff-file", { cwd, file, staged }), diffStat: (cwd: string) => ipcRenderer.invoke("git:diff-stat", cwd) as Promise<{ additions: number; deletions: number }>, log: (cwd: string, count?: number) => ipcRenderer.invoke("git:log", { cwd, count }), + getGitDirSize: (cwd: string) => ipcRenderer.invoke("git:get-git-dir-size", cwd) as Promise<{ sizeBytes: number; error?: string }>, generateCommitMessage: (cwd: string, engine?: string, sessionId?: string) => ipcRenderer.invoke("git:generate-commit-message", { cwd, engine, sessionId }), }, diff --git a/src/hooks/useGitStatus.ts b/src/hooks/useGitStatus.ts index 8d8f495..60ef72d 100644 --- a/src/hooks/useGitStatus.ts +++ b/src/hooks/useGitStatus.ts @@ -12,6 +12,8 @@ export interface RepoState { branches: GitBranch[]; log: GitLogEntry[]; diffStat: DiffStat; + isLargeRepo?: boolean; // Track if repo is large (affects polling) + lastRefreshDuration?: number; // Track how long refresh takes } interface UseGitStatusOptions { @@ -32,7 +34,35 @@ export function useGitStatus({ projectPath }: UseGitStatusOptions) { } (async () => { const discovered = await window.claude.git.discoverRepos(projectPath); - setRepoStates(discovered.map((repo) => ({ repo, status: null, branches: [], log: [], diffStat: { additions: 0, deletions: 0 } }))); + + // Check .git directory sizes to detect very large repos + const statesWithSizeCheck = await Promise.all( + discovered.map(async (repo) => { + const { sizeBytes } = await window.claude.git.getGitDirSize(repo.path); + const gitSizeGB = sizeBytes > 0 ? sizeBytes / (1024 * 1024 * 1024) : 0; + const isVeryLarge = gitSizeGB > 10; // 10GB+ .git is very large + + // Log warning for very large repos + if (isVeryLarge) { + console.warn( + `[useGitStatus] Detected very large .git directory (${gitSizeGB.toFixed(1)}GB) at ${repo.path}. ` + + `Git operations may be slow. Consider using git worktrees or shallow clones to improve performance.` + ); + } + + return { + repo, + status: null, + branches: [], + log: [], + diffStat: { additions: 0, deletions: 0 }, + isLargeRepo: isVeryLarge, + lastRefreshDuration: 0, + }; + }) + ); + + setRepoStates(statesWithSizeCheck); })(); }, [projectPath]); @@ -43,18 +73,23 @@ export function useGitStatus({ projectPath }: UseGitStatusOptions) { try { const updated = await Promise.all( states.map(async (rs) => { + const startTime = performance.now(); const [statusResult, branchesResult, logResult, diffStatResult] = await Promise.all([ window.claude.git.status(rs.repo.path), window.claude.git.branches(rs.repo.path), window.claude.git.log(rs.repo.path, 30), window.claude.git.diffStat(rs.repo.path), ]); + const duration = performance.now() - startTime; + const isLargeRepo = duration > 5000; // Mark as large if refresh takes >5s return { repo: rs.repo, status: (!("error" in statusResult) || !statusResult.error) ? statusResult as GitStatus : rs.status, branches: Array.isArray(branchesResult) ? branchesResult : rs.branches, log: Array.isArray(logResult) ? logResult : rs.log, diffStat: diffStatResult ?? rs.diffStat, + isLargeRepo, + lastRefreshDuration: duration, }; }), ); @@ -69,12 +104,15 @@ export function useGitStatus({ projectPath }: UseGitStatusOptions) { const idx = states.findIndex((rs) => rs.repo.path === repoPath); if (idx === -1) return; const rs = states[idx]; + const startTime = performance.now(); const [statusResult, branchesResult, logResult, diffStatResult] = await Promise.all([ window.claude.git.status(rs.repo.path), window.claude.git.branches(rs.repo.path), window.claude.git.log(rs.repo.path, 30), window.claude.git.diffStat(rs.repo.path), ]); + const duration = performance.now() - startTime; + const isLargeRepo = duration > 5000; setRepoStates((prev) => { const next = [...prev]; next[idx] = { @@ -83,19 +121,25 @@ export function useGitStatus({ projectPath }: UseGitStatusOptions) { branches: Array.isArray(branchesResult) ? branchesResult : rs.branches, log: Array.isArray(logResult) ? logResult : rs.log, diffStat: diffStatResult ?? rs.diffStat, + isLargeRepo, + lastRefreshDuration: duration, }; return next; }); }, []); - // Poll all repos every 3s + // Poll repos with adaptive interval (3s for normal repos, 15s for large repos) useEffect(() => { if (repoStates.length === 0) return; refreshAll(); + // Determine polling interval based on whether we have any large repos + const hasLargeRepo = repoStates.some((rs) => rs.isLargeRepo); + const pollInterval = hasLargeRepo ? 15000 : 3000; // 15s for large repos, 3s for normal + const interval = setInterval(() => { if (!document.hidden) refreshAll(); - }, 3000); + }, pollInterval); const onVisibilityChange = () => { if (!document.hidden) refreshAll(); @@ -106,7 +150,7 @@ export function useGitStatus({ projectPath }: UseGitStatusOptions) { clearInterval(interval); document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [repoStates.length, refreshAll]); + }, [repoStates.length, refreshAll, repoStates.some((rs) => rs.isLargeRepo)]); // Per-repo action creators const stage = useCallback(