mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
* fix: address pr-bug-scan validated finding from #2947 Prepended `:(literal)` pathspec magic to each path passed to `git clean -ffdx --` in both `cleanUntrackedPaths` call sites, so untracked filenames containing pathspec glob magic (`*`, `?`, `[`, `]`) a * fix: literalize discard git pathspecs --------- Co-authored-by: orca-bug-scan-bot <orca-bug-scan-bot@stably.ai> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
634 lines
24 KiB
TypeScript
634 lines
24 KiB
TypeScript
/* eslint-disable max-lines -- Why: this relay handler centralizes the git RPC
|
|
protocol surface so local and SSH git behavior stay in one dispatch table. */
|
|
import { execFile } from 'child_process'
|
|
import { promisify } from 'util'
|
|
import * as path from 'path'
|
|
import type { RelayDispatcher } from './dispatcher'
|
|
import type { RelayContext } from './context'
|
|
import { expandTilde } from './context'
|
|
import { parseBranchDiff, parseWorktreeList } from './git-handler-utils'
|
|
import { parseNumstat } from '../shared/git-uncommitted-line-stats'
|
|
import {
|
|
computeDiff,
|
|
branchCompare as branchCompareOp,
|
|
branchDiffEntries,
|
|
validateGitExecArgs
|
|
} from './git-handler-ops'
|
|
import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops'
|
|
import {
|
|
commitChangesRelay,
|
|
addWorktreeOp,
|
|
removeWorktreeOp,
|
|
worktreeIsCleanOp
|
|
} from './git-handler-worktree-ops'
|
|
import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
|
import { resolveRelayPushTarget } from './git-handler-push-target'
|
|
import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error'
|
|
import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status'
|
|
import { assertGitPushTargetShape } from '../shared/git-push-target-validation'
|
|
import { getPublishTargetStatus, type GitCommandRunner } from '../shared/git-publish-target-status'
|
|
import { resolveGitRemoteRebaseSource } from '../shared/git-rebase-source'
|
|
import type { GitPushTarget } from '../shared/types'
|
|
import {
|
|
getEffectiveGitUpstreamStatus,
|
|
resolveEffectiveGitUpstream
|
|
} from '../shared/git-effective-upstream'
|
|
import { loadGitHistoryFromExecutor } from '../shared/git-history'
|
|
import { buildRelayCommandEnv } from './relay-command-env'
|
|
import {
|
|
removeSafeUntrackedDiscardTarget,
|
|
removeSafeUntrackedDiscardTargets
|
|
} from '../shared/git-discard-path-safety'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
const MAX_GIT_BUFFER = 10 * 1024 * 1024
|
|
const BULK_CHUNK_SIZE = 100
|
|
|
|
export class GitHandler {
|
|
private dispatcher: RelayDispatcher
|
|
|
|
// Why: RelayContext is accepted for protocol back-compat (see
|
|
// docs/relay-fs-allowlist-removal.md) but no longer consulted on git ops.
|
|
constructor(dispatcher: RelayDispatcher, _context: RelayContext) {
|
|
this.dispatcher = dispatcher
|
|
this.registerHandlers()
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
this.dispatcher.onRequest('git.status', (p) => this.getStatus(p))
|
|
this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p))
|
|
this.dispatcher.onRequest('git.history', (p) => this.history(p))
|
|
this.dispatcher.onRequest('git.commit', (p) => this.commit(p))
|
|
this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p))
|
|
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.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.fetchRemoteTrackingRef', (p) => this.fetchRemoteTrackingRef(p))
|
|
this.dispatcher.onRequest('git.push', (p) => this.push(p))
|
|
this.dispatcher.onRequest('git.pull', (p) => this.pull(p))
|
|
this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p))
|
|
this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p))
|
|
this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p))
|
|
this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p))
|
|
this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p))
|
|
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.renameCurrentBranch', (p) => this.renameCurrentBranch(p))
|
|
this.dispatcher.onRequest('git.exec', (p) => this.exec(p))
|
|
this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p))
|
|
}
|
|
|
|
private async git(
|
|
args: string[],
|
|
cwd: string,
|
|
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean }
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const env = buildRelayCommandEnv()
|
|
if (opts?.disableOptionalLocks) {
|
|
env.GIT_OPTIONAL_LOCKS = '0'
|
|
}
|
|
return execFileAsync('git', args, {
|
|
cwd: expandTilde(cwd),
|
|
env,
|
|
encoding: 'utf-8',
|
|
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER
|
|
})
|
|
}
|
|
|
|
private async gitBuffer(args: string[], cwd: string): Promise<Buffer> {
|
|
const { stdout } = (await execFileAsync('git', args, {
|
|
cwd,
|
|
env: buildRelayCommandEnv(),
|
|
encoding: 'buffer',
|
|
maxBuffer: MAX_GIT_BUFFER
|
|
})) as { stdout: Buffer }
|
|
return stdout
|
|
}
|
|
|
|
private async getStatus(params: Record<string, unknown>) {
|
|
return getStatusOp(this.git.bind(this), params)
|
|
}
|
|
|
|
private async checkIgnored(params: Record<string, unknown>) {
|
|
return checkIgnoredPathsOp(this.git.bind(this), params)
|
|
}
|
|
|
|
private 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
|
|
})
|
|
}
|
|
|
|
private async getDiff(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePath = params.filePath as string
|
|
// Why: filePath is relative to worktreePath and used in readWorkingFile via
|
|
// path.join. Without validation, ../../etc/passwd traverses outside the worktree.
|
|
const resolved = path.resolve(worktreePath, filePath)
|
|
const rel = path.relative(path.resolve(worktreePath), resolved)
|
|
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
throw new Error(`Path "${filePath}" resolves outside the worktree`)
|
|
}
|
|
return computeDiff(
|
|
this.gitBuffer.bind(this),
|
|
worktreePath,
|
|
filePath,
|
|
params.staged as boolean,
|
|
params.compareAgainstHead as boolean | undefined
|
|
)
|
|
}
|
|
|
|
private async stage(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePath = params.filePath as string
|
|
await this.git(['add', '--', filePath], worktreePath)
|
|
}
|
|
|
|
private async commit(
|
|
params: Record<string, unknown>
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
const worktreePath = params.worktreePath as string
|
|
const message = params.message as string
|
|
return commitChangesRelay(this.git.bind(this), worktreePath, message)
|
|
}
|
|
|
|
private async unstage(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePath = params.filePath as string
|
|
await this.git(['restore', '--staged', '--', filePath], worktreePath)
|
|
}
|
|
|
|
private async bulkStage(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePaths = params.filePaths as string[]
|
|
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
|
|
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
|
|
await this.git(['add', '--', ...chunk], worktreePath)
|
|
}
|
|
}
|
|
|
|
private async bulkUnstage(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePaths = params.filePaths as string[]
|
|
for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) {
|
|
const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE)
|
|
await this.git(['restore', '--staged', '--', ...chunk], worktreePath)
|
|
}
|
|
}
|
|
|
|
private async abortMerge(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
await this.git(['merge', '--abort'], worktreePath)
|
|
}
|
|
|
|
private async abortRebase(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
await this.git(['rebase', '--abort'], worktreePath)
|
|
}
|
|
|
|
private normalizeGitPathForCompare(filePath: string): string {
|
|
return filePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
}
|
|
|
|
private isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean {
|
|
const normalized = this.normalizeGitPathForCompare(filePath)
|
|
return trackedPaths.some((trackedPath) => {
|
|
const normalizedTracked = this.normalizeGitPathForCompare(trackedPath)
|
|
return normalizedTracked === normalized || normalizedTracked.startsWith(`${normalized}/`)
|
|
})
|
|
}
|
|
|
|
private assertInWorktree(worktreePath: string, filePath: string): string {
|
|
const resolved = path.resolve(worktreePath, filePath)
|
|
const rel = path.relative(path.resolve(worktreePath), resolved)
|
|
// Why: empty rel or '.' means the path IS the worktree root — rm -rf would
|
|
// delete the entire worktree. Reject along with parent-escaping paths.
|
|
if (
|
|
!rel ||
|
|
rel === '.' ||
|
|
rel === '..' ||
|
|
rel.startsWith(`..${path.sep}`) ||
|
|
path.isAbsolute(rel)
|
|
) {
|
|
throw new Error(`Path "${filePath}" resolves outside the worktree`)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
private async discard(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePath = params.filePath as string
|
|
|
|
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])
|
|
)
|
|
}
|
|
|
|
private async bulkDiscard(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const filePaths = params.filePaths as string[]
|
|
if (filePaths.length === 0) {
|
|
return
|
|
}
|
|
|
|
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
|
|
)
|
|
trackedPathSpecs.push(...stdout.split('\0').filter(Boolean))
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
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<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
return detectConflictOperation(worktreePath)
|
|
}
|
|
|
|
private async branchCompare(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const baseRef = params.baseRef as string
|
|
// Why: a baseRef starting with '-' would be interpreted as a flag to
|
|
// git rev-parse, potentially leaking environment variables or config.
|
|
if (baseRef.startsWith('-')) {
|
|
throw new Error('Base ref must not start with "-"')
|
|
}
|
|
const gitBound = this.git.bind(this)
|
|
return branchCompareOp(gitBound, worktreePath, baseRef, async (mergeBase, headOid) => {
|
|
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8;
|
|
// without it parseBranchDiff would yield C-style octal-escaped paths.
|
|
const { stdout } = await gitBound(
|
|
['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid],
|
|
worktreePath
|
|
)
|
|
const { stdout: numstat } = await gitBound(
|
|
['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid],
|
|
worktreePath
|
|
)
|
|
return parseBranchDiff(stdout, parseNumstat(numstat))
|
|
})
|
|
}
|
|
|
|
private async commitCompare(params: Record<string, unknown>) {
|
|
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<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: we only swallow the 'no upstream configured' error — that's an
|
|
// expected state, not a failure. Other errors (auth, corruption, network)
|
|
// should surface to the user so they can act on them.
|
|
if (isNoUpstreamError(error)) {
|
|
return { hasUpstream: false, ahead: 0, behind: 0 }
|
|
}
|
|
// Why: match fetch/push/pull normalization so execFile preamble and local
|
|
// paths don't leak to the renderer.
|
|
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
|
|
}
|
|
}
|
|
|
|
private async getBehindCommitsArePatchEquivalent(
|
|
worktreePath: string,
|
|
upstreamName: string
|
|
): Promise<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
|
|
}
|
|
}
|
|
|
|
private async fetch(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)
|
|
await this.git(['fetch', '--prune', pushTarget.remoteName], worktreePath)
|
|
return
|
|
}
|
|
await this.git(['fetch', '--prune'], worktreePath)
|
|
} catch (error) {
|
|
// Why: mirror the local gitFetch normalization so SSH users see the same
|
|
// actionable messages instead of raw git stderr (which varies across
|
|
// versions/locales and may embed credentials).
|
|
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
|
}
|
|
}
|
|
|
|
private async fetchRemoteTrackingRef(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const remote = params.remote
|
|
const branch = params.branch
|
|
const ref = params.ref
|
|
if (typeof remote !== 'string' || typeof branch !== 'string' || typeof ref !== 'string') {
|
|
throw new Error('Invalid remote-tracking fetch request.')
|
|
}
|
|
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(['fetch', '--no-tags', remote, `+refs/heads/${branch}:${ref}`], worktreePath)
|
|
} catch (error) {
|
|
// Why: create-worktree needs a write-capable fetch, but generic git.exec
|
|
// intentionally rejects fetch. This narrow RPC keeps the relay allowlist
|
|
// tight while preserving the same safe error normalization as git.fetch.
|
|
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
|
|
}
|
|
}
|
|
|
|
private async push(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
// Why: mirror src/main/git/remote.ts. Push to a configured upstream when
|
|
// present so SSH worktrees with non-origin targets do not get repointed.
|
|
void params.publish
|
|
try {
|
|
const target = await resolveRelayPushTarget(
|
|
this.git.bind(this),
|
|
worktreePath,
|
|
params.pushTarget
|
|
)
|
|
const args = [
|
|
'push',
|
|
...(params.forceWithLease === true ? ['--force-with-lease'] : []),
|
|
'--set-upstream',
|
|
...(target ? [target.remote, target.refspec] : ['origin', 'HEAD'])
|
|
]
|
|
await this.git(args, worktreePath)
|
|
} catch (error) {
|
|
// Why: mirror the local gitPush normalization so SSH users see the same
|
|
// "non-fast-forward / pull first" guidance instead of raw git stderr.
|
|
throw new Error(normalizeGitErrorMessage(error, 'push'))
|
|
}
|
|
}
|
|
|
|
private async pullWithArgs(params: Record<string, unknown>, pullArgs: string[]) {
|
|
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)
|
|
await this.git(
|
|
['pull', ...pullArgs, pushTarget.remoteName, pushTarget.branchName],
|
|
worktreePath
|
|
)
|
|
return
|
|
}
|
|
const upstream = await resolveEffectiveGitUpstream((args) => this.git(args, worktreePath))
|
|
if (upstream && !upstream.isConfiguredUpstream) {
|
|
// Why: legacy Orca branches may still track origin/main while pushes
|
|
// target origin/<branch>. Pull the same effective branch the UI reports.
|
|
await this.git(
|
|
['pull', ...pullArgs, upstream.remoteName, upstream.branchName],
|
|
worktreePath
|
|
)
|
|
return
|
|
}
|
|
await this.git(['pull', ...pullArgs], worktreePath)
|
|
} catch (error) {
|
|
// Why: mirror the local gitPull normalization so SSH users see the same
|
|
// actionable messages instead of raw git stderr.
|
|
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
|
}
|
|
}
|
|
|
|
private async pull(params: Record<string, unknown>) {
|
|
// Why: plain `git pull` uses the user's configured pull strategy (merge by
|
|
// default) so diverged branches reconcile instead of erroring out.
|
|
await this.pullWithArgs(params, [])
|
|
}
|
|
|
|
private async fastForward(params: Record<string, unknown>) {
|
|
await this.pullWithArgs(params, ['--ff-only'])
|
|
}
|
|
|
|
private async rebaseFromBase(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const baseRef = params.baseRef as string
|
|
try {
|
|
const source = await resolveGitRemoteRebaseSource(
|
|
((args) => this.git(args, worktreePath)) as GitCommandRunner,
|
|
baseRef
|
|
)
|
|
await this.git(['pull', '--rebase', source.remoteName, source.branchName], worktreePath)
|
|
} catch (error) {
|
|
throw new Error(normalizeGitErrorMessage(error, 'pull'))
|
|
}
|
|
}
|
|
|
|
private async branchDiff(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
const baseRef = params.baseRef as string
|
|
if (baseRef.startsWith('-')) {
|
|
throw new Error('Base ref must not start with "-"')
|
|
}
|
|
return branchDiffEntries(
|
|
this.git.bind(this),
|
|
this.gitBuffer.bind(this),
|
|
worktreePath,
|
|
baseRef,
|
|
{
|
|
includePatch: params.includePatch as boolean | undefined,
|
|
filePath: params.filePath as string | undefined,
|
|
oldPath: params.oldPath as string | undefined
|
|
}
|
|
)
|
|
}
|
|
|
|
private async commitDiff(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath as string
|
|
return commitDiffEntry(this.gitBuffer.bind(this), worktreePath, {
|
|
commitOid: params.commitOid as string,
|
|
parentOid: params.parentOid as string | null | undefined,
|
|
filePath: params.filePath as string,
|
|
oldPath: params.oldPath as string | undefined
|
|
})
|
|
}
|
|
|
|
private async exec(params: Record<string, unknown>) {
|
|
const args = params.args as string[]
|
|
const cwd = params.cwd as string
|
|
|
|
validateGitExecArgs(args)
|
|
const { stdout, stderr } = await this.git(args, cwd)
|
|
return { stdout, stderr }
|
|
}
|
|
|
|
private async renameCurrentBranch(params: Record<string, unknown>) {
|
|
const worktreePath = params.worktreePath
|
|
const newBranch = params.newBranch
|
|
if (typeof worktreePath !== 'string' || typeof newBranch !== 'string') {
|
|
throw new Error('Invalid branch rename request.')
|
|
}
|
|
if (newBranch.startsWith('-')) {
|
|
throw new Error('Branch name must not start with "-".')
|
|
}
|
|
try {
|
|
// Why: generic git.exec intentionally blocks destructive branch flags.
|
|
// This narrow RPC permits only the already-checked current-branch rename.
|
|
await this.git(['check-ref-format', '--branch', newBranch], worktreePath)
|
|
await this.git(['branch', '-m', newBranch], worktreePath)
|
|
} catch (error) {
|
|
throw new Error(normalizeGitErrorMessage(error))
|
|
}
|
|
}
|
|
|
|
private async 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 listWorktrees(params: Record<string, unknown>) {
|
|
const repoPath = params.repoPath as string
|
|
try {
|
|
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath)
|
|
return parseWorktreeList(stdout)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
private async addWorktree(params: Record<string, unknown>) {
|
|
return addWorktreeOp(this.git.bind(this), params)
|
|
}
|
|
|
|
private async removeWorktree(params: Record<string, unknown>) {
|
|
return removeWorktreeOp(this.git.bind(this), params)
|
|
}
|
|
|
|
private async worktreeIsClean(params: Record<string, unknown>) {
|
|
return worktreeIsCleanOp(this.git.bind(this), params)
|
|
}
|
|
}
|