mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
fix(git): harden admission across runtime paths
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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('-'))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ?? []
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : ''))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export type GitInspectionApi = {
|
||||
worktreePath: string
|
||||
baseRef: string
|
||||
connectionId?: string
|
||||
admissionTier?: 'interactive' | 'status' | 'background'
|
||||
}) => Promise<GitBranchCompareResult>
|
||||
commitCompare: (args: {
|
||||
worktreePath: string
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+60
-56
@@ -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
|
||||
|
||||
+12
-1
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user