mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* perf(worktree): defer fork-PR remote creation from create-time to first use Fork-PR review worktrees eagerly ran `git remote add` + `git fetch` for the contributor's fork (and pinned branch.<x>.remote) at create time, even for a read-only review. That grows remote count unboundedly with review volume and pays a network fetch nobody asked for yet. Defer prepareWorktreePushTarget(Ssh) and the --set-upstream-to configure step at create time (local + SSH, IPC + runtime create paths); persist the pushTarget metadata untouched. Materialize the remote on demand the first time push/pull/fetch/fast-forward actually needs it, via two shared functions (materializeWorktreePushTargetRemote(Ssh)) reused across the legacy IPC handlers and the RPC runtime sync commands. A cheap `remote get-url <name>` probe keeps steady-state calls down to one extra subprocess once materialized, instead of repeating the O(remotes) scan. Add repo-local `remote.<name>.orca-created` config provenance, written when the remote is added, so cleanup can recognize ownership of a remote that was lazily materialized (and therefore never round-tripped through the store's `remoteCreated` flag). Refs #17828 * perf(worktree): materialize a deferred fork-PR remote on terminal spawn An agent running raw git in a freshly opened fork-PR review terminal has no usable upstream until an Orca-driven sync happens -- "sync through Orca first" isn't available mid-task, and git pull/log @{u}.. hard-fail without one (verified against real git). Fire the same on-demand materialization used by push/pull/fetch/fast-forward from the single terminal-spawn resolver (resolveTerminalWorkspaceLaunchTarget), fire-and-forget, so a newly opened terminal gets a working upstream without blocking spawn. * fix(worktree): retest deferred fork-remote CI failures, fix SSH provenance-marker RPC Rewrites the 5 CI failures on the deferred fork-remote change (#17828) as evidence, not fixtures: the SSH relay-upgrade/rollback/sibling-ownership tests move to materializeWorktreePushTargetRemoteSsh, where that unchanged logic now actually runs (create defers it to first sync). While writing a stricter test that routes its mock exec through the relay's real validateGitExecArgs, found that the SSH provenance-marker write (`git config remote.<name>.orca-created true`) was unconditionally rejected by the relay's generic git.exec (it blocks all non-read-only config writes) -- a real bug that would break every SSH fork-remote materialization against a live relay. Fixes it with a narrow git.markRemoteOrcaCreated RPC, mirroring renameCurrentBranch, with a graceful no-op fallback for relays that predate it. * fix(worktree): scope post-#17887 test assertions past narrow-refspec config calls Rebasing onto #17887's narrow-refspec `remote add` broke two broad `['config']` call-filters into false positives/negatives, and the local materialize test still asserted the pre-#17887 wide `remote add`/fetch-refspec forms. * fix(worktree): restructure upstream restore, persist provenance, widen short-circuit refspec (#17828 review) - Move upstream restoration to the materializer level so it runs on both the remoteAlreadyMatchesUrl short-circuit and the full-prepare path, not just buried inside prepare*. - Persist {remoteCreated, remoteName} to the store on materialize so #17842's orphan sweep can see a lazily-created remote, including via desktop IPC, terminal-spawn, and the RPC host-callback paths. - Widen the refspec on the local short-circuit path too (SSH's bare `remote add` refspec gap remains a documented, pre-existing limitation). - Fetch the branch's tracking ref before restoring upstream when the short-circuit widens onto a *new* branch on an already-existing remote -- a bare refspec-config widen never itself imports anything, so `branch --set-upstream-to` was hard-failing for a sibling worktree's first materialize (found via a real-git fixture, not just mocked unit tests). Skipped when the ref already exists so the common repeat-call case stays a local-only probe with no network round-trip. * fix(worktree): merge duplicate shared/worktree/types import oxlint --deny-warnings flags the split import as no-duplicates; full pnpm lint was failing on it after the #17828 review restructuring. * fix(worktree): scope the deferred fetch timeout to fetch calls, retarget stale create-time assertions CI on the previous push failed 3 shards, all argument-shape mismatches: - worktrees-wsl-runtime-routing.test.ts: the "restructure upstream restore" commit wrapped every call `prepareWorktreePushTarget` makes (remote, remote add, config, fetch) with DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS, not just the network fetch. Local git subprocesses never need a timeout; scope it to `args[0] === 'fetch'` only, matching the short-circuit path's existing pattern. Updated the test to expect the timeout on the fetch call specifically (point 5 legitimately adds it there), while every other call stays untimed. - worktrees-create-metadata-persistence.test.ts (2 tests): stale from before this session -- create no longer mints a fork remote at all (#17828 deferred that to first sync), so asserting `remote add`/`fetch`/`remoteCreated: true` at create time no longer matches reality. Retargeted both tests to assert the deferred contract (no remote add at create, pushTarget persisted unmaterialized); minting itself stays covered by worktree-remote-push-target-materialization.test.ts and worktree-push-target-setup.test.ts. Re-verified all 5 fixture points (mint upstream, store persistence, single-flight, short-circuit refspec widen + fetch-missing-ref for local and SSH, finite timeout) against a real git fixture after this fix -- all still pass. * fix(worktree): hook pty:spawn into deferred push-target materialization (#17828) triggerTerminalSpawnPushTargetMaterialization only fired for agent/background/ mobile terminals; the desktop GUI's own pty:spawn path (new tab, split, reattach) never materialized a deferred fork-PR remote before raw git commands could run there. Add a small wrapper that resolves the worktree's push target and owning repo from args.worktreeId via the store, and fire-and-forget delegates to the existing materializer, wired as the first statement of runPtyIpcSpawn. Degrades silently (optional chaining + catch) so a partial/fake Store in existing spawn tests can't turn this into a spawn-blocking throw. * test(worktree): retarget stale editor-remote-branch assertions for worktreeId threading runtime-git-sync-client's local-path fetch/pull/fastForward/push calls now forward context.worktreeId (needed by the main-process handlers to key deferred push-target materialization). Update the 17 call-site mocks across 15 tests in editor-remote-branch-actions.test.ts to expect worktreeId: 'wt-1', matching the already-correct source behavior -- no assertion was loosened. * fix(worktree): give a materialize joiner its own branch wiring The materialize single flight is keyed on the remote, but everything after the remote add is per-branch. A sibling worktree joining an in-flight mint for a different branch received the minter's target and skipped its own refspec widen, tracking-ref fetch, and upstream link, so its branch ended with no upstream at all. Wait for the remote, then run the per-branch work against the joiner's own target -- the same path the already-exists short-circuit takes, now shared rather than duplicated. Adopting a remote a sibling minted also stamps ownership, so removing the minter cannot strand the survivor's metadata outside the orphan sweep's reach. * fix(worktree): stop a failed mint from leaving a config-only fork remote Review of the joiner fix found it made things worse in three ways. Swallowing the mint's rejection let a joiner adopt a remote the rollback had already removed, writing remote.<name>.fetch with no URL. Verified on real git: that ghost section breaks `git fetch --all`, forces every later mint to a `-2` name, and cannot be removed by `git remote remove`. Propagate instead; the in-flight map is already cleared, so a retry re-mints. The SSH twin still returned the minter's target to a joiner, so the original per-branch bug survived there. It now adopts against its own target through a twin helper. The ownership stamp was unreachable: it required both a store and a repo id, and no caller passes both. Derive the repo id from the worktree id. Adopters also write remote config, and concurrent `git config --add` has no lock retry -- 135 of 160 writes failed at 8-way concurrency, and equal values duplicate the refspec. Chain adoptions per remote.
665 lines
23 KiB
TypeScript
665 lines
23 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
|
|
import {
|
|
handleMock,
|
|
removeHandlerMock,
|
|
listWorktreesMock,
|
|
getPullRequestPushTargetMock,
|
|
gitExecFileAsyncMock
|
|
} from './worktrees-test-module-mocks'
|
|
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
|
import {
|
|
ORIGIN_HEAD_COMPONENT,
|
|
ORIGIN_REMOTE_URL,
|
|
makeWorktreeMeta
|
|
} from './worktrees-test-fixtures'
|
|
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
|
|
|
|
const WORKTREE_HANDLER_CHANNELS = [
|
|
'worktrees:listAll',
|
|
'worktrees:list',
|
|
'worktrees:listRetiredNames',
|
|
'worktrees:listDetected',
|
|
'worktrees:listKnownForExecutionHost',
|
|
'worktrees:forgetRemovedForExecutionHost',
|
|
'worktrees:cancelListDetected',
|
|
'worktrees:create',
|
|
'worktrees:adoptProvisionedRoot',
|
|
'worktrees:prefetchCreateBase',
|
|
'worktrees:resolvePrBase',
|
|
'worktrees:resolveMrBase',
|
|
'worktrees:remove',
|
|
'worktrees:forgetLocal',
|
|
'worktrees:forceDeletePreservedBranch',
|
|
'worktrees:updateMeta',
|
|
'worktrees:listLineage',
|
|
'worktrees:listLineageForHost',
|
|
'worktrees:updateLineage',
|
|
'worktrees:persistSortOrder',
|
|
'worktrees:getBranchRenameFailureOutput',
|
|
'hooks:check',
|
|
'hooks:inspectSetupScriptImports',
|
|
'hooks:createIssueCommandRunner',
|
|
'hooks:readIssueCommand',
|
|
'hooks:writeIssueCommand'
|
|
] as const
|
|
|
|
vi.mock('electron', async () =>
|
|
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
|
)
|
|
vi.mock('../git/worktree', async () =>
|
|
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
|
)
|
|
vi.mock('../git/runner', async () =>
|
|
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
|
)
|
|
vi.mock('../git/repo', async () =>
|
|
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
|
)
|
|
vi.mock('../git/git-username', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
|
|
.resolveLocalGitUsernameMock
|
|
}))
|
|
vi.mock('../github/client', async () =>
|
|
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
|
|
)
|
|
vi.mock('../source-control/hosted-review', async () =>
|
|
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
|
|
)
|
|
vi.mock('../providers/ssh-git-dispatch', async () =>
|
|
(await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock()
|
|
)
|
|
vi.mock('../providers/ssh-filesystem-dispatch', async () =>
|
|
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
|
|
)
|
|
vi.mock('./worktree-symlinks', async () =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
|
|
)
|
|
vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock())
|
|
vi.mock('../ssh/ssh-target-registry', async () =>
|
|
(await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock()
|
|
)
|
|
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
|
|
vi.mock('../setup-runner-script-text', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
|
|
(await importOriginal()) as Record<string, unknown>
|
|
)
|
|
)
|
|
vi.mock('../worktree-runner-script', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
|
|
(await importOriginal()) as Record<string, unknown>
|
|
)
|
|
)
|
|
vi.mock('../effective-hook-config', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
|
|
(await importOriginal()) as Record<string, unknown>
|
|
)
|
|
)
|
|
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
|
|
(await importOriginal()) as Record<string, unknown>
|
|
)
|
|
)
|
|
vi.mock('./worktree-logic', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
|
|
(await importOriginal()) as Record<string, unknown>
|
|
)
|
|
)
|
|
vi.mock('../terminal-history-deletion', async () =>
|
|
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
|
|
)
|
|
vi.mock('../ports/advertised-url-watcher', async () =>
|
|
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
|
|
)
|
|
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
|
|
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
|
|
)
|
|
vi.mock('../workspace-space-analysis-snapshot', async () =>
|
|
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
|
|
)
|
|
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
|
|
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
|
|
)
|
|
vi.mock('../runtime/worktree-teardown', async () =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
|
|
)
|
|
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
|
|
|
|
describe('registerWorktreeHandlers', () => {
|
|
let runtimeStub: WorktreeRuntimeStub
|
|
|
|
beforeEach(() => {
|
|
runtimeStub = setupWorktreeHandlers()
|
|
})
|
|
|
|
it('clears the GitLab MR base handler before re-registering IPC handlers', () => {
|
|
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:resolveMrBase')
|
|
expect(handlers['worktrees:resolveMrBase']).toBeDefined()
|
|
})
|
|
|
|
it('clears the branch rename failure-output handler before re-registering IPC handlers', () => {
|
|
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:getBranchRenameFailureOutput')
|
|
expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined()
|
|
})
|
|
|
|
it('removes exactly the installed channel set before installing the first handler', () => {
|
|
const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel)
|
|
const installedChannels = handleMock.mock.calls.map(([channel]) => channel)
|
|
|
|
expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
|
|
expect(new Set(removedChannels)).toEqual(new Set(installedChannels))
|
|
expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS))
|
|
expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
|
|
expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length)
|
|
expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan(
|
|
Math.min(...handleMock.mock.invocationCallOrder)
|
|
)
|
|
})
|
|
|
|
it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => {
|
|
const liveId = 'repo-1::/workspace/repo'
|
|
const staleId = 'removed-repo::/workspace/gone'
|
|
// Only the live worktree has meta; the stale id (e.g. a removed repo the
|
|
// renderer still lists) has none and must be skipped, not created.
|
|
store.getWorktreeMeta.mockImplementation((id: string) =>
|
|
id === liveId ? ({ instanceId: 'x' } as never) : undefined
|
|
)
|
|
|
|
handlers['worktrees:persistSortOrder'](null, { orderedIds: [liveId, staleId] })
|
|
|
|
const orderedTargets = store.setWorktreeMeta.mock.calls.map((call) => call[0])
|
|
expect(orderedTargets).toContain(liveId)
|
|
expect(orderedTargets).not.toContain(staleId)
|
|
})
|
|
|
|
it('persistSortOrder skips ranks that already represent the requested order', () => {
|
|
const firstId = 'repo-1::/workspace/first'
|
|
const secondId = 'repo-1::/workspace/second'
|
|
store.getWorktreeMeta.mockImplementation((id: string) => ({
|
|
sortOrder: id === firstId ? 200 : 100
|
|
}))
|
|
|
|
handlers['worktrees:persistSortOrder'](null, { orderedIds: [firstId, secondId] })
|
|
|
|
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('strips Orca provenance fields from renderer metadata updates', () => {
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
const result = handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
updates: {
|
|
comment: 'keep me',
|
|
isPinned: true,
|
|
orcaCreatedAt: 123,
|
|
orcaCreationSource: 'desktop',
|
|
orcaCreationWorkspaceLayout: { path: '/workspace', nestWorkspaces: false }
|
|
}
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt', {
|
|
comment: 'keep me',
|
|
isPinned: true
|
|
})
|
|
expect(result).toMatchObject({ comment: 'keep me', isPinned: true })
|
|
})
|
|
it('routes metadata updates through the explicitly selected host', () => {
|
|
store.setWorktreeMetaForHost.mockImplementation((_worktreeId, _executionHostId, meta) => meta)
|
|
|
|
const result = handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
executionHostId: 'ssh:build-box',
|
|
updates: { comment: 'remote note' }
|
|
})
|
|
|
|
expect(store.setWorktreeMetaForHost).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/feature-wt',
|
|
'ssh:build-box',
|
|
{ comment: 'remote note' }
|
|
)
|
|
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
|
expect(result).toMatchObject({ comment: 'remote note' })
|
|
})
|
|
|
|
it('rejects malformed execution-host identities at the IPC boundary', () => {
|
|
expect(() =>
|
|
handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
executionHostId: 'container:untrusted',
|
|
updates: { comment: 'must not write' }
|
|
})
|
|
).toThrow('Invalid execution host identity.')
|
|
|
|
expect(store.setWorktreeMetaForHost).not.toHaveBeenCalled()
|
|
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('pushes a remote-client invalidation for renames but not read-state updates', () => {
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
updates: { isUnread: false }
|
|
})
|
|
// Why: per-click isUnread writes must stay event-free (PR #209), while a rename must reach paired remote clients that no longer poll for titles.
|
|
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).not.toHaveBeenCalled()
|
|
|
|
handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
updates: { displayName: 'Renamed workspace' }
|
|
})
|
|
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1')
|
|
})
|
|
|
|
it('persists display-name provenance at the host boundary', () => {
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
handlers['worktrees:updateMeta'](null, {
|
|
worktreeId: 'repo-1::/workspace/feature-wt',
|
|
updates: { displayName: 'Agent label' }
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/feature-wt',
|
|
expect.objectContaining({ displayName: 'Agent label', displayNameIsPinned: true })
|
|
)
|
|
})
|
|
|
|
it('does not trust renderer-authored automation provenance during local create', async () => {
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
listWorktreesMock.mockResolvedValue([
|
|
{
|
|
path: '/workspace/improve-dashboard',
|
|
head: 'abc123',
|
|
branch: 'improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
|
|
await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
automationProvenance: {
|
|
kind: 'created-by-automation',
|
|
automationId: 'automation-1',
|
|
automationNameSnapshot: 'Forged',
|
|
automationRunId: 'run-1',
|
|
automationRunTitleSnapshot: 'Forged run',
|
|
createdAt: 123,
|
|
executionTargetType: 'local',
|
|
executionTargetId: 'local',
|
|
projectId: 'repo-1'
|
|
}
|
|
})
|
|
|
|
const persistedMeta = store.setWorktreeMeta.mock.calls.find(
|
|
([worktreeId]) => worktreeId === 'repo-1::/workspace/improve-dashboard'
|
|
)?.[1]
|
|
expect(persistedMeta).toBeDefined()
|
|
expect(persistedMeta).not.toHaveProperty('automationProvenance')
|
|
})
|
|
|
|
it('persists a sanitized artifact title as the worktree display name', async () => {
|
|
listWorktreesMock.mockResolvedValue([
|
|
{
|
|
path: '/workspace/improve-dashboard',
|
|
head: 'abc123',
|
|
branch: 'improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
const result = await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
displayName: ' Fix: dashboards\nfor PRs\u0000 '
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/improve-dashboard',
|
|
expect.objectContaining({
|
|
displayName: 'Fix: dashboards for PRs'
|
|
})
|
|
)
|
|
expect(result).toMatchObject({
|
|
worktree: expect.objectContaining({
|
|
displayName: 'Fix: dashboards for PRs'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('pins a legacy name-only user create when the branch matches', async () => {
|
|
listWorktreesMock.mockResolvedValue([
|
|
{
|
|
path: '/workspace/feature',
|
|
head: 'abc123',
|
|
branch: 'feature',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'feature'
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/feature',
|
|
expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true })
|
|
)
|
|
})
|
|
|
|
it('persists linked issue and PR metadata during local create', async () => {
|
|
listWorktreesMock.mockResolvedValue([
|
|
{
|
|
path: '/workspace/improve-dashboard',
|
|
head: 'abc123',
|
|
branch: 'improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
const result = await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
linkedIssue: 123,
|
|
linkedPR: 456,
|
|
linkedLinearIssue: 'ENG-123',
|
|
manualOrder: 123_456
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/improve-dashboard',
|
|
expect.objectContaining({
|
|
linkedIssue: 123,
|
|
linkedPR: 456,
|
|
linkedLinearIssue: 'ENG-123',
|
|
manualOrder: 123_456
|
|
})
|
|
)
|
|
expect(result).toMatchObject({
|
|
worktree: expect.objectContaining({
|
|
linkedIssue: 123,
|
|
linkedPR: 456,
|
|
linkedLinearIssue: 'ENG-123',
|
|
manualOrder: 123_456
|
|
})
|
|
})
|
|
})
|
|
|
|
it('persists the selected creation agent during local create', async () => {
|
|
listWorktreesMock.mockResolvedValue([
|
|
{
|
|
path: '/workspace/improve-dashboard',
|
|
head: 'abc123',
|
|
branch: 'improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
const result = await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
createdWithAgent: 'codex'
|
|
})
|
|
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/improve-dashboard',
|
|
expect.objectContaining({
|
|
createdWithAgent: 'codex'
|
|
})
|
|
)
|
|
expect(result).toMatchObject({
|
|
worktree: expect.objectContaining({
|
|
createdWithAgent: 'codex'
|
|
})
|
|
})
|
|
})
|
|
|
|
// 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',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
|
|
await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
pushTarget: {
|
|
remoteName: 'pr-prateek-orca',
|
|
branchName: 'prateek/fix-sidebar-agents-toggle',
|
|
remoteUrl: 'git@github.com:prateek/orca.git'
|
|
}
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
|
[
|
|
'remote',
|
|
'add',
|
|
'-t',
|
|
'prateek/fix-sidebar-agents-toggle',
|
|
'--no-tags',
|
|
'pr-prateek-orca',
|
|
'git@github.com:prateek/orca.git'
|
|
],
|
|
{ cwd: '/workspace/repo' }
|
|
)
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
|
[
|
|
'fetch',
|
|
'pr-prateek-orca',
|
|
'+refs/heads/prateek/fix-sidebar-agents-toggle*:refs/remotes/pr-prateek-orca/prateek/fix-sidebar-agents-toggle*'
|
|
],
|
|
{ cwd: '/workspace/repo' }
|
|
)
|
|
// Upstream can only be set once the remote exists, so it defers with the remote.
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
|
[
|
|
'branch',
|
|
'--set-upstream-to',
|
|
'pr-prateek-orca/prateek/fix-sidebar-agents-toggle',
|
|
'improve-dashboard'
|
|
],
|
|
{ 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: {
|
|
remoteName: 'pr-prateek-orca',
|
|
branchName: 'prateek/fix-sidebar-agents-toggle',
|
|
remoteUrl: 'git@github.com:prateek/orca.git'
|
|
}
|
|
})
|
|
)
|
|
})
|
|
|
|
// 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',
|
|
head: 'abc123',
|
|
branch: 'refs/heads/improve-dashboard',
|
|
isBare: false,
|
|
isMainWorktree: false
|
|
}
|
|
])
|
|
const existingPushTarget = {
|
|
remoteName: 'pr-contributor-orca',
|
|
branchName: 'contributor/previous-fix',
|
|
remoteUrl: 'https://github.com/contributor/orca.git',
|
|
remoteCreated: true
|
|
}
|
|
store.getAllWorktreeMeta.mockReturnValue({
|
|
'repo-1::/workspace/previous-fix': makeWorktreeMeta({ pushTarget: existingPushTarget })
|
|
})
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
|
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === 'remote' && args.length === 1) {
|
|
return { stdout: 'pr-contributor-orca\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'remote' && args[1] === 'get-url') {
|
|
return { stdout: 'https://github.com/contributor/orca.git\n', stderr: '' }
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
await handlers['worktrees:create'](null, {
|
|
repoId: 'repo-1',
|
|
name: 'improve-dashboard',
|
|
pushTarget: {
|
|
remoteName: 'pr-contributor-orca',
|
|
branchName: 'contributor/new-fix',
|
|
remoteUrl: 'https://github.com/contributor/orca.git'
|
|
}
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
|
['remote', 'add', expect.any(String), expect.any(String)],
|
|
expect.any(Object)
|
|
)
|
|
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
|
'repo-1::/workspace/improve-dashboard',
|
|
expect.objectContaining({
|
|
pushTarget: {
|
|
remoteName: 'pr-contributor-orca',
|
|
branchName: 'contributor/new-fix',
|
|
remoteUrl: 'https://github.com/contributor/orca.git'
|
|
}
|
|
})
|
|
)
|
|
})
|
|
|
|
it('threads explicit origin preference into dual-remote PR head resolution', async () => {
|
|
store.getRepo.mockReturnValue({
|
|
id: 'repo-1',
|
|
path: '/workspace/repo',
|
|
displayName: 'repo',
|
|
badgeColor: '#000',
|
|
addedAt: 0,
|
|
issueSourcePreference: 'origin',
|
|
worktreeBaseRef: null
|
|
})
|
|
getPullRequestPushTargetMock.mockResolvedValue({
|
|
pushTarget: {
|
|
remoteName: 'pr-prateek-orca',
|
|
branchName: 'prateek/fix-sidebar-agents-toggle',
|
|
remoteUrl: 'git@github.com:prateek/orca.git'
|
|
}
|
|
})
|
|
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === 'remote' && args[1] === 'get-url') {
|
|
const url =
|
|
args[2] === 'origin' ? ORIGIN_REMOTE_URL : 'git@github.com:org/upstream-repo.git'
|
|
return { stdout: `${url}\n`, stderr: '' }
|
|
}
|
|
if (args[0] === 'remote') {
|
|
return { stdout: 'origin\nupstream\n', stderr: '' }
|
|
}
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: 'abc123\n', stderr: '' }
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
const result = await handlers['worktrees:resolvePrBase'](null, {
|
|
repoId: 'repo-1',
|
|
prNumber: 1738,
|
|
headRefName: 'prateek/fix-sidebar-agents-toggle',
|
|
isCrossRepository: true
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'fetch',
|
|
'--no-tags',
|
|
'origin',
|
|
`+refs/pull/1738/head:refs/orca/pull/${ORIGIN_HEAD_COMPONENT}/1738`
|
|
],
|
|
{ cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS }
|
|
)
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
|
['remote', 'get-url', 'upstream'],
|
|
expect.anything()
|
|
)
|
|
expect(getPullRequestPushTargetMock).toHaveBeenCalledWith(
|
|
'/workspace/repo',
|
|
1738,
|
|
null,
|
|
{},
|
|
'origin'
|
|
)
|
|
expect(result).toMatchObject({
|
|
baseBranch: 'abc123',
|
|
headSha: 'abc123',
|
|
branchNameOverride: 'prateek/fix-sidebar-agents-toggle',
|
|
pushTarget: {
|
|
remoteName: 'pr-prateek-orca',
|
|
branchName: 'prateek/fix-sidebar-agents-toggle',
|
|
remoteUrl: 'git@github.com:prateek/orca.git'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('returns the same-repo PR head SHA and exact branch override when resolving a PR base', async () => {
|
|
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
|
if (args[0] === 'rev-parse') {
|
|
return { stdout: 'def456\n', stderr: '' }
|
|
}
|
|
return { stdout: '', stderr: '' }
|
|
})
|
|
|
|
const result = await handlers['worktrees:resolvePrBase'](null, {
|
|
repoId: 'repo-1',
|
|
prNumber: 42,
|
|
headRefName: 'feature/add-feature',
|
|
isCrossRepository: false
|
|
})
|
|
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'fetch',
|
|
'origin',
|
|
'+refs/heads/feature/add-feature:refs/remotes/origin/feature/add-feature'
|
|
],
|
|
{ cwd: '/workspace/repo' }
|
|
)
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
|
['rev-parse', '--verify', 'origin/feature/add-feature'],
|
|
{ cwd: '/workspace/repo' }
|
|
)
|
|
expect(result).toMatchObject({
|
|
baseBranch: 'def456',
|
|
headSha: 'def456',
|
|
branchNameOverride: 'feature/add-feature',
|
|
pushTarget: { remoteName: 'origin', branchName: 'feature/add-feature' }
|
|
})
|
|
})
|
|
})
|