Files
orca/src/relay/git-handler.test.ts
T
Neil b4ba3e97ff perf(worktree): defer fork-PR remote creation from create-time to first use (#17922)
* perf(worktree): defer fork-PR remote creation from create-time to first use

Fork-PR review worktrees eagerly ran `git remote add` + `git fetch` for the
contributor's fork (and pinned branch.<x>.remote) at create time, even for a
read-only review. That grows remote count unboundedly with review volume and
pays a network fetch nobody asked for yet.

Defer prepareWorktreePushTarget(Ssh) and the --set-upstream-to configure step
at create time (local + SSH, IPC + runtime create paths); persist the
pushTarget metadata untouched. Materialize the remote on demand the first
time push/pull/fetch/fast-forward actually needs it, via two shared
functions (materializeWorktreePushTargetRemote(Ssh)) reused across the
legacy IPC handlers and the RPC runtime sync commands. A cheap
`remote get-url <name>` probe keeps steady-state calls down to one extra
subprocess once materialized, instead of repeating the O(remotes) scan.

Add repo-local `remote.<name>.orca-created` config provenance, written when
the remote is added, so cleanup can recognize ownership of a remote that was
lazily materialized (and therefore never round-tripped through the store's
`remoteCreated` flag).

Refs #17828

* perf(worktree): materialize a deferred fork-PR remote on terminal spawn

An agent running raw git in a freshly opened fork-PR review terminal has no
usable upstream until an Orca-driven sync happens -- "sync through Orca
first" isn't available mid-task, and git pull/log @{u}.. hard-fail without
one (verified against real git). Fire the same on-demand materialization
used by push/pull/fetch/fast-forward from the single terminal-spawn
resolver (resolveTerminalWorkspaceLaunchTarget), fire-and-forget, so a
newly opened terminal gets a working upstream without blocking spawn.

* fix(worktree): retest deferred fork-remote CI failures, fix SSH provenance-marker RPC

Rewrites the 5 CI failures on the deferred fork-remote change (#17828) as
evidence, not fixtures: the SSH relay-upgrade/rollback/sibling-ownership
tests move to materializeWorktreePushTargetRemoteSsh, where that
unchanged logic now actually runs (create defers it to first sync).

While writing a stricter test that routes its mock exec through the
relay's real validateGitExecArgs, found that the SSH provenance-marker
write (`git config remote.<name>.orca-created true`) was unconditionally
rejected by the relay's generic git.exec (it blocks all non-read-only
config writes) -- a real bug that would break every SSH fork-remote
materialization against a live relay. Fixes it with a narrow
git.markRemoteOrcaCreated RPC, mirroring renameCurrentBranch, with a
graceful no-op fallback for relays that predate it.

* fix(worktree): scope post-#17887 test assertions past narrow-refspec config calls

Rebasing onto #17887's narrow-refspec `remote add` broke two broad `['config']`
call-filters into false positives/negatives, and the local materialize test still
asserted the pre-#17887 wide `remote add`/fetch-refspec forms.

* fix(worktree): restructure upstream restore, persist provenance, widen short-circuit refspec (#17828 review)

- Move upstream restoration to the materializer level so it runs on both the
  remoteAlreadyMatchesUrl short-circuit and the full-prepare path, not just
  buried inside prepare*.
- Persist {remoteCreated, remoteName} to the store on materialize so #17842's
  orphan sweep can see a lazily-created remote, including via desktop IPC,
  terminal-spawn, and the RPC host-callback paths.
- Widen the refspec on the local short-circuit path too (SSH's bare `remote
  add` refspec gap remains a documented, pre-existing limitation).
- Fetch the branch's tracking ref before restoring upstream when the
  short-circuit widens onto a *new* branch on an already-existing remote --
  a bare refspec-config widen never itself imports anything, so
  `branch --set-upstream-to` was hard-failing for a sibling worktree's first
  materialize (found via a real-git fixture, not just mocked unit tests).
  Skipped when the ref already exists so the common repeat-call case stays a
  local-only probe with no network round-trip.

* fix(worktree): merge duplicate shared/worktree/types import

oxlint --deny-warnings flags the split import as no-duplicates; full pnpm lint
was failing on it after the #17828 review restructuring.

* fix(worktree): scope the deferred fetch timeout to fetch calls, retarget stale create-time assertions

CI on the previous push failed 3 shards, all argument-shape mismatches:

- worktrees-wsl-runtime-routing.test.ts: the "restructure upstream restore" commit
  wrapped every call `prepareWorktreePushTarget` makes (remote, remote add, config,
  fetch) with DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS, not just the network fetch. Local
  git subprocesses never need a timeout; scope it to `args[0] === 'fetch'` only,
  matching the short-circuit path's existing pattern. Updated the test to expect the
  timeout on the fetch call specifically (point 5 legitimately adds it there), while
  every other call stays untimed.

- worktrees-create-metadata-persistence.test.ts (2 tests): stale from before this
  session -- create no longer mints a fork remote at all (#17828 deferred that to
  first sync), so asserting `remote add`/`fetch`/`remoteCreated: true` at create time
  no longer matches reality. Retargeted both tests to assert the deferred contract
  (no remote add at create, pushTarget persisted unmaterialized); minting itself
  stays covered by worktree-remote-push-target-materialization.test.ts and
  worktree-push-target-setup.test.ts.

Re-verified all 5 fixture points (mint upstream, store persistence, single-flight,
short-circuit refspec widen + fetch-missing-ref for local and SSH, finite timeout)
against a real git fixture after this fix -- all still pass.

* fix(worktree): hook pty:spawn into deferred push-target materialization (#17828)

triggerTerminalSpawnPushTargetMaterialization only fired for agent/background/
mobile terminals; the desktop GUI's own pty:spawn path (new tab, split,
reattach) never materialized a deferred fork-PR remote before raw git
commands could run there. Add a small wrapper that resolves the worktree's
push target and owning repo from args.worktreeId via the store, and
fire-and-forget delegates to the existing materializer, wired as the first
statement of runPtyIpcSpawn. Degrades silently (optional chaining + catch)
so a partial/fake Store in existing spawn tests can't turn this into a
spawn-blocking throw.

* test(worktree): retarget stale editor-remote-branch assertions for worktreeId threading

runtime-git-sync-client's local-path fetch/pull/fastForward/push calls now
forward context.worktreeId (needed by the main-process handlers to key
deferred push-target materialization). Update the 17 call-site mocks across
15 tests in editor-remote-branch-actions.test.ts to expect worktreeId: 'wt-1',
matching the already-correct source behavior -- no assertion was loosened.

* fix(worktree): give a materialize joiner its own branch wiring

The materialize single flight is keyed on the remote, but everything after
the remote add is per-branch. A sibling worktree joining an in-flight mint
for a different branch received the minter's target and skipped its own
refspec widen, tracking-ref fetch, and upstream link, so its branch ended
with no upstream at all.

Wait for the remote, then run the per-branch work against the joiner's own
target -- the same path the already-exists short-circuit takes, now shared
rather than duplicated. Adopting a remote a sibling minted also stamps
ownership, so removing the minter cannot strand the survivor's metadata
outside the orphan sweep's reach.

* fix(worktree): stop a failed mint from leaving a config-only fork remote

Review of the joiner fix found it made things worse in three ways.

Swallowing the mint's rejection let a joiner adopt a remote the rollback
had already removed, writing remote.<name>.fetch with no URL. Verified on
real git: that ghost section breaks `git fetch --all`, forces every later
mint to a `-2` name, and cannot be removed by `git remote remove`.
Propagate instead; the in-flight map is already cleared, so a retry
re-mints.

The SSH twin still returned the minter's target to a joiner, so the
original per-branch bug survived there. It now adopts against its own
target through a twin helper.

The ownership stamp was unreachable: it required both a store and a repo
id, and no caller passes both. Derive the repo id from the worktree id.

Adopters also write remote config, and concurrent `git config --add` has
no lock retry -- 135 of 160 writes failed at 8-way concurrency, and equal
values duplicate the refspec. Chain adoptions per remote.
2026-09-01 22:44:05 -07:00

359 lines
13 KiB
TypeScript

/**
* GitHandler RPC registration surface plus the branch/HEAD lifecycle RPCs:
* merge/rebase aborts, checkout, branch rename, preserved-branch deletion,
* commit history, and conflict-operation detection.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { writeFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { GitHandler } from './git-handler'
import { RelayContext } from './context'
import {
gitInit,
gitCommit,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
import {
createGitHandlerRelay,
createGitTempDir,
normalizeGitFileText,
removeGitTempDir
} from './git-handler-test-harness'
describe('GitHandler', () => {
let dispatcher: MockDispatcher
let handler: GitHandler
let tmpDir: string
beforeEach(() => {
tmpDir = createGitTempDir()
;({ dispatcher, handler } = createGitHandlerRelay())
})
afterEach(async () => {
await removeGitTempDir(tmpDir)
})
it('registers all expected handlers', () => {
const methods = Array.from(dispatcher._requestHandlers.keys())
expect(methods).toContain('git.status')
expect(methods).toContain('git.checkIgnored')
expect(methods).toContain('git.history')
expect(methods).toContain('git.commit')
expect(methods).toContain('git.diff')
expect(methods).toContain('git.stage')
expect(methods).toContain('git.unstage')
expect(methods).toContain('git.bulkStage')
expect(methods).toContain('git.bulkUnstage')
expect(methods).toContain('git.abortMerge')
expect(methods).toContain('git.abortRebase')
expect(methods).toContain('git.checkout')
expect(methods).toContain('git.localBranches')
expect(methods).toContain('git.discard')
expect(methods).toContain('git.bulkDiscard')
expect(methods).toContain('git.conflictOperation')
expect(methods).toContain('git.branchCompare')
expect(methods).toContain('git.upstreamStatus')
expect(methods).toContain('git.fetch')
expect(methods).toContain('git.forkSync')
expect(methods).toContain('git.fetchRemoteTrackingRef')
expect(methods).toContain('git.fetchGitHubPullRequestHead')
expect(methods).toContain('git.fetchGitLabMergeRequestHead')
expect(methods).toContain('git.fetchGitLabMergeRequestHeadRef')
expect(methods).toContain('git.push')
expect(methods).toContain('git.pull')
expect(methods).toContain('git.fastForward')
expect(methods).toContain('git.rebaseFromBase')
expect(methods).toContain('git.branchDiff')
expect(methods).toContain('git.listWorktrees')
expect(methods).toContain('git.addWorktree')
expect(methods).toContain('git.removeWorktree')
expect(methods).toContain('git.worktreeIsClean')
expect(methods).toContain('git.refreshLocalBaseRefForWorktreeCreate')
expect(methods).toContain('git.markRemoteOrcaCreated')
expect(methods).toContain('git.renameCurrentBranch')
expect(methods).toContain('git.forceDeletePreservedBranch')
expect(methods).toContain('git.exec')
expect(methods).toContain('git.clone')
expect(methods).toContain('git.isGitRepo')
})
it('runs remote worktree deletion inside the relay watcher fence', async () => {
const removalError = new Error('fenced before Git')
const runWithRemovalFence = vi.fn(async () => {
throw removalError
})
handler.dispose()
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext(), {
runWithRemovalFence
})
await expect(
dispatcher.callRequest('git.removeWorktree', { worktreePath: '/repo-feature' })
).rejects.toBe(removalError)
expect(runWithRemovalFence).toHaveBeenCalledWith('/repo-feature', expect.any(Function))
})
describe('abortMerge', () => {
it('aborts an in-progress merge', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
gitCommit(tmpDir, 'initial')
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
gitCommit(tmpDir, 'feature change')
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
gitCommit(tmpDir, 'main change')
expect(() =>
execFileSync('git', ['merge', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
).toThrow()
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).resolves.toBeUndefined()
await dispatcher.callRequest('git.abortMerge', { worktreePath: tmpDir })
await expect(fs.access(path.join(tmpDir, '.git', 'MERGE_HEAD'))).rejects.toThrow()
await expect(
fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText)
).resolves.toBe('main\n')
})
})
describe('abortRebase', () => {
it('aborts an in-progress rebase', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
gitCommit(tmpDir, 'initial')
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
execFileSync('git', ['checkout', '-b', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'feature\n')
gitCommit(tmpDir, 'feature change')
execFileSync('git', ['checkout', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
writeFileSync(path.join(tmpDir, 'file.txt'), 'main\n')
gitCommit(tmpDir, 'main change')
execFileSync('git', ['checkout', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
expect(() =>
execFileSync('git', ['rebase', baseBranch], { cwd: tmpDir, stdio: 'pipe' })
).toThrow()
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).resolves.toBeUndefined()
await dispatcher.callRequest('git.abortRebase', { worktreePath: tmpDir })
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-merge'))).rejects.toThrow()
await expect(fs.access(path.join(tmpDir, '.git', 'rebase-apply'))).rejects.toThrow()
await expect(
fs.readFile(path.join(tmpDir, 'file.txt'), 'utf-8').then(normalizeGitFileText)
).resolves.toBe('feature\n')
})
})
describe('checkout / localBranches', () => {
it('switches to an existing local branch and lists branches current-first', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'base\n')
gitCommit(tmpDir, 'initial')
const baseBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
execFileSync('git', ['branch', 'feature'], { cwd: tmpDir, stdio: 'pipe' })
const before = (await dispatcher.callRequest('git.localBranches', {
worktreePath: tmpDir
})) as { current: string | null; branches: string[] }
expect(before.current).toBe(baseBranch)
expect(before.branches).toContain('feature')
expect(before.branches[0]).toBe(baseBranch)
await dispatcher.callRequest('git.checkout', { worktreePath: tmpDir, branch: 'feature' })
expect(
execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe'
}).trim()
).toBe('feature')
const after = (await dispatcher.callRequest('git.localBranches', {
worktreePath: tmpDir
})) as { current: string | null; branches: string[] }
expect(after.current).toBe('feature')
expect(after.branches[0]).toBe('feature')
})
})
describe('markRemoteOrcaCreated', () => {
it('writes the provenance marker via config, not the generic git.exec path', async () => {
gitInit(tmpDir)
execFileSync('git', ['remote', 'add', 'pr-contributor-orca', 'https://example.com/x.git'], {
cwd: tmpDir
})
await dispatcher.callRequest('git.markRemoteOrcaCreated', {
repoPath: tmpDir,
remoteName: 'pr-contributor-orca'
})
const value = execFileSync(
'git',
['config', '--get', 'remote.pr-contributor-orca.orca-created'],
{ cwd: tmpDir, encoding: 'utf-8' }
).trim()
expect(value).toBe('true')
})
it('rejects a remote name that is not a plain config-key segment', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.markRemoteOrcaCreated', {
repoPath: tmpDir,
remoteName: 'bad name; rm -rf'
})
).rejects.toThrow('Invalid remote name for provenance marker.')
})
})
describe('renameCurrentBranch', () => {
it('renames only the checked-out branch through the narrow RPC', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['checkout', '-b', 'you/Nautilus'], { cwd: tmpDir })
await dispatcher.callRequest('git.renameCurrentBranch', {
worktreePath: tmpDir,
newBranch: 'you/fix-auth'
})
const current = execFileSync('git', ['branch', '--show-current'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(current).toBe('you/fix-auth')
})
it('rejects branch names that look like flags', async () => {
gitInit(tmpDir)
await expect(
dispatcher.callRequest('git.renameCurrentBranch', {
worktreePath: tmpDir,
newBranch: '-bad'
})
).rejects.toThrow('Branch name must not start with "-"')
})
})
describe('forceDeletePreservedBranch', () => {
function headOf(cwd: string, ref: string): string {
return execFileSync('git', ['rev-parse', ref], { cwd, encoding: 'utf-8' }).trim()
}
it('deletes a preserved branch at its expected head through the narrow RPC', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
execFileSync('git', ['branch', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' })
const head = headOf(tmpDir, 'refs/heads/feature/preserved')
await dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: tmpDir,
branchName: 'feature/preserved',
expectedHead: head
})
const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(refs).toBe('')
})
it('refuses to delete when the branch moved past the expected head', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
const staleHead = headOf(tmpDir, 'HEAD')
execFileSync('git', ['checkout', '-b', 'feature/preserved'], { cwd: tmpDir, stdio: 'pipe' })
// Advance the branch so the saved (stale) head no longer matches its tip.
gitCommit(tmpDir, 'second')
execFileSync('git', ['checkout', '-'], { cwd: tmpDir, stdio: 'pipe' })
await expect(
dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: tmpDir,
branchName: 'feature/preserved',
expectedHead: staleHead
})
).rejects.toThrow('changed after the workspace was deleted')
const refs = execFileSync('git', ['branch', '--list', 'feature/preserved'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(refs).toContain('feature/preserved')
})
it('rejects an empty repoPath at the RPC boundary', async () => {
await expect(
dispatcher.callRequest('git.forceDeletePreservedBranch', {
repoPath: '',
branchName: 'feature/preserved',
expectedHead: 'abc123'
})
).rejects.toThrow('Invalid preserved branch force-delete request.')
})
})
describe('history', () => {
it('returns bounded git history for a repo', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
gitCommit(tmpDir, 'second')
const result = (await dispatcher.callRequest('git.history', {
worktreePath: tmpDir,
limit: 10
})) as {
items: { subject: string; displayId?: string }[]
currentRef?: { category?: string; revision?: string }
hasMore: boolean
limit: number
}
expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial'])
expect(result.currentRef?.category).toBe('branches')
expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/)
expect(result.items[0]?.displayId).toHaveLength(7)
expect(result.hasMore).toBe(false)
expect(result.limit).toBe(10)
})
})
describe('conflictOperation', () => {
it('returns unknown for normal repo', async () => {
gitInit(tmpDir)
gitCommit(tmpDir, 'initial')
const result = await dispatcher.callRequest('git.conflictOperation', { worktreePath: tmpDir })
expect(result).toBe('unknown')
})
})
})