mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
fix(worktree): collapse duplicate "Local Mac" run targets in the host picker (#10472)
* 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `<common>/worktrees/<name>`
|
||||
* 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('/')) {
|
||||
|
||||
Reference in New Issue
Block a user