Files
orca/tests/e2e/add-project-default-checkout.spec.ts
T
Brennan Benson 812a23a729 fix(sidebar): keep discovered worktrees hidden when adding a project (#38208)
* fix(sidebar): keep discovered worktrees hidden when adding a project

Adding a project no longer switches it to show worktrees Orca didn't create
(Conductor workspaces and other tools' worktrees). They stay behind the
sidebar's "Hiding N discovered worktrees" line, the same as every other
way a project is added.

* fix(sidebar): open a picked linked worktree instead of revealing its siblings

When the folder added as a project is itself a linked worktree, its main
checkout is hidden. Open the picked worktree instead of switching the whole
project to show worktrees Orca didn't create.

* fix(repos): register clone and create paths with symlinks resolved

Git reports checkout paths with symlinks resolved. Clone and create saved the
typed path, so a project cloned under a symlinked folder (e.g. /tmp on macOS)
did not recognise its own checkout and hid it as a discovered worktree. Resolve
the existing part of the destination before registering, as adding an existing
folder already does.

* fix(repos): keep finding projects saved under a symlinked clone or create path

Projects cloned or created before paths were resolved keep the typed
spelling. Match both spellings so retrying the same clone or create reuses
the saved project instead of failing on its non-empty folder.

* fix(repos): keep Windows clone and create paths as typed

Windows Git reports the typed spelling, and resolving would turn mapped
drives into UNC paths. Runtime clone/create tests now use resolved temp
roots, matching what registration saves.

* fix(repos): register Git's checkout path for clone and create

Replace the parent-symlink resolution with the path Git reports after clone
or git init, as adding an existing folder already does. This also covers a
symlink at the target folder itself and WSL paths, leaves plain folders and
Windows spellings unchanged, and matches saved projects by both the typed and
the resolved spelling.

* fix(sidebar): keep opening projects whose own checkout isn't recognised

Projects saved through a symlinked path by older builds or paired hosts don't
recognise their own checkout. Keep main's behaviour for that case: show the
project so the add still opens it.

* fix(repos): match resolved paths when importing a folder of repos

Clone and create now save Git's resolved checkout path, so a nested import
through a symlinked folder must also match that spelling instead of
registering a duplicate project.

* fix(repos): find saved projects through a symlink before the clone target exists

Resolve a missing clone target through its existing ancestors, reusing the
worktree-create alias resolver, and read the lookup keys inside the clone
lock. Queued clones through a symlinked folder reuse the first clone, and a
different repository at a saved project whose folder is gone is refused
before cloning, as on main.

* fix(repos): match a folder import's resolved path only to local projects

The resolved spelling names this machine's folder, so a remote (SSH) project
at the same path string must not be treated as the import.

* fix(repos): limit a desktop folder import's resolved-path match to this machine

Runtime-owned and SSH projects can share the path string; only projects on
this machine may match the import's resolved folder.

* fix(repos): read a nested import row's connection through the host helper

* fix(repos): match only this machine's projects when creating a local project
2026-11-04 13:21:06 -08:00

190 lines
7.6 KiB
TypeScript

import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog'
import { execFileSync } from 'node:child_process'
import { mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { mkdtemp } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
const tempRoots: string[] = []
async function createCloneFixture(): Promise<{
sourcePath: string
destinationParent: string
}> {
// Canonicalize macOS /var → /private/var like git does when registering repo.path.
const rootPath = realpathSync(
await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-add-project-clone-'))
)
tempRoots.push(rootPath)
const sourcePath = path.join(rootPath, 'default-checkout-source')
// Clone through a symlinked parent: git reports the resolved path, which must still be the project's own checkout.
const destinationParent = path.join(rootPath, 'clones-link')
mkdirSync(sourcePath, { recursive: true })
mkdirSync(path.join(rootPath, 'clones'), { recursive: true })
symlinkSync(path.join(rootPath, 'clones'), destinationParent, 'junction')
execFileSync('git', ['init'], { cwd: sourcePath, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
cwd: sourcePath,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: sourcePath, stdio: 'pipe' })
writeFileSync(path.join(sourcePath, 'README.md'), '# Default checkout source\n')
execFileSync('git', ['add', 'README.md'], { cwd: sourcePath, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: sourcePath, stdio: 'pipe' })
execFileSync('git', ['branch', '-M', 'main'], { cwd: sourcePath, stdio: 'pipe' })
return { sourcePath, destinationParent }
}
async function createLinkedWorktreeFixture(): Promise<{
mainPath: string
siblingPath: string
}> {
// Canonicalize the main checkout path like git does on macOS /var symlinks.
const rootPath = realpathSync(
await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-add-project-linked-'))
)
tempRoots.push(rootPath)
const mainPath = path.join(rootPath, 'linked-source')
const siblingPath = path.join(rootPath, 'linked-feature')
mkdirSync(mainPath, { recursive: true })
execFileSync('git', ['init'], { cwd: mainPath, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'e2e@test.local'], {
cwd: mainPath,
stdio: 'pipe'
})
execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: mainPath, stdio: 'pipe' })
writeFileSync(path.join(mainPath, 'README.md'), '# Linked source\n')
execFileSync('git', ['add', 'README.md'], { cwd: mainPath, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: mainPath, stdio: 'pipe' })
execFileSync('git', ['branch', '-M', 'main'], { cwd: mainPath, stdio: 'pipe' })
execFileSync('git', ['worktree', 'add', '-b', 'feature', siblingPath], {
cwd: mainPath,
stdio: 'pipe'
})
return { mainPath, siblingPath }
}
test.afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
test.describe('Add project default checkout', () => {
test('clones a repo and opens the default checkout without the setup-choice modal', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const fixture = await createCloneFixture()
await openSidebarProjectDialog(orcaPage)
const addDialog = orcaPage.getByRole('dialog', { name: /Add a project/i })
await expect(addDialog).toBeVisible()
await addDialog.getByRole('button', { name: /Clone from URL/i }).click()
const cloneDialog = orcaPage.getByRole('dialog', { name: /Clone from URL/i })
await expect(cloneDialog).toBeVisible()
await cloneDialog.getByPlaceholder('https://github.com/user/repo.git').fill(fixture.sourcePath)
await cloneDialog.getByPlaceholder('/path/to/destination').fill(fixture.destinationParent)
await cloneDialog.getByRole('button', { name: /^Clone$/ }).click()
await expect(orcaPage.getByRole('dialog', { name: /Repo added/i })).toBeHidden()
await expect(orcaPage.getByText('Use existing worktrees')).toBeHidden()
await expect(orcaPage.getByText('Create a new worktree')).toBeHidden()
await expect
.poll(
() =>
orcaPage.evaluate((cloneName) => {
const state = window.__store?.getState()
if (!state) {
return null
}
const repo = state.repos.find((candidate) => candidate.displayName === cloneName)
if (!repo) {
return null
}
const worktrees = state.worktreesByRepo[repo.id] ?? []
const defaultCheckout = worktrees.find((worktree) => worktree.isMainWorktree)
const normalizedDefaultCheckoutPath = defaultCheckout?.path.replace(/\\/g, '/') ?? null
return {
activeCheckoutIsDefault: state.activeWorktreeId === defaultCheckout?.id,
defaultCheckoutLooksCloned:
normalizedDefaultCheckoutPath?.endsWith(`/clones/${cloneName}`) ?? false,
oldSetupModalOpen: state.activeModal === 'project-added'
}
}, path.basename(fixture.sourcePath)),
{
timeout: 30_000,
message: 'cloned repo default checkout was not opened'
}
)
.toEqual({
activeCheckoutIsDefault: true,
defaultCheckoutLooksCloned: true,
oldSetupModalOpen: false
})
await expect(orcaPage.getByText(/discovered worktree/)).toHaveCount(0)
})
test('keeps sibling git worktrees hidden when opening the default checkout', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const fixture = await createLinkedWorktreeFixture()
await orcaPage.evaluate((folderPath) => {
window.__store?.getState().openModal('confirm-add-project-from-folder', { folderPath })
}, fixture.mainPath)
const addProjectDialog = orcaPage.getByRole('dialog', { name: /^Add Project$/i })
await expect(addProjectDialog).toBeVisible()
await addProjectDialog.getByRole('button', { name: /^Add Project$/ }).click()
await expect(addProjectDialog).toBeHidden()
await expect(orcaPage.getByRole('dialog', { name: /Repo added/i })).toBeHidden()
await expect(orcaPage.getByText('Use existing worktrees')).toBeHidden()
await expect
.poll(
() =>
orcaPage.evaluate((mainPath) => {
const state = window.__store?.getState()
if (!state) {
return null
}
const repo = state.repos.find((candidate) => candidate.path === mainPath)
if (!repo) {
return null
}
const worktrees = state.worktreesByRepo[repo.id] ?? []
const defaultCheckout = worktrees.find((worktree) => worktree.isMainWorktree)
return {
activeCheckoutIsDefault: state.activeWorktreeId === defaultCheckout?.id,
linkedRepoVisibility: repo.externalWorktreeVisibility,
visibleBranches: worktrees.map((worktree) => worktree.branch).sort(),
visibleCount: worktrees.length
}
}, fixture.mainPath),
{
timeout: 30_000,
message: 'default checkout did not open with sibling worktrees hidden'
}
)
.toEqual({
activeCheckoutIsDefault: true,
linkedRepoVisibility: undefined,
visibleBranches: ['refs/heads/main'],
visibleCount: 1
})
await expect(orcaPage.getByText('Hiding 1 discovered worktree')).toBeVisible()
})
})