diff --git a/src/main/git.ts b/src/main/git.ts index 64263ef6..6d101bc1 100644 --- a/src/main/git.ts +++ b/src/main/git.ts @@ -1,14 +1,25 @@ -import { execFile, spawn } from 'child_process' +import { spawn } from 'child_process' import { createHash } from 'crypto' import { existsSync } from 'fs' -import { appendFile, mkdtemp, readFile, rm } from 'fs/promises' +import { appendFile, mkdtemp, readFile, rm, stat } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' -import { promisify } from 'util' import { assertDirectory } from './path-utils' import { cacheAvatarFromUrl, cachedAvatarUrl } from './avatar-cache' -const exec = promisify(execFile) +class GitCommandError extends Error { + code?: number | string | null + stdout: string + stderr: string + + constructor(message: string, options: { code?: number | string | null; stdout?: string; stderr?: string } = {}) { + super(message) + this.name = 'GitCommandError' + this.code = options.code + this.stdout = options.stdout || '' + this.stderr = options.stderr || '' + } +} export interface FileStatus { path: string @@ -65,6 +76,8 @@ type GitHubCommitAvatar = { const gitHubCommitAvatarCache = new Map() // Git suppresses merge file details unless a merge diff strategy is requested. const mergeDiffArgs = ['--diff-merges=first-parent'] +const MAX_UNTRACKED_SUMMARY_FILES = 500 +const MAX_UNTRACKED_SUMMARY_FILE_BYTES = 1024 * 1024 export interface GitCommitSelectionInput { title: string @@ -94,50 +107,84 @@ export interface GitCheckoutBranchOptions { stashChanges?: boolean } -async function git(args: string[], cwd: string): Promise { - assertDirectory(cwd, 'Git working directory') - const { stdout } = await exec('git', args, { cwd, maxBuffer: 10 * 1024 * 1024 }) - return stdout -} - -async function gitWithEnv(args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise { - assertDirectory(cwd, 'Git working directory') - const { stdout } = await exec('git', args, { cwd, env, maxBuffer: 10 * 1024 * 1024 }) - return stdout -} - -async function gitWithInput( +function runGit( args: string[], cwd: string, - input: string, - env: NodeJS.ProcessEnv = process.env + options: { env?: NodeJS.ProcessEnv; input?: string } = {} ): Promise { assertDirectory(cwd, 'Git working directory') return new Promise((resolve, reject) => { - const child = spawn('git', args, { cwd, env }) + let child: ReturnType + try { + child = spawn('git', args, { + cwd, + env: options.env, + stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] + }) + } catch (error) { + reject(error) + return + } + let stdout = '' let stderr = '' + let settled = false - child.stdout.on('data', (chunk) => { + const rejectOnce = (error: unknown): void => { + if (settled) return + settled = true + reject(error) + } + + child.stdout?.on('data', (chunk) => { stdout += String(chunk) + if (stdout.length > 10 * 1024 * 1024) { + child.kill() + rejectOnce(new GitCommandError(`git ${args.join(' ')} exceeded output buffer`, { stdout, stderr })) + } }) - child.stderr.on('data', (chunk) => { + child.stderr?.on('data', (chunk) => { stderr += String(chunk) }) - child.on('error', reject) + child.on('error', rejectOnce) child.on('close', (code) => { + if (settled) return + settled = true if (code === 0) { resolve(stdout) return } - reject(new Error(stderr || `git ${args.join(' ')} exited with code ${code}`)) + reject(new GitCommandError(stderr || `git ${args.join(' ')} exited with code ${code}`, { + code, + stdout, + stderr + })) }) - child.stdin.end(input) + if (options.input !== undefined) { + child.stdin?.end(options.input) + } }) } +async function git(args: string[], cwd: string): Promise { + return runGit(args, cwd) +} + +async function gitWithEnv(args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise { + return runGit(args, cwd, { env }) +} + +async function gitWithInput( + args: string[], + cwd: string, + input: string, + env: NodeJS.ProcessEnv = process.env +): Promise { + return runGit(args, cwd, { env, input }) +} + async function gitAllowNonZeroExit(args: string[], cwd: string): Promise { try { return await git(args, cwd) @@ -150,6 +197,34 @@ async function gitAllowNonZeroExit(args: string[], cwd: string): Promise } } +function countLines(buffer: Buffer): number { + if (buffer.length === 0) return 0 + let lines = 0 + for (let index = 0; index < buffer.length; index += 1) { + if (buffer[index] === 10) lines += 1 + } + return buffer[buffer.length - 1] === 10 ? lines : lines + 1 +} + +async function getUntrackedAdditions(path: string, files: FileStatus[]): Promise { + const untracked = files + .filter((entry) => entry.status === 'untracked') + .slice(0, MAX_UNTRACKED_SUMMARY_FILES) + + const counts = await Promise.all(untracked.map(async (entry) => { + const fullPath = join(path, entry.path) + try { + const info = await stat(fullPath) + if (!info.isFile() || info.size > MAX_UNTRACKED_SUMMARY_FILE_BYTES) return 0 + return countLines(await readFile(fullPath)) + } catch { + return 0 + } + })) + + return counts.reduce((sum, count) => sum + count, 0) +} + export async function getStatus(path: string): Promise { const output = await git(['status', '--porcelain=v2', '--untracked-files'], path) const files: FileStatus[] = [] @@ -292,14 +367,7 @@ export async function getDiff(path: string, file?: string): Promise { diff = await gitAllowNonZeroExit(['diff', '--cached'], path) } - const status = await getStatus(path) - const untrackedDiffs = await Promise.all( - status - .filter((entry) => entry.status === 'untracked') - .map((entry) => gitAllowNonZeroExit(['diff', '--no-index', '--', '/dev/null', entry.path], path)) - ) - - return [diff, ...untrackedDiffs].filter(Boolean).join('\n') + return diff } export async function getFileContent(path: string, file: string, revision = 'HEAD'): Promise { @@ -369,15 +437,8 @@ export async function getSummary(path: string): Promise { getTrackedNumstat(path), getStatus(path) ]) - const untrackedNumstats = await Promise.all( - status - .filter((entry) => entry.status === 'untracked') - .map((entry) => - gitAllowNonZeroExit(['diff', '--no-index', '--numstat', '--', '/dev/null', entry.path], path) - .catch(() => '') - ) - ) - const totals = parseNumstat([trackedNumstat, ...untrackedNumstats].filter(Boolean).join('\n')) + const totals = parseNumstat(trackedNumstat) + totals.additions += await getUntrackedAdditions(path, status) return { branch, diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d5906e97..8509419e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -45,6 +45,20 @@ function assertPathWithinProjects(targetPath: string): void { } } +const gitStatusWarnings = new Set() + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function warnGitStatusOnce(path: string, error: unknown): void { + const message = toErrorMessage(error) + const key = `${path}:${message}` + if (gitStatusWarnings.has(key)) return + gitStatusWarnings.add(key) + console.warn(`[git] Failed to read status for ${path}: ${message}`) +} + export function registerIpcHandlers(): void { // Fan proactive-agent events out to all renderer windows. proactiveAgentManager.onEvent((event) => { @@ -266,7 +280,12 @@ export function registerIpcHandlers(): void { ipcMain.handle('git:status', async (_, path: string) => { assertPathWithinProjects(path) if (!isDirectory(path)) return [] - return git.getStatus(path) + try { + return await git.getStatus(path) + } catch (error) { + warnGitStatusOnce(path, error) + return [] + } }) ipcMain.handle('git:diff', async (_, path: string, file?: string) => { diff --git a/src/renderer/src/stores/git-store.ts b/src/renderer/src/stores/git-store.ts index 1a01a2e0..5046bc47 100644 --- a/src/renderer/src/stores/git-store.ts +++ b/src/renderer/src/stores/git-store.ts @@ -107,6 +107,11 @@ function normalizeRootPaths(paths: Array): string[] { ) } +const statusRequests = new Set() +const summaryRequests = new Set() +const projectStatusRequests = new Set() +const diffRequests = new Set() + export const useGitStore = create((set, get) => ({ files: [], projectFiles: [], @@ -124,27 +129,36 @@ export const useGitStore = create((set, get) => ({ pollInterval: null, fetchStatus: async (path) => { + if (statusRequests.has(path)) return + statusRequests.add(path) try { const files = await pear.git.status(path) if (!sameFileStatuses(get().files, files)) set({ files }) } catch { if (get().files.length > 0) set({ files: [] }) + } finally { + statusRequests.delete(path) } }, fetchSummary: async (path) => { + if (summaryRequests.has(path)) return + summaryRequests.add(path) try { const summary = await pear.git.summary(path) const nextSummary = summary ? { ...summary, rootPath: path } : null if (!sameGitSummary(get().summary, nextSummary)) set({ summary: nextSummary }) } catch { if (get().summary !== null) set({ summary: null }) + } finally { + summaryRequests.delete(path) } }, fetchProjectStatus: async (paths) => { const rootPaths = normalizeRootPaths(paths) const rootPathKey = rootPaths.join('\0') + if (projectStatusRequests.has(rootPathKey)) return if (rootPaths.length === 0) { if (get().projectFiles.length > 0 || get().projectSummary !== null) { set({ projectFiles: [], projectSummary: null }) @@ -152,43 +166,53 @@ export const useGitStore = create((set, get) => ({ return } - const entries = await Promise.all(rootPaths.map(async (rootPath) => { - const [files, summary] = await Promise.all([ - pear.git.status(rootPath).catch(() => [] as FileStatus[]), - pear.git.summary(rootPath).catch(() => null) - ]) - return { rootPath, files, summary } - })) - const summaries = entries - .map((entry) => entry.summary) - .filter((summary): summary is IpcGitSummary => summary !== null) - const projectFiles = entries.flatMap((entry) => - entry.files.map((file) => ({ ...file, rootPath: entry.rootPath })) - ) - const projectSummary = summaries.length > 0 - ? { - rootPathKey, - rootCount: summaries.length, - additions: summaries.reduce((total, summary) => total + summary.additions, 0), - deletions: summaries.reduce((total, summary) => total + summary.deletions, 0) - } - : null - - if ( - !sameProjectFileStatuses(get().projectFiles, projectFiles) || - !sameProjectGitSummary(get().projectSummary, projectSummary) - ) { - set({ projectFiles, projectSummary }) + projectStatusRequests.add(rootPathKey) + try { + const entries = await Promise.all(rootPaths.map(async (rootPath) => { + const [files, summary] = await Promise.all([ + pear.git.status(rootPath).catch(() => [] as FileStatus[]), + pear.git.summary(rootPath).catch(() => null) + ]) + return { rootPath, files, summary } + })) + const summaries = entries + .map((entry) => entry.summary) + .filter((summary): summary is IpcGitSummary => summary !== null) + const projectFiles = entries.flatMap((entry) => + entry.files.map((file) => ({ ...file, rootPath: entry.rootPath })) + ) + const projectSummary = summaries.length > 0 + ? { + rootPathKey, + rootCount: summaries.length, + additions: summaries.reduce((total, summary) => total + summary.additions, 0), + deletions: summaries.reduce((total, summary) => total + summary.deletions, 0) + } + : null + + if ( + !sameProjectFileStatuses(get().projectFiles, projectFiles) || + !sameProjectGitSummary(get().projectSummary, projectSummary) + ) { + set({ projectFiles, projectSummary }) + } + } finally { + projectStatusRequests.delete(rootPathKey) } }, fetchDiff: async (rootPath, file?) => { + const key = `${rootPath}:${file || ''}` + if (diffRequests.has(key)) return + diffRequests.add(key) set({ loading: true }) try { const diff = await pear.git.diff(rootPath, file) set({ diff, loading: false }) } catch { set({ diff: '', loading: false }) + } finally { + diffRequests.delete(key) } }, @@ -285,7 +309,7 @@ export const useGitStore = create((set, get) => ({ if (file) { get().fetchDiff(rootPath, file) } else { - get().fetchDiff(rootPath) + set({ diff: '', loading: false }) } },