/* eslint-disable max-lines */ import { BrowserWindow, dialog, ipcMain, shell } from 'electron' import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'fs/promises' import { randomUUID } from 'crypto' import { dirname, extname, join, resolve } from 'path' import type { ChildProcess } from 'child_process' import { gitExecFileAsync, wslAwareSpawn } from '../git/runner' import { parseWslPath, toWindowsWslPath } from '../wsl' import type { Store } from '../persistence' import type { DirEntry, GitBranchCompareResult, GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitForkSyncExpectedUpstream, GitForkSyncResult, GlobalSettings, GitPushTarget, GitUpstreamStatus, GitStatusResult, MarkdownDocument, SearchOptions, SearchResult, Repo, TuiAgent } from '../../shared/types' import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildRgArgs, createAccumulator, DEFAULT_SEARCH_MAX_RESULTS, finalize, ingestRgJsonLine, SEARCH_TIMEOUT_MS } from '../../shared/text-search' import { getStatus, abortMerge, abortRebase, detectConflictOperation, getDiff, commitChanges, stageFile, unstageFile, bulkStageFiles, bulkUnstageFiles, bulkDiscardChanges, discardChanges, getStagedCommitContext, getBranchCompare, getBranchDiff, getCommitCompare, getCommitDiff } from '../git/status' import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, cancelGeneratePullRequestFieldsLocal, discoverCommitMessageModelsLocal, discoverCommitMessageModelsRemote, generateCommitMessageFromContext, generatePullRequestFieldsFromContext, resolveCommitMessageSettings, type DiscoverCommitMessageModelsResult, type CommitMessageGenerationTarget, type GenerateCommitMessageResult, type GeneratePullRequestFieldsResult } from '../text-generation/commit-message-text-generation' import { getPullRequestDraftContext } from '../text-generation/pull-request-context' import { getUpstreamStatus } from '../git/upstream' import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { gitSyncForkDefaultBranch } from '../git/fork-sync' import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync' import { checkIgnoredPaths } from '../git/check-ignored-paths' import { appendFolderToGitignore, findKnownHugeFolderPathsToIgnore } from '../git/huge-folder-ignore' import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import type { HostedReviewProvider } from '../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import { validateGitPushTarget } from '../git/push-target-validation' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' import { resolveAuthorizedPath, resolveRegisteredWorktreePath, validateGitRelativeFilePath, isENOENT, authorizeExternalPath } from './filesystem-auth' import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { getSshFilesystemProvider, requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' import { prepareLocalCommitMessageAgentEnv, type CommitMessageAgentRuntimeTarget, type CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { listRepoWorktrees } from '../repo-worktrees' import { splitWorktreeId } from '../../shared/worktree-id' import { getRuntimePathBasename } from '../../shared/cross-platform-path' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' // Why: Monaco has large-file optimizations like VS Code; blocking at 5MB makes // ordinary JSON/log files inaccessible before the editor can degrade features. const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB const BINARY_PROBE_BYTES = 8192 const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ // Why: previewable binaries (PDFs, images) are rendered by the viewer as // base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and // raising this cap only affects binary preview, not text/search paths. // The relay (SSH) uses a smaller 10MB cap because its JSON-RPC frames are // bounded by MAX_MESSAGE_SIZE = 16MB; the local IPC path has no such limit, // so 50MB covers real-world PDFs (specs, datasheets, image-heavy contracts). // See src/relay/fs-handler-utils.ts for the remote-side reasoning. const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.pdf': 'application/pdf' } const WINDOWS_RESERVED_LOCAL_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']) type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } function validateRequiredString(value: unknown, label: string): string { if (typeof value !== 'string' || value.trim() === '') { throw new Error(`${label} is required`) } return value } function sanitizeSaveDialogFilename(remoteBasename: string): string { const sanitized = Array.from(remoteBasename, (char) => char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char ) .join('') .replace(/[. ]+$/g, '') if (!sanitized || WINDOWS_RESERVED_LOCAL_BASENAME.test(sanitized)) { return 'download' } return sanitized } function createSiblingTransferPath(destinationPath: string, suffix: string): string { return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) } async function cleanupLocalTransferPath(filePath: string | null): Promise { if (!filePath) { return } await rm(filePath, { force: true }).catch(() => {}) } async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> { try { const destinationStat = await stat(destinationPath) if (destinationStat.isDirectory()) { throw new Error('Cannot download to a directory') } return { existed: true } } catch (error) { if (isENOENT(error)) { return { existed: false } } throw error } } async function assertDestinationStillUnclaimed(destinationPath: string): Promise { try { await stat(destinationPath) } catch (error) { if (isENOENT(error)) { return } throw error } throw new Error('Destination file appeared before download completed') } async function promoteDownloadedFile( tempPath: string, destinationPath: string, destinationExisted: boolean ): Promise { if (!destinationExisted) { await assertDestinationStillUnclaimed(destinationPath) await rename(tempPath, destinationPath) return } const backupPath = createSiblingTransferPath(destinationPath, 'backup') let backupCreated = false try { await rename(destinationPath, backupPath) backupCreated = true await rename(tempPath, destinationPath) await cleanupLocalTransferPath(backupPath) } catch (error) { if (backupCreated) { await rename(backupPath, destinationPath).catch(() => {}) } throw error } } function comparableLocalPath(value: string): string { const normalized = resolve(value) return process.platform === 'win32' ? normalized.toLowerCase() : normalized } function getCandidateLocalWorktreePaths( worktreePath: string, resolvedWorktreePath: string ): Set { return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) } function hasRegisteredWorktreeMetaForRepo( store: Store, repoId: string, candidatePaths: Set ): boolean { for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { const parsed = splitWorktreeId(worktreeId) if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { return true } } return false } function comparableRemotePath(value: string): string { return value.replace(/[/\\]+$/g, '') } function hasRegisteredRemoteWorktreeMetaForRepo( store: Store, repoId: string, worktreePath: string ): boolean { const comparableWorktreePath = comparableRemotePath(worktreePath) for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { const parsed = splitWorktreeId(worktreeId) if ( parsed?.repoId === repoId && comparableRemotePath(parsed.worktreePath) === comparableWorktreePath ) { return true } } return false } async function localRepoOwnsWorktree( store: Store, repo: Repo, worktreePath: string ): Promise { let resolvedWorktreePath: string try { resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) } catch { return false } const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) if (candidatePaths.has(comparableLocalPath(repo.path))) { return true } if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { return true } try { const worktrees = await listRepoWorktrees(repo) return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) } catch { return false } } async function remoteRepoOwnsWorktree( store: Store, repo: Repo, worktreePath: string, connectionId: string ): Promise { const comparableWorktreePath = comparableRemotePath(worktreePath) if (comparableRemotePath(repo.path) === comparableWorktreePath) { return true } const provider = getSshGitProvider(connectionId) if (!provider) { return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) } try { const worktrees = await provider.listWorktrees(repo.path) return worktrees.some( (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath ) } catch { return false } } async function getRepoForSourceControlAi( store: Store, args: { repoId?: string; worktreePath: string; connectionId?: string } ): Promise { if (!args.repoId) { return null } const repo = store.getRepo(args.repoId) if (!repo) { return null } if (args.connectionId) { if (repo.connectionId !== args.connectionId) { return null } // Why: a single SSH connection can host several repos; repo-scoped AI // overrides only apply when the requested worktree belongs to that repo. return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) ? repo : null } if (repo.connectionId) { return null } // Why: renderer-supplied repoId is advisory; only apply repo overrides when // the requested local worktree is known to belong to that repo. return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null } function getLocalAgentRuntimeTarget( gitOptions: LocalProjectWorktreeGitOptions ): CommitMessageAgentRuntimeTarget { return gitOptions.wslDistro ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } : { runtime: 'host' } } function getLocalTextGenerationTarget( worktreePath: string, gitOptions: LocalProjectWorktreeGitOptions, env?: NodeJS.ProcessEnv ): Extract { return { kind: 'local', cwd: worktreePath, ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), ...(env ? { env } : {}) } } function validateFullGitObjectId(value: string, label: string): string { if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { throw new Error(`${label} must be a full git object id`) } return value } /** * Check if a buffer appears to be binary (contains null bytes in first 8KB). */ function isBinaryBuffer(buffer: Buffer): boolean { const len = Math.min(buffer.length, 8192) for (let i = 0; i < len; i++) { if (buffer[i] === 0) { return true } } return false } async function isBinaryFilePrefix(filePath: string): Promise { const handle = await open(filePath, 'r') try { const probe = Buffer.alloc(BINARY_PROBE_BYTES) const { bytesRead } = await handle.read(probe, 0, probe.length, 0) return isBinaryBuffer(probe.subarray(0, bytesRead)) } finally { await handle.close() } } async function isDirectoryEntry( dirPath: string, entry: { name: string; isDirectory(): boolean; isSymbolicLink(): boolean }, _resolveEntryPath: (entryPath: string) => Promise ): Promise { // Why: following a symlink just to decorate readDir can touch macOS // TCC-protected app containers. Treat links as file-like until the user // explicitly opens them. void _resolveEntryPath if (entry.isSymbolicLink()) { void dirPath return false } if (entry.isDirectory()) { return true } return false } export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { const activeTextSearches = new Map() // ─── Filesystem ───────────────────────────────────────── ipcMain.handle( 'fs:readDir', async (_event, args: { dirPath: string; connectionId?: string }): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readDir(args.dirPath) } const dirPath = await resolveAuthorizedPath(args.dirPath, store) const entries = await readdir(dirPath, { withFileTypes: true }) const mapped = await Promise.all( entries.map(async (entry) => ({ name: entry.name, isDirectory: await isDirectoryEntry(dirPath, entry, (entryPath) => resolveAuthorizedPath(entryPath, store) ), isSymlink: entry.isSymbolicLink() })) ) return mapped.sort((a, b) => { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } return a.name.localeCompare(b.name) }) } ) ipcMain.handle( 'fs:readFile', async ( _event, args: { filePath: string; connectionId?: string } ): Promise<{ content: string; isBinary: boolean; isImage?: boolean; mimeType?: string }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.readFile(args.filePath) } const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE if (stats.size > sizeLimit) { throw new Error( `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` ) } if (mimeType) { const buffer = await readFile(filePath) return { content: buffer.toString('base64'), isBinary: true, // Why: the renderer/store contract already keys previewable binary // rendering off `isImage`. Keep that legacy flag for PDFs too so the // new preview path stays compatible with existing callers. isImage: true, mimeType } } // Why: the text cap is intentionally larger than the old binary cap. // Probe unknown large files first so archives do not get fully buffered // just to discover they are not editable text. if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { return { content: '', isBinary: true } } const buffer = await readFile(filePath) if (isBinaryBuffer(buffer)) { return { content: '', isBinary: true } } return { content: buffer.toString('utf-8'), isBinary: false } } ) ipcMain.handle( 'fs:downloadFile', async ( event, args: { filePath?: string; connectionId?: string } ): Promise => { const filePath = validateRequiredString(args?.filePath, 'filePath') const connectionId = validateRequiredString(args?.connectionId, 'connectionId') const provider = requireSshFilesystemProvider(connectionId) const remoteStat = await provider.stat(filePath) if (remoteStat.type === 'directory') { throw new Error('Cannot download a directory') } if (!provider.downloadFile) { throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') } const remoteBasename = getRuntimePathBasename(filePath) const defaultPath = sanitizeSaveDialogFilename(remoteBasename) const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined const dialogResult = parentWindow ? await dialog.showSaveDialog(parentWindow, { defaultPath }) : await dialog.showSaveDialog({ defaultPath }) if (dialogResult.canceled || !dialogResult.filePath) { return { canceled: true } } const destinationPath = dialogResult.filePath const { existed } = await inspectDownloadDestination(destinationPath) const tempPath = createSiblingTransferPath(destinationPath, 'download') let promoted = false try { await provider.downloadFile(filePath, tempPath) await promoteDownloadedFile(tempPath, destinationPath, existed) promoted = true return { canceled: false, destinationPath } } finally { if (!promoted) { await cleanupLocalTransferPath(tempPath) } } } ) ipcMain.handle( 'fs:listMarkdownDocuments', async ( _event, args: { rootPath: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) const relativePaths = await provider.listFiles(args.rootPath) return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) } const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) return listMarkdownDocuments(rootPath) } ) ipcMain.handle( 'fs:writeFile', async ( _event, args: { filePath: string; content: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.writeFile(args.filePath, args.content) } const filePath = await resolveAuthorizedPath(args.filePath, store) try { const fileStats = await lstat(filePath) if (fileStats.isDirectory()) { throw new Error('Cannot write to a directory') } } catch (error) { if (!isENOENT(error)) { throw error } } await writeFile(filePath, args.content, 'utf-8') } ) ipcMain.handle( 'fs:deletePath', async ( _event, args: { targetPath: string; connectionId?: string; recursive?: boolean } ): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.deletePath(args.targetPath, args.recursive) } // Why: deleting must operate on the symlink itself, not its target. // Following the link with realpath() would trash the real file — which // could be another file inside the worktree, or a path outside all // allowed roots that we would never be able to delete again. const targetPath = await resolveAuthorizedPath(args.targetPath, store, { preserveSymlink: true }) // Why: once auto-refresh exists, an external delete can race with a // UI-initiated delete. Swallowing ENOENT keeps the action idempotent // from the user's perspective (design §7.1). try { await shell.trashItem(targetPath) } catch (error) { if (isENOENT(error)) { return } throw error } } ) registerFilesystemMutationHandlers(store) ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { authorizeExternalPath(args.targetPath) }) ipcMain.handle( 'fs:stat', async ( _event, args: { filePath: string; connectionId?: string } ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) const s = await provider.stat(args.filePath) return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } } const filePath = await resolveAuthorizedPath(args.filePath, store) const stats = await stat(filePath) return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } } ) ipcMain.handle( 'fs:pathExists', async (_event, args: { filePath: string; connectionId?: string }): Promise => { try { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) await provider.stat(args.filePath) return true } const filePath = await resolveAuthorizedPath(args.filePath, store) await stat(filePath) return true } catch (error) { if (isENOENT(error)) { return false } throw error } } ) // ─── Search ──────────────────────────────────────────── ipcMain.handle( 'fs:search', async (event, args: SearchOptions & { connectionId?: string }): Promise => { if (args.connectionId) { const provider = requireSshFilesystemProvider(args.connectionId) return provider.search(args) } const rootPath = await resolveAuthorizedPath(args.rootPath, store) const localGitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.rootPath, rootPath ) const maxResults = Math.max( 1, Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` // Why: checking rg availability upfront avoids a race condition where // spawn('rg') emits 'close' before 'error' on some platforms, causing // the handler to resolve with empty results before the git-grep // fallback can run. The result is cached after the first check. const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) if (!rgAvailable) { return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } return new Promise((resolvePromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: search requests are fired on each query/options change. If the // previous ripgrep process keeps running, it can continue streaming and // parsing thousands of matches on the Electron main thread after the UI // no longer cares about that result, which is exactly the freeze users // experience in large repos. activeTextSearches.get(searchKey)?.kill() const acc = createAccumulator() let stdoutBuffer = '' let resolved = false let child: ChildProcess | null = null let killTimeout: ReturnType // Why: WSL-routed rg emits Linux-native paths. UNC repos carry their // distro in the path; Windows-path repos carry it in project runtime. const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro const transformAbsPath = wslDistroForOutput ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined const resolveOnce = (): void => { if (resolved) { return } resolved = true if (activeTextSearches.get(searchKey) === child) { activeTextSearches.delete(searchKey) } clearTimeout(killTimeout) // Why: child.kill() is advisory. If rg ignores it, detach our // closures so repeated local searches do not retain old scans. child?.stdout?.off('data', handleStdoutData) child?.stderr?.off('data', handleStderrData) child?.off('error', handleError) child?.off('close', handleClose) resolvePromise(finalize(acc)) } const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) if (verdict === 'stop') { child?.kill() } } const nextChild = wslAwareSpawn('rg', rgArgs, { cwd: rootPath, ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), stdio: ['ignore', 'pipe', 'pipe'] }) child = nextChild activeTextSearches.set(searchKey, nextChild) const handleStdoutData = (chunk: string): void => { stdoutBuffer += chunk const lines = stdoutBuffer.split('\n') stdoutBuffer = lines.pop() ?? '' for (const line of lines) { processLine(line) } } const handleStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } const handleError = (): void => { resolveOnce() } const handleClose = (): void => { if (stdoutBuffer) { processLine(stdoutBuffer) } resolveOnce() } nextChild.stdout!.setEncoding('utf-8') nextChild.stdout!.on('data', handleStdoutData) nextChild.stderr!.on('data', handleStderrData) nextChild.once('error', handleError) nextChild.once('close', handleClose) // Why: if the timeout fires, the child is killed and results are partial. // We must mark them as truncated so the UI can indicate incomplete results. killTimeout = setTimeout(() => { acc.truncated = true child?.kill() resolveOnce() }, SEARCH_TIMEOUT_MS) }) } ) // ─── List all files (for quick-open) ───────────────────── ipcMain.handle( 'fs:listFiles', async ( _event, args: { rootPath: string; connectionId?: string; excludePaths?: string[] } ): Promise => { if (args.connectionId) { const provider = getSshFilesystemProvider(args.connectionId) // Why: when the SSH connection is not yet established (cold start) or // temporarily disconnected, return [] so quick-open shows "No matching // files" instead of an error banner. The file list will repopulate when // the user re-opens quick-open after the connection is restored. if (!provider) { return [] } // Why: forward excludePaths through to the remote provider. // Dropping it here would silently double-scan nested linked worktrees // over SSH and contribute to timeout-induced partial results. return provider.listFiles(args.rootPath, { excludePaths: args.excludePaths }) } return listQuickOpenFiles(args.rootPath, store, args.excludePaths) } ) // ─── Git operations ───────────────────────────────────── ipcMain.handle( 'git:status', async ( _event, args: { worktreePath: string connectionId?: string includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean } ): Promise => { const options = { includeIgnored: args.includeIgnored ?? false, ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}) } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getStatus(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getStatus(worktreePath, { ...options, ...gitOptions }) } ) ipcMain.handle( 'git:checkIgnored', async ( _event, args: { worktreePath: string; paths: string[]; connectionId?: string } ): Promise => { if (args.connectionId) { const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.checkIgnoredPaths(args.worktreePath, paths) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return checkIgnoredPaths(worktreePath, paths, gitOptions) } ) // Why: when status hits the entry limit, the SCM view offers to .gitignore the // folder that's flooding it. These two handlers back that flow. Local-only: // the huge-untracked-folder case is a local-dev pathology, and routing a // .gitignore write through the SSH provider isn't worth the surface here. ipcMain.handle( 'git:findHugeFoldersToIgnore', async (_event, args: { worktreePath: string }): Promise => { const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) } ) ipcMain.handle( 'git:appendGitignore', async (_event, args: { worktreePath: string; folderName: string }): Promise => { const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return appendFolderToGitignore(worktreePath, args.folderName) } ) ipcMain.handle( 'git:history', async ( _event, args: { worktreePath: string; connectionId?: string } & GitHistoryOptions ): Promise => { const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getHistory(args.worktreePath, options) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getHistory(worktreePath, { ...options, ...gitOptions }) } ) // Why: lightweight fs-only check for conflict operation state. Used to poll // non-active worktrees so their "Rebasing"/"Merging" badges clear when the // operation finishes, without running a full `git status`. ipcMain.handle( 'git:conflictOperation', async ( _event, args: { worktreePath: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.detectConflictOperation(args.worktreePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return detectConflictOperation(worktreePath) } ) ipcMain.handle( 'git:abortMerge', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } return provider.abortMerge(args.worktreePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await abortMerge(worktreePath, gitOptions) } ) ipcMain.handle( 'git:abortRebase', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(`No git provider for connection "${args.connectionId}"`) } return provider.abortRebase(args.worktreePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await abortRebase(worktreePath, gitOptions) } ) ipcMain.handle( 'git:diff', async ( _event, args: { worktreePath: string filePath: string staged: boolean compareAgainstHead?: boolean connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getDiff( args.worktreePath, args.filePath, args.staged, args.compareAgainstHead ) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, gitOptions) } ) ipcMain.handle( 'git:commit', async ( _event, args: { worktreePath: string; message: string; connectionId?: string } ): Promise<{ success: boolean; error?: string }> => { // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. if (typeof args.message !== 'string' || args.message.trim().length === 0) { throw new Error('Commit message is required') } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.commit(args.worktreePath, args.message) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return commitChanges(worktreePath, args.message, gitOptions) } ) ipcMain.handle( 'git:generateCommitMessage', async ( _event, args: { worktreePath: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams sourceControlAi?: GlobalSettings['sourceControlAi'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] } ): Promise => { const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) const baseSettings = store.getSettings() const requestSettings = { ...baseSettings, ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), ...(args.agentCmdOverrides !== undefined ? { agentCmdOverrides: args.agentCmdOverrides } : {}) } const resolvedSettings = args.sourceControlAiResolvedParams ? { ok: true as const, params: args.sourceControlAiResolvedParams } : resolveCommitMessageSettings( requestSettings, discoveryHostKey, 'commitMessage', await getRepoForSourceControlAi(store, args) ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } } let context try { context = await provider.getStagedCommitContext(args.worktreePath) } catch (error) { console.error('[filesystem] Failed to read remote staged commit context:', error) return { success: false, error: 'Failed to read staged changes.' } } if (!context) { return { success: false, error: 'No staged changes to summarize.' } } return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, execute: (plan, cwd, timeoutMs, operation) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) let context try { context = await getStagedCommitContext(worktreePath, gitOptions) } catch (error) { console.error('[filesystem] Failed to read staged commit context:', error) return { success: false, error: 'Failed to read staged changes.' } } if (!context) { return { success: false, error: 'No staged changes to summarize.' } } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, getLocalAgentRuntimeTarget(gitOptions) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } return generateCommitMessageFromContext( context, resolvedSettings.params, getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) ) } ) ipcMain.handle( 'git:cancelGenerateCommitMessage', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { return } await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) cancelGenerateCommitMessageLocal(worktreePath) } ) ipcMain.handle( 'git:discoverCommitMessageModels', async ( _event, args: { agentId: string; worktreePath?: string; connectionId?: string } ): Promise => { const agentId = args.agentId const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] if (args.connectionId) { if (!args.worktreePath) { return { success: false, error: 'Missing worktree path for remote model discovery.' } } const provider = getSshGitProvider(args.connectionId) if (!provider) { return { success: false, error: `No git provider for connection "${args.connectionId}"` } } return discoverCommitMessageModelsRemote( agentId as TuiAgent, args.worktreePath, (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), agentCommandOverride ) } let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } let localDiscoveryOptions: Parameters[3] if (args.worktreePath) { const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions) localDiscoveryOptions = gitOptions.wslDistro ? { cwd: worktreePath, wslDistro: gitOptions.wslDistro } : { cwd: worktreePath } } const localEnv = await prepareLocalCommitMessageAgentEnv( agentId, commitMessageAgentEnv, localRuntimeTarget ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } return localDiscoveryOptions ? discoverCommitMessageModelsLocal( agentId as TuiAgent, localEnv.env, agentCommandOverride, localDiscoveryOptions ) : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) } ) ipcMain.handle( 'git:generatePullRequestFields', async ( _event, args: { worktreePath: string repoId?: string base: string title: string body: string draft: boolean provider?: HostedReviewProvider useTemplate?: boolean connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams sourceControlAi?: GlobalSettings['sourceControlAi'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] } ): Promise => { const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) const baseSettings = store.getSettings() const requestSettings = { ...baseSettings, ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), ...(args.agentCmdOverrides !== undefined ? { agentCmdOverrides: args.agentCmdOverrides } : {}) } const resolvedSettings = args.sourceControlAiResolvedParams ? { ok: true as const, params: args.sourceControlAiResolvedParams } : resolveCommitMessageSettings( requestSettings, discoveryHostKey, 'pullRequest', await getRepoForSourceControlAi(store, args) ) if (!resolvedSettings.ok) { return { success: false, error: resolvedSettings.error } } if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { return { success: false, error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } } let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ body: args.body, repoPath: args.worktreePath, connectionId: args.connectionId, provider: args.provider, useTemplate: args.useTemplate }) context = await getPullRequestDraftContext( (argv) => provider.exec(argv, args.worktreePath), { base: args.base, currentTitle: args.title, currentBody, currentDraft: args.draft } ) } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' } } if (!context) { return { success: false, error: 'No branch changes to summarize.' } } return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, execute: (plan, cwd, timeoutMs, operation) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), missingBinaryLocation: 'remote PATH' }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) let context: Awaited> try { const currentBody = await resolveHostedReviewBodyForGeneration({ body: args.body, repoPath: worktreePath, connectionId: args.connectionId, provider: args.provider, useTemplate: args.useTemplate }) context = await getPullRequestDraftContext( (argv, options) => gitExecFileAsync(argv, { cwd: worktreePath, ...gitOptions, ...options }), { base: args.base, currentTitle: args.title, currentBody, currentDraft: args.draft } ) } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' } } if (!context) { return { success: false, error: 'No branch changes to summarize.' } } const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, getLocalAgentRuntimeTarget(gitOptions) ) if (!localEnv.ok) { return { success: false, error: localEnv.error } } return generatePullRequestFieldsFromContext( context, resolvedSettings.params, getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) ) } ) ipcMain.handle( 'git:cancelGeneratePullRequestFields', async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { return } await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') return } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) cancelGeneratePullRequestFieldsLocal(worktreePath) } ) ipcMain.handle( 'git:branchCompare', async ( _event, args: { worktreePath: string; baseRef: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getBranchCompare(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getBranchCompare(worktreePath, args.baseRef, gitOptions) } ) ipcMain.handle( 'git:commitCompare', async ( _event, args: { worktreePath: string; commitId: string; connectionId?: string } ): Promise => { const commitId = validateFullGitObjectId(args.commitId, 'commitId') if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getCommitCompare(args.worktreePath, commitId) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getCommitCompare(worktreePath, commitId, gitOptions) } ) ipcMain.handle( 'git:upstreamStatus', async ( _event, args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } ): Promise => { if (args.connectionId) { if (args.pushTarget) { assertGitPushTargetShape(args.pushTarget) } const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) } ) ipcMain.handle( 'git:fetch', async ( _event, args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } ): Promise => { if (args.connectionId) { if (args.pushTarget) { assertGitPushTargetShape(args.pushTarget) } const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.fetchRemote(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) if (args.pushTarget) { await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } await gitFetch(worktreePath, args.pushTarget, gitOptions) } ) ipcMain.handle( 'git:syncFork', async ( _event, args: { worktreePath: string connectionId?: string expectedUpstream: GitForkSyncExpectedUpstream } ): Promise => { const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { required: true }) if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, gitOptions) } ) ipcMain.handle( 'git:push', async ( _event, args: { worktreePath: string publish?: boolean forceWithLease?: boolean connectionId?: string pushTarget?: GitPushTarget } ): Promise => { // Why: coerce to strict boolean at the IPC boundary so a malformed // renderer payload (e.g. string 'false') can't silently enable // --set-upstream mode. Mirrors the relay handler in src/relay/git-handler.ts. const publish = args.publish === true if (args.connectionId) { if (args.pushTarget) { assertGitPushTargetShape(args.pushTarget) } const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { forceWithLease: args.forceWithLease === true }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) if (args.pushTarget) { await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } await gitPush(worktreePath, publish, args.pushTarget, { forceWithLease: args.forceWithLease === true, ...gitOptions }) } ) ipcMain.handle( 'git:pull', async ( _event, args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } ): Promise => { if (args.connectionId) { if (args.pushTarget) { assertGitPushTargetShape(args.pushTarget) } const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.pullBranch(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) if (args.pushTarget) { await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } await gitPull(worktreePath, args.pushTarget, gitOptions) } ) ipcMain.handle( 'git:fastForward', async ( _event, args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } ): Promise => { if (args.connectionId) { if (args.pushTarget) { assertGitPushTargetShape(args.pushTarget) } const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.fastForwardBranch(args.worktreePath, args.pushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) if (args.pushTarget) { await validateGitPushTarget(worktreePath, args.pushTarget, gitOptions) } await gitFastForward(worktreePath, args.pushTarget, gitOptions) } ) ipcMain.handle( 'git:rebaseFromBase', async ( _event, args: { worktreePath: string; baseRef: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.rebaseFromBase(args.worktreePath, args.baseRef) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await gitPullRebaseFromBase(worktreePath, args.baseRef, gitOptions) } ) ipcMain.handle( 'git:branchDiff', async ( _event, args: { worktreePath: string compare: { baseRef: string baseOid: string headOid: string mergeBase: string } filePath: string oldPath?: string connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { includePatch: true, filePath: args.filePath, oldPath: args.oldPath }) return ( results[0] ?? { kind: 'text', originalContent: '', modifiedContent: '', originalIsBinary: false, modifiedIsBinary: false } ) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const oldPath = args.oldPath ? validateGitRelativeFilePath(worktreePath, args.oldPath) : undefined const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getBranchDiff( worktreePath, { mergeBase: args.compare.mergeBase, headOid: args.compare.headOid, filePath, oldPath }, gitOptions ) } ) ipcMain.handle( 'git:commitDiff', async ( _event, args: { worktreePath: string commitOid: string parentOid?: string | null filePath: string oldPath?: string connectionId?: string } ): Promise => { const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getCommitDiff(args.worktreePath, { commitOid, parentOid, filePath: args.filePath, oldPath: args.oldPath }) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const oldPath = args.oldPath ? validateGitRelativeFilePath(worktreePath, args.oldPath) : undefined const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) return getCommitDiff( worktreePath, { commitOid, parentOid, filePath, oldPath }, gitOptions ) } ) ipcMain.handle( 'git:stage', async ( _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.stageFile(args.worktreePath, args.filePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await stageFile(worktreePath, filePath, gitOptions) } ) ipcMain.handle( 'git:unstage', async ( _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.unstageFile(args.worktreePath, args.filePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await unstageFile(worktreePath, filePath, gitOptions) } ) ipcMain.handle( 'git:discard', async ( _event, args: { worktreePath: string; filePath: string; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.discardChanges(args.worktreePath, args.filePath) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await discardChanges(worktreePath, filePath, gitOptions) } ) ipcMain.handle( 'git:bulkDiscard', async ( _event, args: { worktreePath: string; filePaths: string[]; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await bulkDiscardChanges(worktreePath, filePaths, gitOptions) } ) ipcMain.handle( 'git:bulkStage', async ( _event, args: { worktreePath: string; filePaths: string[]; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.bulkStageFiles(args.worktreePath, args.filePaths) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await bulkStageFiles(worktreePath, filePaths, gitOptions) } ) ipcMain.handle( 'git:bulkUnstage', async ( _event, args: { worktreePath: string; filePaths: string[]; connectionId?: string } ): Promise => { if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) await bulkUnstageFiles(worktreePath, filePaths, gitOptions) } ) ipcMain.handle( 'git:remoteFileUrl', async ( _event, args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } ): Promise => { // Why: remote repos can't read relay-side .git/config locally. Delegate // URL construction to the SSH provider, which can fetch remote metadata. if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return getRemoteFileUrl(worktreePath, args.relativePath, args.line) } ) ipcMain.handle( 'git:remoteCommitUrl', async ( _event, args: { worktreePath: string; sha: string; connectionId?: string } ): Promise => { const sha = validateFullGitObjectId(args.sha, 'sha') // Why: remote repos can't read relay-side .git/config locally. Delegate // URL construction to the SSH provider, which can fetch remote metadata. if (args.connectionId) { const provider = getSshGitProvider(args.connectionId) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } return provider.getRemoteCommitUrl(args.worktreePath, sha) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) return getRemoteCommitUrl(worktreePath, sha) } ) }