mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user