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:
Neil
2026-10-05 23:47:01 -07:00
committed by GitHub
co-authored by kino
parent 3ec030e80a
commit 1de3aa405f
26 changed files with 754 additions and 55 deletions
+3 -1
View File
@@ -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([])
})
})
+22
View File
@@ -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
}
+24 -21
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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))
}
: {})
}
}
+66 -2
View File
@@ -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',
+14 -2
View File
@@ -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
+12 -5
View File
@@ -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)
+1 -2
View File
@@ -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')
)
)
+5
View File
@@ -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 () => {