Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 103 additions & 42 deletions src/main/git.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -65,6 +76,8 @@ type GitHubCommitAvatar = {
const gitHubCommitAvatarCache = new Map<string, GitHubCommitAvatar>()
// 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
Expand Down Expand Up @@ -94,50 +107,84 @@ export interface GitCheckoutBranchOptions {
stashChanges?: boolean
}

async function git(args: string[], cwd: string): Promise<string> {
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<string> {
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<string> {
assertDirectory(cwd, 'Git working directory')

return new Promise((resolve, reject) => {
const child = spawn('git', args, { cwd, env })
let child: ReturnType<typeof spawn>
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<string> {
return runGit(args, cwd)
}

async function gitWithEnv(args: string[], cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
return runGit(args, cwd, { env })
}

async function gitWithInput(
args: string[],
cwd: string,
input: string,
env: NodeJS.ProcessEnv = process.env
): Promise<string> {
return runGit(args, cwd, { env, input })
}

async function gitAllowNonZeroExit(args: string[], cwd: string): Promise<string> {
try {
return await git(args, cwd)
Expand All @@ -150,6 +197,34 @@ async function gitAllowNonZeroExit(args: string[], cwd: string): Promise<string>
}
}

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<number> {
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<FileStatus[]> {
const output = await git(['status', '--porcelain=v2', '--untracked-files'], path)
const files: FileStatus[] = []
Expand Down Expand Up @@ -292,14 +367,7 @@ export async function getDiff(path: string, file?: string): Promise<string> {
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<string> {
Expand Down Expand Up @@ -369,15 +437,8 @@ export async function getSummary(path: string): Promise<GitSummary | null> {
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,
Expand Down
21 changes: 20 additions & 1 deletion src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ function assertPathWithinProjects(targetPath: string): void {
}
}

const gitStatusWarnings = new Set<string>()

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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
80 changes: 52 additions & 28 deletions src/renderer/src/stores/git-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ function normalizeRootPaths(paths: Array<string | null | undefined>): string[] {
)
}

const statusRequests = new Set<string>()
const summaryRequests = new Set<string>()
const projectStatusRequests = new Set<string>()
const diffRequests = new Set<string>()

export const useGitStore = create<GitState>((set, get) => ({
files: [],
projectFiles: [],
Expand All @@ -124,71 +129,90 @@ export const useGitStore = create<GitState>((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 })
}
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)
}
},

Expand Down Expand Up @@ -285,7 +309,7 @@ export const useGitStore = create<GitState>((set, get) => ({
if (file) {
get().fetchDiff(rootPath, file)
} else {
get().fetchDiff(rootPath)
set({ diff: '', loading: false })
}
},

Expand Down