diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index 829aa8c3b82..168b32b48ad 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = { overflow: boolean structureRepoIds: string[] gitStatusRepoIds: string[] + headIdentityRepoIds: string[] +} + +type ChangeBuckets = { + structureRepoIds: Set + gitStatusRepoIds: Set + headIdentityRepoIds: Set +} + +function emptyBuckets(): ChangeBuckets { + return { + structureRepoIds: new Set(), + gitStatusRepoIds: new Set(), + headIdentityRepoIds: new Set() + } } function emptyChanges(): WorktreeBaseCollectedChanges { - return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] } + return { + overflow: false, + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } } function addMatchingChange( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent, - structureRepoIds: Set, - gitStatusRepoIds: Set + buckets: ChangeBuckets ): void { const change = classifyWorktreeBaseChange(target, event) for (const repoId of change.structureRepoIds) { - structureRepoIds.add(repoId) + buckets.structureRepoIds.add(repoId) } for (const repoId of change.gitStatusRepoIds) { - gitStatusRepoIds.add(repoId) + buckets.gitStatusRepoIds.add(repoId) + } + for (const repoId of change.headIdentityRepoIds) { + buckets.headIdentityRepoIds.add(repoId) } } -function toCollectedChanges( - structureRepoIds: Set, - gitStatusRepoIds: Set -): WorktreeBaseCollectedChanges { +function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges { return { overflow: false, - structureRepoIds: [...structureRepoIds], - gitStatusRepoIds: [...gitStatusRepoIds] + structureRepoIds: [...buckets.structureRepoIds], + gitStatusRepoIds: [...buckets.gitStatusRepoIds], + headIdentityRepoIds: [...buckets.headIdentityRepoIds] } } @@ -49,47 +69,30 @@ export function collectLocalWorktreeBaseChanges( target: WorktreeBaseWatchTarget, events: WorktreeBaseWatcherEvent[] ): WorktreeBaseCollectedChanges { - const structureRepoIds = new Set() - const gitStatusRepoIds = new Set() + const buckets = emptyBuckets() for (const event of events) { - addMatchingChange(target, event, structureRepoIds, gitStatusRepoIds) + addMatchingChange(target, event, buckets) } - return toCollectedChanges(structureRepoIds, gitStatusRepoIds) + return toCollectedChanges(buckets) } export function collectRemoteWorktreeBaseChanges( target: WorktreeBaseWatchTarget, events: FsChangeEvent[] ): WorktreeBaseCollectedChanges { - const structureRepoIds = new Set() - const gitStatusRepoIds = new Set() + const buckets = emptyBuckets() for (const event of events) { if (event.kind === 'overflow') { return { ...emptyChanges(), overflow: true } } if (event.kind === 'rename') { if (event.oldAbsolutePath) { - addMatchingChange( - target, - { type: 'delete', path: event.oldAbsolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: 'delete', path: event.oldAbsolutePath }, buckets) } - addMatchingChange( - target, - { type: 'create', path: event.absolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: 'create', path: event.absolutePath }, buckets) continue } - addMatchingChange( - target, - { type: event.kind, path: event.absolutePath }, - structureRepoIds, - gitStatusRepoIds - ) + addMatchingChange(target, { type: event.kind, path: event.absolutePath }, buckets) } - return toCollectedChanges(structureRepoIds, gitStatusRepoIds) + return toCollectedChanges(buckets) } diff --git a/src/main/ipc/worktree-base-directory-event-filter.test.ts b/src/main/ipc/worktree-base-directory-event-filter.test.ts index 2a87b9f0b25..330be8b925c 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -25,13 +25,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'create', path: join(COMMON_DIR, 'worktrees', 'wt-b') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( matchingWorktreeBaseRepoIds(target, { type: 'delete', @@ -44,12 +52,26 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { const target = makeGitCommonTarget() for (const file of ['HEAD', 'packed-refs']) { expect( - classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, file) }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, file) + }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) } expect( - classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + classifyWorktreeBaseChange(target, { + type: 'update', + path: join(COMMON_DIR, 'index') + }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('classifies linked-worktree index as status-only', () => { @@ -59,16 +81,24 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'index.lock') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) - it('classifies HEAD reflog appends as status-only for linked and primary checkouts', () => { + it('classifies HEAD reflog appends as head-identity triggers for linked and primary checkouts', () => { const target = makeGitCommonTarget() // commit --amend / reset --soft move HEAD without touching index or HEAD. expect( @@ -76,26 +106,42 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'HEAD') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'logs', 'HEAD') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: ['repo-1'] + }) // Per-ref reflogs churn on fetches and stay ignored. expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'logs', 'refs', 'heads', 'main') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'logs', 'refs', 'heads', 'main') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) it('classifies worktree-scoped config as structural for sparse-flag freshness', () => { @@ -105,13 +151,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: join(COMMON_DIR, 'worktrees', 'wt-a', 'config.worktree') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'create', path: join(COMMON_DIR, 'config.worktree') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) }) it('classifies Windows-shaped linked metadata paths', () => { @@ -126,13 +180,21 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { type: 'update', path: win32.join(commonDir, 'worktrees', 'wt a', 'gitdir') }) - ).toEqual({ structureRepoIds: ['repo-1'], gitStatusRepoIds: [] }) + ).toEqual({ + structureRepoIds: ['repo-1'], + gitStatusRepoIds: [], + headIdentityRepoIds: [] + }) expect( classifyWorktreeBaseChange(target, { type: 'update', path: win32.join(commonDir, 'worktrees', 'wt a', 'index') }) - ).toEqual({ structureRepoIds: [], gitStatusRepoIds: ['repo-1'] }) + ).toEqual({ + structureRepoIds: [], + gitStatusRepoIds: ['repo-1'], + headIdentityRepoIds: [] + }) }) it('ignores non-status common-dir churn', () => { diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 7f161e33823..551af5a6568 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -11,6 +11,10 @@ type WorktreeBaseWatcherEvent = { export type WorktreeBaseChangeClass = { structureRepoIds: string[] gitStatusRepoIds: string[] + // logs/HEAD and other true head-move triggers: notify Source Control like + // status churn, but distinct so only these re-read head identities. An index + // rewrite cannot move HEAD, so it must never land here. + headIdentityRepoIds: string[] } export type WorktreeBaseWatchKind = 'base' | 'git-common' @@ -98,9 +102,10 @@ const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index']) const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) const GIT_COMMON_LINKED_STATUS_FILES = new Set(['index']) -// `logs/HEAD` is the status-only trigger for head moves that rewrite no +// `logs/HEAD` is the head-identity trigger for head moves that rewrite no // watched leaf (commit --amend, reset --soft): every ref update through a -// checkout appends there, while `git status` churn never touches it. +// checkout appends there, while `git status` churn never touches it. It is +// kept separate from index churn so only these events re-read head identities. function isHeadLogParts(parts: string[], offset: number): boolean { return parts.length === offset + 2 && parts[offset] === 'logs' && parts[offset + 1] === 'HEAD' } @@ -109,6 +114,36 @@ function allRepoIds(target: WorktreeBaseWatchTarget): string[] { return [...target.repos.keys()] } +const NO_CHANGE: WorktreeBaseChangeClass = { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: [] +} + +function structuralChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: repoIds, + gitStatusRepoIds: [], + headIdentityRepoIds: [] + } +} + +function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: repoIds, + headIdentityRepoIds: [] + } +} + +function headIdentityChange(repoIds: string[]): WorktreeBaseChangeClass { + return { + structureRepoIds: [], + gitStatusRepoIds: [], + headIdentityRepoIds: repoIds + } +} + // Why: Git records linked worktrees under the common dir's `worktrees` // metadata, which is lower churn than watching checkout contents. function classifyGitCommonEvent( @@ -117,54 +152,49 @@ function classifyGitCommonEvent( ): WorktreeBaseChangeClass { const parts = pathRelativeToWorktreeWatchRoot(target.path, event.path) if (!parts) { - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } const repoIds = allRepoIds(target) if (parts.length === 1) { if (parts[0] === 'worktrees') { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_PRIMARY_STRUCTURAL_FILES.has(parts[0])) { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return gitStatusChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } if (parts[0] !== 'worktrees') { if (isHeadLogParts(parts, 0)) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return headIdentityChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } if (parts.length === 2) { - return event.type === 'update' - ? { structureRepoIds: [], gitStatusRepoIds: [] } - : { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return event.type === 'update' ? NO_CHANGE : structuralChange(repoIds) } if (parts.length === 3) { if (GIT_COMMON_LINKED_STRUCTURAL_FILES.has(parts[2])) { - return { structureRepoIds: repoIds, gitStatusRepoIds: [] } + return structuralChange(repoIds) } if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return gitStatusChange(repoIds) } } if (isHeadLogParts(parts, 2)) { - return { structureRepoIds: [], gitStatusRepoIds: repoIds } + return headIdentityChange(repoIds) } - return { structureRepoIds: [], gitStatusRepoIds: [] } + return NO_CHANGE } function classifyBaseEvent( target: WorktreeBaseWatchTarget, event: WorktreeBaseWatcherEvent ): WorktreeBaseChangeClass { - return { - structureRepoIds: matchingBaseRepoIds(target, event.path, event.type), - gitStatusRepoIds: [] - } + return structuralChange(matchingBaseRepoIds(target, event.path, event.type)) } export function classifyWorktreeBaseChange( diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index 7f580421aaa..c560b5e51aa 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -116,7 +116,10 @@ describe('worktree base directory watcher', () => { emit(WORKTREE_ROOT, [ { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104') }, - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -133,7 +136,10 @@ describe('worktree base directory watcher', () => { ) emit(WORKTREE_ROOT, [ - { type: 'create', path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') } + { + type: 'create', + path: join(WORKTREE_ROOT, 'project', 'external-5104', '.git') + } ]) destroyed = true await vi.advanceTimersByTimeAsync(300) @@ -145,7 +151,10 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(WORKTREE_ROOT, [ - { type: 'update', path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') } + { + type: 'update', + path: join(WORKTREE_ROOT, 'project', 'existing', 'src', 'file.ts') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -156,7 +165,10 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'gitdir') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -167,9 +179,18 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }, - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') }, - { type: 'delete', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'delete', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -182,8 +203,14 @@ describe('worktree base directory watcher', () => { await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') }, - { type: 'create', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + }, + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'locked') + } ]) await vi.advanceTimersByTimeAsync(300) @@ -192,17 +219,25 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeGitStatusMetadataChanged).not.toHaveBeenCalled() }) - it('emits head identities for status-only head moves without structural fanout', async () => { + it('emits head identities for a linked reflog head move without structural fanout', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) // External commit --amend: only logs/HEAD moves, no index write. vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } ]) emit(PROJECT_GIT_COMMON_DIR, [ { @@ -217,50 +252,181 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ - { worktreePath: linkedWorktree, head: 'bbb222', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } ]) }) - it('does not emit head identities when a status-only burst leaves heads unchanged', async () => { + it('makes zero head-identity reads on an index-only burst across linked and primary checkouts', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) + // Subscribe baselines heads once; the index burst must add no further reads. + vi.mocked(readGitCommonHeadIdentities).mockClear() emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'create', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'index') } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) - expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + // An index rewrite cannot move HEAD, so the linked-worktree scan is skipped. + expect(readGitCommonHeadIdentities).not.toHaveBeenCalled() expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() + // Source Control still refreshes for the status churn, coalesced to one call. + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('emits head identities for a primary-checkout reflog head move', async () => { + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'aaa111', branch: 'refs/heads/main' } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { worktreePath: PROJECT_ROOT, head: 'bbb222', branch: 'refs/heads/main' } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'logs', 'HEAD') } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1', [ + { + worktreePath: PROJECT_ROOT, + head: 'bbb222', + branch: 'refs/heads/main' + } + ]) + }) + + it('coalesces an index and reflog burst into one head read and one status refresh', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + // reset --soft rewrites the index and appends logs/HEAD in the same burst. + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'bbb222', + branch: 'refs/heads/feature' + } + ]) + emit(PROJECT_GIT_COMMON_DIR, [ + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') + }, + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + ]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreeHeadIdentitiesChanged).toHaveBeenCalledTimes(1) + expect(notifyWorktreesChanged).not.toHaveBeenCalled() + }) + + it('debounces successive reflog events into a single head read', async () => { + const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') + vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } + ]) + await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) + await vi.advanceTimersByTimeAsync(0) + vi.mocked(readGitCommonHeadIdentities).mockClear() + + const reflog = { + type: 'update' as const, + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + // Second burst arrives before the debounce window elapses and resets it. + await vi.advanceTimersByTimeAsync(100) + emit(PROJECT_GIT_COMMON_DIR, [reflog]) + await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(0) + + expect(readGitCommonHeadIdentities).toHaveBeenCalledTimes(1) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) }) it('re-baselines head identities silently on structural notifications', async () => { const linkedWorktree = absolutePath('workspace', 'worktrees', 'project', 'external-5104') vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'aaa111', branch: 'refs/heads/feature' } + { + worktreePath: linkedWorktree, + head: 'aaa111', + branch: 'refs/heads/feature' + } ]) await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never) await vi.advanceTimersByTimeAsync(0) // Branch switch: structural path owns the refresh via the full listing. vi.mocked(readGitCommonHeadIdentities).mockResolvedValue([ - { worktreePath: linkedWorktree, head: 'ccc333', branch: 'refs/heads/other' } + { + worktreePath: linkedWorktree, + head: 'ccc333', + branch: 'refs/heads/other' + } ]) emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'HEAD') + } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) expect(notifyWorktreeHeadIdentitiesChanged).not.toHaveBeenCalled() - // A later status-only event diffs against the re-baselined heads. + // A later reflog event diffs against the re-baselined heads, so an + // unchanged head is not re-reported. emit(PROJECT_GIT_COMMON_DIR, [ - { type: 'update', path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'index') } + { + type: 'update', + path: join(PROJECT_GIT_COMMON_DIR, 'worktrees', 'external-5104', 'logs', 'HEAD') + } ]) await vi.advanceTimersByTimeAsync(300) await vi.advanceTimersByTimeAsync(0) diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index d62eb2384ba..23352d688f1 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & { notifyTimer: ReturnType | null pendingStructureRepoIds: Set pendingGitStatusRepoIds: Set + pendingHeadIdentityRepoIds: Set headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -35,13 +36,17 @@ let syncGeneration = 0 let scheduledSync: ReturnType | null = null let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null -function scheduleNotification( - watch: ActiveWatch, - changes: { structureRepoIds?: readonly string[]; gitStatusRepoIds?: readonly string[] } -): void { +function clearPendingRepoIds(watch: ActiveWatch): void { + watch.pendingStructureRepoIds.clear() + watch.pendingGitStatusRepoIds.clear() + watch.pendingHeadIdentityRepoIds.clear() +} + +type PendingNotificationInput = Partial> + +function scheduleNotification(watch: ActiveWatch, changes: PendingNotificationInput): void { if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) return } for (const repoId of changes.structureRepoIds ?? []) { @@ -50,37 +55,41 @@ function scheduleNotification( for (const repoId of changes.gitStatusRepoIds ?? []) { watch.pendingGitStatusRepoIds.add(repoId) } + for (const repoId of changes.headIdentityRepoIds ?? []) { + watch.pendingHeadIdentityRepoIds.add(repoId) + } if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) } watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null if (watch.disposed || watch.mainWindow.isDestroyed()) { - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) return } const pendingStructure = [...watch.pendingStructureRepoIds] - const pendingGitStatus = [...watch.pendingGitStatusRepoIds].filter( - (repoId) => !watch.pendingStructureRepoIds.has(repoId) + const hasHeadIdentity = watch.pendingHeadIdentityRepoIds.size > 0 + // Source Control refreshes on both index churn and head moves; structural + // repos already refresh via the authoritative listing, so drop them here. + const sourceControlRepoIds = new Set( + [...watch.pendingGitStatusRepoIds, ...watch.pendingHeadIdentityRepoIds].filter( + (repoId) => !watch.pendingStructureRepoIds.has(repoId) + ) ) - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + // Structural ticks refresh silently (emit=false): the authoritative listing + // already reported them, so this only re-baselines ahead of later head diffs. + const emitHeadIdentities = pendingStructure.length === 0 + clearPendingRepoIds(watch) for (const repoId of pendingStructure) { notifyWorktreesChanged(watch.mainWindow, repoId) } - for (const repoId of pendingGitStatus) { + for (const repoId of sourceControlRepoIds) { notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) } - if (supportsHeadIdentityRefresh(watch)) { - // Emit only when no structural notification fired this tick: structural - // paths already run the authoritative worktree listing; the silent pass - // just re-baselines so later status-only diffs stay accurate. - void refreshWorktreeHeadIdentities( - watch, - watch.headIdentityRefresh, - pendingStructure.length === 0 - ) + // Only re-read head identities for true head triggers: an index rewrite + // cannot move HEAD, so status-only bursts skip the linked-worktree scan. + if (supportsHeadIdentityRefresh(watch) && (pendingStructure.length > 0 || hasHeadIdentity)) { + void refreshWorktreeHeadIdentities(watch, watch.headIdentityRefresh, emitHeadIdentities) } }, WATCH_DEBOUNCE_MS) } @@ -92,7 +101,9 @@ function supportsHeadIdentityRefresh(watch: ActiveWatch): boolean { } function hasCollectedChanges(changes: WorktreeBaseCollectedChanges): boolean { - return changes.structureRepoIds.length > 0 || changes.gitStatusRepoIds.length > 0 + return [changes.structureRepoIds, changes.gitStatusRepoIds, changes.headIdentityRepoIds].some( + (ids) => ids.length > 0 + ) } function handleLocalWatchEvents( @@ -131,6 +142,24 @@ function handleRemoteWatchEvents( } } +function createActiveWatch( + target: WorktreeBaseWatchTarget, + mainWindow: BrowserWindow, + subscription: ActiveWatch['subscription'] +): ActiveWatch { + return { + ...target, + mainWindow, + subscription, + notifyTimer: null, + pendingStructureRepoIds: new Set(), + pendingGitStatusRepoIds: new Set(), + pendingHeadIdentityRepoIds: new Set(), + headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), + disposed: false + } +} + async function subscribeTarget( target: WorktreeBaseWatchTarget, mainWindow: BrowserWindow @@ -148,16 +177,9 @@ async function subscribeTarget( } handleRemoteWatchEvents(currentWatch, events) }) - activeWatch = { - ...target, - mainWindow, - subscription: { unsubscribe: async () => unwatch() }, - notifyTimer: null, - pendingStructureRepoIds: new Set(), - pendingGitStatusRepoIds: new Set(), - headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), - disposed: false - } + activeWatch = createActiveWatch(target, mainWindow, { + unsubscribe: async () => unwatch() + }) return activeWatch } @@ -176,16 +198,7 @@ async function subscribeTarget( handleLocalWatchEvents(currentWatch, null, events) } ) - activeWatch = { - ...target, - mainWindow, - subscription, - notifyTimer: null, - pendingStructureRepoIds: new Set(), - pendingGitStatusRepoIds: new Set(), - headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), - disposed: false - } + activeWatch = createActiveWatch(target, mainWindow, subscription) if (supportsHeadIdentityRefresh(activeWatch)) { // Baseline eagerly so the first status-only signal — possibly hours after // subscribe — diffs against subscribe-time heads instead of silently @@ -231,8 +244,7 @@ async function removeWatch(key: string): Promise { if (watch.notifyTimer) { clearTimeout(watch.notifyTimer) } - watch.pendingStructureRepoIds.clear() - watch.pendingGitStatusRepoIds.clear() + clearPendingRepoIds(watch) await watch.subscription.unsubscribe().catch((error) => { console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error) })