diff --git a/src/main/git/fork-remote-refspec.ts b/src/main/git/fork-remote-refspec.ts index 5bf53cfe7f0..c924fdb2cb2 100644 --- a/src/main/git/fork-remote-refspec.ts +++ b/src/main/git/fork-remote-refspec.ts @@ -55,6 +55,30 @@ function refspecSource(refspec: string): string { return refspec.replace(/^\+/, '').split(':')[0]! } +/** + * True if `branchName`'s remote-tracking ref already exists locally under `remoteName`. + * Used to skip a redundant fetch on the common repeat-materialize case (the ref was + * already pulled in by an earlier mint/fetch) while still fetching it on demand the + * first time a sibling worktree widens an existing remote onto a new branch -- a bare + * refspec-config widen never itself imports anything (see `ensureRemoteTracksBranchNarrowly`). + */ +export async function forkRemoteTrackingRefExists( + execGit: GitExecFn, + repoPath: string, + remoteName: string, + branchName: string +): Promise { + try { + await execGit( + ['rev-parse', '--verify', '--quiet', `refs/remotes/${remoteName}/${branchName}`], + repoPath + ) + return true + } catch { + return false + } +} + /** * True only if `remote..url` is actually set. Deliberately plumbing (`config --get`), * not porcelain `git remote get-url` -- the latter falls back to echoing the remote *name* diff --git a/src/main/git/upstream-deferred-fork-remote-real.test.ts b/src/main/git/upstream-deferred-fork-remote-real.test.ts new file mode 100644 index 00000000000..451f7bed8b3 --- /dev/null +++ b/src/main/git/upstream-deferred-fork-remote-real.test.ts @@ -0,0 +1,59 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { GitPushTarget } from '../../shared/worktree/types' +import { getUpstreamStatus } from './upstream' + +// Why: on-demand remote materialization (#17828) defers `git remote add` for a +// fork PR to first push/pull/fetch/fast-forward, so an unpublished review's +// status must be read against a pushTarget whose remote was never created. +// This exercises the real `rev-parse --verify --quiet` failure path -- a +// fake/mocked git can't reproduce its exact exit-code/stderr shape, which is +// exactly what `getPublishTargetStatus`'s missing-ref fallback depends on. +describe('getUpstreamStatus with a deferred (not-yet-materialized) fork remote', () => { + const tempPaths: string[] = [] + + afterEach(() => { + for (const path of tempPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('reports the graceful "publish" state instead of 0 ahead/0 behind', async () => { + const repoPath = mkdtempSync(join(tmpdir(), 'orca-deferred-fork-remote-')) + tempPaths.push(repoPath) + const git = (...args: string[]): string => + execFileSync('git', args, { cwd: repoPath, encoding: 'utf8' }) + + git('init', '--quiet') + git('config', 'user.name', 'Orca Test') + git('config', 'user.email', 'orca@example.test') + git('config', 'commit.gpgSign', 'false') + git('config', 'core.hooksPath', '.git/no-hooks') + writeFileSync(join(repoPath, 'fixture.txt'), 'base\n') + git('add', 'fixture.txt') + git('commit', '-m', 'base') + git('branch', '-M', 'contributor/fix') + + // Simulates a fork-PR review worktree right after create: pushTarget + // metadata is persisted, but `pr-contributor-orca` was never added as a + // remote because materialization is deferred to first use. + const pushTarget: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const status = await getUpstreamStatus(repoPath, pushTarget) + + expect(status).toEqual({ + hasUpstream: false, + upstreamName: 'pr-contributor-orca/contributor/fix', + ahead: 0, + behind: 0, + hasConfiguredPushTarget: true + }) + }) +}) diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts index 53ba72d58a4..2c3273b8c00 100644 --- a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -9,6 +9,10 @@ import { import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { @@ -20,6 +24,7 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl _event, args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -36,7 +41,18 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + // Why: a fork remote deferred at create time (#17828) must exist before push. + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pushBranch(args.worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true }) } @@ -46,13 +62,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPush(worktreePath, publish, args.pushTarget, { + await gitPush(worktreePath, publish, materializedPushTarget, { forceWithLease: args.forceWithLease === true, ...gitOptions, admissionTier: 'interactive' @@ -64,7 +90,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:pull', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -74,7 +105,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.pullBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.pullBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -82,13 +123,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitPull(worktreePath, args.pushTarget, { + await gitPull(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) @@ -99,7 +150,12 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl 'git:fastForward', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -109,7 +165,17 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fastForwardBranch(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -117,13 +183,23 @@ export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandl args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFastForward(worktreePath, args.pushTarget, { + await gitFastForward(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts index eae79c918dd..a924c393a04 100644 --- a/src/main/ipc/filesystem/git-remote/sync-handlers.ts +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -17,6 +17,10 @@ import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-c import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../../worktree-remote' import type { FilesystemHandlerContext } from '../filesystem-handler-context' export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { @@ -52,7 +56,12 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) 'git:fetch', async ( _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + args: { + worktreePath: string + worktreeId?: string + connectionId?: string + pushTarget?: GitPushTarget + } ): Promise => { if (args.connectionId) { if (args.pushTarget) { @@ -62,7 +71,17 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - return provider.fetchRemote(args.worktreePath, args.pushTarget) + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemoteSsh( + provider, + args.worktreePath, + args.pushTarget, + store, + undefined, + args.worktreeId + ) + : undefined + return provider.fetchRemote(args.worktreePath, materializedPushTarget) } const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) const gitOptions = getLocalGitOptionsForRegisteredWorktree( @@ -70,13 +89,23 @@ export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext) args.worktreePath, worktreePath ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { + const materializedPushTarget = args.pushTarget + ? await materializeWorktreePushTargetRemote( + worktreePath, + args.pushTarget, + store, + undefined, + gitOptions, + args.worktreeId + ) + : undefined + if (materializedPushTarget) { + await validateGitPushTarget(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) } - await gitFetch(worktreePath, args.pushTarget, { + await gitFetch(worktreePath, materializedPushTarget, { ...gitOptions, admissionTier: 'interactive' }) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts new file mode 100644 index 00000000000..20e0e646da4 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization-real-git.test.ts @@ -0,0 +1,149 @@ +// Real-binary coverage for #17828's remaining gap: the mocked-underlying-trigger suite in +// `spawn-push-target-materialization.test.ts` proves the wiring/delegation logic, but not +// that a `pty:spawn`-originated terminal -- the desktop GUI's own terminal path, previously +// uncovered -- actually ends up with a configured upstream against real git. No mocks here: +// this exercises the real `triggerTerminalSpawnPushTargetMaterialization` and real +// `materializeWorktreePushTargetRemote`, driven only through `runPtyIpcSpawn`'s hook. +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcDeps } from './spawn-types' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const execFileAsync = promisify(execFile) + +const REPO_ID = 'repo-1' +const FORK_REMOTE = 'pr-contributor-orca' +const TRACKED_BRANCH = 'contributor/fix' + +let scratchDir = '' +let repoPath = '' +let forkPath = '' +let worktreeId = '' +let mainBranch = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function setIdentity(cwd: string): Promise { + await git(['config', 'user.name', 'Orca Test'], cwd) + await git(['config', 'user.email', 'orca@example.test'], cwd) + await git(['config', 'commit.gpgSign', 'false'], cwd) +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/... + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-pty-spawn-push-target-'))) + repoPath = join(scratchDir, 'repo') + forkPath = join(scratchDir, 'fork') + worktreeId = `${REPO_ID}::${repoPath}` + + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q'], repoPath) + await setIdentity(repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + mainBranch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).trim() + + await git(['clone', '-q', repoPath, forkPath], scratchDir) + await setIdentity(forkPath) + await git(['checkout', '-qb', TRACKED_BRANCH], forkPath) + await writeFile(join(forkPath, 'fix.txt'), 'fix\n') + await git(['add', '-A'], forkPath) + await git(['commit', '-qm', 'fix'], forkPath) +}) + +afterEach(async () => { + await rm(scratchDir, { recursive: true, force: true }) +}) + +function forkTarget(): GitPushTarget { + return { remoteName: FORK_REMOTE, branchName: TRACKED_BRANCH, remoteUrl: forkPath } +} + +function depsFor( + pushTarget: GitPushTarget, + setWorktreeMeta?: Store['setWorktreeMeta'] +): { + deps: PtySpawnIpcDeps + meta: Record +} { + const meta: Record = { [worktreeId]: { pushTarget } as WorktreeMeta } + const store = { + getWorktreeMeta: (id: string) => meta[id], + getRepo: (id: string) => ({ id, path: repoPath, connectionId: null }) as unknown as Repo, + getAllWorktreeMeta: () => meta, + ...(setWorktreeMeta ? { setWorktreeMeta } : {}) + } as unknown as Store + return { deps: { store } as unknown as PtySpawnIpcDeps, meta } +} + +describe('triggerPtySpawnPushTargetMaterialization (real git fixture)', () => { + it('materializes the fork remote and configures the upstream for a pty:spawn-originated terminal', async () => { + // Why: not just that materialization was *called* -- the coordinator's bar for closing + // the gap is a real, git-verified configured upstream reachable from a pty:spawn arg set. + // Pre-seeds the remote so materialize takes the short-circuit branch (worktree-remote.ts): + // real `remote add`/`fetch` against a fabricated fork is already covered against real git by + // worktree-push-target-refspec-real-git.test.ts; the top-level entry point this hook calls + // additionally validates `remoteUrl` against a GitHub URL shape, which a local fixture path + // can never satisfy. The short-circuit is also the common case in practice -- every pty:spawn + // after the worktree's first (new tab, split, reattach) -- and still drives real + // `ensureRemoteTracksBranchNarrowly` / narrow `fetch` / `--set-upstream-to` git calls. + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + const { deps } = depsFor(forkTarget()) + + triggerPtySpawnPushTargetMaterialization(deps, { + cols: 80, + rows: 24, + worktreeId + }) + + await vi.waitFor( + async () => { + const upstream = await git( + ['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], + repoPath + ).catch(() => '') + expect(upstream.trim()).toBe(`${FORK_REMOTE}/${TRACKED_BRANCH}`) + }, + { timeout: 5000, interval: 25 } + ) + + const remoteUrl = (await git(['remote', 'get-url', FORK_REMOTE], repoPath)).trim() + expect(remoteUrl).toBe(forkPath) + + // The tracked branch's commit must actually be present -- confirms the narrow fetch ran, + // not just that the remote config was written. + const forkHead = (await git(['rev-parse', TRACKED_BRANCH], forkPath)).trim() + const fetchedHead = ( + await git(['rev-parse', `${FORK_REMOTE}/${TRACKED_BRANCH}`], repoPath) + ).trim() + expect(fetchedHead).toBe(forkHead) + }) + + it('is a no-op once the remote was already created (repeat pty:spawn, e.g. reattach)', async () => { + const target = { ...forkTarget(), remoteCreated: true } + const { deps } = depsFor(target) + await git(['remote', 'add', FORK_REMOTE, forkPath], repoPath) + + triggerPtySpawnPushTargetMaterialization(deps, { cols: 80, rows: 24, worktreeId }) + + // Give the fire-and-forget chain a tick; there is nothing to wait for since a + // remoteCreated target must short-circuit before any git call. + await new Promise((resolve) => setImmediate(resolve)) + const upstream = await git(['rev-parse', '--abbrev-ref', `${mainBranch}@{u}`], repoPath).catch( + () => '' + ) + expect(upstream.trim()).toBe('') + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..df71cb37f8a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { Store } from '../../../persistence' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const { triggerMock } = vi.hoisted(() => ({ triggerMock: vi.fn() })) +vi.mock('../../../runtime/runtime-terminal-spawn-push-target-materialization', () => ({ + triggerTerminalSpawnPushTargetMaterialization: triggerMock +})) + +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' + +const REPO_ID = 'repo-1' +const WORKTREE_PATH = '/repo/worktree' +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const FORK_TARGET: GitPushTarget = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' +} +const REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo + +function depsWithStore(overrides: Partial = {}): PtySpawnIpcDeps { + return { + store: { + getWorktreeMeta: vi.fn().mockReturnValue({ pushTarget: FORK_TARGET } as WorktreeMeta), + getRepo: vi.fn().mockReturnValue(REPO), + ...overrides + } as unknown as Store + } as unknown as PtySpawnIpcDeps +} + +function baseArgs(overrides: Partial = {}): PtySpawnIpcArgs { + return { cols: 80, rows: 24, worktreeId: WORKTREE_ID, ...overrides } +} + +describe('triggerPtySpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + triggerMock.mockReset() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('is a no-op when args has no worktreeId', () => { + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs({ worktreeId: undefined })) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op when deps has no store', () => { + triggerPtySpawnPushTargetMaterialization({} as unknown as PtySpawnIpcDeps, baseArgs()) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a malformed worktreeId (no separator)', () => { + triggerPtySpawnPushTargetMaterialization( + depsWithStore(), + baseArgs({ worktreeId: 'not-a-valid-id' }) + ) + expect(triggerMock).not.toHaveBeenCalled() + }) + + it('parses the worktreeId, looks up the push target and repo, and delegates', () => { + const deps = depsWithStore() + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(deps.store!.getWorktreeMeta).toHaveBeenCalledWith(WORKTREE_ID) + expect(deps.store!.getRepo).toHaveBeenCalledWith(REPO_ID) + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + it('passes null when the repo lookup misses', () => { + const deps = depsWithStore({ getRepo: vi.fn().mockReturnValue(undefined) }) + triggerPtySpawnPushTargetMaterialization(deps, baseArgs()) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + null, + deps.store, + REPO_ID, + WORKTREE_ID + ) + }) + + // Why: many pty:spawn unit tests supply a narrow fake Store missing these methods -- + // this is the actual bug the hook must guard against (#17828), not a hypothetical. + // Optional chaining degrades the lookups to undefined/null; the underlying trigger + // itself no-ops on an undefined push target, so this never blocks or throws on spawn. + it('does not throw when the store lacks getWorktreeMeta/getRepo, delegating with undefined/null', () => { + const partialStore = {} as Store + expect(() => + triggerPtySpawnPushTargetMaterialization( + { store: partialStore } as unknown as PtySpawnIpcDeps, + baseArgs() + ) + ).not.toThrow() + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + undefined, + null, + partialStore, + REPO_ID, + WORKTREE_ID + ) + }) + + it('warns and swallows an error thrown by the underlying trigger', () => { + triggerMock.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + triggerPtySpawnPushTargetMaterialization(depsWithStore(), baseArgs()) + ).not.toThrow() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to trigger push target materialization'), + expect.any(Error) + ) + }) + + it('strips a folder-workspace instance suffix from the worktree path before delegating', () => { + const instanceId = 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f789' + const deps = depsWithStore() + const suffixedId = `${WORKTREE_ID}::workspace:${instanceId}` + triggerPtySpawnPushTargetMaterialization(deps, baseArgs({ worktreeId: suffixedId })) + + expect(triggerMock).toHaveBeenCalledWith( + WORKTREE_PATH, + FORK_TARGET, + REPO, + deps.store, + REPO_ID, + suffixedId + ) + }) +}) diff --git a/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts new file mode 100644 index 00000000000..d9a30326155 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-push-target-materialization.ts @@ -0,0 +1,40 @@ +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import { triggerTerminalSpawnPushTargetMaterialization } from '../../../runtime/runtime-terminal-spawn-push-target-materialization' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +// Why (#17828): pty:spawn is the desktop GUI's own terminal path (new tab, split, reattach) -- +// raw git commands can run here before any Orca-driven sync, so a deferred fork-PR remote must +// exist first. Mirrors the agent/background-terminal hook in +// runtime-terminal-spawn-push-target-materialization.ts, which this delegates to; fire-and-forget +// and a no-op once the remote already exists, so it is safe on every spawn including reattaches. +export function triggerPtySpawnPushTargetMaterialization( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): void { + if (!args.worktreeId || !deps.store) { + return + } + const parsed = splitWorktreeIdForFilesystem(args.worktreeId) + if (!parsed) { + return + } + // Why: never let a partial/fake Store (many pty:spawn unit tests supply a narrow one) or an + // unexpected lookup failure turn this best-effort hook into a spawn-blocking exception. + try { + const pushTarget = deps.store.getWorktreeMeta?.(args.worktreeId)?.pushTarget + const repo = deps.store.getRepo?.(parsed.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + parsed.worktreePath, + pushTarget, + repo, + deps.store, + parsed.repoId, + args.worktreeId + ) + } catch (error) { + console.warn( + `[pty-spawn] failed to trigger push target materialization for ${args.worktreeId}:`, + error + ) + } +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts index 748eb5d8f62..82e2d33f383 100644 --- a/src/main/ipc/pty/ipc/spawn-run.ts +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -7,6 +7,7 @@ import { buildPtyIpcSpawnOptions } from './spawn-options' import { executePtyIpcSpawn } from './spawn-execute' import { commitPtyIpcSpawn } from './spawn-commit' import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import { triggerPtySpawnPushTargetMaterialization } from './spawn-push-target-materialization' import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { @@ -30,6 +31,7 @@ function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { } export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + triggerPtySpawnPushTargetMaterialization(deps, args) const ctx = createPtyIpcSpawnState(deps, args) const early = await beginPtyIpcSpawn(ctx) if (early) { diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts index 0b736acd482..eacd2cd133f 100644 --- a/src/main/ipc/worktree-push-target-cleanup.test.ts +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -108,8 +108,13 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { exec ) expect(removeCalls(exec)).toEqual([]) - // No probing at all when we won't act. - expect(exec).not.toHaveBeenCalled() + // Why: the store flag alone can't rule out ownership -- on-demand + // materialization (#17828) never sets it, so cleanup also probes the + // repo-local `orca-created` config provenance before bailing. + expect(exec).toHaveBeenCalledWith( + ['config', '--get', `remote.${FORK_REMOTE}.orca-created`], + REPO_PATH + ) }) it('never touches origin or upstream', async () => { @@ -242,6 +247,20 @@ describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { expect(removeCalls(exec)).toEqual([]) }) + it('removes a remote owned only via git-config provenance (lazily materialized, #17828)', async () => { + // Why: on-demand materialization never sets the store's `remoteCreated` + // flag, so ownership must also be provable from `remote..orca-created`. + const exec = makeExec({ branchConfig: 'true' }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteCreated: false }), + storeOf({ 'repo-1::/wt/a': forkTarget({ remoteCreated: false }) }), + exec + ) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + it('does nothing when the remote is already gone (get-url throws)', async () => { const exec = makeExec({ getUrlThrows: true }) await cleanupUnusedWorktreePushTargetRemoteWithExec( diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts index 9bf29f718b2..09918ffe8f7 100644 --- a/src/main/ipc/worktree-push-target-cleanup.ts +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -16,7 +16,11 @@ export type GitRemoteExec = ( args: string[], cwd: string ) => Promise<{ stdout: string; stderr?: string }> -export type WorktreePushTargetStore = Pick +// Why: `setWorktreeMeta` is optional so existing narrow test stubs (only +// `getAllWorktreeMeta`) keep compiling; callers that want materialize-time +// provenance persistence (worktree-remote.ts) pass a store that has it. +export type WorktreePushTargetStore = Pick & + Partial> export function sameGitHubRemoteUrl(left: string, right: string): boolean { if (left === right) { @@ -181,6 +185,26 @@ function isBranchConfigSeparator(code: number): boolean { return code === 32 || (code >= 9 && code <= 13) } +// Why: on-demand materialization (push/pull/fetch/fast-forward, #17828) never +// updates the store's `pushTarget.remoteCreated` flag, so ownership must also be +// readable from the repo-local `remote..orca-created` config Orca writes +// when it creates the remote (see `worktree-push-target-setup.ts`). +async function remoteHasOrcaProvenance( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get', `remote.${remoteName}.orca-created`], + repoPath + ) + return stdout.trim() === 'true' + } catch { + return false + } +} + // Exported for unit tests: the `execGit` seam lets tests drive the multi-fork // cleanup matrix without touching a real repo. export async function cleanupUnusedWorktreePushTargetRemoteWithExec( @@ -190,11 +214,12 @@ export async function cleanupUnusedWorktreePushTargetRemoteWithExec( store: WorktreePushTargetStore, execGit: GitRemoteExec ): Promise { + if (!target?.remoteUrl || target.remoteName === 'origin' || target.remoteName === 'upstream') { + return + } if ( - !target?.remoteCreated || - !target.remoteUrl || - target.remoteName === 'origin' || - target.remoteName === 'upstream' + !target.remoteCreated && + !(await remoteHasOrcaProvenance(execGit, repoPath, target.remoteName)) ) { return } diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index be718cfd10c..2d9670c2f9b 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -5,7 +5,9 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' type ExecMock = Mock @@ -15,9 +17,17 @@ const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' // A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so -// later lookups see the new remote, matching real git behavior. -function makeRepoExec(remotes: Record): ExecMock { +// later lookups see the new remote, matching real git behavior. Defaults +// `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD +// always resolves to one (mirrors real git, unlike an empty-stdout stub). +function makeRepoExec( + remotes: Record, + checkedOutBranch = 'local-branch' +): ExecMock { return vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref' && args[1] === '--short' && args[2] === 'HEAD') { + return { stdout: `${checkedOutBranch}\n`, stderr: '' } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: Object.keys(remotes).join('\n'), stderr: '' } } @@ -80,6 +90,29 @@ describe('prepareWorktreePushTargetWithExec', () => { }) }) + it('records repo-local provenance on the remote it adds (#17828)', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + // Why: cleanup's ownership check must survive a store purge (worktree-push-target-cleanup.ts). + // Narrowing the refspec (#17887) also writes `config` calls, so scope to the marker itself. + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([ + ['config', 'remote.pr-contributor-orca.orca-created', 'true'] + ]) + }) + + it('does not record provenance when reusing an existing remote', async () => { + const exec = makeRepoExec({ + origin: 'git@github.com:stablyai/orca.git', + 'pr-contributor-orca': FORK_HTTPS + }) + + await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['config', 'remote.pr-contributor-orca.orca-created'])).toEqual([]) + }) + it('reuses an existing remote pointing at the same fork (SSH vs HTTPS) without adding', async () => { const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git', @@ -158,6 +191,38 @@ describe('findRemoteForUrl', () => { }) }) +describe('remoteAlreadyMatchesUrl', () => { + it('matches an exact URL', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_SSH }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('matches by GitHub owner/repo across URL protocols', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': FORK_HTTPS }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(true) + }) + + it('returns false when the named remote points elsewhere', async () => { + const exec = makeRepoExec({ + 'pr-contributor-orca': 'git@github.com:someone-else/orca.git' + }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) + + it('returns false when the named remote does not exist', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + await expect( + remoteAlreadyMatchesUrl(exec, REPO, 'pr-contributor-orca', FORK_SSH) + ).resolves.toBe(false) + }) +}) + describe('ensureUniqueRemoteName', () => { it('returns the preferred name when it is free', async () => { const exec = makeRepoExec({ origin: 'x' }) @@ -190,6 +255,46 @@ describe('configureCreatedWorktreePushTargetWithExec', () => { }) }) +describe('restoreUpstreamAfterMaterialize', () => { + it('points the checked-out branch upstream at the fork remote', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(exec).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/fix', 'local-branch'], + '/wt/path' + ) + expect(result).toBe(target) + }) + + it('is a no-op when the target has no remoteUrl', async () => { + const exec = makeRepoExec({}, 'local-branch') + const target: GitPushTarget = { remoteName: 'origin', branchName: 'feature' } + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) + + it('is a no-op when HEAD is detached (no checked-out branch)', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + throw new Error('fatal: ref HEAD is not a symbolic ref') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await restoreUpstreamAfterMaterialize(exec, '/wt/path', target) + + expect(callsMatching(exec, ['branch', '--set-upstream-to'])).toEqual([]) + expect(result).toBe(target) + }) +}) + describe('prepareWorktreePushTargetWithExec rollback', () => { it('removes the remote it just added when the fetch fails', async () => { const remotes: Record = { origin: 'git@github.com:stablyai/orca.git' } diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index e064b1f35c3..59ef4c8fea5 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -49,6 +49,54 @@ export async function findRemoteForUrl( return null } +// O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): +// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan +// once a fork remote already exists under its expected name (#17828). +export async function remoteAlreadyMatchesUrl( + execGit: GitRemoteExec, + repoPath: string, + remoteName: string, + remoteUrl: string +): Promise { + try { + const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath) + const candidateUrl = stdout.trim() + if (candidateUrl === remoteUrl) { + return true + } + const target = parseGitHubOwnerRepo(remoteUrl) + const candidate = parseGitHubOwnerRepo(candidateUrl) + return Boolean( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) + } catch { + return false + } +} + +// Why (#17828 CodeRabbit follow-up): a deferred remote materialized after create +// (terminal spawn, push/pull/fetch) must restore the upstream link create used to +// configure, or raw `git pull`/`git log @{u}..` keep failing even once the remote +// exists. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref +// and can differ from the worktree's local branch name (rename-on-collision). +export async function resolveCheckedOutBranchName( + execGit: GitRemoteExec, + repoPath: string +): Promise { + try { + const { stdout } = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath) + const branch = stdout.trim() + return branch.length > 0 ? branch : null + } catch { + // Detached HEAD or an unreadable ref -- nothing to point upstream. + return null + } +} + export async function ensureUniqueRemoteName( execGit: GitRemoteExec, repoPath: string, @@ -103,16 +151,26 @@ export async function prepareWorktreePushTargetWithExec( remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) // Why: `-t --no-tags` means this remote is never, even transiently, // written with the wide default `refs/heads/*` refspec + tag auto-follow (#17828). - // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately - // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` - // (see that function's comment for why the suffix matters). await execGit( ['remote', 'add', '-t', target.branchName, '--no-tags', remoteName, target.remoteUrl], repoPath ) - await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) - remoteCreated = true remoteAddedHere = true + try { + // `-t` itself writes a literal (non-wildcard-suffixed) refspec, so immediately + // rewrite it to the trailing-`*` form via `ensureRemoteTracksBranchNarrowly` + // (see that function's comment for why the suffix matters). + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, remoteName, target.branchName) + // Why: repo-local provenance that survives a store purge and is removed + // atomically with the remote itself, unlike the store's `remoteCreated` flag. + await execGit(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } catch (error) { + // Why: a half-configured remote with no provenance marker is unreclaimable -- + // cleanup only runs off that marker, so a failure here must undo the add. + await execGit(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } @@ -138,6 +196,31 @@ export async function prepareWorktreePushTargetWithExec( } } +// Why (#17828 CodeRabbit follow-up, restructured per review): materializing the remote +// alone isn't enough -- raw `git pull`/`git push`/`git log @{u}..` still fail without the +// upstream link create-time configuration used to set up. This must run at the *materializer* +// level (called by both the short-circuit and full-prepare paths in worktree-remote.ts), not +// buried inside `prepare*`, or every call after the first materialize -- and any sibling +// worktree that reuses the same fork remote -- never reaches it. Unconditional (not just +// "newly added") because a reused remote's upstream for *this* worktree's branch isn't +// guaranteed set. The checked-out branch is resolved fresh rather than threaded through +// every materialize call site, since `target.branchName` is the fork's PR head ref and can +// differ from the worktree's local branch name (rename-on-collision). +export async function restoreUpstreamAfterMaterialize( + execGit: GitRemoteExec, + worktreePath: string, + target: GitPushTarget +): Promise { + if (!target.remoteUrl) { + return target + } + const checkedOutBranch = await resolveCheckedOutBranchName(execGit, worktreePath) + if (!checkedOutBranch) { + return target + } + return configureCreatedWorktreePushTargetWithExec(execGit, worktreePath, checkedOutBranch, target) +} + export async function configureCreatedWorktreePushTargetWithExec( execGit: GitRemoteExec, worktreePath: string, diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts new file mode 100644 index 00000000000..68ed98ab35c --- /dev/null +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -0,0 +1,593 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshGitProvider } from '../providers/ssh-git-provider' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { WorktreePushTargetStore } from './worktree-push-target-cleanup' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) +vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from './worktree-remote' + +const REPO_PATH = '/repo-root' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +describe('materializeWorktreePushTargetRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('is a no-op when the target already reports remoteCreated', async () => { + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo target with no remoteUrl', async () => { + const target = forkTarget({ remoteUrl: undefined }) + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream and widens the refspec', async () => { + // Why (#17828 review follow-up): the short-circuit is the common case for every call + // after the first, and for a sibling worktree reusing the same fork remote under a + // different branch -- it must still restore the upstream link and widen the refspec. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual([ + 'config', + '--add', + `remote.${FORK_REMOTE}.fetch`, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.tagOpt`, '--no-tags']) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // Mint uses the narrow `-t --no-tags` add form (#17887), not a bare `remote add`. + expect(calls).toContainEqual([ + 'remote', + 'add', + '-t', + target.branchName, + '--no-tags', + FORK_REMOTE, + FORK_URL + ]) + expect(calls).toContainEqual(['config', `remote.${FORK_REMOTE}.orca-created`, 'true']) + expect(calls).toContainEqual([ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ]) + }) + + it('fetches the missing tracking ref before restoring upstream on the short-circuit path (#17828 sibling worktree)', async () => { + // Why: a sibling worktree short-circuiting onto an already-existing remote under a + // *new* branch has a widened refspec but no tracking ref yet -- against real git, + // `branch --set-upstream-to` hard-fails with "the requested upstream branch does not + // exist" unless something fetches that branch first. Verified against a real git + // fixture, not just this mock (see PR discussion). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemote(REPO_PATH, target) + + expect(result).toBe(target) + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([ + [ + [ + 'fetch', + FORK_REMOTE, + `+refs/heads/${target.branchName}*:refs/remotes/${FORK_REMOTE}/${target.branchName}*` + ], + expect.objectContaining({ timeout: expect.any(Number) }) + ] + ]) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'rev-parse', + '--verify', + '--quiet', + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + }) + + it('skips the fetch when the tracking ref already exists on the short-circuit path', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + // rev-parse succeeds by default (ref already exists) -- no fetch should follow. + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + + await materializeWorktreePushTargetRemote(REPO_PATH, target) + + const fetchCalls = gitExecFileAsyncMock.mock.calls.filter( + (call) => (call[0] as string[])[0] === 'fetch' + ) + expect(fetchCalls).toEqual([]) + }) + + it("gives a joiner its own branch wiring instead of the minting sibling's target", async () => { + // Why (#17828 review): the single flight is keyed on the remote, but the refspec widen, + // tracking-ref fetch and upstream link are all per-branch. A sibling worktree joining an + // in-flight mint for a *different* branch previously received the minter's target and + // skipped all three, leaving its own branch with no upstream. + let remoteExists = false + let releaseAdd!: () => void + const addGate = new Promise((resolve) => { + releaseAdd = resolve + }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (!remoteExists) { + throw new Error('No such remote') + } + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'add') { + await addGate + remoteExists = true + return { stdout: '', stderr: '' } + } + if (args[0] === 'config' && args[1] === '--get-all') { + throw new Error('no such section') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'joiner/branch\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + releaseAdd() + const [, joined] = await Promise.all([minter, joiner]) + + // The joiner keeps its own branch rather than inheriting the minter's. + expect(joined.branchName).toBe('joiner/branch') + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/joiner/branch`, + 'joiner/branch' + ]) + // Exactly one mint: the joiner must not have raced a second `remote add`. + expect(calls.filter((call) => call[0] === 'remote' && call[1] === 'add')).toHaveLength(1) + }) + + it('propagates a failed mint instead of adopting a remote the rollback removed', async () => { + // Why (#17828 review): both mint rollbacks `remote remove`, so adopting after a failed mint + // writes `remote..fetch` with no URL -- a config-only ghost that breaks + // `git fetch --all`, forces later mints to a `-2` name, and survives `git remote remove`. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('mint failed') + } + return { stdout: '', stderr: '' } + }) + + const minter = materializeWorktreePushTargetRemote(REPO_PATH, forkTarget()) + await Promise.resolve() + const joiner = materializeWorktreePushTargetRemote( + REPO_PATH, + forkTarget({ branchName: 'joiner/branch' }) + ) + + await expect(minter).rejects.toThrow() + await expect(joiner).rejects.toThrow() + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + // No ghost: nothing wrote refspec or tagOpt config for a remote that does not exist. + expect( + calls.filter((call) => call[0] === 'config' && String(call[2] ?? '').includes(FORK_REMOTE)) + ).toHaveLength(0) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + // Why (#17828 review follow-up): on-demand materialization never went through the + // create-time setWorktreeMeta write, so a lazily-minted remote stayed invisible to + // #17842's orphan sweep (which gates solely on the stored remoteCreated flag). + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemote( + REPO_PATH, + target, + store, + undefined, + {}, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + it('does not touch the store when no worktreeId is provided', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + await materializeWorktreePushTargetRemote(REPO_PATH, target, store) + + expect(setWorktreeMeta).not.toHaveBeenCalled() + }) +}) + +describe('materializeWorktreePushTargetRemoteSsh', () => { + it('is a no-op when the target already reports remoteCreated', async () => { + const exec = vi.fn() + const target = forkTarget({ remoteCreated: true }) + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(exec).not.toHaveBeenCalled() + }) + + it('short-circuits the remote probe but still restores upstream (refspec widening is a local-only gap)', async () => { + // Why: mirrors the local short-circuit's upstream restore. Refspec widening is + // intentionally NOT mirrored here -- SSH's bare `remote add` is a pre-existing, + // documented gap this fix does not touch. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['remote', 'get-url', FORK_REMOTE]) + expect(calls).toContainEqual(['symbolic-ref', '--short', 'HEAD']) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('fetches the missing tracking ref (one-off, no config write) before restoring upstream on the short-circuit path', async () => { + // SSH mirror of the local sibling-worktree fix: refspec widening stays out of scope + // here, but the branch must still be fetched once before `--set-upstream-to` can + // succeed for a branch this remote has never pulled in. + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + if (args[0] === 'rev-parse') { + throw new Error('unknown revision') + } + if (args[0] === 'symbolic-ref') { + return { stdout: 'contributor/fix\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toBe(target) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual([ + 'branch', + '--set-upstream-to', + `${FORK_REMOTE}/${target.branchName}`, + 'contributor/fix' + ]) + // Still no config write -- the fetch is a one-off refspec argument, not a widen. + expect(calls.some((call) => call[0] === 'config' && String(call[2]).includes('.fetch'))).toBe( + false + ) + }) + + it('materializes the remote (add + provenance + fetch) when the probe misses', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + const calls = exec.mock.calls.map((call) => call[0] as string[]) + expect(calls).toContainEqual(['check-ref-format', '--branch', target.branchName]) + expect(calls).toContainEqual(['remote', 'add', FORK_REMOTE, FORK_URL]) + // Provenance is a narrow RPC, not exec: the relay's generic git.exec blocks config writes. + expect(markRemoteOrcaCreated).toHaveBeenCalledWith(REPO_PATH, FORK_REMOTE) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + REPO_PATH, + FORK_REMOTE, + target.branchName, + `refs/remotes/${FORK_REMOTE}/${target.branchName}` + ) + }) + + it('persists remoteCreated to the store when a worktreeId is provided and the mint succeeds', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => {}) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + const setWorktreeMeta = vi.fn() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({}), + setWorktreeMeta + } as unknown as WorktreePushTargetStore + + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target, + store, + undefined, + 'worktree-1' + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(setWorktreeMeta).toHaveBeenCalledWith('worktree-1', { + pushTarget: { ...target, remoteCreated: true } + }) + }) + + // Moved from worktrees-ssh-fork-push-target-remote.test.ts: this behavior lives in + // prepareWorktreePushTargetSsh (invoked here through the materialize wrapper, once + // the fast probe misses) and is unchanged -- it just no longer runs at create time. + it('names the relay upgrade when an older host still rejects the fork remote', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + if (args[0] === 'remote' && args[1] === 'add') { + throw new Error('Destructive git remote operations are not allowed via exec') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn() + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('Reconnect to deploy the latest relay') + expect(fetchRemoteTrackingRef).not.toHaveBeenCalled() + }) + + it('drops the fork remote it just added when the SSH head fetch fails', async () => { + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + throw new Error('No such remote') + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const markRemoteOrcaCreated = vi.fn(async () => {}) + const target = forkTarget() + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + REPO_PATH, + target + ) + ).rejects.toThrow('network unreachable') + + expect(exec).toHaveBeenCalledWith(['remote', 'remove', FORK_REMOTE], REPO_PATH) + }) + + // Regression: the rollback must not fire on ownership inherited from a sibling + // worktree, deleting the remote that worktree is still pushing through. The probe + // misses under the *requested* remote name so this reaches prepareWorktreePushTargetSsh's + // own by-URL reuse scan, which finds the sibling's differently-named remote. + it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { + const SIBLING_REMOTE = 'pr-contributor-orca-existing' + const exec = vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + if (args[2] === SIBLING_REMOTE) { + return { stdout: `${FORK_URL}\n`, stderr: '' } + } + throw new Error('No such remote') + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) + const fetchRemoteTrackingRef = vi.fn(async () => { + throw new Error('network unreachable') + }) + const target = forkTarget() + const store: WorktreePushTargetStore = { + getAllWorktreeMeta: () => ({ + 'repo::/repo-root-sibling': { + pushTarget: { + remoteName: SIBLING_REMOTE, + branchName: 'contributor/other', + remoteUrl: FORK_URL, + remoteCreated: true + } + } + }) + } as unknown as WorktreePushTargetStore + + await expect( + materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef } as unknown as SshGitProvider, + REPO_PATH, + target, + store + ) + ).rejects.toThrow('network unreachable') + + expect(exec).not.toHaveBeenCalledWith(['remote', 'remove', SIBLING_REMOTE], REPO_PATH) + expect(exec).not.toHaveBeenCalledWith( + ['remote', 'add', expect.anything(), expect.anything()], + REPO_PATH + ) + }) +}) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index ce6ee7b3394..0b985a0311f 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -112,8 +112,15 @@ import { configureCreatedWorktreePushTargetWithExec, ensureUniqueRemoteName, findRemoteForUrl, - prepareWorktreePushTargetWithExec + prepareWorktreePushTargetWithExec, + remoteAlreadyMatchesUrl, + restoreUpstreamAfterMaterialize } from './worktree-push-target-setup' +import { + buildNarrowForkFetchRefspec, + ensureRemoteTracksBranchNarrowly, + forkRemoteTrackingRefExists +} from '../git/fork-remote-refspec' import { migrateForkRemoteRefspecs } from './worktree-push-target-refspec-migration' import { isENOENT } from './filesystem-path-containment' import { @@ -166,9 +173,36 @@ const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000 const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512 // Why: bound the fallback `git fetch origin` so a Windows credential-manager GUI hang (STA-1292) can't wedge worktree creation forever. const CREATE_BASE_FALLBACK_FETCH_TIMEOUT_MS = 60_000 +// Why (#17828 CodeRabbit follow-up): the deferred materialize fetch runs off the main +// create path (terminal spawn, mid-session sync) with nothing else bounding it -- same +// STA-1292 hang risk as the create-time fallback above, so mirror its timeout. +const DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS = 60_000 const sshWorktreeCreateFetchInflight = new Map>() const sshWorktreeCreateFetchCompletedAt = new Map() const sshWorktreeCreateFetchQueueTail = new Map>() +// Why (#17828 CodeRabbit follow-up): a terminal spawn and an explicit sync action can +// both call materialize for the same worktree remote at once; without single-flighting, +// the loser's `remote add` races the winner's fetch and can strand a duplicate remote. +const worktreePushTargetMaterializeInflight = new Map>() +const sshWorktreePushTargetMaterializeInflight = new WeakMap< + SshGitProvider, + Map> +>() + +function worktreePushTargetMaterializeKey(repoPath: string, remoteName: string): string { + return `${repoPath}::${remoteName}` +} + +function getSshWorktreePushTargetMaterializeInflight( + provider: SshGitProvider +): Map> { + let inflight = sshWorktreePushTargetMaterializeInflight.get(provider) + if (!inflight) { + inflight = new Map() + sshWorktreePushTargetMaterializeInflight.set(provider, inflight) + } + return inflight +} const sshWorktreeCreateBasePlanInflight = new Map< string, Promise @@ -972,7 +1006,16 @@ export async function prepareWorktreePushTarget( ): Promise { await validateGitPushTarget(repoPath, target, gitOptions) const prepared = await prepareWorktreePushTargetWithExec( - (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }), + // Why: this is only ever reached via the deferred materialize path (#17828) -- bound + // just the network fetch so it can't hang indefinitely (see the timeout constant's + // comment). The other calls this makes (`remote`, `remote add`, `config`) are local-only + // and must stay untimed, matching every other local git call in this file. + (args, cwd) => + gitExecFileAsync(args, { + cwd, + ...gitOptions, + ...(args[0] === 'fetch' ? { timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } : {}) + }), repoPath, target, (existingRemote) => @@ -993,6 +1036,170 @@ export async function prepareWorktreePushTarget( return prepared } +// Why: on-demand twin of `prepareWorktreePushTarget` for push/pull/fetch/ +// fast-forward (#17828) -- a deferred fork remote is materialized the first +// time it's needed. The cheap named-remote probe keeps every push after the +// first one down to a handful of extra subprocesses (probe, refspec-widen, +// upstream-restore) instead of repeating the O(remotes) scan +// `prepareWorktreePushTargetWithExec` does when it must add. +export async function materializeWorktreePushTargetRemote( + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + gitOptions: { wslDistro?: string } = {}, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => gitExecFileAsync(args, { cwd, ...gitOptions }) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = worktreePushTargetMaterializeInflight.get(key) + if (existing) { + // Why: the single flight is keyed on the *remote*, but everything after the remote add is + // per-branch. A joiner waiting on a sibling worktree's mint must not take that sibling's + // target -- it would inherit the sibling's branch and silently skip its own refspec widen, + // tracking-ref fetch, and upstream link. Wait for the remote, then do its own. + // + // Why not swallow the rejection: both mint rollbacks remove the remote, so adopting after a + // failed mint would write `remote..fetch` with no URL -- a config-only ghost that + // breaks `git fetch --all`, forces every later mint to a `-2` name, and survives + // `git remote remove`. Propagate instead; the map is already cleared, so a retry re-mints. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingForkRemoteForBranch( + execGit, + repoPath, + target, + gitOptions, + store, + repoId, + worktreeId + ) + ) + } + const promise = prepareWorktreePushTarget(repoPath, target, store, repoId, gitOptions) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (worktreePushTargetMaterializeInflight.get(key) === promise) { + worktreePushTargetMaterializeInflight.delete(key) + } + }) + worktreePushTargetMaterializeInflight.set(key, promise) + return promise +} + +// Why: the remote already exists -- minted by an earlier call, by create, or by a sibling +// worktree. Everything left is per-branch, and it must run for *this* target: the refspec +// widen, the tracking-ref fetch, and the upstream link. Previously these only ran inside +// prepareWorktreePushTarget, unreachable once the remote was there. +async function adoptExistingForkRemoteForBranch( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + gitOptions: { wslDistro?: string }, + store: WorktreePushTargetStore | undefined, + repoId: string | undefined, + worktreeId: string | undefined +): Promise { + await ensureRemoteTracksBranchNarrowly(execGit, repoPath, target.remoteName, target.branchName) + // Why: widening only rewrites config -- it never imports anything. For a sibling worktree's + // first materialize of a *new* branch on an already-existing remote, the branch's tracking + // ref doesn't exist yet, and `--set-upstream-to` below hard-fails with "the requested + // upstream branch does not exist" (verified against real git). Skip the fetch when the ref + // is already there so a repeat push/pull materialize stays a local-only probe. + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + // Why: a network fetch, unlike the local-only probes above -- bound it the same as the + // full-mint path's fetch so it can't hang indefinitely. + await gitExecFileAsync( + [ + 'fetch', + target.remoteName, + buildNarrowForkFetchRefspec(target.remoteName, target.branchName) + ], + { cwd: repoPath, ...gitOptions, timeout: DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS } + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + // Why: a remote another worktree minted is still Orca-owned. Without stamping ownership on + // the adopting worktree too, removing the minter leaves the survivor's metadata unowned and + // #17842's sweep -- which gates solely on `remoteCreated` -- can never reclaim the remote. + // Why derive: no caller supplies both -- IPC handlers pass a store with no repo id, runtime + // commands pass a repo id with no store -- so requiring both made this branch unreachable. + const ownerRepoId = repoId ?? (worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined) + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// Why: the mint single flight only covers `remote add`. Every adopter afterwards writes +// `remote..fetch` and `.tagOpt`, and concurrent `git config --add` has no lock retry -- +// measured 135/160 failures at 8-way concurrency, plus duplicate refspecs when two adopts add +// the same value. Chain adopts per remote so they serialize instead of fanning out. +const forkRemoteAdoptionQueue = new Map>() + +function runForkRemoteAdoption( + repoPath: string, + target: GitPushTarget, + run: () => Promise +): Promise { + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const previous = forkRemoteAdoptionQueue.get(key) + const next = previous ? previous.then(run, run) : run() + const settled = next.then( + () => undefined, + () => undefined + ) + forkRemoteAdoptionQueue.set(key, settled) + void settled.finally(() => { + if (forkRemoteAdoptionQueue.get(key) === settled) { + forkRemoteAdoptionQueue.delete(key) + } + }) + return next +} + +// Why (review follow-up): on-demand materialization never went through the create-time +// `setWorktreeMeta` write, so the store's `pushTarget.remoteCreated` flag stayed stale +// forever for a lazily-minted remote -- invisible to #17842's orphan sweep +// (`shouldReclaimPrRemote` gates solely on that flag) and to any SSH host whose relay +// predates `markRemoteOrcaCreated` (no git-config marker either). `setWorktreeMeta` is +// optional on `WorktreePushTargetStore` so narrow test/reconciliation stores keep compiling. +function persistMaterializedPushTargetIfCreated( + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined, + target: GitPushTarget +): void { + if (!target.remoteCreated || !worktreeId || !store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget: target }) +} + function isPushTargetRemoteCreatedByKnownWorktree( store: WorktreePushTargetStore, target: GitPushTarget, @@ -1062,7 +1269,7 @@ export async function configureCreatedWorktreePushTarget( ) } -async function prepareWorktreePushTargetSsh( +export async function prepareWorktreePushTargetSsh( provider: SshGitProvider, repoPath: string, target: GitPushTarget, @@ -1106,8 +1313,18 @@ async function prepareWorktreePushTargetSsh( } throw error } - remoteCreated = true remoteAddedHere = true + try { + // Why: repo-local provenance mirroring the local path (worktree-push-target-setup.ts). + // A narrow RPC, not provider.exec: the relay's generic git.exec blocks all config writes. + await provider.markRemoteOrcaCreated(repoPath, remoteName) + } catch (error) { + // Why: a remote with no provenance marker is unreclaimable -- cleanup only + // runs off that marker, so a failure here must undo the add. + await provider.exec(['remote', 'remove', remoteName], repoPath).catch(() => {}) + throw error + } + remoteCreated = true } } try { @@ -1129,6 +1346,96 @@ async function prepareWorktreePushTargetSsh( return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) } } +// SSH twin of `adoptExistingForkRemoteForBranch`. Refspec widening is intentionally absent -- +// SSH's bare `remote add` (no `-t`/`--no-tags`) is a pre-existing, documented gap -- but the +// tracking ref must still exist before `--set-upstream-to` can succeed, and the upstream link +// must be made against *this* target's branch rather than a minting sibling's. +async function adoptExistingSshForkRemoteForBranch( + provider: SshGitProvider, + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + store: WorktreePushTargetStore | undefined, + worktreeId: string | undefined +): Promise { + if ( + !(await forkRemoteTrackingRefExists(execGit, repoPath, target.remoteName, target.branchName)) + ) { + await provider.fetchRemoteTrackingRef( + repoPath, + target.remoteName, + target.branchName, + `refs/remotes/${target.remoteName}/${target.branchName}` + ) + } + const restored = await restoreUpstreamAfterMaterialize(execGit, repoPath, target) + const ownerRepoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : undefined + const owned = + store !== undefined && + ownerRepoId !== undefined && + isPushTargetRemoteCreatedByKnownWorktree(store, restored, ownerRepoId) + const adopted = owned ? { ...restored, remoteCreated: true } : restored + persistMaterializedPushTargetIfCreated(store, worktreeId, adopted) + return adopted +} + +// SSH twin of `materializeWorktreePushTargetRemote` -- the relay has no store +// access and trusts `pushTarget.remoteName` already exists, so a deferred fork +// remote must be materialized client-side before dispatching push/pull/fetch/ +// fast-forward over the mux (#17828). +export async function materializeWorktreePushTargetRemoteSsh( + provider: SshGitProvider, + repoPath: string, + target: GitPushTarget, + store?: WorktreePushTargetStore, + repoId?: string, + worktreeId?: string +): Promise { + if (!target.remoteUrl || target.remoteCreated) { + return target + } + const execGit: GitRemoteExec = (args, cwd) => provider.exec(args, cwd) + if (await remoteAlreadyMatchesUrl(execGit, repoPath, target.remoteName, target.remoteUrl)) { + // Why (review follow-up): mirrors the local short-circuit's upstream restore. Refspec + // widening is intentionally NOT mirrored here -- SSH's bare `remote add` (no `-t`/ + // `--no-tags`, see prepareWorktreePushTargetSsh) is a pre-existing, documented gap this + // fix does not touch. + // + // The tracking ref itself, though, must still exist before `--set-upstream-to` below + // can succeed -- a reused remote's wide default refspec covers a future bare fetch, + // but imports nothing on its own. Fetch just this branch (a one-off refspec argument, + // not a config write) when it isn't already there; skip it otherwise so a repeat + // push/pull materialize stays a local-only probe with no relay round-trip. + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const inflight = getSshWorktreePushTargetMaterializeInflight(provider) + const key = worktreePushTargetMaterializeKey(repoPath, target.remoteName) + const existing = inflight.get(key) + if (existing) { + // Why: same per-branch reasoning as the local twin -- a joiner must not inherit the + // minter's branch. Rejection propagates rather than adopting a remote the rollback removed. + await existing + return runForkRemoteAdoption(repoPath, target, () => + adoptExistingSshForkRemoteForBranch(provider, execGit, repoPath, target, store, worktreeId) + ) + } + const promise = prepareWorktreePushTargetSsh(provider, repoPath, target, store, repoId) + .then((prepared) => restoreUpstreamAfterMaterialize(execGit, repoPath, prepared)) + .then((prepared) => { + persistMaterializedPushTargetIfCreated(store, worktreeId, prepared) + return prepared + }) + .finally(() => { + if (inflight.get(key) === promise) { + inflight.delete(key) + } + }) + inflight.set(key, promise) + return promise +} + export async function cleanupUnusedWorktreePushTargetRemoteSsh( provider: SshGitProvider, repoPath: string, @@ -1763,17 +2070,9 @@ export async function createRemoteWorktree( } } - let preparedPushTarget: GitPushTarget | undefined - if (args.pushTarget) { - // Why: fork-PR SSH worktrees need contributor-remote setup before create, else Push/Sync target origin. - preparedPushTarget = await prepareWorktreePushTargetSsh( - provider, - repo.path, - args.pushTarget, - store, - repo.id - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget try { await timing.time('git_worktree_add', async () => @@ -1854,8 +2153,10 @@ export async function createRemoteWorktree( const now = Date.now() // Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch. const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTargetWithExec( (args, cwd) => provider.exec(args, cwd), created.path, @@ -2369,20 +2670,9 @@ export async function createLocalWorktree( } emitCreateWorktreeProgress(mainWindow, 'creating', args.creationId) - let preparedPushTarget: GitPushTarget | undefined - const requestedPushTarget = args.pushTarget - if (requestedPushTarget) { - // Why: validate/fetch the contributor remote before create so a failure doesn't leave a half-created worktree with conflicts on retry. - preparedPushTarget = await timing.time('prepare_push_target', () => - prepareWorktreePushTarget( - repo.path, - requestedPushTarget, - store, - repo.id, - localWorktreeGitOptions - ) - ) - } + // Why: defer the remote add + fetch to first push/pull/fetch/fast-forward + // (#17828) instead of paying it at create time for a read-only review. + const preparedPushTarget: GitPushTarget | undefined = args.pushTarget const suggestLocalBaseRefUpdate = !settings.refreshLocalBaseRefOnWorktreeCreate && @@ -2522,9 +2812,10 @@ export async function createLocalWorktree( await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName) } - let configuredPushTarget: GitPushTarget | undefined - if (preparedPushTarget) { - // Why: fork-PR review worktrees publish back to the PR author's branch; set upstream so Push/Sync use the contributor remote, not origin. + // Why: `--set-upstream-to` needs the remote to exist -- true for a same-repo + // target but not for a fork remote, which materializes lazily (#17828). + let configuredPushTarget: GitPushTarget | undefined = preparedPushTarget + if (preparedPushTarget && !preparedPushTarget.remoteUrl) { configuredPushTarget = await configureCreatedWorktreePushTarget( worktreePath, branchName, diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 934844de2e3..ac003b3a43e 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -427,7 +427,10 @@ describe('registerWorktreeHandlers', () => { }) }) - it('configures a PR push target during local create', async () => { + // Was "configures a PR push target during local create": create used to mint the + // fork remote up front. It now defers to first sync (#17828); the minting itself is + // covered by worktree-remote-push-target-materialization.test.ts. + it('defers the fork-PR remote during local create and persists the target unmaterialized', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -449,7 +452,7 @@ describe('registerWorktreeHandlers', () => { } }) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'remote', 'add', @@ -461,7 +464,7 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'fetch', 'pr-prateek-orca', @@ -469,7 +472,8 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo' } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + // Upstream can only be set once the remote exists, so it defers with the remote. + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', @@ -478,20 +482,25 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/improve-dashboard' } ) + // Exact object, not objectContaining: `remoteCreated` must stay absent until + // something actually mints the remote. expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git', - remoteCreated: true - }) + remoteUrl: 'git@github.com:prateek/orca.git' + } }) ) }) - it('keeps the Orca-created marker when a new worktree reuses an Orca-created fork remote', async () => { + // Was "keeps the Orca-created marker ...": create used to inherit the marker while + // minting. With minting deferred (#17828) create must not claim ownership it has not + // earned; marker inheritance now happens at materialization and is covered by + // worktree-push-target-setup.test.ts. + it('does not claim the Orca-created marker at create when a sibling worktree minted the fork remote', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/improve-dashboard', @@ -538,12 +547,11 @@ describe('registerWorktreeHandlers', () => { expect(store.setWorktreeMeta).toHaveBeenCalledWith( 'repo-1::/workspace/improve-dashboard', expect.objectContaining({ - pushTarget: expect.objectContaining({ + pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/new-fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - }) + remoteUrl: 'https://github.com/contributor/orca.git' + } }) ) }) diff --git a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts index c952a6be9df..fe80219fa8f 100644 --- a/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts +++ b/src/main/ipc/worktrees-ssh-fork-push-target-remote.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { validateGitExecArgs } from '../../relay/git-exec-validator' import { getSshGitProviderMock, getActiveMultiplexerMock } from './worktrees-test-module-mocks' import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemoteSsh } from './worktree-remote' +import type { SshGitProvider } from '../providers/ssh-git-provider' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -90,7 +92,10 @@ describe('registerWorktreeHandlers', () => { setupWorktreeHandlers() }) - it('adds the fork remote for an SSH fork-PR worktree through git.exec', async () => { + // Was "adds the fork remote ... through git.exec": create used to mint the fork + // remote unconditionally. It now defers to first sync (#17828) -- split in two so + // each half stays true to a single claim: create stays a no-op, sync still mints. + it('defers minting the fork remote for an SSH fork-PR worktree until first sync', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -147,11 +152,13 @@ describe('registerWorktreeHandlers', () => { } }) - expect(exec).toHaveBeenCalledWith( + expect(exec).not.toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) - expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + // fetchRemoteTrackingRef IS called once here, but for create's unrelated + // base-ref refresh (origin/main) -- not for the fork remote, which defers. + expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalledWith( '/remote/repo', 'pr-contributor-orca', 'contributor/fix', @@ -163,201 +170,58 @@ describe('registerWorktreeHandlers', () => { pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true + remoteUrl: 'https://github.com/contributor/orca.git' } }) ) }) - it('names the relay upgrade when an older host still rejects the fork remote', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const provider = { - exec: vi.fn().mockImplementation(async (args: string[]) => { - if (args[0] === 'remote' && args[1] === 'add') { - throw new Error('Destructive git remote operations are not allowed via exec') - } - if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }), - fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('Reconnect to deploy the latest relay') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - it('drops the fork remote it just added when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } + // Companion to the deferral test above: `materializeWorktreePushTargetRemoteSsh` is + // exactly what `git:push`/`git:pull`'s SSH dispatch calls before syncing, so this is + // "first sync" without needing the sync IPC handlers registered in this harness. + it('mints the fork remote for an SSH fork-PR worktree on first sync', async () => { const exec = vi.fn().mockImplementation(async (args: string[]) => { validateGitExecArgs(args) if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' } + throw new Error('No such remote') } if (args[0] === 'remote' && args.length === 1) { return { stdout: 'origin\n', stderr: '' } } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } return { stdout: '', stderr: '' } }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) + const fetchRemoteTrackingRef = vi.fn().mockResolvedValue(undefined) + const markRemoteOrcaCreated = vi.fn().mockResolvedValue(undefined) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'https://github.com/contributor/orca.git' } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).toHaveBeenCalledWith(['remote', 'remove', 'pr-contributor-orca'], '/remote/repo') - expect(provider.addWorktree).not.toHaveBeenCalled() - }) - - // Regression: the rollback used to fire on ownership inherited from a sibling - // worktree, deleting the remote that worktree was still pushing through. - it('keeps a reused fork remote a sibling worktree owns when the SSH head fetch fails', async () => { - const repo = { - id: 'repo-ssh', - path: '/remote/repo', - displayName: 'ssh', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1', - worktreeBaseRef: 'origin/main' - } - const exec = vi.fn().mockImplementation(async (args: string[]) => { - validateGitExecArgs(args) - if (args[0] === 'remote' && args[1] === 'get-url') { - return { - stdout: - args[2] === 'pr-contributor-orca' - ? 'git@github.com:contributor/orca.git\n' - : 'git@github.com:stablyai/orca.git\n', - stderr: '' - } - } - if (args[0] === 'remote' && args.length === 1) { - return { stdout: 'origin\npr-contributor-orca\n', stderr: '' } - } - if (args[0] === 'show-ref') { - throw Object.assign(new Error('missing exact ref'), { code: 1 }) - } - return { stdout: '', stderr: '' } - }) - const provider = { - exec, - fetchRemoteTrackingRef: vi - .fn() - .mockImplementation(async (_repoPath: string, remote: string) => { - if (remote === 'pr-contributor-orca') { - throw new Error('network unreachable') - } - }), - addWorktree: vi.fn().mockResolvedValue(undefined), - listWorktrees: vi.fn().mockResolvedValue([]) - } - const mux = { request: vi.fn().mockResolvedValue(undefined), notify: vi.fn() } - store.getRepos.mockReturnValue([repo]) - store.getRepo.mockReturnValue(repo) - store.getAllWorktreeMeta.mockReturnValue({ - 'repo-ssh::/remote/repo-sibling': { - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/other', - remoteUrl: 'https://github.com/contributor/orca.git', - remoteCreated: true - } - } - }) - getSshGitProviderMock.mockReturnValue(provider) - getActiveMultiplexerMock.mockReturnValue(mux) - - await expect( - handlers['worktrees:create'](null, { - repoId: 'repo-ssh', - name: 'contributor-fix', - branchNameOverride: 'contributor/fix', - pushTarget: { - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'https://github.com/contributor/orca.git' - } - }) - ).rejects.toThrow('network unreachable') - - expect(exec).not.toHaveBeenCalledWith( - ['remote', 'remove', 'pr-contributor-orca'], - '/remote/repo' + const result = await materializeWorktreePushTargetRemoteSsh( + { exec, fetchRemoteTrackingRef, markRemoteOrcaCreated } as unknown as SshGitProvider, + '/remote/repo', + target ) - expect(exec).not.toHaveBeenCalledWith( + + expect(result).toEqual({ ...target, remoteCreated: true }) + expect(exec).toHaveBeenCalledWith( ['remote', 'add', 'pr-contributor-orca', 'https://github.com/contributor/orca.git'], '/remote/repo' ) + expect(fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'pr-contributor-orca', + 'contributor/fix', + 'refs/remotes/pr-contributor-orca/contributor/fix' + ) + expect(markRemoteOrcaCreated).toHaveBeenCalledWith('/remote/repo', 'pr-contributor-orca') }) + + // The relay-upgrade-messaging, fetch-failure rollback, and sibling-remote-preserved + // cases used to be exercised here because create minted the remote unconditionally. + // That code (prepareWorktreePushTargetSsh) is unchanged -- it just no longer runs at + // create time for a fork remote, only from materializeWorktreePushTargetRemoteSsh on + // first sync. Coverage for all three moved with it to + // worktree-remote-push-target-materialization.test.ts, which calls that function directly. }) diff --git a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts index 85a015496ed..8c936764291 100644 --- a/src/main/ipc/worktrees-wsl-runtime-routing.test.ts +++ b/src/main/ipc/worktrees-wsl-runtime-routing.test.ts @@ -17,6 +17,7 @@ import { gitExecFileAsyncMock } from './worktrees-test-module-mocks' import { handlers, harnessRepo, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { materializeWorktreePushTargetRemote } from './worktree-remote' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { createdWorktreeList, @@ -243,7 +244,11 @@ describe('registerWorktreeHandlers', () => { expectEveryGitCallRoutedTo('Ubuntu') }) - it('routes fork push target setup through the selected WSL project runtime', async () => { + // Was "routes fork push target setup ... through create": create used to mint the + // fork remote (and route it to the selected WSL distro) unconditionally. It now + // defers to first sync (#17828) -- split in two so each half stays true to a single + // claim: create stays a no-op even for a WSL-routed repo, sync still routes to the distro. + it('does not mint a fork remote at create time for a WSL-routed worktree', async () => { mockSelectedWslProjectRuntime() listWorktreesMock.mockResolvedValue([ { @@ -266,6 +271,43 @@ describe('registerWorktreeHandlers', () => { } }) + const calls = gitExecFileAsyncMock.mock.calls.map((call) => call[0] as string[]) + expect(calls).not.toContainEqual(['check-ref-format', '--branch', 'contributor/wsl-fork']) + expect(calls.some((args) => args[0] === 'remote' && args[1] === 'add')).toBe(false) + expect(calls.some((args) => args[0] === 'fetch')).toBe(false) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + }) + ) + }) + + // Companion to the deferral test above: `materializeWorktreePushTargetRemote` is + // exactly what `git:push`/`git:pull`'s local dispatch calls (with the repo's resolved + // wslDistro) before syncing, so this is "first sync" without needing that IPC handler + // registered in this harness. + it('routes fork push target materialization through the selected WSL project runtime', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + const target = { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/wsl-fork', + remoteUrl: 'git@github.com:contributor/orca.git' + } + + const result = await materializeWorktreePushTargetRemote( + '/workspace/repo', + target, + undefined, + undefined, + { wslDistro: 'Ubuntu' } + ) + + expect(result).toEqual({ ...target, remoteCreated: true }) const wslRoutingOptions = { cwd: '/workspace/repo', wslDistro: 'Ubuntu' } expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/wsl-fork'], @@ -303,18 +345,29 @@ describe('registerWorktreeHandlers', () => { ['config', 'remote.pr-contributor-orca.tagOpt', '--no-tags'], wslRoutingOptions ) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['config', 'remote.pr-contributor-orca.orca-created', 'true'], + wslRoutingOptions + ) + // Why: the mint's fetch is the one call in this sequence that talks to the network -- + // bounded the same as the deferred short-circuit's fetch (see DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS) + // so a hung credential prompt can't wedge it forever. Every other call here is local-only + // and stays untimed, per `wslRoutingOptions` above. expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/wsl-fork*:refs/remotes/pr-contributor-orca/contributor/wsl-fork*' ], - wslRoutingOptions + { ...wslRoutingOptions, timeout: expect.any(Number) } ) - expect(gitExecFileAsyncMock).toHaveBeenCalledWith( - ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/wsl-fork', 'wsl-fork'], - { cwd: '/workspace/wsl-fork', wslDistro: 'Ubuntu' } + // wslDistro threaded through every subprocess this materialize made, not just the adds. + const distros = new Set( + gitExecFileAsyncMock.mock.calls.map( + ([, options]) => (options as { wslDistro?: string } | undefined)?.wslDistro + ) ) + expect(distros).toEqual(new Set(['Ubuntu'])) }) it('routes selected PR branch conflict lookup through the selected WSL project runtime', async () => { diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts index dd0915dafe5..3e6ebf76d74 100644 --- a/src/main/providers/ssh-git-provider-api.test.ts +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -54,6 +54,7 @@ describe('SshGitProvider public API parity', () => { 'worktreeIsClean', 'refreshLocalBaseRefForWorktreeCreate', 'renameCurrentBranch', + 'markRemoteOrcaCreated', 'forceDeletePreservedBranch', 'exec', 'clone', @@ -63,7 +64,7 @@ describe('SshGitProvider public API parity', () => { 'getRemoteCommitUrl' ] as const - expect(methods).toHaveLength(51) + expect(methods).toHaveLength(52) for (const method of methods) { expect(provider[method], method).toBeTypeOf('function') } diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts index 6ddff0a1f68..03722c93a52 100644 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ b/src/main/providers/ssh-git-provider-worktree.test.ts @@ -395,4 +395,38 @@ describe('SshGitProvider', () => { provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') ).rejects.toBe(error) }) + + it('markRemoteOrcaCreated sends the narrow provenance-marker request', async () => { + await provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + expect(mux.request).toHaveBeenCalledWith('git.markRemoteOrcaCreated', { + repoPath: '/home/user/repo', + remoteName: 'pr-contributor-orca' + }) + }) + + it('markRemoteOrcaCreated degrades to a one-time warning for an older relay', async () => { + mux.request.mockRejectedValue(methodNotFound('git.markRemoteOrcaCreated')) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).resolves.toBeUndefined() + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + + it('markRemoteOrcaCreated rethrows non-method-not-found errors', async () => { + const error = new Error('remote config write failed') + mux.request.mockRejectedValueOnce(error) + + await expect( + provider.markRemoteOrcaCreated('/home/user/repo', 'pr-contributor-orca') + ).rejects.toBe(error) + }) }) diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index 8f1430d8e75..8dae1413321 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -24,6 +24,7 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { private loggedWorktreeIsCleanFallback = false + private loggedMarkRemoteOrcaCreatedFallback = false // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< typeof WORKTREE_IS_CLEAN_CAPABILITY @@ -131,6 +132,25 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { }) } + // Why: git.exec blocks config writes outright, so the deferred fork-remote provenance + // marker (#17828) needs its own RPC. Non-essential to push/pull, so an older relay + // that hasn't shipped it yet degrades to no marker rather than failing materialization. + async markRemoteOrcaCreated(repoPath: string, remoteName: string): Promise { + try { + await this.mux.request('git.markRemoteOrcaCreated', { repoPath, remoteName }) + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedMarkRemoteOrcaCreatedFallback) { + this.loggedMarkRemoteOrcaCreatedFallback = true + console.warn( + "[ssh-git] Relay does not implement git.markRemoteOrcaCreated; this remote will lack a git-config provenance marker permanently (reconnecting does not retroactively add it -- only a newer relay deployment does, for remotes added after that). The store's remoteCreated flag remains the fallback ownership signal for cleanup." + ) + } + } + } + async forceDeletePreservedBranch( repoPath: string, branchName: string, diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index c44daefacfd..c195ee5dbd8 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -97,6 +97,16 @@ export class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreservedBranchC linkedWorkItem: meta.linkedWorkItem } : null + }, + // Why (#17828 review follow-up): RuntimeGitSyncCommands materializes with no store to + // avoid unrelated side effects; this is its only way back into the persisted + // `pushTarget.remoteCreated` flag that #17842's orphan sweep relies on. + persistMaterializedPushTarget: (worktreeId, pushTarget) => { + const store = this.store + if (!store?.setWorktreeMeta) { + return + } + store.setWorktreeMeta(worktreeId, { pushTarget }) } }) diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 3ae942280eb..87411ad55d7 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -23,6 +23,7 @@ import { homedir } from 'node:os' import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { @@ -124,6 +125,14 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend const worktreeSelector = parsed?.type === 'worktree' ? `id:${parsed.worktreeId}` : selector const worktree = await this.resolveWorktreeSelector(worktreeSelector) const repo = this.store?.getRepo(worktree.repoId) ?? null + triggerTerminalSpawnPushTargetMaterialization( + worktree.path, + worktree.pushTarget, + repo, + this.store, + worktree.repoId, + worktree.id + ) return { scope: { id: worktree.id, diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 64caa486013..65da9caccde 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -404,27 +404,36 @@ describe('OrcaRuntimeService', () => { wslDistro: 'Ubuntu' } ) - expect(gitSpy).toHaveBeenCalledWith( + // Why: a fork remote is deferred to first push/pull/fetch/fast-forward + // (#17828) instead of being added/fetched at create time, so the create + // path must not run check-ref-format, the fork fetch, or set-upstream-to + // -- the metadata is persisted untouched for on-demand materialization. + expect(gitSpy).not.toHaveBeenCalledWith( ['check-ref-format', '--branch', 'contributor/runtime-wsl'], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'fetch', 'pr-contributor-orca', '+refs/heads/contributor/runtime-wsl*:refs/remotes/pr-contributor-orca/contributor/runtime-wsl*' ], - { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' } + expect.anything() ) - expect(gitSpy).toHaveBeenCalledWith( + expect(gitSpy).not.toHaveBeenCalledWith( [ 'branch', '--set-upstream-to', 'pr-contributor-orca/contributor/runtime-wsl', 'runtime-wsl' ], - { cwd: createdWorktree.path, wslDistro: 'Ubuntu' } + expect.anything() ) + expect(result.worktree.pushTarget).toEqual({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/runtime-wsl', + remoteUrl: 'git@github.com:contributor/orca.git' + }) expect(listWorktrees).toHaveBeenCalledWith(TEST_REPO_PATH, { wslDistro: 'Ubuntu' }) } finally { gitSpy.mockRestore() diff --git a/src/main/runtime/runtime-git-command-target.ts b/src/main/runtime/runtime-git-command-target.ts index 540fd86f5c5..47131ce7e63 100644 --- a/src/main/runtime/runtime-git-command-target.ts +++ b/src/main/runtime/runtime-git-command-target.ts @@ -1,6 +1,6 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { Repo } from '../../shared/repo-types' -import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { GitPushTarget, GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import type { GitRuntimeOptions } from '../git/git-runtime-options' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import type { PullRequestLinkedIssueMeta } from '../source-control/pull-request-linked-issue' @@ -22,6 +22,11 @@ export type RuntimeGitCommandHost = { /** `undefined` keeps cached metadata; `null` is the authoritative unlinked answer. */ getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined getWorktreeLinkedIssueMeta?(worktreeId: string): PullRequestLinkedIssueMeta | null | undefined + /** Why (#17828 review follow-up): RuntimeGitSyncCommands deliberately materializes with + * no store (avoids unrelated ownership-inheritance/refspec-migration side effects), so a + * lazily-minted remote still needs a way back into the store's `pushTarget.remoteCreated` + * for #17842's orphan sweep. Called only when materialize reports `remoteCreated: true`. */ + persistMaterializedPushTarget?(worktreeId: string, pushTarget: GitPushTarget): void } export function localGitOptionsForTarget(target: RuntimeGitTarget): GitRuntimeOptions { diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index 7cc892bac75..f68b82aac79 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -9,11 +9,32 @@ import { getSshGitProvider, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-git-dispatch' -import { localGitOptionsForTarget, type RuntimeGitCommandHost } from './runtime-git-command-target' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { + localGitOptionsForTarget, + type RuntimeGitCommandHost, + type RuntimeGitTarget +} from './runtime-git-command-target' export class RuntimeGitSyncCommands { constructor(private readonly host: RuntimeGitCommandHost) {} + // Why (#17828 review follow-up): this class deliberately materializes with no store (see + // the `undefined` args below) to avoid unrelated ownership-inheritance/refspec-migration + // side effects on the RPC path -- so persistence goes through the host callback instead, + // using `target.worktree.id` already resolved here rather than threading a store through. + private persistMaterializedPushTargetIfCreated( + target: RuntimeGitTarget, + materialized: GitPushTarget | undefined + ): void { + if (materialized?.remoteCreated) { + this.host.persistMaterializedPushTarget?.(target.worktree.id, materialized) + } + } + async abortRuntimeGitMerge(worktreeSelector: string): Promise<{ ok: true }> { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -73,10 +94,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fetchRemote(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fetchRemote(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFetch(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFetch(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -111,10 +146,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pullBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pullBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitPull(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPull(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -131,10 +180,24 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.fastForwardBranch(target.worktree.path, pushTarget) + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.fastForwardBranch(target.worktree.path, materializedPushTarget) return { ok: true } } - await gitFastForward(target.worktree.path, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitFastForward(target.worktree.path, materializedPushTarget, { ...localGitOptionsForTarget(target), admissionTier: 'interactive' }) @@ -170,12 +233,26 @@ export class RuntimeGitSyncCommands { if (!provider) { throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) } - await provider.pushBranch(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemoteSsh(provider, target.worktree.path, pushTarget) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await provider.pushBranch(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true }) return { ok: true } } - await gitPush(target.worktree.path, publish === true, pushTarget, { + const materializedPushTarget = pushTarget + ? await materializeWorktreePushTargetRemote( + target.worktree.path, + pushTarget, + undefined, + target.repo?.id, + localGitOptionsForTarget(target) + ) + : undefined + this.persistMaterializedPushTargetIfCreated(target, materializedPushTarget) + await gitPush(target.worktree.path, publish === true, materializedPushTarget, { forceWithLease: forceWithLease === true, ...localGitOptionsForTarget(target), admissionTier: 'interactive' diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index 9875e5a07d0..4e4b3ebe0b5 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -2,10 +2,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types import type { Repo } from '../../shared/repo-types' import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation' import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories' -import { - configureCreatedWorktreePushTarget, - prepareWorktreePushTarget -} from '../ipc/worktree-remote' +import { configureCreatedWorktreePushTarget } from '../ipc/worktree-remote' import { addSparseWorktree, addWorktree, @@ -129,15 +126,11 @@ export async function createRuntimeLocalGitWorktree(args: { if (args.request.sparseCheckout && sparseDirectories.length === 0) { throw new Error('Sparse checkout requires at least one repo-relative directory.') } + // Why: defer the remote add + fetch (fork case) or the redundant re-fetch + // (same-repo case, already fetched while resolving the PR start point) to + // first use -- push/pull/fetch/fast-forward materialize it on demand + // (#17828). Metadata is persisted untouched; only the git mutation defers. const preparedPushTarget = args.request.pushTarget - ? await prepareWorktreePushTarget( - args.repo.path, - args.request.pushTarget, - args.store, - args.repo.id, - args.localWorktreeGitOptions - ) - : undefined const suggestLocalBaseRefUpdate = !args.settings.refreshLocalBaseRefOnWorktreeCreate && !args.settings.localBaseRefSuggestionDismissed && @@ -242,14 +235,18 @@ export async function createRuntimeLocalGitWorktree(args: { args.effectiveSanitizedName! ) } - const configuredPushTarget = preparedPushTarget - ? await configureCreatedWorktreePushTarget( - args.worktreePath, - args.branchName, - preparedPushTarget, - args.localWorktreeGitOptions - ) - : undefined + // Why: `--set-upstream-to` requires the remote to already exist -- safe for a + // same-repo target (its remote, e.g. `origin`, always exists) but not for a + // deferred fork remote, which is materialized lazily at first push/pull/fetch. + const configuredPushTarget = + preparedPushTarget && !preparedPushTarget.remoteUrl + ? await configureCreatedWorktreePushTarget( + args.worktreePath, + args.branchName, + preparedPushTarget, + args.localWorktreeGitOptions + ) + : preparedPushTarget const { created } = await resolveCreatedWorktree( args.repo.path, args.worktreePath, diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts new file mode 100644 index 00000000000..748225de225 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +const { + materializeLocalMock, + materializeSshMock, + getSshGitProviderMock, + getLocalProjectWorktreeGitOptionsMock +} = vi.hoisted(() => ({ + materializeLocalMock: vi.fn(), + materializeSshMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getLocalProjectWorktreeGitOptionsMock: vi.fn() +})) +vi.mock('../ipc/worktree-remote', () => ({ + materializeWorktreePushTargetRemote: materializeLocalMock, + materializeWorktreePushTargetRemoteSsh: materializeSshMock +})) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock +})) + +import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' + +const WORKTREE_PATH = '/repo/worktree' +const FORK_URL = 'git@github.com:contributor/orca.git' +const REPO_ID = 'repo-1' +const STORE = {} as Store +const LOCAL_REPO = { id: REPO_ID, path: '/repo', connectionId: null } as unknown as Repo +const SSH_REPO = { id: REPO_ID, path: '/repo', connectionId: 'conn-1' } as unknown as Repo + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + ...overrides + } +} + +// Flush the fire-and-forget microtask queue so assertions see the dispatched call. +const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)) + +describe('triggerTerminalSpawnPushTargetMaterialization', () => { + let warnSpy: ReturnType + + beforeEach(() => { + materializeLocalMock.mockReset().mockResolvedValue(undefined) + materializeSshMock.mockReset().mockResolvedValue(undefined) + getSshGitProviderMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('is a no-op when there is no push target', () => { + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, undefined, LOCAL_REPO, STORE) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('is a no-op for a same-repo push target with no remoteUrl', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteUrl: undefined }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the target already reports remoteCreated', () => { + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + forkTarget({ remoteCreated: true }), + LOCAL_REPO, + STORE + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('materializes over the local transport with resolved WSL git options, repoId and worktreeId, fire-and-forget', () => { + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + const target = forkTarget() + const result = triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + LOCAL_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(result).toBeUndefined() + expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(STORE, LOCAL_REPO) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + target, + STORE, + REPO_ID, + { wslDistro: 'Ubuntu' }, + 'worktree-1' + ) + expect(materializeSshMock).not.toHaveBeenCalled() + }) + + it('materializes over SSH when the repo has a connectionId and a provider is registered', () => { + const provider = { exec: vi.fn() } + getSshGitProviderMock.mockReturnValue(provider) + const target = forkTarget() + triggerTerminalSpawnPushTargetMaterialization( + WORKTREE_PATH, + target, + SSH_REPO, + STORE, + REPO_ID, + 'worktree-1' + ) + expect(getSshGitProviderMock).toHaveBeenCalledWith('conn-1') + expect(materializeSshMock).toHaveBeenCalledWith( + provider, + WORKTREE_PATH, + target, + STORE, + undefined, + 'worktree-1' + ) + expect(materializeLocalMock).not.toHaveBeenCalled() + expect(getLocalProjectWorktreeGitOptionsMock).not.toHaveBeenCalled() + }) + + it('is a no-op when the SSH connection has dropped (no registered provider)', () => { + getSshGitProviderMock.mockReturnValue(undefined) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), SSH_REPO, STORE) + expect(materializeSshMock).not.toHaveBeenCalled() + expect(materializeLocalMock).not.toHaveBeenCalled() + }) + + it('falls back to default git options when WSL project runtime resolution throws', () => { + getLocalProjectWorktreeGitOptionsMock.mockImplementation(() => { + throw new Error('repair-required') + }) + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + expect(materializeLocalMock).toHaveBeenCalledWith( + WORKTREE_PATH, + forkTarget(), + STORE, + undefined, + {}, + undefined + ) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to resolve local git options'), + expect.any(Error) + ) + }) + + it('swallows a materialize rejection instead of crashing the caller', async () => { + materializeLocalMock.mockRejectedValue(new Error('remote add failed')) + expect(() => + triggerTerminalSpawnPushTargetMaterialization(WORKTREE_PATH, forkTarget(), LOCAL_REPO, STORE) + ).not.toThrow() + await flush() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to materialize push target remote'), + expect.any(Error) + ) + }) +}) diff --git a/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts new file mode 100644 index 00000000000..958ccb99d14 --- /dev/null +++ b/src/main/runtime/runtime-terminal-spawn-push-target-materialization.ts @@ -0,0 +1,84 @@ +import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { + materializeWorktreePushTargetRemote, + materializeWorktreePushTargetRemoteSsh +} from '../ipc/worktree-remote' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { GitPushTarget } from '../../shared/worktree/types' +import type { Repo } from '../../shared/repo-types' +import type { Store } from '../persistence' + +// Why (#17828): a fork-PR remote deferred at worktree-create time must exist before an +// autonomous agent's raw git commands run in a freshly opened terminal -- "sync through +// Orca first" isn't an option mid-task. Fires on every terminal spawn into the worktree; +// materialize() is already a no-op once the remote exists, so repeat spawns cost one probe. +// Never awaited by callers: terminal spawn must not block on remote-add/fetch network I/O. +export function triggerTerminalSpawnPushTargetMaterialization( + worktreePath: string, + pushTarget: GitPushTarget | undefined, + repo: Repo | null | undefined, + store: Store | undefined, + repoId?: string, + worktreeId?: string +): void { + if (!pushTarget?.remoteUrl || pushTarget.remoteCreated) { + return + } + const connectionId = repo?.connectionId ?? undefined + const materialized = connectionId + ? materializeOverSsh(connectionId, worktreePath, pushTarget, store, worktreeId) + : materializeWorktreePushTargetRemote( + worktreePath, + pushTarget, + store, + repoId, + localGitOptionsForTerminalSpawn(store, repo), + worktreeId + ) + materialized.catch((error: unknown) => { + console.warn( + `[terminal-spawn] failed to materialize push target remote for ${worktreePath}:`, + error + ) + }) +} + +function materializeOverSsh( + connectionId: string, + worktreePath: string, + pushTarget: GitPushTarget, + store: Store | undefined, + worktreeId: string | undefined +): Promise { + const provider = getSshGitProvider(connectionId) + if (!provider) { + // Why: connection dropped -- the next Orca-driven sync action will retry via its own dispatch. + return Promise.resolve(pushTarget) + } + return materializeWorktreePushTargetRemoteSsh( + provider, + worktreePath, + pushTarget, + store, + undefined, + worktreeId + ) +} + +function localGitOptionsForTerminalSpawn( + store: Store | undefined, + repo: Repo | null | undefined +): { wslDistro?: string } { + if (!store || !repo) { + return {} + } + try { + // Why: a WSL-hosted repo's remote add/fetch must run under the same distro as + // the terminal, or it can target the wrong git binary entirely (repair-required + // project runtimes throw here -- fall back to host git rather than crash spawn). + return getLocalProjectWorktreeGitOptions(store, repo) + } catch (error) { + console.warn(`[terminal-spawn] failed to resolve local git options for ${repo.path}:`, error) + return {} + } +} diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 987abab14fc..822af96714d 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -67,6 +67,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:upstreamStatus', args), fetch: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fetch', args), @@ -77,6 +78,7 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:syncFork', args), push: (args: { worktreePath: string + worktreeId?: string publish?: boolean forceWithLease?: boolean connectionId?: string @@ -84,11 +86,13 @@ export const gitApi = { }): Promise => ipcRenderer.invoke('git:push', args), pull: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:pull', args), fastForward: (args: { worktreePath: string + worktreeId?: string connectionId?: string pushTarget?: GitPushTarget }): Promise => ipcRenderer.invoke('git:fastForward', args), diff --git a/src/relay/git-handler-exec-operations.ts b/src/relay/git-handler-exec-operations.ts index 510b7d0b9ee..f7d0c320697 100644 --- a/src/relay/git-handler-exec-operations.ts +++ b/src/relay/git-handler-exec-operations.ts @@ -34,6 +34,21 @@ export class GitHandlerExecOperations extends GitHandlerOperationContext { ) } + // Why: generic git.exec blocks all `git config` writes outright (CONFIG_READ_ONLY_FLAGS), + // so a deferred fork remote's provenance marker (#17828) needs its own narrow RPC that + // only ever writes this fixed key shape, mirroring renameCurrentBranch below. + async markRemoteOrcaCreated(params: Record) { + const repoPath = params.repoPath + const remoteName = params.remoteName + if (typeof repoPath !== 'string' || typeof remoteName !== 'string' || !remoteName) { + throw new Error('Invalid remote provenance marker request.') + } + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { + throw new Error('Invalid remote name for provenance marker.') + } + await this.git(['config', `remote.${remoteName}.orca-created`, 'true'], repoPath) + } + async renameCurrentBranch(params: Record) { return this.runWithGitReadCacheClear(async () => { const worktreePath = params.worktreePath diff --git a/src/relay/git-handler-registration.ts b/src/relay/git-handler-registration.ts index a9f11445dd1..6462327c416 100644 --- a/src/relay/git-handler-registration.ts +++ b/src/relay/git-handler-registration.ts @@ -65,6 +65,7 @@ export function registerGitHandlers( dispatcher.onRequest('git.refreshLocalBaseRefForWorktreeCreate', (p) => handlers.worktree.refreshLocalBaseRefForWorktreeCreate(p) ) + dispatcher.onRequest('git.markRemoteOrcaCreated', (p) => handlers.exec.markRemoteOrcaCreated(p)) dispatcher.onRequest('git.renameCurrentBranch', (p) => handlers.exec.renameCurrentBranch(p)) dispatcher.onRequest('git.forceDeletePreservedBranch', (p) => handlers.exec.forceDeletePreservedBranch(p) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index d8156741a3f..590b22636ba 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -73,6 +73,7 @@ describe('GitHandler', () => { 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') @@ -197,6 +198,37 @@ describe('GitHandler', () => { }) }) + 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) diff --git a/src/renderer/src/runtime/runtime-git-sync-client.ts b/src/renderer/src/runtime/runtime-git-sync-client.ts index c4318371f1f..5f1652a3893 100644 --- a/src/renderer/src/runtime/runtime-git-sync-client.ts +++ b/src/renderer/src/runtime/runtime-git-sync-client.ts @@ -72,6 +72,7 @@ export async function fetchRuntimeGit( await window.api.git.fetch({ worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), ...(pushTarget ? { pushTarget } : {}) }) return @@ -116,6 +117,7 @@ export async function pullRuntimeGit( await window.api.git.pull({ worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), ...(pushTarget ? { pushTarget } : {}) }) return @@ -140,6 +142,7 @@ export async function fastForwardRuntimeGit( await window.api.git.fastForward({ worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), ...(pushTarget ? { pushTarget } : {}) }) return @@ -185,6 +188,7 @@ export async function pushRuntimeGit( await window.api.git.push({ worktreePath: resolveLocalWorktreePath(context), connectionId: context.connectionId, + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), ...(args.publish !== undefined ? { publish: args.publish } : {}), ...(args.pushTarget !== undefined ? { pushTarget: args.pushTarget } : {}), ...(args.forceWithLease !== undefined ? { forceWithLease: args.forceWithLease } : {}) diff --git a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts index b786ea018e8..de8dbe9651f 100644 --- a/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts +++ b/src/renderer/src/store/slices/editor-remote-branch-actions.test.ts @@ -138,7 +138,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitPullMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(toastErrorMock).not.toHaveBeenCalled() }) @@ -155,6 +156,7 @@ describe('createEditorSlice remote branch actions', () => { worktreePath: '/repo', publish: false, connectionId: undefined, + worktreeId: 'wt-1', pushTarget: undefined, forceWithLease: undefined }) @@ -193,6 +195,7 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFastForwardMock).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: undefined, + worktreeId: 'wt-1', pushTarget }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ @@ -284,6 +287,7 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: undefined, + worktreeId: 'wt-1', pushTarget }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ @@ -337,7 +341,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitPushMock).toHaveBeenCalledWith({ worktreePath: '/repo', publish: true, - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(store.getState().isRemoteOperationActive).toBe(false) }) @@ -361,7 +366,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitStatusMock).not.toHaveBeenCalled() expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -389,7 +395,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitStatusMock).not.toHaveBeenCalled() expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -450,7 +457,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitStatusMock).not.toHaveBeenCalled() expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -476,7 +484,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitStatusMock).not.toHaveBeenCalled() expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -507,7 +516,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitStatusMock).not.toHaveBeenCalled() expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -534,7 +544,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -590,7 +601,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ worktreePath: '/repo', @@ -634,7 +646,8 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(store.getState().isRemoteOperationActive).toBe(false) expect(toastErrorMock).not.toHaveBeenCalled() @@ -728,16 +741,19 @@ describe('createEditorSlice remote branch actions', () => { expect(gitFetchMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(gitPullMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) // ahead=1 in the default mock, so sync pushes. expect(gitPushMock).toHaveBeenCalledWith({ worktreePath: '/repo', - connectionId: undefined + connectionId: undefined, + worktreeId: 'wt-1' }) expect(toastErrorMock).not.toHaveBeenCalled() expect(store.getState().isRemoteOperationActive).toBe(false) @@ -786,6 +802,7 @@ describe('createEditorSlice remote branch actions', () => { expect(gitPushMock).toHaveBeenCalledWith({ worktreePath: '/repo', connectionId: undefined, + worktreeId: 'wt-1', forceWithLease: true }) expect(gitUpstreamStatusMock).toHaveBeenCalledTimes(2)