/* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ import { execFile, spawn, type ExecFileOptions } from 'node:child_process' import { promisify } from 'node:util' import * as path from 'node:path' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' import { isUnsupportedWorktreeListZError, parseBranchDiff, parseWorktreeList } from './git-handler-utils' import { parseNumstat } from '../shared/git-uncommitted-line-stats' import { computeDiff, branchCompare as branchCompareOp, branchDiffEntries, validateGitExecArgs } from './git-handler-ops' import { buildSubmoduleInnerCommitRangeDiff, computeSubmodulePointerDiff, computeSubmoduleRangeEntries, createSubmodulePathsCache, findContainingSubmodule, listSubmodulePathsCached, resolveSubmoduleWorktreePath, resolveSubmoduleCommitRange, type SubmodulePathsCache } from './git-handler-submodule-ops' import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' import { areRelayWorktreePathsEqual, commitChangesRelay, addWorktreeOp, removeWorktreeOp, worktreeIsCleanOp } from './git-handler-worktree-ops' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { checkIgnoredPathsOp } from './git-handler-check-ignore' import { resolveRelayPushTarget } from './git-handler-push-target' import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error' import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status' import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status' import { resolveGitRemoteRebaseSource } from '../shared/git-rebase-source' import type { GitPushTarget } from '../shared/types' import { getEffectiveGitUpstreamStatus, resolveEffectiveGitUpstream } from '../shared/git-effective-upstream' import { loadGitHistoryFromExecutor } from '../shared/git-history' import { buildRelayGitEnv } from './relay-command-env' import { removeSafeUntrackedDiscardTarget, removeSafeUntrackedDiscardTargets } from '../shared/git-discard-path-safety' import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' import { GitCapabilityCache } from '../shared/git-capability-cache' import { hasUnsupportedRevParsePathFormatEcho, isUnsupportedRevParsePathFormatError } from '../shared/git-worktree-command-capabilities' import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 const BULK_CHUNK_SIZE = 100 function resolveSubmoduleStatusArea( params: Record ): 'staged' | 'unstaged' | 'untracked' { if (params.area === 'staged' || params.area === 'unstaged' || params.area === 'untracked') { return params.area } return 'unstaged' } function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') } function resolveRelayPath(repoPath: string, value: string): string { if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { return value } // Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir // must be resolved against the scanned repo path. Mirror worktree.ts's // resolveRevParsePath: pick the win32/posix resolver from the repoPath shape. return isWindowsAbsolutePath(repoPath) ? path.win32.resolve(repoPath, value) : path.posix.resolve(repoPath, value) } type RelayRepoLocation = { topLevel: string; commonDir: string } function parseRelayRepoLocation(repoPath: string, output: string): RelayRepoLocation | undefined { // Old git (pre `--path-format`) echoes the unrecognized flag to stdout and // exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and // read the two trailing path lines (toplevel, then git-common-dir). Strip only // the trailing CR, not surrounding spaces — git paths may legitimately start // or end with a space. const lines = output .split('\n') .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) .filter((line) => line.length > 0 && !line.startsWith('-')) if (lines.length < 2) { return undefined } const [topLevel, commonDir] = lines.slice(-2) return { topLevel: resolveRelayPath(repoPath, topLevel), commonDir: resolveRelayPath(repoPath, commonDir) } } function execFileWithStdin( command: string, args: string[], options: ExecFileOptions, stdin: string ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { let settled = false const finish = ( error: Error | null, stdout: string | Buffer = '', stderr: string | Buffer = '' ): void => { if (settled) { return } settled = true if (error) { reject(Object.assign(error, { stdout, stderr })) return } resolve({ stdout: String(stdout), stderr: String(stderr) }) } const child = execFile(command, args, options, (error, stdout, stderr) => { if (error) { finish(error, stdout, stderr) return } finish(null, stdout, stderr) }) child.once('error', (error) => finish(error)) endSubprocessStdin(child.stdin, stdin) }) } export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() private readonly gitCapabilities = new GitCapabilityCache() // Why: large diff/exec responses are chunked onto the bulk lane so they do // not head-of-line-block interactive pty.data echo on the shared SSH channel. private readonly responseStreams = new GitResponseStreamRegistry() // Why: configured submodule paths change rarely; an instance-level TTL cache // avoids re-reading `.gitmodules` on every diff click over SSH, and being // per-instance it stays bound to the connection lifecycle (no cross-test leak). private submodulePathsCache: SubmodulePathsCache = createSubmodulePathsCache() // Why: RelayContext is accepted for protocol back-compat (see // docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops. constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher this.registerHandlers() // Why: a detached client's git.responseAck frames will never arrive; wake // any pump parked on the ack window so it re-checks staleness and exits. this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) } dispose(): void { this.responseStreams.disposeAll() } private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) this.dispatcher.onRequest('git.submoduleStatus', (p) => this.getSubmoduleStatus(p)) this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p, context) => this.getDiff(p, context)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p)) this.dispatcher.onRequest('git.bulkUnstage', (p) => this.bulkUnstage(p)) this.dispatcher.onRequest('git.abortMerge', (p) => this.abortMerge(p)) this.dispatcher.onRequest('git.abortRebase', (p) => this.abortRebase(p)) this.dispatcher.onRequest('git.checkout', (p) => this.checkout(p)) this.dispatcher.onRequest('git.localBranches', (p) => this.localBranches(p)) this.dispatcher.onRequest('git.discard', (p) => this.discard(p)) this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p)) this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) this.dispatcher.onRequest('git.branchCompare', (p) => this.branchCompare(p)) this.dispatcher.onRequest('git.commitCompare', (p) => this.commitCompare(p)) this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p)) this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) this.dispatcher.onRequest('git.forkSync', (p, context) => this.forkSync(p, context)) this.dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p)) this.dispatcher.onRequest('git.fetchGitLabMergeRequestHead', (p) => this.fetchGitLabMergeRequestHead(p) ) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p, context) => this.branchDiff(p, context)) this.dispatcher.onRequest('git.commitDiff', (p, context) => this.commitDiff(p, context)) this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) this.dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => this.refreshLocalBaseRefForWorktreeCreate(p) ) this.dispatcher.onRequest('git.renameCurrentBranch', (p) => this.renameCurrentBranch(p)) this.dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => this.forceDeletePreservedBranch(p) ) this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context)) this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) this.dispatcher.onNotification('git.responseAck', (p, context) => this.responseAck(p, context)) this.dispatcher.onNotification('git.cancelResponseStream', (p, context) => this.cancelResponseStream(p, context) ) } private responseAck(params: Record, context: RequestContext): void { const streamId = params.streamId const seq = params.seq if (typeof streamId === 'number' && typeof seq === 'number') { this.responseStreams.recordAck(streamId, seq, context.clientId) } } private cancelResponseStream(params: Record, context: RequestContext): void { const streamId = params.streamId if (typeof streamId === 'number') { this.responseStreams.abort(streamId, context.clientId) } } // Why: when the client opted into response streaming and the serialized result // exceeds the threshold, chunk it onto the bulk lane and return a small // sentinel as the RPC result. Old clients omit the flag (single-frame, as // today); old relays never call this, so a new client falls back to the plain // result they return. private maybeStreamResponse( result: unknown, params: Record, context: RequestContext | undefined ): unknown { if (params.__streamResponse !== true || !context) { return result } const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { return result } return this.responseStreams.startStream(payload, this.dispatcher, context) } private async runWithDiffDedupeClear(run: () => Promise): Promise { // Why: git mutations can stale both existing and concurrently-started diff reads. // Clear before and after so later reads never join pre-mutation work. this.gitDiffReadDedupe.clear() try { return await run() } finally { this.gitDiffReadDedupe.clear() } } private async git( args: string[], cwd: string, opts?: { maxBuffer?: number disableOptionalLocks?: boolean signal?: AbortSignal nonInteractive?: boolean stdin?: string timeout?: number } ): Promise<{ stdout: string; stderr: string }> { const env = buildRelayGitEnv() if (opts?.disableOptionalLocks) { env.GIT_OPTIONAL_LOCKS = '0' } if (opts?.nonInteractive) { env.GIT_TERMINAL_PROMPT = '0' env.GIT_ASKPASS = '' env.SSH_ASKPASS = '' env.GIT_SSH_COMMAND ??= 'ssh -o BatchMode=yes' } const execOptions = { cwd: expandTilde(cwd), env, encoding: 'utf-8', maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER, timeout: opts?.timeout, signal: opts?.signal } satisfies ExecFileOptions if (opts?.stdin !== undefined) { return execFileWithStdin('git', args, execOptions, opts.stdin) } const { stdout, stderr } = await execFileAsync('git', args, execOptions) return { stdout: String(stdout), stderr: String(stderr) } } private async gitBuffer(args: string[], cwd: string): Promise { const { stdout } = (await execFileAsync('git', args, { cwd, env: buildRelayGitEnv(), encoding: 'buffer', maxBuffer: MAX_GIT_BUFFER })) as { stdout: Buffer } return stdout } private async getStatus(params: Record) { this.gitDiffReadDedupe.clear() return getStatusOp(this.git.bind(this), params) } // Why: the parent status only lists a single gitlink row per submodule. The // renderer fetches inner per-file changes on demand by running a plain status // inside the submodule's own worktree. Reject paths escaping the worktree to // match the diff handler's traversal guard. private async getSubmoduleStatus(params: Record) { const worktreePath = params.worktreePath as string const submodulePath = params.submodulePath as string const area = resolveSubmoduleStatusArea(params) const staged = area === 'staged' const resolved = resolveSubmoduleWorktreePath(worktreePath, submodulePath) const workingResult = await getStatusOp(this.git.bind(this), { ...params, worktreePath: resolved }) // Why: a moved gitlink (clean worktree) has no uncommitted rows; surface the // files changed between the recorded and checked-out commits so the expanded // submodule isn't empty. Mirrors getSubmoduleStatus in the local handler. const { fromOid, toOid } = await resolveSubmoduleCommitRange( this.git.bind(this), worktreePath, submodulePath, staged ) if (fromOid && toOid && fromOid !== toOid) { const rangeEntries = await computeSubmoduleRangeEntries( this.git.bind(this), resolved, fromOid, toOid ) if (staged) { return { ...workingResult, entries: rangeEntries } } const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) const entries = [ ...rangeEntries, ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) ] return { ...workingResult, entries } } if (staged) { return { ...workingResult, entries: [] } } return workingResult } private async checkIgnored(params: Record) { return checkIgnoredPathsOp(this.git.bind(this), params) } private async history(params: Record) { const worktreePath = params.worktreePath as string return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, { limit: typeof params.limit === 'number' ? params.limit : undefined, baseRef: typeof params.baseRef === 'string' ? params.baseRef : null }) } private async getDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string // Why: filePath is relative to worktreePath and used in readWorkingFile via // path.join. Without validation, ../../etc/passwd traverses outside the worktree. const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } const staged = params.staged as boolean const compareAgainstHead = params.compareAgainstHead as boolean | undefined // Why: register the in-flight dedupe synchronously (before any await) so // concurrent identical reads coalesce; submodule routing happens inside. const result = await this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => { // Why: gitlink paths can't be read as blobs and submodule working dirs // read as empty, so route the gitlink root → pointer diff and inner // files → recurse into the submodule's own worktree (mirrors local). const submodulePaths = await listSubmodulePathsCached( this.git.bind(this), worktreePath, this.submodulePathsCache ) if (submodulePaths.length > 0) { const matchedSubmodule = findContainingSubmodule(submodulePaths, filePath) if (matchedSubmodule) { const normalizedFilePath = filePath.replace(/\\/g, '/').replace(/\/+$/, '') if (normalizedFilePath === matchedSubmodule) { return computeSubmodulePointerDiff( this.git.bind(this), worktreePath, matchedSubmodule, staged, compareAgainstHead ) } const submoduleWorktreePath = resolveSubmoduleWorktreePath( worktreePath, matchedSubmodule ) const innerPath = normalizedFilePath.slice(matchedSubmodule.length + 1) const { fromOid, toOid } = await resolveSubmoduleCommitRange( this.git.bind(this), worktreePath, matchedSubmodule, staged ) // Why: a moved gitlink (clean worktree) keeps inner changes in // committed history, so diff the two commits; otherwise read the // working-tree blob. if (fromOid && toOid && fromOid !== toOid) { return buildSubmoduleInnerCommitRangeDiff( this.gitBuffer.bind(this), submoduleWorktreePath, innerPath, fromOid, toOid ) } return computeDiff( this.gitBuffer.bind(this), submoduleWorktreePath, innerPath, staged, compareAgainstHead ) } } return computeDiff( this.gitBuffer.bind(this), worktreePath, filePath, staged, compareAgainstHead ) } ) return this.maybeStreamResponse(result, params, context) } private async stage(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { await this.git(['add', '--', filePath], worktreePath) } finally { this.gitDiffReadDedupe.clear() } } private async commit( params: Record ): Promise<{ success: boolean; error?: string }> { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const message = params.message as string try { return await commitChangesRelay(this.git.bind(this), worktreePath, message) } finally { this.gitDiffReadDedupe.clear() } } private async unstage(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { await this.git(['restore', '--staged', '--', filePath], worktreePath) } finally { this.gitDiffReadDedupe.clear() } } private async bulkStage(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await this.git(['add', '--', ...chunk], worktreePath) } } finally { this.gitDiffReadDedupe.clear() } } private async bulkUnstage(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await this.git(['restore', '--staged', '--', ...chunk], worktreePath) } } finally { this.gitDiffReadDedupe.clear() } } private async abortMerge(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string try { await this.git(['merge', '--abort'], worktreePath) } finally { this.gitDiffReadDedupe.clear() } } private async abortRebase(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string try { await this.git(['rebase', '--abort'], worktreePath) } finally { this.gitDiffReadDedupe.clear() } } private async checkout(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const branch = params.branch as string // Defense-in-depth: reject option-like branch tokens (the RPC schema also // validates, but this relay entrypoint is reachable independently). The // `startsWith('-')` guard is what prevents flag injection; the trailing `--` // marks that no pathspecs follow so the token is treated as a branch ref. if (typeof branch !== 'string' || branch.length === 0 || branch.startsWith('-')) { throw new Error('invalid_branch_name') } try { await this.git(['checkout', branch, '--'], worktreePath) return { ok: true as const, branch } } finally { this.gitDiffReadDedupe.clear() } } private async localBranches(params: Record) { const worktreePath = params.worktreePath as string const { stdout } = await this.git( ['for-each-ref', '--format=%(HEAD)%09%(refname:short)', 'refs/heads/'], worktreePath ) let current: string | null = null const branches: string[] = [] for (const line of stdout.split('\n')) { if (line.length === 0) { continue } const [marker, name] = line.split('\t') if (!name) { continue } if (marker === '*') { current = name } branches.push(name) } branches.sort((a, b) => (a === current ? -1 : b === current ? 1 : 0)) return { current, branches } } private normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } private isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { const normalized = this.normalizeGitPathForCompare(filePath) return trackedPaths.some((trackedPath) => { const normalizedTracked = this.normalizeGitPathForCompare(trackedPath) return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`) }) } private assertInWorktree(worktreePath: string, filePath: string): string { const resolved = path.resolve(worktreePath, filePath) const rel = path.relative(path.resolve(worktreePath), resolved) // Why: empty rel or '.' means the path IS the worktree root — rm -rf would // delete the entire worktree. Reject along with parent-escaping paths. if ( !rel || rel === '.' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) ) { throw new Error(`Path "${filePath}" resolves outside the worktree`) } return resolved } private async discard(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePath = params.filePath as string try { this.assertInWorktree(worktreePath, filePath) let tracked = false try { await this.git( ['ls-files', '--error-unmatch', '--', this.literalPathspec(filePath)], worktreePath ) tracked = true } catch { // untracked } if (tracked) { await this.git( ['restore', '--worktree', '--source=HEAD', '--', this.literalPathspec(filePath)], worktreePath ) return } await removeSafeUntrackedDiscardTarget(worktreePath, filePath, (targetPath) => this.cleanUntrackedPaths(worktreePath, [targetPath]) ) } finally { this.gitDiffReadDedupe.clear() } } private async bulkDiscard(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const filePaths = params.filePaths as string[] if (filePaths.length === 0) { return } try { for (const filePath of filePaths) { this.assertInWorktree(worktreePath, filePath) } const trackedPathSpecs: string[] = [] for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await this.git( ['ls-files', '-z', '--', ...chunk.map((p) => this.literalPathspec(p))], worktreePath ) // Why: selecting a tracked directory can make `ls-files -z` return // enough descendants for push(...split) to exceed the argument limit. for (const trackedPathSpec of stdout.split('\0')) { if (trackedPathSpec) { trackedPathSpecs.push(trackedPathSpec) } } } const trackedPaths = filePaths.filter((filePath) => this.isTrackedPathSpec(filePath, trackedPathSpecs) ) const untrackedPaths = filePaths.filter( (filePath) => !this.isTrackedPathSpec(filePath, trackedPathSpecs) ) await removeSafeUntrackedDiscardTargets( worktreePath, untrackedPaths, (targetPaths) => this.cleanUntrackedPaths(worktreePath, targetPaths), async () => { for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await this.git( [ 'restore', '--worktree', '--source=HEAD', '--', ...chunk.map((p) => this.literalPathspec(p)) ], worktreePath ) } } ) } finally { this.gitDiffReadDedupe.clear() } } private literalPathspec(filePath: string): string { // Why: source-control selections are concrete paths, not user-authored Git globs. return `:(literal)${filePath}` } private async cleanUntrackedPaths(worktreePath: string, filePaths: readonly string[]) { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. await this.git( ['clean', '-ffdx', '--', ...chunk.map((p) => this.literalPathspec(p))], worktreePath ) } } } private async conflictOperation(params: Record) { const worktreePath = params.worktreePath as string return detectConflictOperation(worktreePath) } private async branchCompare(params: Record) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string // Why: a baseRef starting with '-' would be interpreted as a flag to // git rev-parse, potentially leaking environment variables or config. if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } const gitBound = this.git.bind(this) return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => { // Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8; // without it parseBranchDiff would yield C-style octal-escaped paths. const { stdout } = await gitBound( ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) const { stdout: numstat } = await gitBound( ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], worktreePath ) return parseBranchDiff(stdout, parseNumstat(numstat)) }) } private async commitCompare(params: Record) { const worktreePath = params.worktreePath as string const commitId = params.commitId as string return commitCompareOp(this.git.bind(this), worktreePath, commitId) } private async upstreamStatus(params: Record) { const worktreePath = params.worktreePath as string try { if (params.pushTarget !== undefined) { assertGitPushTargetShape(params.pushTarget) const pushTarget = params.pushTarget as GitPushTarget await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) return await getPublishTargetStatus( ((args) => this.git(args, worktreePath)) as GitCommandRunner, pushTarget, (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) ) } return await getEffectiveGitUpstreamStatus( (args) => this.git(args, worktreePath), (upstreamName) => this.getBehindCommitsArePatchEquivalent(worktreePath, upstreamName) ) } catch (error) { // Why: we only swallow the 'no upstream configured' error — that's an // expected state, not a failure. Other errors (auth, corruption, network) // should surface to the user so they can act on them. if (isNoUpstreamError(error)) { return { hasUpstream: false, ahead: 0, behind: 0 } } // Why: match fetch/push/pull normalization so execFile preamble and local // paths don't leak to the renderer. throw new Error(normalizeGitErrorMessage(error, 'upstream')) } } private async getBehindCommitsArePatchEquivalent( worktreePath: string, upstreamName: string ): Promise { try { const { stdout } = await this.git( ['log', '--oneline', '--cherry-mark', '--right-only', `HEAD...${upstreamName}`, '--'], worktreePath ) return upstreamOnlyCommitsArePatchEquivalent(stdout) } catch { // Why: this only identifies stale post-rebase upstreams. If the probe // fails over SSH, keep the conservative pull-first sync path. return false } } private async fetch(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string try { try { if (params.pushTarget !== undefined) { assertGitPushTargetShape(params.pushTarget) const pushTarget = params.pushTarget as GitPushTarget await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) await this.git(['fetch', '--prune', pushTarget.remoteName], worktreePath) return } await this.git(['fetch', '--prune'], worktreePath) } catch (error) { // Why: mirror the local gitFetch normalization so SSH users see the same // actionable messages instead of raw git stderr (which varies across // versions/locales and may embed credentials). throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { this.gitDiffReadDedupe.clear() } } private async forkSync(params: Record, context?: RequestContext) { return this.runWithDiffDedupeClear(async () => { const worktreePath = params.worktreePath as string const expectedUpstream = validateGitForkSyncExpectedUpstream(params.expectedUpstream, { required: true }) const controller = new AbortController() const abortFromContext = () => controller.abort() if (context?.signal?.aborted) { controller.abort() } else { context?.signal?.addEventListener('abort', abortFromContext, { once: true }) } const timeout = setTimeout(() => controller.abort(), 60_000) try { return await syncForkDefaultBranch( (args) => this.git(args, worktreePath, { nonInteractive: true, signal: controller.signal }), { expectedUpstream } ) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'push')) } finally { clearTimeout(timeout) context?.signal?.removeEventListener('abort', abortFromContext) } }) } private async fetchRemoteTrackingRef(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const remote = params.remote const branch = params.branch const ref = params.ref const skipAutoMaintenance = params.skipAutoMaintenance try { if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') { throw new Error('Invalid remote-tracking fetch request.') } if (skipAutoMaintenance !== undefined && typeof skipAutoMaintenance !== 'boolean') { throw new Error('Invalid remote-tracking fetch maintenance option.') } if (remote.startsWith('-') || branch.startsWith('-')) { throw new Error('Remote-tracking fetch inputs must not start with "-".') } if (ref !== `refs/remotes/${remote}/${branch}`) { throw new Error('Remote-tracking ref does not match the requested remote and branch.') } try { const { stdout } = await this.git(['remote'], worktreePath) const remotes = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) if (!remotes.includes(remote)) { throw new Error(`Remote "${remote}" is not configured.`) } await this.git(['check-ref-format', `refs/heads/${branch}`], worktreePath) await this.git(['check-ref-format', ref], worktreePath) await this.git( [ ...(skipAutoMaintenance ? GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS : []), 'fetch', '--no-tags', remote, `+refs/heads/${branch}:${ref}` ], worktreePath ) } catch (error) { // Why: create-worktree needs a write-capable fetch, but generic git.exec // intentionally rejects fetch. This narrow RPC keeps the relay allowlist // tight while preserving the same safe error normalization as git.fetch. throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { this.gitDiffReadDedupe.clear() } } private async fetchGitLabMergeRequestHead(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const remote = params.remote const mrIid = params.mrIid try { if (typeof remote !== 'string') { throw new Error('Invalid GitLab merge request fetch request.') } if (typeof mrIid !== 'number' || !Number.isSafeInteger(mrIid) || mrIid <= 0) { throw new Error('Invalid GitLab merge request fetch request.') } const mergeRequestIid = mrIid if (remote.startsWith('-')) { throw new Error('GitLab merge request fetch remote must not start with "-".') } try { const { stdout } = await this.git(['remote'], worktreePath) const remotes = stdout .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) if (!remotes.includes(remote)) { throw new Error(`Remote "${remote}" is not configured.`) } // Why: GitLab MR heads are not refs/heads/*, so the remote-tracking // fetch RPC cannot represent fork MRs. Keep this write path MR-only. await this.git( ['fetch', '--no-tags', remote, `refs/merge-requests/${mergeRequestIid}/head`], worktreePath ) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'fetch')) } } finally { this.gitDiffReadDedupe.clear() } } private async push(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string // Why: mirror src/main/git/remote.ts. Push to a configured upstream when // present so SSH worktrees with non-origin targets do not get repointed. void params.publish try { try { const target = await resolveRelayPushTarget( this.git.bind(this), worktreePath, params.pushTarget ) const args = [ 'push', ...(params.forceWithLease === true ? ['--force-with-lease'] : []), '--set-upstream', ...(target ? [target.remote, target.refspec] : ['origin', 'HEAD']) ] await this.git(args, worktreePath) } catch (error) { // Why: mirror the local gitPush normalization so SSH users see the same // "non-fast-forward / pull first" guidance instead of raw git stderr. throw new Error(normalizeGitErrorMessage(error, 'push')) } } finally { this.gitDiffReadDedupe.clear() } } private async pullWithArgs(params: Record, pullArgs: string[]) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string try { try { if (params.pushTarget !== undefined) { assertGitPushTargetShape(params.pushTarget) const pushTarget = params.pushTarget as GitPushTarget await this.git(['check-ref-format', '--branch', pushTarget.branchName], worktreePath) await this.git( ['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName], worktreePath ) return } const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) if (upstream && !upstream.isConfiguredUpstream) { // Why: legacy Orca branches may still track origin/main while pushes // target origin/. Pull the same effective branch the UI reports. await this.git( ['pull', ...pullArgs, upstream.remoteName, upstream.branchName], worktreePath ) return } await this.git(['pull', ...pullArgs], worktreePath) } catch (error) { // Why: mirror the local gitPull normalization so SSH users see the same // actionable messages instead of raw git stderr. throw new Error(normalizeGitErrorMessage(error, 'pull')) } } finally { this.gitDiffReadDedupe.clear() } } private async pull(params: Record) { // Why: plain `git pull` honors the user's configured merge/rebase/ff policy. // If no policy exists, Git's policy error is normalized with setup guidance. await this.pullWithArgs(params, []) } private async fastForward(params: Record) { await this.pullWithArgs(params, ['--ff-only']) } private async rebaseFromBase(params: Record) { this.gitDiffReadDedupe.clear() const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string try { try { const source = await resolveGitRemoteRebaseSource( ((args) => this.git(args, worktreePath)) as GitCommandRunner, baseRef ) await this.git(['pull', '--rebase', source.remoteName, source.branchName], worktreePath) } catch (error) { throw new Error(normalizeGitErrorMessage(error, 'pull')) } } finally { this.gitDiffReadDedupe.clear() } } private async branchDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string if (baseRef.startsWith('-')) { throw new Error('Base ref must not start with "-"') } const options = { includePatch: params.includePatch as boolean | undefined, filePath: params.filePath as string | undefined, oldPath: params.oldPath as string | undefined } const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', worktreePath, baseRef, options.includePatch ?? null, options.filePath ?? null, options.oldPath ?? null ]), () => branchDiffEntries( this.git.bind(this), this.gitBuffer.bind(this), worktreePath, baseRef, options ) ) return this.maybeStreamResponse(result, params, context) } private async commitDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const args = { commitOid: params.commitOid as string, parentOid: params.parentOid as string | null | undefined, filePath: params.filePath as string, oldPath: params.oldPath as string | undefined } const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'commitDiff', worktreePath, args.commitOid, args.parentOid ?? null, args.filePath, args.oldPath ?? null ]), () => commitDiffEntry(this.gitBuffer.bind(this), worktreePath, args) ) return this.maybeStreamResponse(result, params, context) } private async exec(params: Record, context?: RequestContext) { const args = params.args as string[] const cwd = params.cwd as string validateGitExecArgs(args) const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) return this.maybeStreamResponse({ stdout, stderr }, params, context) } private async clone(params: Record, context?: RequestContext) { const args = params.args as string[] const cwd = params.cwd as string const progressId = params.progressId validateGitExecArgs(args) if (typeof progressId !== 'string' || progressId.length === 0) { throw new Error('Missing clone progress id.') } if (args[0] !== 'clone') { throw new Error('git.clone only supports clone commands.') } return await this.spawnClone(args, cwd, progressId, context) } private async spawnClone( args: string[], cwd: string, progressId: string, context?: RequestContext ): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { const child = spawn('git', args, { cwd: expandTilde(cwd), env: buildRelayGitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' let settled = false const cleanup = (): void => { context?.signal?.removeEventListener('abort', onAbort) } const onAbort = (): void => { child.kill() } context?.signal?.addEventListener('abort', onAbort, { once: true }) child.stdout?.on('data', (chunk: Buffer) => { stdout = (stdout + chunk.toString('utf-8')).slice(-4096) }) child.stderr?.on('data', (chunk: Buffer) => { const text = chunk.toString('utf-8') stderr = (stderr + text).slice(-4096) for (const line of text.split(/[\r\n]+/)) { const match = line.match(/^([\w\s]+):\s+(\d+)%/) if (match) { this.dispatcher.notify('git.cloneProgress', { progressId, phase: match[1].trim(), percent: Number.parseInt(match[2], 10) }) } } }) child.on('error', (error) => { if (settled) { return } settled = true cleanup() reject(error) }) child.on('close', (code, signal) => { if (settled) { return } settled = true cleanup() if (context?.signal?.aborted) { reject(new Error('Clone aborted')) return } if (code === 0 && !signal) { resolve({ stdout, stderr }) return } reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderr)}`)) }) }) } private async renameCurrentBranch(params: Record) { return this.runWithDiffDedupeClear(async () => { const worktreePath = params.worktreePath const newBranch = params.newBranch if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') { throw new Error('Invalid branch rename request.') } if (newBranch.startsWith('-')) { throw new Error('Branch name must not start with "-".') } try { // Why: generic git.exec intentionally blocks destructive branch flags. // This narrow RPC permits only the already-checked current-branch rename. await this.git(['check-ref-format', '--branch', newBranch], worktreePath) await this.git(['branch', '-m', newBranch], worktreePath) } catch (error) { throw new Error(normalizeGitErrorMessage(error)) } }) } private async forceDeletePreservedBranch(params: Record) { const repoPath = params.repoPath const branchName = params.branchName const expectedHead = params.expectedHead if ( typeof repoPath !== 'string' || typeof branchName !== 'string' || typeof expectedHead !== 'string' ) { throw new Error('Invalid preserved branch force-delete request.') } // Why: an empty repoPath would resolve `cwd` to the relay's own process // directory, running the destructive update-ref against the wrong repo. NUL // bytes cannot reach git safely either; reject both at the boundary. if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) { throw new Error('Invalid preserved branch force-delete request.') } return this.runWithDiffDedupeClear(() => forceDeletePreservedRelayBranch(this.git.bind(this), repoPath, branchName, expectedHead) ) } private async isGitRepo(params: Record) { const dirPath = params.dirPath as string try { const { stdout } = await this.git(['rev-parse', '--show-toplevel'], dirPath) return { isRepo: true, rootPath: stdout.trim() } } catch { return { isRepo: false, rootPath: null } } } private async readRepoLocation(repoPath: string): Promise { try { return await this.gitCapabilities.runWithFallback( 'rev-parse-path-format', async () => { const { stdout } = await this.git( ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], repoPath ) if (hasUnsupportedRevParsePathFormatEcho(stdout)) { // Why: some old Git versions echo the unknown option and exit zero; // remember that signal even though the trailing paths remain usable. this.gitCapabilities.rememberUnsupported('rev-parse-path-format') } return parseRelayRepoLocation(repoPath, stdout) }, async () => { const { stdout } = await this.git( ['rev-parse', '--show-toplevel', '--git-common-dir'], repoPath ) return parseRelayRepoLocation(repoPath, stdout) }, isUnsupportedRevParsePathFormatError ) } catch { return undefined } } private async normalizeMainWorktreePath( repoPath: string, worktrees: Record[] ): Promise[]> { const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true) const mainWorktree = worktrees[mainIndex] const mainPath = typeof mainWorktree?.path === 'string' ? mainWorktree.path : '' // Expand `~` so the early-return matches git's absolute porcelain path for // legacy SSH repos stored with a tilde, sparing them a rev-parse per poll. const resolvedRepoPath = expandTilde(repoPath) if (!mainPath || areRelayWorktreePathsEqual(mainPath, resolvedRepoPath)) { return worktrees } const location = await this.readRepoLocation(resolvedRepoPath) if (!location) { return worktrees } // Why: only a separate-git-dir/submodule main worktree reports the Git // directory as the main entry — i.e. the main entry equals git-common-dir. // A linked worktree's main entry is a real working root, so gating on this // equality avoids overwriting it with the linked worktree's own toplevel. if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) { return worktrees } const normalized = [...worktrees] normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } return normalized } private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string return this.gitCapabilities .runWithFallback( 'worktree-list-z', async () => { const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { signal: context?.signal }) return this.normalizeMainWorktreePath( repoPath, parseWorktreeList(stdout, { nulDelimited: true }) ) }, async () => { // Why: `-z` keeps newline-containing SSH worktree paths intact, but // Git <2.36 requires the line-block parser. try { const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { signal: context?.signal }) return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] } }, isUnsupportedWorktreeListZError ) .catch(() => []) } private async addWorktree(params: Record) { return this.runWithDiffDedupeClear(() => addWorktreeOp(this.git.bind(this), params)) } private async removeWorktree(params: Record) { return this.runWithDiffDedupeClear(() => removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) ) } private async worktreeIsClean(params: Record) { return worktreeIsCleanOp(this.git.bind(this), params) } private async refreshLocalBaseRefForWorktreeCreate(params: Record) { return this.runWithDiffDedupeClear(() => refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) ) } }