mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
perf: add versioned git repository snapshots
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitPushTarget, GitStatusResult, GitUpstreamStatus } from '../../shared/types'
|
||||
import {
|
||||
GitRepositorySnapshotOwner,
|
||||
type GitRepositoryExecutionIdentity,
|
||||
type GitRepositoryStatusIdentity
|
||||
} from './git-repository-snapshot-owner'
|
||||
import { MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES } from './git-repository-snapshot-projection'
|
||||
|
||||
const native = { kind: 'native' } as const
|
||||
const defaultStatusIdentity: GitRepositoryStatusIdentity = {
|
||||
includeIgnored: false,
|
||||
reuseLineStats: false,
|
||||
bypassEffectiveUpstreamNegativeCache: false,
|
||||
limit: 1_000,
|
||||
sharedLinkPaths: []
|
||||
}
|
||||
const statusResult: GitStatusResult = {
|
||||
entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged', added: 2, removed: 1 }],
|
||||
conflictOperation: 'merge',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 2
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<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 }
|
||||
}
|
||||
|
||||
function query(
|
||||
executionIdentity: GitRepositoryExecutionIdentity = native,
|
||||
worktreePath = '/repo',
|
||||
identity: GitRepositoryStatusIdentity = defaultStatusIdentity,
|
||||
pushTarget?: GitPushTarget
|
||||
) {
|
||||
return { executionIdentity, worktreePath, statusIdentity: identity, pushTarget }
|
||||
}
|
||||
|
||||
describe('GitRepositorySnapshotOwner', () => {
|
||||
it('publishes immutable combined projections with monotonic revisions', async () => {
|
||||
const now = vi.fn().mockReturnValueOnce(100).mockReturnValueOnce(200)
|
||||
const owner = new GitRepositorySnapshotOwner(128, 8, now)
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
const first = owner.getSnapshot(query())
|
||||
|
||||
expect(first).toMatchObject({
|
||||
revision: 1,
|
||||
generatedAt: 100,
|
||||
repositoryIdentity: { head: 'abc123', branch: 'refs/heads/main' },
|
||||
status: {
|
||||
entries: statusResult.entries,
|
||||
didHitLimit: false,
|
||||
statusLength: null,
|
||||
lineStatsState: 'complete'
|
||||
},
|
||||
upstream: statusResult.upstreamStatus,
|
||||
conflicts: 'merge',
|
||||
worktreeGraphVersion: 0,
|
||||
freshness: {
|
||||
status: { state: 'fresh', generation: 0, revision: 1 },
|
||||
upstream: { state: 'fresh', generation: 0, revision: 1 },
|
||||
worktreeGraph: { state: 'placeholder', generation: 0 }
|
||||
}
|
||||
})
|
||||
expect(Object.isFrozen(first)).toBe(true)
|
||||
expect(Object.isFrozen(first?.status)).toBe(true)
|
||||
expect(Object.isFrozen(first?.status.entries)).toBe(true)
|
||||
expect(Object.isFrozen(first?.status.entries[0])).toBe(true)
|
||||
|
||||
const refreshedUpstream = {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 3,
|
||||
behind: 0,
|
||||
behindCommitsArePatchEquivalent: true
|
||||
}
|
||||
await owner.readUpstream(native, '/repo', undefined, async () => refreshedUpstream)
|
||||
const second = owner.getSnapshot(query())
|
||||
|
||||
expect(second?.revision).toBe(2)
|
||||
expect(second?.generatedAt).toBe(200)
|
||||
expect(second?.status.entries).toEqual(first?.status.entries)
|
||||
expect(second?.upstream).toEqual(refreshedUpstream)
|
||||
expect(second?.freshness.status.revision).toBe(1)
|
||||
expect(second?.freshness.upstream.revision).toBe(2)
|
||||
})
|
||||
|
||||
it('does not publish rejected reads and always retries settled work', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const failure = new Error('read failed')
|
||||
const statusLoad = vi
|
||||
.fn<(_signal: AbortSignal) => Promise<GitStatusResult>>()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce(statusResult)
|
||||
.mockResolvedValueOnce(statusResult)
|
||||
.mockRejectedValueOnce(failure)
|
||||
const upstreamLoad = vi
|
||||
.fn<() => Promise<GitUpstreamStatus>>()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
.mockResolvedValueOnce({ hasUpstream: false, ahead: 0, behind: 0 })
|
||||
.mockRejectedValueOnce(failure)
|
||||
|
||||
await expect(
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad)
|
||||
).rejects.toBe(failure)
|
||||
await expect(owner.readUpstream(native, '/repo', undefined, upstreamLoad)).rejects.toBe(failure)
|
||||
expect(owner.getSnapshot(query())).toBeNull()
|
||||
expect(owner.getRetentionState().repositories).toBe(0)
|
||||
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad)
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad)
|
||||
await owner.readUpstream(native, '/repo', undefined, upstreamLoad)
|
||||
await owner.readUpstream(native, '/repo', undefined, upstreamLoad)
|
||||
|
||||
expect(statusLoad).toHaveBeenCalledTimes(3)
|
||||
expect(upstreamLoad).toHaveBeenCalledTimes(3)
|
||||
expect(owner.getSnapshot(query())?.revision).toBe(4)
|
||||
|
||||
await expect(
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad)
|
||||
).rejects.toBe(failure)
|
||||
await expect(owner.readUpstream(native, '/repo', undefined, upstreamLoad)).rejects.toBe(failure)
|
||||
expect(owner.getSnapshot(query())).toMatchObject({
|
||||
revision: 4,
|
||||
freshness: {
|
||||
status: { state: 'failed' },
|
||||
upstream: { state: 'failed' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps status cancellation scoped to each lease', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const pending = deferred<GitStatusResult>()
|
||||
const load = vi.fn((_signal: AbortSignal) => pending.promise)
|
||||
const firstController = new AbortController()
|
||||
const secondController = new AbortController()
|
||||
const firstError = new Error('first cancelled')
|
||||
|
||||
const first = owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
firstController.signal,
|
||||
load
|
||||
)
|
||||
const second = owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
secondController.signal,
|
||||
load
|
||||
)
|
||||
firstController.abort(firstError)
|
||||
|
||||
await expect(first).rejects.toBe(firstError)
|
||||
expect(load).toHaveBeenCalledOnce()
|
||||
expect(load.mock.calls[0][0].aborted).toBe(false)
|
||||
pending.resolve(statusResult)
|
||||
await expect(second).resolves.toBe(statusResult)
|
||||
expect(owner.getSnapshot(query())?.revision).toBe(1)
|
||||
})
|
||||
|
||||
it('prefers a newer embedded upstream projection after invalidation', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
await owner.readUpstream(native, '/repo', undefined, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'old/main',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
owner.invalidate()
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fresh/main',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
}))
|
||||
|
||||
expect(owner.getSnapshot(query())).toMatchObject({
|
||||
upstream: { upstreamName: 'fresh/main' },
|
||||
freshness: { upstream: { state: 'fresh', generation: 1 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses stale status and upstream completion across both mutation fences', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const statuses = Array.from({ length: 3 }, () => deferred<GitStatusResult>())
|
||||
const upstreams = Array.from({ length: 3 }, () => deferred<GitUpstreamStatus>())
|
||||
let statusIndex = 0
|
||||
let upstreamIndex = 0
|
||||
const readStatus = () =>
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, () => {
|
||||
const pending = statuses[statusIndex]
|
||||
statusIndex += 1
|
||||
return pending.promise
|
||||
})
|
||||
const readUpstream = () =>
|
||||
owner.readUpstream(native, '/repo', undefined, () => {
|
||||
const pending = upstreams[upstreamIndex]
|
||||
upstreamIndex += 1
|
||||
return pending.promise
|
||||
})
|
||||
|
||||
const before = [readStatus(), readUpstream()]
|
||||
await Promise.resolve()
|
||||
owner.invalidate()
|
||||
const during = [readStatus(), readUpstream()]
|
||||
await Promise.resolve()
|
||||
owner.invalidate()
|
||||
const after = [readStatus(), readUpstream()]
|
||||
await Promise.resolve()
|
||||
|
||||
statuses[0].resolve({ ...statusResult, head: 'before' })
|
||||
statuses[1].resolve({ ...statusResult, head: 'during' })
|
||||
statuses[2].resolve({ ...statusResult, head: 'after' })
|
||||
upstreams[0].resolve({ hasUpstream: true, upstreamName: 'before/main', ahead: 0, behind: 0 })
|
||||
upstreams[1].resolve({ hasUpstream: true, upstreamName: 'during/main', ahead: 0, behind: 0 })
|
||||
upstreams[2].resolve({ hasUpstream: true, upstreamName: 'after/main', ahead: 0, behind: 0 })
|
||||
|
||||
await Promise.all([...before, ...during, ...after])
|
||||
expect(owner.getSnapshot(query())).toMatchObject({
|
||||
revision: 2,
|
||||
repositoryIdentity: { head: 'after' },
|
||||
upstream: { upstreamName: 'after/main' },
|
||||
freshness: {
|
||||
status: { state: 'fresh', generation: 2 },
|
||||
upstream: { state: 'fresh', generation: 2 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates every host, path, status option, and upstream target dimension', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const statusLoad = vi.fn(async () => statusResult)
|
||||
const upstreamLoad = vi.fn(async () => ({ hasUpstream: false, ahead: 0, behind: 0 }))
|
||||
const statusCases: [GitRepositoryExecutionIdentity, string, GitRepositoryStatusIdentity][] = [
|
||||
[native, '/repo-a', defaultStatusIdentity],
|
||||
[native, '/repo-b', defaultStatusIdentity],
|
||||
[{ kind: 'wsl', distro: 'Ubuntu' }, '/repo-a', defaultStatusIdentity],
|
||||
[{ kind: 'wsl', distro: 'Debian' }, '/repo-a', defaultStatusIdentity],
|
||||
[native, '/repo-a', { ...defaultStatusIdentity, includeIgnored: true }],
|
||||
[native, '/repo-a', { ...defaultStatusIdentity, reuseLineStats: true }],
|
||||
[native, '/repo-a', { ...defaultStatusIdentity, bypassEffectiveUpstreamNegativeCache: true }],
|
||||
[native, '/repo-a', { ...defaultStatusIdentity, limit: 20 }],
|
||||
[native, '/repo-a', { ...defaultStatusIdentity, sharedLinkPaths: ['node_modules'] }]
|
||||
]
|
||||
const target = { remoteName: 'fork', branchName: 'feature' }
|
||||
const targets: (GitPushTarget | undefined)[] = [
|
||||
undefined,
|
||||
target,
|
||||
{ ...target, remoteName: 'origin' },
|
||||
{ ...target, branchName: 'other' },
|
||||
{ ...target, remoteUrl: 'https://github.com/example/fork.git' },
|
||||
{ ...target, remoteCreated: false },
|
||||
{ ...target, remoteCreated: true }
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
statusCases.map(([host, path, identity]) =>
|
||||
owner.readStatus(host, path, identity, undefined, statusLoad)
|
||||
)
|
||||
)
|
||||
await Promise.all(
|
||||
targets.map((candidate) => owner.readUpstream(native, '/repo-a', candidate, upstreamLoad))
|
||||
)
|
||||
|
||||
expect(statusLoad).toHaveBeenCalledTimes(statusCases.length)
|
||||
expect(upstreamLoad).toHaveBeenCalledTimes(targets.length)
|
||||
})
|
||||
|
||||
it('bounds repositories and projection variants without merging evicted identities', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner(2, 2)
|
||||
const evictedPending = deferred<GitStatusResult>()
|
||||
const evicted = owner.readStatus(
|
||||
native,
|
||||
'/repo-a',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
() => evictedPending.promise
|
||||
)
|
||||
await owner.readStatus(native, '/repo-b', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
head: 'repo-b'
|
||||
}))
|
||||
await owner.readStatus(native, '/repo-c', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
head: 'repo-c'
|
||||
}))
|
||||
evictedPending.resolve({ ...statusResult, head: 'repo-a-late' })
|
||||
await evicted
|
||||
|
||||
expect(owner.getRetentionState().repositories).toBe(2)
|
||||
expect(owner.getSnapshot(query(native, '/repo-a'))).toBeNull()
|
||||
expect(owner.getSnapshot(query(native, '/repo-c'))?.repositoryIdentity.head).toBe('repo-c')
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo-c',
|
||||
{ ...defaultStatusIdentity, includeIgnored: true },
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo-c',
|
||||
{ ...defaultStatusIdentity, reuseLineStats: true },
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
const target = { remoteName: 'fork', branchName: 'feature' }
|
||||
await owner.readUpstream(native, '/repo-c', undefined, async () => ({
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
await owner.readUpstream(native, '/repo-c', target, async () => ({
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
await owner.readUpstream(native, '/repo-c', { ...target, branchName: 'other' }, async () => ({
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
|
||||
expect(owner.getRetentionState()).toEqual({
|
||||
repositories: 2,
|
||||
statusProjections: 3,
|
||||
upstreamProjections: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds retained status payloads without changing the caller result', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const entries = Array.from(
|
||||
{ length: MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES + 1 },
|
||||
(_, index) => ({
|
||||
path: `generated/file-${index}.ts`,
|
||||
status: 'untracked' as const,
|
||||
area: 'untracked' as const
|
||||
})
|
||||
)
|
||||
const result = { entries, conflictOperation: 'unknown' as const }
|
||||
|
||||
const returned = await owner.readStatus(
|
||||
native,
|
||||
'/large-repo',
|
||||
{ ...defaultStatusIdentity, limit: 0 },
|
||||
undefined,
|
||||
async () => result
|
||||
)
|
||||
const snapshot = owner.getSnapshot(
|
||||
query(native, '/large-repo', { ...defaultStatusIdentity, limit: 0 })
|
||||
)
|
||||
|
||||
expect(returned.entries).toHaveLength(MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES + 1)
|
||||
expect(snapshot?.status.entries).toHaveLength(MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES)
|
||||
expect(snapshot?.status.retentionTruncated).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { GitPushTarget, GitStatusResult, GitUpstreamStatus } from '../../shared/types'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import {
|
||||
createGitRepositoryProjectionFreshness,
|
||||
EMPTY_GIT_IGNORED_PATHS,
|
||||
EMPTY_GIT_STATUS_ENTRIES,
|
||||
freezeGitRepositoryStatus,
|
||||
freezeGitRepositoryUpstream,
|
||||
gitRepositoryScopeKey,
|
||||
gitRepositoryStatusKey,
|
||||
gitRepositoryUpstreamKey,
|
||||
type GitRepositoryExecutionIdentity,
|
||||
type GitRepositorySnapshot,
|
||||
type GitRepositorySnapshotQuery,
|
||||
type GitRepositoryStatusIdentity,
|
||||
type GitRepositoryStatusProjection
|
||||
} from './git-repository-snapshot-projection'
|
||||
|
||||
export type {
|
||||
GitRepositoryExecutionIdentity,
|
||||
GitRepositoryProjectionFreshness,
|
||||
GitRepositorySnapshot,
|
||||
GitRepositorySnapshotQuery,
|
||||
GitRepositoryStatusIdentity
|
||||
} from './git-repository-snapshot-projection'
|
||||
|
||||
export const MAX_GIT_REPOSITORY_SNAPSHOTS = 64
|
||||
export const MAX_GIT_REPOSITORY_PROJECTION_VARIANTS = 4
|
||||
|
||||
type ProjectionRecord<T> = {
|
||||
generation: number
|
||||
generatedAt: number
|
||||
revision: number
|
||||
value: T
|
||||
failedGeneration?: number
|
||||
}
|
||||
|
||||
type RepositoryRecord = {
|
||||
status: Map<string, ProjectionRecord<GitRepositoryStatusProjection>>
|
||||
upstream: Map<string, ProjectionRecord<Readonly<GitUpstreamStatus>>>
|
||||
}
|
||||
|
||||
function clampBound(value: number, maximum: number): number {
|
||||
return Number.isFinite(value) ? Math.min(maximum, Math.max(1, Math.floor(value))) : maximum
|
||||
}
|
||||
|
||||
export class GitRepositorySnapshotOwner {
|
||||
private readonly statusReads = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
private readonly upstreamReads = new InFlightPromiseDedupe<GitUpstreamStatus>()
|
||||
private readonly repositories = new Map<string, RepositoryRecord>()
|
||||
private readonly maxRepositories: number
|
||||
private readonly maxProjectionVariants: number
|
||||
private generation = 0
|
||||
private revision = 0
|
||||
|
||||
constructor(
|
||||
maxRepositories = MAX_GIT_REPOSITORY_SNAPSHOTS,
|
||||
maxProjectionVariants = MAX_GIT_REPOSITORY_PROJECTION_VARIANTS,
|
||||
private readonly now: () => number = Date.now
|
||||
) {
|
||||
this.maxRepositories = clampBound(maxRepositories, MAX_GIT_REPOSITORY_SNAPSHOTS)
|
||||
this.maxProjectionVariants = clampBound(
|
||||
maxProjectionVariants,
|
||||
MAX_GIT_REPOSITORY_PROJECTION_VARIANTS
|
||||
)
|
||||
}
|
||||
|
||||
readStatus(
|
||||
executionIdentity: GitRepositoryExecutionIdentity,
|
||||
worktreePath: string,
|
||||
identity: GitRepositoryStatusIdentity,
|
||||
signal: AbortSignal | undefined,
|
||||
load: (sharedSignal: AbortSignal) => Promise<GitStatusResult>
|
||||
): Promise<GitStatusResult> {
|
||||
const repositoryKey = gitRepositoryScopeKey(executionIdentity, worktreePath)
|
||||
const projectionKey = gitRepositoryStatusKey(identity)
|
||||
const generation = this.generation
|
||||
return this.statusReads.lease(
|
||||
stableInFlightKey([repositoryKey, projectionKey]),
|
||||
signal,
|
||||
async (sharedSignal) => {
|
||||
const repository = this.getOrCreateRepository(repositoryKey)
|
||||
try {
|
||||
const result = await load(sharedSignal)
|
||||
this.publishStatus(repositoryKey, repository, projectionKey, generation, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const retained = repository.status.get(projectionKey)
|
||||
if (
|
||||
!sharedSignal.aborted &&
|
||||
generation === this.generation &&
|
||||
this.repositories.get(repositoryKey) === repository &&
|
||||
retained
|
||||
) {
|
||||
retained.failedGeneration = generation
|
||||
}
|
||||
this.removeEmptyRepository(repositoryKey, repository)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
readUpstream(
|
||||
executionIdentity: GitRepositoryExecutionIdentity,
|
||||
worktreePath: string,
|
||||
pushTarget: GitPushTarget | undefined,
|
||||
load: () => Promise<GitUpstreamStatus>
|
||||
): Promise<GitUpstreamStatus> {
|
||||
const repositoryKey = gitRepositoryScopeKey(executionIdentity, worktreePath)
|
||||
const projectionKey = gitRepositoryUpstreamKey(pushTarget)
|
||||
const generation = this.generation
|
||||
return this.upstreamReads.run(stableInFlightKey([repositoryKey, projectionKey]), async () => {
|
||||
const repository = this.getOrCreateRepository(repositoryKey)
|
||||
try {
|
||||
const result = await load()
|
||||
this.publishUpstream(repositoryKey, repository, projectionKey, generation, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const retained = repository.upstream.get(projectionKey)
|
||||
if (
|
||||
generation === this.generation &&
|
||||
this.repositories.get(repositoryKey) === repository &&
|
||||
retained
|
||||
) {
|
||||
retained.failedGeneration = generation
|
||||
}
|
||||
this.removeEmptyRepository(repositoryKey, repository)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getSnapshot(query: GitRepositorySnapshotQuery): GitRepositorySnapshot | null {
|
||||
const repositoryKey = gitRepositoryScopeKey(query.executionIdentity, query.worktreePath)
|
||||
const repository = this.repositories.get(repositoryKey)
|
||||
if (!repository) {
|
||||
return null
|
||||
}
|
||||
this.touchRepository(repositoryKey, repository)
|
||||
const statusIdentity = gitRepositoryStatusKey(query.statusIdentity)
|
||||
const upstreamIdentity = gitRepositoryUpstreamKey(query.pushTarget)
|
||||
const statusRecord = repository.status.get(statusIdentity)
|
||||
const upstreamRecord = repository.upstream.get(upstreamIdentity)
|
||||
if (!statusRecord && !upstreamRecord) {
|
||||
return null
|
||||
}
|
||||
|
||||
const statusFreshness = createGitRepositoryProjectionFreshness(
|
||||
statusRecord,
|
||||
this.generation,
|
||||
statusIdentity
|
||||
)
|
||||
const embeddedUpstreamRecord =
|
||||
query.pushTarget === undefined && statusRecord?.value.upstream ? statusRecord : undefined
|
||||
const useEmbeddedUpstream =
|
||||
embeddedUpstreamRecord &&
|
||||
(!upstreamRecord || embeddedUpstreamRecord.revision > upstreamRecord.revision)
|
||||
const selectedUpstreamRecord = useEmbeddedUpstream ? embeddedUpstreamRecord : upstreamRecord
|
||||
const upstreamFreshness = selectedUpstreamRecord
|
||||
? createGitRepositoryProjectionFreshness(
|
||||
selectedUpstreamRecord,
|
||||
this.generation,
|
||||
useEmbeddedUpstream ? `status:${statusIdentity}` : upstreamIdentity
|
||||
)
|
||||
: createGitRepositoryProjectionFreshness(undefined, this.generation, upstreamIdentity)
|
||||
const revision = Math.max(statusRecord?.revision ?? 0, upstreamRecord?.revision ?? 0)
|
||||
const generatedAt = Math.max(statusRecord?.generatedAt ?? 0, upstreamRecord?.generatedAt ?? 0)
|
||||
const status = statusRecord?.value
|
||||
return Object.freeze({
|
||||
revision,
|
||||
generatedAt,
|
||||
repositoryIdentity: status?.repositoryIdentity ?? Object.freeze({ head: null, branch: null }),
|
||||
status:
|
||||
status?.status ??
|
||||
Object.freeze({
|
||||
entries: EMPTY_GIT_STATUS_ENTRIES,
|
||||
didHitLimit: false,
|
||||
statusLength: null,
|
||||
ignoredPaths: EMPTY_GIT_IGNORED_PATHS,
|
||||
lineStatsState: 'missing' as const,
|
||||
retentionTruncated: false
|
||||
}),
|
||||
upstream: useEmbeddedUpstream
|
||||
? embeddedUpstreamRecord.value.upstream
|
||||
: (upstreamRecord?.value ?? null),
|
||||
conflicts: status?.conflicts ?? null,
|
||||
worktreeGraphVersion: 0,
|
||||
freshness: Object.freeze({
|
||||
repositoryIdentity: statusFreshness,
|
||||
status: statusFreshness,
|
||||
upstream: upstreamFreshness,
|
||||
conflicts: statusFreshness,
|
||||
worktreeGraph: Object.freeze({
|
||||
state: 'placeholder',
|
||||
generation: this.generation,
|
||||
currentGeneration: this.generation,
|
||||
revision: null,
|
||||
identity: null
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.generation += 1
|
||||
this.statusReads.invalidate()
|
||||
this.upstreamReads.clear()
|
||||
}
|
||||
|
||||
getRetentionState(): Readonly<{
|
||||
repositories: number
|
||||
statusProjections: number
|
||||
upstreamProjections: number
|
||||
}> {
|
||||
let statusProjections = 0
|
||||
let upstreamProjections = 0
|
||||
for (const repository of this.repositories.values()) {
|
||||
statusProjections += repository.status.size
|
||||
upstreamProjections += repository.upstream.size
|
||||
}
|
||||
return Object.freeze({
|
||||
repositories: this.repositories.size,
|
||||
statusProjections,
|
||||
upstreamProjections
|
||||
})
|
||||
}
|
||||
|
||||
private getOrCreateRepository(repositoryKey: string): RepositoryRecord {
|
||||
const existing = this.repositories.get(repositoryKey)
|
||||
if (existing) {
|
||||
this.touchRepository(repositoryKey, existing)
|
||||
return existing
|
||||
}
|
||||
const repository = { status: new Map(), upstream: new Map() }
|
||||
this.repositories.set(repositoryKey, repository)
|
||||
while (this.repositories.size > this.maxRepositories) {
|
||||
const oldestKey = this.repositories.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
this.repositories.delete(oldestKey)
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
private touchRepository(repositoryKey: string, repository: RepositoryRecord): void {
|
||||
this.repositories.delete(repositoryKey)
|
||||
this.repositories.set(repositoryKey, repository)
|
||||
}
|
||||
|
||||
private removeEmptyRepository(repositoryKey: string, repository: RepositoryRecord): void {
|
||||
if (
|
||||
this.repositories.get(repositoryKey) === repository &&
|
||||
repository.status.size === 0 &&
|
||||
repository.upstream.size === 0
|
||||
) {
|
||||
this.repositories.delete(repositoryKey)
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(
|
||||
repositoryKey: string,
|
||||
repository: RepositoryRecord,
|
||||
projectionKey: string,
|
||||
generation: number,
|
||||
result: GitStatusResult
|
||||
): void {
|
||||
if (generation !== this.generation || this.repositories.get(repositoryKey) !== repository) {
|
||||
return
|
||||
}
|
||||
const revision = this.nextRevision()
|
||||
const generatedAt = this.now()
|
||||
this.remember(repository.status, projectionKey, {
|
||||
generation,
|
||||
generatedAt,
|
||||
revision,
|
||||
value: freezeGitRepositoryStatus(result)
|
||||
})
|
||||
}
|
||||
|
||||
private publishUpstream(
|
||||
repositoryKey: string,
|
||||
repository: RepositoryRecord,
|
||||
projectionKey: string,
|
||||
generation: number,
|
||||
result: GitUpstreamStatus
|
||||
): void {
|
||||
if (generation !== this.generation || this.repositories.get(repositoryKey) !== repository) {
|
||||
return
|
||||
}
|
||||
this.remember(repository.upstream, projectionKey, {
|
||||
generation,
|
||||
generatedAt: this.now(),
|
||||
revision: this.nextRevision(),
|
||||
value: freezeGitRepositoryUpstream(result)
|
||||
})
|
||||
}
|
||||
|
||||
private remember<T>(projections: Map<string, T>, key: string, value: T): void {
|
||||
projections.delete(key)
|
||||
projections.set(key, value)
|
||||
while (projections.size > this.maxProjectionVariants) {
|
||||
const oldestKey = projections.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
projections.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private nextRevision(): number {
|
||||
this.revision += 1
|
||||
return this.revision
|
||||
}
|
||||
}
|
||||
|
||||
export const nativeAndWslGitRepositorySnapshotOwner = new GitRepositorySnapshotOwner()
|
||||
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
GitConflictOperation,
|
||||
GitPushTarget,
|
||||
GitStatusEntry,
|
||||
GitStatusResult,
|
||||
GitUpstreamStatus
|
||||
} from '../../shared/types'
|
||||
import { stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
|
||||
export const MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES = 1_000
|
||||
export const MAX_GIT_REPOSITORY_SNAPSHOT_IGNORED_PATHS = 1_000
|
||||
export const MAX_GIT_REPOSITORY_SNAPSHOT_TEXT_CODE_UNITS = 64 * 1_024
|
||||
|
||||
export type GitRepositoryExecutionIdentity =
|
||||
| { kind: 'native' }
|
||||
| { kind: 'wsl'; distro: string }
|
||||
| { kind: 'ssh-provider'; connectionId: string }
|
||||
|
||||
export type GitRepositoryStatusIdentity = {
|
||||
includeIgnored: boolean
|
||||
reuseLineStats: boolean
|
||||
bypassEffectiveUpstreamNegativeCache: boolean
|
||||
limit: number | null
|
||||
sharedLinkPaths: readonly string[]
|
||||
}
|
||||
|
||||
export type GitRepositoryProjectionFreshness = Readonly<{
|
||||
state: 'missing' | 'fresh' | 'stale' | 'failed' | 'placeholder'
|
||||
generation: number
|
||||
currentGeneration: number
|
||||
revision: number | null
|
||||
identity: string | null
|
||||
}>
|
||||
|
||||
export type GitRepositorySnapshot = Readonly<{
|
||||
revision: number
|
||||
generatedAt: number
|
||||
repositoryIdentity: Readonly<{ head: string | null; branch: string | null }>
|
||||
status: Readonly<{
|
||||
entries: readonly Readonly<GitStatusEntry>[]
|
||||
didHitLimit: boolean
|
||||
statusLength: number | null
|
||||
ignoredPaths: readonly string[]
|
||||
lineStatsState: 'missing' | 'complete' | 'skipped-at-limit'
|
||||
retentionTruncated: boolean
|
||||
}>
|
||||
upstream: Readonly<GitUpstreamStatus> | null
|
||||
conflicts: GitConflictOperation | null
|
||||
worktreeGraphVersion: number
|
||||
freshness: Readonly<{
|
||||
repositoryIdentity: GitRepositoryProjectionFreshness
|
||||
status: GitRepositoryProjectionFreshness
|
||||
upstream: GitRepositoryProjectionFreshness
|
||||
conflicts: GitRepositoryProjectionFreshness
|
||||
worktreeGraph: GitRepositoryProjectionFreshness
|
||||
}>
|
||||
}>
|
||||
|
||||
export type GitRepositoryStatusProjection = {
|
||||
repositoryIdentity: Readonly<{ head: string | null; branch: string | null }>
|
||||
status: GitRepositorySnapshot['status']
|
||||
upstream: Readonly<GitUpstreamStatus> | null
|
||||
conflicts: GitConflictOperation
|
||||
}
|
||||
|
||||
export type GitRepositorySnapshotQuery = {
|
||||
executionIdentity: GitRepositoryExecutionIdentity
|
||||
worktreePath: string
|
||||
statusIdentity: GitRepositoryStatusIdentity
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
|
||||
export const EMPTY_GIT_STATUS_ENTRIES = Object.freeze([]) as readonly Readonly<GitStatusEntry>[]
|
||||
export const EMPTY_GIT_IGNORED_PATHS = Object.freeze([]) as readonly string[]
|
||||
|
||||
export function gitRepositoryScopeKey(
|
||||
executionIdentity: GitRepositoryExecutionIdentity,
|
||||
worktreePath: string
|
||||
): string {
|
||||
return stableInFlightKey([executionIdentity, worktreePath])
|
||||
}
|
||||
|
||||
export function gitRepositoryStatusKey(identity: GitRepositoryStatusIdentity): string {
|
||||
return stableInFlightKey([
|
||||
identity.includeIgnored,
|
||||
identity.reuseLineStats,
|
||||
identity.bypassEffectiveUpstreamNegativeCache,
|
||||
identity.limit,
|
||||
identity.sharedLinkPaths
|
||||
])
|
||||
}
|
||||
|
||||
export function gitRepositoryUpstreamKey(pushTarget: GitPushTarget | undefined): string {
|
||||
return stableInFlightKey([
|
||||
pushTarget
|
||||
? [
|
||||
'explicit-target',
|
||||
pushTarget.remoteName,
|
||||
pushTarget.branchName,
|
||||
pushTarget.remoteUrl ?? null,
|
||||
pushTarget.remoteCreated ?? null
|
||||
]
|
||||
: ['configured-upstream']
|
||||
])
|
||||
}
|
||||
|
||||
export function freezeGitRepositoryUpstream(
|
||||
status: GitUpstreamStatus
|
||||
): Readonly<GitUpstreamStatus> {
|
||||
return Object.freeze({ ...status })
|
||||
}
|
||||
|
||||
function copyBoundedGitStatusEntries(entries: GitStatusEntry[]): {
|
||||
entries: readonly Readonly<GitStatusEntry>[]
|
||||
textCodeUnits: number
|
||||
} {
|
||||
const retained: Readonly<GitStatusEntry>[] = []
|
||||
let textCodeUnits = 0
|
||||
for (const entry of entries) {
|
||||
const entryCodeUnits = entry.path.length + (entry.oldPath?.length ?? 0)
|
||||
if (
|
||||
retained.length >= MAX_GIT_REPOSITORY_SNAPSHOT_STATUS_ENTRIES ||
|
||||
textCodeUnits + entryCodeUnits > MAX_GIT_REPOSITORY_SNAPSHOT_TEXT_CODE_UNITS
|
||||
) {
|
||||
break
|
||||
}
|
||||
retained.push(
|
||||
Object.freeze({
|
||||
...entry,
|
||||
...(entry.submodule ? { submodule: Object.freeze({ ...entry.submodule }) } : {})
|
||||
})
|
||||
)
|
||||
textCodeUnits += entryCodeUnits
|
||||
}
|
||||
return { entries: Object.freeze(retained), textCodeUnits }
|
||||
}
|
||||
|
||||
function copyBoundedGitIgnoredPaths(
|
||||
ignoredPaths: string[],
|
||||
textCodeUnits: number
|
||||
): readonly string[] {
|
||||
const retained: string[] = []
|
||||
let retainedCodeUnits = textCodeUnits
|
||||
for (const ignoredPath of ignoredPaths) {
|
||||
if (
|
||||
retained.length >= MAX_GIT_REPOSITORY_SNAPSHOT_IGNORED_PATHS ||
|
||||
retainedCodeUnits + ignoredPath.length > MAX_GIT_REPOSITORY_SNAPSHOT_TEXT_CODE_UNITS
|
||||
) {
|
||||
break
|
||||
}
|
||||
retained.push(ignoredPath)
|
||||
retainedCodeUnits += ignoredPath.length
|
||||
}
|
||||
return Object.freeze(retained)
|
||||
}
|
||||
|
||||
export function freezeGitRepositoryStatus(result: GitStatusResult): GitRepositoryStatusProjection {
|
||||
const boundedEntries = copyBoundedGitStatusEntries(result.entries)
|
||||
const ignoredPaths = copyBoundedGitIgnoredPaths(
|
||||
result.ignoredPaths ?? [],
|
||||
boundedEntries.textCodeUnits
|
||||
)
|
||||
const retentionTruncated =
|
||||
boundedEntries.entries.length !== result.entries.length ||
|
||||
ignoredPaths.length !== (result.ignoredPaths?.length ?? 0)
|
||||
return Object.freeze({
|
||||
repositoryIdentity: Object.freeze({
|
||||
head: result.head ?? null,
|
||||
branch: result.branch ?? null
|
||||
}),
|
||||
status: Object.freeze({
|
||||
entries: boundedEntries.entries,
|
||||
didHitLimit: result.didHitLimit === true,
|
||||
statusLength: result.statusLength ?? null,
|
||||
ignoredPaths,
|
||||
lineStatsState: result.didHitLimit === true ? 'skipped-at-limit' : 'complete',
|
||||
retentionTruncated
|
||||
}),
|
||||
upstream: result.upstreamStatus ? freezeGitRepositoryUpstream(result.upstreamStatus) : null,
|
||||
conflicts: result.conflictOperation
|
||||
})
|
||||
}
|
||||
|
||||
export function createGitRepositoryProjectionFreshness(
|
||||
record:
|
||||
| {
|
||||
generation: number
|
||||
revision: number
|
||||
failedGeneration?: number
|
||||
}
|
||||
| undefined,
|
||||
generation: number,
|
||||
identity: string
|
||||
): GitRepositoryProjectionFreshness {
|
||||
const state =
|
||||
record?.failedGeneration === generation
|
||||
? 'failed'
|
||||
: record
|
||||
? record.generation === generation
|
||||
? 'fresh'
|
||||
: 'stale'
|
||||
: 'missing'
|
||||
return Object.freeze({
|
||||
state,
|
||||
generation: record?.generation ?? generation,
|
||||
currentGeneration: generation,
|
||||
revision: record?.revision ?? null,
|
||||
identity
|
||||
})
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
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()
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
getBranchCompare,
|
||||
getCommitCompare,
|
||||
getDiff,
|
||||
getGitRepositorySnapshot,
|
||||
getStagedCommitContext,
|
||||
getStatus,
|
||||
getSubmoduleStatus,
|
||||
@@ -1176,6 +1177,38 @@ describe('getStatus', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes the live native status result into the repository snapshot owner', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout:
|
||||
'# branch.oid abcdef1234567890\n' +
|
||||
'# branch.head feature/snapshot\n' +
|
||||
'# branch.upstream origin/feature/snapshot\n' +
|
||||
'# branch.ab +2 -1\n' +
|
||||
'1 .M N... 100644 100644 100644 aaaa aaaa src/app.ts\n'
|
||||
})
|
||||
|
||||
const result = await getStatus('/snapshot-repo')
|
||||
const snapshot = getGitRepositorySnapshot('/snapshot-repo')
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
repositoryIdentity: {
|
||||
head: 'abcdef1234567890',
|
||||
branch: 'refs/heads/feature/snapshot'
|
||||
},
|
||||
status: {
|
||||
entries: result.entries,
|
||||
didHitLimit: false,
|
||||
lineStatsState: 'complete'
|
||||
},
|
||||
upstream: result.upstreamStatus,
|
||||
conflicts: 'unknown',
|
||||
freshness: {
|
||||
status: { state: 'fresh' },
|
||||
upstream: { state: 'fresh' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces identical in-flight status reads without caching after settle', async () => {
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
+37
-20
@@ -12,6 +12,7 @@ import type {
|
||||
GitConflictOperation,
|
||||
GitDiffResult,
|
||||
GitFileStatus,
|
||||
GitPushTarget,
|
||||
GitStatusEntry,
|
||||
GitStatusResult,
|
||||
GitUpstreamStatus
|
||||
@@ -52,9 +53,12 @@ import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
|
||||
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
|
||||
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 {
|
||||
nativeAndWslGitRepositorySnapshotOwner,
|
||||
type GitRepositoryExecutionIdentity,
|
||||
type GitRepositorySnapshot
|
||||
} from './git-repository-snapshot-owner'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
@@ -94,13 +98,11 @@ const effectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatu
|
||||
const retiredEffectiveUpstreamStatusInFlight = new Map<string, Promise<GitUpstreamStatus>>()
|
||||
const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
|
||||
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
|
||||
const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
|
||||
// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read.
|
||||
export function invalidateGitReadCaches(): void {
|
||||
gitDiffReadDedupe.clear()
|
||||
statusReadLeaseOwner.invalidate()
|
||||
nativeAndWslGitUpstreamStatusReadOwner.invalidate()
|
||||
nativeAndWslGitRepositorySnapshotOwner.invalidate()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache()
|
||||
resolvedUpstreamNameCache.clear()
|
||||
@@ -219,26 +221,41 @@ export async function getStatus(
|
||||
options: GetStatusOptions = {}
|
||||
): Promise<GitStatusResult> {
|
||||
gitDiffReadDedupe.clear()
|
||||
// Why: dedupe only concurrent identical reads; after settle, callers must run a fresh read.
|
||||
const cacheKey = getStatusReadKey(worktreePath, options)
|
||||
return statusReadLeaseOwner.lease(cacheKey, options.signal, (sharedSignal) =>
|
||||
runGetStatus(worktreePath, { ...options, signal: sharedSignal })
|
||||
return nativeAndWslGitRepositorySnapshotOwner.readStatus(
|
||||
getStatusExecutionIdentity(options),
|
||||
worktreePath,
|
||||
getStatusSnapshotIdentity(options),
|
||||
options.signal,
|
||||
(sharedSignal) => runGetStatus(worktreePath, { ...options, signal: sharedSignal })
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusReadKey(worktreePath: string, options: GetStatusOptions): string {
|
||||
// Why: each key part can change the output shape or runtime routing.
|
||||
const limit = resolveGitStatusLimit(options.limit)
|
||||
return stableInFlightKey([
|
||||
export function getGitRepositorySnapshot(
|
||||
worktreePath: string,
|
||||
options: GetStatusOptions = {},
|
||||
pushTarget?: GitPushTarget
|
||||
): GitRepositorySnapshot | null {
|
||||
return nativeAndWslGitRepositorySnapshotOwner.getSnapshot({
|
||||
executionIdentity: getStatusExecutionIdentity(options),
|
||||
worktreePath,
|
||||
options.wslDistro ?? '',
|
||||
options.includeIgnored === true,
|
||||
options.reuseLineStats === true,
|
||||
options.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit,
|
||||
statusIdentity: getStatusSnapshotIdentity(options),
|
||||
pushTarget
|
||||
})
|
||||
}
|
||||
|
||||
function getStatusExecutionIdentity(options: GetStatusOptions): GitRepositoryExecutionIdentity {
|
||||
return options.wslDistro ? { kind: 'wsl', distro: options.wslDistro } : { kind: 'native' }
|
||||
}
|
||||
|
||||
function getStatusSnapshotIdentity(options: GetStatusOptions) {
|
||||
return {
|
||||
includeIgnored: options.includeIgnored === true,
|
||||
reuseLineStats: options.reuseLineStats === true,
|
||||
bypassEffectiveUpstreamNegativeCache: options.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit: resolveGitStatusLimit(options.limit),
|
||||
// Why: this changes which entries survive, so it must not share a cache slot.
|
||||
options.sharedLinkPaths ?? []
|
||||
])
|
||||
sharedLinkPaths: options.sharedLinkPaths ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove untracked entries that are shared symlinks Orca created.
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('./runner', () => ({
|
||||
}))
|
||||
|
||||
import { getUpstreamStatus, invalidateGitUpstreamStatusReads } from './upstream'
|
||||
import { runWithGitReadCacheInvalidation } from './status'
|
||||
import { getGitRepositorySnapshot, runWithGitReadCacheInvalidation } from './status'
|
||||
|
||||
const missingTrackingRefError = new Error(
|
||||
"fatal: ambiguous argument 'HEAD@{u}': unknown revision or path not in the working tree.\n" +
|
||||
@@ -71,6 +71,24 @@ describe('getUpstreamStatus', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes the live native upstream result into the repository snapshot owner', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'main\n' })
|
||||
.mockResolvedValueOnce({ stdout: 'origin/main\n' })
|
||||
.mockResolvedValueOnce({ stdout: '2\t0\n' })
|
||||
|
||||
const result = await getUpstreamStatus('/snapshot-upstream-repo')
|
||||
const snapshot = getGitRepositorySnapshot('/snapshot-upstream-repo')
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
upstream: result,
|
||||
freshness: {
|
||||
status: { state: 'missing' },
|
||||
upstream: { state: 'fresh' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates physical reads by worktree, native or WSL host, and every target field', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
|
||||
if (args[0] === 'symbolic-ref') {
|
||||
|
||||
@@ -5,14 +5,14 @@ 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'
|
||||
import { nativeAndWslGitRepositorySnapshotOwner } from './git-repository-snapshot-owner'
|
||||
|
||||
type GitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
export function invalidateGitUpstreamStatusReads(): void {
|
||||
nativeAndWslGitUpstreamStatusReadOwner.invalidate()
|
||||
nativeAndWslGitRepositorySnapshotOwner.invalidate()
|
||||
}
|
||||
|
||||
function gitExecOptions(
|
||||
@@ -85,7 +85,7 @@ export function getUpstreamStatus(
|
||||
const executionIdentity = options.wslDistro
|
||||
? ({ kind: 'wsl', distro: options.wslDistro } as const)
|
||||
: ({ kind: 'native' } as const)
|
||||
return nativeAndWslGitUpstreamStatusReadOwner.read(
|
||||
return nativeAndWslGitRepositorySnapshotOwner.readUpstream(
|
||||
executionIdentity,
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
|
||||
@@ -1121,6 +1121,75 @@ describe('SshGitProvider', () => {
|
||||
expect(mux.request).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('publishes combined immutable snapshots per provider incarnation', async () => {
|
||||
mux.request.mockImplementation((method) => {
|
||||
if (method === 'git.status') {
|
||||
return Promise.resolve({
|
||||
entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }],
|
||||
conflictOperation: 'rebase',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature'
|
||||
})
|
||||
}
|
||||
if (method === 'git.upstreamStatus') {
|
||||
return Promise.resolve({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/feature',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
})
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
await provider.getStatus('/home/user/repo')
|
||||
await provider.getUpstreamStatus('/home/user/repo')
|
||||
const snapshot = provider.getRepositorySnapshot('/home/user/repo')
|
||||
const replacement = new SshGitProvider('conn-1', mux as never)
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
repositoryIdentity: { head: 'abc123', branch: 'refs/heads/feature' },
|
||||
status: { entries: [{ path: 'src/app.ts' }] },
|
||||
upstream: { upstreamName: 'origin/feature' },
|
||||
conflicts: 'rebase',
|
||||
freshness: {
|
||||
status: { state: 'fresh' },
|
||||
upstream: { state: 'fresh' }
|
||||
}
|
||||
})
|
||||
expect(Object.isFrozen(snapshot)).toBe(true)
|
||||
expect(replacement.getRepositorySnapshot('/home/user/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('marks retained snapshots stale across the existing SSH mutation fence', async () => {
|
||||
mux.request.mockImplementation((method) => {
|
||||
if (method === 'git.status') {
|
||||
return Promise.resolve({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
await provider.getStatus('/home/user/repo')
|
||||
const before = provider.getRepositorySnapshot('/home/user/repo')
|
||||
await provider.stageFile('/home/user/repo', 'src/app.ts')
|
||||
const after = provider.getRepositorySnapshot('/home/user/repo')
|
||||
|
||||
expect(before?.freshness.status.state).toBe('fresh')
|
||||
expect(after?.revision).toBe(before?.revision)
|
||||
expect(after?.freshness.status).toMatchObject({
|
||||
state: 'stale',
|
||||
generation: before!.freshness.status.generation
|
||||
})
|
||||
expect(after!.freshness.status.currentGeneration).toBeGreaterThan(
|
||||
after!.freshness.status.generation
|
||||
)
|
||||
})
|
||||
|
||||
it('getUpstreamStatus forwards an explicit push target', async () => {
|
||||
const upstreamResult = { hasUpstream: true, upstreamName: 'fork/feature', ahead: 0, behind: 1 }
|
||||
mux.request.mockResolvedValue(upstreamResult)
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
} from '../git/max-buffer-overflow'
|
||||
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'
|
||||
import {
|
||||
GitRepositorySnapshotOwner,
|
||||
type GitRepositorySnapshot
|
||||
} from '../git/git-repository-snapshot-owner'
|
||||
|
||||
type NonInteractiveExecQueueEntry = {
|
||||
started: boolean
|
||||
@@ -85,8 +87,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 readonly repositorySnapshotOwner = new GitRepositorySnapshotOwner()
|
||||
|
||||
private connectionId: string
|
||||
private mux: SshChannelMultiplexer
|
||||
@@ -103,8 +104,7 @@ export class SshGitProvider implements IGitProvider {
|
||||
|
||||
private invalidateGitReads(): void {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
this.statusReadLeaseOwner.invalidate()
|
||||
this.upstreamStatusReadOwner.invalidate()
|
||||
this.repositorySnapshotOwner.invalidate()
|
||||
}
|
||||
|
||||
private loggedWorktreeIsCleanFallback = false
|
||||
@@ -142,17 +142,23 @@ export class SshGitProvider implements IGitProvider {
|
||||
...upstreamCacheBypassArgs,
|
||||
...lineStatsReuseArgs
|
||||
}
|
||||
const key = stableInFlightKey([
|
||||
return this.repositorySnapshotOwner.readStatus(
|
||||
{ kind: 'ssh-provider', connectionId: this.connectionId },
|
||||
worktreePath,
|
||||
options?.includeIgnored === true,
|
||||
options?.bypassEffectiveUpstreamNegativeCache === true,
|
||||
options?.reuseLineStats === true
|
||||
])
|
||||
return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => {
|
||||
return (await this.mux.request('git.status', request, {
|
||||
signal: sharedSignal
|
||||
})) as GitStatusResult
|
||||
})
|
||||
{
|
||||
includeIgnored: options?.includeIgnored === true,
|
||||
reuseLineStats: options?.reuseLineStats === true,
|
||||
bypassEffectiveUpstreamNegativeCache:
|
||||
options?.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit: null,
|
||||
sharedLinkPaths: []
|
||||
},
|
||||
options?.signal,
|
||||
async (sharedSignal) =>
|
||||
(await this.mux.request('git.status', request, {
|
||||
signal: sharedSignal
|
||||
})) as GitStatusResult
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmoduleStatus(
|
||||
@@ -510,8 +516,8 @@ export class SshGitProvider implements IGitProvider {
|
||||
worktreePath: string,
|
||||
pushTarget?: GitPushTarget
|
||||
): Promise<GitUpstreamStatus> {
|
||||
return this.upstreamStatusReadOwner.read(
|
||||
{ kind: 'ssh-provider' },
|
||||
return this.repositorySnapshotOwner.readUpstream(
|
||||
{ kind: 'ssh-provider', connectionId: this.connectionId },
|
||||
worktreePath,
|
||||
pushTarget,
|
||||
async () =>
|
||||
@@ -522,6 +528,26 @@ export class SshGitProvider implements IGitProvider {
|
||||
)
|
||||
}
|
||||
|
||||
getRepositorySnapshot(
|
||||
worktreePath: string,
|
||||
options?: GitProviderStatusOptions,
|
||||
pushTarget?: GitPushTarget
|
||||
): GitRepositorySnapshot | null {
|
||||
return this.repositorySnapshotOwner.getSnapshot({
|
||||
executionIdentity: { kind: 'ssh-provider', connectionId: this.connectionId },
|
||||
worktreePath,
|
||||
statusIdentity: {
|
||||
includeIgnored: options?.includeIgnored === true,
|
||||
reuseLineStats: options?.reuseLineStats === true,
|
||||
bypassEffectiveUpstreamNegativeCache:
|
||||
options?.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit: null,
|
||||
sharedLinkPaths: []
|
||||
},
|
||||
pushTarget
|
||||
})
|
||||
}
|
||||
|
||||
async pushBranch(
|
||||
worktreePath: string,
|
||||
publish = false,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Session } from 'node:inspector'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import type { GitStatusEntry, GitStatusResult } from '../../src/shared/types'
|
||||
import { GitStatusReadLeaseOwner } from '../../src/main/git/git-status-read-lease-owner'
|
||||
import {
|
||||
GitRepositorySnapshotOwner,
|
||||
type GitRepositoryStatusIdentity
|
||||
} from '../../src/main/git/git-repository-snapshot-owner'
|
||||
|
||||
type Arm = 'lease-only' | 'snapshot-owner'
|
||||
type Measurement = { cpuMicrosecondsPerRead: number; wallMicrosecondsPerRead: number }
|
||||
type HeapProfileNode = { selfSize: number; children?: HeapProfileNode[] }
|
||||
|
||||
const native = { kind: 'native' } as const
|
||||
const statusIdentity: GitRepositoryStatusIdentity = {
|
||||
includeIgnored: false,
|
||||
reuseLineStats: false,
|
||||
bypassEffectiveUpstreamNegativeCache: false,
|
||||
limit: 1_000,
|
||||
sharedLinkPaths: []
|
||||
}
|
||||
const configurations = [
|
||||
{ entries: 0, timingReads: 10_000, warmupReads: 2_000, allocationReads: 5_000 },
|
||||
{ entries: 100, timingReads: 2_000, warmupReads: 400, allocationReads: 500 },
|
||||
{ entries: 1_000, timingReads: 250, warmupReads: 50, allocationReads: 100 }
|
||||
]
|
||||
const timingRounds = 11
|
||||
const allocationRounds = 7
|
||||
|
||||
function createStatusResult(entryCount: number): GitStatusResult {
|
||||
const entries: GitStatusEntry[] = Array.from({ length: entryCount }, (_, index) => ({
|
||||
path: `src/features/feature-${index}/index.ts`,
|
||||
status: 'modified',
|
||||
area: 'unstaged',
|
||||
added: index % 7,
|
||||
removed: index % 5
|
||||
}))
|
||||
return {
|
||||
entries,
|
||||
conflictOperation: 'unknown',
|
||||
head: '0123456789abcdef',
|
||||
branch: 'main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 2,
|
||||
behind: 3,
|
||||
behindCommitsArePatchEquivalent: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createReader(arm: Arm, result: GitStatusResult): () => Promise<GitStatusResult> {
|
||||
if (arm === 'lease-only') {
|
||||
const owner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
return () => owner.lease('native:/repo', undefined, async () => result)
|
||||
}
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
return () => owner.readStatus(native, '/repo', statusIdentity, undefined, async () => result)
|
||||
}
|
||||
|
||||
async function runReads(read: () => Promise<GitStatusResult>, repetitions: number): Promise<void> {
|
||||
for (let index = 0; index < repetitions; index += 1) {
|
||||
await read()
|
||||
}
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right)
|
||||
return sorted[Math.floor(sorted.length / 2)]
|
||||
}
|
||||
|
||||
async function measureTiming(
|
||||
read: () => Promise<GitStatusResult>,
|
||||
repetitions: number
|
||||
): Promise<Measurement> {
|
||||
const startedCpu = process.cpuUsage()
|
||||
const startedWall = performance.now()
|
||||
await runReads(read, repetitions)
|
||||
const wallMilliseconds = performance.now() - startedWall
|
||||
const cpu = process.cpuUsage(startedCpu)
|
||||
return {
|
||||
cpuMicrosecondsPerRead: (cpu.user + cpu.system) / repetitions,
|
||||
wallMicrosecondsPerRead: (wallMilliseconds * 1_000) / repetitions
|
||||
}
|
||||
}
|
||||
|
||||
function post<T>(session: Session, method: string, parameters?: object): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
session.post(method, parameters, (error, result) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(result as T)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function sumSampledBytes(node: HeapProfileNode): number {
|
||||
return (
|
||||
node.selfSize +
|
||||
(node.children?.reduce((total, child) => total + sumSampledBytes(child), 0) ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
async function measureSampledAllocation(
|
||||
read: () => Promise<GitStatusResult>,
|
||||
repetitions: number
|
||||
): Promise<number> {
|
||||
globalThis.gc?.()
|
||||
const session = new Session()
|
||||
session.connect()
|
||||
try {
|
||||
await post(session, 'HeapProfiler.startSampling', {
|
||||
samplingInterval: 128,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true
|
||||
})
|
||||
await runReads(read, repetitions)
|
||||
const result = await post<{ profile: { head: HeapProfileNode } }>(
|
||||
session,
|
||||
'HeapProfiler.stopSampling'
|
||||
)
|
||||
return sumSampledBytes(result.profile.head) / repetitions
|
||||
} finally {
|
||||
session.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCallerResult(result: GitStatusResult): Promise<object> {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const before = JSON.stringify(result)
|
||||
const returned = await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
statusIdentity,
|
||||
undefined,
|
||||
async () => result
|
||||
)
|
||||
const snapshot = owner.getSnapshot({
|
||||
executionIdentity: native,
|
||||
worktreePath: '/repo',
|
||||
statusIdentity
|
||||
})
|
||||
return {
|
||||
returnedOriginalReference: returned === result,
|
||||
callerResultUnchanged: JSON.stringify(result) === before,
|
||||
callerResultFrozen: Object.isFrozen(result),
|
||||
callerEntriesFrozen: Object.isFrozen(result.entries),
|
||||
snapshotFrozen: Object.isFrozen(snapshot),
|
||||
snapshotEntriesFrozen: Object.isFrozen(snapshot?.status.entries),
|
||||
snapshotEntryCopies: result.entries.every(
|
||||
(entry, index) => snapshot?.status.entries[index] !== entry
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!globalThis.gc) {
|
||||
throw new Error('Run with node --expose-gc --import tsx')
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const configuration of configurations) {
|
||||
const statusResult = createStatusResult(configuration.entries)
|
||||
const readers = {
|
||||
'lease-only': createReader('lease-only', statusResult),
|
||||
'snapshot-owner': createReader('snapshot-owner', statusResult)
|
||||
}
|
||||
await runReads(readers['lease-only'], configuration.warmupReads)
|
||||
await runReads(readers['snapshot-owner'], configuration.warmupReads)
|
||||
|
||||
const timing: Record<Arm, Measurement[]> = {
|
||||
'lease-only': [],
|
||||
'snapshot-owner': []
|
||||
}
|
||||
for (let round = 0; round < timingRounds; round += 1) {
|
||||
const order: Arm[] =
|
||||
round % 2 === 0 ? ['lease-only', 'snapshot-owner'] : ['snapshot-owner', 'lease-only']
|
||||
for (const arm of order) {
|
||||
timing[arm].push(await measureTiming(readers[arm], configuration.timingReads))
|
||||
}
|
||||
}
|
||||
|
||||
const sampledAllocation: Record<Arm, number[]> = {
|
||||
'lease-only': [],
|
||||
'snapshot-owner': []
|
||||
}
|
||||
for (let round = 0; round < allocationRounds; round += 1) {
|
||||
for (const arm of ['lease-only', 'snapshot-owner'] as const) {
|
||||
sampledAllocation[arm].push(
|
||||
await measureSampledAllocation(readers[arm], configuration.allocationReads)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const medianCpu = {
|
||||
'lease-only': median(timing['lease-only'].map((value) => value.cpuMicrosecondsPerRead)),
|
||||
'snapshot-owner': median(
|
||||
timing['snapshot-owner'].map((value) => value.cpuMicrosecondsPerRead)
|
||||
)
|
||||
}
|
||||
const medianWall = {
|
||||
'lease-only': median(timing['lease-only'].map((value) => value.wallMicrosecondsPerRead)),
|
||||
'snapshot-owner': median(
|
||||
timing['snapshot-owner'].map((value) => value.wallMicrosecondsPerRead)
|
||||
)
|
||||
}
|
||||
const medianAllocatedBytes = {
|
||||
'lease-only': median(sampledAllocation['lease-only']),
|
||||
'snapshot-owner': median(sampledAllocation['snapshot-owner'])
|
||||
}
|
||||
results.push({
|
||||
entries: configuration.entries,
|
||||
timingReadsPerRound: configuration.timingReads,
|
||||
timingRounds,
|
||||
allocationReadsPerRound: configuration.allocationReads,
|
||||
allocationRounds,
|
||||
medianCpuMicrosecondsPerRead: medianCpu,
|
||||
incrementalCpuMicrosecondsPerRead: medianCpu['snapshot-owner'] - medianCpu['lease-only'],
|
||||
medianWallMicrosecondsPerRead: medianWall,
|
||||
incrementalWallMicrosecondsPerRead: medianWall['snapshot-owner'] - medianWall['lease-only'],
|
||||
medianSampledAllocatedBytesPerRead: medianAllocatedBytes,
|
||||
incrementalSampledAllocatedBytesPerRead:
|
||||
medianAllocatedBytes['snapshot-owner'] - medianAllocatedBytes['lease-only'],
|
||||
immutablePublicationOperations: {
|
||||
entryCopies: configuration.entries,
|
||||
objectFreezeCalls: configuration.entries + 6
|
||||
},
|
||||
callerVerification: await verifyCallerResult(statusResult)
|
||||
})
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
void main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
# Phase 2 Git repository snapshot owner — 2026-07-30
|
||||
|
||||
## Decision
|
||||
|
||||
Retain the first live host-scoped, versioned Git repository snapshot owner. Native, exact-distro
|
||||
WSL, and each `SshGitProvider` incarnation now route both status and upstream reads through this
|
||||
owner while current APIs continue returning fresh post-settlement work. No TTL result reuse, Git
|
||||
command, SSH RPC method, payload, renderer subscription, or new RPC was added.
|
||||
|
||||
Checkpoint: `b680c59da6d28767af9ae3cd5592ca8b6038db0d`.
|
||||
|
||||
## Live ownership
|
||||
|
||||
`GitRepositorySnapshotOwner` now owns the existing cancellation-safe status leases, upstream
|
||||
in-flight sharing, mutation generations, and bounded snapshot publication. The native/WSL process
|
||||
uses one host-keyed owner; each SSH provider constructs its own owner, and the key also includes
|
||||
its connection id. A replacement provider therefore begins with no retained snapshot even when it
|
||||
reuses the same connection id.
|
||||
|
||||
Status identities include:
|
||||
|
||||
- native versus the exact WSL distro or the per-instance SSH provider;
|
||||
- worktree path;
|
||||
- `includeIgnored`;
|
||||
- `reuseLineStats`;
|
||||
- effective-upstream negative-cache bypass;
|
||||
- resolved local status limit; and
|
||||
- ordered shared-link paths.
|
||||
|
||||
Upstream identities include the same execution host and worktree scope, configured-upstream versus
|
||||
explicit-target mode, and every `GitPushTarget` field: `remoteName`, `branchName`, `remoteUrl`, and
|
||||
absent versus explicit `remoteCreated: false`.
|
||||
|
||||
## Snapshot projection
|
||||
|
||||
Each immutable projection contains:
|
||||
|
||||
- monotonic process-owner revision and generation timestamp;
|
||||
- repository head and branch identity;
|
||||
- immutable status entries, ignored paths, limit state, status length, line-stat state, and
|
||||
retention-truncation state;
|
||||
- configured or explicit-target upstream status, including patch-equivalence fields;
|
||||
- conflict operation;
|
||||
- worktree graph version placeholder `0`; and
|
||||
- per-projection `missing`, `fresh`, `stale`, `failed`, or `placeholder` state with publication
|
||||
generation, current generation, revision, and exact projection identity.
|
||||
|
||||
A status result's embedded configured upstream can populate the combined snapshot without another
|
||||
command. A later explicit upstream read updates only its exact target projection. For configured
|
||||
upstream, the newest direct or status-embedded projection wins, so a fresh post-invalidation status
|
||||
cannot be shadowed by a stale direct read.
|
||||
|
||||
The existing pre/post mutation wrappers increment the owner generation and clear only joinability.
|
||||
Already-issued caller promises may settle, but their captured record identity and generation must
|
||||
still match before they can publish. Reads begun before or during a mutation therefore cannot
|
||||
repopulate the current snapshot after either fence.
|
||||
|
||||
## Retention bounds
|
||||
|
||||
The owner retains at most:
|
||||
|
||||
- 64 repository/worktree scopes;
|
||||
- four status variants and four upstream variants per scope;
|
||||
- 1,000 status entries and 1,000 ignored paths per status projection; and
|
||||
- 64 Ki code units across retained status and ignored paths.
|
||||
|
||||
Current status callers still receive their complete original result. If snapshot retention trims a
|
||||
limit-disabled or unusually large result, `retentionTruncated` is true. Repository and projection
|
||||
maps use deterministic insertion eviction; publication also requires the exact retained record
|
||||
object, so a late completion from an evicted identity cannot publish into a newly created record
|
||||
with the same path.
|
||||
|
||||
## A/B physical counts
|
||||
|
||||
Opt-in deterministic benchmarks issue ten concurrent identical calls against the mocked physical
|
||||
Git and mux boundaries.
|
||||
|
||||
| Boundary | A — checkpoint | B — snapshot owner |
|
||||
| ---------------------------------------- | -------------: | -----------------: |
|
||||
| Local status, unsignalled callers | 1 | 1 |
|
||||
| Local status, distinct-signalled callers | 1 | 1 |
|
||||
| Local configured-upstream Git calls | 4 | 4 |
|
||||
| SSH status, unsignalled callers | 1 | 1 |
|
||||
| SSH status, distinct-signalled callers | 1 | 1 |
|
||||
| SSH upstream RPCs | 1 | 1 |
|
||||
|
||||
The exact local status command remains:
|
||||
|
||||
```text
|
||||
git -c core.quotePath=false status --porcelain=v2 --branch --untracked-files=all
|
||||
```
|
||||
|
||||
The configured-upstream chain remains:
|
||||
|
||||
```text
|
||||
git symbolic-ref --quiet --short HEAD
|
||||
git rev-parse --abbrev-ref HEAD@{u}
|
||||
git rev-list --left-right --count HEAD...origin/main
|
||||
git log --oneline --cherry-mark --right-only HEAD...origin/main --
|
||||
```
|
||||
|
||||
SSH methods and default payloads remain exactly:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"method": "git.status",
|
||||
"payload": { "worktreePath": "/home/user/repo" }
|
||||
},
|
||||
{
|
||||
"method": "git.upstreamStatus",
|
||||
"payload": { "worktreePath": "/home/user/repo" }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
True status options and explicit targets retain their existing fields; false or absent status
|
||||
options and absent `pushTarget` remain omitted. Baseline artifacts are
|
||||
`/tmp/git-snapshot-status-a.json`, `/tmp/git-snapshot-upstream-a.json`,
|
||||
`/tmp/git-snapshot-ssh-status-a.json`, and `/tmp/git-snapshot-ssh-upstream-a.json`; retained
|
||||
artifacts use the corresponding `-b.json` names.
|
||||
|
||||
## Publication hot-path retention audit
|
||||
|
||||
The deterministic publication benchmark compares sequential, already-settled
|
||||
`GitStatusReadLeaseOwner` reads with `GitRepositorySnapshotOwner.readStatus` reads returning the
|
||||
same representative result. It therefore isolates owner admission, immutable bounded copying,
|
||||
freezing, and publication without measuring Git subprocess or parsing time. The retained harness
|
||||
is `tools/benchmarks/git-repository-snapshot-owner-publication-bench.ts` and runs with:
|
||||
|
||||
```text
|
||||
node --expose-gc --import tsx tools/benchmarks/git-repository-snapshot-owner-publication-bench.ts
|
||||
```
|
||||
|
||||
On Node 24.18.0, Darwin arm64, Apple M4 Max, each arm received 2,000 / 400 / 50 warmup reads for
|
||||
empty / 100-entry / 1,000-entry payloads. Timing then used 11 alternating rounds of 10,000 / 2,000
|
||||
/ 250 reads per arm; allocation used seven rounds of 5,000 / 500 / 100 reads per arm with V8 heap
|
||||
sampling at 128-byte intervals and an explicit GC before each profile. The table is the median of
|
||||
the per-process medians from four independent process runs:
|
||||
|
||||
| Status entries | Lease CPU µs/read | Snapshot CPU µs/read | Incremental CPU µs/read | Incremental wall µs/read | Incremental sampled allocation/read |
|
||||
| -------------: | ----------------: | -------------------: | ----------------------: | -----------------------: | ----------------------------------: |
|
||||
| 0 | 0.455 | 1.198 | +0.743 | +0.683 | +1,612 bytes |
|
||||
| 100 | 0.473 | 4.027 | +3.554 | +3.501 | +15,971 bytes |
|
||||
| 1,000 | 0.510 | 29.464 | +28.954 | +28.485 | +151,086 bytes |
|
||||
|
||||
Incremental CPU ranges across the four processes were 0.710–0.804, 3.505–3.586, and
|
||||
28.116–29.652 µs/read respectively. V8 sampling measures transient allocation rather than retained
|
||||
heap; the exact immutable work is zero / 100 / 1,000 entry copies and six / 106 / 1,006
|
||||
`Object.freeze` calls. The owner replaces the prior retained projection after each settled read,
|
||||
so these temporary copies do not escape the existing one-projection-per-identity bound.
|
||||
|
||||
Every size returned the exact original `GitStatusResult` reference. JSON content and caller-owned
|
||||
freeze state remained unchanged, while the snapshot, its status collection, and every retained
|
||||
entry copy were separately frozen. This directly verifies that publication does not mutate or
|
||||
substitute the current caller result.
|
||||
|
||||
No implementation optimization was retained. The 0.7 µs empty-result delta is too small to
|
||||
justify conditional empty-array paths, and copying plus deep freezing are the mechanisms that keep
|
||||
caller mutation out of retained snapshots. Deferring those operations until `getSnapshot` would
|
||||
weaken the owner's uniformly immutable publication contract, while lazily loading query/freshness
|
||||
code would make the synchronous exported live seam unavailable. The 10,250 raw / 2,043 gzip main
|
||||
cost is fully main-owned; the former specialized upstream owner is removed, and preload plus
|
||||
renderer remain byte-identical. At 29 µs for the 1,000-entry bound, publication is proportionate to
|
||||
the process/RPC Git work and parsing it follows, without introducing a TTL cache or another
|
||||
physical read.
|
||||
|
||||
## Production A/B
|
||||
|
||||
Fresh production builds were archived outside the worktree:
|
||||
|
||||
- A: `/tmp/orca-phase2-git-repository-snapshot-a.yuLlk5/out`
|
||||
- B: `/tmp/orca-phase2-git-repository-snapshot-b-final.75XkBW/out`
|
||||
|
||||
| Artifact | A raw / gzip | B raw / gzip | Change | A SHA-256 | B SHA-256 |
|
||||
| ---------------------- | ----------------- | ----------------- | ---------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Electron main entry | 779,267 / 174,769 | 789,517 / 176,812 | +10,250 / +2,043 | `64526204d377f8ca634de5940d77b3f2eff1421bd7a8e7346b0ffcc189be21da` | `6413904f6420882168b8993c1240614bacde1de92dfb26a55c4db67c84007b8e` |
|
||||
| Electron preload entry | 130,798 / 20,642 | 130,798 / 20,642 | 0 / 0 | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` | `c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f` |
|
||||
|
||||
The unchanged Electron-main raw budget is 825,109 bytes; B retains 35,592 bytes of headroom. File
|
||||
counts remain 184 main, one preload, and 787 renderer artifacts.
|
||||
|
||||
| Tree | A SHA-256 | B SHA-256 |
|
||||
| -------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Main | `7f6b86e1b736f43d8d259b4ff90f53dd78b0de7ab516dd0f54edf931034bfe2e` | `619e5977034ec79f679489666a826205500de88f51af39c3a3ccf99fbb68874c` |
|
||||
| Preload | `06584e5cc8835554c7e0897efe5c578a9f66c3049033b2a3b79b1ca9a88462be` | `06584e5cc8835554c7e0897efe5c578a9f66c3049033b2a3b79b1ca9a88462be` |
|
||||
| Renderer | `5f6156236d93a03256beb23df69ef705a78dd54a1290c58cc4330fbe8cc34e74` | `5f6156236d93a03256beb23df69ef705a78dd54a1290c58cc4330fbe8cc34e74` |
|
||||
|
||||
Preload and every renderer artifact are byte-identical. The renderer manifest is byte-identical
|
||||
with SHA-256 `92ad617f8c8aee6d4c4a23622f524266cbe9c93b3fe65544d1acb833dc3f0d67`.
|
||||
Complete A and B validation found 778 manifest records, three HTML entries, 6,247 static edges, 213
|
||||
dynamic edges, and 860 emitted references, with zero missing or escaping targets, zero cross-entry
|
||||
imports, and zero cycles.
|
||||
|
||||
Renderer entry measurements remain 8,416,540 raw / 1,877,823 gzip for index, 4,507,253 /
|
||||
984,615 for popout, and 4,360,652 / 928,355 for web. JS/CSS counts remain 292/2, 77/2, and 33/1.
|
||||
|
||||
## Validation
|
||||
|
||||
- Current-behavior baseline suite: 211 passed.
|
||||
- Final focused owner/status/upstream/provider suite: 228 passed.
|
||||
- Physical-count candidate suite: 215 passed.
|
||||
- Publication benchmark: four independent process runs; caller identity/immutability checks passed
|
||||
for empty, 100-entry, and 1,000-entry results.
|
||||
- Post-audit focused lease/snapshot-owner suite: 14 passed.
|
||||
- Broad owner, status/cache, native/WSL, upstream, remote, SSH provider/dispatch/session,
|
||||
filesystem mutation, and runtime-routing suite: 536 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.
|
||||
- Fresh post-audit production build reproduced B exactly: main 789,517 raw / 176,812 gzip with
|
||||
SHA-256 `6413904f6420882168b8993c1240614bacde1de92dfb26a55c4db67c84007b8e`, preload
|
||||
130,798 / 20,642 with SHA-256
|
||||
`c388a39cdca9609760e286d95b87ad1e53793720450e507f830ff1f6c5bd259f`, and renderer
|
||||
manifest SHA-256 `92ad617f8c8aee6d4c4a23622f524266cbe9c93b3fe65544d1acb833dc3f0d67`.
|
||||
- `pnpm run check:electron-bundle-budgets`: passed against fresh B.
|
||||
- Complete renderer manifest/path and entry-cycle validation: passed for A and B.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
## Next seam and limitations
|
||||
|
||||
The next slice can migrate Checks and active polling to the exported snapshot seam, then add
|
||||
revision subscriptions and runtime/mobile projection transport. This tranche intentionally leaves
|
||||
those consumers and all RPC surfaces unchanged.
|
||||
|
||||
- Revisions and generations are process-local and reset with the host owner.
|
||||
- Local mutation invalidation remains intentionally coarse across the native/WSL owner; it may
|
||||
mark unrelated retained projections stale, but it cannot merge identities or return cached data
|
||||
through current APIs.
|
||||
- Snapshot status retention may be a bounded prefix when the current caller explicitly disables
|
||||
the Git status limit; current callers still receive the complete fresh result.
|
||||
- Counts use mocked physical boundaries and do not measure live subprocess, WSL, or SSH latency.
|
||||
- No packaged cross-platform or live SSH smoke was run.
|
||||
Reference in New Issue
Block a user