mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Fix truncated Unicode branch names in base ref search
Preserve complete branch selectors when Git truncates or disambiguates short names, and keep slash-named local branches separate from remote-tracking refs through worktree creation and reuse. Keep native/SSH namespace boundaries and mixed-version client capability gates intact. Accept Git-valid dotted components and cover collisions with configured and orphan tracking refs. Adapted from @nishino-tsukasa's #19540. Independently reviewed in seven adversarial rounds; 235 focused tests and 77 affected-Apple-Git tests pass, along with typecheck, quality checks, and the desktop build. Correct the inherited file-explorer cache-scan expectation while retaining its exact constant-work bound. Correct the inherited release-parser compatibility assertion to match the pinned test dependency; all 121 cross-version tests pass locally. Fixes #19515 Co-authored-by: kino <nishinotsukasavirgo@gmail.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
@@ -85,7 +86,8 @@ describe('CLI remote WebSocket transport', () => {
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseAndFilterSearchRefDetails } from './repo-base-ref-search'
|
||||
import { resolveBaseRefSearchSelector } from './base-ref-search-selector'
|
||||
|
||||
describe('base ref search selectors', () => {
|
||||
it('qualifies slash-named locals even when no remote is configured', () => {
|
||||
expect(resolveBaseRefSearchSelector('refs/heads/origin/feature', 'origin/feature')).toBe(
|
||||
'refs/heads/origin/feature'
|
||||
)
|
||||
expect(resolveBaseRefSearchSelector('refs/heads/foo/bar/feature', 'foo/bar/feature')).toBe(
|
||||
'refs/heads/foo/bar/feature'
|
||||
)
|
||||
expect(resolveBaseRefSearchSelector('refs/heads/feature/local', 'feature/local')).toBe(
|
||||
'refs/heads/feature/local'
|
||||
)
|
||||
expect(resolveBaseRefSearchSelector('refs/heads/main', 'main')).toBe('main')
|
||||
})
|
||||
it.each([
|
||||
['refs/heads/feature/加', 'feature/�', 'refs/heads/feature/加'],
|
||||
['refs/heads/feature/加', 'feature/', 'refs/heads/feature/加'],
|
||||
['refs/heads/feature/�', 'feature/�', 'refs/heads/feature/�'],
|
||||
['refs/heads/�', '�', '�'],
|
||||
['refs/heads/feature/�', 'refs/heads/feature/�', 'refs/heads/feature/�'],
|
||||
['refs/heads/feature', 'heads/feature', 'refs/heads/feature'],
|
||||
['refs/remotes/origin/feature', 'remotes/origin/feature', 'refs/remotes/origin/feature'],
|
||||
['refs/remotes/refs/heads/feature', 'refs/heads/feature', 'refs/remotes/refs/heads/feature'],
|
||||
['refs/remotes/origin/feature/HEAD', 'origin/feature', 'refs/remotes/origin/feature/HEAD'],
|
||||
[
|
||||
'refs/heads/refs/remotes/origin/feature',
|
||||
'refs/remotes/origin/feature',
|
||||
'refs/heads/refs/remotes/origin/feature'
|
||||
],
|
||||
[
|
||||
'refs/remotes/refs/remotes/feature',
|
||||
'refs/remotes/feature',
|
||||
'refs/remotes/refs/remotes/feature'
|
||||
],
|
||||
['refs/remotes/origin/feature/HEAD', 'origin/fea', 'refs/remotes/origin/feature/HEAD']
|
||||
])('preserves the identity of %s with short field %s', (full, short, expected) => {
|
||||
expect(resolveBaseRefSearchSelector(full, short)).toBe(expected)
|
||||
})
|
||||
|
||||
it('filters unsupported selectors before the page limit and deduplicates recovered full refs', () => {
|
||||
const stdout = [
|
||||
'refs/heads/feature/加\0feature/�',
|
||||
'refs/heads/feature/加\0feature/�',
|
||||
'refs/remotes/origin/feature/加\0origin/feature/�',
|
||||
'refs/heads/safe-one\0safe-one',
|
||||
'refs/heads/safe-two\0safe-two',
|
||||
'refs/heads/safe-three\0safe-three'
|
||||
].join('\n')
|
||||
expect(parseAndFilterSearchRefDetails(stdout, 2, ['origin'], false)).toEqual([
|
||||
{ refName: 'safe-one', localBranchName: 'safe-one' },
|
||||
{ refName: 'safe-two', localBranchName: 'safe-two' }
|
||||
])
|
||||
expect(parseAndFilterSearchRefDetails(stdout, 3, ['origin'])).toEqual([
|
||||
{ refName: 'refs/heads/feature/加', localBranchName: 'feature/加' },
|
||||
{ refName: 'refs/remotes/origin/feature/加', localBranchName: 'feature/加' },
|
||||
{ refName: 'safe-one', localBranchName: 'safe-one' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not publish malformed full refs', () => {
|
||||
expect(parseAndFilterSearchRefDetails('refs/heads/bad..name\0bad..name', 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
export function isQualifiedBaseRef(refName: string): boolean {
|
||||
return refName.startsWith('refs/heads/') || refName.startsWith('refs/remotes/')
|
||||
}
|
||||
|
||||
export function resolveBaseRefSearchSelector(fullRef: string, shortRef: string): string {
|
||||
const parts = /^refs\/(heads|remotes)\/(.+)$/.exec(fullRef)
|
||||
if (!parts) {
|
||||
return fullRef
|
||||
}
|
||||
const namespaceName = `${parts[1]}/${parts[2]}`
|
||||
const naturalName = parts[2]
|
||||
// Full selectors preserve identity when Git disambiguates or corrupts a short name.
|
||||
if (shortRef === namespaceName || ![fullRef, namespaceName, naturalName].includes(shortRef)) {
|
||||
return fullRef
|
||||
}
|
||||
// Slash-named locals can collide with remote-tracking refs even without a configured remote.
|
||||
if (parts[1] === 'heads' && naturalName.includes('/')) {
|
||||
return fullRef
|
||||
}
|
||||
// Natural names beginning with refs/ must not impersonate another namespace.
|
||||
return shortRef.startsWith('refs/') ? fullRef : shortRef
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { isRemoteHeadRef } from '../../shared/hosted-review-refs'
|
||||
import { getLocalGitCapabilityCache } from './git-capability-state'
|
||||
import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { isQualifiedBaseRef, resolveBaseRefSearchSelector } from './base-ref-search-selector'
|
||||
|
||||
const REF_SEARCH_CANDIDATE_MULTIPLIER = 4
|
||||
const REF_SEARCH_LEGACY_HEADROOM = 100
|
||||
@@ -176,7 +177,8 @@ export async function searchBaseRefs(
|
||||
export async function searchBaseRefDetails(
|
||||
path: string,
|
||||
query: string,
|
||||
limit = REPO_SEARCH_REFS_DEFAULT_LIMIT
|
||||
limit = REPO_SEARCH_REFS_DEFAULT_LIMIT,
|
||||
includeQualifiedRefs = true
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
if (!isRepoSearchRefsRequestLimit(limit)) {
|
||||
return []
|
||||
@@ -200,7 +202,12 @@ export async function searchBaseRefDetails(
|
||||
])
|
||||
return mergeBaseRefSearchResultGroups(
|
||||
results.map((entry) =>
|
||||
parseAndFilterSearchRefDetails(entry.stdout, boundedScanLimit, remotes)
|
||||
parseAndFilterSearchRefDetails(
|
||||
entry.stdout,
|
||||
boundedScanLimit,
|
||||
remotes,
|
||||
includeQualifiedRefs
|
||||
)
|
||||
),
|
||||
boundedScanLimit
|
||||
)
|
||||
@@ -209,7 +216,12 @@ export async function searchBaseRefDetails(
|
||||
const result = await runSearchBaseRefsGit(path, normalizedQuery, boundedScanLimit, {
|
||||
remoteNames: remotes
|
||||
})
|
||||
return parseAndFilterSearchRefDetails(result.stdout, boundedScanLimit, remotes)
|
||||
return parseAndFilterSearchRefDetails(
|
||||
result.stdout,
|
||||
boundedScanLimit,
|
||||
remotes,
|
||||
includeQualifiedRefs
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[searchBaseRefs] for-each-ref failed', { path, err })
|
||||
return []
|
||||
@@ -234,26 +246,12 @@ export async function listRemoteNames(
|
||||
export function parseAndFilterSearchRefDetails(
|
||||
stdout: string,
|
||||
limit: number,
|
||||
remotes: string[] = []
|
||||
remotes: string[] = [],
|
||||
includeQualifiedRefs = true
|
||||
): BaseRefSearchResult[] {
|
||||
const seen = new Set<string>()
|
||||
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
|
||||
|
||||
const canonicalShortRef = (fullRef: string, gitShortRef: string): string => {
|
||||
// Git's refname:short DWIM rule can strip a trailing `/HEAD` (for example,
|
||||
// `refs/remotes/origin/feature/HEAD` becomes `origin/feature`). Derive the
|
||||
// display name only for that case; otherwise Git's disambiguation prefixes
|
||||
// (such as `heads/` and `remotes/`) are significant and must be retained.
|
||||
if (
|
||||
fullRef.startsWith('refs/remotes/') &&
|
||||
fullRef.endsWith('/HEAD') &&
|
||||
!gitShortRef.endsWith('/HEAD')
|
||||
) {
|
||||
return fullRef.slice('refs/remotes/'.length)
|
||||
}
|
||||
return gitShortRef
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
@@ -265,10 +263,11 @@ export function parseAndFilterSearchRefDetails(
|
||||
}
|
||||
const full = line.slice(0, nul)
|
||||
const gitShort = line.slice(nul + 1)
|
||||
return { full, short: canonicalShortRef(full, gitShort) }
|
||||
return { full, short: resolveBaseRefSearchSelector(full, gitShort) }
|
||||
})
|
||||
.filter((entry): entry is { full: string; short: string } => entry !== null)
|
||||
.filter(({ full }) => !isRemoteHeadRef(full, sortedRemotes))
|
||||
.filter(({ full }) => isSafeGitRefName(full) && !isRemoteHeadRef(full, sortedRemotes))
|
||||
.filter(({ short }) => includeQualifiedRefs || !isQualifiedBaseRef(short))
|
||||
.filter(({ short }) => {
|
||||
if (seen.has(short)) {
|
||||
return false
|
||||
@@ -303,6 +302,10 @@ export function resolveLocalBranchName(
|
||||
shortRef: string,
|
||||
remotes: string[]
|
||||
): string {
|
||||
const localRefPrefix = 'refs/heads/'
|
||||
if (fullRef.startsWith(localRefPrefix)) {
|
||||
return fullRef.slice(localRefPrefix.length) || shortRef
|
||||
}
|
||||
const remoteRefPrefix = 'refs/remotes/'
|
||||
if (!fullRef.startsWith(remoteRefPrefix)) {
|
||||
return shortRef
|
||||
|
||||
+267
-8
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
||||
|
||||
import {
|
||||
buildSearchBaseRefsArgv,
|
||||
@@ -179,7 +180,7 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
const results = await searchBaseRefs(tmpDir, 'login')
|
||||
|
||||
expect(results).toContain('feature/login')
|
||||
expect(results).toContain('refs/heads/feature/login')
|
||||
})
|
||||
|
||||
it('finds a local slashed branch when the query lands in an ancestor segment', async () => {
|
||||
@@ -187,7 +188,7 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
const results = await searchBaseRefs(tmpDir, 'feature')
|
||||
|
||||
expect(results).toContain('feature/login')
|
||||
expect(results).toContain('refs/heads/feature/login')
|
||||
})
|
||||
|
||||
it('finds a remote slashed branch when the query lands in a deep segment', async () => {
|
||||
@@ -227,11 +228,245 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
const results = await searchBaseRefDetails(tmpDir, 'feature/something')
|
||||
|
||||
expect(results).toContainEqual({
|
||||
refName: 'feature/something',
|
||||
refName: 'refs/heads/feature/something',
|
||||
localBranchName: 'feature/something'
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers complete Unicode ref names when Git splits a short ref byte sequence', () => {
|
||||
const branch = 'feature/运动记录及预约详情页优化'
|
||||
const results = parseAndFilterSearchRefDetails(
|
||||
[
|
||||
`refs/heads/${branch}\0feature/运动记录及预约详�`,
|
||||
`refs/remotes/origin/${branch}\0origin/feature/运动记录及预约详�`
|
||||
].join('\n'),
|
||||
10,
|
||||
['origin']
|
||||
)
|
||||
|
||||
expect(results).toEqual([
|
||||
{ refName: `refs/heads/${branch}`, localBranchName: branch },
|
||||
{ refName: `refs/remotes/origin/${branch}`, localBranchName: branch }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves namespace disambiguation while recovering colliding Unicode refs', () => {
|
||||
const branch = 'origin/feature/运动记录及预约详情页优化'
|
||||
const results = parseAndFilterSearchRefDetails(
|
||||
[
|
||||
`refs/heads/${branch}\0origin/feature/运动记录及预约详�`,
|
||||
`refs/remotes/${branch}\0origin/feature/运动记录及预约详�`
|
||||
].join('\n'),
|
||||
10,
|
||||
['origin']
|
||||
)
|
||||
|
||||
expect(results).toEqual([
|
||||
{ refName: `refs/heads/${branch}`, localBranchName: branch },
|
||||
{
|
||||
refName: `refs/remotes/${branch}`,
|
||||
localBranchName: 'feature/运动记录及预约详情页优化'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('distinguishes literal U+FFFD from a later decoder-introduced truncation marker', () => {
|
||||
const literal = 'feature/�'
|
||||
const fullyQualifiedLiteral = 'feature/fully-qualified/�'
|
||||
const truncated = 'feature/�运动记录及预约详情页优化'
|
||||
const results = parseAndFilterSearchRefDetails(
|
||||
[
|
||||
`refs/heads/${literal}\0${literal}`,
|
||||
`refs/heads/${fullyQualifiedLiteral}\0refs/heads/${fullyQualifiedLiteral}`,
|
||||
`refs/heads/${truncated}\0feature/�运动记录及预约详�`
|
||||
].join('\n'),
|
||||
10
|
||||
)
|
||||
|
||||
expect(results).toEqual([
|
||||
{ refName: `refs/heads/${literal}`, localBranchName: literal },
|
||||
{
|
||||
refName: `refs/heads/${fullyQualifiedLiteral}`,
|
||||
localBranchName: fullyQualifiedLiteral
|
||||
},
|
||||
{ refName: `refs/heads/${truncated}`, localBranchName: truncated }
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['feature/运动记录及预约详情页优化', 'feature/运动记录及预约详情页优化加', 'feature/加'])(
|
||||
'returns intact Unicode ref names from real Git: %s',
|
||||
async (branch) => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
git(tmpDir, ['branch', branch])
|
||||
createRemoteRef(tmpDir, `origin/${branch}`, sha)
|
||||
|
||||
const results = await searchBaseRefDetails(tmpDir, branch.slice('feature/'.length))
|
||||
|
||||
const refNames = results.map(({ refName }) => refName)
|
||||
expect(refNames.some((ref) => ref === branch || ref === `refs/heads/${branch}`)).toBe(true)
|
||||
expect(
|
||||
refNames.some(
|
||||
(ref) => ref === `origin/${branch}` || ref === `refs/remotes/origin/${branch}`
|
||||
)
|
||||
).toBe(true)
|
||||
expect(results.every(({ localBranchName }) => localBranchName === branch)).toBe(true)
|
||||
expect(results.every(({ refName }) => !refName.includes('\uFFFD'))).toBe(true)
|
||||
expect(
|
||||
results.every(
|
||||
({ refName }) => git(tmpDir, ['rev-parse', '--verify', refName]).trim() === sha
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('creates worktrees from intact local and remote Unicode search selectors', async () => {
|
||||
const branch = 'feature/加'
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['branch', branch])
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, `origin/${branch}`, sha)
|
||||
const results = await searchBaseRefDetails(tmpDir, '加')
|
||||
expect(results).toHaveLength(2)
|
||||
const worktreeRoot = mkdtempSync(path.join(tmpdir(), 'orca-unicode-worktrees-'))
|
||||
try {
|
||||
for (const [index, result] of results.entries()) {
|
||||
const worktreePath = path.join(worktreeRoot, String(index))
|
||||
git(tmpDir, ['worktree', 'add', '-b', `recovered-${index}`, worktreePath, result.refName])
|
||||
expect(git(worktreePath, ['rev-parse', 'HEAD']).trim()).toBe(sha)
|
||||
expect(result.localBranchName).toBe(branch)
|
||||
}
|
||||
} finally {
|
||||
rmSync(worktreeRoot, { recursive: true, force: true })
|
||||
git(tmpDir, ['worktree', 'prune'])
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['feature./valid', 'feature./运动记录', 'feature./加'])(
|
||||
'keeps valid dotted components searchable: %s',
|
||||
async (branch) => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['branch', branch])
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, `origin/${branch}`, sha)
|
||||
const results = await searchBaseRefDetails(tmpDir, branch)
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results.every(({ localBranchName }) => localBranchName === branch)).toBe(true)
|
||||
expect(
|
||||
results.map(({ refName }) => git(tmpDir, ['rev-parse', '--verify', refName]).trim())
|
||||
).toEqual([sha, sha])
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps colliding local and remote selectors distinct in loose mode with configured remote %s',
|
||||
async (configured) => {
|
||||
const branch = 'origin/feature'
|
||||
const localSha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['branch', branch])
|
||||
if (configured) {
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
}
|
||||
git(tmpDir, ['commit', '--allow-empty', '-m', 'remote target', '--quiet'])
|
||||
const remoteSha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, branch, remoteSha)
|
||||
git(tmpDir, ['config', 'core.warnAmbiguousRefs', 'false'])
|
||||
const results = await searchBaseRefDetails(tmpDir, branch)
|
||||
expect(results).toContainEqual({ refName: `refs/heads/${branch}`, localBranchName: branch })
|
||||
for (const result of results) {
|
||||
const base = await resolveWorktreeAddBaseRef(result.refName, async (ref) => {
|
||||
try {
|
||||
git(tmpDir, ['rev-parse', '--verify', ref])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
expect(git(tmpDir, ['rev-parse', '--verify', base]).trim()).toBe(
|
||||
result.localBranchName === branch ? localSha : remoteSha
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['branch', 'tag'])(
|
||||
'preserves a nested remote HEAD against a colliding %s',
|
||||
async (kind) => {
|
||||
const remoteSha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, 'origin/feature/HEAD', remoteSha)
|
||||
git(tmpDir, ['commit', '--allow-empty', '-m', 'collision target', '--quiet'])
|
||||
git(tmpDir, [kind, 'origin/feature/HEAD'])
|
||||
const results = await searchBaseRefDetails(tmpDir, 'feature/HEAD')
|
||||
expect(results).toContainEqual({
|
||||
refName: 'refs/remotes/origin/feature/HEAD',
|
||||
localBranchName: 'feature/HEAD'
|
||||
})
|
||||
expect(
|
||||
git(tmpDir, ['rev-parse', '--verify', 'refs/remotes/origin/feature/HEAD']).trim()
|
||||
).toBe(remoteSha)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['refs/heads/topic', 'refs/remotes/origin/topic'])(
|
||||
'preserves the local namespace for a branch named %s',
|
||||
async (branch) => {
|
||||
git(tmpDir, ['branch', branch])
|
||||
const results = await searchBaseRefDetails(tmpDir, branch)
|
||||
expect(results).toEqual([{ refName: `refs/heads/${branch}`, localBranchName: branch }])
|
||||
}
|
||||
)
|
||||
|
||||
it('returns distinct resolvable names for real colliding Unicode refs', async () => {
|
||||
const branch = 'origin/运动记录及预约详情页优化'
|
||||
const localSha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'origin', 'https://example.invalid/repo.git'])
|
||||
git(tmpDir, ['branch', branch, localSha])
|
||||
git(tmpDir, ['commit', '--allow-empty', '-m', 'remote ref', '--quiet'])
|
||||
const remoteSha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, branch, remoteSha)
|
||||
|
||||
const results = await searchBaseRefDetails(tmpDir, 'origin/运动')
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
results.map(({ refName, localBranchName }) => [
|
||||
localBranchName,
|
||||
git(tmpDir, ['rev-parse', '--verify', refName]).trim()
|
||||
])
|
||||
)
|
||||
).toEqual({
|
||||
[branch]: localSha,
|
||||
运动记录及预约详情页优化: remoteSha
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a Unicode branch rather than a same-name tag', async () => {
|
||||
const branch = 'feature/运动记录及预约详情页优化'
|
||||
const branchSha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['branch', branch, branchSha])
|
||||
git(tmpDir, ['commit', '--allow-empty', '-m', 'tag target', '--quiet'])
|
||||
git(tmpDir, ['tag', branch])
|
||||
|
||||
const results = await searchBaseRefDetails(tmpDir, '运动记录')
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]?.refName).toBe(`refs/heads/${branch}`)
|
||||
expect(results[0]?.localBranchName).toBe(branch)
|
||||
expect(git(tmpDir, ['rev-parse', '--verify', results[0]?.refName ?? '']).trim()).toBe(branchSha)
|
||||
|
||||
const worktreeDir = mkdtempSync(path.join(tmpdir(), 'orca-ref-worktree-test-'))
|
||||
rmSync(worktreeDir, { recursive: true })
|
||||
try {
|
||||
git(tmpDir, ['worktree', 'add', '--quiet', worktreeDir, results[0]?.localBranchName ?? ''])
|
||||
expect(git(worktreeDir, ['symbolic-ref', 'HEAD']).trim()).toBe(`refs/heads/${branch}`)
|
||||
} finally {
|
||||
rmSync(worktreeDir, { recursive: true, force: true })
|
||||
git(tmpDir, ['worktree', 'prune'])
|
||||
}
|
||||
})
|
||||
|
||||
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'])
|
||||
@@ -306,6 +541,19 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
expect(result).toBe('remote')
|
||||
})
|
||||
|
||||
it('keeps a remote named refs/heads distinct from a fully qualified local ref', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
git(tmpDir, ['remote', 'add', 'refs/heads', 'https://example.invalid/repo.git'])
|
||||
createRemoteRef(tmpDir, 'refs/heads/feature-example', sha)
|
||||
|
||||
const results = await searchBaseRefDetails(tmpDir, 'feature-example')
|
||||
|
||||
expect(results).toContainEqual({
|
||||
refName: 'refs/remotes/refs/heads/feature-example',
|
||||
localBranchName: 'feature-example'
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
@@ -424,7 +672,11 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
const results = await searchBaseRefs(tmpDir, 'feature/HEAD')
|
||||
|
||||
expect(results).toContain('upstream/feature/HEAD')
|
||||
const nestedRef = results.find(
|
||||
(ref) => ref.endsWith('/upstream/feature/HEAD') || ref === 'upstream/feature/HEAD'
|
||||
)
|
||||
expect(nestedRef).toBeDefined()
|
||||
expect(git(tmpDir, ['rev-parse', '--verify', nestedRef ?? '']).trim()).toBe(sha)
|
||||
expect(results).not.toContain('upstream/HEAD')
|
||||
})
|
||||
|
||||
@@ -438,12 +690,19 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
['origin']
|
||||
)
|
||||
|
||||
expect(results.map((result) => result.refName)).toEqual([
|
||||
'heads/origin/main',
|
||||
'remotes/origin/main'
|
||||
expect(results).toEqual([
|
||||
{ refName: 'refs/heads/origin/main', localBranchName: 'origin/main' },
|
||||
{ refName: 'refs/remotes/origin/main', localBranchName: 'main' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a disambiguated local branch attached to its real branch name', () => {
|
||||
const branch = 'feature/colliding-tag'
|
||||
const results = parseAndFilterSearchRefDetails(`refs/heads/${branch}\0heads/${branch}`, 10)
|
||||
|
||||
expect(results).toEqual([{ refName: `refs/heads/${branch}`, localBranchName: branch }])
|
||||
})
|
||||
|
||||
it('tolerates trailing, leading, and doubled slashes in the query', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'upstream/main', sha)
|
||||
@@ -533,7 +792,7 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
|
||||
const results = await searchBaseRefs(tmpDir, 'plan/unified-brainstorm-plan-docs')
|
||||
|
||||
expect(results).toContain('plan/unified-brainstorm-plan-docs')
|
||||
expect(results).toContain('refs/heads/plan/unified-brainstorm-plan-docs')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
@@ -35,6 +36,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
// Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer
|
||||
// targeting a remote host is admitted — the asymmetry this constant exists to close.
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY
|
||||
|
||||
@@ -1516,6 +1516,9 @@ async function resolveRemoteTrackingBaseSsh(
|
||||
repoPath: string,
|
||||
baseBranch: string
|
||||
): Promise<RemoteTrackingBase | null> {
|
||||
if (baseBranch.startsWith('refs/') && !baseBranch.startsWith('refs/remotes/')) {
|
||||
return null
|
||||
}
|
||||
let remotes: string[]
|
||||
try {
|
||||
const { stdout } = await provider.exec(['remote'], repoPath)
|
||||
|
||||
@@ -94,6 +94,69 @@ describe('registerWorktreeHandlers', () => {
|
||||
setupWorktreeHandlers()
|
||||
})
|
||||
|
||||
it('keeps a qualified local base local when an SSH remote is named refs', async () => {
|
||||
const baseBranch = 'refs/heads/feature/加'
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn(async (args: string[]) => {
|
||||
if (args[0] === 'show-ref') {
|
||||
throw Object.assign(new Error('ref not found'), { code: 1 })
|
||||
}
|
||||
return {
|
||||
stdout:
|
||||
args[0] === 'remote'
|
||||
? 'refs\n'
|
||||
: args[0] === 'rev-parse' && args.includes(`${baseBranch}^{commit}`)
|
||||
? 'local-sha\n'
|
||||
: args[0] === 'rev-parse' && args.includes(`refs/remotes/${baseBranch}^{commit}`)
|
||||
? 'remote-sha\n'
|
||||
: '',
|
||||
stderr: ''
|
||||
}
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/repo-recovered-local',
|
||||
head: 'local-sha',
|
||||
branch: 'refs/heads/recovered-local',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
})
|
||||
store.setWorktreeMeta.mockImplementation((_id, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: repo.id,
|
||||
name: 'recovered-local',
|
||||
baseBranch
|
||||
})
|
||||
|
||||
expect(provider.fetchRemoteTrackingRef).not.toHaveBeenCalled()
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
repo.path,
|
||||
'recovered-local',
|
||||
'/remote/repo-recovered-local',
|
||||
{ base: baseBranch }
|
||||
)
|
||||
})
|
||||
|
||||
it('attempts SSH base cleanup and still removes a sparse worktree when that cleanup fails', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
|
||||
@@ -246,6 +246,33 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['refs/heads/feature/加', 'refs/tags/release'])(
|
||||
'does not reinterpret a qualified nonremote ref through a remote named refs: %s',
|
||||
async (base) => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'refs\n', stderr: '' })
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
for (const options of [{}, { wslDistro: 'Ubuntu' }]) {
|
||||
await expect(
|
||||
runtime.resolveRemoteTrackingBase('/repo/e', base, options)
|
||||
).resolves.toBeNull()
|
||||
}
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves a qualified remote whose name begins with refs', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'refs/heads\n', stderr: '' })
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
await expect(
|
||||
runtime.resolveRemoteTrackingBase('/repo/e', 'refs/remotes/refs/heads/feature/加')
|
||||
).resolves.toEqual({
|
||||
remote: 'refs/heads',
|
||||
branch: 'feature/加',
|
||||
ref: 'refs/remotes/refs/heads/feature/加',
|
||||
base: 'refs/heads/feature/加'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves full remote-tracking refs with longest configured remote matching', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'foo\nfoo/bar\norigin\n', stderr: '' })
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
|
||||
@@ -259,9 +259,10 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu
|
||||
async searchRepoRefs(
|
||||
repoSelector: string,
|
||||
query: string,
|
||||
limit = DEFAULT_REPO_SEARCH_REFS_LIMIT
|
||||
limit = DEFAULT_REPO_SEARCH_REFS_LIMIT,
|
||||
includeQualifiedRefs = true
|
||||
): Promise<RuntimeRepoSearchRefs> {
|
||||
return this.repositoryRefQueries.search(repoSelector, query, limit)
|
||||
return this.repositoryRefQueries.search(repoSelector, query, limit, includeQualifiedRefs)
|
||||
}
|
||||
|
||||
protected async resolveHostedReviewTarget(args: {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
type RuntimeCapability
|
||||
} from '../../../../shared/protocol-version'
|
||||
import type { RuntimeRepoSearchRefs } from '../../../../shared/runtime-worktree-contracts'
|
||||
|
||||
import { isQualifiedBaseRef } from '../../../git/base-ref-search-selector'
|
||||
|
||||
export function includesQualifiedSearchRefs(
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
): boolean {
|
||||
return (
|
||||
clientCapabilities === undefined ||
|
||||
clientCapabilities.includes(REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY)
|
||||
)
|
||||
}
|
||||
|
||||
export function projectRepoSearchRefsForClient(
|
||||
result: RuntimeRepoSearchRefs,
|
||||
clientCapabilities: readonly RuntimeCapability[] | undefined
|
||||
): RuntimeRepoSearchRefs {
|
||||
// In-process callers have no capability array; legacy remote clients have an empty one.
|
||||
if (includesQualifiedSearchRefs(clientCapabilities)) {
|
||||
return result
|
||||
}
|
||||
// Legacy clients could mistake a qualified selector for an ordinary branch name.
|
||||
return {
|
||||
...result,
|
||||
refs: result.refs.filter((refName) => !isQualifiedBaseRef(refName)),
|
||||
...(result.refDetails
|
||||
? {
|
||||
refDetails: result.refDetails.filter(({ refName }) => !isQualifiedBaseRef(refName))
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { REPO_METHODS } from './repo'
|
||||
import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
RUNTIME_CAPABILITIES,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities'
|
||||
import { REPO_SEARCH_REFS_MAX_LIMIT } from '../../../../shared/repo-search-limits'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
@@ -12,6 +18,16 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
}
|
||||
|
||||
describe('repo RPC methods', () => {
|
||||
it('advertises qualified-ref support from hosts and native remote clients', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY)
|
||||
expect(NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES).toContain(
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY
|
||||
)
|
||||
expect(remoteRuntimeClientCapabilities()).toContain(
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY
|
||||
)
|
||||
})
|
||||
|
||||
it('passes oversized safe ref-search limits to the runtime clamp', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
@@ -31,10 +47,58 @@ describe('repo RPC methods', () => {
|
||||
expect(runtime.searchRepoRefs).toHaveBeenCalledWith(
|
||||
'id:repo-1',
|
||||
'main',
|
||||
REPO_SEARCH_REFS_MAX_LIMIT + 1
|
||||
REPO_SEARCH_REFS_MAX_LIMIT + 1,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('only exposes namespace-qualified ref selectors to capable clients', async () => {
|
||||
const result = {
|
||||
refs: ['refs/heads/feature/local', 'refs/remotes/origin/feature/remote', 'main'],
|
||||
refDetails: [
|
||||
{ refName: 'refs/heads/feature/local', localBranchName: 'feature/local' },
|
||||
{
|
||||
refName: 'refs/remotes/origin/feature/remote',
|
||||
localBranchName: 'feature/remote'
|
||||
},
|
||||
{ refName: 'main', localBranchName: 'main' }
|
||||
],
|
||||
truncated: false
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: repo.searchRefs only calls the fixture methods defined below.
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
searchRepoRefs: vi.fn().mockResolvedValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
const legacyReplies: string[] = []
|
||||
const capableReplies: string[] = []
|
||||
const inProcessResponse = await dispatcher.dispatch(
|
||||
makeRequest('repo.searchRefs', { repo: 'id:repo-1', query: 'feature', limit: 20 })
|
||||
)
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('repo.searchRefs', { repo: 'id:repo-1', query: 'feature', limit: 20 }),
|
||||
(reply) => legacyReplies.push(reply),
|
||||
{ clientCapabilities: [] }
|
||||
)
|
||||
await dispatcher.dispatchStreaming(
|
||||
makeRequest('repo.searchRefs', { repo: 'id:repo-1', query: 'feature', limit: 20 }),
|
||||
(reply) => capableReplies.push(reply),
|
||||
{ clientCapabilities: [REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY] }
|
||||
)
|
||||
|
||||
expect(runtime.searchRepoRefs).toHaveBeenNthCalledWith(2, 'id:repo-1', 'feature', 20, false)
|
||||
expect(runtime.searchRepoRefs).toHaveBeenNthCalledWith(3, 'id:repo-1', 'feature', 20, true)
|
||||
expect(JSON.parse(legacyReplies[0]!).result).toEqual({
|
||||
refs: ['main'],
|
||||
refDetails: [{ refName: 'main', localBranchName: 'main' }],
|
||||
truncated: false
|
||||
})
|
||||
expect(JSON.parse(capableReplies[0]!).result).toEqual(result)
|
||||
expect(inProcessResponse).toMatchObject({ ok: true, result })
|
||||
})
|
||||
|
||||
it('projects inherited visibility for old clients but preserves inheritance for capable clients', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineMethod } from '../core'
|
||||
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
|
||||
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
|
||||
import {
|
||||
includesQualifiedSearchRefs,
|
||||
projectRepoSearchRefsForClient
|
||||
} from './repo-search-ref-projection'
|
||||
import { RepoSelector } from './github-repo-target-schemas'
|
||||
import {
|
||||
projectRepoResultVisibilityForClient,
|
||||
@@ -183,8 +187,16 @@ export const REPO_METHODS = [
|
||||
defineMethod({
|
||||
name: 'repo.searchRefs',
|
||||
params: RepoSearchRefs,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.searchRepoRefs(params.repo, params.query, params.limit)
|
||||
handler: async (params, { runtime, clientCapabilities }) =>
|
||||
projectRepoSearchRefsForClient(
|
||||
await runtime.searchRepoRefs(
|
||||
params.repo,
|
||||
params.query,
|
||||
params.limit,
|
||||
includesQualifiedSearchRefs(clientCapabilities)
|
||||
),
|
||||
clientCapabilities
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.hooks',
|
||||
|
||||
@@ -228,6 +228,9 @@ export class RuntimeRemoteFetchController {
|
||||
gitOptions: GitOptions = {}
|
||||
): Promise<RemoteTrackingBase | null> {
|
||||
const remoteRefPrefix = 'refs/remotes/'
|
||||
if (baseBranch.startsWith('refs/') && !baseBranch.startsWith(remoteRefPrefix)) {
|
||||
return null
|
||||
}
|
||||
const shortBaseBranch = baseBranch.startsWith(remoteRefPrefix)
|
||||
? baseBranch.slice(remoteRefPrefix.length)
|
||||
: baseBranch
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { validateGitExecArgs } from '../../relay/git-exec-validator'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { RuntimeRepositoryRefQueries } from './runtime-repository-ref-queries'
|
||||
|
||||
const { getProvider } = vi.hoisted(() => ({ getProvider: vi.fn() }))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getProvider }))
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'remote-repo',
|
||||
path: '/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
|
||||
describe('qualified refs in repository searches', () => {
|
||||
it.each(['feature', 'origin/feature'])(
|
||||
'fills legacy pages before the limit for query %s',
|
||||
async (query) => {
|
||||
const exec = vi.fn(async (argv: string[]) => {
|
||||
validateGitExecArgs(argv)
|
||||
return {
|
||||
stdout:
|
||||
argv[0] === 'remote'
|
||||
? 'origin\n'
|
||||
: [
|
||||
'refs/heads/feature/加\0feature/�',
|
||||
'refs/remotes/origin/feature/加\0origin/feature/�',
|
||||
'refs/remotes/origin/feature/one\0origin/feature/one',
|
||||
'refs/remotes/origin/feature/two\0origin/feature/two',
|
||||
'refs/remotes/origin/feature/three\0origin/feature/three'
|
||||
].join('\n'),
|
||||
stderr: ''
|
||||
}
|
||||
})
|
||||
getProvider.mockReturnValue({ exec })
|
||||
const queries = new RuntimeRepositoryRefQueries({ resolveRepo: async () => repo })
|
||||
expect(await queries.search('id:remote-repo', query, 2, false)).toEqual({
|
||||
refs: ['origin/feature/one', 'origin/feature/two'],
|
||||
refDetails: [
|
||||
{ refName: 'origin/feature/one', localBranchName: 'feature/one' },
|
||||
{ refName: 'origin/feature/two', localBranchName: 'feature/two' }
|
||||
],
|
||||
truncated: true
|
||||
})
|
||||
expect(await queries.search('id:remote-repo', query, 2)).toMatchObject({
|
||||
refs: ['refs/heads/feature/加', 'refs/remotes/origin/feature/加'],
|
||||
truncated: true
|
||||
})
|
||||
expect(exec).toHaveBeenCalledWith(expect.arrayContaining(['--count=12']), '/repo')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps folder workspaces outside Git searches', async () => {
|
||||
vi.mocked(getSshGitProvider).mockClear()
|
||||
const queries = new RuntimeRepositoryRefQueries({
|
||||
resolveRepo: async () => ({ ...repo, kind: 'folder' })
|
||||
})
|
||||
expect(await queries.search('id:remote-repo', 'feature', 2, false)).toEqual({
|
||||
refs: [],
|
||||
truncated: false
|
||||
})
|
||||
expect(getSshGitProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -28,7 +28,12 @@ type RuntimeRepositoryRefQueryDependencies = {
|
||||
export class RuntimeRepositoryRefQueries {
|
||||
constructor(private readonly deps: RuntimeRepositoryRefQueryDependencies) {}
|
||||
|
||||
async search(repoSelector: string, query: string, limit: number): Promise<RuntimeRepoSearchRefs> {
|
||||
async search(
|
||||
repoSelector: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
includeQualifiedRefs = true
|
||||
): Promise<RuntimeRepoSearchRefs> {
|
||||
if (!isRepoSearchRefsRequestLimit(limit)) {
|
||||
throw new Error('invalid_limit')
|
||||
}
|
||||
@@ -39,8 +44,8 @@ export class RuntimeRepositoryRefQueries {
|
||||
return { refs: [], truncated: false }
|
||||
}
|
||||
const refDetails = repo.connectionId
|
||||
? await this.searchRemote(repo, query, probeLimit)
|
||||
: await searchBaseRefDetails(repo.path, query, probeLimit)
|
||||
? await this.searchRemote(repo, query, probeLimit, includeQualifiedRefs)
|
||||
: await searchBaseRefDetails(repo.path, query, probeLimit, includeQualifiedRefs)
|
||||
return {
|
||||
refs: refDetails.slice(0, effectiveLimit).map((entry) => entry.refName),
|
||||
refDetails: refDetails.slice(0, effectiveLimit),
|
||||
@@ -106,7 +111,8 @@ export class RuntimeRepositoryRefQueries {
|
||||
private async searchRemote(
|
||||
repo: Repo,
|
||||
query: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
includeQualifiedRefs: boolean
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null
|
||||
if (!provider) {
|
||||
@@ -150,11 +156,13 @@ export class RuntimeRepositoryRefQueries {
|
||||
if (normalizedQuery.split('/').filter((token) => token.length > 0).length > 1) {
|
||||
const results = await Promise.all([runSearch('segmented'), runSearch('branchRoot')])
|
||||
return mergeBaseRefSearchResultGroups(
|
||||
results.map((stdout) => parseAndFilterSearchRefDetails(stdout, limit, remotes)),
|
||||
results.map((stdout) =>
|
||||
parseAndFilterSearchRefDetails(stdout, limit, remotes, includeQualifiedRefs)
|
||||
),
|
||||
limit
|
||||
)
|
||||
}
|
||||
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes)
|
||||
return parseAndFilterSearchRefDetails(await runSearch(), limit, remotes, includeQualifiedRefs)
|
||||
} catch (error) {
|
||||
console.warn('[runtime:repo.searchRefs] SSH for-each-ref failed', {
|
||||
path: repo.path,
|
||||
|
||||
@@ -263,6 +263,32 @@ describe('resolveComposerReuseOverride', () => {
|
||||
})
|
||||
|
||||
describe('resolveComposerBranchReuse', () => {
|
||||
it('reuses a qualified local selector but does not reuse a qualified remote selector', () => {
|
||||
expect(
|
||||
resolveComposerBranchReuse({
|
||||
refName: 'refs/heads/feature/加',
|
||||
localBranchName: 'feature/加',
|
||||
selectionProducedOverride: true,
|
||||
branchCheckedOutElsewhere: false
|
||||
})
|
||||
).toEqual({ reuseEligibleBranch: 'feature/加', defaultReuse: true })
|
||||
expect(
|
||||
resolveComposerBranchReuse({
|
||||
refName: 'refs/remotes/origin/feature/加',
|
||||
localBranchName: 'feature/加',
|
||||
selectionProducedOverride: true,
|
||||
branchCheckedOutElsewhere: false
|
||||
})
|
||||
).toEqual({ reuseEligibleBranch: null, defaultReuse: false })
|
||||
expect(
|
||||
resolveComposerReuseOverride({
|
||||
refName: 'refs/heads/feature/加',
|
||||
localBranchName: 'feature/加',
|
||||
branchNameOverride: 'feature/加',
|
||||
branchCheckedOutElsewhere: true
|
||||
})
|
||||
).toBeUndefined()
|
||||
})
|
||||
it('marks an existing local branch reusable and defaults reuse ON for an auto-derived name', () => {
|
||||
expect(
|
||||
resolveComposerBranchReuse({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
@@ -21,6 +22,7 @@ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
@@ -126,6 +127,7 @@ describe('WebRuntimeClient', () => {
|
||||
SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/protocol-version'
|
||||
@@ -27,6 +28,7 @@ describe('web runtime connection capability advertisement', () => {
|
||||
expect.objectContaining({
|
||||
type: 'e2ee_auth',
|
||||
clientCapabilities: expect.arrayContaining([
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
// The web client runs the desktop renderer, which shows Deleting from the marker.
|
||||
WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY
|
||||
|
||||
@@ -59,14 +59,18 @@ export function getComposerRepoWorktreeBranches(
|
||||
: []
|
||||
}
|
||||
|
||||
function isLocalBranchSelection(refName: string, localBranchName: string): boolean {
|
||||
return refName === localBranchName || refName === `refs/heads/${localBranchName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #5181: decide whether a picked branch row is an existing LOCAL branch
|
||||
* that can be reused (checked out) instead of branched off, and whether reuse
|
||||
* should default ON.
|
||||
*
|
||||
* Reuse is only possible for a LOCAL branch (ref === local name; remote-only
|
||||
* refs carry an `origin/`-style prefix) that is NOT already checked out in
|
||||
* another worktree — git allows a branch in only one worktree at a time. Reuse
|
||||
* Reuse is only possible for a LOCAL branch that is NOT already checked out in
|
||||
* another worktree; namespace-qualified local selectors count too. Git allows
|
||||
* a branch in only one worktree at a time. Reuse
|
||||
* defaults ON only when the worktree name was auto-derived from the branch (the
|
||||
* selection produced a branch-name override); a user who typed a custom
|
||||
* worktree name first is branching off the ref, so reuse stays OFF unless they
|
||||
@@ -79,7 +83,7 @@ export function resolveComposerBranchReuse(args: {
|
||||
branchCheckedOutElsewhere: boolean
|
||||
}): { reuseEligibleBranch: string | null; defaultReuse: boolean } {
|
||||
const reuseEligibleBranch =
|
||||
args.refName === args.localBranchName && !args.branchCheckedOutElsewhere
|
||||
isLocalBranchSelection(args.refName, args.localBranchName) && !args.branchCheckedOutElsewhere
|
||||
? args.localBranchName
|
||||
: null
|
||||
return {
|
||||
@@ -102,7 +106,10 @@ export function resolveComposerReuseOverride(args: {
|
||||
branchNameOverride: string | undefined
|
||||
branchCheckedOutElsewhere: boolean
|
||||
}): string | undefined {
|
||||
if (args.branchCheckedOutElsewhere && args.refName === args.localBranchName) {
|
||||
if (
|
||||
args.branchCheckedOutElsewhere &&
|
||||
isLocalBranchSelection(args.refName, args.localBranchName)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return args.branchNameOverride
|
||||
|
||||
@@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest'
|
||||
import { isSafeGitRefName, isSafeGitStatusUpstreamRef } from './git-status-upstream-ref'
|
||||
|
||||
describe('git status upstream refs', () => {
|
||||
it.each([
|
||||
'refs/heads/feature./valid',
|
||||
'refs/heads/feature./运动记录',
|
||||
'refs/remotes/origin./main'
|
||||
])('accepts a trailing dot in an intermediate component: %s', (ref) => {
|
||||
expect(isSafeGitRefName(ref)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['refs/heads/feature.', 'refs/heads/.hidden/valid', 'refs/heads/feature.lock/valid'])(
|
||||
'rejects an unsafe dot placement: %s',
|
||||
(ref) => {
|
||||
expect(isSafeGitRefName(ref)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('accepts exact remote and custom upstream namespaces', () => {
|
||||
expect(isSafeGitStatusUpstreamRef('refs/remotes/team/fork/feature/nested')).toBe(true)
|
||||
expect(isSafeGitStatusUpstreamRef('refs/custom/origin/main')).toBe(true)
|
||||
|
||||
@@ -14,7 +14,7 @@ function hasForbiddenRefChar(ref: string): boolean {
|
||||
}
|
||||
|
||||
export function isSafeGitRefName(ref: string): boolean {
|
||||
if (!ref.startsWith(REF_PREFIX) || ref.endsWith('/')) {
|
||||
if (!ref.startsWith(REF_PREFIX) || ref.endsWith('/') || ref.endsWith('.')) {
|
||||
return false
|
||||
}
|
||||
if (ref.includes('..') || ref.includes('@{') || hasForbiddenRefChar(ref)) {
|
||||
@@ -29,7 +29,6 @@ export function isSafeGitRefName(ref: string): boolean {
|
||||
part !== '.' &&
|
||||
part !== '..' &&
|
||||
!part.startsWith('.') &&
|
||||
!part.endsWith('.') &&
|
||||
!part.endsWith('.lock')
|
||||
)
|
||||
)
|
||||
|
||||
@@ -62,6 +62,9 @@ export const WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY =
|
||||
'worktree.linked-work-item-context.v1' as const
|
||||
export const WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY =
|
||||
'worktree.github-pr-suppression.v1' as const
|
||||
// Legacy clients cannot safely derive a branch name from a qualified ref selector.
|
||||
export const REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY =
|
||||
'repo.search-qualified-refs.v1' as const
|
||||
export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const
|
||||
export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const
|
||||
export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY =
|
||||
@@ -321,6 +324,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY
|
||||
@@ -369,6 +373,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
|
||||
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
|
||||
JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_GITHUB_PR_SUPPRESSION_RUNTIME_CAPABILITY,
|
||||
@@ -32,6 +33,7 @@ export function remoteRuntimeClientCapabilities(
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY,
|
||||
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
|
||||
...additionalCapabilities
|
||||
])
|
||||
)
|
||||
|
||||
@@ -300,9 +300,8 @@ describe('release checkout materialization', () => {
|
||||
expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./)
|
||||
}, 180_000)
|
||||
|
||||
// v1.4.221 imports @streamparser/json, which the current tree no longer installs.
|
||||
// Default cache root: package resolution must walk up into the repo's node_modules.
|
||||
it('loads release source that imports a package the current tree dropped', async () => {
|
||||
// Released sources resolve the pinned test-only dependency through the repo's node_modules.
|
||||
it('loads release source that imports a package the current production tree dropped', async () => {
|
||||
const checkout = await materializeReleaseCheckout('v1.4.221')
|
||||
const ripgrep = await importReleaseCheckoutModule(
|
||||
checkout,
|
||||
@@ -320,9 +319,10 @@ describe('release checkout materialization', () => {
|
||||
expect(callExport('parseRipgrepMatchJson', '{"type":"match"}', 1, limits)).toEqual({
|
||||
type: 'match'
|
||||
})
|
||||
expect(() => callExport('parseDenseRipgrepMatchJson', '{"type":"match"}', 1, 8)).toThrow(
|
||||
/imports '@streamparser\/json'.*does not install/
|
||||
)
|
||||
expect(callExport('parseDenseRipgrepMatchJson', '{"type":"match"}', 1, 8)).toEqual({
|
||||
type: 'match',
|
||||
data: { submatches: [] }
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it('keeps an import live while another colliding release label materializes', async () => {
|
||||
|
||||
Reference in New Issue
Block a user