From eb545aaa59be6ca812eaaa0a55421d78f0acaa3e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:38:35 -0700 Subject: [PATCH] fix(worktree): collapse duplicate "Local Mac" run targets in the host picker (#10472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktree): collapse duplicate "Local Mac" run targets in the host picker A linked worktree added as its own project projects a second ready host setup on the same project+host, so the run-target picker rendered N identical "Local Mac" rows differing only by path. Only the first was reachable — resolveWorkspaceCreationTarget takes the first project+host match — so the extras pointed at paths that may no longer exist. - Dedupe ready setup options by host in the picker (display fix for profiles that already hold duplicates). - Canonicalize a stale draft's setup id to the setup the picker shows, so the displayed path is the path the workspace is created in. - Reject a linked worktree at repos:add when its main checkout is already tracked, preventing new duplicates. * fix(worktree): only dedupe a linked worktree against a git main checkout Review follow-up: the repos:add guard matched any tracked repo on the main checkout path, including a folder-kind record. A folder repo does not project onto the same project as the git worktree, so matching it would suppress a legitimate add without deduping anything. --- src/main/git/repo-detection.test.ts | 86 +++++++- src/main/git/repo.ts | 46 +++++ .../ipc/repos-add-linked-worktree.test.ts | 193 ++++++++++++++++++ src/main/ipc/repos.ts | 24 +++ .../lib/project-host-setup-options.test.ts | 36 ++++ .../src/lib/project-host-setup-options.ts | 17 ++ .../lib/project-host-workspace-target.test.ts | 45 ++++ .../src/lib/project-host-workspace-target.ts | 14 +- 8 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 src/main/ipc/repos-add-linked-worktree.test.ts diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index ff76b7e12ea..83bf3939189 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -11,7 +11,12 @@ import { import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getGitRepoRoot, isGitRepo, normalizeGitRepoRootForInputPath } from './repo' +import { + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + isGitRepo, + normalizeGitRepoRootForInputPath +} from './repo' function git(cwd: string, args: string[]): string { return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }) @@ -329,6 +334,85 @@ describe('isGitRepo', () => { }) }) +describe('getLinkedWorktreeMainRepoRoot', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-linked-worktree-')) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function initRepoWithCommit(repoRoot: string): void { + mkdirSync(repoRoot, { recursive: true }) + git(repoRoot, ['init', '--quiet']) + git(repoRoot, ['config', 'user.email', 'test@orca.test']) + git(repoRoot, ['config', 'user.name', 'Orca Test']) + writeFileSync(path.join(repoRoot, 'README.md'), 'seed\n') + git(repoRoot, ['add', 'README.md']) + git(repoRoot, ['commit', '--quiet', '-m', 'seed']) + } + + it('resolves a linked worktree back to its main checkout', () => { + const repoRoot = path.join(tmpDir, 'repo') + initRepoWithCommit(repoRoot) + const linked = path.join(tmpDir, 'linked') + git(repoRoot, ['worktree', 'add', '--quiet', '-b', 'feature', linked]) + + const expectedMainRoot = git(repoRoot, ['rev-parse', '--show-toplevel']) + .trim() + .replace(/\\/g, '/') + expect(getLinkedWorktreeMainRepoRoot(linked)).toBe(expectedMainRoot) + }) + + it('returns null for the main checkout itself', () => { + const repoRoot = path.join(tmpDir, 'repo') + initRepoWithCommit(repoRoot) + + expect(getLinkedWorktreeMainRepoRoot(repoRoot)).toBeNull() + }) + + it('returns null for a nested directory inside the main checkout', () => { + const repoRoot = path.join(tmpDir, 'repo') + initRepoWithCommit(repoRoot) + const nested = path.join(repoRoot, 'packages', 'web') + mkdirSync(nested, { recursive: true }) + + expect(getLinkedWorktreeMainRepoRoot(nested)).toBeNull() + }) + + it('returns null for a bare repository', () => { + const bareRepo = path.join(tmpDir, 'bare.git') + git(tmpDir, ['init', '--bare', '--quiet', bareRepo]) + + expect(getLinkedWorktreeMainRepoRoot(bareRepo)).toBeNull() + }) + + it('returns null for a non-repository directory', () => { + const plain = path.join(tmpDir, 'plain') + mkdirSync(plain) + + expect(getLinkedWorktreeMainRepoRoot(plain)).toBeNull() + }) + + it('returns null for a missing path', () => { + expect(getLinkedWorktreeMainRepoRoot(path.join(tmpDir, 'does-not-exist'))).toBeNull() + }) + + it('returns null when git cannot be run rather than guessing a main checkout', () => { + const repoRoot = path.join(tmpDir, 'repo') + initRepoWithCommit(repoRoot) + const linked = path.join(tmpDir, 'linked') + git(repoRoot, ['worktree', 'add', '--quiet', '-b', 'feature', linked]) + + withGitUnavailable(() => { + expect(getLinkedWorktreeMainRepoRoot(linked)).toBeNull() + }) + }) +}) + /** * Run `fn` with `git` removed from PATH so the in-process git probe fails the * same way a transient spawn failure would, exercising the `.git`-marker diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index df5b2865132..ca1b4571c17 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -155,6 +155,52 @@ export function getGitRepoRoot(path: string): string { return path } +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** + * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, + * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` + * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is + * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both + * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. + */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other + // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares + // unequal on raw strings and a main checkout gets misread as a linked worktree. + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + // A bare/separate git dir has no adjacent working checkout to point at. + if (basename(absoluteCommonDir) !== '.git') { + return null + } + // Re-resolve through getGitRepoRoot so the returned path matches the canonical form + // add-project stores for the main checkout (symlinks resolved the way git reports them). + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { const inputWsl = parseWslUncPath(inputPath) if (inputWsl && rootPath.startsWith('/')) { diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts new file mode 100644 index 00000000000..d6880848850 --- /dev/null +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -0,0 +1,193 @@ +/** + * Regression tests for repos:add + git worktrees. + * + * A linked worktree reports itself as its own `--show-toplevel`, so the path-based dedupe in + * addLocalRepoFromPath cannot see that it belongs to an already-tracked repo. Adding it anyway + * produced a second ready ProjectHostSetup on the same project and host — a duplicate "Local Mac" + * run-target row pointing at a transient worktree path. + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Repo } from '../../shared/types' + +const { + handleMock, + removeHandlerMock, + mockStore, + isGitRepoMock, + getGitRepoRootMock, + getLinkedWorktreeMainRepoRootMock, + invalidateAuthorizedRootsCacheMock, + prepareLocalWorktreeRootForRepoMock, + detectRepoIconAndUpstreamMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeProject: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn() + }, + isGitRepoMock: vi.fn().mockReturnValue(true), + getGitRepoRootMock: vi.fn(), + getLinkedWorktreeMainRepoRootMock: vi.fn(), + invalidateAuthorizedRootsCacheMock: vi.fn(), + prepareLocalWorktreeRootForRepoMock: vi.fn(), + detectRepoIconAndUpstreamMock: vi.fn() +})) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: isGitRepoMock, + getGitRepoRoot: getGitRepoRootMock, + getLinkedWorktreeMainRepoRoot: getLinkedWorktreeMainRepoRootMock, + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../repo-detection', () => ({ + detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock +})) + +vi.mock('./filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock +})) + +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) +vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() })) + +import { registerRepoHandlers } from './repos' + +const MAIN_CHECKOUT = '/Users/dev/projects/orca' +const LINKED_WORKTREE = '/Users/dev/orca/workspaces/orca/pr-3235' + +type AddResult = { repo: Repo } | { error: string } + +describe('repos:add with git worktrees', () => { + const handlers = new Map unknown>() + const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } } + + const trackedMainRepo = (): Repo => + ({ + id: 'main-repo-id', + path: MAIN_CHECKOUT, + displayName: 'orca', + badgeColor: '#ef4444', + addedAt: 1, + kind: 'git' + }) as Repo + + const callAdd = (args: { path: string; kind?: 'git' | 'folder' }): Promise => { + const handler = handlers.get('repos:add') + if (!handler) { + throw new Error('repos:add handler was never registered') + } + return handler(null, args) as Promise + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + removeHandlerMock.mockReset() + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + isGitRepoMock.mockReset().mockReturnValue(true) + // A linked worktree is its own toplevel — this is exactly why path dedupe alone misses it. + getGitRepoRootMock.mockReset().mockImplementation((path: string) => path) + getLinkedWorktreeMainRepoRootMock.mockReset().mockReturnValue(null) + detectRepoIconAndUpstreamMock.mockReset().mockResolvedValue({}) + invalidateAuthorizedRootsCacheMock.mockReset() + prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('returns the tracked main checkout instead of adding its linked worktree', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(result).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('still adds a linked worktree whose main checkout is not tracked', async () => { + mockStore.getRepos.mockReturnValue([]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) + + it('adds a normal repo when git reports it is not a linked worktree', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(null) + + const result = await callAdd({ path: '/Users/dev/projects/other' }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: '/Users/dev/projects/other' }) }) + }) + + it('does not consult worktree detection for folder projects', async () => { + mockStore.getRepos.mockReturnValue([trackedMainRepo()]) + + await callAdd({ path: '/Users/dev/notes', kind: 'folder' }) + + expect(getLinkedWorktreeMainRepoRootMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + }) + + it('matches the tracked main checkout across path separator differences', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), path: 'C:\\Users\\dev\\projects\\orca' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue('C:/Users/dev/projects/orca') + + const result = await callAdd({ path: 'C:/Users/dev/worktrees/pr-3235' }) + + expect(result).toEqual({ repo: expect.objectContaining({ id: 'main-repo-id' }) }) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('does not match a folder record sitting on the main-checkout path', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), id: 'folder-repo-id', kind: 'folder' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) + + it('does not match a tracked SSH repo that shares the local main-checkout path', async () => { + mockStore.getRepos.mockReturnValue([ + { ...trackedMainRepo(), id: 'ssh-repo-id', connectionId: 'builder' } as Repo + ]) + getLinkedWorktreeMainRepoRootMock.mockReturnValue(MAIN_CHECKOUT) + + const result = await callAdd({ path: LINKED_WORKTREE }) + + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(result).toEqual({ repo: expect.objectContaining({ path: LINKED_WORKTREE }) }) + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index fd0ddcf770d..185096e5ff6 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -59,6 +59,7 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r import { isGitRepo, getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, getRepoName, getBaseRefDefault, getRemoteCount, @@ -195,6 +196,29 @@ async function addLocalRepoFromPath( } } + // Why: a linked worktree reports itself as its own toplevel, so the path checks above can't see that + // it belongs to an already-tracked repo. Adding it anyway yields a second "ready" host setup on the + // same project and host — a duplicate run-target row that resolves to a transient worktree path. + if (repoKind === 'git') { + const mainRepoRoot = getLinkedWorktreeMainRepoRoot(resolvedPath) + if (mainRepoRoot) { + const mainRepoKey = normalizeRuntimePathForComparison(mainRepoRoot) + // Why !isFolderRepo: only a git-kind main checkout projects onto the same project as its + // worktree, so matching a folder record would suppress the add without deduping anything. + const trackedMainRepo = store + .getRepos() + .find( + (repo) => + !repo.connectionId && + !isFolderRepo(repo) && + normalizeRuntimePathForComparison(repo.path) === mainRepoKey + ) + if (trackedMainRepo) { + return { repo: trackedMainRepo, alreadyExisted: true } + } + } + } + const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind }) const repo: Repo = { id: randomUUID(), diff --git a/src/renderer/src/lib/project-host-setup-options.test.ts b/src/renderer/src/lib/project-host-setup-options.test.ts index 8785d7b35e5..ecd4d74495e 100644 --- a/src/renderer/src/lib/project-host-setup-options.test.ts +++ b/src/renderer/src/lib/project-host-setup-options.test.ts @@ -199,6 +199,42 @@ describe('buildProjectHostSetupOptions', () => { expect(options.map((option) => option.id)).toEqual(['ready']) }) + it('collapses duplicate ready setups on one host to the setup creation actually uses', () => { + // Why: a linked worktree added as its own project projected a second ready `local` setup for the + // same project, which rendered as repeated identical "Local Mac" rows separated only by path. + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('main-checkout'), repo('worktree-a'), repo('worktree-b')], + hosts: [host('local')], + projectHostSetups: [ + setup('main', 'project-1', 'local', 'main-checkout', { path: '/Users/dev/projects/orca' }), + setup('dup-a', 'project-1', 'local', 'worktree-a', { + path: '/Users/dev/worktrees/pr-1908' + }), + setup('dup-b', 'project-1', 'local', 'worktree-b', { path: '/Users/dev/worktrees/pr-3235' }) + ] + }) + + expect(options).toEqual([ + expect.objectContaining({ id: 'main', kind: 'ready', label: LOCAL_HOST_LABEL }) + ]) + }) + + it('keeps one ready choice per host when a project is set up on several hosts', () => { + const options = buildProjectHostSetupOptions({ + projectId: 'project-1', + eligibleRepos: [repo('local-repo'), repo('local-dup'), repo('remote-repo')], + hosts: [host('local'), host('ssh:builder', { label: 'Builder' })], + projectHostSetups: [ + setup('local', 'project-1', 'local', 'local-repo'), + setup('local-dup', 'project-1', 'local', 'local-dup'), + setup('remote', 'project-1', 'ssh:builder', 'remote-repo') + ] + }) + + expect(options.map((option) => option.id)).toEqual(['local', 'remote']) + }) + it('includes known hosts that still need project setup', () => { const options = buildProjectHostSetupOptions({ projectId: 'project-1', diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 6a7999902a8..4323a486032 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -136,6 +136,23 @@ function buildReadySetupOptions({ detail: setup.displayName, path: setup.path })) + .filter(dedupeByHost()) +} + +// Why: a project resolves to at most one setup per host — resolveWorkspaceCreationTarget takes the +// first project+host match and ignores the rest, so extra same-host setups are unreachable. Legacy +// profiles can still hold them (a linked worktree added as its own project projects a second local +// setup), which rendered as repeated identical "Local Mac" rows. Keep the first in input order so +// the row shown is the one workspace creation actually uses. +function dedupeByHost(): (option: ReadyProjectHostSetupOption) => boolean { + const seenHosts = new Set() + return (option) => { + if (seenHosts.has(option.hostId)) { + return false + } + seenHosts.add(option.hostId) + return true + } } function buildNeedsSetupOptions({ diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index e26eb4d4108..e0e3281a079 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -121,6 +121,51 @@ describe('project-host workspace target resolution', () => { }) }) + it('canonicalizes a stale same-host setup id to the setup the picker shows', () => { + // Why: the run-target picker renders one row per host. A draft persisted before that collapse + // can still name a duplicate local setup; creation must land in the displayed path, not a + // transient worktree path the user never sees. + const repos = [makeRepo('orca-main'), makeRepo('orca-worktree')] + const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-worktree'])] + const projectHostSetups = [ + makeSetup('orca-main', 'github:stablyai/orca', 'local', 'orca-main'), + makeSetup('orca-worktree', 'github:stablyai/orca', 'local', 'orca-worktree') + ] + + const resolution = resolveWorkspaceCreationTarget({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectHostSetupId: 'orca-worktree' + }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { projectHostSetupId: 'orca-main', repoId: 'orca-main', hostId: 'local' } + }) + }) + + it('keeps an explicit setup id that is the only one on its host', () => { + const repos = [makeRepo('orca-local'), makeRepo('orca-ssh', { connectionId: 'builder' })] + const projects = [makeProject('github:stablyai/orca', ['orca-local', 'orca-ssh'])] + const projectHostSetups = [ + makeSetup('orca-local', 'github:stablyai/orca', 'local', 'orca-local'), + makeSetup('orca-ssh', 'github:stablyai/orca', 'ssh:builder', 'orca-ssh') + ] + + expect( + resolveWorkspaceCreationTarget({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectHostSetupId: 'orca-ssh' + }) + ).toMatchObject({ + status: 'ready', + target: { projectHostSetupId: 'orca-ssh', repoId: 'orca-ssh', hostId: 'ssh:builder' } + }) + }) + it('does not merge same-name repos without shared project identity', () => { const repos = [ makeRepo('personal-orca', { displayName: 'orca' }), diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index 13e9271bac0..2a7c776eec6 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -130,9 +130,17 @@ export function resolveWorkspaceCreationTarget( if (!isReadySetup(setup)) { return { status: 'unavailable', reason: 'setup-not-ready' } } - const target = createTarget(setup, repoById) - if (target) { - return { status: 'ready', target } + // Why: a project resolves to one setup per host, and the run-target picker shows only the first. + // A stale draft can still name a same-host duplicate from a legacy profile; canonicalize to the + // same setup the picker displays so the shown path is the path the workspace is created in. + const canonical = + findReadySetupTarget( + setups, + repoById, + (entry) => entry.projectId === setup.projectId && entry.hostId === setup.hostId + ) ?? createTarget(setup, repoById) + if (canonical) { + return { status: 'ready', target: canonical } } return { status: 'unavailable', reason: 'setup-not-found' } }