Files
orca/src/main/git/remote.ts
T
Jinjing 7e76bb3aec Fix rebase race by fetching to private ref before rebasing (#15990)
* Fix rebase race by fetching to private ref before rebasing

`git pull --rebase` is vulnerable to concurrent fetches modifying remote-tracking refs during execution. Fetch to a temporary private ref (refs/orca/rebase/*) first, then rebase from that stable ref to avoid the race condition.

* Fix rebase race by fetching to private ref with timeout

Concurrent fetches can interfere with remote-tracking refs between
fetch and rebase. Use a unique private ref and 60-second timeout to
isolate each rebase operation and prevent hangs on stalled remotes.
Extract gitPullRebaseFromBase to a dedicated module.

* fix rebase race by fetching to private ref with timeouts

Concurrent fetches can replace FETCH_HEAD and remote-tracking refs between
fetch and rebase, causing the rebase to fail. Fetch to a temporary private
ref instead, use --no-write-fetch-head when available (Git 2.29+), and
serialize FETCH_HEAD access for older versions. Add process termination
barriers to ensure proper cleanup and extend timeouts for SSH operations.

* Fix rebase race by fetching to both private and tracking refs

Concurrent fetches between source and rebase can replace remote-tracking refs,
causing rebases to use stale bases. Now fetch to both a private ref and the
remote-tracking ref simultaneously, ensuring the tracking ref stays current.

Also improves process termination for WSL guests with process-group tracking,
fixes process-tree termination timeouts on POSIX, and serializes FETCH_HEAD
operations for linked worktrees through their shared Git directory.

* Add WSL setsid --wait probe and barrier termination timeout

Probe for `setsid --wait` support and fall back to unwrapped execution for BusyBox compatibility. Add a deadline for process termination barriers to prevent hanging when tree termination cannot be verified. Update tests for cross-platform compatibility.

* Add wsl-process-group-termination to WSL invocation allowlist

* Serialize per-worktree git mutations to fix rebase race

Introduce operation locking for each worktree to prevent concurrent
mutations (like rebase) from interfering with each other. Ensures
rebasing a linked worktree doesn't affect the source worktree state.
Add SIGKILL fallback if process termination barriers cannot verify
tree termination.

* Serialize pull and fastForward operations per-worktree

- Extract generic git operation lock to reuse locking pattern
- Refactor existing locks to use the generic implementation
- Apply per-worktree serialization to pull and fastForward to prevent races

* Route WSL group termination through runWslProcess

ce743a4fd0 silenced the wsl-invocation boundary guard by appending
wsl-process-group-termination.ts to the allowlist. That fixture only
grows when the scanner learns to see a spawn it was blind to, and only
shrinks for a migration -- this was new code on this branch, so the
entry was the boundary regressing rather than the guard getting honest.

Migrate the kill instead. terminate() now calls runWslProcess with the
script form (`<shell> -c <script> -- <args>`), which keeps the group id
in $1, so the payload is unchanged. The script is plain POSIX, so it
must not pin shell: 'bash'; it calls only builtins and coreutils on the
default PATH and reads no login environment, so loginPath is 'none'.

wrapGuestArgs() is untouched: its argv is spliced into git/runner.ts's
own wsl.exe invocation, which is a long-standing allowlist entry.

The unit test now mocks runWslProcess and asserts the spec shape --
distro, loginPath, the group id in args -- so a regression back to a raw
spawn fails here as well as at the boundary guard.

* Assert cleanup is defined before accessing properties
2026-08-24 12:11:55 -07:00

298 lines
9.3 KiB
TypeScript

import {
normalizeGitErrorMessage,
runPullWithDivergenceFallback
} from '../../shared/git-remote-error'
import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream'
import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitRuntimeOptions } from './git-runtime-options'
import { gitOptionsForWorktree } from './git-runtime-options'
import { validateGitPushTarget } from './push-target-validation'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { runWithGitWorktreeOperationLock } from '../../shared/git-worktree-operation-lock'
export { gitPullRebaseFromBase } from './remote-rebase'
async function getConfiguredPushTarget(
worktreePath: string,
options: GitRuntimeOptions = {}
): Promise<{ remote: string; refspec: string } | null> {
try {
const { stdout: branchStdout } = await gitExecFileAsync(
['symbolic-ref', '--quiet', '--short', 'HEAD'],
gitOptionsForWorktree(worktreePath, options)
)
const branch = branchStdout.trim()
if (!branch) {
return null
}
const [pushRemote, { stdout: mergeStdout }] = await Promise.all([
getConfiguredPushRemote(worktreePath, branch, options),
gitExecFileAsync(
['config', '--get', `branch.${branch}.merge`],
gitOptionsForWorktree(worktreePath, options)
)
])
const remote = pushRemote?.remote
const mergeRef = mergeStdout.trim()
const branchRef = mergeRef.replace(/^refs\/heads\//, '')
if (!remote || !branchRef || remote === '.' || branchRef === mergeRef) {
return null
}
if (await branchMergeTargetsConfiguredBase(worktreePath, branch, remote, branchRef, options)) {
return null
}
if (!canPushConfiguredMergeBranch(pushRemote, branch, branchRef)) {
return null
}
return { remote, refspec: `HEAD:${branchRef}` }
} catch {
return null
}
}
async function getConfigValue(
worktreePath: string,
key: string,
options: GitRuntimeOptions = {}
): Promise<string | null> {
try {
const { stdout } = await gitExecFileAsync(
['config', '--get', key],
gitOptionsForWorktree(worktreePath, options)
)
const value = stdout.trim()
return value || null
} catch {
return null
}
}
function isUrlValuedRemote(remote: string): boolean {
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote)
}
type ConfiguredPushRemote = {
remote: string
branchRemote: string | null
}
async function findRemoteNameForUrl(
worktreePath: string,
remoteUrl: string,
options: GitRuntimeOptions = {}
): Promise<string | null> {
try {
const { stdout } = await gitExecFileAsync(
['remote'],
gitOptionsForWorktree(worktreePath, options)
)
const remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
for (const remoteName of remotes) {
try {
const { stdout: urlStdout } = await gitExecFileAsync(
['remote', 'get-url', remoteName],
gitOptionsForWorktree(worktreePath, options)
)
if (urlStdout.trim() === remoteUrl) {
return remoteName
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
} catch {
return null
}
return null
}
async function normalizePushRemote(
worktreePath: string,
remote: string,
options: GitRuntimeOptions = {}
): Promise<string> {
if (!isUrlValuedRemote(remote)) {
return remote
}
return (await findRemoteNameForUrl(worktreePath, remote, options)) ?? remote
}
async function getConfiguredPushRemote(
worktreePath: string,
branch: string,
options: GitRuntimeOptions = {}
): Promise<ConfiguredPushRemote | null> {
const branchRemote = await getConfigValue(worktreePath, `branch.${branch}.remote`, options)
const remote =
(await getConfigValue(worktreePath, `branch.${branch}.pushRemote`, options)) ??
(await getConfigValue(worktreePath, 'remote.pushDefault', options)) ??
branchRemote
if (!remote) {
return null
}
return {
remote: await normalizePushRemote(worktreePath, remote, options),
branchRemote: branchRemote
? await normalizePushRemote(worktreePath, branchRemote, options)
: null
}
}
async function branchMergeTargetsConfiguredBase(
worktreePath: string,
branch: string,
remote: string,
branchRef: string,
options: GitRuntimeOptions = {}
): Promise<boolean> {
return gitRefTargetsBranchOnRemote(
await getConfigValue(worktreePath, `branch.${branch}.base`, options),
remote,
branchRef
)
}
function canPushConfiguredMergeBranch(
pushRemote: ConfiguredPushRemote | null,
branch: string,
branchRef: string
): boolean {
if (!pushRemote) {
return false
}
if (branchRef === branch) {
return true
}
// Why: branch.merge belongs to branch.remote. A pushDefault fork must not
// inherit origin/main as its destination branch.
return pushRemote.remote !== 'origin' && pushRemote.branchRemote === pushRemote.remote
}
function explicitPushTarget(target: GitPushTarget): { remote: string; refspec: string } {
return { remote: target.remoteName, refspec: `HEAD:${target.branchName}` }
}
export async function gitPush(
worktreePath: string,
_publish = false,
pushTarget?: GitPushTarget,
options: { forceWithLease?: boolean } & GitRuntimeOptions = {}
): Promise<void> {
try {
if (pushTarget) {
await validateGitPushTarget(worktreePath, pushTarget, options)
}
// Why: push to the branch's configured upstream when one exists. PR-created
// worktrees can track a contributor fork remote; hardcoding origin here
// would send review commits to the upstream repository instead.
//
// When no upstream exists, keep the existing first-publish behavior:
// create/update origin/<current branch> and set it as upstream.
//
// Branch-vs-base reporting (the "Committed on Branch" section) is
// unaffected because it uses branchCompare against an explicit baseRef
// from worktree config, not the upstream relationship.
const target = pushTarget
? explicitPushTarget(pushTarget)
: await getConfiguredPushTarget(worktreePath, options)
const args = [
'push',
...(options.forceWithLease ? ['--force-with-lease'] : []),
'--set-upstream',
...(target ? [target.remote, target.refspec] : ['origin', 'HEAD'])
]
await gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options))
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'push'))
}
}
async function gitPullWithArgs(
worktreePath: string,
pullArgs: string[],
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
const runPull = async (effectiveArgs: string[]): Promise<void> => {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
await gitExecFileAsync(
['pull', ...effectiveArgs, target.remoteName, target.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
}
const upstream = await resolveEffectiveGitUpstream((args) =>
gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options))
)
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 gitExecFileAsync(
['pull', ...effectiveArgs, upstream.remoteName, upstream.branchName],
gitOptionsForWorktree(worktreePath, options)
)
return
}
await gitExecFileAsync(['pull', ...effectiveArgs], gitOptionsForWorktree(worktreePath, options))
}
try {
await runPullWithDivergenceFallback(pullArgs, runPull)
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'pull'))
}
}
export async function gitPull(
worktreePath: string,
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
// Why: plain `git pull` uses the user's configured pull strategy (merge by
// default) so diverged branches reconcile instead of erroring out. Conflicts
// surface through the existing conflict-resolution flow.
await runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() => gitPullWithArgs(worktreePath, [], pushTarget, options))
)
}
export async function gitFastForward(
worktreePath: string,
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
await runWithGitWorktreeOperationLock(worktreePath, options.signal, () =>
runWithGitReadCacheInvalidation(() =>
gitPullWithArgs(worktreePath, ['--ff-only'], pushTarget, options)
)
)
}
export async function gitFetch(
worktreePath: string,
pushTarget?: GitPushTarget,
options: GitRuntimeOptions = {}
): Promise<void> {
try {
if (pushTarget) {
const target = await validateGitPushTarget(worktreePath, pushTarget, options)
await gitExecFileAsync(
['fetch', '--prune', target.remoteName],
gitOptionsForWorktree(worktreePath, options)
)
return
}
await gitExecFileAsync(['fetch', '--prune'], gitOptionsForWorktree(worktreePath, options))
} catch (error) {
throw new Error(normalizeGitErrorMessage(error, 'fetch'))
}
}