Files
orca/src/main/runtime/orca-runtime-git.ts
T
NeilandOrca 2100fb2553 fix(runtime): cap remote git.diff and file previews at the transport budget (#14160)
* fix(runtime): cap remote git.diff and file previews at the transport budget

A remote or mobile user who opens the diff of a large image loses their whole
WebSocket, not just that request: the E2EE channel closes with 1013 when a reply
exceeds the 4 MiB outbound envelope. Two producers can exceed it unaided.

git.diff/branchDiff/commitDiff cap text with MAX_RENDERED_DIFF_COMBINED_CHARACTERS
(6M chars) -- a *renderer* budget that sits above the transport limit -- and return
base64 for previewable binaries bounded only by MAX_GIT_SHOW_BYTES, so a 10 MiB PNG
changed in place is ~26.7 MiB in one envelope. files.readPreview inlines base64 up
to 10 MiB, and mobile calls it for every image tab.

Both now measure against a budget derived from the outbound limit. The check sits in
orca-runtime-git.ts, downstream of the dedupe and of both the SSH-provider and local
branches, so a payload forwarded verbatim by an old relay is covered by the same code
and src/relay needs no change. Local and in-process callers pass no budget and keep
full fidelity.

Measuring raw bytes would not work, which is the whole reason this needs a module.
JSON escaping turns one control byte into six (\u00XX), and binary-buffer.ts sniffs
only for NUL in the first 8 KiB -- so a NUL-free file of 0x01-0x1f bytes is classified
as *text*, would pass a raw-byte cap, and would then blow the envelope. The budget is
escape-aware, with a three-branch fast path that keeps normal diffs at two native
byteLength calls and scans only the ambiguous band.

The SSH branch of readFileExplorerPreview had the same raw-vs-escaped gap: its stat
gate sizes base64 binaries, but text crossed unbounded. It now honours the same
decoded-text limit the local branch already enforced.

No wire change: GitDiffResult is untouched -- no third kind, no new field. Old clients
see an error for one request instead of a dropped connection. diff_too_large joins the
structured passthrough codes and lands on an existing error arm in both mobile
consumers and the desktop remote path; file_too_large was already handled on both.

Instruments the 1013 close, which nothing measured before, so the incidence this cap
is meant to drive to zero is finally observable. `emitter` separates a producer size
bug from a wedged link.

Known regression: remote image previews between ~3.096 and ~3.146 MB now return
file_too_large. They only intermittently worked before -- above ~3.0 MB they killed
the socket -- so this trades intermittent connection loss for a consistent error.

Test: 10281 passed in src/main/runtime + src/shared + src/main/git; mobile 3427
passed. Each of the six budget-enforcement sites is independently mutation-killed.
Escaping fixtures cover newline-dense, control-char, CJK, lone-surrogate and base64
content against native JSON.stringify. tsc clean for node, web and cli; oxlint clean.

Co-authored-by: Orca <help@stably.ai>

* fix(runtime): harden remote reply transport budgets

* test(runtime): cover desktop remote preview budgets

* test(runtime): close telemetry review gaps

* chore(shared): repoint budget imports after the shared/types barrel removal

Upstream #14447 dropped the shared/types barrel; GitDiffResult now lives in
git-diff-compare-types and GlobalSettings in global-settings-types.

Co-authored-by: Orca <help@stably.ai>

* fix(ssh): surface an over-cap preview read as file_too_large

The stream reader aborts an over-cap read with StreamProtocolError, whose numeric
code falls through mapRuntimeError to a generic runtime_error carrying the raw
"Reported totalSize N exceeds client cap M" string. Neither preview client
recognizes that: runtime-file-client.ts and mobile-file-preview-response.ts both
key on file_too_large. It also made the two file_too_large guards directly below
the read unreachable on the streaming path.

Gives the cap its own error type so the caller can translate it, keeping the
bandwidth saving the cap exists for. A genuine protocol fault still propagates
unmasked.

Found by the readiness review. Mutation-verified: removing the translation fails
exactly the new test.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-14 00:24:57 -07:00

1027 lines
39 KiB
TypeScript

/* eslint-disable max-lines -- Why: runtime git dispatch stays in one boundary so local, SSH, and runtime-environment behavior remains comparable. */
import type {
GitBranchCompareResult,
GitCommitCompareResult,
GitDiffResult
} from '../../shared/git-diff-compare-types'
import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync'
import type {
GitConflictOperation,
GitStagingArea,
GitStatusResult,
GitUpstreamStatus
} from '../../shared/git-status-types'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { GitPushTarget, GitWorktreeInfo, Worktree } from '../../shared/worktree/types'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import { assertGitDiffWithinTransportBudget } from '../../shared/git-diff-transport-budget'
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
type ResolvedSourceControlAiGenerationParams
} from '../../shared/source-control-ai'
import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables'
import type { SourceControlAiOperation } from '../../shared/source-control-ai-types'
import type { GitProviderStatusOptions } from '../providers/types'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
import {
abortMerge,
abortRebase,
bulkDiscardChanges,
bulkStageFiles,
bulkUnstageFiles,
commitChanges,
detectConflictOperation,
discardChanges,
getBranchCompare,
getBranchDiff,
getCommitCompare,
getCommitDiff,
getDiff,
getStagedCommitContext,
getStatus as getGitStatus,
getSubmoduleStatus as getGitSubmoduleStatus,
stageFile,
unstageFile
} from '../git/status'
import { checkoutBranch, listLocalBranches } from '../git/checkout'
import type { RuntimeGitCheckoutResult, RuntimeGitLocalBranches } from '../../shared/runtime-types'
import { getHistory as getGitHistory } from '../git/history'
import { getUpstreamStatus } from '../git/upstream'
import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote'
import { gitSyncForkDefaultBranch } from '../git/fork-sync'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { checkIgnoredPaths } from '../git/check-ignored-paths'
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
import {
cancelGenerateCommitMessageLocal,
cancelGeneratePullRequestFieldsLocal,
discoverCommitMessageModelsLocal,
discoverCommitMessageModelsRemote,
generateCommitMessageFromContext,
generatePullRequestFieldsFromContext,
resolveCommitMessageSettings,
type CommitMessageGenerationTarget,
type DiscoverCommitMessageModelsResult,
type GenerateCommitMessageResult,
type GeneratePullRequestFieldsResult
} from '../text-generation/commit-message-text-generation'
import type {
CommitMessageAgentEnvironmentResolvers,
CommitMessageAgentRuntimeTarget
} from '../text-generation/commit-message-agent-environment'
import { prepareLocalCommitMessageAgentEnv } from '../text-generation/commit-message-agent-environment'
import { getPullRequestDraftContext } from '../text-generation/pull-request-context'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner'
import type { GitRuntimeOptions } from '../git/git-runtime-options'
import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template'
import {
loadPullRequestLinkedIssue,
type PullRequestLinkedIssueMeta
} from '../source-control/pull-request-linked-issue'
import type { HostedReviewProvider } from '../../shared/hosted-review'
export type ResolvedRuntimeGitWorktree = Worktree & { git: GitWorktreeInfo }
type RuntimeCommitMessageSettingsOverride = Partial<
Pick<GlobalSettings, 'commitMessageAi' | 'sourceControlAi' | 'agentCmdOverrides'>
> & {
commitMessageDiscoveryHostKey?: string
sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams
}
function getRuntimeGitGenerationSettings(
settings: GlobalSettings,
settingsOverride: RuntimeCommitMessageSettingsOverride | undefined,
operation: SourceControlAiOperation
): GlobalSettings {
const mergedSettings = {
...settings,
...settingsOverride
}
if (
settingsOverride?.commitMessageAi !== undefined &&
settingsOverride.sourceControlAi === undefined
) {
mergedSettings.sourceControlAi = mergeLegacyCommitMessageAiIntoSourceControlAi(
settings.sourceControlAi,
settingsOverride.commitMessageAi,
{ pullRequestInstructionsFromLegacy: operation === 'pullRequest' }
)
}
return mergedSettings
}
function normalizeRuntimeGitRelativePath(filePath: string): string {
const relativePath = normalizeRuntimeRelativePath(filePath)
if (relativePath === '') {
// Why: git mutation APIs treat an empty pathspec as the worktree root;
// runtime RPC must never let malformed file paths discard whole worktrees.
throw new Error('invalid_relative_path')
}
return relativePath
}
type RuntimeGitTarget = {
worktree: ResolvedRuntimeGitWorktree
repo?: Repo
connectionId?: string
localGitOptions?: GitRuntimeOptions
}
function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions {
return target.connectionId ? {} : (target.localGitOptions ?? {})
}
function localAgentRuntimeTargetForTarget(
target: RuntimeGitTarget
): CommitMessageAgentRuntimeTarget {
const wslDistro = localGitOptionsForTarget(target).wslDistro
return wslDistro ? { runtime: 'wsl', wslDistro } : { runtime: 'host' }
}
function localTextGenerationTargetForTarget(
target: RuntimeGitTarget,
env?: NodeJS.ProcessEnv
): Extract<CommitMessageGenerationTarget, { kind: 'local' }> {
const wslDistro = localGitOptionsForTarget(target).wslDistro
return {
kind: 'local',
cwd: target.worktree.path,
...(wslDistro ? { wslDistro } : {}),
...(env ? { env } : {})
}
}
export type RuntimeGitCommandHost = {
resolveRuntimeGitTarget(selector: string): Promise<RuntimeGitTarget>
getRuntimeSettings(): GlobalSettings
getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined
/**
* Live linked-issue read by worktree id. Resolved worktrees come from a
* short-TTL cache, so link/unlink would otherwise lag generation; hosts that
* implement this are authoritative, including the `null` unlinked answer.
* Return `undefined` when metadata is unavailable (store not ready) so the
* caller keeps the resolved worktree's cached value instead of reading it as
* unlinked.
*/
getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined
getWorktreeLinkedIssueMeta?(worktreeId: string): PullRequestLinkedIssueMeta | null | undefined
}
export class RuntimeGitCommands {
constructor(private readonly host: RuntimeGitCommandHost) {}
private linkedIssueForTarget(target: RuntimeGitTarget): number | null | undefined {
const live = this.host.getWorktreeLinkedIssue?.(target.worktree.id)
// Why: `undefined` means the host could not answer, not "unlinked".
return live === undefined ? target.worktree.linkedIssue : live
}
private linkedIssueMetaForTarget(target: RuntimeGitTarget): PullRequestLinkedIssueMeta | null {
const live = this.host.getWorktreeLinkedIssueMeta?.(target.worktree.id)
if (live !== undefined) {
return live
}
const liveGitHubIssue = this.host.getWorktreeLinkedIssue?.(target.worktree.id)
return {
linkedIssue: liveGitHubIssue === undefined ? target.worktree.linkedIssue : liveGitHubIssue,
linkedGitLabIssue: target.worktree.linkedGitLabIssue,
linkedWorkItem: target.worktree.linkedWorkItem
}
}
async getRuntimeGitStatus(
worktreeSelector: string,
options?: GitProviderStatusOptions
): Promise<GitStatusResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return options
? provider.getStatus(target.worktree.path, options)
: provider.getStatus(target.worktree.path)
}
const gitOptions = localGitOptionsForTarget(target)
// Why: Git can't ignore a shared symlink under a directory-only rule, so tell
// status which untracked entries are Orca's own artifacts (issue #10451).
const sharedLinkPaths = target.repo ? getWorktreeSharedLinkPaths(target.repo) : []
const sharedOptions = sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}
return options
? getGitStatus(target.worktree.path, { ...options, ...gitOptions, ...sharedOptions })
: getGitStatus(target.worktree.path, { ...gitOptions, ...sharedOptions })
}
async getRuntimeGitSubmoduleStatus(
worktreeSelector: string,
submodulePath: string,
area: GitStagingArea = 'unstaged'
): Promise<GitStatusResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getSubmoduleStatus(target.worktree.path, submodulePath, area)
}
return getGitSubmoduleStatus(target.worktree.path, submodulePath, {
...localGitOptionsForTarget(target),
...(area === 'staged' ? { staged: true } : {})
})
}
async checkRuntimeGitIgnoredPaths(
worktreeSelector: string,
relativePaths: string[]
): Promise<string[]> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.checkIgnoredPaths(target.worktree.path, relativePaths)
}
return checkIgnoredPaths(target.worktree.path, relativePaths, localGitOptionsForTarget(target))
}
async getRuntimeGitHistory(
worktreeSelector: string,
options: GitHistoryOptions = {}
): Promise<GitHistoryResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getHistory(target.worktree.path, options)
}
return getGitHistory(target.worktree.path, {
...options,
...localGitOptionsForTarget(target)
})
}
async getRuntimeGitConflictOperation(worktreeSelector: string): Promise<GitConflictOperation> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.detectConflictOperation(target.worktree.path)
}
return detectConflictOperation(target.worktree.path)
}
async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.abortMerge(target.worktree.path)
return { ok: true }
}
await abortMerge(target.worktree.path, localGitOptionsForTarget(target))
return { ok: true }
}
async abortRuntimeGitRebase(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.abortRebase(target.worktree.path)
return { ok: true }
}
await abortRebase(target.worktree.path, localGitOptionsForTarget(target))
return { ok: true }
}
async checkoutRuntimeGitBranch(
worktreeSelector: string,
branch: string
): Promise<RuntimeGitCheckoutResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.checkoutBranch(target.worktree.path, branch)
return { ok: true, branch }
}
await checkoutBranch(target.worktree.path, branch, localGitOptionsForTarget(target))
return { ok: true, branch }
}
async listRuntimeGitLocalBranches(worktreeSelector: string): Promise<RuntimeGitLocalBranches> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.listLocalBranches(target.worktree.path)
}
return listLocalBranches(target.worktree.path, localGitOptionsForTarget(target))
}
// Why: the budget is enforced here, after both branches, so an SSH payload forwarded verbatim from
// an older relay is capped too.
async getRuntimeGitDiff(
worktreeSelector: string,
filePath: string,
staged: boolean,
compareAgainstHead?: boolean,
maxContentBytes?: number
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return assertGitDiffWithinTransportBudget(
await provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead),
maxContentBytes
)
}
return assertGitDiffWithinTransportBudget(
await getDiff(
target.worktree.path,
relativePath,
staged,
compareAgainstHead,
localGitOptionsForTarget(target)
),
maxContentBytes
)
}
async getRuntimeGitBranchCompare(
worktreeSelector: string,
baseRef: string
): Promise<GitBranchCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getBranchCompare(target.worktree.path, baseRef)
}
return getBranchCompare(target.worktree.path, baseRef, localGitOptionsForTarget(target))
}
async getRuntimeGitCommitCompare(
worktreeSelector: string,
commitId: string
): Promise<GitCommitCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getCommitCompare(target.worktree.path, commitId)
}
return getCommitCompare(target.worktree.path, commitId, localGitOptionsForTarget(target))
}
async getRuntimeGitUpstreamStatus(
worktreeSelector: string,
pushTarget?: GitPushTarget
): Promise<GitUpstreamStatus> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getUpstreamStatus(target.worktree.path, pushTarget)
}
return getUpstreamStatus(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
}
async fetchRuntimeGit(
worktreeSelector: string,
pushTarget?: GitPushTarget
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.fetchRemote(target.worktree.path, pushTarget)
return { ok: true }
}
await gitFetch(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
return { ok: true }
}
async syncRuntimeGitForkDefaultBranch(
worktreeSelector: string,
expectedUpstream: GitForkSyncExpectedUpstream
): Promise<GitForkSyncResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.syncForkDefaultBranch(target.worktree.path, expectedUpstream)
}
return gitSyncForkDefaultBranch(
target.worktree.path,
expectedUpstream,
localGitOptionsForTarget(target)
)
}
async pullRuntimeGit(
worktreeSelector: string,
pushTarget?: GitPushTarget
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.pullBranch(target.worktree.path, pushTarget)
return { ok: true }
}
await gitPull(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
return { ok: true }
}
async fastForwardRuntimeGit(
worktreeSelector: string,
pushTarget?: GitPushTarget
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.fastForwardBranch(target.worktree.path, pushTarget)
return { ok: true }
}
await gitFastForward(target.worktree.path, pushTarget, localGitOptionsForTarget(target))
return { ok: true }
}
async rebaseRuntimeGitFromBase(worktreeSelector: string, baseRef: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.rebaseFromBase(target.worktree.path, baseRef)
return { ok: true }
}
await gitPullRebaseFromBase(target.worktree.path, baseRef, localGitOptionsForTarget(target))
return { ok: true }
}
async pushRuntimeGit(
worktreeSelector: string,
publish?: boolean,
pushTarget?: GitPushTarget,
forceWithLease?: boolean
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.pushBranch(target.worktree.path, publish === true, pushTarget, {
forceWithLease: forceWithLease === true
})
return { ok: true }
}
await gitPush(target.worktree.path, publish === true, pushTarget, {
forceWithLease: forceWithLease === true,
...localGitOptionsForTarget(target)
})
return { ok: true }
}
async getRuntimeGitBranchDiff(
worktreeSelector: string,
compare: { mergeBase: string; headOid: string },
filePath: string,
oldPath?: string,
maxContentBytes?: number
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const oldRelativePath = oldPath ? normalizeRuntimeGitRelativePath(oldPath) : undefined
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
const results = await provider.getBranchDiff(target.worktree.path, compare.mergeBase, {
includePatch: true,
headOid: compare.headOid,
filePath: relativePath,
oldPath: oldRelativePath
})
return assertGitDiffWithinTransportBudget(
results[0] ?? {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
},
maxContentBytes
)
}
return assertGitDiffWithinTransportBudget(
await getBranchDiff(
target.worktree.path,
{
mergeBase: compare.mergeBase,
headOid: compare.headOid,
filePath: relativePath,
oldPath: oldRelativePath
},
localGitOptionsForTarget(target)
),
maxContentBytes
)
}
async getRuntimeGitCommitDiff(
worktreeSelector: string,
args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string },
maxContentBytes?: number
): Promise<GitDiffResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeRelativePath(args.filePath)
const oldRelativePath = args.oldPath ? normalizeRuntimeRelativePath(args.oldPath) : undefined
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return assertGitDiffWithinTransportBudget(
await provider.getCommitDiff(target.worktree.path, {
commitOid: args.commitOid,
parentOid: args.parentOid,
filePath: relativePath,
oldPath: oldRelativePath
}),
maxContentBytes
)
}
return assertGitDiffWithinTransportBudget(
await getCommitDiff(
target.worktree.path,
{
commitOid: args.commitOid,
parentOid: args.parentOid,
filePath: relativePath,
oldPath: oldRelativePath
},
localGitOptionsForTarget(target)
),
maxContentBytes
)
}
async commitRuntimeGit(
worktreeSelector: string,
message: string
): Promise<{ success: boolean; error?: string }> {
if (message.trim().length === 0) {
throw new Error('Commit message is required')
}
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.commit(target.worktree.path, message)
}
return commitChanges(target.worktree.path, message, localGitOptionsForTarget(target))
}
async generateRuntimeCommitMessage(
worktreeSelector: string,
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GenerateCommitMessageResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = settingsOverride?.sourceControlAiResolvedParams
? { ok: true as const, params: settingsOverride.sourceControlAiResolvedParams }
: resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'commitMessage'
),
discoveryHostKey,
'commitMessage',
target.repo ?? null
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
return {
success: false,
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
}
}
let context: CommitMessageDraftContext | null
try {
context = await provider.getStagedCommitContext(target.worktree.path)
} catch (error) {
console.error('[runtime-git] Failed to read remote staged commit context:', error)
return { success: false, error: 'Failed to read staged changes.' }
}
if (!context) {
return { success: false, error: 'No staged changes to summarize.' }
}
context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target))
return generateCommitMessageFromContext(context, resolvedSettings.params, {
kind: 'remote',
cwd: target.worktree.path,
execute: (plan, cwd, timeoutMs, operation) =>
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
})
}
let context: CommitMessageDraftContext | null
try {
context = await getStagedCommitContext(target.worktree.path, localGitOptionsForTarget(target))
} catch (error) {
console.error('[runtime-git] Failed to read staged commit context:', error)
return { success: false, error: 'Failed to read staged changes.' }
}
if (!context) {
return { success: false, error: 'No staged changes to summarize.' }
}
context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target))
const localEnv = await prepareLocalCommitMessageAgentEnv(
resolvedSettings.params.agentId,
this.host.getCommitMessageAgentEnvironment?.(),
localAgentRuntimeTargetForTarget(target)
)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
return generateCommitMessageFromContext(
context,
resolvedSettings.params,
localTextGenerationTargetForTarget(target, localEnv.env)
)
}
async cancelRuntimeGenerateCommitMessage(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
await provider?.cancelGenerateCommitMessage(target.worktree.path, 'commit-message')
return { ok: true }
}
cancelGenerateCommitMessageLocal(target.worktree.path)
return { ok: true }
}
async generateRuntimePullRequestFields(
worktreeSelector: string,
input: {
base: string
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
},
settingsOverride?: RuntimeCommitMessageSettingsOverride
): Promise<GeneratePullRequestFieldsResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const discoveryHostKey =
settingsOverride?.commitMessageDiscoveryHostKey ??
getCommitMessageModelDiscoveryHostKey(target.connectionId ?? null)
const resolvedSettings = settingsOverride?.sourceControlAiResolvedParams
? { ok: true as const, params: settingsOverride.sourceControlAiResolvedParams }
: resolveCommitMessageSettings(
getRuntimeGitGenerationSettings(
this.host.getRuntimeSettings(),
settingsOverride,
'pullRequest'
),
discoveryHostKey,
'pullRequest',
target.repo ?? null
)
if (!resolvedSettings.ok) {
return { success: false, error: resolvedSettings.error }
}
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId && !provider) {
return {
success: false,
error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
}
}
const issueMeta = this.linkedIssueMetaForTarget(target)
const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({
meta: issueMeta,
provider: input.provider,
repoPath: target.worktree.path,
connectionId: target.connectionId,
localGitOptions: localGitOptionsForTarget(target)
})
let context: Awaited<ReturnType<typeof getPullRequestDraftContext>>
try {
const currentBody = await resolveHostedReviewBodyForGeneration({
body: input.body,
repoPath: target.worktree.path,
connectionId: target.connectionId,
provider: input.provider,
useTemplate: input.useTemplate
})
context = target.connectionId
? await getPullRequestDraftContext((argv) => provider!.exec(argv, target.worktree.path), {
base: input.base,
currentTitle: input.title,
currentBody,
currentDraft: input.draft
})
: await getPullRequestDraftContext(
(argv, options) =>
gitExecFileAsync(argv, {
cwd: target.worktree.path,
...localGitOptionsForTarget(target),
...options
}),
{
base: input.base,
currentTitle: input.title,
currentBody,
currentDraft: input.draft
}
)
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.'
}
}
if (!context) {
return { success: false, error: 'No branch changes to summarize.' }
}
const linkedIssueDetails = await linkedIssueDetailsPromise
context = {
...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue),
...(input.provider ? { provider: input.provider } : {}),
...(linkedIssueDetails ? { linkedIssueDetails } : {})
}
if (target.connectionId) {
return generatePullRequestFieldsFromContext(context, resolvedSettings.params, {
kind: 'remote',
cwd: target.worktree.path,
execute: (plan, cwd, timeoutMs, operation) =>
provider!.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
missingBinaryLocation: 'remote PATH'
})
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
resolvedSettings.params.agentId,
this.host.getCommitMessageAgentEnvironment?.(),
localAgentRuntimeTargetForTarget(target)
)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
return generatePullRequestFieldsFromContext(
context,
resolvedSettings.params,
localTextGenerationTargetForTarget(target, localEnv.env)
)
}
async cancelRuntimeGeneratePullRequestFields(worktreeSelector: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
await provider?.cancelGenerateCommitMessage(target.worktree.path, 'pull-request-fields')
return { ok: true }
}
cancelGeneratePullRequestFieldsLocal(target.worktree.path)
return { ok: true }
}
async discoverRuntimeCommitMessageModels(
worktreeSelector: string,
agentId: string,
settingsOverride?: Pick<RuntimeCommitMessageSettingsOverride, 'agentCmdOverrides'>
): Promise<DiscoverCommitMessageModelsResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const typedAgentId = agentId as TuiAgent
const agentCommandOverride =
settingsOverride?.agentCmdOverrides?.[typedAgentId] ??
this.host.getRuntimeSettings().agentCmdOverrides?.[typedAgentId]
if (target.connectionId) {
const provider = getSshGitProvider(target.connectionId)
if (!provider) {
return {
success: false,
error: `No git provider for connection "${target.connectionId}"`
}
}
return discoverCommitMessageModelsRemote(
typedAgentId,
target.worktree.path,
(plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs),
agentCommandOverride
)
}
const localEnv = await prepareLocalCommitMessageAgentEnv(
typedAgentId,
this.host.getCommitMessageAgentEnvironment?.(),
localAgentRuntimeTargetForTarget(target)
)
if (!localEnv.ok) {
return { success: false, error: localEnv.error }
}
const localOptions = localGitOptionsForTarget(target)
return localOptions.wslDistro
? discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride, {
cwd: target.worktree.path,
wslDistro: localOptions.wslDistro
})
: discoverCommitMessageModelsLocal(typedAgentId, localEnv.env, agentCommandOverride)
}
async stageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.stageFile(target.worktree.path, relativePath)
return { ok: true }
}
await stageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target))
return { ok: true }
}
async unstageRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.unstageFile(target.worktree.path, relativePath)
return { ok: true }
}
await unstageFile(target.worktree.path, relativePath, localGitOptionsForTarget(target))
return { ok: true }
}
async bulkStageRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.bulkStageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkStageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target))
return { ok: true }
}
async bulkUnstageRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.bulkUnstageFiles(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkUnstageFiles(target.worktree.path, relativePaths, localGitOptionsForTarget(target))
return { ok: true }
}
async bulkDiscardRuntimeGitPaths(
worktreeSelector: string,
filePaths: string[]
): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePaths = filePaths.map((path) => normalizeRuntimeGitRelativePath(path))
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.bulkDiscardChanges(target.worktree.path, relativePaths)
return { ok: true }
}
await bulkDiscardChanges(target.worktree.path, relativePaths, localGitOptionsForTarget(target))
return { ok: true }
}
async discardRuntimeGitPath(worktreeSelector: string, filePath: string): Promise<{ ok: true }> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const relativePath = normalizeRuntimeGitRelativePath(filePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
await provider.discardChanges(target.worktree.path, relativePath)
return { ok: true }
}
await discardChanges(target.worktree.path, relativePath, localGitOptionsForTarget(target))
return { ok: true }
}
async getRuntimeGitRemoteFileUrl(
worktreeSelector: string,
relativePath: string,
line: number
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeGitRelativePath(relativePath)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
}
await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path })
return getRemoteFileUrl(target.worktree.path, normalizedRelativePath, line)
}
async getRuntimeGitRemoteCommitUrl(
worktreeSelector: string,
sha: string
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
if (target.connectionId) {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getRemoteCommitUrl(target.worktree.path, sha)
}
await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path })
return getRemoteCommitUrl(target.worktree.path, sha)
}
}