Files
orca/src/main/ipc/worktree-base-directory-notifications.test.ts
T
Jinwoo HongandNeil 29aa2a4f0f 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 <common>/worktrees/<entry>/ 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 <neil@stably.ai>
2026-10-14 04:40:22 +00:00

181 lines
6.1 KiB
TypeScript

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<void> {
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
)
})
})