From 29aa2a4f0f18920b60fa9a5ee06e8e947d72338d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:40:22 -0400 Subject: [PATCH] Refresh Source Control only for the worktree whose git index changed (#26946) * Refresh Source Control only for the worktree whose git index changed Index and HEAD-log writes under /worktrees// now carry the checkout they belong to (resolved through the head-identity memo), and the renderer skips the status nudge when that checkout is a known sibling of the active one. Unattributable changes (shared config, bound upstream ref, overflow, SSH watches, unknown admin dirs) still refresh as before. * Pass the stable worktree-path getter as an effect dependency instead of a render-time ref * Fan out sibling head moves to every checkout's Source Control A commit to a shared branch (e.g. main) from a sibling worktree can move the active checkout's local upstream, whose ref leaf is not polled. Its logs/HEAD append now stays unattributed so ahead/behind refreshes immediately; index-only churn keeps its per-worktree attribution. --------- Co-authored-by: Neil --- ...orktree-base-directory-change-collector.ts | 12 +- ...rktree-base-directory-event-filter.test.ts | 64 +++++-- .../worktree-base-directory-event-filter.ts | 40 ++-- ...ktree-base-directory-notifications.test.ts | 180 ++++++++++++++++++ .../worktree-base-directory-notifications.ts | 14 +- .../worktree-base-directory-watcher.test.ts | 6 +- .../ipc/worktree-base-directory-watcher.ts | 1 + ...ee-git-status-attribution-real-git.test.ts | 134 +++++++++++++ .../worktree-head-identity-cached-paths.ts | 35 ++++ src/main/ipc/worktree-remote.ts | 9 +- ...aired-client-discovery.integration.test.ts | 4 + src/preload/api/worktree-api.ts | 5 +- src/preload/api/worktrees-bridge.ts | 12 +- .../git-status-push-signal-refresh.test.ts | 54 +++++- .../git-status-push-signal-refresh.ts | 20 +- .../git-status-signal-attribution.ts | 23 +++ .../right-sidebar/useGitStatusPolling.ts | 10 +- src/shared/worktree/types.ts | 7 + 18 files changed, 574 insertions(+), 56 deletions(-) create mode 100644 src/main/ipc/worktree-base-directory-notifications.test.ts create mode 100644 src/main/ipc/worktree-git-status-attribution-real-git.test.ts create mode 100644 src/main/ipc/worktree-head-identity-cached-paths.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-signal-attribution.ts diff --git a/src/main/ipc/worktree-base-directory-change-collector.ts b/src/main/ipc/worktree-base-directory-change-collector.ts index ed46089031f..66fbf0ac4bc 100644 --- a/src/main/ipc/worktree-base-directory-change-collector.ts +++ b/src/main/ipc/worktree-base-directory-change-collector.ts @@ -21,6 +21,7 @@ export type WorktreeBaseCollectedChanges = { gitStatusRepoIds: string[] headIdentityRepoIds: string[] headIdentityScope: WorktreeHeadIdentityScope + gitStatusScope: WorktreeHeadIdentityScope } export function hasCollectedWorktreeBaseChanges(changes: WorktreeBaseCollectedChanges): boolean { @@ -34,6 +35,7 @@ type ChangeBuckets = { gitStatusRepoIds: Set headIdentityRepoIds: Set headIdentityScope: WorktreeHeadIdentityScope + gitStatusScope: WorktreeHeadIdentityScope } function emptyBuckets(): ChangeBuckets { @@ -41,7 +43,8 @@ function emptyBuckets(): ChangeBuckets { structureRepoIds: new Set(), gitStatusRepoIds: new Set(), headIdentityRepoIds: new Set(), - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE } } @@ -54,7 +57,8 @@ function overflowChanges(): WorktreeBaseCollectedChanges { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: FULL_HEAD_IDENTITY_SCOPE + headIdentityScope: FULL_HEAD_IDENTITY_SCOPE, + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE } } @@ -77,6 +81,7 @@ function addMatchingChange( buckets.headIdentityScope, change.headIdentityScope ) + buckets.gitStatusScope = mergeHeadIdentityScopes(buckets.gitStatusScope, change.gitStatusScope) } function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChanges { @@ -85,7 +90,8 @@ function toCollectedChanges(buckets: ChangeBuckets): WorktreeBaseCollectedChange structureRepoIds: [...buckets.structureRepoIds], gitStatusRepoIds: [...buckets.gitStatusRepoIds], headIdentityRepoIds: [...buckets.headIdentityRepoIds], - headIdentityScope: buckets.headIdentityScope + headIdentityScope: buckets.headIdentityScope, + gitStatusScope: buckets.gitStatusScope } } 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 18eb3cd91f2..6d1de655f13 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.test.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.test.ts @@ -36,7 +36,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: ['repo-1'], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: headIdentityScopeForEntry('wt-a') + headIdentityScope: headIdentityScopeForEntry('wt-a'), + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -52,7 +53,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { headIdentityScope: { ...LISTING_HEAD_IDENTITY_SCOPE, entryNames: new Set(['wt-b']) - } + }, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( matchingWorktreeBaseRepoIds(target, { @@ -79,7 +81,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: ['repo-1'], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope + headIdentityScope, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) } expect( @@ -91,7 +94,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: PRIMARY_HEAD_IDENTITY_SCOPE }) }) @@ -106,7 +110,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: headIdentityScopeForEntry('wt-a') }) expect( classifyWorktreeBaseChange(target, { @@ -117,7 +122,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -133,7 +139,9 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: ['repo-1'], - headIdentityScope: headIdentityScopeForEntry('wt-a') + headIdentityScope: headIdentityScopeForEntry('wt-a'), + // The moved branch can be a sibling's local upstream, so status fans out. + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -144,7 +152,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: ['repo-1'], - headIdentityScope: PRIMARY_HEAD_IDENTITY_SCOPE + headIdentityScope: PRIMARY_HEAD_IDENTITY_SCOPE, + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE }) // Per-ref reflogs churn on fetches and stay ignored. expect( @@ -156,7 +165,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -167,7 +177,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -183,7 +194,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { gitStatusRepoIds: [], headIdentityRepoIds: [], // Sparse-flag only: structural, but provably cannot move a head. - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -194,7 +206,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: ['repo-1'], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -212,7 +225,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE }) const boundPaths = [ join(COMMON_DIR, 'refs', 'remotes', 'origin', 'main'), @@ -227,7 +241,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE }) } } @@ -241,7 +256,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -252,7 +268,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) }) @@ -273,7 +290,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: ['repo-1'], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: headIdentityScopeForEntry('wt a') + headIdentityScope: headIdentityScopeForEntry('wt a'), + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) expect( classifyWorktreeBaseChange(target, { @@ -284,7 +302,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: headIdentityScopeForEntry('wt a') }) expect( classifyWorktreeBaseChange(target, { @@ -295,7 +314,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: ['repo-1'], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE }) }) @@ -316,7 +336,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) } }) @@ -332,7 +353,8 @@ describe('matchingWorktreeBaseRepoIds (git-common)', () => { structureRepoIds: ['repo-1'], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: FULL_HEAD_IDENTITY_SCOPE + headIdentityScope: FULL_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE }) } }) diff --git a/src/main/ipc/worktree-base-directory-event-filter.ts b/src/main/ipc/worktree-base-directory-event-filter.ts index 2bc27f388f5..80da7ae377d 100644 --- a/src/main/ipc/worktree-base-directory-event-filter.ts +++ b/src/main/ipc/worktree-base-directory-event-filter.ts @@ -27,6 +27,9 @@ export type WorktreeBaseChangeClass = { // Which slice of the common dir's head identities this event can have moved. // Every classification must state one; `EMPTY` is a claim that no head moved. headIdentityScope: WorktreeHeadIdentityScope + // Whose status the gitStatus/headIdentity repo ids can have changed, so only + // that worktree's Source Control refreshes; `FULL` means unattributable. + gitStatusScope: WorktreeHeadIdentityScope } export type WorktreeBaseWatchKind = 'base' | 'git-common' @@ -120,10 +123,6 @@ const GIT_COMMON_PRIMARY_STRUCTURAL_SCOPES = new Map.remote/merge there, and a config write can move neither HEAD -// nor the worktree listing. -const GIT_COMMON_PRIMARY_STATUS_FILES = new Set(['index', 'config']) const GIT_COMMON_LINKED_STRUCTURAL_FILES = new Set(['HEAD', 'gitdir', 'locked', 'config.worktree']) // `HEAD` carries the branch and `gitdir` the checkout path; `locked` and // `config.worktree` are written by `git worktree lock` / sparse toggles, neither @@ -162,7 +161,8 @@ const NO_CHANGE: WorktreeBaseChangeClass = { structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE } function structuralChange( @@ -173,16 +173,21 @@ function structuralChange( structureRepoIds: repoIds, gitStatusRepoIds: [], headIdentityRepoIds: [], - headIdentityScope + headIdentityScope, + gitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE } } -function gitStatusChange(repoIds: string[]): WorktreeBaseChangeClass { +function gitStatusChange( + repoIds: string[], + gitStatusScope: WorktreeHeadIdentityScope +): WorktreeBaseChangeClass { return { structureRepoIds: [], gitStatusRepoIds: repoIds, headIdentityRepoIds: [], - headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE + headIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + gitStatusScope } } @@ -194,7 +199,10 @@ function headIdentityChange( structureRepoIds: [], gitStatusRepoIds: [], headIdentityRepoIds: repoIds, - headIdentityScope + headIdentityScope, + // A head move rewrites a shared branch ref (e.g. a commit to `main`), which + // is any checkout's local upstream; refs/heads leaves are not polled, so fan out. + gitStatusScope: FULL_HEAD_IDENTITY_SCOPE } } @@ -219,8 +227,13 @@ function classifyGitCommonEvent( if (primaryScope) { return structuralChange(repoIds, primaryScope) } - if (GIT_COMMON_PRIMARY_STATUS_FILES.has(parts[0])) { - return gitStatusChange(repoIds) + if (parts[0] === 'index') { + return gitStatusChange(repoIds, PRIMARY_HEAD_IDENTITY_SCOPE) + } + if (parts[0] === 'config') { + // Status-tier: an external `git push -u` writes only branch upstreams here, + // which can move no HEAD or listing but can change any checkout's status. + return gitStatusChange(repoIds, FULL_HEAD_IDENTITY_SCOPE) } return NO_CHANGE } @@ -228,8 +241,9 @@ function classifyGitCommonEvent( if (isHeadLogParts(parts, 0)) { return headIdentityChange(repoIds, PRIMARY_HEAD_IDENTITY_SCOPE) } + // Bound to the active status result, so it must reach it whoever wrote it. if (isBoundUpstreamRef(target, event.path, parts)) { - return gitStatusChange(repoIds) + return gitStatusChange(repoIds, FULL_HEAD_IDENTITY_SCOPE) } return NO_CHANGE } @@ -254,7 +268,7 @@ function classifyGitCommonEvent( ) } if (GIT_COMMON_LINKED_STATUS_FILES.has(parts[2])) { - return gitStatusChange(repoIds) + return gitStatusChange(repoIds, headIdentityScopeForEntry(parts[1])) } } if (isHeadLogParts(parts, 2)) { diff --git a/src/main/ipc/worktree-base-directory-notifications.test.ts b/src/main/ipc/worktree-base-directory-notifications.test.ts new file mode 100644 index 00000000000..25b8c7be32e --- /dev/null +++ b/src/main/ipc/worktree-base-directory-notifications.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { collectLocalWorktreeBaseChanges } from './worktree-base-directory-change-collector' +import { + scheduleWorktreeBaseNotification, + type WorktreeBaseNotificationWatch +} from './worktree-base-directory-notifications' +import { createWorktreeHeadIdentityRefreshState } from './worktree-head-identity-refresh' +import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope' +import { notifyWorktreeGitStatusMetadataChanged } from './worktree-remote' + +vi.mock('./worktree-remote', () => ({ + notifyWorktreeGitStatusMetadataChanged: vi.fn(), + notifyWorktreeHeadIdentitiesChanged: vi.fn() +})) + +vi.mock('./watched-worktree-catalog-notification', () => ({ + notifyWatchedWorktreeCatalogChanged: vi.fn() +})) + +const COMMON_DIR = join('/repos', 'project', '.git') +const PRIMARY_PATH = join('/repos', 'project') +// The admin dir name need not match the checkout folder name. +const ACTIVE_PATH = join('/work', 'feature') +const SIBLING_PATH = join('/work', 'other') +const HEAD = 'a'.repeat(40) + +function makeWatch(connectionId?: string): WorktreeBaseNotificationWatch { + const headIdentityRefresh = createWorktreeHeadIdentityRefreshState() + headIdentityRefresh.cache.primary = { worktreePath: PRIMARY_PATH, head: HEAD, branch: null } + headIdentityRefresh.cache.entries.set('Feature1', { + worktreePath: ACTIVE_PATH, + head: HEAD, + branch: null + }) + headIdentityRefresh.cache.entries.set('other', { + worktreePath: SIBLING_PATH, + head: HEAD, + branch: null + }) + return { + key: 'git-common:local', + kind: 'git-common', + path: COMMON_DIR, + ...(connectionId ? { connectionId } : {}), + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: notifications only call isDestroyed. + mainWindow: { isDestroyed: () => false } as never, + notifyTimer: null, + pendingStructureRepoIds: new Set(), + pendingGitStatusRepoIds: new Set(), + pendingHeadIdentityRepoIds: new Set(), + pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, + headIdentityRefresh, + disposed: false + } +} + +function update(...parts: string[]): { type: 'update'; path: string } { + return { type: 'update', path: join(COMMON_DIR, ...parts) } +} + +async function flush(watch: WorktreeBaseNotificationWatch, paths: string[][]): Promise { + scheduleWorktreeBaseNotification( + watch, + collectLocalWorktreeBaseChanges( + watch, + paths.map((parts) => update(...parts)) + ) + ) + await vi.advanceTimersByTimeAsync(300) +} + +describe('scheduleWorktreeBaseNotification git status attribution', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('names the checkout behind a linked index write via the admin-dir memo', async () => { + await flush(makeWatch(), [['worktrees', 'other', 'index']]) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + [SIBLING_PATH] + ) + }) + + it('folds admin-dir case and unions every checkout in one burst', async () => { + await flush(makeWatch(), [ + ['worktrees', 'feature1', 'index'], + ['worktrees', 'other', 'index'], + ['index'] + ]) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + [PRIMARY_PATH, ACTIVE_PATH, SIBLING_PATH] + ) + }) + + it('leaves sibling commits unattributed: the moved branch can be the active local upstream', async () => { + // A commit to `main` in a sibling writes its index and logs/HEAD; refs/heads/main + // itself is not polled, so the head move must still reach the active checkout. + for (const paths of [ + [ + ['worktrees', 'other', 'index'], + ['worktrees', 'other', 'logs', 'HEAD'] + ], + [['index'], ['logs', 'HEAD']] + ]) { + vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear() + await flush(makeWatch(), paths) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + null + ) + } + }) + + it('leaves the signal unattributed when any part of the burst cannot be attributed', async () => { + const cases: string[][][] = [ + // Admin dir the memo has not resolved yet (e.g. just added). + [['worktrees', 'unknown', 'index']], + // Shared config can change any checkout's upstream. + [['worktrees', 'other', 'index'], ['config']] + ] + for (const paths of cases) { + vi.mocked(notifyWorktreeGitStatusMetadataChanged).mockClear() + await flush(makeWatch(), paths) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + null + ) + } + }) + + it('leaves bound upstream ref writes unattributed so the active checkout refreshes', async () => { + const watch = makeWatch() + const refPath = join(COMMON_DIR, 'refs', 'remotes', 'origin', 'feature') + watch.gitStatusRefPaths = new Set([refPath]) + scheduleWorktreeBaseNotification( + watch, + collectLocalWorktreeBaseChanges(watch, [{ type: 'update', path: refPath }]) + ) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + null + ) + }) + + it('leaves callers that state no scope (overflow, watcher error) unattributed', async () => { + const watch = makeWatch() + scheduleWorktreeBaseNotification(watch, { gitStatusRepoIds: ['repo-1'] }) + await vi.advanceTimersByTimeAsync(300) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + null + ) + }) + + it('never attributes SSH watches, which keep no local head memo', async () => { + await flush(makeWatch('ssh-1'), [['worktrees', 'other', 'index']]) + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith( + expect.anything(), + 'repo-1', + null + ) + }) +}) diff --git a/src/main/ipc/worktree-base-directory-notifications.ts b/src/main/ipc/worktree-base-directory-notifications.ts index c4c69f0f691..89381b16669 100644 --- a/src/main/ipc/worktree-base-directory-notifications.ts +++ b/src/main/ipc/worktree-base-directory-notifications.ts @@ -11,6 +11,7 @@ import { mergeHeadIdentityScopes, type WorktreeHeadIdentityScope } from './worktree-head-identity-scope' +import { cachedWorktreePathsForScope } from './worktree-head-identity-cached-paths' import { notifyWorktreeGitStatusMetadataChanged } from './worktree-remote' import { notifyWatchedWorktreeCatalogChanged } from './watched-worktree-catalog-notification' @@ -21,6 +22,7 @@ export type WorktreeBaseNotificationWatch = WorktreeBaseWatchTarget & { pendingGitStatusRepoIds: Set pendingHeadIdentityRepoIds: Set pendingHeadIdentityScope: WorktreeHeadIdentityScope + pendingGitStatusScope: WorktreeHeadIdentityScope headIdentityRefresh: WorktreeHeadIdentityRefreshState disposed: boolean } @@ -32,6 +34,7 @@ export function clearPendingWorktreeBaseNotifications(watch: WorktreeBaseNotific watch.pendingGitStatusRepoIds.clear() watch.pendingHeadIdentityRepoIds.clear() watch.pendingHeadIdentityScope = EMPTY_HEAD_IDENTITY_SCOPE + watch.pendingGitStatusScope = EMPTY_HEAD_IDENTITY_SCOPE } export function supportsWorktreeHeadIdentityRefresh(watch: WorktreeBaseNotificationWatch): boolean { @@ -62,6 +65,10 @@ export function scheduleWorktreeBaseNotification( watch.pendingHeadIdentityScope, changes.headIdentityScope ?? FULL_HEAD_IDENTITY_SCOPE ) + watch.pendingGitStatusScope = mergeHeadIdentityScopes( + watch.pendingGitStatusScope, + changes.gitStatusScope ?? FULL_HEAD_IDENTITY_SCOPE + ) clearTimeout(watch.notifyTimer ?? undefined) watch.notifyTimer = setTimeout(() => { watch.notifyTimer = null @@ -78,12 +85,17 @@ export function scheduleWorktreeBaseNotification( ) const emitHeadIdentities = pendingStructure.length === 0 const headIdentityScope = watch.pendingHeadIdentityScope + // Resolved before the head refresh below can rewrite the memo. Only local + // watches keep one; SSH stays unattributed so every checkout still refreshes. + const gitStatusWorktreePaths = supportsWorktreeHeadIdentityRefresh(watch) + ? cachedWorktreePathsForScope(watch.headIdentityRefresh.cache, watch.pendingGitStatusScope) + : null clearPendingWorktreeBaseNotifications(watch) for (const repoId of pendingStructure) { notifyWatchedWorktreeCatalogChanged(watch.mainWindow, repoId, watch.connectionId) } for (const repoId of sourceControlRepoIds) { - notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId) + notifyWorktreeGitStatusMetadataChanged(watch.mainWindow, repoId, gitStatusWorktreePaths) } if ( supportsWorktreeHeadIdentityRefresh(watch) && diff --git a/src/main/ipc/worktree-base-directory-watcher.test.ts b/src/main/ipc/worktree-base-directory-watcher.test.ts index a23bc901bdc..06b560d7efe 100644 --- a/src/main/ipc/worktree-base-directory-watcher.test.ts +++ b/src/main/ipc/worktree-base-directory-watcher.test.ts @@ -67,6 +67,8 @@ const absolutePath = (...parts: string[]): string => join(sep, ...parts) const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') const PROJECT_ROOT = absolutePath('workspace', 'projects', 'project') const PROJECT_GIT_COMMON_DIR = join(PROJECT_ROOT, '.git') +// The test cache never resolves admin dirs, so status signals carry no attribution. +const UNATTRIBUTED_STATUS = [expect.anything(), 'repo-1', null] as const const settings = { workspaceDir: WORKTREE_ROOT, @@ -220,7 +222,7 @@ describe('worktree base directory watcher', () => { expect(notifyWorktreesChanged).not.toHaveBeenCalled() expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledTimes(1) - expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(...UNATTRIBUTED_STATUS) }) it('keeps one active-worktree upstream ref binding and ignores stale cleanup', async () => { @@ -317,7 +319,7 @@ describe('worktree base directory watcher', () => { } ] as never[]) await vi.advanceTimersByTimeAsync(300) - expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1') + expect(notifyWorktreeGitStatusMetadataChanged).toHaveBeenCalledWith(...UNATTRIBUTED_STATUS) }) it('invalidates exact resolution only when common config changes', async () => { diff --git a/src/main/ipc/worktree-base-directory-watcher.ts b/src/main/ipc/worktree-base-directory-watcher.ts index abb0d51782c..8f534ad30af 100644 --- a/src/main/ipc/worktree-base-directory-watcher.ts +++ b/src/main/ipc/worktree-base-directory-watcher.ts @@ -60,6 +60,7 @@ function createActiveWatch( pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), gitStatusRefPaths, watcherFailureRefresh: new WorktreeWatcherFailureRefreshCooldown(), diff --git a/src/main/ipc/worktree-git-status-attribution-real-git.test.ts b/src/main/ipc/worktree-git-status-attribution-real-git.test.ts new file mode 100644 index 00000000000..fd603cd729c --- /dev/null +++ b/src/main/ipc/worktree-git-status-attribution-real-git.test.ts @@ -0,0 +1,134 @@ +// Real-binary coverage: `git add` in one linked worktree must attribute its +// status signal to that checkout only, through the real watcher, the admin dir +// names Git picks (which need not match the folder name), and the head-identity +// memo that maps them back to checkout paths. +import { execFile } from 'node:child_process' +import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { areRuntimePathsEqual } from '../../shared/worktree/ownership' +import { collectLocalWorktreeBaseChanges } from './worktree-base-directory-change-collector' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import { + startWorktreeBaseDirectoryPoller, + type WorktreeBasePollEvent, + type WorktreeBaseSubscription +} from './worktree-base-directory-poller' +import { cachedWorktreePathsForScope } from './worktree-head-identity-cached-paths' +import { + createWorktreeHeadIdentityCache, + readGitCommonHeadIdentities, + type WorktreeHeadIdentityCache +} from './worktree-head-identity-reader' + +const execFileAsync = promisify(execFile) +const POLL_MS = 25 + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +async function listedWorktreePaths(cwd: string): Promise { + return (await git(['worktree', 'list', '--porcelain'], cwd)) + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length)) +} + +let scratch = '' +let primary = '' +let linkedOne = '' +let linkedTwo = '' +let target: WorktreeBaseWatchTarget +let cache: WorktreeHeadIdentityCache +let poller: WorktreeBaseSubscription | null = null +let received: WorktreeBasePollEvent[] = [] + +beforeEach(async () => { + scratch = await mkdtemp(join(tmpdir(), 'orca-status-attribution-')) + primary = join(scratch, 'repo') + await git(['init', '-q', '-b', 'main', primary], scratch) + for (const [key, value] of [ + ['user.name', 'Orca Test'], + ['user.email', 'orca@example.test'], + ['commit.gpgSign', 'false'] + ]) { + await git(['config', key, value], primary) + } + await git(['commit', '-q', '--allow-empty', '-m', 'init'], primary) + // Same folder name twice: Git names the second admin dir `wt1`, not `wt`. + linkedOne = join(scratch, 'one', 'wt') + linkedTwo = join(scratch, 'two', 'wt') + await git(['worktree', 'add', '-q', linkedOne, '-b', 'one'], primary) + await git(['worktree', 'add', '-q', linkedTwo, '-b', 'two'], primary) + + // Production watches the canonical common dir (see watch-targets `addTarget`). + const commonDir = await realpath(join(primary, '.git')) + target = { + key: `git-common:local:${commonDir}`, + kind: 'git-common', + path: commonDir, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'repo', nestWorkspaces: false }]]) + } + cache = createWorktreeHeadIdentityCache() + await readGitCommonHeadIdentities(commonDir, cache) + received = [] + poller = await startWorktreeBaseDirectoryPoller( + target, + () => target.repos, + (events) => received.push(...events), + { pollIntervalMs: POLL_MS } + ) +}) + +afterEach(async () => { + await poller?.unsubscribe() + poller = null + await rm(scratch, { recursive: true, force: true }) +}) + +// The native watcher can deliver a previous stage's events late; drain them. +async function waitForQuietWatcher(): Promise { + let seen = -1 + while (seen !== received.length) { + seen = received.length + await new Promise((resolve) => setTimeout(resolve, 300)) + } +} + +/** Stages a file in `checkout` and returns the checkout paths its status signal names. */ +async function stageAndAttribute(checkout: string): Promise { + await waitForQuietWatcher() + received = [] + await writeFile(join(checkout, 'staged.txt'), `${checkout}\n`) + await git(['add', 'staged.txt'], checkout) + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const changes = collectLocalWorktreeBaseChanges(target, received) + if (changes.gitStatusRepoIds.length > 0) { + return cachedWorktreePathsForScope(cache, changes.gitStatusScope) + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)) + } + throw new Error(`no status signal for ${checkout}; events: ${JSON.stringify(received)}`) +} + +function expectAttributedTo(paths: string[] | null, listed: string, others: string[]): void { + expect(paths).not.toBeNull() + expect(paths?.some((path) => areRuntimePathsEqual(path, listed))).toBe(true) + for (const other of others) { + expect(paths?.some((path) => areRuntimePathsEqual(path, other))).toBe(false) + } +} + +describe('git status signal attribution (real git)', () => { + it('attributes index writes to the checkout that staged, in `git worktree list` spelling', async () => { + const [listedPrimary, listedOne, listedTwo] = await listedWorktreePaths(primary) + expectAttributedTo(await stageAndAttribute(linkedTwo), listedTwo, [listedPrimary, listedOne]) + expectAttributedTo(await stageAndAttribute(linkedOne), listedOne, [listedPrimary, listedTwo]) + expectAttributedTo(await stageAndAttribute(primary), listedPrimary, [listedOne, listedTwo]) + }, 30_000) +}) diff --git a/src/main/ipc/worktree-head-identity-cached-paths.ts b/src/main/ipc/worktree-head-identity-cached-paths.ts new file mode 100644 index 00000000000..4796e3b8400 --- /dev/null +++ b/src/main/ipc/worktree-head-identity-cached-paths.ts @@ -0,0 +1,35 @@ +import type { WorktreeHeadIdentityCache } from './worktree-head-identity-reader' +import { + headIdentityEntryKey, + type WorktreeHeadIdentityScope +} from './worktree-head-identity-scope' + +/** Checkout paths the memo maps `scope` to, or null when any part of it cannot + * be attributed (global scope, or an entry/primary the memo has not resolved). */ +export function cachedWorktreePathsForScope( + cache: WorktreeHeadIdentityCache, + scope: WorktreeHeadIdentityScope +): string[] | null { + if (scope.all || scope.listing || (!scope.primary && scope.entryNames.size === 0)) { + return null + } + const paths: string[] = [] + if (scope.primary) { + if (!cache.primary) { + return null + } + paths.push(cache.primary.worktreePath) + } + const pathsByEntryKey = new Map() + for (const [name, identity] of cache.entries) { + pathsByEntryKey.set(headIdentityEntryKey(name), identity.worktreePath) + } + for (const key of scope.entryNames) { + const path = pathsByEntryKey.get(key) + if (path === undefined) { + return null + } + paths.push(path) + } + return paths +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index a6cf75150b4..a1d42fe22b5 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -20,6 +20,7 @@ import type { AutomationWorkspaceProvenance, CliWorkspaceProvenance, GitPushTarget, + GitStatusMetadataChangedEvent, Worktree, WorktreeHeadIdentity } from '../../shared/worktree/types' @@ -918,11 +919,15 @@ export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string export function notifyWorktreeGitStatusMetadataChanged( mainWindow: BrowserWindow, - repoId: string + repoId: string, + worktreePaths: readonly string[] | null = null ): void { // Why: index churn is a Source Control freshness hint, not a graph mutation; leave structural caches and runtime/mobile events untouched. if (!mainWindow.isDestroyed()) { - mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', { repoId }) + const payload: GitStatusMetadataChangedEvent = worktreePaths + ? { repoId, worktreePaths: [...worktreePaths] } + : { repoId } + mainWindow.webContents.send('worktrees:gitStatusMetadataChanged', payload) } } diff --git a/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts b/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts index f9af8d126fa..993371e2f29 100644 --- a/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts +++ b/src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts @@ -168,6 +168,7 @@ describe('external worktree discovery for paired clients', () => { pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -355,6 +356,7 @@ describe('external worktree discovery for paired clients', () => { pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -441,6 +443,7 @@ describe('external worktree discovery for paired clients', () => { pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } @@ -498,6 +501,7 @@ describe('external worktree discovery for paired clients', () => { pendingGitStatusRepoIds: new Set(), pendingHeadIdentityRepoIds: new Set(), pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE, + pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE, headIdentityRefresh: createWorktreeHeadIdentityRefreshState(), disposed: false } diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index aa4bb092182..d4469435ec9 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -34,6 +34,7 @@ import type { DetectedWorktreeListResult, GitHubPrStartPoint, GitPushTarget, + GitStatusMetadataChangedEvent, GitWorktreeInfo, Worktree, WorktreeHeadIdentity @@ -137,7 +138,9 @@ export type WorktreeApi = { * in main memory only — null after a restart or once the failure clears. */ getBranchRenameFailureOutput: (args: { worktreeId: string }) => Promise onChanged: (callback: (data: { repoId: string }) => void) => () => void - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void) => () => void + onGitStatusMetadataChanged: ( + callback: (data: GitStatusMetadataChangedEvent) => void + ) => () => void onHeadIdentitiesChanged: ( callback: (data: { repoId: string; identities: WorktreeHeadIdentity[] }) => void ) => () => void diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 1dc4dfdd67e..9cb74c773cb 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -3,7 +3,10 @@ import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent } from '../../shared/worktree/base-ref-drift-types' -import type { WorktreeHeadIdentity } from '../../shared/worktree/types' +import type { + GitStatusMetadataChangedEvent, + WorktreeHeadIdentity +} from '../../shared/worktree/types' import type { PreloadApi } from '../api-types' export const worktreesApi = { @@ -77,8 +80,11 @@ export const worktreesApi = { return () => ipcRenderer.removeListener('worktrees:changed', listener) }, - onGitStatusMetadataChanged: (callback: (data: { repoId: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => callback(data) + onGitStatusMetadataChanged: ( + callback: (data: GitStatusMetadataChangedEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: GitStatusMetadataChangedEvent) => + callback(data) ipcRenderer.on('worktrees:gitStatusMetadataChanged', listener) return () => ipcRenderer.removeListener('worktrees:gitStatusMetadataChanged', listener) }, diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts index b9ae7fd01b2..1d1deab04b8 100644 --- a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts @@ -3,17 +3,19 @@ import type * as React from 'react' import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT } from '@/hooks/terminal-command-finished-event' type WorktreesChangedCallback = (data: { repoId: string }) => void -type GitStatusMetadataChangedCallback = (data: { repoId: string }) => void +type GitStatusMetadataChangedCallback = (data: { repoId: string; worktreePaths?: string[] }) => void type HookParams = { activeRepoId: string | null activeWorktreeId: string | null + activeWorktreePath?: string | null enabled: boolean fetchStatus: () => void + getRepoWorktreePaths?: (repoId: string) => readonly string[] } async function renderHookOnce(params: HookParams): Promise<{ emitWorktreesChanged: (repoId: string) => void - emitGitStatusMetadataChanged: (repoId: string) => void + emitGitStatusMetadataChanged: (repoId: string, worktreePaths?: string[]) => void emitCommandFinished: (worktreeId: string) => void onChangedSubscribe: ReturnType onGitStatusMetadataChangedSubscribe: ReturnType @@ -76,16 +78,20 @@ async function renderHookOnce(params: HookParams): Promise<{ }) const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh') - function PushSignalRefreshHarness(props: HookParams): null { + function PushSignalRefreshHarness(props: Required): null { useGitStatusPushSignalRefresh(props) return null } - PushSignalRefreshHarness(params) + PushSignalRefreshHarness({ + activeWorktreePath: '/repo/wt-1', + getRepoWorktreePaths: () => ['/repo', '/repo/wt-1', '/repo/wt-2'], + ...params + }) return { emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }), - emitGitStatusMetadataChanged: (repoId: string) => - gitStatusMetadataChangedCallback?.({ repoId }), + emitGitStatusMetadataChanged: (repoId: string, worktreePaths?: string[]) => + gitStatusMetadataChangedCallback?.(worktreePaths ? { repoId, worktreePaths } : { repoId }), emitCommandFinished: (worktreeId: string) => { const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT) listener?.({ detail: { worktreeId } } as unknown as Event) @@ -137,6 +143,42 @@ describe('useGitStatusPushSignalRefresh', () => { expect(fetchStatus).toHaveBeenCalledTimes(1) }) + it('skips metadata signals attributed only to sibling worktrees', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + harness.emitGitStatusMetadataChanged('repo-1', ['/repo/wt-2']) + harness.emitGitStatusMetadataChanged('repo-1', ['/repo']) + expect(fetchStatus).not.toHaveBeenCalled() + + harness.emitGitStatusMetadataChanged('repo-1', ['/repo/wt-2', '/repo/wt-1']) + expect(fetchStatus).toHaveBeenCalledTimes(1) + }) + + it('refreshes when attribution is missing or names no known worktree', async () => { + const fetchStatus = vi.fn() + const harness = await renderHookOnce({ + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + enabled: true, + fetchStatus + }) + + // Older main process / SSH watch: no attribution. + harness.emitGitStatusMetadataChanged('repo-1') + expect(fetchStatus).toHaveBeenCalledTimes(1) + harness.emitGitStatusMetadataChanged('repo-1', []) + expect(fetchStatus).toHaveBeenCalledTimes(2) + // A differently spelled path (symlinked root) may be the active checkout. + harness.emitGitStatusMetadataChanged('repo-1', ['/private/repo/wt-1']) + expect(fetchStatus).toHaveBeenCalledTimes(3) + }) + it('nudges status when a terminal command finishes in the active worktree', async () => { const fetchStatus = vi.fn() const harness = await renderHookOnce({ diff --git a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts index 45959adc74e..62badc993f9 100644 --- a/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.ts @@ -4,12 +4,17 @@ import { ORCA_TERMINAL_COMMAND_FINISHED_EVENT, type TerminalCommandFinishedEventDetail } from '@/hooks/terminal-command-finished-event' +import type { GitStatusMetadataChangedEvent } from '../../../../shared/worktree/types' +import { isGitStatusSignalForWorktree } from './git-status-signal-attribution' type UseGitStatusPushSignalRefreshParams = { activeRepoId: string | null activeWorktreeId: string | null + activeWorktreePath: string | null enabled: boolean fetchStatus: () => void + /** Keep stable: it re-subscribes the push signals when it changes. */ + getRepoWorktreePaths: (repoId: string) => readonly string[] } // Why: these push signals close the latency gap left by the slow terminal-only @@ -20,8 +25,10 @@ type UseGitStatusPushSignalRefreshParams = { export function useGitStatusPushSignalRefresh({ activeRepoId, activeWorktreeId, + activeWorktreePath, enabled, - fetchStatus + fetchStatus, + getRepoWorktreePaths }: UseGitStatusPushSignalRefreshParams): void { const fetchStatusRef = useRef(fetchStatus) fetchStatusRef.current = fetchStatus @@ -43,18 +50,25 @@ export function useGitStatusPushSignalRefresh({ } fetchStatusRef.current() } + // Why: sibling-worktree index churn must not re-run this checkout's status. + const handleGitStatusSignal = (signal: GitStatusMetadataChangedEvent): void => { + const paths = getRepoWorktreePaths(signal.repoId) + if (isGitStatusSignalForWorktree(signal, activeWorktreePath, paths)) { + handleRepoSignal(signal) + } + } // Repo metadata changed on disk. Hidden windows skip the nudge; the // visibility interval refreshes immediately on reveal. const unsubs = [ subscribeToWorktreesChanged?.(handleRepoSignal), - subscribeToGitStatusMetadataChanged?.(handleRepoSignal) + subscribeToGitStatusMetadataChanged?.(handleGitStatusSignal) ].filter((unsubscribe): unsubscribe is () => void => typeof unsubscribe === 'function') return () => { for (const unsubscribe of unsubs) { unsubscribe() } } - }, [enabled, activeRepoId]) + }, [enabled, activeRepoId, activeWorktreePath, getRepoWorktreePaths]) useEffect(() => { if (!enabled || !activeWorktreeId) { diff --git a/src/renderer/src/components/right-sidebar/git-status-signal-attribution.ts b/src/renderer/src/components/right-sidebar/git-status-signal-attribution.ts new file mode 100644 index 00000000000..be9615463b0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-signal-attribution.ts @@ -0,0 +1,23 @@ +import type { GitStatusMetadataChangedEvent } from '../../../../shared/worktree/types' +import { areRuntimePathsEqual } from '../../../../shared/worktree/ownership' + +/** Whether a metadata signal can have changed the active checkout's status. + * Unattributed signals (older main, SSH, global changes) always can. */ +export function isGitStatusSignalForWorktree( + signal: GitStatusMetadataChangedEvent, + activeWorktreePath: string | null, + repoWorktreePaths: readonly string[] +): boolean { + const attributed = signal.worktreePaths + if (!attributed?.length || !activeWorktreePath) { + return true + } + if (attributed.some((path) => areRuntimePathsEqual(path, activeWorktreePath))) { + return true + } + // Why: skip only when every path is a known sibling — an unknown spelling + // (e.g. a symlinked repo root) may still be the active checkout. + return !attributed.every((path) => + repoWorktreePaths.some((known) => areRuntimePathsEqual(path, known)) + ) +} diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index ef8503b40f4..b8c3b77bfd0 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -293,6 +293,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { statusSchedulerRef.current?.signal() }, []) + const getRepoWorktreePathsForSignal = useCallback( + (repoId: string) => + (useAppStore.getState().worktreesByRepo[repoId] ?? []).map((worktree) => worktree.path), + [] + ) + useGitStatusFileWatchRefresh({ activeConnectionId, activeRepoSupportsGit, @@ -311,8 +317,10 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { useGitStatusPushSignalRefresh({ activeRepoId, activeWorktreeId, + activeWorktreePath: worktreePath, enabled: canFetchActiveWorktreeGitStatus, - fetchStatus: fetchStatusOnChangeSignal + fetchStatus: fetchStatusOnChangeSignal, + getRepoWorktreePaths: getRepoWorktreePathsForSignal }) useStaleConflictOperationPolling({ diff --git a/src/shared/worktree/types.ts b/src/shared/worktree/types.ts index b379a43c163..be752c2691a 100644 --- a/src/shared/worktree/types.ts +++ b/src/shared/worktree/types.ts @@ -79,6 +79,13 @@ export type WorktreeHeadIdentity = { branch: string | null } +/** `worktreePaths` names the only checkouts whose status the burst can have + * changed; absent means unattributed, so every listener must refresh. */ +export type GitStatusMetadataChangedEvent = { + repoId: string + worktreePaths?: string[] +} + // ─── Worktree (app-level, enriched) ────────────────────────────────── export type WorkspaceStatus = string