perf(worktree-watcher): skip head-identity reads on index-only bursts (#8558)

An index rewrite cannot move HEAD, yet every quiet local Git-common status
burst was calling readGitCommonHeadIdentities, which scans the primary
checkout plus every linked worktree sequentially (~1,500 file reads for 500
worktrees) even when only index files changed.

Split the conflated git-status signal into two: index churn stays
`gitStatusRepoIds`, while logs/HEAD (and other reflog head moves) become a
distinct `headIdentityRepoIds` signal. Source Control is still notified for
both, but readGitCommonHeadIdentities now runs only for true head triggers
(and structural ticks, which re-baseline silently). Index-only bursts do zero
head-identity reads.

Adds deterministic tests: index-only bursts read zero head identities across
linked and primary checkouts; logs/HEAD still refreshes identities for both;
plus coalescing and debounce coverage.
This commit is contained in:
Jinjing
2026-07-13 05:40:02 -07:00
committed by GitHub
parent 17f032dbd5
commit 6091995e75
5 changed files with 413 additions and 140 deletions
@@ -13,35 +13,55 @@ export type WorktreeBaseCollectedChanges = {
overflow: boolean
structureRepoIds: string[]
gitStatusRepoIds: string[]
headIdentityRepoIds: string[]
}
type ChangeBuckets = {
structureRepoIds: Set<string>
gitStatusRepoIds: Set<string>
headIdentityRepoIds: Set<string>
}
function emptyBuckets(): ChangeBuckets {
return {
structureRepoIds: new Set<string>(),
gitStatusRepoIds: new Set<string>(),
headIdentityRepoIds: new Set<string>()
}
}
function emptyChanges(): WorktreeBaseCollectedChanges {
return { overflow: false, structureRepoIds: [], gitStatusRepoIds: [] }
return {
overflow: false,
structureRepoIds: [],
gitStatusRepoIds: [],
headIdentityRepoIds: []
}
}
function addMatchingChange(
target: WorktreeBaseWatchTarget,
event: WorktreeBaseWatcherEvent,
structureRepoIds: Set<string>,
gitStatusRepoIds: Set<string>
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<string>,
gitStatusRepoIds: Set<string>
): 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<string>()
const gitStatusRepoIds = new Set<string>()
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<string>()
const gitStatusRepoIds = new Set<string>()
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)
}
@@ -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', () => {
@@ -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(
@@ -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)
+57 -45
View File
@@ -25,6 +25,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
notifyTimer: ReturnType<typeof setTimeout> | null
pendingStructureRepoIds: Set<string>
pendingGitStatusRepoIds: Set<string>
pendingHeadIdentityRepoIds: Set<string>
headIdentityRefresh: WorktreeHeadIdentityRefreshState
disposed: boolean
}
@@ -35,13 +36,17 @@ let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | 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<Omit<WorktreeBaseCollectedChanges, 'overflow'>>
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<void> {
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)
})