mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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>
1027 lines
39 KiB
TypeScript
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)
|
|
}
|
|
}
|