diff --git a/src/main/git/git-repository-snapshot-owner.ts b/src/main/git/git-repository-snapshot-owner.ts index bf792bfa316..6a926eb12dc 100644 --- a/src/main/git/git-repository-snapshot-owner.ts +++ b/src/main/git/git-repository-snapshot-owner.ts @@ -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 = { - generation: number - generatedAt: number - revision: number - value: T - failedGeneration?: number -} - -type RepositoryRecord = { - status: Map> - upstream: Map>> -} - 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() private readonly upstreamReads = new InFlightPromiseDedupe() - private readonly repositories = new Map() + private readonly repositories = new Map() + 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(projections: Map, key: string, value: T): void { diff --git a/src/main/git/git-repository-snapshot-reader.ts b/src/main/git/git-repository-snapshot-reader.ts new file mode 100644 index 00000000000..ce7bc60bb45 --- /dev/null +++ b/src/main/git/git-repository-snapshot-reader.ts @@ -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 + }) + }) + }) +} diff --git a/src/main/git/git-repository-snapshot-revision-subscriptions.ts b/src/main/git/git-repository-snapshot-revision-subscriptions.ts new file mode 100644 index 00000000000..82c6ac6c97c --- /dev/null +++ b/src/main/git/git-repository-snapshot-revision-subscriptions.ts @@ -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 = { + generation: number + generatedAt: number + revision: number + value: T + failedGeneration?: number +} + +export type GitRepositoryRecord = { + status: Map> + upstream: Map>> +} + +type SnapshotSubscription = { + statusKey: string + upstreamKey: string + usesConfiguredUpstream: boolean + lastReadyRevision: number + listener: (event: GitRepositorySnapshotRevisionEvent) => void +} + +export class GitRepositorySnapshotRevisionSubscriptions { + private readonly subscriptions = new Map>() + + 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 | 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() + 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() + 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, + generation: number + ): boolean { + return ( + record.generation === generation && + record.failedGeneration !== generation && + !record.value.status.retentionTruncated + ) + } + + private upstreamRecordIsReady( + record: GitRepositoryProjectionRecord> | undefined, + generation: number + ): record is GitRepositoryProjectionRecord> { + return ( + record !== undefined && + record.generation === generation && + record.failedGeneration !== generation + ) + } + + private upstreamValueIsComplete(value: Readonly): 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 + ): 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) + } +} diff --git a/src/main/git/git-repository-snapshot-subscription.test.ts b/src/main/git/git-repository-snapshot-subscription.test.ts new file mode 100644 index 00000000000..eaf1b2ac8df --- /dev/null +++ b/src/main/git/git-repository-snapshot-subscription.test.ts @@ -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((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() + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 0ffdb345ece..45d2054e508 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -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' } } diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 2a6791feda0..8b72121d17c 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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 => { + async (_event, args: GitRepositorySnapshotRequest): Promise => { 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) } ) diff --git a/src/main/ipc/git-repository-snapshot-request.ts b/src/main/ipc/git-repository-snapshot-request.ts new file mode 100644 index 00000000000..fe64ecda2e1 --- /dev/null +++ b/src/main/ipc/git-repository-snapshot-request.ts @@ -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 } : {}) + } + } +} diff --git a/src/main/ipc/git-repository-snapshot-subscriptions.test.ts b/src/main/ipc/git-repository-snapshot-subscriptions.test.ts new file mode 100644 index 00000000000..6a0b7f23c65 --- /dev/null +++ b/src/main/ipc/git-repository-snapshot-subscriptions.test.ts @@ -0,0 +1,301 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handlers: new Map 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 +} + +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 { + return mocks.handlers.get(channel) as (event: never, args: never) => Promise +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((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) + }) +}) diff --git a/src/main/ipc/git-repository-snapshot-subscriptions.ts b/src/main/ipc/git-repository-snapshot-subscriptions.ts new file mode 100644 index 00000000000..bfe2fb945e3 --- /dev/null +++ b/src/main/ipc/git-repository-snapshot-subscriptions.ts @@ -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() +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 +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 707c6fd7c69..e9ce1689096 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -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) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index ef903dcae05..ecc050249da 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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) diff --git a/src/main/providers/ssh-git-dispatch.test.ts b/src/main/providers/ssh-git-dispatch.test.ts index a975f3db475..ca1c5dd4621 100644 --- a/src/main/providers/ssh-git-dispatch.test.ts +++ b/src/main/providers/ssh-git-dispatch.test.ts @@ -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) + }) }) diff --git a/src/main/providers/ssh-git-dispatch.ts b/src/main/providers/ssh-git-dispatch.ts index c0acc757ada..a4813d70921 100644 --- a/src/main/providers/ssh-git-dispatch.ts +++ b/src/main/providers/ssh-git-dispatch.ts @@ -2,21 +2,43 @@ import type { SshGitProvider } from './ssh-git-provider' const sshProviders = new Map() const sshProviderGenerations = new Map() +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) + } +} diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index c2ca854cc5a..76e93a11925 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -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, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4ce5c976ce4..0ead38859e8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + subscribeRepositorySnapshot: ( + args: GitRepositorySnapshotRequest, + callback: (event: GitRepositorySnapshotSubscriptionEvent) => void + ) => Promise<{ unsubscribe: () => void }> submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/preload/git-repository-snapshot-subscriptions.test.ts b/src/preload/git-repository-snapshot-subscriptions.test.ts new file mode 100644 index 00000000000..4395eedb04b --- /dev/null +++ b/src/preload/git-repository-snapshot-subscriptions.test.ts @@ -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) + }) +}) diff --git a/src/preload/git-repository-snapshot-subscriptions.ts b/src/preload/git-repository-snapshot-subscriptions.ts new file mode 100644 index 00000000000..bd47ae67b09 --- /dev/null +++ b/src/preload/git-repository-snapshot-subscriptions.ts @@ -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 + 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 { + 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 }) + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c8d33670676..9776101e452 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('git:repositorySnapshot', args), + subscribeRepositorySnapshot: ( + args: GitRepositorySnapshotRequest, + callback: (event: GitRepositorySnapshotSubscriptionEvent) => void + ): Promise => + subscribeGitRepositorySnapshotFromPreload(ipcRenderer, args, callback), submoduleStatus: (args: { worktreePath: string submodulePath: string diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 0d500523845..30fbd301cc6 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -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(() => { + 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[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 diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts index bd2e964c0fc..bf4f0d4bea7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.test.ts @@ -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', diff --git a/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.test.ts new file mode 100644 index 00000000000..9439603e27d --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.ts b/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.ts new file mode 100644 index 00000000000..024fd4c6ba7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-repository-snapshot-revision-gate.ts @@ -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 + } +} diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.test.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.test.ts new file mode 100644 index 00000000000..bb56aaa1c18 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.test.ts @@ -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 + ): ReturnType => subscribe(...args) +})) + +const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo' +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((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()) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.ts new file mode 100644 index 00000000000..8522b8b85d0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-repository-snapshot-revision.ts @@ -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 => { + const options = pushTarget ? { pushTarget } : {} + const callback = (event: Parameters[0]): void => { + if (!stale && gate.observe(event)) { + requestRefresh() + } + } + const retain = async ( + request: ReturnType + ): Promise => { + 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]) +} diff --git a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts index 492bed74053..5ff07134c2b 100644 --- a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts +++ b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts index 3ee64a91941..e416a3af897 100644 --- a/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts +++ b/src/renderer/src/runtime/desktop-git-repository-snapshot-client.ts @@ -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 + ) } diff --git a/src/shared/git-repository-snapshot.ts b/src/shared/git-repository-snapshot.ts index 7136ae64446..9418257436f 100644 --- a/src/shared/git-repository-snapshot.ts +++ b/src/shared/git-repository-snapshot.ts @@ -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 diff --git a/tools/benchmarks/results/phase2-git-repository-snapshot-revision-subscription-2026-07-30.md b/tools/benchmarks/results/phase2-git-repository-snapshot-revision-subscription-2026-07-30.md new file mode 100644 index 00000000000..826f0772c02 --- /dev/null +++ b/tools/benchmarks/results/phase2-git-repository-snapshot-revision-subscription-2026-07-30.md @@ -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.