From 91a35b9257904df39935c29dbabfcab9d9fc224f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:19:09 -0700 Subject: [PATCH] Split relay Git handler responsibilities (#17217) * Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161 --- config/max-lines-baseline.txt | 1 - src/main/global-fetch-call-site-audit.test.ts | 2 +- src/relay/git-handler-command-termination.ts | 53 + .../git-handler-comparison-operations.ts | 87 + src/relay/git-handler-discard-operations.ts | 149 ++ src/relay/git-handler-exec-operations.ts | 76 + src/relay/git-handler-fetch-operations.ts | 217 +++ .../git-handler-object-diff-operations.ts | 85 + src/relay/git-handler-operation-context.ts | 108 ++ src/relay/git-handler-operation-set.ts | 36 + src/relay/git-handler-read-operations.ts | 176 ++ src/relay/git-handler-registration.ts | 79 + src/relay/git-handler-sync-operations.ts | 205 +++ .../git-handler-worktree-change-operations.ts | 134 ++ src/relay/git-handler-worktree-operations.ts | 179 ++ src/relay/git-handler.ts | 1449 +---------------- 16 files changed, 1626 insertions(+), 1410 deletions(-) create mode 100644 src/relay/git-handler-command-termination.ts create mode 100644 src/relay/git-handler-comparison-operations.ts create mode 100644 src/relay/git-handler-discard-operations.ts create mode 100644 src/relay/git-handler-exec-operations.ts create mode 100644 src/relay/git-handler-fetch-operations.ts create mode 100644 src/relay/git-handler-object-diff-operations.ts create mode 100644 src/relay/git-handler-operation-context.ts create mode 100644 src/relay/git-handler-operation-set.ts create mode 100644 src/relay/git-handler-read-operations.ts create mode 100644 src/relay/git-handler-registration.ts create mode 100644 src/relay/git-handler-sync-operations.ts create mode 100644 src/relay/git-handler-worktree-change-operations.ts create mode 100644 src/relay/git-handler-worktree-operations.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 60cf5a61f8c..43809ae8dd7 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -29,7 +29,6 @@ inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts inline src/main/updater.ts inline src/preload/index.ts -inline src/relay/git-handler.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/LinearItemDrawer.tsx inline src/renderer/src/components/TaskPage.tsx diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 8902b867554..e4c0539fbfb 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -42,7 +42,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/pi/agent-status-extension-source.ts', 1], // local identifiers named `fetch` (git fetch), not HTTP ['main/ipc/worktree-remote.ts', 2], - ['relay/git-handler.ts', 1], + ['relay/git-handler-fetch-operations.ts', 1], // fetch mentioned only in a comment ['main/ipc/feedback.ts', 1] ]) diff --git a/src/relay/git-handler-command-termination.ts b/src/relay/git-handler-command-termination.ts new file mode 100644 index 00000000000..0cb6bc08995 --- /dev/null +++ b/src/relay/git-handler-command-termination.ts @@ -0,0 +1,53 @@ +import { runProcess } from '../shared/child-process/run-process' + +export const MAX_GIT_BUFFER = 10 * 1024 * 1024 +const GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS = 2_147_000_000 + +type GitTerminationOptions = { + cwd?: string + env?: NodeJS.ProcessEnv + timeout?: number + maxBuffer?: number + signal?: AbortSignal +} + +export async function runGitToTermination( + args: string[], + options: GitTerminationOptions, + stdin: string | undefined +): Promise<{ stdout: string; stderr: string }> { + const result = await runProcess({ + program: 'git', + args, + cwd: typeof options.cwd === 'string' ? options.cwd : undefined, + env: options.env, + timeoutMs: + typeof options.timeout === 'number' + ? options.timeout + : GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS, + maxOutputBytes: typeof options.maxBuffer === 'number' ? options.maxBuffer : MAX_GIT_BUFFER, + signal: options.signal, + terminationBarrier: true, + ...(stdin === undefined ? {} : { input: stdin }) + }) + if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { + return { stdout: result.stdout, stderr: result.stderr } + } + const error = new Error( + result.timedOut + ? `git ${args[0] ?? 'command'} timed out.` + : options.signal?.aborted + ? 'The operation was aborted.' + : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` + ) + if (options.signal?.aborted) { + error.name = 'AbortError' + } + throw Object.assign(error, { + code: result.code, + killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr + }) +} diff --git a/src/relay/git-handler-comparison-operations.ts b/src/relay/git-handler-comparison-operations.ts new file mode 100644 index 00000000000..e5ebe65365b --- /dev/null +++ b/src/relay/git-handler-comparison-operations.ts @@ -0,0 +1,87 @@ +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { branchCompare as branchCompareOp } from './git-handler-ops' +import { commitCompare as commitCompareOp } from './git-handler-commit-diff-ops' +import { parseBranchDiff } from './git-handler-utils' +import { parseNumstat } from '../shared/git-uncommitted-line-stats' +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 type { GitPushTarget } from '../shared/worktree/types' +import { getEffectiveGitUpstreamStatus } from '../shared/git-effective-upstream' + +export class GitHandlerComparisonOperations extends GitHandlerOperationContext { + async branchCompare(params: Record) { + const worktreePath = params.worktreePath as string + const baseRef = params.baseRef as string + // Why: reject flag-like base refs to prevent rev-parse option injection. + 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: preserve non-ASCII filenames as UTF-8 for parseBranchDiff. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitBound( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], + worktreePath + ), + gitBound( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + worktreePath + ) + ]) + return parseBranchDiff(stdout, parseNumstat(numstat)) + }) + } + + async commitCompare(params: Record) { + const worktreePath = params.worktreePath as string + const commitId = params.commitId as string + return commitCompareOp(this.git.bind(this), worktreePath, commitId) + } + + 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: suppress only the expected no-upstream error; surface all others. + 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 + } + } +} diff --git a/src/relay/git-handler-discard-operations.ts b/src/relay/git-handler-discard-operations.ts new file mode 100644 index 00000000000..c87d13c74ff --- /dev/null +++ b/src/relay/git-handler-discard-operations.ts @@ -0,0 +1,149 @@ +import * as path from 'node:path' +import { GitHandlerOperationContext, GIT_BULK_CHUNK_SIZE } from './git-handler-operation-context' +import { + removeSafeUntrackedDiscardTarget, + removeSafeUntrackedDiscardTargets +} from '../shared/git-discard-path-safety' +import { detectConflictOperation } from './git-handler-status-ops' + +const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE + +export class GitHandlerDiscardOperations extends GitHandlerOperationContext { + 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; reject (with parent-escaping paths) so a discard can't wipe the whole worktree. + if ( + !rel || + rel === '.' || + rel === '..' || + rel.startsWith(`..${path.sep}`) || + path.isAbsolute(rel) + ) { + throw new Error(`Path "${filePath}" resolves outside the worktree`) + } + return resolved + } + + async discard(params: Record) { + this.clearGitMutationReadCaches() + 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.clearGitMutationReadCaches() + } + } + + async bulkDiscard(params: Record) { + this.clearGitMutationReadCaches() + 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: a selected 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.clearGitMutationReadCaches() + } + } + + 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 + ) + } + } + } + + async conflictOperation(params: Record) { + const worktreePath = params.worktreePath as string + return detectConflictOperation(worktreePath) + } +} diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts new file mode 100644 index 00000000000..510b7d0b9ee --- /dev/null +++ b/src/relay/git-handler-exec-operations.ts @@ -0,0 +1,76 @@ +import type { RequestContext } from './dispatcher' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { validateGitExecArgs } from './git-handler-ops' +import { gitExecMutatesRepository } from '../shared/git-exec-mutation' +import { normalizeGitErrorMessage } from '../shared/git-remote-error' +import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' + +export class GitHandlerExecOperations extends GitHandlerOperationContext { + async exec(params: Record, context?: RequestContext) { + const args = params.args as string[] + const cwd = params.cwd as string + + validateGitExecArgs(args) + const run = () => this.git(args, cwd, { signal: context?.signal }) + const { stdout, stderr } = gitExecMutatesRepository(args) + ? await this.runWithGitReadCacheClear(run) + : await run() + return this.maybeStreamResponse({ stdout, stderr }, params, context) + } + + 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.runWithGitReadCacheClear(() => + this.spawnClone(args, cwd, progressId, context) + ) + } + + async renameCurrentBranch(params: Record) { + return this.runWithGitReadCacheClear(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 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)) + } + }) + } + + 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: empty repoPath would target the relay's own cwd with a destructive update-ref, and NUL bytes can't reach git safely — reject both. + if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) { + throw new Error('Invalid preserved branch force-delete request.') + } + return this.runWithGitReadCacheClear(() => + forceDeletePreservedRelayBranch(this.git.bind(this), repoPath, branchName, expectedHead) + ) + } +} diff --git a/src/relay/git-handler-fetch-operations.ts b/src/relay/git-handler-fetch-operations.ts new file mode 100644 index 00000000000..cd3a86fcb63 --- /dev/null +++ b/src/relay/git-handler-fetch-operations.ts @@ -0,0 +1,217 @@ +import type { RequestContext } from './dispatcher' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { assertGitPushTargetShape } from '../shared/git-push-target-validation' +import type { GitPushTarget } from '../shared/worktree/types' +import { normalizeGitErrorMessage, isExecKilledError } from '../shared/git-remote-error' +import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' +import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' +import { + githubPullRequestHeadLocalRef, + gitlabMergeRequestHeadLocalRef, + isSafeReviewHeadFetchRemote, + isValidReviewHeadNumber, + reviewHeadRemoteRefComponent, + REVIEW_HEAD_FETCH_TIMEOUT_MS +} from '../shared/review-head-tracking-ref' + +export class GitHandlerFetchOperations extends GitHandlerOperationContext { + async fetch(params: Record) { + this.clearGitMutationReadCaches() + 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: normalize like local gitFetch so SSH users get actionable messages, not raw stderr (may embed credentials). + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + async forkSync(params: Record, context?: RequestContext) { + return this.runWithGitReadCacheClear(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) + } + }) + } + + async fetchRemoteTrackingRef(params: Record) { + this.clearGitMutationReadCaches() + 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 that generic git.exec rejects; narrow RPC keeps the allowlist tight. + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + // Why: the durable review-head ref embeds the remote's identity, and a + // missing remote must fail with an actionable message, not a raw fetch error. + private async reviewHeadRemoteComponent(worktreePath: string, remote: string): Promise { + let remoteUrl: string + try { + const { stdout } = await this.git(['remote', 'get-url', remote], worktreePath) + remoteUrl = stdout.trim() + } catch { + remoteUrl = '' + } + if (!remoteUrl) { + throw new Error(`Remote "${remote}" is not configured.`) + } + return reviewHeadRemoteRefComponent(remote, remoteUrl) + } + + async fetchGitLabMergeRequestHead(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const remote = params.remote + const mrIid = params.mrIid + try { + if (typeof remote !== 'string' || !isValidReviewHeadNumber(mrIid)) { + throw new Error('Invalid GitLab merge request fetch request.') + } + const mergeRequestIid = mrIid + if (!isSafeReviewHeadFetchRemote(remote)) { + throw new Error('GitLab merge request fetch remote must not start with "-".') + } + + try { + const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) + // Why: GitLab fork heads need a dedicated write RPC and ref outside refs/heads/*. + // Return the exact written path so the client does not re-hash a second get-url. + const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mergeRequestIid) + await this.git( + [ + 'fetch', + '--no-tags', + remote, + `+refs/merge-requests/${mergeRequestIid}/head:${localRef}` + ], + worktreePath, + { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return { localRef } + } catch (error) { + // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. + if (isExecKilledError(error)) { + throw new Error( + `Fetching refs/merge-requests/${mergeRequestIid}/head from "${remote}" timed out.` + ) + } + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + async fetchGitHubPullRequestHead(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const remote = params.remote + const prNumber = params.prNumber + try { + if (typeof remote !== 'string' || !isValidReviewHeadNumber(prNumber)) { + throw new Error('Invalid GitHub pull request fetch request.') + } + if (!isSafeReviewHeadFetchRemote(remote)) { + throw new Error('GitHub pull request fetch remote must not start with "-".') + } + + try { + const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) + // Why: return the written path so resolve can rev-parse the same ref the host wrote. + const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) + await this.git( + ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], + worktreePath, + { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } + ) + return { localRef } + } catch (error) { + // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. + if (isExecKilledError(error)) { + throw new Error(`Fetching refs/pull/${prNumber}/head from "${remote}" timed out.`) + } + throw new Error(normalizeGitErrorMessage(error, 'fetch')) + } + } finally { + this.clearGitMutationReadCaches() + } + } +} diff --git a/src/relay/git-handler-object-diff-operations.ts b/src/relay/git-handler-object-diff-operations.ts new file mode 100644 index 00000000000..819cf5e0f4a --- /dev/null +++ b/src/relay/git-handler-object-diff-operations.ts @@ -0,0 +1,85 @@ +import type { RequestContext } from './dispatcher' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { branchDiffEntries } from './git-handler-ops' +import { + branchDiffEntryAtPinnedOids, + isFullGitObjectId, + parseOptionalBranchDiffHeadOid +} from './git-handler-branch-diff-ops' +import { commitDiffEntry } from './git-handler-commit-diff-ops' +import { stableInFlightKey } from '../shared/in-flight-promise-dedupe' + +export class GitHandlerObjectDiffOperations extends GitHandlerOperationContext { + 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 headOid = parseOptionalBranchDiffHeadOid(params) + 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, + headOid ?? null, + options.includePatch ?? null, + options.filePath ?? null, + options.oldPath ?? null + ]), + () => { + if ( + headOid && + isFullGitObjectId(baseRef) && + options.includePatch === true && + typeof options.filePath === 'string' && + options.filePath.length > 0 + ) { + return branchDiffEntryAtPinnedOids( + this.gitBuffer.bind(this), + worktreePath, + baseRef, + headOid, + options.filePath, + options.oldPath + ) + } + return branchDiffEntries( + this.git.bind(this), + this.gitBuffer.bind(this), + worktreePath, + baseRef, + options + ) + } + ) + return this.maybeStreamResponse(result, params, context) + } + + 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) + } +} diff --git a/src/relay/git-handler-operation-context.ts b/src/relay/git-handler-operation-context.ts new file mode 100644 index 00000000000..85d2d908097 --- /dev/null +++ b/src/relay/git-handler-operation-context.ts @@ -0,0 +1,108 @@ +import type { RequestContext } from './dispatcher' +import type { InFlightPromiseDedupe } from '../shared/in-flight-promise-dedupe' +import type { GitCapabilityCache } from '../shared/git-capability-cache' +import type { SubmodulePathsCache } from './git-handler-submodule-ops' +import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' + +export const GIT_BULK_CHUNK_SIZE = 100 + +export type GitHandlerCommandOptions = { + maxBuffer?: number + disableOptionalLocks?: boolean + signal?: AbortSignal + nonInteractive?: boolean + stdin?: string + timeout?: number + terminationBarrier?: boolean +} + +export type GitHandlerCommandResult = { stdout: string; stderr: string } +export type GitHandlerWatcherRegistry = Pick + +export type GitHandlerOperationHost = { + readonly gitDiffReadDedupe: InFlightPromiseDedupe + readonly gitCapabilities: GitCapabilityCache + readonly submodulePathsCache: SubmodulePathsCache + readonly watcherRegistry: GitHandlerWatcherRegistry | undefined + git( + args: string[], + cwd: string, + opts?: GitHandlerCommandOptions + ): Promise + gitBuffer(args: string[], cwd: string): Promise + spawnClone( + args: string[], + cwd: string, + progressId: string, + context?: RequestContext + ): Promise + clearGitMutationReadCaches(): void + runWithGitReadCacheClear(run: () => Promise): Promise + maybeStreamResponse( + result: unknown, + params: Record, + context: RequestContext | undefined + ): unknown +} + +export abstract class GitHandlerOperationContext { + constructor(private readonly host: GitHandlerOperationHost) {} + + protected get gitDiffReadDedupe(): InFlightPromiseDedupe { + return this.host.gitDiffReadDedupe + } + + protected get gitCapabilities(): GitCapabilityCache { + return this.host.gitCapabilities + } + + protected get submodulePathsCache(): SubmodulePathsCache { + return this.host.submodulePathsCache + } + + protected get watcherRegistry(): GitHandlerWatcherRegistry | undefined { + return this.host.watcherRegistry + } + + protected git( + args: string[], + cwd: string, + opts?: GitHandlerCommandOptions + ): Promise { + return this.host.git(args, cwd, opts) + } + + protected gitBuffer(args: string[], cwd: string): Promise { + return this.host.gitBuffer(args, cwd) + } + + protected spawnClone( + args: string[], + cwd: string, + progressId: string, + context?: RequestContext + ): Promise { + return this.host.spawnClone(args, cwd, progressId, context) + } + + protected clearGitMutationReadCaches(): void { + this.host.clearGitMutationReadCaches() + } + + protected runWithGitReadCacheClear(run: () => Promise): Promise { + return this.host.runWithGitReadCacheClear(run) + } + + protected maybeStreamResponse( + result: unknown, + params: Record, + context: RequestContext | undefined + ): unknown { + return this.host.maybeStreamResponse(result, params, context) + } + + protected literalPathspec(filePath: string): string { + // Why: source-control selections are concrete paths, not user-authored Git globs. + return `:(literal)${filePath}` + } +} diff --git a/src/relay/git-handler-operation-set.ts b/src/relay/git-handler-operation-set.ts new file mode 100644 index 00000000000..fda295edb7e --- /dev/null +++ b/src/relay/git-handler-operation-set.ts @@ -0,0 +1,36 @@ +import type { GitHandlerOperationHost } from './git-handler-operation-context' +import { GitHandlerReadOperations } from './git-handler-read-operations' +import { GitHandlerWorktreeChangeOperations } from './git-handler-worktree-change-operations' +import { GitHandlerDiscardOperations } from './git-handler-discard-operations' +import { GitHandlerComparisonOperations } from './git-handler-comparison-operations' +import { GitHandlerFetchOperations } from './git-handler-fetch-operations' +import { GitHandlerSyncOperations } from './git-handler-sync-operations' +import { GitHandlerObjectDiffOperations } from './git-handler-object-diff-operations' +import { GitHandlerExecOperations } from './git-handler-exec-operations' +import { GitHandlerWorktreeOperations } from './git-handler-worktree-operations' + +export function createGitHandlerOperationSet(host: GitHandlerOperationHost) { + const read = new GitHandlerReadOperations(host) + const changes = new GitHandlerWorktreeChangeOperations(host) + const discard = new GitHandlerDiscardOperations(host) + const comparison = new GitHandlerComparisonOperations(host) + const fetch = new GitHandlerFetchOperations(host) + const sync = new GitHandlerSyncOperations(host) + const objectDiff = new GitHandlerObjectDiffOperations(host) + const exec = new GitHandlerExecOperations(host) + const worktree = new GitHandlerWorktreeOperations(host) + + return { + read, + changes, + discard, + comparison, + fetch, + sync, + objectDiff, + exec, + worktree + } +} + +export type GitHandlerOperationSet = ReturnType diff --git a/src/relay/git-handler-read-operations.ts b/src/relay/git-handler-read-operations.ts new file mode 100644 index 00000000000..5976573b5a3 --- /dev/null +++ b/src/relay/git-handler-read-operations.ts @@ -0,0 +1,176 @@ +import * as path from 'node:path' +import type { RequestContext } from './dispatcher' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { getStatusOp } from './git-handler-status-ops' +import { streamRelayGitStdout } from './git-stdout-stream' +import { capGitStatusEntries, resolveGitStatusLimit } from '../shared/git-status-limit' +import { + buildSubmoduleInnerCommitRangeDiff, + computeSubmodulePointerDiff, + computeSubmoduleRangeEntries, + findContainingSubmodule, + listSubmodulePathsCached, + resolveSubmoduleWorktreePath, + resolveSubmoduleCommitRange +} from './git-handler-submodule-ops' +import { computeDiff, type GitExec } from './git-handler-ops' +import { checkIgnoredPathsOp } from './git-handler-check-ignore' +import { loadGitHistoryFromExecutor } from '../shared/git-history' +import { stableInFlightKey } from '../shared/in-flight-promise-dedupe' + +function resolveSubmoduleStatusArea( + params: Record +): 'staged' | 'unstaged' | 'untracked' { + if (params.area === 'staged' || params.area === 'unstaged' || params.area === 'untracked') { + return params.area + } + return 'unstaged' +} + +export class GitHandlerReadOperations extends GitHandlerOperationContext { + async getStatus(params: Record, context: RequestContext) { + this.gitDiffReadDedupe.clear() + return getStatusOp(this.git.bind(this), streamRelayGitStdout, params, { + signal: context.signal + }) + } + + // Why: fetch per-file submodule changes from the submodule worktree. + async getSubmoduleStatus(params: Record, context: RequestContext) { + 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 limit = resolveGitStatusLimit(params.limit) + // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. + const workingResult = staged + ? { entries: [], conflictOperation: 'unknown' } + : await getStatusOp( + this.git.bind(this), + streamRelayGitStdout, + { + ...params, + worktreePath: resolved + }, + { signal: context.signal } + ) + // Why: pointer/range probes are part of the same SSH request and must not outlive its cancellation. + const requestGit: GitExec = (args, cwd, options) => + this.git(args, cwd, { ...options, signal: context.signal }) + // Why: moved clean gitlinks need committed changes surfaced. + const { fromOid, toOid } = await resolveSubmoduleCommitRange( + requestGit, + worktreePath, + submodulePath, + staged + ) + if (fromOid && toOid && fromOid !== toOid) { + const rangeEntries = await computeSubmoduleRangeEntries(requestGit, resolved, fromOid, toOid) + if (staged) { + return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } + } + const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) + const entries = [ + ...rangeEntries, + ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) + ] + return { + ...workingResult, + ...capGitStatusEntries(entries, limit, workingResult) + } + } + if (staged) { + return { ...workingResult, entries: [] } + } + return workingResult + } + + async checkIgnored(params: Record) { + return checkIgnoredPathsOp(this.git.bind(this), params) + } + + 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 + }) + } + + async getDiff(params: Record, context?: RequestContext) { + const worktreePath = params.worktreePath as string + const filePath = params.filePath as string + // Why: validate relative paths to prevent traversal 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 dedupe before awaiting so identical reads coalesce. + const result = await this.gitDiffReadDedupe.run( + stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), + async () => { + // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. + 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) + } +} diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts new file mode 100644 index 00000000000..a9f11445dd1 --- /dev/null +++ b/src/relay/git-handler-registration.ts @@ -0,0 +1,79 @@ +import type { RelayDispatcher, RequestContext } from './dispatcher' +import type { GitHandlerOperationSet } from './git-handler-operation-set' + +export function registerGitHandlers( + dispatcher: RelayDispatcher, + handlers: GitHandlerOperationSet, + responseAck: (params: Record, context: RequestContext) => void, + cancelResponseStream: (params: Record, context: RequestContext) => void +): void { + dispatcher.onRequest('git.status', (p, context) => handlers.read.getStatus(p, context)) + dispatcher.onRequest('git.submoduleStatus', (p, context) => + handlers.read.getSubmoduleStatus(p, context) + ) + dispatcher.onRequest('git.checkIgnored', (p) => handlers.read.checkIgnored(p)) + dispatcher.onRequest('git.history', (p) => handlers.read.history(p)) + dispatcher.onRequest('git.commit', (p) => handlers.changes.commit(p)) + dispatcher.onRequest('git.diff', (p, context) => handlers.read.getDiff(p, context)) + dispatcher.onRequest('git.stage', (p) => handlers.changes.stage(p)) + dispatcher.onRequest('git.unstage', (p) => handlers.changes.unstage(p)) + dispatcher.onRequest('git.bulkStage', (p) => handlers.changes.bulkStage(p)) + dispatcher.onRequest('git.bulkUnstage', (p) => handlers.changes.bulkUnstage(p)) + dispatcher.onRequest('git.abortMerge', (p) => handlers.changes.abortMerge(p)) + dispatcher.onRequest('git.abortRebase', (p) => handlers.changes.abortRebase(p)) + dispatcher.onRequest('git.checkout', (p) => handlers.changes.checkout(p)) + dispatcher.onRequest('git.localBranches', (p) => handlers.changes.localBranches(p)) + dispatcher.onRequest('git.discard', (p) => handlers.discard.discard(p)) + dispatcher.onRequest('git.bulkDiscard', (p) => handlers.discard.bulkDiscard(p)) + dispatcher.onRequest('git.conflictOperation', (p) => handlers.discard.conflictOperation(p)) + dispatcher.onRequest('git.branchCompare', (p) => handlers.comparison.branchCompare(p)) + dispatcher.onRequest('git.commitCompare', (p) => handlers.comparison.commitCompare(p)) + dispatcher.onRequest('git.upstreamStatus', (p) => handlers.comparison.upstreamStatus(p)) + dispatcher.onRequest('git.fetch', (p) => handlers.fetch.fetch(p)) + dispatcher.onRequest('git.forkSync', (p, context) => handlers.fetch.forkSync(p, context)) + dispatcher.onRequest('git.fetchRemoteTrackingRef', (p) => + handlers.fetch.fetchRemoteTrackingRef(p) + ) + dispatcher.onRequest('git.fetchGitHubPullRequestHead', (p) => + handlers.fetch.fetchGitHubPullRequestHead(p) + ) + dispatcher.onRequest('git.fetchGitLabMergeRequestHead', (p) => + handlers.fetch.fetchGitLabMergeRequestHead(p) + ) + // Why: the durable-ref variant is a distinct method name so an old relay + // (which only knows FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead) + // returns -32601 and the client can prompt a reconnect instead of silently + // resolving a stale/missing ref. Both names share the durable handler: a + // refspec fetch still writes FETCH_HEAD, so old clients keep their semantics. + dispatcher.onRequest('git.fetchGitLabMergeRequestHeadRef', (p) => + handlers.fetch.fetchGitLabMergeRequestHead(p) + ) + dispatcher.onRequest('git.push', (p) => handlers.sync.push(p)) + dispatcher.onRequest('git.pull', (p, context) => handlers.sync.pull(p, context)) + dispatcher.onRequest('git.fastForward', (p, context) => handlers.sync.fastForward(p, context)) + dispatcher.onRequest('git.rebaseFromBase', (p, context) => + handlers.sync.rebaseFromBase(p, context) + ) + dispatcher.onRequest('git.branchDiff', (p, context) => handlers.objectDiff.branchDiff(p, context)) + dispatcher.onRequest('git.commitDiff', (p, context) => handlers.objectDiff.commitDiff(p, context)) + dispatcher.onRequest('git.listWorktrees', (p, context) => + handlers.worktree.listWorktrees(p, context) + ) + dispatcher.onRequest('git.addWorktree', (p) => handlers.worktree.addWorktree(p)) + dispatcher.onRequest('git.removeWorktree', (p) => handlers.worktree.removeWorktree(p)) + dispatcher.onRequest('git.worktreeIsClean', (p) => handlers.worktree.worktreeIsClean(p)) + dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => + handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) + ) + dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) + dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => + handlers.exec.forceDeletePreservedBranch(p) + ) + dispatcher.onRequest('git.exec', (p, context) => handlers.exec.exec(p, context)) + dispatcher.onRequest('git.clone', (p, context) => handlers.exec.clone(p, context)) + dispatcher.onRequest('git.isGitRepo', (p) => handlers.worktree.isGitRepo(p)) + dispatcher.onNotification('git.responseAck', (p, context) => responseAck(p, context)) + dispatcher.onNotification('git.cancelResponseStream', (p, context) => + cancelResponseStream(p, context) + ) +} diff --git a/src/relay/git-handler-sync-operations.ts b/src/relay/git-handler-sync-operations.ts new file mode 100644 index 00000000000..262517b33cc --- /dev/null +++ b/src/relay/git-handler-sync-operations.ts @@ -0,0 +1,205 @@ +import { randomUUID } from 'node:crypto' +import type { RequestContext } from './dispatcher' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { resolveRelayPushTarget } from './git-handler-push-target' +import { normalizeGitErrorMessage, runPullWithDivergenceFallback } from '../shared/git-remote-error' +import { assertGitPushTargetShape } from '../shared/git-push-target-validation' +import type { GitCommandRunner } from '../shared/git-publish-target-status' +import type { GitPushTarget } from '../shared/worktree/types' +import { resolveEffectiveGitUpstream } from '../shared/git-effective-upstream' +import { runWithGitWorktreeOperationLock } from '../shared/git-worktree-operation-lock' +import { + REBASE_FROM_BASE_OPERATION_TIMEOUT_MS, + REBASE_SOURCE_FETCH_TIMEOUT_MS, + resolveGitRemoteRebaseSource +} from '../shared/git-rebase-source' +import { isNoWriteFetchHeadUnsupportedError } from '../shared/git-fetch-head-capability' + +export class GitHandlerSyncOperations extends GitHandlerOperationContext { + async push(params: Record) { + this.clearGitMutationReadCaches() + 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 aren't 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 local gitPush normalization so SSH users get "non-fast-forward / pull first" guidance instead of raw git stderr. + throw new Error(normalizeGitErrorMessage(error, 'push')) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + private async pullWithArgs( + params: Record, + pullArgs: string[], + signal?: AbortSignal + ) { + const worktreePath = params.worktreePath as string + return runWithGitWorktreeOperationLock(worktreePath, signal, () => + this.runPullWithArgsUnlocked(params, pullArgs) + ) + } + + private async runPullWithArgsUnlocked( + params: Record, + pullArgs: string[] + ): Promise { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const runPull = async (effectiveArgs: string[]): Promise => { + 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', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName], + worktreePath + ) + return + } + const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) + if (upstream && !upstream.isConfiguredUpstream) { + // Why: legacy Orca branches may track origin/main while pushes target origin/; pull the same effective branch the UI reports. + await this.git( + ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], + worktreePath + ) + return + } + await this.git(['pull', ...effectiveArgs], worktreePath) + } + + try { + try { + await runPullWithDivergenceFallback(pullArgs, runPull) + } catch (error) { + // Why: mirror local gitPull normalization so SSH users get actionable messages instead of raw git stderr. + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + async pull(params: Record, context?: RequestContext) { + // Why: plain `git pull` honors user merge/rebase/ff policy. + await this.pullWithArgs(params, [], context?.signal) + } + + async fastForward(params: Record, context?: RequestContext) { + await this.pullWithArgs(params, ['--ff-only'], context?.signal) + } + + async rebaseFromBase(params: Record, context?: RequestContext) { + return runWithGitWorktreeOperationLock(params.worktreePath as string, context?.signal, () => + this.runRebaseFromBase(params, context) + ) + } + + private async runRebaseFromBase(params: Record, context?: RequestContext) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const baseRef = params.baseRef as string + let rebaseRef: string | null = null + 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(), REBASE_FROM_BASE_OPERATION_TIMEOUT_MS) + try { + try { + const source = await resolveGitRemoteRebaseSource( + ((args) => + this.git(args, worktreePath, { + signal: controller.signal, + terminationBarrier: true + })) as GitCommandRunner, + baseRef + ) + let forkPoint: string | null = null + let hasHead = true + try { + const { stdout } = await this.git( + ['merge-base', '--fork-point', `refs/remotes/${source.displayName}`, 'HEAD'], + worktreePath, + { signal: controller.signal, terminationBarrier: true } + ) + forkPoint = stdout.trim() || null + } catch { + // A first fetch or an unhelpful reflog falls back to Git's merge-base behavior. + try { + await this.git(['rev-parse', '--verify', 'HEAD'], worktreePath, { + signal: controller.signal, + terminationBarrier: true + }) + } catch { + hasHead = false + } + } + // Why: concurrent fetches can replace FETCH_HEAD and remote-tracking refs between fetch and rebase. + rebaseRef = `refs/orca/rebase/${randomUUID()}` + const fetchArgs = [ + source.remoteName, + `+refs/heads/${source.branchName}:${rebaseRef}`, + `+refs/heads/${source.branchName}:refs/remotes/${source.displayName}` + ] + await this.gitCapabilities.runWithFallback( + 'fetch-no-write-fetch-head', + () => + this.git(['fetch', '--no-write-fetch-head', ...fetchArgs], worktreePath, { + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, + signal: controller.signal, + terminationBarrier: true + }), + () => + this.git(['fetch', ...fetchArgs], worktreePath, { + timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, + signal: controller.signal, + terminationBarrier: true + }), + isNoWriteFetchHeadUnsupportedError + ) + await this.git( + hasHead + ? forkPoint + ? ['rebase', '--onto', rebaseRef, forkPoint] + : ['rebase', rebaseRef] + : ['merge', '--ff-only', rebaseRef], + worktreePath, + { signal: controller.signal, terminationBarrier: true } + ) + } catch (error) { + throw new Error(normalizeGitErrorMessage(error, 'pull')) + } + } finally { + if (rebaseRef) { + try { + await this.git(['update-ref', '-d', rebaseRef], worktreePath) + } catch { + // Cleanup must not hide the fetch or rebase result. + } + } + clearTimeout(timeout) + context?.signal?.removeEventListener('abort', abortFromContext) + this.clearGitMutationReadCaches() + } + } +} diff --git a/src/relay/git-handler-worktree-change-operations.ts b/src/relay/git-handler-worktree-change-operations.ts new file mode 100644 index 00000000000..907e5dba50d --- /dev/null +++ b/src/relay/git-handler-worktree-change-operations.ts @@ -0,0 +1,134 @@ +import { GitHandlerOperationContext, GIT_BULK_CHUNK_SIZE } from './git-handler-operation-context' +import { commitChangesRelay } from './git-handler-worktree-ops' + +const BULK_CHUNK_SIZE = GIT_BULK_CHUNK_SIZE + +export class GitHandlerWorktreeChangeOperations extends GitHandlerOperationContext { + async stage(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const filePath = params.filePath as string + try { + await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath) + } finally { + this.clearGitMutationReadCaches() + } + } + + async commit(params: Record): Promise<{ success: boolean; error?: string }> { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const message = params.message as string + try { + return await commitChangesRelay(this.git.bind(this), worktreePath, message) + } finally { + this.clearGitMutationReadCaches() + } + } + + async unstage(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const filePath = params.filePath as string + try { + await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath) + } finally { + this.clearGitMutationReadCaches() + } + } + + async bulkStage(params: Record) { + this.clearGitMutationReadCaches() + 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.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + async bulkUnstage(params: Record) { + this.clearGitMutationReadCaches() + 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.map((filePath) => this.literalPathspec(filePath))], + worktreePath + ) + } + } finally { + this.clearGitMutationReadCaches() + } + } + + async abortMerge(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + try { + await this.git(['merge', '--abort'], worktreePath) + } finally { + this.clearGitMutationReadCaches() + } + } + + async abortRebase(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + try { + await this.git(['rebase', '--abort'], worktreePath) + } finally { + this.clearGitMutationReadCaches() + } + } + + async checkout(params: Record) { + this.clearGitMutationReadCaches() + const worktreePath = params.worktreePath as string + const branch = params.branch as string + // Defense-in-depth: reject `-`-prefixed branch tokens to block flag injection (this relay entrypoint is reachable independently of the RPC schema). + 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.clearGitMutationReadCaches() + } + } + + 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 } + } +} diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts new file mode 100644 index 00000000000..32993dd845b --- /dev/null +++ b/src/relay/git-handler-worktree-operations.ts @@ -0,0 +1,179 @@ +import * as path from 'node:path' +import type { RequestContext } from './dispatcher' +import { expandTilde } from './context' +import { GitHandlerOperationContext } from './git-handler-operation-context' +import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' +import { + addWorktreeOp, + areRelayWorktreePathsEqual, + removeWorktreeOp, + worktreeIsCleanOp +} from './git-handler-worktree-ops' +import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' +import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' +import { + hasUnsupportedRevParsePathFormatEcho, + isUnsupportedRevParsePathFormatError +} from '../shared/git-worktree-command-capabilities' + +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`; resolve relative paths against repoPath by path 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 unknown flag and exits 0; drop `-`-prefixed lines, take the last two paths. + // 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) + } +} + +export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { + 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: old Git echoes the unknown option and exits zero; remember the signal though the paths still parse. + 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 legacy tilde SSH repo paths match git's absolute path, sparing 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 separate-git-dir/submodule repos have main entry == git-common-dir; gate on it so we don't clobber a linked worktree's real root. + if (!areRelayWorktreePathsEqual(mainPath, location.commonDir)) { + return worktrees + } + + const normalized = [...worktrees] + normalized[mainIndex] = { ...mainWorktree, path: location.topLevel } + return normalized + } + + 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: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). + try { + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) + const normalized = await this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout) + ) + // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) + } catch { + return [] + } + }, + isUnsupportedWorktreeListZError + ) + .catch(() => []) + } + + async addWorktree(params: Record) { + return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params)) + } + + async removeWorktree(params: Record) { + const remove = () => + this.runWithGitReadCacheClear(() => + removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) + ) + const worktreePath = params.worktreePath + return this.watcherRegistry && typeof worktreePath === 'string' + ? this.watcherRegistry.runWithRemovalFence(expandTilde(worktreePath), remove) + : remove() + } + + async worktreeIsClean(params: Record) { + return worktreeIsCleanOp(this.git.bind(this), params) + } + + async refreshLocalBaseRefForWorktreeCreate(params: Record) { + return this.runWithGitReadCacheClear(() => + refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) + ) + } +} diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index b3e5cb96fcb..58f47da9fce 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -1,157 +1,33 @@ -/* eslint-disable max-lines -- Why: centralizes the git RPC protocol surface so local and SSH git behavior stay in one dispatch table. */ -import { randomUUID } from 'node:crypto' 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, - type GitExec -} from './git-handler-ops' -import { - branchDiffEntryAtPinnedOids, - isFullGitObjectId, - parseOptionalBranchDiffHeadOid -} from './git-handler-branch-diff-ops' -import { - buildSubmoduleInnerCommitRangeDiff, - computeSubmodulePointerDiff, - computeSubmoduleRangeEntries, - clearSubmodulePathsCache, - 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 { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list' -import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' -import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh' -import { gitExecMutatesRepository } from '../shared/git-exec-mutation' -import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' -import { capGitStatusEntries, resolveGitStatusLimit } from '../shared/git-status-limit' -import { checkIgnoredPathsOp } from './git-handler-check-ignore' -import { resolveRelayPushTarget } from './git-handler-push-target' -import { - isExecKilledError, - isNoUpstreamError, - normalizeGitErrorMessage, - runPullWithDivergenceFallback -} 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 { isNoWriteFetchHeadUnsupportedError } from '../shared/git-fetch-head-capability' -import { runWithGitWorktreeOperationLock } from '../shared/git-worktree-operation-lock' -import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../shared/git-fetch-head-lock' -import { - REBASE_FROM_BASE_OPERATION_TIMEOUT_MS, - REBASE_SOURCE_FETCH_TIMEOUT_MS, - resolveGitRemoteRebaseSource -} from '../shared/git-rebase-source' -import type { GitPushTarget } from '../shared/worktree/types' -import { - getEffectiveGitUpstreamStatus, - resolveEffectiveGitUpstream -} from '../shared/git-effective-upstream' -import { loadGitHistoryFromExecutor } from '../shared/git-history' -import { buildRelayGitEnv, buildRelayUnattendedGitEnv } 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 { InFlightPromiseDedupe } from '../shared/in-flight-promise-dedupe' import { GitCapabilityCache } from '../shared/git-capability-cache' import { - githubPullRequestHeadLocalRef, - gitlabMergeRequestHeadLocalRef, - isSafeReviewHeadFetchRemote, - isValidReviewHeadNumber, - reviewHeadRemoteRefComponent, - REVIEW_HEAD_FETCH_TIMEOUT_MS -} from '../shared/review-head-tracking-ref' -import type { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' -import { - hasUnsupportedRevParsePathFormatEcho, - isUnsupportedRevParsePathFormatError -} from '../shared/git-worktree-command-capabilities' + clearSubmodulePathsCache, + createSubmodulePathsCache, + type SubmodulePathsCache +} from './git-handler-submodule-ops' import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' -import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' -import { streamRelayGitStdout } from './git-stdout-stream' -import { runProcess } from '../shared/child-process/run-process' +import { buildRelayGitEnv, buildRelayUnattendedGitEnv } from './relay-command-env' +import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' +import type { + GitHandlerCommandOptions, + GitHandlerCommandResult, + GitHandlerWatcherRegistry +} from './git-handler-operation-context' +import { createGitHandlerOperationSet } from './git-handler-operation-set' +import { registerGitHandlers } from './git-handler-registration' +import { resolveGitFetchHeadCommand, runWithGitFetchHeadLock } from '../shared/git-fetch-head-lock' +import { endSubprocessStdin } from '../shared/subprocess-stdin-write' +import { MAX_GIT_BUFFER, runGitToTermination } from './git-handler-command-termination' const execFileAsync = promisify(execFile) -const MAX_GIT_BUFFER = 10 * 1024 * 1024 -const BULK_CHUNK_SIZE = 100 -const GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS = 2_147_000_000 - -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`; resolve relative paths against repoPath by path 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 unknown flag and exits 0; drop `-`-prefixed lines, take the last two paths. - // 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, @@ -188,47 +64,6 @@ function execFileWithStdin( }) } -async function runGitToTermination( - args: string[], - options: ExecFileOptions, - stdin: string | undefined -): Promise<{ stdout: string; stderr: string }> { - const result = await runProcess({ - program: 'git', - args, - cwd: typeof options.cwd === 'string' ? options.cwd : undefined, - env: options.env, - timeoutMs: - typeof options.timeout === 'number' - ? options.timeout - : GIT_REBASE_PROCESS_FALLBACK_TIMEOUT_MS, - maxOutputBytes: typeof options.maxBuffer === 'number' ? options.maxBuffer : MAX_GIT_BUFFER, - signal: options.signal, - terminationBarrier: true, - ...(stdin === undefined ? {} : { input: stdin }) - }) - if (result.code === 0 && !result.timedOut && !options.signal?.aborted) { - return { stdout: result.stdout, stderr: result.stderr } - } - const error = new Error( - result.timedOut - ? `git ${args[0] ?? 'command'} timed out.` - : options.signal?.aborted - ? 'The operation was aborted.' - : result.stderr.trim() || `git ${args[0] ?? 'command'} failed.` - ) - if (options.signal?.aborted) { - error.name = 'AbortError' - } - throw Object.assign(error, { - code: result.code, - killed: result.timedOut || result.signal !== null || options.signal?.aborted === true, - signal: result.signal, - stdout: result.stdout, - stderr: result.stderr - }) -} - export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() @@ -243,10 +78,30 @@ export class GitHandler { constructor( dispatcher: RelayDispatcher, _context: RelayContext, - private readonly watcherRegistry?: Pick + private readonly watcherRegistry?: GitHandlerWatcherRegistry ) { this.dispatcher = dispatcher - this.registerHandlers() + const handlers = createGitHandlerOperationSet({ + gitDiffReadDedupe: this.gitDiffReadDedupe, + gitCapabilities: this.gitCapabilities, + submodulePathsCache: this.submodulePathsCache, + watcherRegistry: this.watcherRegistry, + git: (args, cwd, opts) => + opts === undefined ? this.git(args, cwd) : this.git(args, cwd, opts), + gitBuffer: (args, cwd) => this.gitBuffer(args, cwd), + spawnClone: (args, cwd, progressId, context) => + this.spawnClone(args, cwd, progressId, context), + clearGitMutationReadCaches: () => this.clearGitMutationReadCaches(), + runWithGitReadCacheClear: (run) => this.runWithGitReadCacheClear(run), + maybeStreamResponse: (result, params, context) => + this.maybeStreamResponse(result, params, context) + }) + registerGitHandlers( + this.dispatcher, + handlers, + (params, context) => this.responseAck(params, context), + (params, context) => this.cancelResponseStream(params, context) + ) // Why: a detached client's git.responseAck frames never arrive; wake any pump parked on the ack window so it re-checks staleness and exits. this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) } @@ -256,72 +111,6 @@ export class GitHandler { this.clearGitMutationReadCaches() } - private registerHandlers(): void { - this.dispatcher.onRequest('git.status', (p, context) => this.getStatus(p, context)) - this.dispatcher.onRequest('git.submoduleStatus', (p, context) => - this.getSubmoduleStatus(p, context) - ) - 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.fetchGitHubPullRequestHead', (p) => - this.fetchGitHubPullRequestHead(p) - ) - this.dispatcher.onRequest('git.fetchGitLabMergeRequestHead', (p) => - this.fetchGitLabMergeRequestHead(p) - ) - // Why: the durable-ref variant is a distinct method name so an old relay - // (which only knows FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead) - // returns -32601 and the client can prompt a reconnect instead of silently - // resolving a stale/missing ref. Both names share the durable handler: a - // refspec fetch still writes FETCH_HEAD, so old clients keep their semantics. - this.dispatcher.onRequest('git.fetchGitLabMergeRequestHeadRef', (p) => - this.fetchGitLabMergeRequestHead(p) - ) - this.dispatcher.onRequest('git.push', (p) => this.push(p)) - this.dispatcher.onRequest('git.pull', (p, context) => this.pull(p, context)) - this.dispatcher.onRequest('git.fastForward', (p, context) => this.fastForward(p, context)) - this.dispatcher.onRequest('git.rebaseFromBase', (p, context) => this.rebaseFromBase(p, context)) - 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 @@ -373,16 +162,8 @@ export class GitHandler { private async git( args: string[], cwd: string, - opts?: { - maxBuffer?: number - disableOptionalLocks?: boolean - signal?: AbortSignal - nonInteractive?: boolean - stdin?: string - timeout?: number - terminationBarrier?: boolean - } - ): Promise<{ stdout: string; stderr: string }> { + opts?: GitHandlerCommandOptions + ): Promise { const expandedCwd = expandTilde(cwd) const run = async (): Promise<{ stdout: string; stderr: string }> => { const env = opts?.nonInteractive ? buildRelayUnattendedGitEnv() : buildRelayGitEnv() @@ -422,987 +203,6 @@ export class GitHandler { return stdout } - private async getStatus(params: Record, context: RequestContext) { - this.gitDiffReadDedupe.clear() - return getStatusOp(this.git.bind(this), streamRelayGitStdout, params, { - signal: context.signal - }) - } - - // Why: fetch per-file submodule changes from the submodule worktree. - private async getSubmoduleStatus(params: Record, context: RequestContext) { - 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 limit = resolveGitStatusLimit(params.limit) - // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work. - const workingResult = staged - ? { entries: [], conflictOperation: 'unknown' } - : await getStatusOp( - this.git.bind(this), - streamRelayGitStdout, - { - ...params, - worktreePath: resolved - }, - { signal: context.signal } - ) - // Why: pointer/range probes are part of the same SSH request and must not outlive its cancellation. - const requestGit: GitExec = (args, cwd, options) => - this.git(args, cwd, { ...options, signal: context.signal }) - // Why: moved clean gitlinks need committed changes surfaced. - const { fromOid, toOid } = await resolveSubmoduleCommitRange( - requestGit, - worktreePath, - submodulePath, - staged - ) - if (fromOid && toOid && fromOid !== toOid) { - const rangeEntries = await computeSubmoduleRangeEntries(requestGit, resolved, fromOid, toOid) - if (staged) { - return { ...workingResult, ...capGitStatusEntries(rangeEntries, limit) } - } - const rangePaths = new Set(rangeEntries.map((entry) => entry.path)) - const entries = [ - ...rangeEntries, - ...workingResult.entries.filter((entry) => !rangePaths.has(entry.path)) - ] - return { - ...workingResult, - ...capGitStatusEntries(entries, limit, workingResult) - } - } - 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: validate relative paths to prevent traversal 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 dedupe before awaiting so identical reads coalesce. - const result = await this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => { - // Why: route gitlink roots to pointer diffs and inner files to their submodule worktree. - 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.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - try { - await this.git(['add', '--', this.literalPathspec(filePath)], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async commit( - params: Record - ): Promise<{ success: boolean; error?: string }> { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const message = params.message as string - try { - return await commitChangesRelay(this.git.bind(this), worktreePath, message) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async unstage(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const filePath = params.filePath as string - try { - await this.git(['restore', '--staged', '--', this.literalPathspec(filePath)], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async bulkStage(params: Record) { - this.clearGitMutationReadCaches() - 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.map((filePath) => this.literalPathspec(filePath))], - worktreePath - ) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async bulkUnstage(params: Record) { - this.clearGitMutationReadCaches() - 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.map((filePath) => this.literalPathspec(filePath))], - worktreePath - ) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async abortMerge(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - try { - await this.git(['merge', '--abort'], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async abortRebase(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - try { - await this.git(['rebase', '--abort'], worktreePath) - } finally { - this.clearGitMutationReadCaches() - } - } - - private async checkout(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const branch = params.branch as string - // Defense-in-depth: reject `-`-prefixed branch tokens to block flag injection (this relay entrypoint is reachable independently of the RPC schema). - 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.clearGitMutationReadCaches() - } - } - - 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; reject (with parent-escaping paths) so a discard can't wipe the whole worktree. - 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.clearGitMutationReadCaches() - 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.clearGitMutationReadCaches() - } - } - - private async bulkDiscard(params: Record) { - this.clearGitMutationReadCaches() - 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: a selected 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.clearGitMutationReadCaches() - } - } - - 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: reject flag-like base refs to prevent rev-parse option injection. - 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: preserve non-ASCII filenames as UTF-8 for parseBranchDiff. - const [{ stdout }, { stdout: numstat }] = await Promise.all([ - gitBound( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - worktreePath - ), - 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: suppress only the expected no-upstream error; surface all others. - 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.clearGitMutationReadCaches() - 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: normalize like local gitFetch so SSH users get actionable messages, not raw stderr (may embed credentials). - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async forkSync(params: Record, context?: RequestContext) { - return this.runWithGitReadCacheClear(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.clearGitMutationReadCaches() - 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 that generic git.exec rejects; narrow RPC keeps the allowlist tight. - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - // Why: the durable review-head ref embeds the remote's identity, and a - // missing remote must fail with an actionable message, not a raw fetch error. - private async reviewHeadRemoteComponent(worktreePath: string, remote: string): Promise { - let remoteUrl: string - try { - const { stdout } = await this.git(['remote', 'get-url', remote], worktreePath) - remoteUrl = stdout.trim() - } catch { - remoteUrl = '' - } - if (!remoteUrl) { - throw new Error(`Remote "${remote}" is not configured.`) - } - return reviewHeadRemoteRefComponent(remote, remoteUrl) - } - - private async fetchGitLabMergeRequestHead(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const mrIid = params.mrIid - try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(mrIid)) { - throw new Error('Invalid GitLab merge request fetch request.') - } - const mergeRequestIid = mrIid - if (!isSafeReviewHeadFetchRemote(remote)) { - throw new Error('GitLab merge request fetch remote must not start with "-".') - } - - try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: GitLab fork heads need a dedicated write RPC and ref outside refs/heads/*. - // Return the exact written path so the client does not re-hash a second get-url. - const localRef = gitlabMergeRequestHeadLocalRef(remoteComponent, mergeRequestIid) - await this.git( - [ - 'fetch', - '--no-tags', - remote, - `+refs/merge-requests/${mergeRequestIid}/head:${localRef}` - ], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } - ) - return { localRef } - } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error( - `Fetching refs/merge-requests/${mergeRequestIid}/head from "${remote}" timed out.` - ) - } - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async fetchGitHubPullRequestHead(params: Record) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const remote = params.remote - const prNumber = params.prNumber - try { - if (typeof remote !== 'string' || !isValidReviewHeadNumber(prNumber)) { - throw new Error('Invalid GitHub pull request fetch request.') - } - if (!isSafeReviewHeadFetchRemote(remote)) { - throw new Error('GitHub pull request fetch remote must not start with "-".') - } - - try { - const remoteComponent = await this.reviewHeadRemoteComponent(worktreePath, remote) - // Why: return the written path so resolve can rev-parse the same ref the host wrote. - const localRef = githubPullRequestHeadLocalRef(remoteComponent, prNumber) - await this.git( - ['fetch', '--no-tags', remote, `+refs/pull/${prNumber}/head:${localRef}`], - worktreePath, - { timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } - ) - return { localRef } - } catch (error) { - // Why: a timeout kill has no git stderr; name it so the client can classify it as transient. - if (isExecKilledError(error)) { - throw new Error(`Fetching refs/pull/${prNumber}/head from "${remote}" timed out.`) - } - throw new Error(normalizeGitErrorMessage(error, 'fetch')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async push(params: Record) { - this.clearGitMutationReadCaches() - 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 aren't 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 local gitPush normalization so SSH users get "non-fast-forward / pull first" guidance instead of raw git stderr. - throw new Error(normalizeGitErrorMessage(error, 'push')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async pullWithArgs( - params: Record, - pullArgs: string[], - signal?: AbortSignal - ) { - const worktreePath = params.worktreePath as string - return runWithGitWorktreeOperationLock(worktreePath, signal, () => - this.runPullWithArgsUnlocked(params, pullArgs) - ) - } - - private async runPullWithArgsUnlocked( - params: Record, - pullArgs: string[] - ): Promise { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const runPull = async (effectiveArgs: string[]): Promise => { - 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', ...effectiveArgs, pushTarget.remoteName, pushTarget.branchName], - worktreePath - ) - return - } - const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath)) - if (upstream && !upstream.isConfiguredUpstream) { - // Why: legacy Orca branches may track origin/main while pushes target origin/; pull the same effective branch the UI reports. - await this.git( - ['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName], - worktreePath - ) - return - } - await this.git(['pull', ...effectiveArgs], worktreePath) - } - - try { - try { - await runPullWithDivergenceFallback(pullArgs, runPull) - } catch (error) { - // Why: mirror local gitPull normalization so SSH users get actionable messages instead of raw git stderr. - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } - } finally { - this.clearGitMutationReadCaches() - } - } - - private async pull(params: Record, context?: RequestContext) { - // Why: plain `git pull` honors user merge/rebase/ff policy. - await this.pullWithArgs(params, [], context?.signal) - } - - private async fastForward(params: Record, context?: RequestContext) { - await this.pullWithArgs(params, ['--ff-only'], context?.signal) - } - - private async rebaseFromBase(params: Record, context?: RequestContext) { - return runWithGitWorktreeOperationLock(params.worktreePath as string, context?.signal, () => - this.runRebaseFromBase(params, context) - ) - } - - private async runRebaseFromBase(params: Record, context?: RequestContext) { - this.clearGitMutationReadCaches() - const worktreePath = params.worktreePath as string - const baseRef = params.baseRef as string - let rebaseRef: string | null = null - 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(), REBASE_FROM_BASE_OPERATION_TIMEOUT_MS) - try { - try { - const source = await resolveGitRemoteRebaseSource( - ((args) => - this.git(args, worktreePath, { - signal: controller.signal, - terminationBarrier: true - })) as GitCommandRunner, - baseRef - ) - let forkPoint: string | null = null - let hasHead = true - try { - const { stdout } = await this.git( - ['merge-base', '--fork-point', `refs/remotes/${source.displayName}`, 'HEAD'], - worktreePath, - { signal: controller.signal, terminationBarrier: true } - ) - forkPoint = stdout.trim() || null - } catch { - // A first fetch or an unhelpful reflog falls back to Git's merge-base behavior. - try { - await this.git(['rev-parse', '--verify', 'HEAD'], worktreePath, { - signal: controller.signal, - terminationBarrier: true - }) - } catch { - hasHead = false - } - } - // Why: concurrent fetches can replace FETCH_HEAD and remote-tracking refs between fetch and rebase. - rebaseRef = `refs/orca/rebase/${randomUUID()}` - const fetchArgs = [ - source.remoteName, - `+refs/heads/${source.branchName}:${rebaseRef}`, - `+refs/heads/${source.branchName}:refs/remotes/${source.displayName}` - ] - await this.gitCapabilities.runWithFallback( - 'fetch-no-write-fetch-head', - () => - this.git(['fetch', '--no-write-fetch-head', ...fetchArgs], worktreePath, { - timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, - signal: controller.signal, - terminationBarrier: true - }), - () => - this.git(['fetch', ...fetchArgs], worktreePath, { - timeout: REBASE_SOURCE_FETCH_TIMEOUT_MS, - signal: controller.signal, - terminationBarrier: true - }), - isNoWriteFetchHeadUnsupportedError - ) - await this.git( - hasHead - ? forkPoint - ? ['rebase', '--onto', rebaseRef, forkPoint] - : ['rebase', rebaseRef] - : ['merge', '--ff-only', rebaseRef], - worktreePath, - { signal: controller.signal, terminationBarrier: true } - ) - } catch (error) { - throw new Error(normalizeGitErrorMessage(error, 'pull')) - } - } finally { - if (rebaseRef) { - try { - await this.git(['update-ref', '-d', rebaseRef], worktreePath) - } catch { - // Cleanup must not hide the fetch or rebase result. - } - } - clearTimeout(timeout) - context?.signal?.removeEventListener('abort', abortFromContext) - this.clearGitMutationReadCaches() - } - } - - 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 headOid = parseOptionalBranchDiffHeadOid(params) - 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, - headOid ?? null, - options.includePatch ?? null, - options.filePath ?? null, - options.oldPath ?? null - ]), - () => { - if ( - headOid && - isFullGitObjectId(baseRef) && - options.includePatch === true && - typeof options.filePath === 'string' && - options.filePath.length > 0 - ) { - return branchDiffEntryAtPinnedOids( - this.gitBuffer.bind(this), - worktreePath, - baseRef, - headOid, - options.filePath, - options.oldPath - ) - } - return 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 run = () => this.git(args, cwd, { signal: context?.signal }) - const { stdout, stderr } = gitExecMutatesRepository(args) - ? await this.runWithGitReadCacheClear(run) - : await run() - 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.runWithGitReadCacheClear(() => - this.spawnClone(args, cwd, progressId, context) - ) - } - private async spawnClone( args: string[], cwd: string, @@ -1468,171 +268,4 @@ export class GitHandler { }) }) } - - private async renameCurrentBranch(params: Record) { - return this.runWithGitReadCacheClear(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 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: empty repoPath would target the relay's own cwd with a destructive update-ref, and NUL bytes can't reach git safely — reject both. - if (!repoPath || repoPath.includes('\0') || expectedHead.includes('\0')) { - throw new Error('Invalid preserved branch force-delete request.') - } - return this.runWithGitReadCacheClear(() => - 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: old Git echoes the unknown option and exits zero; remember the signal though the paths still parse. - 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 legacy tilde SSH repo paths match git's absolute path, sparing 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 separate-git-dir/submodule repos have main entry == git-common-dir; gate on it so we don't clobber a linked worktree's real root. - 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: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout) - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) - } catch { - return [] - } - }, - isUnsupportedWorktreeListZError - ) - .catch(() => []) - } - - private async addWorktree(params: Record) { - return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params)) - } - - private async removeWorktree(params: Record) { - const remove = () => - this.runWithGitReadCacheClear(() => - removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) - ) - const worktreePath = params.worktreePath - return this.watcherRegistry && typeof worktreePath === 'string' - ? this.watcherRegistry.runWithRemovalFence(expandTilde(worktreePath), remove) - : remove() - } - - private async worktreeIsClean(params: Record) { - return worktreeIsCleanOp(this.git.bind(this), params) - } - - private async refreshLocalBaseRefForWorktreeCreate(params: Record) { - return this.runWithGitReadCacheClear(() => - refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) - ) - } }