Files
orca/src/main/runtime/runtime-git-diff-commands.ts
T
Neil d5750648c2 fix(runtime): route runtime Git by resolved execution host, not repo connectionId (#18307)
`RuntimeGitTarget` carried `connectionId?: string` and no host id, so `undefined`
spelled three different answers at once — "runtime: host", "unresolved", and
"genuinely local". Its sole resolver read `store.getRepo(worktree.repoId)?.connectionId`
and never looked at `worktree.hostId`, which outranks every repo row, so one
arbitrarily chosen row decided the execution host for 36 downstream dispatches.

The target now carries `executionHostId: ExecutionHostId` (never null, never
optional), resolved through the shared rule that landed with #17909/#17919 and
dispatched through the host-keyed routes from #18296. Dispatch sites call
`requireRuntimeGitProvider`, where `null` means exactly one thing: the host is
`local` and the command runs here as free functions.

Four answers that used to collapse into one:

- `ssh:x` with a rival row on `ssh:y` — routes to x. Previously the first row won,
  which is the reproduced cross-host leak.
- `local` with a surviving `connectionId` — a row contradicting itself; no SSH
  connection is handed out.
- `runtime:<env>` — throws `ExecutionHostNotDispatchableError`. Its repo row's
  connection names a target in the *server's* namespace; dialling it here reaches a
  same-named target on this client.
- rival rows disagreeing with no worktree host — `worktree_execution_host_unresolved`,
  matching the launch path rather than guessing a row.

An unreachable SSH host still throws `SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE`; loss of
contact is never evidence of locality (docs/reference/ssh-execution-boundary.md).

`resolveWorktreeLaunchHost` keeps its exact signature and now delegates to
`resolveWorktreeHostRouting`, the same resolution answering "which host is this on"
rather than "what may this client dial" — the git target needs the first question
because `local` and `runtime:` are two different non-SSH answers.

No wire change: `RuntimeGitTarget` is main-process internal, and the SSH and local
model-discovery host keys are byte-identical to before.

`RuntimeFileTarget` has the same defect in ~30 filesystem dispatches and is
deliberately left for a follow-up.
2026-09-02 19:26:07 -07:00

198 lines
6.6 KiB
TypeScript

import type {
GitBranchCompareResult,
GitCommitCompareResult,
GitDiffResult
} from '../../shared/git-diff-compare-types'
import { assertGitDiffWithinTransportBudget } from '../../shared/git-diff-transport-budget'
import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo'
import {
getBranchCompare,
getBranchDiff,
getCommitCompare,
getCommitDiff,
getDiff
} from '../git/status'
import { awaitWindowsHostGitEnvironmentReady } from '../git/runner'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import { normalizeRuntimeRelativePath } from './runtime-relative-paths'
import {
localGitOptionsForTarget,
normalizeRuntimeGitRelativePath,
requireRuntimeGitProvider,
type RuntimeGitCommandHost
} from './runtime-git-command-target'
export class RuntimeGitDiffCommands {
constructor(private readonly host: RuntimeGitCommandHost) {}
// Why: cap both local and forwarded SSH payloads after execution-host dispatch.
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 = requireRuntimeGitProvider(target)
if (provider) {
return assertGitDiffWithinTransportBudget(
await provider.getDiff(target.worktree.path, relativePath, staged, compareAgainstHead),
maxContentBytes
)
}
return assertGitDiffWithinTransportBudget(
await getDiff(target.worktree.path, relativePath, staged, compareAgainstHead, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
}),
maxContentBytes
)
}
async getRuntimeGitBranchCompare(
worktreeSelector: string,
baseRef: string,
admissionTier: GitAdmissionTier = 'interactive'
): Promise<GitBranchCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = requireRuntimeGitProvider(target)
if (provider) {
return provider.getBranchCompare(target.worktree.path, baseRef, { admissionTier })
}
return getBranchCompare(target.worktree.path, baseRef, {
...localGitOptionsForTarget(target),
admissionTier
})
}
async getRuntimeGitCommitCompare(
worktreeSelector: string,
commitId: string
): Promise<GitCommitCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = requireRuntimeGitProvider(target)
if (provider) {
return provider.getCommitCompare(target.worktree.path, commitId)
}
return getCommitCompare(target.worktree.path, commitId, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
})
}
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 = requireRuntimeGitProvider(target)
if (provider) {
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),
admissionTier: 'interactive'
}
),
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 = requireRuntimeGitProvider(target)
if (provider) {
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),
admissionTier: 'interactive'
}
),
maxContentBytes
)
}
async getRuntimeGitRemoteFileUrl(
worktreeSelector: string,
relativePath: string,
line: number
): Promise<string | null> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const normalizedRelativePath = normalizeRuntimeGitRelativePath(relativePath)
const provider = requireRuntimeGitProvider(target)
if (provider) {
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 = requireRuntimeGitProvider(target)
if (provider) {
return provider.getRemoteCommitUrl(target.worktree.path, sha)
}
await awaitWindowsHostGitEnvironmentReady({ cwd: target.worktree.path })
return getRemoteCommitUrl(target.worktree.path, sha)
}
}