Files
orca/src/main/ipc/worktree-push-target-refspec-migration.ts
T
Neil f176e49478 fix(git): narrow fork-remote fetch refspecs to tracked branches (#17887)
* fix(git): narrow fork-remote fetch refspecs to tracked branches

git remote add with no -t writes the wide +refs/heads/*:refs/remotes/<name>/*
refspec, so any later plain `git fetch` (user, agent, or Orca's own Fetch
action) re-imports a fork's entire branch set and its tags -- one real
machine had ~50 leaked/wide fork remotes producing 59,716 remote-tracking
refs. Mint and reuse now pin -t <branch> --no-tags; a rate-limited sweep
narrows and cleans up remotes minted before this fix; gitFetch self-heals
when a narrowed remote's tracked branch is later deleted upstream.

Refs #17828

* fix(git): soften narrow fork-remote refspec against deleted upstream branches

A bare `git fetch` in a worktree checked out on a fork-PR branch resolves to
the pr-* remote via branch.<name>.remote -- not origin -- making it the
dominant fetch shape in Orca's terminal-centric, agent-driven usage. The
previous literal-refspec design hard-failed that fetch ("couldn't find
remote ref") the moment the tracked branch was deleted/renamed upstream,
which is not the narrow edge case it was first described as.

Switch to a trailing-`*`-suffixed refspec source/destination
(refs/heads/<branch>*:refs/remotes/<name>/<branch>*). Verified against real
git: this restores wildcard zero-match tolerance (silent no-op instead of a
hard failure) and lets plain `git fetch --prune` reclaim the stale ref once
the branch disappears, at the cost of also matching sibling branches that
share the literal name as a prefix -- a materially smaller widening than the
original unbounded-import bug.

Also close a race with #17842's orphaned-pr-remote reconciliation sweep:
both sweeps read the same worktree-metadata store to pick candidate remotes,
so reconciliation can `remote remove` a remote this migration is
concurrently narrowing. `ensureRemoteTracksBranchNarrowly`'s plain `config
--add` would silently resurrect a url-less config section in that case;
re-check `remote.<name>.url` (via the new `remoteHasUrl`, plumbing rather
than porcelain `remote get-url`, which falls back to echoing the remote name
as a bogus URL) after the narrowing writes and remove the section if it's
gone.

* fix(git): update stale fork-remote mint assertions for -t/--no-tags and wildcard-suffix refspec

Four test files still asserted the pre-#17828 remote-add shape or the
literal (non-wildcard-suffixed) fetch refspec from before the
deleted-upstream-branch softening commit, so CI went red on that HEAD:

- worktree-push-target-refspec-real-git.test.ts: the migration fixture
  asserted a hardcoded tracked-ref count before narrowing. Under git
  >= 2.44, `followRemoteHEAD` auto-creates a `refs/remotes/<name>/HEAD`
  symref on the first fetch matching the full wildcard refspec, adding
  one untracked ref. Made the count/assertions robust to that ref's
  presence instead of hand-tuning the constant per git version.
- worktrees-wsl-runtime-routing.test.ts: assertions predated both the
  `-t <branch> --no-tags` mint change and the wildcard-suffix refspec
  change; updated to the full, correct call sequence and confirmed the
  WSL routing options (cwd, wslDistro) are threaded to every call.
- worktrees-create-metadata-persistence.test.ts and
  orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts: same
  class of staleness, found via CI job log cross-referencing rather
  than being explicitly flagged.

Verified out of scope: the SSH fork-remote mint path
(prepareWorktreePushTargetSsh) is untouched by this PR -- it never
persists a `remote.<name>.fetch` refspec at all, using
provider.fetchRemoteTrackingRef for a targeted per-branch fetch
instead -- so worktrees-ssh-fork-push-target-remote.test.ts needed no
change.

* fix(git): migrate pr-* remotes with zero worktree-metadata trace too

The migration sweep's candidate discovery was purely metadata-driven
(store.getAllWorktreeMeta()), so a pr-* remote whose every referencing
worktree was removed outside preserve-on-delete (metadata purged, not
just the worktree) was permanently invisible to it and stayed on the
wide default forever.

Field data from a manual migration run against a real user's repo (31
pr-* remotes, 34,637 tracking refs, only 18 actually needed) found
exactly this: 15 of 31 remotes had no branch pinning them at all.

Widen discovery to every pr-* remote git reports on disk, in addition
to metadata-derived candidates. For a remote with no branch provenance
from either metadata or surviving branch.*.remote/.pushRemote config,
there's nothing to narrow *to* -- clear its fetch refspec entirely
instead (stays pushable, imports nothing on a plain fetch), gated on
it still carrying the untouched stock wide default so a user's own
custom pr-*-named remote isn't touched. Removing the remote outright
stays #17842's job.

Adds clearForkRemoteFetchRefspec (fork-remote-refspec.ts), 3 new
mocked-exec tests, and a real-git integration test proving a
subsequent plain `git fetch` on the cleared remote imports nothing.
2026-09-01 03:45:09 -07:00

228 lines
10 KiB
TypeScript

// Why: remotes minted or reused before #17828's narrow-refspec fix are stuck on the
// wide `+refs/heads/*:refs/remotes/<name>/*` default, so any later plain `git fetch`
// keeps re-importing the fork's whole branch set. This sweep rewrites each surviving
// Orca-provenance `pr-*` remote's refspec to only the branches Orca actually tracked
// for it, then deletes the refs the earlier wide fetch already imported for every other
// branch (`git fetch --prune` cannot reclaim these once the refspec is narrow -- verified
// against real git, see #17828 PR). It never deletes a remote (that is
// `worktree-push-target-cleanup.ts`'s job) -- only narrows what a future fetch pulls.
//
// Candidate discovery also widens past worktree metadata to every `pr-*` remote on disk
// (see `listRemoteNames` below): metadata-only discovery misses a remote whose every
// worktree was removed outside preserve-on-delete (worktree gone *and* metadata purged,
// not just the worktree) -- field data on a real repo found 15 of 31 fork remotes with no
// branch pinning them at all, permanently invisible to metadata-only discovery and stuck
// wide forever. For those, there's nothing to narrow *to*, so the sweep clears the fetch
// refspec entirely instead (stays pushable, imports nothing on a plain fetch) -- gated on
// the remote still carrying the untouched stock wide default, since a `pr`-prefixed name
// alone isn't proof of Orca provenance the way a metadata entry is.
//
// Interaction with `worktree-push-target-reconciliation.ts` (#17842, orphaned `pr-*`
// remote reclamation): the two sweeps fire from different lifecycle events (this one
// from worktree creation, that one from worktree removal), rate-limit via separate
// `Map<repoId, timestamp>` cooldowns, and so never share state or starve each other.
// They *can* still race on the same remote if creation and removal happen close
// together for the same repo, because both derive their candidate remotes from the
// same worktree-metadata store: a remote reconciliation is about to reclaim (no live
// worktree still claims it) can be one this sweep is concurrently narrowing (its stale
// metadata entry hasn't been pruned from the store yet). `remoteHasUrl` re-checked both
// before and after the narrowing writes closes the practical impact of that race down
// to "reconciliation wins and this sweep's writes get cleaned back up" rather than a
// stray url-less `remote.<name>.*` config section -- see the guard below.
import { gitExecFileAsync } from '../git/runner'
import {
clearForkRemoteFetchRefspec,
ensureRemoteTracksBranchNarrowly,
getRemoteFetchRefspecs,
pruneUntrackedForkRemoteRefs,
remoteHasUrl,
wildcardForkFetchRefspec
} from '../git/fork-remote-refspec'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner'
import type { GitRemoteExec, WorktreePushTargetStore } from './worktree-push-target-cleanup'
const NEVER_MIGRATE_REMOTE_NAMES = new Set(['origin', 'upstream'])
// Fork remotes are always minted as `pr-${slug}` (see pull-request-push-target.ts). Used
// only as a secondary discovery signal below for remotes with zero metadata trace --
// primary gating stays the wide-refspec check, not this prefix alone.
const PR_REMOTE_NAME_PREFIX = 'pr-'
async function listRemoteNames(execGit: GitRemoteExec, repoPath: string): Promise<string[]> {
try {
const { stdout } = await execGit(['remote'], repoPath)
return [...iterateProcessOutputLines(stdout)].map((line) => line.trim()).filter(Boolean)
} catch {
return []
}
}
/** `pushTarget`-derived branches to keep per remote, gated on at least one entry proving Orca created it. */
function collectProvenBranchesByRemote(
store: WorktreePushTargetStore,
repoId: string
): Map<string, Set<string>> {
const branchesByRemote = new Map<string, Set<string>>()
const provenRemotes = new Set<string>()
for (const [worktreeId, meta] of Object.entries(store.getAllWorktreeMeta())) {
if (getRepoIdFromWorktreeId(worktreeId) !== repoId || !meta.pushTarget) {
continue
}
const { remoteName, branchName, remoteCreated } = meta.pushTarget
if (remoteCreated === true) {
provenRemotes.add(remoteName)
}
const branches = branchesByRemote.get(remoteName) ?? new Set<string>()
branches.add(branchName)
branchesByRemote.set(remoteName, branches)
}
for (const remoteName of branchesByRemote.keys()) {
if (!provenRemotes.has(remoteName)) {
branchesByRemote.delete(remoteName)
}
}
return branchesByRemote
}
// `branch.<name>.remote`/`.pushRemote` config can outlive worktree metadata (preserve-on-delete),
// so a branch it protects is still worth keeping narrowly tracked even with no metadata left.
async function collectBranchesFromLocalConfig(
execGit: GitRemoteExec,
repoPath: string,
remoteName: string
): Promise<string[]> {
let stdout: string
try {
;({ stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
repoPath
))
} catch {
return []
}
const branches: string[] = []
for (const line of iterateProcessOutputLines(stdout)) {
const match = /^branch\.(.+)\.(?:remote|pushRemote) (.+)$/.exec(line.trim())
if (match && match[2] === remoteName) {
branches.push(match[1]!)
}
}
return branches
}
/**
* Exported for tests: the `execGit` seam drives the migration matrix without a real repo.
* Returns the names of remotes actually rewritten (wide -> narrow, then pruned).
*/
export async function migrateForkRemoteRefspecsWithExec(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
execGit: GitRemoteExec
): Promise<string[]> {
const branchesByRemote = collectProvenBranchesByRemote(store, repoId)
// Why: `branchesByRemote` only surfaces remotes with a *surviving* worktree-metadata
// entry. A remote whose every worktree was removed outside preserve-on-delete (metadata
// purged, not just the worktree) is invisible to it -- field data on a real repo found
// 15 of 31 fork remotes with zero branch pinning at all, still stuck wide. Widen
// discovery to every `pr-*` remote on disk so those aren't silently skipped forever.
const candidateNames = new Set(branchesByRemote.keys())
for (const remoteName of await listRemoteNames(execGit, repoPath)) {
if (remoteName.startsWith(PR_REMOTE_NAME_PREFIX)) {
candidateNames.add(remoteName)
}
}
const migrated: string[] = []
for (const remoteName of candidateNames) {
if (NEVER_MIGRATE_REMOTE_NAMES.has(remoteName)) {
continue
}
const branches = new Set(branchesByRemote.get(remoteName) ?? [])
for (const branch of await collectBranchesFromLocalConfig(execGit, repoPath, remoteName)) {
branches.add(branch)
}
if (!(await remoteHasUrl(execGit, repoPath, remoteName))) {
continue // config references a remote that no longer exists
}
const before = await getRemoteFetchRefspecs(execGit, repoPath, remoteName)
const wasWide = before.includes(wildcardForkFetchRefspec(remoteName))
if (branches.size === 0) {
// No metadata and no branch config pins this remote to anything -- there's nothing
// to narrow *to*. Only act if it's still the untouched stock wide default: that's
// the strongest available signal this came from a bare `git remote add` (ours or a
// pre-#17828 Orca's), not a `pr`-prefixed remote a user configured by hand. Clears
// rather than deletes -- removing the remote outright stays #17842's job.
if (!wasWide) {
continue
}
await clearForkRemoteFetchRefspec(execGit, repoPath, remoteName)
} else {
for (const branch of branches) {
await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, branch)
}
}
// #17842's reconciliation sweep can concurrently `remote remove` this same
// remote (both sweeps derive their candidate list from the same, possibly-stale,
// worktree metadata). `remote remove` deletes the whole `remote.<name>.*` section,
// but `ensureRemoteTracksBranchNarrowly` above would have just resurrected a
// url-less `fetch`/`tagOpt` section via plain `config --add`, which doesn't care
// whether the remote "exists". Detect that and clean up instead of leaving ghost
// config behind -- narrows but does not close the race (no cross-process lock
// exists), so this is a best-effort self-heal, not a guarantee.
if (!(await remoteHasUrl(execGit, repoPath, remoteName))) {
await execGit(['config', '--remove-section', `remote.${remoteName}`], repoPath).catch(
() => {}
)
continue
}
if (!wasWide) {
continue // already narrow (minted post-fix, or a prior sweep already ran); nothing to prune
}
// Best-effort: the refspec is narrowed regardless of whether this local ref cleanup
// succeeds. Purely local (no network), so failures here should be rare/unexpected.
await pruneUntrackedForkRemoteRefs(execGit, repoPath, remoteName, branches).catch(() => [])
migrated.push(remoteName)
}
return migrated
}
// Why: the sweep costs a handful of git subprocesses per candidate remote; bound to once
// per repo per cooldown so bursts of worktree creates don't repeat it.
const MIGRATE_COOLDOWN_MS = 60 * 60 * 1000
const lastMigratedAtByRepoId = new Map<string, number>()
function shouldMigrateNow(repoId: string): boolean {
const last = lastMigratedAtByRepoId.get(repoId)
return last === undefined || Date.now() - last >= MIGRATE_COOLDOWN_MS
}
export function _resetForkRemoteRefspecMigrationRateLimitForTests(): void {
lastMigratedAtByRepoId.clear()
}
/** Best-effort, rate-limited sweep; call sites fire this without awaiting it. */
export async function migrateForkRemoteRefspecs(
repoPath: string,
repoId: string,
store: WorktreePushTargetStore,
gitOptions: { wslDistro?: string } = {}
): Promise<void> {
if (!shouldMigrateNow(repoId)) {
return
}
lastMigratedAtByRepoId.set(repoId, Date.now())
try {
const migrated = await migrateForkRemoteRefspecsWithExec(repoPath, repoId, store, (args, cwd) =>
gitExecFileAsync(args, { cwd, ...gitOptions })
)
if (migrated.length > 0) {
console.log(
`[worktrees] Narrowed fetch refspec for ${migrated.length} fork remote(s) in ${repoPath}: ${migrated.join(', ')}`
)
}
} catch (error) {
console.warn(`[worktrees] Fork remote refspec migration failed for ${repoPath}:`, error)
}
}