Files
orca/src/main/git/repo.test.ts
T
JahyunBaek e3757a1d30 fix(worktree): ignore orphan remote-tracking refs in branch conflicts (#16699)
Refs #16646

Unify native, WSL, and direct SSH conflict checks behind the execution host, remove the duplicated SSH classifier, and cover orphan/configured remote behavior across both paths.
2026-08-29 14:36:24 -07:00

629 lines
23 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
buildSearchBaseRefsArgv,
getDefaultBaseRef,
getBranchConflictKind,
getRemoteCount,
parseAndFilterSearchRefDetails,
resolveDefaultBaseRefViaExec,
searchBaseRefDetails,
searchBaseRefs
} from './repo'
// Why: use real git state (not mocked) because the bug is in the for-each-ref glob shape a mock would miss.
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
}
function initRepo(dir: string): void {
git(dir, ['init', '--quiet'])
// Why: `--initial-branch=main` needs git >= 2.28; symbolic-ref before the first commit forces `main` on any git version.
git(dir, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(dir, ['config', 'user.email', 'test@test.com'])
git(dir, ['config', 'user.name', 'Test'])
git(dir, ['commit', '--allow-empty', '-m', 'initial', '--quiet'])
}
/** Create a remote-tracking ref via `update-ref`, avoiding a live remote. */
function createRemoteRef(mainDir: string, shortName: string, sha: string): void {
git(mainDir, ['update-ref', `refs/remotes/${shortName}`, sha])
}
function getHeadSha(dir: string): string {
return git(dir, ['rev-parse', 'HEAD']).trim()
}
describe('buildSearchBaseRefsArgv', () => {
it('caps broad local ref searches before parsing results', () => {
const argv = buildSearchBaseRefsArgv('feature', 25)
expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
expect(argv).toContain('--count=100')
expect(argv).toContain('refs/heads/**/*feature*')
expect(argv).toContain('refs/remotes/**/*feature*/**')
})
it('keeps segmented display-format searches bounded', () => {
const argv = buildSearchBaseRefsArgv('upstream/main', 10)
expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
expect(argv).toContain('--count=40')
expect(argv).toContain('refs/remotes/*upstream*/*main*')
expect(argv).toContain('refs/heads/*upstream*/*main*')
expect(argv).toContain('refs/remotes/*/upstream/main*')
expect(argv).toContain('refs/heads/upstream/main*')
})
it('anchors local-branch-name searches below configured remotes', () => {
const argv = buildSearchBaseRefsArgv('plan/docs', 10, { remoteNames: ['origin', 'foo/bar'] })
expect(argv).toContain('refs/remotes/origin/plan/docs*')
expect(argv).toContain('refs/remotes/foo/bar/plan/docs*')
expect(argv).not.toContain('refs/remotes/**/*plan/docs*')
})
it('can build display-format and branch-root patterns separately', () => {
const segmentedArgv = buildSearchBaseRefsArgv('upstream/feat', 10, {
remoteNames: ['origin', 'upstream'],
patternGroup: 'segmented'
})
const argv = buildSearchBaseRefsArgv('upstream/feat', 10, {
remoteNames: ['origin', 'upstream'],
patternGroup: 'branchRoot'
})
expect(segmentedArgv).toContain('refs/remotes/*upstream*/*feat*')
expect(segmentedArgv).not.toContain('refs/remotes/origin/upstream/feat*')
expect(argv).toContain('refs/remotes/origin/upstream/feat*')
expect(argv).not.toContain('refs/remotes/*upstream*/*feat*')
})
it('adds fallback headroom when remote HEAD cannot be excluded by git', () => {
const argv = buildSearchBaseRefsArgv('feature', 25, { excludeRemoteHead: false })
expect(argv).not.toContain('--exclude=refs/remotes/**/HEAD')
expect(argv).toContain('--count=200')
})
})
describe('searchBaseRefs (widened glob)', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-repo-test-'))
initRepo(tmpDir)
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('returns upstream/* branches when querying a non-origin remote', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
const results = await searchBaseRefs(tmpDir, 'upstream')
expect(results).toContain('upstream/main')
})
it('returns both origin/* and upstream/* for a shared branch name', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/feature-x', sha)
createRemoteRef(tmpDir, 'upstream/feature-x', sha)
const results = await searchBaseRefs(tmpDir, 'feature-x')
expect(results).toContain('origin/feature-x')
expect(results).toContain('upstream/feature-x')
})
it('filters out <remote>/HEAD pseudo-refs for all remotes', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/main', sha)
createRemoteRef(tmpDir, 'upstream/main', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
git(tmpDir, ['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main'])
const results = await searchBaseRefs(tmpDir, 'HEAD')
expect(results).not.toContain('origin/HEAD')
expect(results).not.toContain('upstream/HEAD')
})
it('is not hardcoded to `upstream` — arbitrary remote names are discoverable', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'mycorp-fork/main', sha)
const results = await searchBaseRefs(tmpDir, 'mycorp')
expect(results).toContain('mycorp-fork/main')
})
it('still returns local branches from refs/heads/*', async () => {
git(tmpDir, ['branch', 'local-only'])
const results = await searchBaseRefs(tmpDir, 'local')
expect(results).toContain('local-only')
})
// Why: fnmatch `*` doesn't cross `/`, so a single-word query needs `**` to match any segment of a slashed name.
it('finds a local slashed branch when the query lands in a deep segment', async () => {
git(tmpDir, ['branch', 'feature/login'])
const results = await searchBaseRefs(tmpDir, 'login')
expect(results).toContain('feature/login')
})
it('finds a local slashed branch when the query lands in an ancestor segment', async () => {
git(tmpDir, ['branch', 'feature/login'])
const results = await searchBaseRefs(tmpDir, 'feature')
expect(results).toContain('feature/login')
})
it('finds a remote slashed branch when the query lands in a deep segment', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/feature/login', sha)
const results = await searchBaseRefs(tmpDir, 'login')
expect(results).toContain('origin/feature/login')
})
it('finds a remote slashed branch when the query lands in an ancestor segment', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/feature/login', sha)
const results = await searchBaseRefs(tmpDir, 'feature')
expect(results).toContain('origin/feature/login')
})
it('returns the local branch name for a remote ref with slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
const results = await searchBaseRefDetails(tmpDir, 'origin/feature/something')
expect(results).toContainEqual({
refName: 'origin/feature/something',
localBranchName: 'feature/something'
})
})
it('keeps local branch names unchanged in detailed search results', async () => {
git(tmpDir, ['branch', 'feature/something'])
const results = await searchBaseRefDetails(tmpDir, 'feature/something')
expect(results).toContainEqual({
refName: 'feature/something',
localBranchName: 'feature/something'
})
})
it('allows creating a local branch from the selected matching remote base ref', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
const result = await getBranchConflictKind(
tmpDir,
'feature/something',
'origin/feature/something'
)
expect(result).toBeNull()
})
it('still reports a remote conflict for a different tracking ref with the same branch name', async () => {
const sha = getHeadSha(tmpDir)
// Why register both: a ref only tracks a branch when a remote actually owns its
// prefix, and this case is about a second *tracking* ref, not an orphan.
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
createRemoteRef(tmpDir, 'origin/feature/something', sha)
createRemoteRef(tmpDir, 'upstream/feature/something', sha)
expect(
await getBranchConflictKind(tmpDir, 'feature/something', 'origin/feature/something')
).toBe('remote')
})
it('ignores a ref whose prefix matches no configured remote', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
})
it('stops treating leftover refs from a removed remote as conflicts', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'fork', 'https://example.invalid/fork.git'])
createRemoteRef(tmpDir, 'fork/my-task', sha)
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('remote')
// Why not `remote remove`: that prunes the tracking refs too, which would pass
// with or without the ownership guard. Dropping only the config leaves the
// orphan ref behind, which is the state this guard exists for.
git(tmpDir, ['config', '--remove-section', 'remote.fork'])
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBeNull()
})
it('still reports a local conflict when an unrelated ref shares the name', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'mimic-fork/my-task', sha)
git(tmpDir, ['branch', 'my-task'])
expect(await getBranchConflictKind(tmpDir, 'my-task', 'origin/main')).toBe('local')
})
it('reports remote conflicts when the remote name contains a slash', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'foo/bar/feature/something', sha)
const result = await getBranchConflictKind(
tmpDir,
'feature/something',
'origin/feature/something'
)
expect(result).toBe('remote')
})
it('uses the longest configured remote name when deriving local branch names', () => {
const results = parseAndFilterSearchRefDetails(
'refs/remotes/foo/bar/feature/something\u0000foo/bar/feature/something\n',
10,
['foo', 'foo/bar']
)
expect(results).toEqual([
{
refName: 'foo/bar/feature/something',
localBranchName: 'feature/something'
}
])
})
it('returns [] for a repo with no matching refs', async () => {
const results = await searchBaseRefs(tmpDir, 'nonexistent-query-xyz')
expect(results).toEqual([])
})
it('returns recent refs for an empty query so branch pickers can open populated', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
createRemoteRef(tmpDir, 'upstream/feature-x', sha)
const results = await searchBaseRefs(tmpDir, '')
expect(results).toEqual(['main', 'upstream/feature-x', 'upstream/main'])
})
it('caps broad ref-search argv before git output is captured', () => {
const argv = buildSearchBaseRefsArgv('', 12)
expect(argv).toContain('--exclude=refs/remotes/**/HEAD')
expect(argv).toContain('--count=48')
})
it('does not hard-cap large explicit ref-search limits below the request size', () => {
const argv = buildSearchBaseRefsArgv('', 600)
expect(argv).toContain('--count=2400')
})
it('returns [] for invalid search limits instead of running an uncapped search', async () => {
await expect(searchBaseRefs(tmpDir, '', 0.5)).resolves.toEqual([])
await expect(searchBaseRefs(tmpDir, '', Number.NaN)).resolves.toEqual([])
})
// Why: users retype the displayed `<remote>/<branch>` format, so a slashed query must still match.
it('finds the ref when the query is in display format `<remote>/<branch>`', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/main')
expect(results).toContain('upstream/main')
})
it('matches remote-and-branch prefixes with display-format queries', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/feature-x', sha)
createRemoteRef(tmpDir, 'upstream/feature-y', sha)
createRemoteRef(tmpDir, 'origin/feature-x', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/feat')
expect(results).toContain('upstream/feature-x')
expect(results).toContain('upstream/feature-y')
// Why: `upstream/feat` pins the remote segment to *upstream*, so origin/feature-x must not leak in.
expect(results).not.toContain('origin/feature-x')
})
it('does not match when tokens are in the wrong segments', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
// Why: each token is pinned to its own segment (remote vs branch), so `main/upstream` must not match `upstream/main`.
const results = await searchBaseRefs(tmpDir, 'main/upstream')
expect(results).not.toContain('upstream/main')
})
it('still filters HEAD pseudo-refs for display-format queries', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main'])
// Why: the HEAD filter runs after the glob match, so display-format queries must still drop the pseudo-ref.
const results = await searchBaseRefs(tmpDir, 'upstream/HEAD')
expect(results).not.toContain('upstream/HEAD')
})
it('tolerates trailing, leading, and doubled slashes in the query', async () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
// Why: empty tokens from stray slashes would degrade to `**` and match nothing, so they're filtered out.
expect(await searchBaseRefs(tmpDir, 'upstream/')).toContain('upstream/main')
expect(await searchBaseRefs(tmpDir, '/upstream')).toContain('upstream/main')
expect(await searchBaseRefs(tmpDir, 'upstream//main')).toContain('upstream/main')
})
it('finds a remote branch when the query is the local branch name with slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'origin/plan/unified-brainstorm-plan-docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('origin/plan/unified-brainstorm-plan-docs')
})
it('finds a remote branch by local branch name when the remote name has slashes', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'foo/bar', 'https://example.invalid/repo.git'])
createRemoteRef(tmpDir, 'foo/bar/plan/unified-brainstorm-plan-docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('foo/bar/plan/unified-brainstorm-plan-docs')
})
it('does not match slash queries inside unrelated nested branch paths', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
createRemoteRef(tmpDir, 'origin/upstream/feature-x', sha)
createRemoteRef(tmpDir, 'origin/foo/upstream/feature-x', sha)
createRemoteRef(tmpDir, 'upstream/feature-y', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/feat')
expect(results).toContain('upstream/feature-y')
expect(results).toContain('origin/upstream/feature-x')
expect(results).not.toContain('origin/foo/upstream/feature-x')
})
it('keeps display-format matches when many branch-root matches share the query', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.invalid/upstream.git'])
for (let i = 0; i < 12; i += 1) {
createRemoteRef(tmpDir, `origin/upstream/feature-${i}`, sha)
}
createRemoteRef(tmpDir, 'upstream/feature-target', sha)
const results = await searchBaseRefs(tmpDir, 'upstream/feature', 2)
expect(results).toContain('upstream/feature-target')
})
it('still finds a local-branch-name match when the first segment is also a remote name', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git'])
createRemoteRef(tmpDir, 'origin/plan/docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/docs')
expect(results).toContain('origin/plan/docs')
})
it('keeps branch-root matches when many display-format matches share the query', async () => {
const sha = getHeadSha(tmpDir)
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
git(tmpDir, ['remote', 'add', 'plan', 'https://example.invalid/plan.git'])
for (let i = 0; i < 12; i += 1) {
createRemoteRef(tmpDir, `plan/docs-${i}`, sha)
}
createRemoteRef(tmpDir, 'origin/plan/docs', sha)
const results = await searchBaseRefs(tmpDir, 'plan/docs', 2)
expect(results).toContain('origin/plan/docs')
})
it('finds a local slashed branch when the query repeats the full branch name', async () => {
git(tmpDir, ['branch', 'plan/unified-brainstorm-plan-docs'])
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
expect(results).toContain('plan/unified-brainstorm-plan-docs')
})
})
describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-repo-test-'))
initRepo(tmpDir)
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('returns origin/main when both origin/main and upstream/main exist (origin wins)', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/main', sha)
createRemoteRef(tmpDir, 'upstream/main', sha)
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/main')
})
it('returns the target of origin/HEAD when set', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/main', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/main')
})
it('falls through from a stale origin/HEAD target to an existing primary ref', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/main', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'])
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/main')
})
it('falls through from a stale origin/HEAD primary target to another existing default ref', () => {
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'origin/master', sha)
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
const result = getDefaultBaseRef(tmpDir)
expect(result).toBe('origin/master')
})
it('does NOT fall through to upstream/main when origin/* is absent', () => {
// Why: default probe order is origin-only by design; upstream-aware defaulting is deferred.
const sha = getHeadSha(tmpDir)
createRemoteRef(tmpDir, 'upstream/main', sha)
const result = getDefaultBaseRef(tmpDir)
// initRepo creates a local `main`, so with no origin/* we expect it — not `upstream/main`.
expect(result).toBe('main')
expect(result).not.toBe('upstream/main')
})
})
describe('resolveDefaultBaseRefViaExec', () => {
it('falls through from a stale origin/HEAD target to the probe list', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/master\n' }
}
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/main') {
return { stdout: 'main-sha\n' }
}
throw new Error('missing ref')
}
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/main')
expect(calls).toEqual([
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main']
])
})
it('verifies origin/HEAD even when it points at origin/main', async () => {
const calls: string[][] = []
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
calls.push(argv)
if (argv[0] === 'symbolic-ref') {
return { stdout: 'refs/remotes/origin/main\n' }
}
if (argv[0] === 'rev-parse' && argv.at(-1) === 'refs/remotes/origin/master') {
return { stdout: 'master-sha\n' }
}
throw new Error('missing ref')
}
await expect(resolveDefaultBaseRefViaExec(exec)).resolves.toBe('origin/master')
expect(calls).toEqual([
['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main'],
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/master']
])
})
})
describe('getRemoteCount', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'orca-repo-test-'))
initRepo(tmpDir)
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
it('returns 0 for a repo with no remotes', async () => {
const count = await getRemoteCount(tmpDir)
expect(count).toBe(0)
})
it('returns 1 for a repo with origin only', async () => {
git(tmpDir, ['remote', 'add', 'origin', 'https://example.com/repo.git'])
const count = await getRemoteCount(tmpDir)
expect(count).toBe(1)
})
it('returns 2 for a repo with origin + upstream', async () => {
git(tmpDir, ['remote', 'add', 'origin', 'https://example.com/fork.git'])
git(tmpDir, ['remote', 'add', 'upstream', 'https://example.com/source.git'])
const count = await getRemoteCount(tmpDir)
expect(count).toBe(2)
})
it('returns 0 on error (non-existent path)', async () => {
const count = await getRemoteCount(path.join(tmpDir, 'does-not-exist'))
expect(count).toBe(0)
})
})