mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
perf: coalesce git upstream status reads
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitPushTarget, GitUpstreamStatus } from '../../shared/types'
|
||||
import {
|
||||
GitUpstreamStatusReadOwner,
|
||||
type GitUpstreamStatusExecutionIdentity
|
||||
} from './git-upstream-status-read-owner'
|
||||
|
||||
const upstreamStatus: GitUpstreamStatus = {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
|
||||
function deferredPromise<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve
|
||||
reject = innerReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('GitUpstreamStatusReadOwner', () => {
|
||||
it('shares one live read across ten identical callers', async () => {
|
||||
const owner = new GitUpstreamStatusReadOwner()
|
||||
const pending = deferredPromise<GitUpstreamStatus>()
|
||||
const load = vi.fn(() => pending.promise)
|
||||
|
||||
const reads = Array.from({ length: 10 }, () =>
|
||||
owner.read({ kind: 'native' }, '/repo', undefined, load)
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(upstreamStatus)
|
||||
await expect(Promise.all(reads)).resolves.toEqual(
|
||||
Array.from({ length: 10 }, () => upstreamStatus)
|
||||
)
|
||||
})
|
||||
|
||||
it('isolates paths, execution hosts, and every explicit target field', async () => {
|
||||
const owner = new GitUpstreamStatusReadOwner()
|
||||
const load = vi.fn().mockResolvedValue(upstreamStatus)
|
||||
const baseTarget: GitPushTarget = {
|
||||
remoteName: 'fork',
|
||||
branchName: 'feature'
|
||||
}
|
||||
const cases: [GitUpstreamStatusExecutionIdentity, string, GitPushTarget | undefined][] = [
|
||||
[{ kind: 'native' }, '/repo-a', undefined],
|
||||
[{ kind: 'native' }, '/repo-b', undefined],
|
||||
[{ kind: 'wsl', distro: 'Ubuntu' }, '/repo-a', undefined],
|
||||
[{ kind: 'wsl', distro: 'Debian' }, '/repo-a', undefined],
|
||||
[{ kind: 'native' }, '/repo-a', baseTarget],
|
||||
[{ kind: 'native' }, '/repo-a', { ...baseTarget, remoteName: 'origin' }],
|
||||
[{ kind: 'native' }, '/repo-a', { ...baseTarget, branchName: 'other' }],
|
||||
[
|
||||
{ kind: 'native' },
|
||||
'/repo-a',
|
||||
{ ...baseTarget, remoteUrl: 'https://github.com/example/fork.git' }
|
||||
],
|
||||
[{ kind: 'native' }, '/repo-a', { ...baseTarget, remoteCreated: false }],
|
||||
[{ kind: 'native' }, '/repo-a', { ...baseTarget, remoteCreated: true }],
|
||||
[{ kind: 'ssh-provider' }, '/repo-a', baseTarget]
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
cases.map(([identity, worktreePath, target]) =>
|
||||
owner.read(identity, worktreePath, target, load)
|
||||
)
|
||||
)
|
||||
|
||||
expect(load).toHaveBeenCalledTimes(cases.length)
|
||||
})
|
||||
|
||||
it('runs fresh work after success and rejection', async () => {
|
||||
const owner = new GitUpstreamStatusReadOwner()
|
||||
const failure = new Error('upstream failed')
|
||||
const load = vi
|
||||
.fn<() => Promise<GitUpstreamStatus>>()
|
||||
.mockResolvedValueOnce(upstreamStatus)
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce(upstreamStatus)
|
||||
|
||||
await expect(owner.read({ kind: 'native' }, '/repo', undefined, load)).resolves.toBe(
|
||||
upstreamStatus
|
||||
)
|
||||
await expect(owner.read({ kind: 'native' }, '/repo', undefined, load)).rejects.toBe(failure)
|
||||
await expect(owner.read({ kind: 'native' }, '/repo', undefined, load)).resolves.toBe(
|
||||
upstreamStatus
|
||||
)
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('fences before, during, and after mutation generations', async () => {
|
||||
const owner = new GitUpstreamStatusReadOwner()
|
||||
const pendingReads = Array.from({ length: 3 }, () => deferredPromise<GitUpstreamStatus>())
|
||||
const load = vi.fn(() => pendingReads[load.mock.calls.length - 1].promise)
|
||||
|
||||
const before = owner.read({ kind: 'native' }, '/repo', undefined, load)
|
||||
await Promise.resolve()
|
||||
owner.invalidate()
|
||||
const during = owner.read({ kind: 'native' }, '/repo', undefined, load)
|
||||
await Promise.resolve()
|
||||
owner.invalidate()
|
||||
const after = owner.read({ kind: 'native' }, '/repo', undefined, load)
|
||||
await Promise.resolve()
|
||||
|
||||
pendingReads.forEach((pending) => pending.resolve(upstreamStatus))
|
||||
await expect(Promise.all([before, during, after])).resolves.toEqual(
|
||||
Array.from({ length: 3 }, () => upstreamStatus)
|
||||
)
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { GitPushTarget, GitUpstreamStatus } from '../../shared/types'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
|
||||
export type GitUpstreamStatusExecutionIdentity =
|
||||
| { kind: 'native' }
|
||||
| { kind: 'wsl'; distro: string }
|
||||
| { kind: 'ssh-provider' }
|
||||
|
||||
export class GitUpstreamStatusReadOwner {
|
||||
private readonly inFlightReads = new InFlightPromiseDedupe<GitUpstreamStatus>()
|
||||
|
||||
read(
|
||||
executionIdentity: GitUpstreamStatusExecutionIdentity,
|
||||
worktreePath: string,
|
||||
pushTarget: GitPushTarget | undefined,
|
||||
load: () => Promise<GitUpstreamStatus>
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const key = stableInFlightKey([
|
||||
executionIdentity,
|
||||
worktreePath,
|
||||
pushTarget
|
||||
? [
|
||||
'explicit-target',
|
||||
pushTarget.remoteName,
|
||||
pushTarget.branchName,
|
||||
pushTarget.remoteUrl ?? null,
|
||||
pushTarget.remoteCreated ?? null
|
||||
]
|
||||
: ['configured-upstream']
|
||||
])
|
||||
return this.inFlightReads.run(key, load)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.inFlightReads.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const nativeAndWslGitUpstreamStatusReadOwner = new GitUpstreamStatusReadOwner()
|
||||
@@ -54,6 +54,7 @@ import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
import { nativeAndWslGitUpstreamStatusReadOwner } from './git-upstream-status-read-owner'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
@@ -99,6 +100,7 @@ const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
export function invalidateGitReadCaches(): void {
|
||||
gitDiffReadDedupe.clear()
|
||||
statusReadLeaseOwner.invalidate()
|
||||
nativeAndWslGitUpstreamStatusReadOwner.invalidate()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache()
|
||||
resolvedUpstreamNameCache.clear()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn()
|
||||
@@ -8,7 +10,8 @@ vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock
|
||||
}))
|
||||
|
||||
import { getUpstreamStatus } from './upstream'
|
||||
import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
|
||||
const missingTrackingRefError = new Error(
|
||||
"fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" +
|
||||
@@ -19,6 +22,157 @@ const missingTrackingRefError = new Error(
|
||||
describe('getUpstreamStatus', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
invalidateGitUpstreamStatusReads()
|
||||
})
|
||||
|
||||
it('benchmarks concurrent upstream Git command pressure', async () => {
|
||||
const benchPath = process.env.ORCA_GIT_UPSTREAM_COALESCING_BENCH_JSON
|
||||
if (!benchPath) {
|
||||
return
|
||||
}
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return Promise.resolve({ stdout: 'main\n' })
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return Promise.resolve({ stdout: 'origin/main\n' })
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return Promise.resolve({ stdout: '2\t3\n' })
|
||||
}
|
||||
if (args[0] === 'log') {
|
||||
return Promise.resolve({ stdout: '+ abc123 remote work\n' })
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await Promise.all(Array.from({ length: 10 }, () => getUpstreamStatus('/repo')))
|
||||
|
||||
const commands = gitExecFileAsyncMock.mock.calls.map(([args, options]) => ({ args, options }))
|
||||
const commandCounts = Object.fromEntries(
|
||||
['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) => [
|
||||
command,
|
||||
commands.filter(({ args }) => args[0] === command).length
|
||||
])
|
||||
)
|
||||
const { mkdirSync, writeFileSync } = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
mkdirSync(path.dirname(benchPath), { recursive: true })
|
||||
writeFileSync(
|
||||
benchPath,
|
||||
JSON.stringify({
|
||||
scenario: 'local-git-upstream-concurrent-burst',
|
||||
concurrentCalls: 10,
|
||||
physicalGitCalls: commands.length,
|
||||
commandCounts,
|
||||
commandChain: ['symbolic-ref', 'rev-parse', 'rev-list', 'log'].map((command) =>
|
||||
commands.find(({ args }) => args[0] === command)
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('isolates physical reads by worktree, native or WSL host, and every target field', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
return Promise.resolve({ stdout: 'main\n' })
|
||||
}
|
||||
if (args[0] === 'check-ref-format') {
|
||||
return Promise.resolve({ stdout: '' })
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) {
|
||||
return Promise.resolve({ stdout: 'origin/main\n' })
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--verify')) {
|
||||
return Promise.resolve({ stdout: 'abc123\n' })
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return Promise.resolve({ stdout: '0\t0\n' })
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
const baseTarget = { remoteName: 'fork', branchName: 'feature' }
|
||||
|
||||
await Promise.all([
|
||||
getUpstreamStatus('/repo-a'),
|
||||
getUpstreamStatus('/repo-b'),
|
||||
getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Ubuntu' }),
|
||||
getUpstreamStatus('/repo-a', undefined, { wslDistro: 'Debian' }),
|
||||
getUpstreamStatus('/repo-a', baseTarget),
|
||||
getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }),
|
||||
getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }),
|
||||
getUpstreamStatus('/repo-a', {
|
||||
...baseTarget,
|
||||
remoteUrl: 'https://github.com/example/fork.git'
|
||||
}),
|
||||
getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }),
|
||||
getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true })
|
||||
])
|
||||
|
||||
expect(
|
||||
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'symbolic-ref')
|
||||
).toHaveLength(4)
|
||||
expect(
|
||||
gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'check-ref-format')
|
||||
).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('runs fresh physical work after a normalized rejection', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
|
||||
.mockRejectedValueOnce(new Error('fatal: authentication failed'))
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
|
||||
.mockResolvedValueOnce({ stdout: '0\t0\n' })
|
||||
|
||||
await expect(getUpstreamStatus('/repo')).rejects.toThrow('fatal: authentication failed')
|
||||
await expect(getUpstreamStatus('/repo')).resolves.toMatchObject({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main'
|
||||
})
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
|
||||
it('uses the common pre/post mutation fence for physical upstream reads', async () => {
|
||||
const pendingReads: {
|
||||
promise: Promise<{ stdout: string }>
|
||||
resolve: (value: { stdout: string }) => void
|
||||
}[] = []
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
let resolve!: (value: { stdout: string }) => void
|
||||
const promise = new Promise<{ stdout: string }>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
pendingReads.push({ promise, resolve })
|
||||
return promise
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return Promise.resolve({ stdout: 'origin/main\n' })
|
||||
}
|
||||
if (args[0] === 'rev-list') {
|
||||
return Promise.resolve({ stdout: '0\t0\n' })
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
})
|
||||
let finishMutation!: () => void
|
||||
const mutationGate = new Promise<void>((resolve) => {
|
||||
finishMutation = resolve
|
||||
})
|
||||
|
||||
const before = getUpstreamStatus('/repo')
|
||||
await vi.waitFor(() => expect(pendingReads).toHaveLength(1))
|
||||
const mutation = runWithGitReadCacheInvalidation(() => mutationGate)
|
||||
const during = getUpstreamStatus('/repo')
|
||||
await vi.waitFor(() => expect(pendingReads).toHaveLength(2))
|
||||
finishMutation()
|
||||
await mutation
|
||||
const after = getUpstreamStatus('/repo')
|
||||
await vi.waitFor(() => expect(pendingReads).toHaveLength(3))
|
||||
|
||||
pendingReads.forEach(({ resolve }) => resolve({ stdout: 'main\n' }))
|
||||
await Promise.all([before, during, after])
|
||||
expect(pendingReads).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('returns upstream and ahead/behind counts when tracking is configured', async () => {
|
||||
|
||||
@@ -5,11 +5,16 @@ import { getEffectiveGitUpstreamStatus } from '../../shared/git-effective-upstre
|
||||
import { getPublishTargetStatus } from '../../shared/git-publish-target-status'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { validateGitPushTarget } from './push-target-validation'
|
||||
import { nativeAndWslGitUpstreamStatusReadOwner } from './git-upstream-status-read-owner'
|
||||
|
||||
type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
export function invalidateGitUpstreamStatusReads(): void {
|
||||
nativeAndWslGitUpstreamStatusReadOwner.invalidate()
|
||||
}
|
||||
|
||||
function gitExecOptions(
|
||||
cwd: string,
|
||||
options: GitExecOptions = {}
|
||||
@@ -35,7 +40,7 @@ async function getBehindCommitsArePatchEquivalent(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpstreamStatus(
|
||||
async function readUpstreamStatus(
|
||||
worktreePath: string,
|
||||
pushTarget?: GitPushTarget,
|
||||
options: GitExecOptions = {}
|
||||
@@ -71,3 +76,19 @@ export async function getUpstreamStatus(
|
||||
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpstreamStatus(
|
||||
worktreePath: string,
|
||||
pushTarget?: GitPushTarget,
|
||||
options: GitExecOptions = {}
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const executionIdentity = options.wslDistro
|
||||
? ({ kind: 'wsl', distro: options.wslDistro } as const)
|
||||
: ({ kind: 'native' } as const)
|
||||
return nativeAndWslGitUpstreamStatusReadOwner.read(
|
||||
executionIdentity,
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
() => readUpstreamStatus(worktreePath, pushTarget, options)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1002,6 +1002,125 @@ describe('SshGitProvider', () => {
|
||||
expect(result).toEqual(upstreamResult)
|
||||
})
|
||||
|
||||
it('benchmarks concurrent upstream-status RPC pressure', async () => {
|
||||
const benchPath = process.env.ORCA_SSH_GIT_UPSTREAM_COALESCING_BENCH_JSON
|
||||
if (!benchPath) {
|
||||
return
|
||||
}
|
||||
mux.request.mockResolvedValue({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, () => provider.getUpstreamStatus('/home/user/repo'))
|
||||
)
|
||||
|
||||
const upstreamRequests = mux.request.mock.calls.filter(
|
||||
([method]) => method === 'git.upstreamStatus'
|
||||
)
|
||||
const { mkdirSync, writeFileSync } = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
mkdirSync(path.dirname(benchPath), { recursive: true })
|
||||
writeFileSync(
|
||||
benchPath,
|
||||
JSON.stringify({
|
||||
scenario: 'ssh-git-upstream-concurrent-burst',
|
||||
concurrentCalls: 10,
|
||||
upstreamStatusRequests: upstreamRequests.length,
|
||||
method: 'git.upstreamStatus',
|
||||
payload: { worktreePath: '/home/user/repo' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shares one upstream-status RPC across ten identical callers', async () => {
|
||||
const pending = deferredPromise<{
|
||||
hasUpstream: true
|
||||
upstreamName: string
|
||||
ahead: number
|
||||
behind: number
|
||||
}>()
|
||||
mux.request.mockReturnValue(pending.promise)
|
||||
|
||||
const reads = Array.from({ length: 10 }, () => provider.getUpstreamStatus('/home/user/repo'))
|
||||
await waitForRequestCount(mux.request, 1)
|
||||
|
||||
expect(mux.request).toHaveBeenCalledTimes(1)
|
||||
pending.resolve({ hasUpstream: true, upstreamName: 'origin/main', ahead: 1, behind: 0 })
|
||||
await expect(Promise.all(reads)).resolves.toHaveLength(10)
|
||||
})
|
||||
|
||||
it('isolates upstream-status RPCs by worktree and every target field', async () => {
|
||||
mux.request.mockResolvedValue({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
const baseTarget = { remoteName: 'fork', branchName: 'feature' }
|
||||
|
||||
await Promise.all([
|
||||
provider.getUpstreamStatus('/repo-a'),
|
||||
provider.getUpstreamStatus('/repo-b'),
|
||||
provider.getUpstreamStatus('/repo-a', baseTarget),
|
||||
provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteName: 'origin' }),
|
||||
provider.getUpstreamStatus('/repo-a', { ...baseTarget, branchName: 'other' }),
|
||||
provider.getUpstreamStatus('/repo-a', {
|
||||
...baseTarget,
|
||||
remoteUrl: 'https://github.com/example/fork.git'
|
||||
}),
|
||||
provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: false }),
|
||||
provider.getUpstreamStatus('/repo-a', { ...baseTarget, remoteCreated: true })
|
||||
])
|
||||
|
||||
expect(mux.request).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('keeps upstream-status reads isolated per provider instance', async () => {
|
||||
const otherProvider = new SshGitProvider('conn-1', mux as never)
|
||||
mux.request.mockResolvedValue({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
provider.getUpstreamStatus('/home/user/repo'),
|
||||
otherProvider.getUpstreamStatus('/home/user/repo')
|
||||
])
|
||||
|
||||
expect(mux.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('runs a fresh upstream-status RPC after result and error settlement', async () => {
|
||||
const failure = new Error('upstream RPC failed')
|
||||
mux.request
|
||||
.mockResolvedValueOnce({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
|
||||
await expect(provider.getUpstreamStatus('/home/user/repo')).resolves.toMatchObject({
|
||||
hasUpstream: true
|
||||
})
|
||||
await expect(provider.getUpstreamStatus('/home/user/repo')).rejects.toBe(failure)
|
||||
await expect(provider.getUpstreamStatus('/home/user/repo')).resolves.toMatchObject({
|
||||
hasUpstream: false
|
||||
})
|
||||
expect(mux.request).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('getUpstreamStatus forwards an explicit push target', async () => {
|
||||
const upstreamResult = { hasUpstream: true, upstreamName: 'fork/feature', ahead: 0, behind: 1 }
|
||||
mux.request.mockResolvedValue(upstreamResult)
|
||||
@@ -1432,6 +1551,42 @@ describe('SshGitProvider', () => {
|
||||
expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fences upstream-status reads before, during, and after an SSH mutation', async () => {
|
||||
const upstreamRequests = Array.from({ length: 3 }, () =>
|
||||
deferredPromise<{ hasUpstream: false; ahead: 0; behind: 0 }>()
|
||||
)
|
||||
const mutation = deferredPromise<void>()
|
||||
let upstreamRequestIndex = 0
|
||||
mux.request.mockImplementation((method) => {
|
||||
if (method === 'git.upstreamStatus') {
|
||||
return upstreamRequests[upstreamRequestIndex++]?.promise
|
||||
}
|
||||
if (method === 'git.stage') {
|
||||
return mutation.promise
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
const beforeMutation = provider.getUpstreamStatus('/home/user/repo')
|
||||
await waitForRequestCount(mux.request, 1)
|
||||
const mutating = provider.stageFile('/home/user/repo', 'src/file.ts')
|
||||
await waitForRequestCount(mux.request, 2)
|
||||
const duringMutation = provider.getUpstreamStatus('/home/user/repo')
|
||||
await waitForRequestCount(mux.request, 3)
|
||||
mutation.resolve(undefined)
|
||||
await mutating
|
||||
const afterMutation = provider.getUpstreamStatus('/home/user/repo')
|
||||
await waitForRequestCount(mux.request, 4)
|
||||
|
||||
upstreamRequests.forEach((pending) =>
|
||||
pending.resolve({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
)
|
||||
await Promise.all([beforeMutation, duringMutation, afterMutation])
|
||||
expect(
|
||||
mux.request.mock.calls.filter(([method]) => method === 'git.upstreamStatus')
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'clone',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
import { gitExecMutatesRepository } from '../../shared/git-exec-mutation'
|
||||
import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner'
|
||||
import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner'
|
||||
|
||||
type NonInteractiveExecQueueEntry = {
|
||||
started: boolean
|
||||
@@ -85,6 +86,7 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un
|
||||
export class SshGitProvider implements IGitProvider {
|
||||
private readonly gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult | GitDiffResult[]>()
|
||||
private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner()
|
||||
|
||||
private connectionId: string
|
||||
private mux: SshChannelMultiplexer
|
||||
@@ -102,6 +104,7 @@ export class SshGitProvider implements IGitProvider {
|
||||
private invalidateGitReads(): void {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.statusReadLeaseOwner.invalidate()
|
||||
this.upstreamStatusReadOwner.invalidate()
|
||||
}
|
||||
|
||||
private loggedWorktreeIsCleanFallback = false
|
||||
@@ -507,10 +510,16 @@ export class SshGitProvider implements IGitProvider {
|
||||
worktreePath: string,
|
||||
pushTarget?: GitPushTarget
|
||||
): Promise<GitUpstreamStatus> {
|
||||
return (await this.mux.request('git.upstreamStatus', {
|
||||
return this.upstreamStatusReadOwner.read(
|
||||
{ kind: 'ssh-provider' },
|
||||
worktreePath,
|
||||
...(pushTarget ? { pushTarget } : {})
|
||||
})) as GitUpstreamStatus
|
||||
pushTarget,
|
||||
async () =>
|
||||
(await this.mux.request('git.upstreamStatus', {
|
||||
worktreePath,
|
||||
...(pushTarget ? { pushTarget } : {})
|
||||
})) as GitUpstreamStatus
|
||||
)
|
||||
}
|
||||
|
||||
async pushBranch(
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 2 Git upstream-status in-flight sharing — 2026-07-30
|
||||
|
||||
## Decision
|
||||
|
||||
Retain in-flight-only upstream-status sharing at both physical owners. Ten concurrent identical
|
||||
local callers now share one four-command Git chain, and ten concurrent identical SSH callers share
|
||||
one `git.upstreamStatus` RPC. No settled result, rejected result, Git command, RPC method, payload,
|
||||
or max-lines policy changed.
|
||||
|
||||
Checkpoint: `6fee674c48a6dc5742156f70e1fa1363a760f6c6`.
|
||||
|
||||
## Ownership and identity
|
||||
|
||||
`GitUpstreamStatusReadOwner` is the concrete bounded joinability owner. The native/WSL path uses
|
||||
one module-owned instance, keyed by:
|
||||
|
||||
- native versus the exact WSL distro;
|
||||
- worktree path;
|
||||
- configured-upstream versus explicit-target mode; and
|
||||
- explicit target `remoteName`, `branchName`, `remoteUrl`, and `remoteCreated`, preserving absent
|
||||
versus explicit `false`.
|
||||
|
||||
Each `SshGitProvider` owns a separate instance, keyed by worktree path and the same complete target
|
||||
identity. Provider replacement or reconnect therefore cannot reuse another connection
|
||||
incarnation's work. The shared primitive removes successful and failed entries after settlement
|
||||
and bounds joinability to 128 entries and 30 seconds; it does not retain a status result.
|
||||
|
||||
The existing local `runWithGitReadCacheInvalidation` and SSH `runWithGitReadInvalidation` wrappers
|
||||
invalidate upstream joinability before and after every existing Git mutation. Already-issued
|
||||
promises remain valid, while reads admitted before, during, and after a mutation use separate
|
||||
generations.
|
||||
|
||||
## A/B physical-call measurement
|
||||
|
||||
The opt-in deterministic benchmarks call the actual local and SSH provider entry points with ten
|
||||
concurrent identical callers. Counts are taken at the mocked physical `gitExecFileAsync` and mux
|
||||
request boundaries.
|
||||
|
||||
| Arm | Local physical Git calls | SSH `git.upstreamStatus` RPCs |
|
||||
| -------------------- | -----------------------: | ----------------------------: |
|
||||
| A — checkpoint | 40 | 10 |
|
||||
| B — retained sharing | 4 | 1 |
|
||||
| Change | -36 | -9 |
|
||||
|
||||
The local command histogram changes from ten of each command to one of each. The command vectors
|
||||
and execution options are otherwise byte-for-byte equivalent:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"args": ["symbolic-ref", "--quiet", "--short", "HEAD"],
|
||||
"options": { "cwd": "/repo" }
|
||||
},
|
||||
{
|
||||
"args": ["rev-parse", "--abbrev-ref", "HEAD@{u}"],
|
||||
"options": { "cwd": "/repo" }
|
||||
},
|
||||
{
|
||||
"args": ["rev-list", "--left-right", "--count", "HEAD...origin/main"],
|
||||
"options": { "cwd": "/repo" }
|
||||
},
|
||||
{
|
||||
"args": ["log", "--oneline", "--cherry-mark", "--right-only", "HEAD...origin/main", "--"],
|
||||
"options": { "cwd": "/repo" }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The SSH request remains exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "git.upstreamStatus",
|
||||
"payload": { "worktreePath": "/home/user/repo" }
|
||||
}
|
||||
```
|
||||
|
||||
When an explicit target exists, the existing `pushTarget` property is still present with the
|
||||
unchanged object; when absent, the property remains omitted. Existing configured-upstream and
|
||||
explicit-target tests continue to cover validation, remote tracking probes, URL/created metadata,
|
||||
ahead/behind counts, patch equivalence, normalized errors, and no-upstream semantics.
|
||||
|
||||
Baseline JSON was archived at `/tmp/git-upstream-inflight-a.json` and
|
||||
`/tmp/ssh-git-upstream-inflight-a.json`; retained JSON is at
|
||||
`/tmp/git-upstream-inflight-b.json` and `/tmp/ssh-git-upstream-inflight-b.json`.
|
||||
|
||||
## Production A/B artifacts
|
||||
|
||||
Fresh production builds were archived outside the worktree:
|
||||
|
||||
- A: `/tmp/orca-phase2-git-upstream-a.vQcXwZ/out`
|
||||
- B: `/tmp/orca-phase2-git-upstream-b-final.OVLh7P/out`
|
||||
|
||||
| Artifact | A raw / gzip | B raw / gzip | Change | A SHA-256 | B SHA-256 |
|
||||
| ---------------------- | ----------------- | ----------------- | ----------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Electron main entry | 778,457 / 174,582 | 779,267 / 174,769 | +810 / +187 | `c5992b64d59edfb9719ea2e19b2872f923253686615e53e356aeac6d3139e632` | `64526204d377f8ca634de5940d77b3f2eff1421bd7a8e7346b0ffcc189be21da` |
|
||||
| Electron preload entry | 130,798 / 20,642 | 130,798 / 20,642 | 0 / 0 | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` |
|
||||
|
||||
The Electron-main raw budget remains 825,109 bytes; B retains 45,842 bytes of headroom. Main,
|
||||
preload, and renderer file counts remain 184, 1, and 787 respectively. Preload and all renderer
|
||||
artifacts are byte-identical; the renderer manifest remains byte-identical with SHA-256
|
||||
`92ad617f8c8aee6d4c4a23622f524266cbe9c93b3fe65544d1acb833dc3f0d67`.
|
||||
|
||||
Renderer entry measurements are unchanged:
|
||||
|
||||
| Entry | Raw bytes | Gzip bytes | JS | CSS |
|
||||
| --------------- | --------: | ---------: | --: | --: |
|
||||
| Renderer index | 8,416,540 | 1,877,823 | 292 | 2 |
|
||||
| Renderer popout | 4,507,253 | 984,615 | 77 | 2 |
|
||||
| Renderer web | 4,360,652 | 928,355 | 33 | 1 |
|
||||
|
||||
Complete A and B manifest validation found 778 records, three HTML entries, 6,247 static edges,
|
||||
213 dynamic edges, and 860 emitted artifact references, with zero missing or escaping paths, zero
|
||||
cross-entry imports, and zero cycles.
|
||||
|
||||
An initial non-retained wiring arm imported the full upstream implementation into the eager status
|
||||
module and cost +6,686 main-entry raw bytes. Moving the common fence onto the named owner singleton
|
||||
reduced the retained cost to +810 bytes without changing invalidation behavior.
|
||||
|
||||
## Validation
|
||||
|
||||
- Baseline provider/upstream suite: 112 passed.
|
||||
- Focused owner/upstream/provider suite: 124 passed.
|
||||
- Final owner/upstream/status/provider suite: 215 passed.
|
||||
- Broad dedupe, upstream, remote, native/WSL status, SSH provider/dispatch, filesystem mutation,
|
||||
and runtime-routing suite: 465 passed, 1 skipped.
|
||||
- `pnpm run typecheck:node`: passed.
|
||||
- Targeted `oxlint --deny-warnings`: passed.
|
||||
- Targeted `oxfmt --check`: passed.
|
||||
- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new bypass.
|
||||
- Fresh A and B `pnpm run build:electron-vite`: passed.
|
||||
- `pnpm run check:electron-bundle-budgets`: passed against fresh B.
|
||||
- Complete renderer manifest resolution and entry-cycle validation: passed for A and B.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Limitations and residual risk
|
||||
|
||||
- Physical call counts use deterministic mocked Git and mux boundaries. They prove admission,
|
||||
exact commands, options, method, and payload, not live process startup or SSH latency.
|
||||
- After the bounded primitive's 30-second joinability window, a still-hung read may be joined by no
|
||||
new callers and a fresh call may start. Existing callers remain attached to their original work.
|
||||
- This tranche does not add caller cancellation to the upstream-status API and does not broaden
|
||||
into a repository snapshot service.
|
||||
- No packaged live smoke was run across macOS, Linux, Windows, WSL, or SSH hosts. Existing routing,
|
||||
folder-workspace, relay, and Git 2.25-compatible command suites cover the unchanged boundaries.
|
||||
Reference in New Issue
Block a user