mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
perf: subscribe checks to repository revisions
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import type { GitPushTarget, GitStatusResult, GitUpstreamStatus } from '../../shared/types'
|
||||
import type { GitRepositorySnapshotRevisionEvent } from '../../shared/git-repository-snapshot'
|
||||
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,
|
||||
@@ -13,9 +11,13 @@ import {
|
||||
type GitRepositoryExecutionIdentity,
|
||||
type GitRepositorySnapshot,
|
||||
type GitRepositorySnapshotQuery,
|
||||
type GitRepositoryStatusIdentity,
|
||||
type GitRepositoryStatusProjection
|
||||
type GitRepositoryStatusIdentity
|
||||
} from './git-repository-snapshot-projection'
|
||||
import {
|
||||
GitRepositorySnapshotRevisionSubscriptions,
|
||||
type GitRepositoryRecord
|
||||
} from './git-repository-snapshot-revision-subscriptions'
|
||||
import { createGitRepositorySnapshot } from './git-repository-snapshot-reader'
|
||||
|
||||
export type {
|
||||
GitRepositoryExecutionIdentity,
|
||||
@@ -28,19 +30,6 @@ export type {
|
||||
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
|
||||
}
|
||||
@@ -48,7 +37,8 @@ function clampBound(value: number, maximum: number): number {
|
||||
export class GitRepositorySnapshotOwner {
|
||||
private readonly statusReads = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
private readonly upstreamReads = new InFlightPromiseDedupe<GitUpstreamStatus>()
|
||||
private readonly repositories = new Map<string, RepositoryRecord>()
|
||||
private readonly repositories = new Map<string, GitRepositoryRecord>()
|
||||
private readonly revisionSubscriptions = new GitRepositorySnapshotRevisionSubscriptions()
|
||||
private readonly maxRepositories: number
|
||||
private readonly maxProjectionVariants: number
|
||||
private generation = 0
|
||||
@@ -141,72 +131,31 @@ export class GitRepositorySnapshotOwner {
|
||||
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,
|
||||
return createGitRepositorySnapshot(
|
||||
repository,
|
||||
this.generation,
|
||||
statusIdentity
|
||||
statusIdentity,
|
||||
upstreamIdentity,
|
||||
query.pushTarget === undefined
|
||||
)
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(
|
||||
query: GitRepositorySnapshotQuery,
|
||||
listener: (event: GitRepositorySnapshotRevisionEvent) => void
|
||||
): () => void {
|
||||
return this.revisionSubscriptions.subscribe(query, listener)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.generation += 1
|
||||
this.statusReads.invalidate()
|
||||
this.upstreamReads.clear()
|
||||
this.revisionSubscriptions.invalidate(this.generation, this.revision)
|
||||
}
|
||||
|
||||
getSubscriptionCountForTests(): number {
|
||||
return this.revisionSubscriptions.count
|
||||
}
|
||||
|
||||
getRetentionState(): Readonly<{
|
||||
@@ -227,7 +176,7 @@ export class GitRepositorySnapshotOwner {
|
||||
})
|
||||
}
|
||||
|
||||
private getOrCreateRepository(repositoryKey: string): RepositoryRecord {
|
||||
private getOrCreateRepository(repositoryKey: string): GitRepositoryRecord {
|
||||
const existing = this.repositories.get(repositoryKey)
|
||||
if (existing) {
|
||||
this.touchRepository(repositoryKey, existing)
|
||||
@@ -245,12 +194,12 @@ export class GitRepositorySnapshotOwner {
|
||||
return repository
|
||||
}
|
||||
|
||||
private touchRepository(repositoryKey: string, repository: RepositoryRecord): void {
|
||||
private touchRepository(repositoryKey: string, repository: GitRepositoryRecord): void {
|
||||
this.repositories.delete(repositoryKey)
|
||||
this.repositories.set(repositoryKey, repository)
|
||||
}
|
||||
|
||||
private removeEmptyRepository(repositoryKey: string, repository: RepositoryRecord): void {
|
||||
private removeEmptyRepository(repositoryKey: string, repository: GitRepositoryRecord): void {
|
||||
if (
|
||||
this.repositories.get(repositoryKey) === repository &&
|
||||
repository.status.size === 0 &&
|
||||
@@ -262,7 +211,7 @@ export class GitRepositorySnapshotOwner {
|
||||
|
||||
private publishStatus(
|
||||
repositoryKey: string,
|
||||
repository: RepositoryRecord,
|
||||
repository: GitRepositoryRecord,
|
||||
projectionKey: string,
|
||||
generation: number,
|
||||
result: GitStatusResult
|
||||
@@ -270,6 +219,7 @@ export class GitRepositorySnapshotOwner {
|
||||
if (generation !== this.generation || this.repositories.get(repositoryKey) !== repository) {
|
||||
return
|
||||
}
|
||||
const previous = repository.status.get(projectionKey)
|
||||
const revision = this.nextRevision()
|
||||
const generatedAt = this.now()
|
||||
this.remember(repository.status, projectionKey, {
|
||||
@@ -278,11 +228,18 @@ export class GitRepositorySnapshotOwner {
|
||||
revision,
|
||||
value: freezeGitRepositoryStatus(result)
|
||||
})
|
||||
this.revisionSubscriptions.statusPublished(
|
||||
repositoryKey,
|
||||
repository,
|
||||
projectionKey,
|
||||
previous,
|
||||
this.generation
|
||||
)
|
||||
}
|
||||
|
||||
private publishUpstream(
|
||||
repositoryKey: string,
|
||||
repository: RepositoryRecord,
|
||||
repository: GitRepositoryRecord,
|
||||
projectionKey: string,
|
||||
generation: number,
|
||||
result: GitUpstreamStatus
|
||||
@@ -296,6 +253,12 @@ export class GitRepositorySnapshotOwner {
|
||||
revision: this.nextRevision(),
|
||||
value: freezeGitRepositoryUpstream(result)
|
||||
})
|
||||
this.revisionSubscriptions.upstreamPublished(
|
||||
repositoryKey,
|
||||
repository,
|
||||
projectionKey,
|
||||
this.generation
|
||||
)
|
||||
}
|
||||
|
||||
private remember<T>(projections: Map<string, T>, key: string, value: T): void {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { GitRepositorySnapshot } from '../../shared/git-repository-snapshot'
|
||||
import {
|
||||
createGitRepositoryProjectionFreshness,
|
||||
EMPTY_GIT_IGNORED_PATHS,
|
||||
EMPTY_GIT_STATUS_ENTRIES
|
||||
} from './git-repository-snapshot-projection'
|
||||
import type { GitRepositoryRecord } from './git-repository-snapshot-revision-subscriptions'
|
||||
|
||||
export function createGitRepositorySnapshot(
|
||||
repository: GitRepositoryRecord,
|
||||
generation: number,
|
||||
statusIdentity: string,
|
||||
upstreamIdentity: string,
|
||||
usesConfiguredUpstream: boolean
|
||||
): GitRepositorySnapshot | null {
|
||||
const statusRecord = repository.status.get(statusIdentity)
|
||||
const upstreamRecord = repository.upstream.get(upstreamIdentity)
|
||||
if (!statusRecord && !upstreamRecord) {
|
||||
return null
|
||||
}
|
||||
|
||||
const statusFreshness = createGitRepositoryProjectionFreshness(
|
||||
statusRecord,
|
||||
generation,
|
||||
statusIdentity
|
||||
)
|
||||
const embeddedUpstreamRecord =
|
||||
usesConfiguredUpstream && statusRecord?.value.upstream ? statusRecord : undefined
|
||||
const useEmbeddedUpstream =
|
||||
embeddedUpstreamRecord &&
|
||||
(!upstreamRecord || embeddedUpstreamRecord.revision > upstreamRecord.revision)
|
||||
const selectedUpstreamRecord = useEmbeddedUpstream ? embeddedUpstreamRecord : upstreamRecord
|
||||
const upstreamFreshness = selectedUpstreamRecord
|
||||
? createGitRepositoryProjectionFreshness(
|
||||
selectedUpstreamRecord,
|
||||
generation,
|
||||
useEmbeddedUpstream ? `status:${statusIdentity}` : upstreamIdentity
|
||||
)
|
||||
: createGitRepositoryProjectionFreshness(undefined, 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,
|
||||
currentGeneration: generation,
|
||||
revision: null,
|
||||
identity: null
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { GitRepositorySnapshotRevisionEvent } from '../../shared/git-repository-snapshot'
|
||||
import type { GitUpstreamStatus } from '../../shared/types'
|
||||
import type { GitRepositoryStatusProjection } from './git-repository-snapshot-projection'
|
||||
import {
|
||||
gitRepositoryScopeKey,
|
||||
gitRepositoryStatusKey,
|
||||
gitRepositoryUpstreamKey,
|
||||
type GitRepositorySnapshotQuery
|
||||
} from './git-repository-snapshot-projection'
|
||||
|
||||
export type GitRepositoryProjectionRecord<T> = {
|
||||
generation: number
|
||||
generatedAt: number
|
||||
revision: number
|
||||
value: T
|
||||
failedGeneration?: number
|
||||
}
|
||||
|
||||
export type GitRepositoryRecord = {
|
||||
status: Map<string, GitRepositoryProjectionRecord<GitRepositoryStatusProjection>>
|
||||
upstream: Map<string, GitRepositoryProjectionRecord<Readonly<GitUpstreamStatus>>>
|
||||
}
|
||||
|
||||
type SnapshotSubscription = {
|
||||
statusKey: string
|
||||
upstreamKey: string
|
||||
usesConfiguredUpstream: boolean
|
||||
lastReadyRevision: number
|
||||
listener: (event: GitRepositorySnapshotRevisionEvent) => void
|
||||
}
|
||||
|
||||
export class GitRepositorySnapshotRevisionSubscriptions {
|
||||
private readonly subscriptions = new Map<string, Set<SnapshotSubscription>>()
|
||||
|
||||
subscribe(
|
||||
query: GitRepositorySnapshotQuery,
|
||||
listener: (event: GitRepositorySnapshotRevisionEvent) => void
|
||||
): () => void {
|
||||
const repositoryKey = gitRepositoryScopeKey(query.executionIdentity, query.worktreePath)
|
||||
const subscription: SnapshotSubscription = {
|
||||
statusKey: gitRepositoryStatusKey(query.statusIdentity),
|
||||
upstreamKey: gitRepositoryUpstreamKey(query.pushTarget),
|
||||
usesConfiguredUpstream: query.pushTarget === undefined,
|
||||
lastReadyRevision: 0,
|
||||
listener
|
||||
}
|
||||
const subscriptions = this.subscriptions.get(repositoryKey) ?? new Set()
|
||||
subscriptions.add(subscription)
|
||||
this.subscriptions.set(repositoryKey, subscriptions)
|
||||
return () => {
|
||||
subscriptions.delete(subscription)
|
||||
if (subscriptions.size === 0) {
|
||||
this.subscriptions.delete(repositoryKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(generation: number, revision: number): void {
|
||||
const event = Object.freeze({ state: 'invalidated' as const, generation, revision })
|
||||
for (const subscriptions of this.subscriptions.values()) {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.listener(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statusPublished(
|
||||
repositoryKey: string,
|
||||
repository: GitRepositoryRecord,
|
||||
projectionKey: string,
|
||||
previous: GitRepositoryProjectionRecord<GitRepositoryStatusProjection> | undefined,
|
||||
generation: number
|
||||
): void {
|
||||
const subscriptions = this.subscriptions.get(repositoryKey)
|
||||
const status = repository.status.get(projectionKey)
|
||||
if (!subscriptions || !status || !this.statusRecordIsReady(status, generation)) {
|
||||
return
|
||||
}
|
||||
const events = new Map<number, GitRepositorySnapshotRevisionEvent>()
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.statusKey !== projectionKey) {
|
||||
continue
|
||||
}
|
||||
if (subscription.usesConfiguredUpstream) {
|
||||
const embedded = status.value.upstream
|
||||
if (embedded) {
|
||||
if (this.upstreamValueIsComplete(embedded)) {
|
||||
this.emitReady(subscription, generation, status.revision, events)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
const upstream = repository.upstream.get(subscription.upstreamKey)
|
||||
if (
|
||||
!this.upstreamRecordIsReady(upstream, generation) ||
|
||||
!this.upstreamValueIsComplete(upstream.value) ||
|
||||
(upstream.revision <= status.revision &&
|
||||
!this.repositoryIdentityMatches(previous?.value, status.value))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.emitReady(subscription, generation, Math.max(status.revision, upstream.revision), events)
|
||||
}
|
||||
}
|
||||
|
||||
upstreamPublished(
|
||||
repositoryKey: string,
|
||||
repository: GitRepositoryRecord,
|
||||
projectionKey: string,
|
||||
generation: number
|
||||
): void {
|
||||
const subscriptions = this.subscriptions.get(repositoryKey)
|
||||
const upstream = repository.upstream.get(projectionKey)
|
||||
if (
|
||||
!subscriptions ||
|
||||
!this.upstreamRecordIsReady(upstream, generation) ||
|
||||
!this.upstreamValueIsComplete(upstream.value)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const events = new Map<number, GitRepositorySnapshotRevisionEvent>()
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.upstreamKey !== projectionKey) {
|
||||
continue
|
||||
}
|
||||
const status = repository.status.get(subscription.statusKey)
|
||||
if (!status || !this.statusRecordIsReady(status, generation)) {
|
||||
continue
|
||||
}
|
||||
this.emitReady(subscription, generation, Math.max(status.revision, upstream.revision), events)
|
||||
}
|
||||
}
|
||||
|
||||
get count(): number {
|
||||
let count = 0
|
||||
for (const subscriptions of this.subscriptions.values()) {
|
||||
count += subscriptions.size
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private statusRecordIsReady(
|
||||
record: GitRepositoryProjectionRecord<GitRepositoryStatusProjection>,
|
||||
generation: number
|
||||
): boolean {
|
||||
return (
|
||||
record.generation === generation &&
|
||||
record.failedGeneration !== generation &&
|
||||
!record.value.status.retentionTruncated
|
||||
)
|
||||
}
|
||||
|
||||
private upstreamRecordIsReady(
|
||||
record: GitRepositoryProjectionRecord<Readonly<GitUpstreamStatus>> | undefined,
|
||||
generation: number
|
||||
): record is GitRepositoryProjectionRecord<Readonly<GitUpstreamStatus>> {
|
||||
return (
|
||||
record !== undefined &&
|
||||
record.generation === generation &&
|
||||
record.failedGeneration !== generation
|
||||
)
|
||||
}
|
||||
|
||||
private upstreamValueIsComplete(value: Readonly<GitUpstreamStatus>): boolean {
|
||||
return !(
|
||||
value.ahead > 0 &&
|
||||
value.behind > 0 &&
|
||||
value.behindCommitsArePatchEquivalent === undefined
|
||||
)
|
||||
}
|
||||
|
||||
private repositoryIdentityMatches(
|
||||
previous: GitRepositoryStatusProjection | undefined,
|
||||
current: GitRepositoryStatusProjection
|
||||
): boolean {
|
||||
return (
|
||||
previous !== undefined &&
|
||||
previous.repositoryIdentity.head === current.repositoryIdentity.head &&
|
||||
previous.repositoryIdentity.branch === current.repositoryIdentity.branch
|
||||
)
|
||||
}
|
||||
|
||||
private emitReady(
|
||||
subscription: SnapshotSubscription,
|
||||
generation: number,
|
||||
revision: number,
|
||||
events: Map<number, GitRepositorySnapshotRevisionEvent>
|
||||
): void {
|
||||
if (revision <= subscription.lastReadyRevision) {
|
||||
return
|
||||
}
|
||||
subscription.lastReadyRevision = revision
|
||||
const event =
|
||||
events.get(revision) ?? Object.freeze({ state: 'ready' as const, generation, revision })
|
||||
events.set(revision, event)
|
||||
subscription.listener(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitStatusResult } from '../../shared/types'
|
||||
import type { GitRepositorySnapshotRevisionEvent } from '../../shared/git-repository-snapshot'
|
||||
import {
|
||||
GitRepositorySnapshotOwner,
|
||||
type GitRepositoryExecutionIdentity,
|
||||
type GitRepositoryStatusIdentity
|
||||
} from './git-repository-snapshot-owner'
|
||||
|
||||
const native = { kind: 'native' } as const
|
||||
const defaultStatusIdentity: GitRepositoryStatusIdentity = {
|
||||
includeIgnored: false,
|
||||
reuseLineStats: false,
|
||||
bypassEffectiveUpstreamNegativeCache: false,
|
||||
limit: 1_000,
|
||||
sharedLinkPaths: []
|
||||
}
|
||||
const statusResult: GitStatusResult = {
|
||||
entries: [],
|
||||
conflictOperation: 'unknown',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}
|
||||
}
|
||||
const explicitTarget = {
|
||||
remoteName: 'fork',
|
||||
branchName: 'feature',
|
||||
remoteUrl: 'ssh://git.example/repo',
|
||||
remoteCreated: false
|
||||
}
|
||||
|
||||
function query(
|
||||
executionIdentity: GitRepositoryExecutionIdentity = native,
|
||||
worktreePath = '/repo',
|
||||
statusIdentity: GitRepositoryStatusIdentity = defaultStatusIdentity,
|
||||
pushTarget = explicitTarget
|
||||
) {
|
||||
return { executionIdentity, worktreePath, statusIdentity, pushTarget }
|
||||
}
|
||||
|
||||
describe('GitRepositorySnapshotOwner revision subscriptions', () => {
|
||||
it('publishes configured-upstream readiness without a second upstream read', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
owner.subscribe({ ...query(), pushTarget: undefined }, (event) => events.push(event))
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
|
||||
expect(events).toEqual([{ state: 'ready', generation: 0, revision: 1 }])
|
||||
})
|
||||
|
||||
it('waits for a complete explicit upstream projection before publishing readiness', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
owner.subscribe(query(), (event) => events.push(event))
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
expect(events).toEqual([])
|
||||
|
||||
await owner.readUpstream(native, '/repo', explicitTarget, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 1,
|
||||
behind: 1
|
||||
}))
|
||||
expect(events).toEqual([])
|
||||
|
||||
await owner.readUpstream(native, '/repo', explicitTarget, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 1,
|
||||
behind: 1,
|
||||
behindCommitsArePatchEquivalent: false
|
||||
}))
|
||||
expect(events).toEqual([{ state: 'ready', generation: 0, revision: 3 }])
|
||||
})
|
||||
|
||||
it('does not admit an ambiguous embedded upstream through an older complete projection', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
const configuredQuery = { ...query(), pushTarget: undefined }
|
||||
owner.subscribe(configuredQuery, (event) => events.push(event))
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
await owner.readUpstream(native, '/repo', undefined, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 0
|
||||
}))
|
||||
events.length = 0
|
||||
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
upstreamStatus: {
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 1
|
||||
}
|
||||
}))
|
||||
|
||||
expect(events).toEqual([])
|
||||
expect(owner.getSnapshot(configuredQuery)?.upstream).toEqual({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'origin/main',
|
||||
ahead: 1,
|
||||
behind: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps overlapping producer and revision-driven consumer demand at one physical read each', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const statusLoad = vi.fn(async () => statusResult)
|
||||
const upstreamLoad = vi.fn(async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
const snapshots: unknown[] = []
|
||||
owner.subscribe(query(), (event) => {
|
||||
if (event.state === 'ready') {
|
||||
snapshots.push(owner.getSnapshot(query()))
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad),
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, statusLoad)
|
||||
])
|
||||
await Promise.all([
|
||||
owner.readUpstream(native, '/repo', explicitTarget, upstreamLoad),
|
||||
owner.readUpstream(native, '/repo', explicitTarget, upstreamLoad)
|
||||
])
|
||||
|
||||
expect(statusLoad).toHaveBeenCalledOnce()
|
||||
expect(upstreamLoad).toHaveBeenCalledOnce()
|
||||
expect(snapshots).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reuses a retained explicit upstream only while repository identity is unchanged', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
owner.subscribe(query(), (event) => events.push(event))
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
await owner.readUpstream(native, '/repo', explicitTarget, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
events.length = 0
|
||||
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
entries: [{ path: 'src/app.ts', status: 'modified', area: 'unstaged' }]
|
||||
}))
|
||||
expect(events).toEqual([{ state: 'ready', generation: 0, revision: 3 }])
|
||||
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/other'
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
await owner.readUpstream(native, '/repo', explicitTarget, async () => ({
|
||||
hasUpstream: true,
|
||||
upstreamName: 'fork/feature',
|
||||
ahead: 2,
|
||||
behind: 0
|
||||
}))
|
||||
expect(events.at(-1)).toEqual({ state: 'ready', generation: 0, revision: 5 })
|
||||
})
|
||||
|
||||
it('reports both mutation fences and suppresses stale completion readiness', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
let resolveStatus!: (result: GitStatusResult) => void
|
||||
const pending = new Promise<GitStatusResult>((resolve) => {
|
||||
resolveStatus = resolve
|
||||
})
|
||||
owner.subscribe({ ...query(), pushTarget: undefined }, (event) => events.push(event))
|
||||
const stale = owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, () => pending)
|
||||
|
||||
owner.invalidate()
|
||||
resolveStatus(statusResult)
|
||||
await stale
|
||||
owner.invalidate()
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ state: 'invalidated', generation: 1, revision: 0 },
|
||||
{ state: 'invalidated', generation: 2, revision: 0 },
|
||||
{ state: 'ready', generation: 2, revision: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('isolates exact host, path, status-option, and target subscriptions', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const matching = vi.fn()
|
||||
const listeners = [
|
||||
owner.subscribe(query(), matching),
|
||||
owner.subscribe(query({ kind: 'wsl', distro: 'Ubuntu' }), vi.fn()),
|
||||
owner.subscribe(query(native, '/other'), vi.fn()),
|
||||
owner.subscribe(
|
||||
query(native, '/repo', { ...defaultStatusIdentity, reuseLineStats: true }),
|
||||
vi.fn()
|
||||
),
|
||||
owner.subscribe(
|
||||
query(native, '/repo', defaultStatusIdentity, { ...explicitTarget, remoteCreated: true }),
|
||||
vi.fn()
|
||||
)
|
||||
]
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
await owner.readUpstream(native, '/repo', explicitTarget, async () => ({
|
||||
hasUpstream: false,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
}))
|
||||
|
||||
expect(matching).toHaveBeenCalledOnce()
|
||||
for (const unsubscribe of listeners.slice(1)) {
|
||||
expect(unsubscribe).toEqual(expect.any(Function))
|
||||
}
|
||||
})
|
||||
|
||||
it('releases exact listeners and shares one immutable event allocation across fan-out', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const events: GitRepositorySnapshotRevisionEvent[] = []
|
||||
const unsubscribes = Array.from({ length: 100 }, () =>
|
||||
owner.subscribe({ ...query(), pushTarget: undefined }, (event) => events.push(event))
|
||||
)
|
||||
|
||||
await owner.readStatus(
|
||||
native,
|
||||
'/repo',
|
||||
defaultStatusIdentity,
|
||||
undefined,
|
||||
async () => statusResult
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(100)
|
||||
expect(new Set(events)).toHaveLength(1)
|
||||
expect(Object.isFrozen(events[0])).toBe(true)
|
||||
expect(owner.getSubscriptionCountForTests()).toBe(100)
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe()
|
||||
}
|
||||
expect(owner.getSubscriptionCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not publish failed or retention-truncated status projections', async () => {
|
||||
const owner = new GitRepositorySnapshotOwner()
|
||||
const listener = vi.fn()
|
||||
owner.subscribe({ ...query(), pushTarget: undefined }, listener)
|
||||
await expect(
|
||||
owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => {
|
||||
throw new Error('status failed')
|
||||
})
|
||||
).rejects.toThrow('status failed')
|
||||
await owner.readStatus(native, '/repo', defaultStatusIdentity, undefined, async () => ({
|
||||
...statusResult,
|
||||
entries: Array.from({ length: 1_001 }, (_, index) => ({
|
||||
path: `src/file-${index}.ts`,
|
||||
status: 'modified' as const,
|
||||
area: 'unstaged' as const
|
||||
}))
|
||||
}))
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,7 @@ import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
import type { GitRepositorySnapshotRevisionEvent } from '../../shared/git-repository-snapshot'
|
||||
import {
|
||||
nativeAndWslGitRepositorySnapshotOwner,
|
||||
type GitRepositoryExecutionIdentity,
|
||||
@@ -243,6 +244,23 @@ export function getGitRepositorySnapshot(
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeGitRepositorySnapshot(
|
||||
worktreePath: string,
|
||||
options: GetStatusOptions,
|
||||
pushTarget: GitPushTarget | undefined,
|
||||
listener: (event: GitRepositorySnapshotRevisionEvent) => void
|
||||
): () => void {
|
||||
return nativeAndWslGitRepositorySnapshotOwner.subscribe(
|
||||
{
|
||||
executionIdentity: getStatusExecutionIdentity(options),
|
||||
worktreePath,
|
||||
statusIdentity: getStatusSnapshotIdentity(options),
|
||||
pushTarget
|
||||
},
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusExecutionIdentity(options: GetStatusOptions): GitRepositoryExecutionIdentity {
|
||||
return options.wslDistro ? { kind: 'wsl', distro: options.wslDistro } : { kind: 'native' }
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ import type {
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import type { GitRepositorySnapshot } from '../../shared/git-repository-snapshot'
|
||||
import type {
|
||||
GitRepositorySnapshot,
|
||||
GitRepositorySnapshotRequest
|
||||
} from '../../shared/git-repository-snapshot'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import {
|
||||
@@ -136,6 +139,7 @@ import { sanitizeLocalDownloadFilename } from '../local-download-filename'
|
||||
import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
|
||||
import { resolveLocalGitRepositorySnapshotRequest } from './git-repository-snapshot-request'
|
||||
|
||||
// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files.
|
||||
const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
@@ -1145,17 +1149,7 @@ export function registerFilesystemHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'git:repositorySnapshot',
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): Promise<GitRepositorySnapshot | null> => {
|
||||
async (_event, args: GitRepositorySnapshotRequest): Promise<GitRepositorySnapshot | null> => {
|
||||
if (args.pushTarget) {
|
||||
assertGitPushTargetShape(args.pushTarget)
|
||||
}
|
||||
@@ -1173,19 +1167,8 @@ export function registerFilesystemHandlers(
|
||||
}
|
||||
return provider.getRepositorySnapshot(args.worktreePath, options, args.pushTarget)
|
||||
}
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
|
||||
const gitOptions = getLocalGitOptionsForRepo(store, repo)
|
||||
const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : []
|
||||
return getGitRepositorySnapshot(
|
||||
worktreePath,
|
||||
{
|
||||
...options,
|
||||
...gitOptions,
|
||||
...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {})
|
||||
},
|
||||
args.pushTarget
|
||||
)
|
||||
const request = await resolveLocalGitRepositorySnapshotRequest(store, args)
|
||||
return getGitRepositorySnapshot(request.worktreePath, request.options, args.pushTarget)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GitRepositorySnapshotRequest } from '../../shared/git-repository-snapshot'
|
||||
import type { Store } from '../persistence'
|
||||
import type { GetStatusOptions } from '../git/status'
|
||||
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
|
||||
import { resolveRegisteredWorktreePath } from './filesystem-auth'
|
||||
import {
|
||||
getLocalGitOptionsForRepo,
|
||||
getLocalRepoForRegisteredWorktree
|
||||
} from './local-worktree-runtime-options'
|
||||
|
||||
export async function resolveLocalGitRepositorySnapshotRequest(
|
||||
store: Store,
|
||||
args: GitRepositorySnapshotRequest
|
||||
): Promise<{ worktreePath: string; options: GetStatusOptions }> {
|
||||
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
|
||||
const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)
|
||||
const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : []
|
||||
return {
|
||||
worktreePath,
|
||||
options: {
|
||||
includeIgnored: args.includeIgnored ?? false,
|
||||
...(args.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(args.bypassEffectiveUpstreamNegativeCache === true
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
...getLocalGitOptionsForRepo(store, repo),
|
||||
...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: never, args: never) => unknown>(),
|
||||
localSubscribe: vi.fn(),
|
||||
registrySubscribe: vi.fn(),
|
||||
resolveLocal: vi.fn(),
|
||||
getProvider: vi.fn(),
|
||||
getGeneration: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: never, args: never) => unknown) => {
|
||||
mocks.handlers.set(channel, handler)
|
||||
},
|
||||
removeHandler: (channel: string) => {
|
||||
mocks.handlers.delete(channel)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('../git/status', () => ({
|
||||
subscribeGitRepositorySnapshot: mocks.localSubscribe
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: mocks.getProvider,
|
||||
getSshGitProviderGeneration: mocks.getGeneration,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'unavailable',
|
||||
subscribeSshGitProviderRegistry: mocks.registrySubscribe
|
||||
}))
|
||||
|
||||
vi.mock('./git-repository-snapshot-request', () => ({
|
||||
resolveLocalGitRepositorySnapshotRequest: mocks.resolveLocal
|
||||
}))
|
||||
|
||||
import {
|
||||
disposeGitRepositorySnapshotSubscriptionHandlers,
|
||||
getGitRepositorySnapshotSubscriptionCountForTests,
|
||||
registerGitRepositorySnapshotSubscriptionHandlers
|
||||
} from './git-repository-snapshot-subscriptions'
|
||||
|
||||
type Sender = EventEmitter & {
|
||||
id: number
|
||||
isDestroyed: () => boolean
|
||||
send: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function sender(id: number): Sender {
|
||||
const value = new EventEmitter() as Sender
|
||||
value.id = id
|
||||
value.isDestroyed = () => false
|
||||
value.send = vi.fn()
|
||||
return value
|
||||
}
|
||||
|
||||
function handler(channel: string): (event: never, args: never) => Promise<unknown> {
|
||||
return mocks.handlers.get(channel) as (event: never, args: never) => Promise<unknown>
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('Git repository snapshot subscription IPC', () => {
|
||||
beforeEach(() => {
|
||||
disposeGitRepositorySnapshotSubscriptionHandlers()
|
||||
mocks.handlers.clear()
|
||||
mocks.localSubscribe.mockReset()
|
||||
mocks.localSubscribe.mockReturnValue(vi.fn())
|
||||
mocks.registrySubscribe.mockReset()
|
||||
mocks.registrySubscribe.mockReturnValue(vi.fn())
|
||||
mocks.resolveLocal.mockReset()
|
||||
mocks.resolveLocal.mockResolvedValue({
|
||||
worktreePath: '/resolved/repo',
|
||||
options: { includeIgnored: false, sharedLinkPaths: ['/shared'] }
|
||||
})
|
||||
mocks.getProvider.mockReset()
|
||||
mocks.getGeneration.mockReset()
|
||||
})
|
||||
|
||||
it('isolates senders and removes exact listeners on unsubscribe and destruction', async () => {
|
||||
const ownerListeners: ((event: {
|
||||
state: 'ready'
|
||||
generation: number
|
||||
revision: number
|
||||
}) => void)[] = []
|
||||
const ownerUnsubscribes = [vi.fn(), vi.fn()]
|
||||
mocks.localSubscribe
|
||||
.mockImplementationOnce(
|
||||
(_path, _options, _target, listener: (typeof ownerListeners)[number]) => {
|
||||
ownerListeners.push(listener)
|
||||
return ownerUnsubscribes[0]
|
||||
}
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_path, _options, _target, listener: (typeof ownerListeners)[number]) => {
|
||||
ownerListeners.push(listener)
|
||||
return ownerUnsubscribes[1]
|
||||
}
|
||||
)
|
||||
registerGitRepositorySnapshotSubscriptionHandlers({} as never)
|
||||
const first = sender(1)
|
||||
const second = sender(2)
|
||||
|
||||
await handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: first } as never,
|
||||
{ subscriptionId: 'one', worktreePath: '/repo' } as never
|
||||
)
|
||||
await handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: second } as never,
|
||||
{ subscriptionId: 'two', worktreePath: '/repo' } as never
|
||||
)
|
||||
expect(mocks.localSubscribe).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/resolved/repo',
|
||||
{ includeIgnored: false, sharedLinkPaths: ['/shared'] },
|
||||
undefined,
|
||||
expect.any(Function)
|
||||
)
|
||||
ownerListeners[0]({ state: 'ready', generation: 2, revision: 3 })
|
||||
|
||||
expect(first.send).toHaveBeenCalledWith('git:repositorySnapshotRevision', {
|
||||
subscriptionId: 'one',
|
||||
event: { state: 'ready', generation: 2, revision: 3, incarnation: 0 }
|
||||
})
|
||||
expect(second.send).not.toHaveBeenCalled()
|
||||
expect(first.send.mock.calls[0][1]).not.toHaveProperty('worktreePath')
|
||||
|
||||
await handler('git:unsubscribeRepositorySnapshot')(
|
||||
{ sender: second } as never,
|
||||
{ subscriptionId: 'one' } as never
|
||||
)
|
||||
expect(ownerUnsubscribes[0]).not.toHaveBeenCalled()
|
||||
await handler('git:unsubscribeRepositorySnapshot')(
|
||||
{ sender: first } as never,
|
||||
{ subscriptionId: 'one' } as never
|
||||
)
|
||||
expect(ownerUnsubscribes[0]).toHaveBeenCalledTimes(1)
|
||||
expect(first.listenerCount('destroyed')).toBe(0)
|
||||
|
||||
second.emit('destroyed')
|
||||
expect(ownerUnsubscribes[1]).toHaveBeenCalledTimes(1)
|
||||
expect(getGitRepositorySnapshotSubscriptionCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('rebinds provider incarnations and suppresses late old-provider events', async () => {
|
||||
let registryListener!: (event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: never
|
||||
}) => void
|
||||
mocks.registrySubscribe.mockImplementation((listener) => {
|
||||
registryListener = listener
|
||||
return vi.fn()
|
||||
})
|
||||
const firstListener = vi.fn()
|
||||
const secondListener = vi.fn()
|
||||
const firstUnsubscribe = vi.fn()
|
||||
const secondUnsubscribe = vi.fn()
|
||||
const firstProvider = {
|
||||
subscribeRepositorySnapshot: vi.fn((_path, _options, _target, listener) => {
|
||||
firstListener.mockImplementation(listener)
|
||||
return firstUnsubscribe
|
||||
})
|
||||
}
|
||||
const secondProvider = {
|
||||
subscribeRepositorySnapshot: vi.fn((_path, _options, _target, listener) => {
|
||||
secondListener.mockImplementation(listener)
|
||||
return secondUnsubscribe
|
||||
})
|
||||
}
|
||||
mocks.getProvider.mockReturnValue(firstProvider)
|
||||
mocks.getGeneration.mockReturnValue(5)
|
||||
registerGitRepositorySnapshotSubscriptionHandlers({} as never)
|
||||
const target = sender(3)
|
||||
|
||||
await handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: target } as never,
|
||||
{
|
||||
subscriptionId: 'ssh',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'connection',
|
||||
reuseLineStats: true
|
||||
} as never
|
||||
)
|
||||
firstListener({ state: 'ready', generation: 1, revision: 4 })
|
||||
registryListener({
|
||||
connectionId: 'connection',
|
||||
generation: 6,
|
||||
provider: secondProvider as never
|
||||
})
|
||||
firstListener({ state: 'ready', generation: 1, revision: 5 })
|
||||
secondListener({ state: 'ready', generation: 0, revision: 1 })
|
||||
|
||||
expect(firstUnsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(target.send.mock.calls.map((call) => call[1].event)).toEqual([
|
||||
{ state: 'ready', generation: 1, revision: 4, incarnation: 5 },
|
||||
{ state: 'invalidated', generation: 0, revision: 0, incarnation: 6 },
|
||||
{ state: 'ready', generation: 0, revision: 1, incarnation: 6 }
|
||||
])
|
||||
disposeGitRepositorySnapshotSubscriptionHandlers()
|
||||
expect(secondUnsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not bind an owner after sender destruction during local request resolution', async () => {
|
||||
const resolution = deferred<{
|
||||
worktreePath: string
|
||||
options: { includeIgnored: boolean }
|
||||
}>()
|
||||
mocks.resolveLocal.mockReturnValue(resolution.promise)
|
||||
registerGitRepositorySnapshotSubscriptionHandlers({} as never)
|
||||
const target = sender(4)
|
||||
let destroyed = false
|
||||
target.isDestroyed = () => destroyed
|
||||
|
||||
const pending = handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: target } as never,
|
||||
{ subscriptionId: 'pending', worktreePath: '/repo' } as never
|
||||
)
|
||||
destroyed = true
|
||||
target.emit('destroyed')
|
||||
resolution.resolve({ worktreePath: '/repo', options: { includeIgnored: false } })
|
||||
await pending
|
||||
|
||||
expect(mocks.localSubscribe).not.toHaveBeenCalled()
|
||||
expect(getGitRepositorySnapshotSubscriptionCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('closes a subscription when its renderer can no longer receive events', async () => {
|
||||
let ownerListener!: (event: { state: 'ready'; generation: number; revision: number }) => void
|
||||
const unsubscribe = vi.fn()
|
||||
mocks.localSubscribe.mockImplementation((_path, _options, _target, listener) => {
|
||||
ownerListener = listener
|
||||
return unsubscribe
|
||||
})
|
||||
registerGitRepositorySnapshotSubscriptionHandlers({} as never)
|
||||
const target = sender(5)
|
||||
target.send.mockImplementation(() => {
|
||||
throw new Error('render frame disposed')
|
||||
})
|
||||
await handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: target } as never,
|
||||
{ subscriptionId: 'send-failure', worktreePath: '/repo' } as never
|
||||
)
|
||||
|
||||
expect(() => ownerListener({ state: 'ready', generation: 1, revision: 1 })).not.toThrow()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(getGitRepositorySnapshotSubscriptionCountForTests()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not rebind SSH after replacement invalidation delivery fails', async () => {
|
||||
let registryListener!: (event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: never
|
||||
}) => void
|
||||
mocks.registrySubscribe.mockImplementation((listener) => {
|
||||
registryListener = listener
|
||||
return vi.fn()
|
||||
})
|
||||
const firstUnsubscribe = vi.fn()
|
||||
const firstProvider = {
|
||||
subscribeRepositorySnapshot: vi.fn(() => firstUnsubscribe)
|
||||
}
|
||||
const secondProvider = {
|
||||
subscribeRepositorySnapshot: vi.fn(() => vi.fn())
|
||||
}
|
||||
mocks.getProvider.mockReturnValue(firstProvider)
|
||||
mocks.getGeneration.mockReturnValue(1)
|
||||
registerGitRepositorySnapshotSubscriptionHandlers({} as never)
|
||||
const target = sender(6)
|
||||
await handler('git:subscribeRepositorySnapshot')(
|
||||
{ sender: target } as never,
|
||||
{
|
||||
subscriptionId: 'ssh-send-failure',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'connection'
|
||||
} as never
|
||||
)
|
||||
target.send.mockImplementation(() => {
|
||||
throw new Error('render frame disposed')
|
||||
})
|
||||
|
||||
registryListener({
|
||||
connectionId: 'connection',
|
||||
generation: 2,
|
||||
provider: secondProvider as never
|
||||
})
|
||||
|
||||
expect(firstUnsubscribe).toHaveBeenCalledOnce()
|
||||
expect(secondProvider.subscribeRepositorySnapshot).not.toHaveBeenCalled()
|
||||
expect(getGitRepositorySnapshotSubscriptionCountForTests()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { ipcMain } from 'electron'
|
||||
import type { WebContents } from 'electron'
|
||||
import type {
|
||||
GitRepositorySnapshotRequest,
|
||||
GitRepositorySnapshotRevisionEvent,
|
||||
GitRepositorySnapshotSubscriptionEvent
|
||||
} from '../../shared/git-repository-snapshot'
|
||||
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
|
||||
import { subscribeGitRepositorySnapshot } from '../git/status'
|
||||
import type { Store } from '../persistence'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import {
|
||||
getSshGitProvider,
|
||||
getSshGitProviderGeneration,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE,
|
||||
subscribeSshGitProviderRegistry
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { resolveLocalGitRepositorySnapshotRequest } from './git-repository-snapshot-request'
|
||||
|
||||
const SUBSCRIBE_CHANNEL = 'git:subscribeRepositorySnapshot'
|
||||
const UNSUBSCRIBE_CHANNEL = 'git:unsubscribeRepositorySnapshot'
|
||||
export const GIT_REPOSITORY_SNAPSHOT_REVISION_CHANNEL = 'git:repositorySnapshotRevision'
|
||||
|
||||
type RetainedSubscription = {
|
||||
id: string
|
||||
args: GitRepositorySnapshotRequest
|
||||
sender: WebContents
|
||||
senderId: number
|
||||
incarnation: number
|
||||
bindingToken: number
|
||||
unsubscribeOwner: () => void
|
||||
removeDestroyedListener: () => void
|
||||
}
|
||||
|
||||
const subscriptions = new Map<string, RetainedSubscription>()
|
||||
let disposeRegistryListener: (() => void) | null = null
|
||||
|
||||
function closeSubscription(subscription: RetainedSubscription): void {
|
||||
if (subscriptions.get(subscription.id) !== subscription) {
|
||||
return
|
||||
}
|
||||
subscriptions.delete(subscription.id)
|
||||
subscription.bindingToken += 1
|
||||
subscription.unsubscribeOwner()
|
||||
subscription.removeDestroyedListener()
|
||||
}
|
||||
|
||||
function sendEvent(
|
||||
subscription: RetainedSubscription,
|
||||
event: GitRepositorySnapshotRevisionEvent,
|
||||
token: number
|
||||
): void {
|
||||
if (
|
||||
subscriptions.get(subscription.id) !== subscription ||
|
||||
subscription.bindingToken !== token ||
|
||||
subscription.sender.isDestroyed()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const payload: GitRepositorySnapshotSubscriptionEvent = Object.freeze({
|
||||
...event,
|
||||
incarnation: subscription.incarnation
|
||||
})
|
||||
try {
|
||||
subscription.sender.send(GIT_REPOSITORY_SNAPSHOT_REVISION_CHANNEL, {
|
||||
subscriptionId: subscription.id,
|
||||
event: payload
|
||||
})
|
||||
} catch {
|
||||
closeSubscription(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
function bindSshProvider(
|
||||
subscription: RetainedSubscription,
|
||||
provider: SshGitProvider,
|
||||
incarnation: number
|
||||
): void {
|
||||
subscription.bindingToken += 1
|
||||
const token = subscription.bindingToken
|
||||
subscription.incarnation = incarnation
|
||||
subscription.unsubscribeOwner = provider.subscribeRepositorySnapshot(
|
||||
subscription.args.worktreePath,
|
||||
{
|
||||
includeIgnored: subscription.args.includeIgnored,
|
||||
bypassEffectiveUpstreamNegativeCache: subscription.args.bypassEffectiveUpstreamNegativeCache,
|
||||
reuseLineStats: subscription.args.reuseLineStats
|
||||
},
|
||||
subscription.args.pushTarget,
|
||||
(event) => sendEvent(subscription, event, token)
|
||||
)
|
||||
}
|
||||
|
||||
function handleSshProviderChange(event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: SshGitProvider | undefined
|
||||
}): void {
|
||||
for (const subscription of subscriptions.values()) {
|
||||
if (subscription.args.connectionId !== event.connectionId) {
|
||||
continue
|
||||
}
|
||||
subscription.bindingToken += 1
|
||||
subscription.unsubscribeOwner()
|
||||
subscription.unsubscribeOwner = () => {}
|
||||
subscription.incarnation = event.generation
|
||||
sendEvent(
|
||||
subscription,
|
||||
{ state: 'invalidated', generation: 0, revision: 0 },
|
||||
subscription.bindingToken
|
||||
)
|
||||
if (event.provider && subscriptions.get(subscription.id) === subscription) {
|
||||
bindSshProvider(subscription, event.provider, event.generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeGitRepositorySnapshotSubscriptionHandlers(): void {
|
||||
for (const subscription of Array.from(subscriptions.values())) {
|
||||
closeSubscription(subscription)
|
||||
}
|
||||
disposeRegistryListener?.()
|
||||
disposeRegistryListener = null
|
||||
ipcMain.removeHandler(SUBSCRIBE_CHANNEL)
|
||||
ipcMain.removeHandler(UNSUBSCRIBE_CHANNEL)
|
||||
}
|
||||
|
||||
export function registerGitRepositorySnapshotSubscriptionHandlers(store: Store): void {
|
||||
disposeGitRepositorySnapshotSubscriptionHandlers()
|
||||
disposeRegistryListener = subscribeSshGitProviderRegistry(handleSshProviderChange)
|
||||
|
||||
ipcMain.handle(
|
||||
SUBSCRIBE_CHANNEL,
|
||||
async (
|
||||
event,
|
||||
args: GitRepositorySnapshotRequest & { subscriptionId?: string }
|
||||
): Promise<{ subscriptionId: string }> => {
|
||||
if (args.pushTarget) {
|
||||
assertGitPushTargetShape(args.pushTarget)
|
||||
}
|
||||
const id =
|
||||
typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0
|
||||
? args.subscriptionId
|
||||
: randomUUID()
|
||||
if (subscriptions.has(id)) {
|
||||
throw new Error('Git repository snapshot subscription id already exists')
|
||||
}
|
||||
const sender = event.sender
|
||||
let destroyedListenerAttached = false
|
||||
const subscription: RetainedSubscription = {
|
||||
id,
|
||||
args,
|
||||
sender,
|
||||
senderId: sender.id,
|
||||
incarnation: 0,
|
||||
bindingToken: 0,
|
||||
unsubscribeOwner: () => {},
|
||||
removeDestroyedListener: () => {
|
||||
if (!destroyedListenerAttached) {
|
||||
return
|
||||
}
|
||||
destroyedListenerAttached = false
|
||||
sender.removeListener('destroyed', closeOnDestroyed)
|
||||
}
|
||||
}
|
||||
const closeOnDestroyed = (): void => closeSubscription(subscription)
|
||||
sender.once('destroyed', closeOnDestroyed)
|
||||
destroyedListenerAttached = true
|
||||
subscriptions.set(id, subscription)
|
||||
try {
|
||||
if (args.connectionId) {
|
||||
const provider = getSshGitProvider(args.connectionId)
|
||||
if (!provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
bindSshProvider(subscription, provider, getSshGitProviderGeneration(args.connectionId))
|
||||
} else {
|
||||
const request = await resolveLocalGitRepositorySnapshotRequest(store, args)
|
||||
if (subscriptions.get(subscription.id) !== subscription || sender.isDestroyed()) {
|
||||
closeSubscription(subscription)
|
||||
return { subscriptionId: id }
|
||||
}
|
||||
const token = subscription.bindingToken
|
||||
subscription.unsubscribeOwner = subscribeGitRepositorySnapshot(
|
||||
request.worktreePath,
|
||||
request.options,
|
||||
args.pushTarget,
|
||||
(revisionEvent) => sendEvent(subscription, revisionEvent, token)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closeSubscription(subscription)
|
||||
throw error
|
||||
}
|
||||
if (sender.isDestroyed()) {
|
||||
closeSubscription(subscription)
|
||||
}
|
||||
return { subscriptionId: id }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
UNSUBSCRIBE_CHANNEL,
|
||||
(event, args: { subscriptionId: string }): { unsubscribed: boolean } => {
|
||||
const subscription = subscriptions.get(args.subscriptionId)
|
||||
if (!subscription || subscription.senderId !== event.sender.id) {
|
||||
return { unsubscribed: false }
|
||||
}
|
||||
closeSubscription(subscription)
|
||||
return { unsubscribed: true }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function getGitRepositorySnapshotSubscriptionCountForTests(): number {
|
||||
return subscriptions.size
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
registerUIHandlersMock,
|
||||
setTrustedUIRendererWebContentsIdMock,
|
||||
registerFilesystemHandlersMock,
|
||||
registerGitRepositorySnapshotSubscriptionHandlersMock,
|
||||
registerRuntimeHandlersMock,
|
||||
registerRuntimeEnvironmentHandlersMock,
|
||||
registerEphemeralVmHandlersMock,
|
||||
@@ -93,6 +94,7 @@ const {
|
||||
registerUIHandlersMock: vi.fn(),
|
||||
setTrustedUIRendererWebContentsIdMock: vi.fn(),
|
||||
registerFilesystemHandlersMock: vi.fn(),
|
||||
registerGitRepositorySnapshotSubscriptionHandlersMock: vi.fn(),
|
||||
registerRuntimeHandlersMock: vi.fn(),
|
||||
registerRuntimeEnvironmentHandlersMock: vi.fn(),
|
||||
registerEphemeralVmHandlersMock: vi.fn(),
|
||||
@@ -283,6 +285,11 @@ vi.mock('./filesystem', () => ({
|
||||
registerFilesystemHandlers: registerFilesystemHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./git-repository-snapshot-subscriptions', () => ({
|
||||
registerGitRepositorySnapshotSubscriptionHandlers:
|
||||
registerGitRepositorySnapshotSubscriptionHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-watcher', () => ({
|
||||
registerFilesystemWatcherHandlers: registerFilesystemWatcherHandlersMock
|
||||
}))
|
||||
@@ -411,6 +418,7 @@ describe('registerCoreHandlers', () => {
|
||||
registerUIHandlersMock.mockReset()
|
||||
setTrustedUIRendererWebContentsIdMock.mockReset()
|
||||
registerFilesystemHandlersMock.mockReset()
|
||||
registerGitRepositorySnapshotSubscriptionHandlersMock.mockReset()
|
||||
registerRuntimeHandlersMock.mockReset()
|
||||
registerRuntimeEnvironmentHandlersMock.mockReset()
|
||||
registerEphemeralVmHandlersMock.mockReset()
|
||||
@@ -533,6 +541,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled()
|
||||
expect(registerEmulatorVideoStreamHandlersMock).toHaveBeenCalled()
|
||||
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerGitRepositorySnapshotSubscriptionHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined)
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import { registerFilesystemHandlers } from './filesystem'
|
||||
import { registerGitRepositorySnapshotSubscriptionHandlers } from './git-repository-snapshot-subscriptions'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
import { registerClaudeUsageHandlers } from './claude-usage'
|
||||
@@ -211,6 +212,7 @@ export function registerCoreHandlers(
|
||||
} else {
|
||||
registerFilesystemHandlers(store)
|
||||
}
|
||||
registerGitRepositorySnapshotSubscriptionHandlers(store)
|
||||
registerFilesystemWatcherHandlers()
|
||||
registerRuntimeHandlers(runtime)
|
||||
registerRuntimeEnvironmentHandlers(store)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getSshGitProviderGeneration,
|
||||
registerSshGitProvider,
|
||||
subscribeSshGitProviderRegistry,
|
||||
unregisterSshGitProvider
|
||||
} from './ssh-git-dispatch'
|
||||
|
||||
@@ -25,4 +26,29 @@ describe('SSH Git provider registry', () => {
|
||||
expect(unregistered).toBe(registered + 1)
|
||||
expect(reRegistered).toBe(unregistered + 1)
|
||||
})
|
||||
|
||||
it('reports exact replacement generations until the listener unsubscribes', () => {
|
||||
const events: { generation: number; provider: unknown }[] = []
|
||||
const unsubscribe = subscribeSshGitProviderRegistry((event) => {
|
||||
if (event.connectionId === connectionId) {
|
||||
events.push({ generation: event.generation, provider: event.provider })
|
||||
}
|
||||
})
|
||||
const first = {} as never
|
||||
const second = {} as never
|
||||
|
||||
registerSshGitProvider(connectionId, first)
|
||||
registerSshGitProvider(connectionId, second)
|
||||
unregisterSshGitProvider(connectionId)
|
||||
unsubscribe()
|
||||
registerSshGitProvider(connectionId, first)
|
||||
|
||||
expect(events).toEqual([
|
||||
{ generation: expect.any(Number), provider: first },
|
||||
{ generation: expect.any(Number), provider: second },
|
||||
{ generation: expect.any(Number), provider: undefined }
|
||||
])
|
||||
expect(events[1].generation).toBe(events[0].generation + 1)
|
||||
expect(events[2].generation).toBe(events[1].generation + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,43 @@ import type { SshGitProvider } from './ssh-git-provider'
|
||||
|
||||
const sshProviders = new Map<string, SshGitProvider>()
|
||||
const sshProviderGenerations = new Map<string, number>()
|
||||
const sshProviderRegistryListeners = new Set<
|
||||
(event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: SshGitProvider | undefined
|
||||
}) => void
|
||||
>()
|
||||
|
||||
export const SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
|
||||
export function registerSshGitProvider(connectionId: string, provider: SshGitProvider): void {
|
||||
sshProviders.set(connectionId, provider)
|
||||
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
|
||||
const generation = (sshProviderGenerations.get(connectionId) ?? 0) + 1
|
||||
sshProviderGenerations.set(connectionId, generation)
|
||||
notifySshGitProviderRegistry({ connectionId, generation, provider })
|
||||
}
|
||||
|
||||
export function unregisterSshGitProvider(connectionId: string): void {
|
||||
if (sshProviders.delete(connectionId)) {
|
||||
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
|
||||
const generation = (sshProviderGenerations.get(connectionId) ?? 0) + 1
|
||||
sshProviderGenerations.set(connectionId, generation)
|
||||
notifySshGitProviderRegistry({ connectionId, generation, provider: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeSshGitProviderRegistry(
|
||||
listener: (event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: SshGitProvider | undefined
|
||||
}) => void
|
||||
): () => void {
|
||||
sshProviderRegistryListeners.add(listener)
|
||||
return () => sshProviderRegistryListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function getSshGitProviderGeneration(connectionId: string): number {
|
||||
return sshProviderGenerations.get(connectionId) ?? 0
|
||||
}
|
||||
@@ -32,3 +54,13 @@ export function requireSshGitProvider(connectionId: string): SshGitProvider {
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
function notifySshGitProviderRegistry(event: {
|
||||
connectionId: string
|
||||
generation: number
|
||||
provider: SshGitProvider | undefined
|
||||
}): void {
|
||||
for (const listener of sshProviderRegistryListeners) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
RemoveWorktreeResult
|
||||
} from '../../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import type { GitRepositorySnapshotRevisionEvent } from '../../shared/git-repository-snapshot'
|
||||
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url'
|
||||
import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader'
|
||||
@@ -548,6 +549,30 @@ export class SshGitProvider implements IGitProvider {
|
||||
})
|
||||
}
|
||||
|
||||
subscribeRepositorySnapshot(
|
||||
worktreePath: string,
|
||||
options: GitProviderStatusOptions | undefined,
|
||||
pushTarget: GitPushTarget | undefined,
|
||||
listener: (event: GitRepositorySnapshotRevisionEvent) => void
|
||||
): () => void {
|
||||
return this.repositorySnapshotOwner.subscribe(
|
||||
{
|
||||
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
|
||||
},
|
||||
listener
|
||||
)
|
||||
}
|
||||
|
||||
async pushBranch(
|
||||
worktreePath: string,
|
||||
publish = false,
|
||||
|
||||
@@ -282,7 +282,11 @@ import type {
|
||||
|
||||
import type { SetupScriptImportCandidate } from '../shared/setup-script-imports'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { GitRepositorySnapshot } from '../shared/git-repository-snapshot'
|
||||
import type {
|
||||
GitRepositorySnapshot,
|
||||
GitRepositorySnapshotRequest,
|
||||
GitRepositorySnapshotSubscriptionEvent
|
||||
} from '../shared/git-repository-snapshot'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { EphemeralVmRecipeDoctorResult } from '../shared/ephemeral-vm-recipes'
|
||||
import type { EphemeralVmRecipeResultWarning } from '../shared/ephemeral-vm-recipe-diagnostics'
|
||||
@@ -2846,6 +2850,10 @@ export type PreloadApi = {
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
}) => Promise<GitRepositorySnapshot | null>
|
||||
subscribeRepositorySnapshot: (
|
||||
args: GitRepositorySnapshotRequest,
|
||||
callback: (event: GitRepositorySnapshotSubscriptionEvent) => void
|
||||
) => Promise<{ unsubscribe: () => void }>
|
||||
submoduleStatus: (args: {
|
||||
worktreePath: string
|
||||
submodulePath: string
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { subscribeGitRepositorySnapshotFromPreload } from './git-repository-snapshot-subscriptions'
|
||||
|
||||
type Payload = {
|
||||
subscriptionId: string
|
||||
event: {
|
||||
state: 'ready' | 'invalidated'
|
||||
generation: number
|
||||
revision: number
|
||||
incarnation: number
|
||||
}
|
||||
}
|
||||
|
||||
function createIpc() {
|
||||
const listeners = new Set<(event: unknown, payload: Payload) => void>()
|
||||
return {
|
||||
invoke: vi.fn(async (_channel: string, args: unknown) => args),
|
||||
on: vi.fn((_channel: string, listener: (event: unknown, payload: Payload) => void) => {
|
||||
listeners.add(listener)
|
||||
}),
|
||||
removeListener: vi.fn(
|
||||
(_channel: string, listener: (event: unknown, payload: Payload) => void) => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
),
|
||||
emit: (payload: Payload): void => {
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(null, payload)
|
||||
}
|
||||
},
|
||||
listenerCount: (): number => listeners.size
|
||||
}
|
||||
}
|
||||
|
||||
describe('subscribeGitRepositorySnapshotFromPreload', () => {
|
||||
it('installs exact listeners before subscribe and isolates callback ids', async () => {
|
||||
const ipc = createIpc()
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
const firstHandle = await subscribeGitRepositorySnapshotFromPreload(
|
||||
ipc,
|
||||
{ worktreePath: '/one' },
|
||||
first,
|
||||
() => 'sub-1'
|
||||
)
|
||||
const secondHandle = await subscribeGitRepositorySnapshotFromPreload(
|
||||
ipc,
|
||||
{ worktreePath: '/two' },
|
||||
second,
|
||||
() => 'sub-2'
|
||||
)
|
||||
|
||||
expect(ipc.on).toHaveBeenCalledTimes(2)
|
||||
expect(ipc.on.mock.invocationCallOrder[0]).toBeLessThan(ipc.invoke.mock.invocationCallOrder[0])
|
||||
ipc.emit({
|
||||
subscriptionId: 'sub-2',
|
||||
event: { state: 'ready', generation: 1, revision: 2, incarnation: 3 }
|
||||
})
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstHandle.unsubscribe()
|
||||
expect(ipc.listenerCount()).toBe(1)
|
||||
secondHandle.unsubscribe()
|
||||
expect(ipc.listenerCount()).toBe(0)
|
||||
expect(ipc.invoke).toHaveBeenCalledWith('git:unsubscribeRepositorySnapshot', {
|
||||
subscriptionId: 'sub-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('releases the dispatcher when subscribe rejects', async () => {
|
||||
const ipc = createIpc()
|
||||
ipc.invoke.mockRejectedValueOnce(new Error('unavailable'))
|
||||
|
||||
await expect(
|
||||
subscribeGitRepositorySnapshotFromPreload(
|
||||
ipc,
|
||||
{ worktreePath: '/repo' },
|
||||
vi.fn(),
|
||||
() => 'sub-error'
|
||||
)
|
||||
).rejects.toThrow('unavailable')
|
||||
|
||||
expect(ipc.listenerCount()).toBe(0)
|
||||
expect(ipc.removeListener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('makes explicit unsubscribe idempotent', async () => {
|
||||
const ipc = createIpc()
|
||||
const handle = await subscribeGitRepositorySnapshotFromPreload(
|
||||
ipc,
|
||||
{ worktreePath: '/repo' },
|
||||
vi.fn(),
|
||||
() => 'sub-1'
|
||||
)
|
||||
|
||||
handle.unsubscribe()
|
||||
handle.unsubscribe()
|
||||
|
||||
expect(ipc.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
GitRepositorySnapshotRequest,
|
||||
GitRepositorySnapshotSubscriptionEvent
|
||||
} from '../shared/git-repository-snapshot'
|
||||
|
||||
export type GitRepositorySnapshotSubscriptionHandle = {
|
||||
unsubscribe: () => void
|
||||
}
|
||||
|
||||
type SubscriptionPayload = {
|
||||
subscriptionId: string
|
||||
event: GitRepositorySnapshotSubscriptionEvent
|
||||
}
|
||||
|
||||
type GitRepositorySnapshotSubscriptionIpc = {
|
||||
invoke: (channel: string, args: unknown) => Promise<unknown>
|
||||
on: (channel: string, listener: (event: unknown, payload: SubscriptionPayload) => void) => void
|
||||
removeListener: (
|
||||
channel: string,
|
||||
listener: (event: unknown, payload: SubscriptionPayload) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
const REVISION_CHANNEL = 'git:repositorySnapshotRevision'
|
||||
|
||||
function createSubscriptionId(): string {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
|
||||
export async function subscribeGitRepositorySnapshotFromPreload(
|
||||
ipc: GitRepositorySnapshotSubscriptionIpc,
|
||||
args: GitRepositorySnapshotRequest,
|
||||
callback: (event: GitRepositorySnapshotSubscriptionEvent) => void,
|
||||
createId = createSubscriptionId
|
||||
): Promise<GitRepositorySnapshotSubscriptionHandle> {
|
||||
const subscriptionId = createId()
|
||||
let closed = false
|
||||
const listener = (_event: unknown, payload: SubscriptionPayload): void => {
|
||||
if (payload.subscriptionId === subscriptionId) {
|
||||
callback(payload.event)
|
||||
}
|
||||
}
|
||||
const release = (): boolean => {
|
||||
if (closed) {
|
||||
return false
|
||||
}
|
||||
closed = true
|
||||
ipc.removeListener(REVISION_CHANNEL, listener)
|
||||
return true
|
||||
}
|
||||
ipc.on(REVISION_CHANNEL, listener)
|
||||
try {
|
||||
await ipc.invoke('git:subscribeRepositorySnapshot', {
|
||||
...args,
|
||||
subscriptionId
|
||||
})
|
||||
} catch (error) {
|
||||
release()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
if (!release()) {
|
||||
return
|
||||
}
|
||||
void ipc.invoke('git:unsubscribeRepositorySnapshot', { subscriptionId })
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -14,7 +14,11 @@ import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
|
||||
import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types'
|
||||
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { GitRepositorySnapshot } from '../shared/git-repository-snapshot'
|
||||
import type {
|
||||
GitRepositorySnapshot,
|
||||
GitRepositorySnapshotRequest,
|
||||
GitRepositorySnapshotSubscriptionEvent
|
||||
} from '../shared/git-repository-snapshot'
|
||||
import type { StartupCommandDelivery } from '../shared/codex-startup-delivery'
|
||||
import type {
|
||||
AgentProviderSessionMetadata,
|
||||
@@ -255,6 +259,10 @@ import type {
|
||||
LocalLogTailWatchArgs
|
||||
} from '../shared/local-log-tail-types'
|
||||
import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions'
|
||||
import {
|
||||
subscribeGitRepositorySnapshotFromPreload,
|
||||
type GitRepositorySnapshotSubscriptionHandle
|
||||
} from './git-repository-snapshot-subscriptions'
|
||||
import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions'
|
||||
import type { HostedReviewForBranchArgs } from '../shared/hosted-review'
|
||||
import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
|
||||
@@ -3184,6 +3192,11 @@ const api = {
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
}): Promise<GitRepositorySnapshot | null> => ipcRenderer.invoke('git:repositorySnapshot', args),
|
||||
subscribeRepositorySnapshot: (
|
||||
args: GitRepositorySnapshotRequest,
|
||||
callback: (event: GitRepositorySnapshotSubscriptionEvent) => void
|
||||
): Promise<GitRepositorySnapshotSubscriptionHandle> =>
|
||||
subscribeGitRepositorySnapshotFromPreload(ipcRenderer, args, callback),
|
||||
submoduleStatus: (args: {
|
||||
worktreePath: string
|
||||
submodulePath: string
|
||||
|
||||
@@ -84,7 +84,6 @@ import type {
|
||||
HostedReviewProvider
|
||||
} from '../../../../shared/hosted-review'
|
||||
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
|
||||
import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime'
|
||||
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
|
||||
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
|
||||
import { toast } from 'sonner'
|
||||
@@ -142,6 +141,7 @@ import {
|
||||
resolveChecksPanelReviewEvidenceProvider
|
||||
} from './checks-panel-pr-refresh-request'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { useChecksPanelRepositorySnapshotRevision } from './use-checks-panel-repository-snapshot-revision'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline-checks'
|
||||
@@ -518,6 +518,10 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
null
|
||||
)
|
||||
const [gitStatusRefreshNonce, setGitStatusRefreshNonce] = useState(0)
|
||||
const requestGitStatusRefresh = useCallback(
|
||||
() => setGitStatusRefreshNonce((value) => value + 1),
|
||||
[]
|
||||
)
|
||||
// Bumped by manual Retry/Refresh so eligibility re-runs even when Git state is unchanged (e.g. an auth fix must still clear the hard error).
|
||||
const [eligibilityRefreshNonce, setEligibilityRefreshNonce] = useState(0)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
@@ -601,14 +605,16 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
[runtimeEnvironmentId, settings]
|
||||
)
|
||||
const repoConnectionId = repo?.connectionId?.trim() || null
|
||||
// Local execution host variant (wsl:{distro} vs host); applies only when local — remote contexts are scoped by runtimeEnvironmentId/connectionId.
|
||||
const localExecutionScope = useMemo<string | null>(() => {
|
||||
const localExecutionScope = useAppStore((state): string | null => {
|
||||
if (runtimeEnvironmentId != null || repoConnectionId != null) {
|
||||
return null
|
||||
}
|
||||
const localRuntime = normalizeGlobalWindowsRuntimeDefault(settings?.localWindowsRuntimeDefault)
|
||||
return localRuntime.kind === 'wsl' ? `wsl:${localRuntime.distro ?? ''}` : 'host'
|
||||
}, [runtimeEnvironmentId, repoConnectionId, settings?.localWindowsRuntimeDefault])
|
||||
const localRuntime = getLocalProjectExecutionRuntimeContext(state, activeWorktreeId)
|
||||
if (localRuntime?.status === 'repair-required') {
|
||||
return `repair:${localRuntime.repair.cacheKey}`
|
||||
}
|
||||
return localRuntime?.runtime.kind === 'wsl' ? `wsl:${localRuntime.runtime.distro}` : 'host'
|
||||
})
|
||||
const sshConnectionStatus = useAppStore((s) =>
|
||||
repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined
|
||||
)
|
||||
@@ -685,6 +691,30 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}
|
||||
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const gitStatusSnapshotContext = useMemo(
|
||||
() => ({
|
||||
settings: ownerSettings,
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath: activeWorktreePath ?? '',
|
||||
connectionId: activeConnectionId ?? undefined
|
||||
}),
|
||||
[activeConnectionId, activeWorktreeId, activeWorktreePath, ownerSettings]
|
||||
)
|
||||
const gitStatusSnapshotRevision = useChecksPanelRepositorySnapshotRevision({
|
||||
context: gitStatusSnapshotContext,
|
||||
contextKey: panelContextKey,
|
||||
enabled:
|
||||
Boolean(repo) &&
|
||||
!isFolder &&
|
||||
Boolean(branch) &&
|
||||
isPanelVisible &&
|
||||
Boolean(activeWorktreeId) &&
|
||||
Boolean(activeWorktreePath) &&
|
||||
!runtimeEnvironmentId &&
|
||||
(!repoConnectionId || sshConnectionStatus === 'connected'),
|
||||
pushTarget: activeWorktreePushTarget,
|
||||
requestRefresh: requestGitStatusRefresh
|
||||
})
|
||||
const prCacheKey =
|
||||
repo && branch
|
||||
? getGitHubPRCacheKey(
|
||||
@@ -1498,7 +1528,8 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
gitStatusSnapshotInFlightContextRef.current = requestContextKey
|
||||
// Why: global status maps are keyed only by worktree; use their changes as invalidation signals, then fetch a local snapshot.
|
||||
const revisionRead = gitStatusSnapshotRevision.beginRead()
|
||||
let observedRepositoryRevision: number | null = null
|
||||
if (gitStatusSnapshotRetryTimerRef.current) {
|
||||
clearTimeout(gitStatusSnapshotRetryTimerRef.current)
|
||||
gitStatusSnapshotRetryTimerRef.current = null
|
||||
@@ -1513,9 +1544,17 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
connectionId
|
||||
}
|
||||
void (async () => {
|
||||
const readOwnerSnapshot = async (
|
||||
options?: Parameters<typeof getDesktopGitRepositorySnapshot>[1]
|
||||
) => {
|
||||
const snapshot = await getDesktopGitRepositorySnapshot(context, options)
|
||||
if (snapshot) {
|
||||
observedRepositoryRevision = Math.max(observedRepositoryRevision ?? 0, snapshot.revision)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
let repositorySnapshot = readChecksPanelRepositorySnapshot(
|
||||
await getDesktopGitRepositorySnapshot(
|
||||
context,
|
||||
await readOwnerSnapshot(
|
||||
activeWorktreePushTarget ? { pushTarget: activeWorktreePushTarget } : undefined
|
||||
),
|
||||
requestContextKey,
|
||||
@@ -1523,7 +1562,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
)
|
||||
if (!repositorySnapshot) {
|
||||
repositorySnapshot = readChecksPanelRepositorySnapshot(
|
||||
await getDesktopGitRepositorySnapshot(context, {
|
||||
await readOwnerSnapshot({
|
||||
reuseLineStats: true,
|
||||
...(activeWorktreePushTarget ? { pushTarget: activeWorktreePushTarget } : {})
|
||||
}),
|
||||
@@ -1562,6 +1601,9 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
) {
|
||||
freshRemoteStatus = await getRuntimeGitUpstreamStatus(context)
|
||||
}
|
||||
await readOwnerSnapshot(
|
||||
activeWorktreePushTarget ? { pushTarget: activeWorktreePushTarget } : undefined
|
||||
)
|
||||
return {
|
||||
contextKey: requestContextKey,
|
||||
hasUncommittedChanges: status.entries.length > 0,
|
||||
@@ -1612,12 +1654,19 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
if (gitStatusSnapshotInFlightContextRef.current === requestContextKey) {
|
||||
gitStatusSnapshotInFlightContextRef.current = null
|
||||
}
|
||||
let shouldRerun = gitStatusSnapshotRevision.finishRead(
|
||||
revisionRead,
|
||||
observedRepositoryRevision
|
||||
)
|
||||
if (gitStatusSnapshotRerunContextRef.current === requestContextKey) {
|
||||
gitStatusSnapshotRerunContextRef.current = null
|
||||
shouldRerun = true
|
||||
}
|
||||
if (shouldRerun) {
|
||||
if (
|
||||
shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey)
|
||||
) {
|
||||
setGitStatusRefreshNonce((value) => value + 1)
|
||||
requestGitStatusRefresh()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1634,7 +1683,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
activeWorktreePath,
|
||||
activeConnectionId,
|
||||
branch,
|
||||
gitStatusInvalidation,
|
||||
gitStatusSnapshotRevision,
|
||||
gitStatusRefreshNonce,
|
||||
isFolder,
|
||||
isPanelVisible,
|
||||
@@ -1642,7 +1691,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
||||
panelContextKey,
|
||||
repo,
|
||||
repoConnectionId,
|
||||
remoteStatusInvalidation,
|
||||
requestGitStatusRefresh,
|
||||
runtimeEnvironmentId,
|
||||
sshConnectionStatus,
|
||||
updateWorktreeGitIdentity
|
||||
|
||||
@@ -106,6 +106,23 @@ describe('buildChecksPanelGitStatusContextKey', () => {
|
||||
expect(context(target)).not.toBe(context({ ...target, remoteUrl: 'ssh://git.example/repo' }))
|
||||
})
|
||||
|
||||
it('distinguishes the exact local execution host and WSL distro', () => {
|
||||
const base = {
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'worktree-1',
|
||||
worktreePath: 'repo-worktree',
|
||||
branch: 'feature/checks',
|
||||
runtimeEnvironmentId: null,
|
||||
repoConnectionId: null,
|
||||
pushTarget: null
|
||||
}
|
||||
const context = (localExecutionScope: string) =>
|
||||
buildChecksPanelGitStatusContextKey({ ...base, localExecutionScope })
|
||||
|
||||
expect(context('host')).not.toBe(context('wsl:Ubuntu'))
|
||||
expect(context('wsl:Ubuntu')).not.toBe(context('wsl:Debian'))
|
||||
})
|
||||
|
||||
it('changes when linked hosted review metadata changes', () => {
|
||||
const base = {
|
||||
repoId: 'repo-1',
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitRepositorySnapshotSubscriptionEvent } from '../../../../shared/git-repository-snapshot'
|
||||
import { ChecksPanelRepositorySnapshotRevisionGate } from './checks-panel-repository-snapshot-revision-gate'
|
||||
|
||||
function event(
|
||||
revision: number,
|
||||
state: 'invalidated' | 'ready' = 'ready',
|
||||
generation = 0,
|
||||
incarnation = 0
|
||||
): GitRepositorySnapshotSubscriptionEvent {
|
||||
return { state, generation, revision, incarnation }
|
||||
}
|
||||
|
||||
describe('ChecksPanelRepositorySnapshotRevisionGate', () => {
|
||||
it('requests one refresh for a newer ready revision while idle', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
expect(gate.observe(event(1))).toBe(true)
|
||||
expect(gate.observe(event(1))).toBe(false)
|
||||
expect(gate.observe(event(2))).toBe(false)
|
||||
})
|
||||
|
||||
it('consumes a self-published ready revision observed by the active read', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
const read = gate.begin()
|
||||
expect(gate.observe(event(4))).toBe(false)
|
||||
expect(gate.finish(read, 4)).toBe(false)
|
||||
})
|
||||
|
||||
it('requests a trailing refresh for a newer publication than the active read observed', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
const read = gate.begin()
|
||||
expect(gate.observe(event(5))).toBe(false)
|
||||
expect(gate.observe(event(6))).toBe(false)
|
||||
expect(gate.finish(read, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not consume a replacement-provider publication with an old read revision', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
const read = gate.begin()
|
||||
expect(gate.observe(event(0, 'invalidated', 0, 2))).toBe(false)
|
||||
expect(gate.observe(event(2, 'ready', 0, 2))).toBe(false)
|
||||
expect(gate.finish(read, 20)).toBe(true)
|
||||
})
|
||||
|
||||
it('advances invalidation fences without launching a premature refresh', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
expect(gate.observe(event(3, 'invalidated', 1))).toBe(false)
|
||||
expect(gate.observe(event(2, 'ready', 0))).toBe(false)
|
||||
expect(gate.observe(event(4, 'ready', 1))).toBe(true)
|
||||
})
|
||||
|
||||
it('resets context-local scheduling state', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
|
||||
expect(gate.observe(event(1))).toBe(true)
|
||||
gate.reset()
|
||||
expect(gate.observe(event(1))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a read that settles after a context reset', () => {
|
||||
const gate = new ChecksPanelRepositorySnapshotRevisionGate()
|
||||
const staleRead = gate.begin()
|
||||
gate.reset()
|
||||
const currentRead = gate.begin()
|
||||
expect(gate.observe(event(2))).toBe(false)
|
||||
|
||||
expect(gate.finish(staleRead, 20)).toBe(false)
|
||||
expect(gate.finish(currentRead, 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import type { GitRepositorySnapshotSubscriptionEvent } from '../../../../shared/git-repository-snapshot'
|
||||
|
||||
function compareEvent(
|
||||
left: GitRepositorySnapshotSubscriptionEvent,
|
||||
right: GitRepositorySnapshotSubscriptionEvent
|
||||
): number {
|
||||
return (
|
||||
left.incarnation - right.incarnation ||
|
||||
left.generation - right.generation ||
|
||||
left.revision - right.revision
|
||||
)
|
||||
}
|
||||
|
||||
export class ChecksPanelRepositorySnapshotRevisionGate {
|
||||
private floor: GitRepositorySnapshotSubscriptionEvent | null = null
|
||||
private pending: GitRepositorySnapshotSubscriptionEvent | null = null
|
||||
private inFlight = false
|
||||
private invalidatedDuringFlight = false
|
||||
private refreshRequested = false
|
||||
private readSequence = 0
|
||||
private activeRead: number | null = null
|
||||
|
||||
reset(): void {
|
||||
this.readSequence += 1
|
||||
this.activeRead = null
|
||||
this.floor = null
|
||||
this.pending = null
|
||||
this.inFlight = false
|
||||
this.invalidatedDuringFlight = false
|
||||
this.refreshRequested = false
|
||||
}
|
||||
|
||||
begin(): number {
|
||||
this.readSequence += 1
|
||||
this.activeRead = this.readSequence
|
||||
this.inFlight = true
|
||||
this.invalidatedDuringFlight = false
|
||||
this.refreshRequested = false
|
||||
return this.readSequence
|
||||
}
|
||||
|
||||
observe(event: GitRepositorySnapshotSubscriptionEvent): boolean {
|
||||
if (this.floor && compareEvent(event, this.floor) <= 0) {
|
||||
return false
|
||||
}
|
||||
this.floor = event
|
||||
if (event.state === 'invalidated') {
|
||||
if (this.inFlight) {
|
||||
this.invalidatedDuringFlight = true
|
||||
}
|
||||
if (this.pending && compareEvent(this.pending, event) <= 0) {
|
||||
this.pending = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (!this.pending || compareEvent(event, this.pending) > 0) {
|
||||
this.pending = event
|
||||
}
|
||||
if (this.inFlight || this.refreshRequested) {
|
||||
return false
|
||||
}
|
||||
this.refreshRequested = true
|
||||
return true
|
||||
}
|
||||
|
||||
finish(read: number, observedRevision: number | null): boolean {
|
||||
if (this.activeRead !== read) {
|
||||
return false
|
||||
}
|
||||
this.activeRead = null
|
||||
this.inFlight = false
|
||||
const pending = this.pending
|
||||
if (
|
||||
!pending ||
|
||||
(!this.invalidatedDuringFlight &&
|
||||
observedRevision !== null &&
|
||||
observedRevision >= pending.revision)
|
||||
) {
|
||||
this.pending = null
|
||||
this.invalidatedDuringFlight = false
|
||||
return false
|
||||
}
|
||||
this.invalidatedDuringFlight = false
|
||||
if (this.refreshRequested) {
|
||||
return false
|
||||
}
|
||||
this.refreshRequested = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitRepositorySnapshotSubscriptionEvent } from '../../../../shared/git-repository-snapshot'
|
||||
import { useChecksPanelRepositorySnapshotRevision } from './use-checks-panel-repository-snapshot-revision'
|
||||
|
||||
const subscribe = vi.fn()
|
||||
|
||||
vi.mock('@/runtime/desktop-git-repository-snapshot-client', () => ({
|
||||
subscribeDesktopGitRepositorySnapshot: (
|
||||
...args: Parameters<typeof subscribe>
|
||||
): ReturnType<typeof subscribe> => subscribe(...args)
|
||||
}))
|
||||
|
||||
const context = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useChecksPanelRepositorySnapshotRevision', () => {
|
||||
beforeEach(() => {
|
||||
subscribe.mockReset()
|
||||
})
|
||||
|
||||
it('subscribes to both polling identities and tears down on context change', async () => {
|
||||
const unsubscribes = [vi.fn(), vi.fn(), vi.fn(), vi.fn()]
|
||||
subscribe.mockImplementation(async () => ({
|
||||
unsubscribe: unsubscribes[subscribe.mock.calls.length - 1]
|
||||
}))
|
||||
const requestRefresh = vi.fn()
|
||||
const { rerender, unmount } = renderHook(
|
||||
({ contextKey }) =>
|
||||
useChecksPanelRepositorySnapshotRevision({
|
||||
context,
|
||||
contextKey,
|
||||
enabled: true,
|
||||
pushTarget: null,
|
||||
requestRefresh
|
||||
}),
|
||||
{ initialProps: { contextKey: 'one' } }
|
||||
)
|
||||
|
||||
await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
|
||||
expect(subscribe.mock.calls.map((call) => call[1])).toEqual([{}, { reuseLineStats: true }])
|
||||
|
||||
rerender({ contextKey: 'two' })
|
||||
await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(4))
|
||||
expect(unsubscribes[0]).toHaveBeenCalledOnce()
|
||||
expect(unsubscribes[1]).toHaveBeenCalledOnce()
|
||||
|
||||
unmount()
|
||||
expect(unsubscribes[2]).toHaveBeenCalledOnce()
|
||||
expect(unsubscribes[3]).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces ready delivery and consumes self-publication without another refresh', async () => {
|
||||
const callbacks: ((event: GitRepositorySnapshotSubscriptionEvent) => void)[] = []
|
||||
subscribe.mockImplementation(async (_context, _options, callback) => {
|
||||
callbacks.push(callback)
|
||||
return { unsubscribe: vi.fn() }
|
||||
})
|
||||
const requestRefresh = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useChecksPanelRepositorySnapshotRevision({
|
||||
context,
|
||||
contextKey: 'one',
|
||||
enabled: true,
|
||||
pushTarget: null,
|
||||
requestRefresh
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(callbacks).toHaveLength(2))
|
||||
const ready = { state: 'ready', generation: 0, revision: 1, incarnation: 0 } as const
|
||||
|
||||
act(() => {
|
||||
callbacks[0](ready)
|
||||
callbacks[1](ready)
|
||||
})
|
||||
expect(requestRefresh).toHaveBeenCalledOnce()
|
||||
|
||||
let read = 0
|
||||
act(() => {
|
||||
read = result.current.beginRead()
|
||||
})
|
||||
act(() => callbacks[0]({ ...ready, revision: 2 }))
|
||||
expect(result.current.finishRead(read, 2)).toBe(false)
|
||||
expect(requestRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not subscribe while disabled and cleans a partial subscription failure', async () => {
|
||||
const unsubscribe = vi.fn()
|
||||
subscribe.mockResolvedValueOnce({ unsubscribe }).mockRejectedValueOnce(new Error('unavailable'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) =>
|
||||
useChecksPanelRepositorySnapshotRevision({
|
||||
context,
|
||||
contextKey: 'one',
|
||||
enabled,
|
||||
pushTarget: null,
|
||||
requestRefresh: vi.fn()
|
||||
}),
|
||||
{ initialProps: { enabled: false } }
|
||||
)
|
||||
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
rerender({ enabled: true })
|
||||
await waitFor(() => expect(warn).toHaveBeenCalledOnce())
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
rerender({ enabled: false })
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('cleans a settled handle while the sibling subscription is still pending', async () => {
|
||||
const pending = deferred<{ unsubscribe: () => void }>()
|
||||
const firstUnsubscribe = vi.fn()
|
||||
const secondUnsubscribe = vi.fn()
|
||||
subscribe
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValueOnce({ unsubscribe: secondUnsubscribe })
|
||||
const { unmount } = renderHook(() =>
|
||||
useChecksPanelRepositorySnapshotRevision({
|
||||
context,
|
||||
contextKey: 'one',
|
||||
enabled: true,
|
||||
pushTarget: null,
|
||||
requestRefresh: vi.fn()
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(subscribe).toHaveBeenCalledTimes(2))
|
||||
|
||||
unmount()
|
||||
expect(secondUnsubscribe).toHaveBeenCalledOnce()
|
||||
pending.resolve({ unsubscribe: firstUnsubscribe })
|
||||
await waitFor(() => expect(firstUnsubscribe).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import type { GitPushTarget } from '../../../../shared/types'
|
||||
import {
|
||||
subscribeDesktopGitRepositorySnapshot,
|
||||
type DesktopGitRepositorySnapshotContext
|
||||
} from '@/runtime/desktop-git-repository-snapshot-client'
|
||||
import { ChecksPanelRepositorySnapshotRevisionGate } from './checks-panel-repository-snapshot-revision-gate'
|
||||
|
||||
type Input = {
|
||||
context: DesktopGitRepositorySnapshotContext
|
||||
contextKey: string
|
||||
enabled: boolean
|
||||
pushTarget: GitPushTarget | null
|
||||
requestRefresh: () => void
|
||||
}
|
||||
|
||||
export function useChecksPanelRepositorySnapshotRevision(input: Input): {
|
||||
beginRead: () => number
|
||||
finishRead: (read: number, observedRevision: number | null) => boolean
|
||||
} {
|
||||
const { context, contextKey, enabled, pushTarget, requestRefresh } = input
|
||||
const gateRef = useRef(new ChecksPanelRepositorySnapshotRevisionGate())
|
||||
|
||||
useEffect(() => {
|
||||
const gate = gateRef.current
|
||||
gate.reset()
|
||||
if (!enabled) {
|
||||
return undefined
|
||||
}
|
||||
let stale = false
|
||||
const handles: { unsubscribe: () => void }[] = []
|
||||
const subscribe = async (): Promise<void> => {
|
||||
const options = pushTarget ? { pushTarget } : {}
|
||||
const callback = (event: Parameters<typeof gate.observe>[0]): void => {
|
||||
if (!stale && gate.observe(event)) {
|
||||
requestRefresh()
|
||||
}
|
||||
}
|
||||
const retain = async (
|
||||
request: ReturnType<typeof subscribeDesktopGitRepositorySnapshot>
|
||||
): Promise<void> => {
|
||||
const handle = await request
|
||||
if (!handle) {
|
||||
return
|
||||
}
|
||||
if (stale) {
|
||||
handle.unsubscribe()
|
||||
return
|
||||
}
|
||||
handles.push(handle)
|
||||
}
|
||||
const settled = await Promise.allSettled([
|
||||
retain(subscribeDesktopGitRepositorySnapshot(context, options, callback)),
|
||||
retain(
|
||||
subscribeDesktopGitRepositorySnapshot(
|
||||
context,
|
||||
{ ...options, reuseLineStats: true },
|
||||
callback
|
||||
)
|
||||
)
|
||||
])
|
||||
const rejected = settled.find(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
||||
)
|
||||
if (rejected) {
|
||||
for (const handle of handles.splice(0)) {
|
||||
handle.unsubscribe()
|
||||
}
|
||||
throw rejected.reason
|
||||
}
|
||||
}
|
||||
void subscribe().catch((error) => {
|
||||
if (!stale) {
|
||||
console.warn('[ChecksPanel] repository snapshot subscription failed', error)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
for (const handle of handles) {
|
||||
handle.unsubscribe()
|
||||
}
|
||||
gate.reset()
|
||||
}
|
||||
}, [context, contextKey, enabled, pushTarget, requestRefresh])
|
||||
|
||||
const beginRead = useCallback((): number => gateRef.current.begin(), [])
|
||||
const finishRead = useCallback(
|
||||
(read: number, observedRevision: number | null): boolean =>
|
||||
gateRef.current.finish(read, observedRevision),
|
||||
[]
|
||||
)
|
||||
|
||||
return useMemo(() => ({ beginRead, finishRead }), [beginRead, finishRead])
|
||||
}
|
||||
@@ -1,21 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDesktopGitRepositorySnapshot } from './desktop-git-repository-snapshot-client'
|
||||
import {
|
||||
getDesktopGitRepositorySnapshot,
|
||||
subscribeDesktopGitRepositorySnapshot
|
||||
} from './desktop-git-repository-snapshot-client'
|
||||
|
||||
const repositorySnapshot = vi.fn()
|
||||
const subscribeRepositorySnapshot = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
repositorySnapshot.mockReset()
|
||||
subscribeRepositorySnapshot.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
git: { repositorySnapshot },
|
||||
git: { repositorySnapshot, subscribeRepositorySnapshot },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentCall }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDesktopGitRepositorySnapshot', () => {
|
||||
it('subscribes with exact desktop host, option, and push-target identity', async () => {
|
||||
const handle = { unsubscribe: vi.fn() }
|
||||
const callback = vi.fn()
|
||||
const pushTarget = {
|
||||
remoteName: 'fork',
|
||||
branchName: 'feature/checks',
|
||||
remoteCreated: false
|
||||
}
|
||||
subscribeRepositorySnapshot.mockResolvedValue(handle)
|
||||
|
||||
await expect(
|
||||
subscribeDesktopGitRepositorySnapshot(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1'
|
||||
},
|
||||
{ reuseLineStats: true, pushTarget },
|
||||
callback
|
||||
)
|
||||
).resolves.toBe(handle)
|
||||
|
||||
expect(subscribeRepositorySnapshot).toHaveBeenCalledWith(
|
||||
{
|
||||
worktreePath: '/repo',
|
||||
connectionId: 'ssh-1',
|
||||
reuseLineStats: true,
|
||||
pushTarget
|
||||
},
|
||||
callback
|
||||
)
|
||||
})
|
||||
|
||||
it('reads the exact desktop host snapshot identity', async () => {
|
||||
repositorySnapshot.mockResolvedValue(null)
|
||||
const pushTarget = {
|
||||
@@ -88,4 +127,20 @@ describe('getDesktopGitRepositorySnapshot', () => {
|
||||
expect(repositorySnapshot).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not subscribe through desktop transport for active runtime environments', async () => {
|
||||
await expect(
|
||||
subscribeDesktopGitRepositorySnapshot(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' },
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
{},
|
||||
vi.fn()
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(subscribeRepositorySnapshot).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import type { GitPushTarget } from '../../../shared/types'
|
||||
import type { GitRepositorySnapshot } from '../../../shared/git-repository-snapshot'
|
||||
import type {
|
||||
GitRepositorySnapshot,
|
||||
GitRepositorySnapshotRequest,
|
||||
GitRepositorySnapshotSubscriptionEvent
|
||||
} from '../../../shared/git-repository-snapshot'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../shared/worktree-id'
|
||||
import { getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import type { RuntimeGitContext } from './runtime-git-client'
|
||||
|
||||
export type DesktopGitRepositorySnapshotContext = RuntimeGitContext
|
||||
|
||||
function resolveDesktopSnapshotWorktreePath(context: RuntimeGitContext): string {
|
||||
return context.worktreeId
|
||||
? (splitWorktreeIdForFilesystem(context.worktreeId)?.worktreePath ?? context.worktreePath)
|
||||
: context.worktreePath
|
||||
}
|
||||
|
||||
function createDesktopSnapshotRequest(
|
||||
context: RuntimeGitContext,
|
||||
options: {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
): GitRepositorySnapshotRequest {
|
||||
return {
|
||||
worktreePath: resolveDesktopSnapshotWorktreePath(context),
|
||||
connectionId: context.connectionId,
|
||||
...(options.includeIgnored ? { includeIgnored: true } : {}),
|
||||
...(options.bypassEffectiveUpstreamNegativeCache
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
...(options.reuseLineStats ? { reuseLineStats: true } : {}),
|
||||
...(options.pushTarget ? { pushTarget: options.pushTarget } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDesktopGitRepositorySnapshot(
|
||||
context: RuntimeGitContext,
|
||||
options: {
|
||||
@@ -23,14 +50,25 @@ export async function getDesktopGitRepositorySnapshot(
|
||||
if (target.kind !== 'local' && context.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return window.api.git.repositorySnapshot({
|
||||
worktreePath: resolveDesktopSnapshotWorktreePath(context),
|
||||
connectionId: context.connectionId,
|
||||
...(options.includeIgnored ? { includeIgnored: true } : {}),
|
||||
...(options.bypassEffectiveUpstreamNegativeCache
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
...(options.reuseLineStats ? { reuseLineStats: true } : {}),
|
||||
...(options.pushTarget ? { pushTarget: options.pushTarget } : {})
|
||||
})
|
||||
return window.api.git.repositorySnapshot(createDesktopSnapshotRequest(context, options))
|
||||
}
|
||||
|
||||
export async function subscribeDesktopGitRepositorySnapshot(
|
||||
context: RuntimeGitContext,
|
||||
options: {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
},
|
||||
callback: (event: GitRepositorySnapshotSubscriptionEvent) => void
|
||||
): Promise<{ unsubscribe: () => void } | null> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind !== 'local' && context.worktreeId) {
|
||||
return null
|
||||
}
|
||||
return window.api.git.subscribeRepositorySnapshot(
|
||||
createDesktopSnapshotRequest(context, options),
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import type { GitConflictOperation, GitStatusEntry, GitUpstreamStatus } from './types'
|
||||
import type {
|
||||
GitConflictOperation,
|
||||
GitPushTarget,
|
||||
GitStatusEntry,
|
||||
GitUpstreamStatus
|
||||
} from './types'
|
||||
|
||||
export type GitRepositorySnapshotRequest = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
pushTarget?: GitPushTarget
|
||||
}
|
||||
|
||||
export type GitRepositoryProjectionFreshness = Readonly<{
|
||||
state: 'missing' | 'fresh' | 'stale' | 'failed' | 'placeholder'
|
||||
@@ -8,6 +22,15 @@ export type GitRepositoryProjectionFreshness = Readonly<{
|
||||
identity: string | null
|
||||
}>
|
||||
|
||||
export type GitRepositorySnapshotRevisionEvent = Readonly<{
|
||||
state: 'invalidated' | 'ready'
|
||||
generation: number
|
||||
revision: number
|
||||
}>
|
||||
|
||||
export type GitRepositorySnapshotSubscriptionEvent = GitRepositorySnapshotRevisionEvent &
|
||||
Readonly<{ incarnation: number }>
|
||||
|
||||
export type GitRepositorySnapshot = Readonly<{
|
||||
revision: number
|
||||
generatedAt: number
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Phase 2 Git repository snapshot revision subscription — 2026-07-30
|
||||
|
||||
## Decision
|
||||
|
||||
Retain the host-to-renderer generation/revision subscription and migrate automatic desktop
|
||||
Checks invalidation from renderer Git-status/upstream store references to the host-owned
|
||||
`GitRepositorySnapshot` seam. The active poller remains the producer, and a ready event only
|
||||
causes Checks to query the immutable projection; it does not run Git or an SSH RPC itself.
|
||||
|
||||
Checkpoint: `6fbeb2abda6e987f459e452942aedbcec937ecb7`.
|
||||
|
||||
This adds no timer, TTL, settled-result cache, Git command, SSH RPC method, relay method, runtime
|
||||
transport, or renderer store owner. Existing status/upstream APIs remain fresh after settlement.
|
||||
|
||||
## Subscription boundary
|
||||
|
||||
Each subscription is exact for:
|
||||
|
||||
- native versus exact WSL distro or the current `SshGitProvider` instance;
|
||||
- registered worktree/folder backing path and local shared-link paths;
|
||||
- status options including `includeIgnored`, negative-cache bypass, and `reuseLineStats`;
|
||||
- configured upstream versus every explicit `GitPushTarget` field, including absent
|
||||
`remoteCreated` versus explicit `false`; and
|
||||
- the subscribing `WebContents`.
|
||||
|
||||
IPC events contain only the caller-generated subscription ID, state, generation, revision, and SSH
|
||||
provider incarnation. Repository paths and snapshots are never broadcast. Explicit unsubscribe,
|
||||
sender destruction/reload, context or visibility change, connection loss, provider replacement,
|
||||
and handler disposal all detach the exact owner listener.
|
||||
|
||||
Checks derives its local identity from the resolved project runtime, so changing a project-level
|
||||
Windows-host/WSL override or WSL distro changes the context and rebinds the subscription.
|
||||
|
||||
SSH registry replacement increments `incarnation`, detaches the old provider before rebinding, and
|
||||
starts the replacement provider at generation/revision zero. A binding token suppresses late
|
||||
events from the old provider. Active runtime environments return `null` from the desktop
|
||||
subscription client and keep the existing runtime/SSH visibility interval and fresh RPC route.
|
||||
|
||||
The owner emits `invalidated` at existing mutation fences and `ready` only when the requested
|
||||
status plus configured or explicit upstream projection is admissible. Retention-truncated status
|
||||
and ambiguous ahead-plus-behind upstream state do not wake Checks. A retained explicit upstream
|
||||
can complete a later status revision only when HEAD and branch identity are unchanged.
|
||||
An ambiguous newer embedded configured-upstream projection cannot fall through to an older
|
||||
complete projection.
|
||||
|
||||
Checks subscribes to the ordinary and `reuseLineStats: true` polling identities. Its revision gate:
|
||||
|
||||
1. advances invalidation/provider-incarnation fences without launching work;
|
||||
2. coalesces duplicate same-revision delivery;
|
||||
3. records ready events arriving during a Checks read;
|
||||
4. queries the owner again after a fresh fallback without running Git; and
|
||||
5. schedules a trailing read only when the newest ready revision is newer than the projection the
|
||||
completed read observed.
|
||||
|
||||
Monotonic read tokens fence late completion from a previous context. Subscription handles are
|
||||
retained as each registration settles, so context cleanup is exact even if its sibling registration
|
||||
remains pending. A renderer send failure closes the main-process subscription and cannot rebind a
|
||||
replacement SSH provider or reject the status/upstream producer.
|
||||
|
||||
This removes `gitStatusByWorktree` and `remoteStatusesByWorktree` from the automatic Checks
|
||||
snapshot effect dependencies. Those store values remain only as the existing bounded Publish
|
||||
fallback, while manual Refresh, retry, runtime routing, and cold/inadmissible fallback behavior are
|
||||
unchanged.
|
||||
|
||||
## Deterministic operation counts
|
||||
|
||||
The retained owner test overlaps two identical status callers and two identical explicit-upstream
|
||||
callers, then has the ready listener query the projection.
|
||||
|
||||
| Boundary | A | B |
|
||||
| --------------------------------------------------------------------- | --: | --: |
|
||||
| Physical local/WSL status reads | 1 | 1 |
|
||||
| Physical local/WSL explicit-upstream reads | 1 | 1 |
|
||||
| SSH `git.status` RPCs under the retained settled-overlap path | 1 | 1 |
|
||||
| SSH `git.upstreamStatus` RPCs under the retained settled-overlap path | 1 | 1 |
|
||||
| Physical work launched by one ready event | 0 | 0 |
|
||||
| Automatic Checks store-trigger dependencies | 2 | 0 |
|
||||
| Exact owner projection queries after a fresh fallback | 0 | 1 |
|
||||
|
||||
The extra B query is memory-only. It observes the publication revision so the ready event produced
|
||||
by the same fallback does not launch duplicate status/upstream work. Existing ten-caller status
|
||||
and upstream owner suites continue to prove 10-to-1 physical sharing and unchanged exact commands
|
||||
and RPC payloads.
|
||||
|
||||
For fan-out, 100 same-identity owner subscribers receive 100 callbacks backed by one frozen owner
|
||||
event object. After 100 unsubscriptions the owner retains zero listeners. Checks normally owns two
|
||||
preload listeners, one per exact polling identity; both are removed on cleanup. Main allocates one
|
||||
small incarnation wrapper per targeted subscription because different SSH incarnations cannot
|
||||
share that object.
|
||||
|
||||
## Production A/B
|
||||
|
||||
Fresh builds were archived outside the worktree:
|
||||
|
||||
- A: `/tmp/orca-phase2-snapshot-revision-subscription-a.h4Db8W/out`
|
||||
- retained B: `/tmp/orca-phase2-snapshot-revision-subscription-reviewed.sd8qpB/out`
|
||||
|
||||
| Artifact | A raw / gzip | B raw / gzip | Delta | A SHA-256 | B SHA-256 |
|
||||
| ---------------------- | ----------------- | ----------------- | --------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Electron main entry | 789,869 / 176,858 | 795,061 / 177,868 | +5,192 / +1,010 | `7f4984a54c65cf5b749a87ed7ec7f2074c5b844992215ad25e8c17d179045660` | `e536ed0c16ca8071e74fb1bfdf9253ea8c75b2e1cf9cb40987f6f78085de5f7e` |
|
||||
| Electron preload entry | 130,891 / 20,658 | 131,910 / 20,835 | +1,019 / +177 | `afa8494e803f0e57cabdb5d2ff636f30fc8021069a7694d57d2ecdcdd6837a8a` | `2bdcea2978045dcc5ac328c8cc4a32ab2c49da83fcd82ebaa2c8f69f9532c0fc` |
|
||||
| Renderer manifest | 403,000 / 48,288 | 403,037 / 48,306 | +37 / +18 | `9f57d550a1c9f6c75bf2d3db4f5ff53e53c4c52eceb8b10e9b86d8659a48d7fe` | `b615db1461347955088d93a91186d2b07997b761eb2e9a21c50433261914f731` |
|
||||
|
||||
Electron main remains 30,048 raw bytes below its unchanged 825,109 budget. Preload remains 90 raw
|
||||
bytes below its unchanged 132,000 budget.
|
||||
|
||||
| Renderer entry | A raw / gzip | B raw / gzip | Delta | A JS/CSS | B JS/CSS |
|
||||
| -------------- | --------------------- | --------------------- | ------ | -------- | -------- |
|
||||
| Index | 8,416,540 / 1,877,824 | 8,416,540 / 1,877,828 | 0 / +4 | 292 / 2 | 292 / 2 |
|
||||
| Popout | 4,507,253 / 984,615 | 4,507,253 / 984,615 | 0 / 0 | 77 / 2 | 77 / 2 |
|
||||
| Web | 4,360,652 / 928,352 | 4,360,652 / 928,351 | 0 / -1 | 33 / 1 | 33 / 1 |
|
||||
|
||||
| Tree | A files / SHA-256 | B files / SHA-256 |
|
||||
| -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| Main | 184 / `fb9d10e85030515e08c78e1226fa574c3c3828a68b2639b4a0cf0773b185fd11` | 184 / `d30fbdb2eedf419d28f4aeda85f1df22877329621c12c951b2c4412d155328c8` |
|
||||
| Preload | 1 / `750483cc40dc0e5f9612ea63f2e09d587ef77230ceda36144ee1a380c0274908` | 1 / `c220e1f7cda9e772c28b894048aae889c81bd7167811f10df9a3cd601cf250ef` |
|
||||
| Renderer | 787 / `3e37f385175e2b51309723d5b5bf50742d9256bb11399f1b3913122e87850acb` | 787 / `422a591173e891ae1de38a9014ece3ef2342131b2e5636466baf4a608fab2a02` |
|
||||
|
||||
Complete A and B manifest validation found 778 records, three HTML entries, 213 dynamic edges,
|
||||
860 emitted references, no missing or escaping targets, no cross-entry imports, and no static
|
||||
cycles. A has 6,247 static edges and B has 6,248 because the Checks chunk now imports its concrete
|
||||
revision hook.
|
||||
|
||||
## Independent review
|
||||
|
||||
Independent acceptance added regression coverage and corrected:
|
||||
|
||||
- ambiguous embedded configured-upstream admission through an older projection;
|
||||
- a late read from a previous context consuming the current context's revision;
|
||||
- sender delivery failure escaping into a producer or leaking an SSH rebind;
|
||||
- a successful subscription handle being hidden from cleanup by a pending sibling; and
|
||||
- project-level Windows/WSL runtime changes not rebinding the exact host identity.
|
||||
|
||||
The core IPC registrar test was also updated to exercise the new store-bound registrar.
|
||||
|
||||
## Validation
|
||||
|
||||
- Focused owner, IPC, preload, provider registry, desktop routing, status projection, revision gate,
|
||||
and hook tests: 75 passed.
|
||||
- Broad host status/cache, native/WSL, SSH provider/dispatch, filesystem, IPC, and preload suite:
|
||||
376 passed, one skipped.
|
||||
- Broad renderer snapshot, polling, refresh scheduler, folder-workspace, runtime routing, and Checks
|
||||
suite: 121 passed.
|
||||
- Core IPC registrar suite: two passed.
|
||||
- `pnpm run typecheck:node`: passed.
|
||||
- `pnpm run typecheck:web`: passed.
|
||||
- Targeted `pnpm exec oxlint --deny-warnings`: passed.
|
||||
- Targeted `pnpm exec oxfmt --check`: passed.
|
||||
- `pnpm run check:max-lines-ratchet`: passed with 354 grandfathered suppressions and no new bypass.
|
||||
- Fresh A and retained B `pnpm run build:electron-vite`: passed.
|
||||
- `pnpm run check:electron-bundle-budgets`: passed against retained B.
|
||||
- Complete renderer manifest/path/static-cycle validation: passed for A and B.
|
||||
- `git diff --check`: passed.
|
||||
- Added/changed max-lines-disable scan: zero.
|
||||
|
||||
The production build emitted the two pre-existing CSS optimizer warnings for the standard
|
||||
`::highlight(markdown-preview-search-*)` selectors; no new build warning was introduced.
|
||||
|
||||
## Limitations and next seam
|
||||
|
||||
- Subscription savings require active polling to publish an admissible status/upstream projection.
|
||||
Cold, failed, stale, truncated, branch-mismatched, runtime-routed, and manual-refresh paths retain
|
||||
the fresh fallback.
|
||||
- Invalidation alone deliberately does not wake Checks. The matching ready event follows the
|
||||
active poller's complete status/upstream publication, preventing a status-only wakeup from
|
||||
launching duplicate upstream work.
|
||||
- Measurements are deterministic mocked owner/RPC operation counts, not live Git latency across
|
||||
every supported host.
|
||||
- Runtime/mobile snapshot transport and renderer snapshot payload subscriptions remain later work.
|
||||
The next seam is migrating another desktop status consumer to the revision/query boundary, then
|
||||
introducing a bounded provider-neutral transport only when a runtime consumer is selected.
|
||||
- No packaged smoke was run on Windows, WSL, Linux, or a live SSH host. Existing path, identity,
|
||||
provider replacement, mutation fence, command, and routing suites cover the unchanged behavior.
|
||||
Reference in New Issue
Block a user