mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
* 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.
438 lines
15 KiB
TypeScript
438 lines
15 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import * as path from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
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'] })
|
|
}
|
|
|
|
describe('isGitRepo', () => {
|
|
let tmpDir: string
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-repo-detect-'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('rejects directories with an invalid .git file', () => {
|
|
const fakeRepo = path.join(tmpDir, 'fake')
|
|
mkdirSync(fakeRepo)
|
|
writeFileSync(path.join(fakeRepo, '.git'), 'not a gitdir file')
|
|
|
|
expect(isGitRepo(fakeRepo)).toBe(false)
|
|
})
|
|
|
|
it('accepts bare git repositories', () => {
|
|
const bareRepo = path.join(tmpDir, 'bare.git')
|
|
git(tmpDir, ['init', '--bare', '--quiet', bareRepo])
|
|
|
|
expect(isGitRepo(bareRepo)).toBe(true)
|
|
})
|
|
|
|
it('accepts a real repository when git itself cannot be run', () => {
|
|
// Why: regression guard for the spurious "Open as Folder" prompt. When the
|
|
// `git rev-parse` probe fails for an environmental reason (here simulated by
|
|
// making `git` unresolvable), a directory carrying valid Git metadata must
|
|
// still be recognized rather than silently downgraded to a plain folder.
|
|
const realRepo = path.join(tmpDir, 'real')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(realRepo)).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('accepts a nested directory in a repository when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'nested-real')
|
|
const nestedDir = path.join(realRepo, 'packages', 'web')
|
|
mkdirSync(nestedDir, { recursive: true })
|
|
git(realRepo, ['init', '--quiet'])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(nestedDir)).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('resolves a nested directory to the repo root when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'nested-root-real')
|
|
const nestedDir = path.join(realRepo, 'packages', 'web')
|
|
mkdirSync(nestedDir, { recursive: true })
|
|
git(realRepo, ['init', '--quiet'])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(getGitRepoRoot(nestedDir)).toBe(realRepo)
|
|
})
|
|
})
|
|
|
|
it('accepts a symlinked nested directory in a repository when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'symlink-real')
|
|
const nestedDir = path.join(realRepo, 'packages', 'web')
|
|
const symlinkedNestedDir = path.join(tmpDir, 'linked-nested')
|
|
mkdirSync(nestedDir, { recursive: true })
|
|
git(realRepo, ['init', '--quiet'])
|
|
symlinkSync(nestedDir, symlinkedNestedDir, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(symlinkedNestedDir)).toBe(true)
|
|
expect(getGitRepoRoot(symlinkedNestedDir)).toBe(
|
|
realpathSync.native(realRepo).replace(/\\/g, '/')
|
|
)
|
|
})
|
|
})
|
|
|
|
it('rejects a symlink inside a repository that points outside when git cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'symlink-parent-real')
|
|
const outsideDir = path.join(tmpDir, 'outside-target')
|
|
const symlinkedOutsideDir = path.join(realRepo, 'links', 'outside')
|
|
mkdirSync(path.dirname(symlinkedOutsideDir), { recursive: true })
|
|
mkdirSync(outsideDir)
|
|
git(realRepo, ['init', '--quiet'])
|
|
symlinkSync(outsideDir, symlinkedOutsideDir, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(symlinkedOutsideDir)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('resolves a symlink from one repository into another to the real target repo', () => {
|
|
const sourceRepo = path.join(tmpDir, 'symlink-source-real')
|
|
const targetRepo = path.join(tmpDir, 'symlink-target-real')
|
|
const targetNestedDir = path.join(targetRepo, 'packages', 'web')
|
|
const symlinkedTargetDir = path.join(sourceRepo, 'links', 'target')
|
|
mkdirSync(path.dirname(symlinkedTargetDir), { recursive: true })
|
|
mkdirSync(targetNestedDir, { recursive: true })
|
|
git(sourceRepo, ['init', '--quiet'])
|
|
git(targetRepo, ['init', '--quiet'])
|
|
symlinkSync(
|
|
targetNestedDir,
|
|
symlinkedTargetDir,
|
|
process.platform === 'win32' ? 'junction' : 'dir'
|
|
)
|
|
|
|
const expectedRoot = git(symlinkedTargetDir, ['rev-parse', '--show-toplevel'])
|
|
.trim()
|
|
.replace(/\\/g, '/')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(symlinkedTargetDir)).toBe(true)
|
|
expect(getGitRepoRoot(symlinkedTargetDir)).toBe(expectedRoot)
|
|
})
|
|
})
|
|
|
|
it('accepts a linked worktree when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'linked-main')
|
|
const linkedWorktree = path.join(tmpDir, 'linked-worktree')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
git(realRepo, [
|
|
'-c',
|
|
'user.name=Orca Test',
|
|
'-c',
|
|
'user.email=orca@example.com',
|
|
'commit',
|
|
'--allow-empty',
|
|
'--message',
|
|
'initial'
|
|
])
|
|
git(realRepo, ['worktree', 'add', '--quiet', '-b', 'offline-linked', linkedWorktree])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(linkedWorktree)).toBe(true)
|
|
})
|
|
})
|
|
|
|
it('rejects a plain folder when git cannot be run', () => {
|
|
const plain = path.join(tmpDir, 'plain')
|
|
mkdirSync(plain)
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(plain)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a garbage .git file even when git cannot be run', () => {
|
|
const fakeRepo = path.join(tmpDir, 'fake-offline')
|
|
mkdirSync(fakeRepo)
|
|
writeFileSync(path.join(fakeRepo, '.git'), 'not a gitdir file')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(fakeRepo)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a nested folder with an invalid .git marker even inside a valid repo', () => {
|
|
const realRepo = path.join(tmpDir, 'outer-real')
|
|
const nestedDir = path.join(realRepo, 'packages', 'web')
|
|
mkdirSync(nestedDir, { recursive: true })
|
|
git(realRepo, ['init', '--quiet'])
|
|
writeFileSync(path.join(nestedDir, '.git'), 'not a gitdir file')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(nestedDir)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a .git file that points at a missing gitdir when git cannot be run', () => {
|
|
const fakeRepo = path.join(tmpDir, 'missing-gitdir')
|
|
mkdirSync(fakeRepo)
|
|
writeFileSync(path.join(fakeRepo, '.git'), 'gitdir: /missing/orca/gitdir')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(fakeRepo)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects an empty .git directory', () => {
|
|
const emptyGitDir = path.join(tmpDir, 'empty-gitdir')
|
|
mkdirSync(path.join(emptyGitDir, '.git'), { recursive: true })
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(emptyGitDir)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects an incomplete .git directory with only HEAD', () => {
|
|
const incompleteGitDir = path.join(tmpDir, 'incomplete-gitdir')
|
|
mkdirSync(path.join(incompleteGitDir, '.git'), { recursive: true })
|
|
writeFileSync(path.join(incompleteGitDir, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(incompleteGitDir)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a regular repository admin directory when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a case-insensitive .git admin directory alias when git itself cannot be run', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir-uppercase')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
const uppercaseAdminDir = path.join(realRepo, '.GIT')
|
|
try {
|
|
realpathSync.native(uppercaseAdminDir)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(uppercaseAdminDir)).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a regular repository admin directory when core.bare uses alternate false spelling', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir-no')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
git(realRepo, ['config', 'core.bare', 'no'])
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a regular repository admin directory when core.bare is empty false', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir-empty-false')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
const configPath = path.join(realRepo, '.git', 'config')
|
|
writeFileSync(configPath, readFileSync(configPath, 'utf8').replace(/bare = false/, 'bare ='))
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a regular repository admin directory when core.bare false has inline comments', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir-commented-false')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
const configPath = path.join(realRepo, '.git', 'config')
|
|
const config = readFileSync(configPath, 'utf8')
|
|
writeFileSync(configPath, config.replace(/bare = false/, 'bare = false # regular worktree'))
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
|
|
writeFileSync(configPath, config.replace(/bare = false/, 'bare = false ; regular worktree'))
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a regular repository admin directory when core.bare is quoted false', () => {
|
|
const realRepo = path.join(tmpDir, 'admin-dir-quoted-false')
|
|
mkdirSync(realRepo)
|
|
git(realRepo, ['init', '--quiet'])
|
|
const configPath = path.join(realRepo, '.git', 'config')
|
|
writeFileSync(
|
|
configPath,
|
|
readFileSync(configPath, 'utf8').replace(/bare = false/, String.raw`bare = \"false\"`)
|
|
)
|
|
|
|
withGitUnavailable(() => {
|
|
expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
it('resolves a contained path to the worktree root', () => {
|
|
const repoRoot = path.join(tmpDir, 'repo')
|
|
const nestedDir = path.join(repoRoot, 'packages', 'web')
|
|
mkdirSync(nestedDir, { recursive: true })
|
|
git(tmpDir, ['init', '--quiet', repoRoot])
|
|
|
|
// Why: derive the expected root from git's own --show-toplevel so the
|
|
// assertion matches getGitRepoRoot's canonicalization (e.g. macOS resolves
|
|
// the /var tmpdir symlink to /private/var) across all platforms.
|
|
const expectedRoot = git(repoRoot, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/')
|
|
expect(getGitRepoRoot(nestedDir)).toBe(expectedRoot)
|
|
})
|
|
|
|
it('keeps WSL UNC identity when git reports a Linux worktree root', () => {
|
|
expect(
|
|
normalizeGitRepoRootForInputPath(
|
|
String.raw`\\wsl.localhost\Ubuntu\home\alice\repo\packages\web`,
|
|
'/home/alice/repo'
|
|
)
|
|
).toBe(String.raw`\\wsl.localhost\Ubuntu\home\alice\repo`)
|
|
})
|
|
|
|
it('preserves bare repository paths when no worktree root exists', () => {
|
|
const bareRepo = path.join(tmpDir, 'bare.git')
|
|
git(tmpDir, ['init', '--bare', '--quiet', bareRepo])
|
|
|
|
expect(getGitRepoRoot(bareRepo)).toBe(bareRepo)
|
|
})
|
|
})
|
|
|
|
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
|
|
* fallback path. PATH is restored afterward.
|
|
*/
|
|
function withGitUnavailable(fn: () => void): void {
|
|
const originalPath = process.env.PATH
|
|
// An empty PATH leaves no directory to resolve the bare `git` binary, so the
|
|
// probe throws ENOENT — the indeterminate failure the fallback exists for.
|
|
process.env.PATH = ''
|
|
try {
|
|
fn()
|
|
} finally {
|
|
// Why: restoring an originally-unset PATH via assignment would write the
|
|
// string "undefined", corrupting PATH for later tests in this process.
|
|
if (originalPath === undefined) {
|
|
delete process.env.PATH
|
|
} else {
|
|
process.env.PATH = originalPath
|
|
}
|
|
}
|
|
}
|