fix(git): harden admission across runtime paths

This commit is contained in:
Brennan Benson
2026-08-27 19:10:32 -07:00
parent bd3cc2f98c
commit 7e3338ee1a
47 changed files with 756 additions and 232 deletions
@@ -0,0 +1,85 @@
import type { AdmissionWaiter } from './git-admission-state'
export type WaiterLane = {
items: AdmissionWaiter[]
head: number
count: number
baseEligible: boolean
headroomEligible: boolean
version: number
}
export type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number }
const CANDIDATE_HEAP_COMPACTION_SLACK = 64
export class CandidateHeap {
private items: Candidate[] = []
get size(): number {
return this.items.length
}
push(candidate: Candidate): void {
this.items.push(candidate)
let index = this.items.length - 1
while (index > 0) {
const parent = Math.floor((index - 1) / 2)
if (this.items[parent].waiter.id <= candidate.waiter.id) {
break
}
this.items[index] = this.items[parent]
index = parent
}
this.items[index] = candidate
}
peek(valid: (candidate: Candidate) => boolean): Candidate | null {
// Eligibility changes invalidate by version so route updates stay O(log N).
while (this.items.length > 0 && !valid(this.items[0])) {
this.pop()
}
return this.items[0] ?? null
}
compactIfOversized(maxLiveCandidates: number, valid: (candidate: Candidate) => boolean): void {
if (this.items.length <= maxLiveCandidates * 2 + CANDIDATE_HEAP_COMPACTION_SLACK) {
return
}
this.items = this.items.filter(valid)
for (let index = Math.floor(this.items.length / 2) - 1; index >= 0; index -= 1) {
this.siftDown(index)
}
}
private pop(): void {
const last = this.items.pop()
if (!last || this.items.length === 0) {
return
}
this.items[0] = last
this.siftDown(0)
}
private siftDown(index: number): void {
const candidate = this.items[index]
let cursor = index
while (true) {
const left = cursor * 2 + 1
if (left >= this.items.length) {
break
}
const right = left + 1
const child =
right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id
? right
: left
if (this.items[child].waiter.id >= candidate.waiter.id) {
break
}
this.items[cursor] = this.items[child]
cursor = child
}
this.items[cursor] = candidate
}
}
@@ -1,4 +1,5 @@
import type { AdmissionClass, AdmissionSlotKind, AdmissionWaiter } from './git-admission-state'
import { CandidateHeap, type Candidate, type WaiterLane } from './git-admission-candidate-heap'
import type { GitAdmissionTier } from './git-exec-options'
type SelectedWaiter = {
@@ -6,15 +7,6 @@ type SelectedWaiter = {
slotKind: AdmissionSlotKind
}
type WaiterLane = {
items: AdmissionWaiter[]
head: number
count: number
baseEligible: boolean
headroomEligible: boolean
version: number
}
const TIERS = ['interactive', 'status', 'background'] as const
const createLane = (): WaiterLane => ({
items: [],
@@ -30,59 +22,6 @@ const createTierLanes = (): TierLanes => ({
status: new Map(),
background: new Map()
})
type Candidate = { lane: WaiterLane; waiter: AdmissionWaiter; version: number }
class CandidateHeap {
private readonly items: Candidate[] = []
push(candidate: Candidate): void {
this.items.push(candidate)
let index = this.items.length - 1
while (index > 0) {
const parent = Math.floor((index - 1) / 2)
if (this.items[parent].waiter.id <= candidate.waiter.id) {
break
}
this.items[index] = this.items[parent]
index = parent
}
this.items[index] = candidate
}
peek(valid: (candidate: Candidate) => boolean): Candidate | null {
// Eligibility changes invalidate by version so route updates stay O(log N).
while (this.items.length > 0 && !valid(this.items[0])) {
this.pop()
}
return this.items[0] ?? null
}
private pop(): void {
const last = this.items.pop()
if (!last || this.items.length === 0) {
return
}
let index = 0
while (true) {
const left = index * 2 + 1
if (left >= this.items.length) {
break
}
const right = left + 1
const child =
right < this.items.length && this.items[right].waiter.id < this.items[left].waiter.id
? right
: left
if (this.items[child].waiter.id >= last.waiter.id) {
break
}
this.items[index] = this.items[child]
index = child
}
this.items[index] = last
}
}
export class GitAdmissionWaiterQueue {
private readonly lanes: Record<AdmissionClass, TierLanes> = {
general: createTierLanes(),
@@ -112,6 +51,17 @@ export class GitAdmissionWaiterQueue {
return this.totalCount
}
/** @internal - exposed for bounded-storage regression tests only. */
get candidateCountForTests(): number {
let count = this.headroomCandidates.general.size + this.headroomCandidates.network.size
for (const admissionClass of ['general', 'network'] as const) {
for (const tier of TIERS) {
count += this.baseCandidates[admissionClass][tier].size
}
}
return count
}
enqueue(waiter: AdmissionWaiter): void {
const lanes = this.lanes[waiter.admissionClass][waiter.tier]
let lane = lanes.get(waiter.route)
@@ -151,9 +101,11 @@ export class GitAdmissionWaiterQueue {
if (lane.count === 0) {
lane.version += 1
lanes.delete(waiter.route)
this.compactCandidateHeaps(waiter.admissionClass, waiter.tier)
} else if (lane.items[lane.head] !== previousHead) {
lane.version += 1
this.publishLaneHead(waiter.admissionClass, waiter.tier, lane)
this.compactCandidateHeaps(waiter.admissionClass, waiter.tier)
}
}
@@ -175,6 +127,7 @@ export class GitAdmissionWaiterQueue {
lane.headroomEligible = headroomEligible
lane.version += 1
this.publishLaneHead(admissionClass, tier, lane)
this.compactCandidateHeaps(admissionClass, tier)
}
}
@@ -250,15 +203,31 @@ export class GitAdmissionWaiterQueue {
}
private peekValid(heap: CandidateHeap, slotKind: AdmissionSlotKind): Candidate | null {
return heap.peek(
({ lane, waiter, version }) =>
version === lane.version &&
waiter === lane.items[lane.head] &&
waiter.state === 'queued' &&
(slotKind === 'base' ? lane.baseEligible : lane.headroomEligible)
return heap.peek((candidate) => this.candidateIsValid(candidate, slotKind))
}
private candidateIsValid(candidate: Candidate, slotKind: AdmissionSlotKind): boolean {
const { lane, waiter, version } = candidate
return (
version === lane.version &&
waiter === lane.items[lane.head] &&
waiter.state === 'queued' &&
(slotKind === 'base' ? lane.baseEligible : lane.headroomEligible)
)
}
private compactCandidateHeaps(admissionClass: AdmissionClass, tier: GitAdmissionTier): void {
const liveLaneCount = this.lanes[admissionClass][tier].size
this.baseCandidates[admissionClass][tier].compactIfOversized(liveLaneCount, (candidate) =>
this.candidateIsValid(candidate, 'base')
)
if (tier === 'interactive') {
this.headroomCandidates[admissionClass].compactIfOversized(liveLaneCount, (candidate) =>
this.candidateIsValid(candidate, 'headroom')
)
}
}
private compact(lane: WaiterLane): void {
let head = lane.head
while (head < lane.items.length && lane.items[head].state !== 'queued') {
@@ -110,6 +110,7 @@ async function gitExecFileAsyncUnlocked(
result = await capture(resolved)
} catch (error) {
if (directWslGitExitCode(error, resolved) !== null && !options.signal?.aborted) {
await terminationState.current
const wasMissing = invalidateMissingDirectWslGit(error, resolved)
const fallback = resolveGitCommand(
args,
@@ -285,6 +285,28 @@ describe('GitAdmissionScheduler', () => {
running.release()
})
it('bounds canceled candidate storage while the only permit stays saturated', async () => {
const scheduler = schedulerWithOneSlot()
const running = await scheduler.acquire(local())
const canceled: Promise<void>[] = []
for (let index = 0; index < 4_000; index += 1) {
const controller = new AbortController()
const request = scheduler.acquire({ ...local(), signal: controller.signal }).then(
() => undefined,
(error) => expect(error).toMatchObject({ name: 'AbortError' })
)
controller.abort()
canceled.push(request)
}
await Promise.all(canceled)
expect(scheduler.snapshot().queued).toBe(0)
expect(scheduler.snapshot().candidateCount).toBeLessThanOrEqual(64)
running.release()
expect(scheduler.snapshot().candidateCount).toBe(0)
})
it('does not decrement for an already-aborted signal with a free slot', async () => {
const scheduler = schedulerWithOneSlot()
const controller = new AbortController()
@@ -293,7 +315,12 @@ describe('GitAdmissionScheduler', () => {
await expect(
scheduler.acquire({ ...local(), signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
expect(scheduler.snapshot()).toEqual({ queued: 0, queuedWaiters: [], budgets: {} })
expect(scheduler.snapshot()).toEqual({
queued: 0,
queuedWaiters: [],
candidateCount: 0,
budgets: {}
})
})
it('returns a grant selected in the same tick when abort wins delivery', async () => {
@@ -92,11 +92,13 @@ export class GitAdmissionScheduler {
queued: number
queuedWaiters: { id: number; args: readonly string[]; tier: AdmissionWaiter['tier'] }[]
budgets: Record<string, { baseUsed: number; headroomUsed: number }>
candidateCount: number
} {
const queuedWaiters = this.waiters.snapshot()
return {
queued: this.waiters.count,
queuedWaiters: queuedWaiters.map(({ id, args, tier }) => ({ id, args, tier })),
candidateCount: this.waiters.candidateCountForTests,
budgets: Object.fromEntries(
[...this.budgets].map(([key, budget]) => [
key,
+31 -17
View File
@@ -500,17 +500,18 @@ describe('WSL direct Git reads', () => {
})
})
it('invalidates a missing direct executable and retries through the login shell', async () => {
it('waits for direct Git to close before retrying through the login shell', async () => {
await withPlatform('win32', async () => {
const admissionEvents: GitAdmissionEvent[] = []
_resetGitAdmissionForTests(
new GitAdmissionScheduler({ onAdmissionEvent: (event) => admissionEvents.push(event) })
)
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
let directChild!: ReturnType<typeof createMockChild>
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
directChild = child
queueMicrotask(() => {
child.emit('close', 127, null)
callback?.(
Object.assign(new Error('exit 127'), { code: 127 }),
'',
@@ -528,13 +529,17 @@ describe('WSL direct Git reads', () => {
return child
})
await expect(
gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
preferWslDirectGit: true,
wslDistro: DISTRO
})
).resolves.toEqual({ stdout: 'ok', stderr: '' })
const result = gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
preferWslDirectGit: true,
wslDistro: DISTRO
})
await vi.waitFor(() => expect(directChild).toBeDefined())
await Promise.resolve()
expect(execFileMock).toHaveBeenCalledTimes(1)
directChild.emit('close', 127, null)
await expect(result).resolves.toEqual({ stdout: 'ok', stderr: '' })
expect(execFileMock.mock.calls[0]?.[1]).toContain('--exec')
expect(execFileMock.mock.calls[1]?.[1]?.slice(3, 5)).toEqual(['sh', '-lc'])
@@ -550,14 +555,18 @@ describe('WSL direct Git reads', () => {
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'helper failed')
)
child.emit('close', 128, null)
})
return child
})
execFileMock.mockImplementation((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => callback?.(null, 'ok', ''))
queueMicrotask(() => {
callback?.(null, 'ok', '')
child.emit('close', 0, null)
})
return child
})
const options = {
@@ -581,21 +590,26 @@ describe('WSL direct Git reads', () => {
execFileMock
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref')
)
child.emit('close', 128, null)
})
return child
})
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() =>
queueMicrotask(() => {
callback?.(Object.assign(new Error('exit 128'), { code: 128 }), '', 'missing ref')
)
child.emit('close', 128, null)
})
return child
})
.mockImplementationOnce((_command, _args, _options, callback) => {
const child = createMockChild()
queueMicrotask(() => callback?.(null, 'ok', ''))
queueMicrotask(() => {
callback?.(null, 'ok', '')
child.emit('close', 0, null)
})
return child
})
const options = {
@@ -132,6 +132,7 @@ async function runGetStatus(
const result = await gitStreamStdout(statusArgs, {
cwd: worktreePath,
wslDistro: options.wslDistro,
admissionTier: options.admissionTier,
preferWslDirectGit: true,
// Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock.
env: gitOptionalLocksDisabledEnv(),
+7 -3
View File
@@ -71,15 +71,19 @@ describe('getStatus', () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
it('opts status reads into direct WSL Git without changing mutation options', async () => {
it('preserves status admission through the direct WSL stream without changing mutation options', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
existsSyncMock.mockReturnValue(false)
await getStatus('/repo', { wslDistro: 'Ubuntu' })
await getStatus('/repo', { wslDistro: 'Ubuntu', admissionTier: 'interactive' })
await stageFile('/repo', 'src/file.ts', { wslDistro: 'Ubuntu' })
expect(gitStreamOptionsMock).toHaveBeenCalledWith(
expect.objectContaining({ preferWslDirectGit: true, wslDistro: 'Ubuntu' })
expect.objectContaining({
admissionTier: 'interactive',
preferWslDirectGit: true,
wslDistro: 'Ubuntu'
})
)
const addOptions = gitExecFileAsyncMock.mock.calls.find(([args]) =>
(args as string[]).includes('add')
@@ -49,6 +49,8 @@ describe('isWslDirectGitReadCommand', () => {
[['symbolic-ref', 'HEAD', 'refs/heads/main']],
[['worktree', 'add', '/tmp/wt']],
[['branch', '-D', 'feature']],
[['branch', '-r', '-d', 'origin/feature']],
[['branch', '-rd', 'origin/feature']],
[['branch', 'newbranch']],
[[]]
])('keeps %j on the login shell', (args) => {
@@ -103,6 +105,8 @@ describe('classifyGitCommand', () => {
[['-C', '/repo', 'status', '--porcelain=v2'], 'read'],
[['status', '--porcelain=v2'], 'read'],
[['branch', '--show-current'], 'read'],
[['branch', '-r', '-d', 'origin/feature'], 'other'],
[['branch', '-rd', 'origin/feature'], 'other'],
[['remote', 'get-url', 'origin'], 'read'],
[['config', '--get-regexp', String.raw`^remote\.`], 'read'],
[['show', '--end-of-options', 'HEAD:file'], 'read'],
@@ -50,6 +50,38 @@ const READ_FLAG_SUBCOMMANDS: Record<string, ReadonlySet<string>> = {
config: new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l'])
}
const BRANCH_MUTATION_FLAGS = new Set([
'--copy',
'--create-reflog',
'--delete',
'--edit-description',
'--force',
'--move',
'--no-create-reflog',
'--no-track',
'--recurse-submodules',
'--set-upstream-to',
'--track',
'--unset-upstream'
])
const BRANCH_MUTATION_SHORT_FLAGS = new Set(['c', 'C', 'd', 'D', 'f', 'm', 'M', 't', 'u'])
function hasBranchMutationFlag(args: readonly string[]): boolean {
return args.some((arg) => {
const flag = arg.split('=')[0]
if (BRANCH_MUTATION_FLAGS.has(flag)) {
return true
}
return (
/^-[^-]/.test(flag) &&
flag
.slice(1)
.split('')
.some((part) => BRANCH_MUTATION_SHORT_FLAGS.has(part))
)
})
}
// Read markers that must be the *first non-flag* argument, i.e. the action.
// Position matters here: matching them anywhere would read `worktree remove list`
// as a listing, because a worktree may legitimately be named "list".
@@ -97,6 +129,10 @@ export function isWslDirectGitReadCommand(args: readonly string[]): boolean {
return rest.filter((arg) => arg !== '--' && !arg.startsWith('-')).length <= 1
}
if (subcommand === 'branch' && hasBranchMutationFlag(rest)) {
return false
}
const readActions = READ_ACTION_SUBCOMMANDS[subcommand]
if (readActions) {
const action = rest.find((arg) => !arg.startsWith('-'))
+20
View File
@@ -298,6 +298,26 @@ describe('getPRConflictSummary caching', () => {
).toHaveLength(1)
})
it('preserves background admission across the complete WSL derivation chain', async () => {
mockGitDispatch()
await getPRConflictSummary('/repo-root', 'main', 'github-base-oid', 'head-oid-1', {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(5)
for (const [, options] of gitExecFileAsyncMock.mock.calls) {
expect(options).toEqual(
expect.objectContaining({
cwd: '/repo-root',
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
)
}
})
it('keeps identities distinct when paths or ref names contain a joiner character', async () => {
mockGitDispatch()
+9 -16
View File
@@ -4,6 +4,7 @@ import {
isUnsupportedMergeTreeWriteTreeError
} from '../../shared/git-merge-tree-capability'
import { gitExecFileAsync } from '../git/runner'
import { gitOptionsForWorktree, type GitRuntimeOptions } from '../git/git-runtime-options'
import {
clearGitCapabilityStateForTests,
withLocalGitCapabilityCacheForExecution
@@ -21,9 +22,7 @@ import {
storeCachedSummary
} from './conflict-summary-cache'
type LocalGitExecOptions = {
wslDistro?: string
}
type LocalGitExecOptions = Pick<GitRuntimeOptions, 'wslDistro' | 'admissionTier'>
export function __resetPRConflictSummaryCachesForTests(): void {
clearGitCapabilityStateForTests()
@@ -159,9 +158,8 @@ async function resolveLatestBaseOid(
// Why: cap the fetch at 10 s so slow or unreachable remotes don't block
// the conflict-summary derivation indefinitely.
await gitExecFileAsync(['fetch', '--quiet', remoteName, baseRefName], {
cwd: repoPath,
timeout: 10_000,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions),
timeout: 10_000
})
} catch {
// Why: fetching the base ref keeps the conflict list aligned with GitHub's
@@ -172,8 +170,7 @@ async function resolveLatestBaseOid(
for (const ref of [`refs/remotes/${remoteName}/${baseRefName}`, `${remoteName}/${baseRefName}`]) {
try {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
const oid = stdout.trim()
if (oid) {
@@ -194,8 +191,7 @@ async function resolveMergeBase(
localGitOptions: LocalGitExecOptions
): Promise<string> {
const { stdout } = await gitExecFileAsync(['merge-base', headOid, baseOid], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return stdout.trim()
}
@@ -206,8 +202,7 @@ async function countCommits(
localGitOptions: LocalGitExecOptions
): Promise<number> {
const { stdout } = await gitExecFileAsync(['rev-list', '--count', range], {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return Number.parseInt(stdout.trim(), 10) || 0
}
@@ -251,8 +246,7 @@ async function loadConflictingFiles(
async () => {
try {
const result = await gitExecFileAsync(modernArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (error) {
@@ -288,8 +282,7 @@ async function loadConflictingFilesWithLegacyMergeTree(
): Promise<string[]> {
try {
const result = await gitExecFileAsync(legacyArgs, {
cwd: repoPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
...gitOptionsForWorktree(repoPath, localGitOptions)
})
return parseMergeTreeNameOnlyOutput(result.stdout)
} catch (fallbackError) {
@@ -34,7 +34,7 @@ export function hostedReviewOptionArgs(
...(candidate.localGitOptions?.wslDistro
? { wslDistro: candidate.localGitOptions.wslDistro }
: {}),
admissionTier: reason === 'manual' ? 'interactive' : 'background'
admissionTier: admissionTierForRefreshReason(reason)
}
}
if (shouldAcceptMergedFallbackPR(candidate)) {
@@ -46,6 +46,12 @@ export function hostedReviewOptionArgs(
return Object.keys(options).length > 0 ? [options] : []
}
export function admissionTierForRefreshReason(
reason: GitHubPRRefreshReason
): 'interactive' | 'background' {
return reason === 'manual' ? 'interactive' : 'background'
}
export function refreshKey(candidate: GitHubPRRefreshCandidate): string {
const connectionScope = candidate.connectionId ?? 'local'
const runtimeScope = candidate.connectionId
+2 -1
View File
@@ -152,7 +152,8 @@ async function probeGlabKnownHosts(
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
...(!connectionId && localGitOptions.wslDistro
? { wslDistro: localGitOptions.wslDistro }
: {})
: {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
const remembered = knownHostsCacheByExecutionContext.get(key) ?? []
+15
View File
@@ -662,6 +662,21 @@ describe('getGlabKnownHosts', () => {
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 })
})
it('preserves WSL and background admission on the cold auth-status probe', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' })
await getGlabKnownHosts(undefined, {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
timeout: 10_000,
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
})
it('falls back to default when glab auth status fails', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('glab not authenticated'))
@@ -90,10 +90,13 @@ describe('registerFilesystemHandlers', () => {
await handlers.get('git:branchCompare')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
baseRef: 'origin/main'
baseRef: 'origin/main',
admissionTier: 'background'
})
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {})
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main', {
admissionTier: 'background'
})
})
it('allows git operations on worktrees outside repo/workspace roots', async () => {
+16 -3
View File
@@ -28,6 +28,7 @@ import type { Repo } from '../../shared/repo-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { sortDirEntries } from '../../shared/file-name-sort'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
@@ -1831,14 +1832,23 @@ export function registerFilesystemHandlers(
'git:branchCompare',
async (
_event,
args: { worktreePath: string; baseRef: string; connectionId?: string }
args: {
worktreePath: string
baseRef: string
connectionId?: string
admissionTier?: GitAdmissionTier
}
): Promise<GitBranchCompareResult> => {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getBranchCompare(args.worktreePath, args.baseRef)
return args.admissionTier
? provider.getBranchCompare(args.worktreePath, args.baseRef, {
admissionTier: args.admissionTier
})
: provider.getBranchCompare(args.worktreePath, args.baseRef)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const gitOptions = getLocalGitOptionsForRegisteredWorktree(
@@ -1846,7 +1856,10 @@ export function registerFilesystemHandlers(
args.worktreePath,
worktreePath
)
return getBranchCompare(worktreePath, args.baseRef, gitOptions)
return getBranchCompare(worktreePath, args.baseRef, {
...gitOptions,
...(args.admissionTier ? { admissionTier: args.admissionTier } : {})
})
}
)
+6 -1
View File
@@ -15,6 +15,7 @@ import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { GitProviderStatusOptions } from './git-provider-status-options'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
export type { GitProviderStatusOptions } from './git-provider-status-options'
@@ -46,7 +47,11 @@ export type IGitProvider = {
abortRebase(worktreePath: string): Promise<void>
checkoutBranch(worktreePath: string, branch: string): Promise<void>
listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }>
getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult>
getBranchCompare(
worktreePath: string,
baseRef: string,
options?: { admissionTier?: GitAdmissionTier }
): Promise<GitBranchCompareResult>
getCommitCompare(worktreePath: string, commitId: string): Promise<GitCommitCompareResult>
getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise<GitUpstreamStatus>
pushBranch(
@@ -193,10 +193,13 @@ describe('SshGitProvider', () => {
const compareResult = { summary: { ahead: 2, behind: 0 }, entries: [] }
mux.request.mockResolvedValue(compareResult)
const result = await provider.getBranchCompare('/home/user/repo', 'main')
const result = await provider.getBranchCompare('/home/user/repo', 'main', {
admissionTier: 'background'
})
expect(mux.request).toHaveBeenCalledWith('git.branchCompare', {
worktreePath: '/home/user/repo',
baseRef: 'main'
baseRef: 'main',
admissionTier: 'background'
})
expect(result).toEqual(compareResult)
})
@@ -4,6 +4,7 @@ import type {
} from '../../shared/git-diff-compare-types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { GitConflictOperation } from '../../shared/git-status-types'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider'
export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider {
@@ -106,10 +107,15 @@ export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider {
}
}
async getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult> {
async getBranchCompare(
worktreePath: string,
baseRef: string,
options: { admissionTier?: GitAdmissionTier } = {}
): Promise<GitBranchCompareResult> {
return (await this.mux.request('git.branchCompare', {
worktreePath,
baseRef
baseRef,
...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
})) as GitBranchCompareResult
}
+34
View File
@@ -7789,6 +7789,24 @@ describe('OrcaRuntimeService', () => {
const checkDetailsSignal = new AbortController().signal
await runtime.getRepoPRForBranch('id:repo-1', 'feature/wsl', 42, 43)
await runtime.getRepoPRForBranch(
'id:repo-1',
'feature/wsl-manual',
42,
43,
undefined,
undefined,
'manual'
)
await runtime.getRepoPRForBranch(
'id:repo-1',
'feature/wsl-active',
42,
43,
undefined,
undefined,
'active'
)
await runtime.getRepoWorkItem('id:repo-1', 42, 'pr')
await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr')
await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr')
@@ -7860,6 +7878,22 @@ describe('OrcaRuntimeService', () => {
localGitExecOptions: localGitOptions
}
)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/wsl-manual',
42,
null,
null,
{ localGitExecOptions: { ...localGitOptions, admissionTier: 'interactive' } }
)
expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/wsl-active',
42,
null,
null,
{ localGitExecOptions: { ...localGitOptions, admissionTier: 'background' } }
)
expect(getGitHubWorkItemMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
42,
+12 -3
View File
@@ -288,7 +288,11 @@ import type {
GitHubPRReviewCommentInput,
GitHubReactionContent
} from '../../shared/github/comment-types'
import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types'
import type {
GitHubPRRefreshReason,
PRRefreshOutcome
} from '../../shared/github/pull-request-refresh-types'
import { admissionTierForRefreshReason } from '../github/pr-refresh-candidate-policy'
import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types'
import type { ListWorkItemsResult } from '../../shared/github/work-item-types'
import type {
@@ -22880,10 +22884,15 @@ export class OrcaRuntimeService {
linkedPRNumber?: number | null,
fallbackPRNumber?: number | null,
acceptMergedFallbackPR?: boolean,
currentHeadOid?: string | null
currentHeadOid?: string | null,
reason?: GitHubPRRefreshReason
): Promise<PRRefreshOutcome> {
const repo = await this.resolveRepoSelector(repoSelector)
const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {}
const options: GitHubPRBranchLookupOptions =
this.getHostedReviewExecutionOptions(
repo,
reason ? admissionTierForRefreshReason(reason) : undefined
) ?? {}
const lookupOptions = { ...options }
if (acceptMergedFallbackPR === true) {
lookupOptions.acceptMergedFallbackPR = true
@@ -48,6 +48,7 @@ export const GitDiff = GitFilePath.extend({
})
export const GitBranchCompare = WorktreeSelector.extend({
admissionTier: z.enum(['interactive', 'status', 'background']).optional(),
baseRef: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
+32
View File
@@ -679,6 +679,38 @@ describe('git RPC methods', () => {
expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled()
})
it('forwards valid branch-compare admission and rejects invalid tiers', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRuntimeGitBranchCompare: vi.fn().mockResolvedValue({ summary: {}, entries: [] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS })
const accepted = await dispatcher.dispatch(
makeRequest('git.branchCompare', {
worktree: 'id:wt-1',
baseRef: 'origin/main',
admissionTier: 'background'
})
)
const rejected = await dispatcher.dispatch(
makeRequest('git.branchCompare', {
worktree: 'id:wt-1',
baseRef: 'origin/main',
admissionTier: 'urgent'
})
)
expect(accepted.ok).toBe(true)
expect(runtime.getRuntimeGitBranchCompare).toHaveBeenCalledOnce()
expect(runtime.getRuntimeGitBranchCompare).toHaveBeenCalledWith(
'id:wt-1',
'origin/main',
'background'
)
expect(rejected.ok).toBe(false)
})
it('rejects git history limits above the runtime cap', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+1 -1
View File
@@ -103,7 +103,7 @@ export const GIT_METHODS: RpcMethod[] = [
name: 'git.branchCompare',
params: GitBranchCompare,
handler: async (params, { runtime }) =>
runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef)
runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef, params.admissionTier)
}),
defineMethod({
name: 'git.commitCompare',
@@ -5,6 +5,7 @@ import { RepoSelector, SlugRepo } from './github-repo-target-schemas'
const PrForBranch = RepoSelector.extend({
branch: requiredString('Missing branch'),
reason: z.enum(['visible', 'active', 'post-push', 'manual', 'swr']).optional(),
linkedPRNumber: z.number().int().positive().nullable().optional(),
fallbackPRNumber: z.number().int().positive().nullable().optional(),
acceptMergedFallbackPR: z.boolean().optional(),
@@ -75,7 +76,8 @@ export const GITHUB_PULL_REQUEST_METHODS: RpcMethod[] = [
params.linkedPRNumber,
params.fallbackPRNumber,
params.acceptMergedFallbackPR,
params.currentHeadOid
params.currentHeadOid,
params.reason
)
}),
defineMethod({
@@ -9,6 +9,46 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('github RPC methods', () => {
it('forwards an optional PR refresh reason without requiring it from older clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoPRForBranch: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 1 })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
await dispatcher.dispatch(
makeRequest('github.prForBranch', {
repo: 'repo-1',
branch: 'feature/admission',
reason: 'manual'
})
)
await dispatcher.dispatch(
makeRequest('github.prForBranch', { repo: 'repo-1', branch: 'feature/legacy' })
)
expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith(
1,
'repo-1',
'feature/admission',
undefined,
undefined,
undefined,
undefined,
'manual'
)
expect(runtime.getRepoPRForBranch).toHaveBeenNthCalledWith(
2,
'repo-1',
'feature/legacy',
undefined,
undefined,
undefined,
undefined,
undefined
)
})
it('resolves the repo slug on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target'
const mocks = vi.hoisted(() => ({
getBranchCompare: vi.fn(),
getSshGitProvider: vi.fn()
}))
vi.mock('../git/status', () => ({
getBranchCompare: mocks.getBranchCompare,
getBranchDiff: vi.fn(),
getCommitCompare: vi.fn(),
getCommitDiff: vi.fn(),
getDiff: vi.fn()
}))
vi.mock('../git/repo', () => ({ getRemoteCommitUrl: vi.fn(), getRemoteFileUrl: vi.fn() }))
vi.mock('../git/runner', () => ({ awaitWindowsHostGitEnvironmentReady: vi.fn() }))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: mocks.getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'provider unavailable'
}))
import { RuntimeGitDiffCommands } from './runtime-git-diff-commands'
function makeCommands(overrides: Partial<RuntimeGitTarget> = {}): RuntimeGitDiffCommands {
const target = {
worktree: {
id: 'wt-1',
repoId: 'repo-1',
path: 'C:\\repo',
git: {
path: 'C:\\repo',
branch: 'feature',
isBare: false,
isMainWorktree: false,
head: 'a'.repeat(40)
}
},
localGitOptions: { wslDistro: 'Ubuntu' },
...overrides
} as RuntimeGitTarget
const host = {
resolveRuntimeGitTarget: async () => target,
getRuntimeSettings: () => ({})
} as unknown as RuntimeGitCommandHost
return new RuntimeGitDiffCommands(host)
}
describe('RuntimeGitDiffCommands branch-compare admission', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getBranchCompare.mockResolvedValue({ summary: {}, entries: [] })
})
it('preserves a background tier with WSL routing and defaults legacy callers to interactive', async () => {
const commands = makeCommands()
await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background')
await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main')
expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(1, 'C:\\repo', 'origin/main', {
wslDistro: 'Ubuntu',
admissionTier: 'background'
})
expect(mocks.getBranchCompare).toHaveBeenNthCalledWith(2, 'C:\\repo', 'origin/main', {
wslDistro: 'Ubuntu',
admissionTier: 'interactive'
})
})
it('forwards background admission to the SSH execution host', async () => {
const getBranchCompare = vi.fn().mockResolvedValue({ summary: {}, entries: [] })
mocks.getSshGitProvider.mockReturnValue({ getBranchCompare })
const commands = makeCommands({ connectionId: 'conn-1' })
await commands.getRuntimeGitBranchCompare('id:wt-1', 'origin/main', 'background')
expect(getBranchCompare).toHaveBeenCalledWith('C:\\repo', 'origin/main', {
admissionTier: 'background'
})
expect(mocks.getBranchCompare).not.toHaveBeenCalled()
})
})
@@ -13,6 +13,7 @@ import {
getDiff
} from '../git/status'
import { awaitWindowsHostGitEnvironmentReady } from '../git/runner'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
@@ -58,7 +59,8 @@ export class RuntimeGitDiffCommands {
async getRuntimeGitBranchCompare(
worktreeSelector: string,
baseRef: string
baseRef: string,
admissionTier: GitAdmissionTier = 'interactive'
): Promise<GitBranchCompareResult> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null
@@ -66,11 +68,11 @@ export class RuntimeGitDiffCommands {
if (!provider) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
return provider.getBranchCompare(target.worktree.path, baseRef)
return provider.getBranchCompare(target.worktree.path, baseRef, { admissionTier })
}
return getBranchCompare(target.worktree.path, baseRef, {
...localGitOptionsForTarget(target),
admissionTier: 'interactive'
admissionTier
})
}
+1
View File
@@ -66,6 +66,7 @@ export type GitInspectionApi = {
worktreePath: string
baseRef: string
connectionId?: string
admissionTier?: 'interactive' | 'status' | 'background'
}) => Promise<GitBranchCompareResult>
commitCompare: (args: {
worktreePath: string
+1
View File
@@ -3596,6 +3596,7 @@ const api = {
worktreePath: string
baseRef: string
connectionId?: string
admissionTier?: 'interactive' | 'status' | 'background'
}): Promise<unknown> => ipcRenderer.invoke('git:branchCompare', args),
commitCompare: (args: {
worktreePath: string
@@ -112,5 +112,13 @@ describe('useChecksPanelManualRefresh ordering', () => {
'comments-loading:false',
'refreshing:false'
])
expect(input.fetchPRForBranch).toHaveBeenCalledWith('/workspace/repo', 'main', {
force: true,
repoId: 'repo-1',
worktreeId: undefined,
linkedPRNumber: null,
fallbackPRNumber: null,
reason: 'manual'
})
})
})
@@ -212,7 +212,8 @@ export function useChecksPanelManualRefresh(model: ChecksPanelManualRefreshInput
repoId: repo.id,
worktreeId: activeWorktreeId ?? undefined,
linkedPRNumber: linkedPR,
fallbackPRNumber: fallbackGitHubPRNumber
fallbackPRNumber: fallbackGitHubPRNumber,
reason: 'manual'
})
} finally {
if (startedPRRefreshToken) {
@@ -61,61 +61,65 @@ export function useSourceControlBranchCompare({
const branchCompareStatusHeadRef = useRef<BranchCompareStatusHeadSnapshot | null>(null)
const branchCompareRemoteStatusRef = useRef<BranchCompareRemoteStatusSnapshot | null>(null)
const runBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) {
return
}
const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}`
const existingSummary =
useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId]
// Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker.
const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef
const shouldResetToLoading = !existingSummary || baseRefChanged
if (shouldResetToLoading) {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef)
} else {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, {
preserveExistingSummary: true
})
}
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
const result = await getRuntimeGitBranchCompare(
{
// Why: route the branch compare by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
compareBaseRef
)
setGitBranchCompareResult(activeWorktreeId, requestKey, result)
} catch (error) {
setGitBranchCompareResult(activeWorktreeId, requestKey, {
summary: {
baseRef: compareBaseRef,
baseOid: null,
compareRef: branchName,
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'error',
errorMessage: error instanceof Error ? error.message : 'Branch compare failed'
},
entries: []
})
}
}, [
activeRepoSettings,
activeWorktreeId,
beginGitBranchCompareRequest,
branchName,
compareBaseRef,
isFolder,
setGitBranchCompareResult,
worktreePath
])
const runBranchCompare = useCallback(
async (kind: BranchCompareRefreshKind) => {
if (!activeWorktreeId || !worktreePath || !compareBaseRef || isFolder) {
return
}
const requestKey = `${activeWorktreeId}:${compareBaseRef}:${Date.now()}`
const existingSummary =
useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId]
// Why: only reset to 'loading' on the first request or a base-ref change; resetting on every poll caused a visible loading→error→loading flicker.
const baseRefChanged = existingSummary && existingSummary.baseRef !== compareBaseRef
const shouldResetToLoading = !existingSummary || baseRefChanged
if (shouldResetToLoading) {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef)
} else {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, compareBaseRef, {
preserveExistingSummary: true
})
}
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
const result = await getRuntimeGitBranchCompare(
{
// Why: route the branch compare by the repo OWNER host, not the focused runtime.
settings: activeRepoSettings,
worktreeId: activeWorktreeId,
worktreePath,
connectionId
},
compareBaseRef,
kind === 'interval' ? 'background' : 'interactive'
)
setGitBranchCompareResult(activeWorktreeId, requestKey, result)
} catch (error) {
setGitBranchCompareResult(activeWorktreeId, requestKey, {
summary: {
baseRef: compareBaseRef,
baseOid: null,
compareRef: branchName,
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'error',
errorMessage: error instanceof Error ? error.message : 'Branch compare failed'
},
entries: []
})
}
},
[
activeRepoSettings,
activeWorktreeId,
beginGitBranchCompareRequest,
branchName,
compareBaseRef,
isFolder,
setGitBranchCompareResult,
worktreePath
]
)
const clearBranchComparePollTimer = useCallback((): void => {
if (branchComparePollTimerRef.current !== null) {
@@ -177,7 +181,7 @@ export function useSourceControlBranchCompare({
const runPromise = (async (): Promise<void> => {
// Why: keep one branch-compare chain in flight and collapse skipped ticks into one trailing refresh instead of stacking git subprocesses.
try {
await runBranchCompare()
await runBranchCompare(kind)
} finally {
const endedAt = Date.now()
branchCompareLastRunEndedAtRef.current = endedAt
@@ -238,6 +238,11 @@ describe('useSourceControlBranchCompare scheduler', () => {
// Visible mounts run once immediately through the visibility interval.
await mount({ isBranchVisible: true })
expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(1)
expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith(
expect.objectContaining({ worktreeId: 'A' }),
'origin/main',
'background'
)
await act(async () => {
vi.advanceTimersByTime(BRANCH_REFRESH_INTERVAL_MS * 3)
@@ -261,6 +266,11 @@ describe('useSourceControlBranchCompare scheduler', () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledTimes(2)
expect(mocks.getRuntimeGitBranchCompare).toHaveBeenLastCalledWith(
expect.objectContaining({ worktreeId: 'A' }),
'origin/main',
'background'
)
})
it('preserves the existing summary on a poll refresh but resets to loading on a base-ref change', async () => {
@@ -315,7 +325,8 @@ describe('useSourceControlBranchCompare scheduler', () => {
})
expect(mocks.getRuntimeGitBranchCompare).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'A' }),
'origin/dev'
'origin/dev',
'interactive'
)
})
@@ -9,6 +9,7 @@ import {
fetchRuntimeGit,
generateRuntimeCommitMessage,
generateRuntimePullRequestFields,
getRuntimeGitBranchCompare,
getRuntimeGitDiff,
getRuntimeGitHistory,
getRuntimeGitIgnoredPaths,
@@ -29,6 +30,7 @@ const gitCancelStatus = vi.fn()
const gitCheckIgnored = vi.fn()
const gitSubmoduleStatus = vi.fn()
const gitDiff = vi.fn()
const gitBranchCompare = vi.fn()
const gitHistory = vi.fn()
const gitBulkStage = vi.fn()
const gitBulkDiscard = vi.fn()
@@ -53,6 +55,7 @@ beforeEach(() => {
gitCheckIgnored.mockReset()
gitSubmoduleStatus.mockReset()
gitDiff.mockReset()
gitBranchCompare.mockReset()
gitHistory.mockReset()
gitBulkStage.mockReset()
gitBulkDiscard.mockReset()
@@ -79,6 +82,7 @@ beforeEach(() => {
checkIgnored: gitCheckIgnored,
submoduleStatus: gitSubmoduleStatus,
diff: gitDiff,
branchCompare: gitBranchCompare,
history: gitHistory,
bulkStage: gitBulkStage,
bulkDiscard: gitBulkDiscard,
@@ -99,6 +103,28 @@ beforeEach(() => {
})
describe('runtime git client', () => {
it('preserves branch-compare admission through local IPC', async () => {
gitBranchCompare.mockResolvedValue({ summary: {}, entries: [] })
await getRuntimeGitBranchCompare(
{
settings: { activeRuntimeEnvironmentId: null },
worktreeId: 'wt-1',
worktreePath: '/repo',
connectionId: 'ssh-1'
},
'origin/main',
'background'
)
expect(gitBranchCompare).toHaveBeenCalledWith({
worktreePath: '/repo',
baseRef: 'origin/main',
connectionId: 'ssh-1',
admissionTier: 'background'
})
})
it('uses local git IPC when no remote runtime is active', async () => {
gitStatus.mockResolvedValue({ entries: [], conflictOperation: 'unknown' })
@@ -31,20 +31,22 @@ export async function getRuntimeGitDiff(
export async function getRuntimeGitBranchCompare(
context: RuntimeGitContext,
baseRef: string
baseRef: string,
admissionTier: 'interactive' | 'background' = 'interactive'
): Promise<GitBranchCompareResult> {
const target = getActiveRuntimeTarget(context.settings)
if (target.kind === 'local' || !context.worktreeId) {
return window.api.git.branchCompare({
worktreePath: resolveLocalWorktreePath(context),
baseRef,
connectionId: context.connectionId
connectionId: context.connectionId,
admissionTier
})
}
return callRuntimeRpc<GitBranchCompareResult>(
target,
'git.branchCompare',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef },
{ worktree: toRuntimeWorktreeSelector(context.worktreeId), baseRef, admissionTier },
{ timeoutMs: 15_000 }
)
}
@@ -80,6 +80,7 @@ export function startPullRequestLookup(args: {
branch,
linkedPRNumber,
currentHeadOid: requestHeadOid,
...(options?.reason ? { reason: options.reason } : {}),
...(fallbackPRNumber !== null
? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null }
: {})
@@ -248,7 +249,8 @@ export function startPullRequestLookup(args: {
void get().fetchPRForBranch(repoPath, branch, {
force: true,
repoId,
worktreeId: options.worktreeId
worktreeId: options.worktreeId,
reason: options.reason
})
}
}
@@ -33,7 +33,8 @@ export const createRefreshRoutingActions = (
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason
})
return
}
@@ -68,7 +69,8 @@ export const createRefreshRoutingActions = (
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'visible'
})
continue
}
@@ -133,7 +133,8 @@ export const createRefreshSweepActions = (
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'swr'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'swr', priority: 10 })
@@ -207,7 +208,8 @@ export const createRefreshSweepActions = (
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'post-push'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'post-push', priority: 100 })
@@ -58,6 +58,7 @@ export type GitHubSlice = {
linkedPRNumber?: number | null
fallbackPRNumber?: number | null
fallbackPRSource?: GitHubPRFallbackSource | null
reason?: GitHubPRRefreshReason
}
) => Promise<PRInfo | null>
fetchIssue: (
@@ -56,7 +56,8 @@ export const createStaleWorktreeRefreshActions = (
worktreeId: candidate.worktreeId,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null,
fallbackPRSource: candidate.fallbackPRSource ?? null
fallbackPRSource: candidate.fallbackPRSource ?? null,
reason: 'active'
})
} else if (shouldEnqueueLocalPRRefresh(candidate)) {
enqueueLocalGitHubPRRefresh({ candidate, reason: 'active', priority: 80 })
@@ -126,41 +126,53 @@ describe('GitHub PR refresh owner-host routing', () => {
resetRuntimeMocks()
})
it('routes explicit PR refresh for a runtime-owned repo to its owner while Local desktop is active', async () => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-1',
ok: true,
result: makePR({ number: 23 }),
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repoPath = '/runtime/repo'
const branch = 'feature/runtime-owner'
seed(store, {
settings: { activeRuntimeEnvironmentId: null } as AppState['settings'],
repos: [
makeRepo({
id: 'repo-runtime',
path: repoPath,
executionHostId: 'runtime:env-1'
})
],
worktreesByRepo: {
'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')]
}
})
it.each([
['active', 80],
['manual', 100]
] as const)(
'routes %s PR refresh to the runtime repo owner with its reason',
async (reason, priority) => {
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-1',
ok: true,
result: makePR({ number: 23 }),
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repoPath = '/runtime/repo'
const branch = 'feature/runtime-owner'
seed(store, {
settings: { activeRuntimeEnvironmentId: null } as AppState['settings'],
repos: [
makeRepo({
id: 'repo-runtime',
path: repoPath,
executionHostId: 'runtime:env-1'
})
],
worktreesByRepo: {
'repo-runtime': [makeWorktree('repo-runtime', branch, 'wt-runtime')]
}
})
store.getState().enqueueGitHubPRRefresh('wt-runtime', 'active', 80)
store.getState().enqueueGitHubPRRefresh('wt-runtime', reason, priority)
await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1))
expect(enqueuePRRefresh).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' },
timeoutMs: 30_000
})
})
await vi.waitFor(() => expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1))
expect(enqueuePRRefresh).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: {
repo: 'repo-runtime',
branch,
linkedPRNumber: null,
currentHeadOid: 'head-oid',
reason
},
timeoutMs: 30_000
})
}
)
it('keeps connected SSH PR refresh on the local coordinator even when a runtime is focused', () => {
const store = createTestStore()
@@ -230,7 +242,13 @@ describe('GitHub PR refresh owner-host routing', () => {
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: { repo: 'repo-runtime', branch, linkedPRNumber: null, currentHeadOid: 'head-oid' },
params: {
repo: 'repo-runtime',
branch,
linkedPRNumber: null,
currentHeadOid: 'head-oid',
reason: 'post-push'
},
timeoutMs: 30_000
})
})
@@ -278,7 +296,8 @@ describe('GitHub PR refresh owner-host routing', () => {
repo: 'repo-runtime',
branch: 'feature/runtime',
linkedPRNumber: null,
currentHeadOid: 'head-oid'
currentHeadOid: 'head-oid',
reason: 'visible'
},
timeoutMs: 30_000
})
@@ -587,7 +587,13 @@ describe('createGitHubSlice.refreshAllGitHub', () => {
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null },
params: {
repo: 'repo-1',
branch,
linkedPRNumber: null,
currentHeadOid: null,
reason: 'swr'
},
timeoutMs: 30_000
})
})
@@ -717,7 +723,13 @@ describe('createGitHubSlice.refreshGitHubForWorktree', () => {
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: { repo: 'repo-1', branch, linkedPRNumber: null, currentHeadOid: null },
params: {
repo: 'repo-1',
branch,
linkedPRNumber: null,
currentHeadOid: null,
reason: 'post-push'
},
timeoutMs: 30_000
})
})
@@ -530,7 +530,13 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => {
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'github.prForBranch',
params: { repo: 'repo-1', branch, linkedPRNumber: 12, currentHeadOid: null },
params: {
repo: 'repo-1',
branch,
linkedPRNumber: 12,
currentHeadOid: null,
reason: 'active'
},
timeoutMs: 30_000
})
expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toMatchObject({
@@ -120,11 +120,12 @@ export function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
compareAgainstHead
})
},
branchCompare: async ({ worktreePath, baseRef }) => {
branchCompare: async ({ worktreePath, baseRef, admissionTier }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.branchCompare', {
worktree: toRuntimeWorktreeSelector(worktree.id),
baseRef
baseRef,
...(admissionTier ? { admissionTier } : {})
})
},
commitCompare: async ({ worktreePath, commitId }) => {
@@ -164,6 +164,11 @@ describe('web git preload API', () => {
branchLineTotalMergeBase: TEST_COMMIT_OID
})
await globals.window.api.git.status({ worktreePath: '/workspace/repo' })
await globals.window.api.git.branchCompare({
worktreePath: '/workspace/repo',
baseRef: 'origin/main',
admissionTier: 'background'
})
const statusCalls = runtimeCalls.filter((call) => call.method === 'git.status')
// Why: strict — `toEqual` would pass on a forwarded `branchLineTotalMergeBase: undefined`,
@@ -189,5 +194,13 @@ describe('web git preload API', () => {
}
}
])
expect(runtimeCalls.find((call) => call.method === 'git.branchCompare')).toStrictEqual({
method: 'git.branchCompare',
params: {
worktree: 'id:wt-1',
baseRef: 'origin/main',
admissionTier: 'background'
}
})
})
})