mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
|
||||
['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]
|
||||
])
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const commitId = params.commitId as string
|
||||
return commitCompareOp(this.git.bind(this), worktreePath, commitId)
|
||||
}
|
||||
|
||||
async upstreamStatus(params: Record<string, unknown>) {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
return detectConflictOperation(worktreePath)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>, 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<string, unknown>) {
|
||||
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<string> {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>, 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<string, unknown>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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<RelayFilesystemWatchRegistry, 'runWithRemovalFence'>
|
||||
|
||||
export type GitHandlerOperationHost = {
|
||||
readonly gitDiffReadDedupe: InFlightPromiseDedupe<unknown>
|
||||
readonly gitCapabilities: GitCapabilityCache
|
||||
readonly submodulePathsCache: SubmodulePathsCache
|
||||
readonly watcherRegistry: GitHandlerWatcherRegistry | undefined
|
||||
git(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
opts?: GitHandlerCommandOptions
|
||||
): Promise<GitHandlerCommandResult>
|
||||
gitBuffer(args: string[], cwd: string): Promise<Buffer>
|
||||
spawnClone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
progressId: string,
|
||||
context?: RequestContext
|
||||
): Promise<GitHandlerCommandResult>
|
||||
clearGitMutationReadCaches(): void
|
||||
runWithGitReadCacheClear<T>(run: () => Promise<T>): Promise<T>
|
||||
maybeStreamResponse(
|
||||
result: unknown,
|
||||
params: Record<string, unknown>,
|
||||
context: RequestContext | undefined
|
||||
): unknown
|
||||
}
|
||||
|
||||
export abstract class GitHandlerOperationContext {
|
||||
constructor(private readonly host: GitHandlerOperationHost) {}
|
||||
|
||||
protected get gitDiffReadDedupe(): InFlightPromiseDedupe<unknown> {
|
||||
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<GitHandlerCommandResult> {
|
||||
return this.host.git(args, cwd, opts)
|
||||
}
|
||||
|
||||
protected gitBuffer(args: string[], cwd: string): Promise<Buffer> {
|
||||
return this.host.gitBuffer(args, cwd)
|
||||
}
|
||||
|
||||
protected spawnClone(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
progressId: string,
|
||||
context?: RequestContext
|
||||
): Promise<GitHandlerCommandResult> {
|
||||
return this.host.spawnClone(args, cwd, progressId, context)
|
||||
}
|
||||
|
||||
protected clearGitMutationReadCaches(): void {
|
||||
this.host.clearGitMutationReadCaches()
|
||||
}
|
||||
|
||||
protected runWithGitReadCacheClear<T>(run: () => Promise<T>): Promise<T> {
|
||||
return this.host.runWithGitReadCacheClear(run)
|
||||
}
|
||||
|
||||
protected maybeStreamResponse(
|
||||
result: unknown,
|
||||
params: Record<string, unknown>,
|
||||
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}`
|
||||
}
|
||||
}
|
||||
@@ -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<typeof createGitHandlerOperationSet>
|
||||
@@ -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<string, unknown>
|
||||
): '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<string, unknown>, 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<string, unknown>, 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<string, unknown>) {
|
||||
return checkIgnoredPathsOp(this.git.bind(this), params)
|
||||
}
|
||||
|
||||
async history(params: Record<string, unknown>) {
|
||||
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<string, unknown>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>, context: RequestContext) => void,
|
||||
cancelResponseStream: (params: Record<string, unknown>, 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)
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>,
|
||||
pullArgs: string[],
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const worktreePath = params.worktreePath as string
|
||||
return runWithGitWorktreeOperationLock(worktreePath, signal, () =>
|
||||
this.runPullWithArgsUnlocked(params, pullArgs)
|
||||
)
|
||||
}
|
||||
|
||||
private async runPullWithArgsUnlocked(
|
||||
params: Record<string, unknown>,
|
||||
pullArgs: string[]
|
||||
): Promise<void> {
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
const runPull = async (effectiveArgs: string[]): Promise<void> => {
|
||||
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/<branch>; 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<string, unknown>, context?: RequestContext) {
|
||||
// Why: plain `git pull` honors user merge/rebase/ff policy.
|
||||
await this.pullWithArgs(params, [], context?.signal)
|
||||
}
|
||||
|
||||
async fastForward(params: Record<string, unknown>, context?: RequestContext) {
|
||||
await this.pullWithArgs(params, ['--ff-only'], context?.signal)
|
||||
}
|
||||
|
||||
async rebaseFromBase(params: Record<string, unknown>, context?: RequestContext) {
|
||||
return runWithGitWorktreeOperationLock(params.worktreePath as string, context?.signal, () =>
|
||||
this.runRebaseFromBase(params, context)
|
||||
)
|
||||
}
|
||||
|
||||
private async runRebaseFromBase(params: Record<string, unknown>, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>): 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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
await this.git(['merge', '--abort'], worktreePath)
|
||||
} finally {
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
async abortRebase(params: Record<string, unknown>) {
|
||||
this.clearGitMutationReadCaches()
|
||||
const worktreePath = params.worktreePath as string
|
||||
try {
|
||||
await this.git(['rebase', '--abort'], worktreePath)
|
||||
} finally {
|
||||
this.clearGitMutationReadCaches()
|
||||
}
|
||||
}
|
||||
|
||||
async checkout(params: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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<RelayRepoLocation | undefined> {
|
||||
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<string, unknown>[]
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
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<string, unknown>, 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<string, unknown>) {
|
||||
return this.runWithGitReadCacheClear(() => addWorktreeOp(this.git.bind(this), params))
|
||||
}
|
||||
|
||||
async removeWorktree(params: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
return worktreeIsCleanOp(this.git.bind(this), params)
|
||||
}
|
||||
|
||||
async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
|
||||
return this.runWithGitReadCacheClear(() =>
|
||||
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities)
|
||||
)
|
||||
}
|
||||
}
|
||||
+41
-1408
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user