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>
This commit is contained in:
Jinwoo Hong
2026-10-14 04:40:22 +00:00
committed by GitHub
co-authored by Neil
parent bf07132ac2
commit 29aa2a4f0f
18 changed files with 574 additions and 56 deletions
@@ -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<string>
headIdentityRepoIds: Set<string>
headIdentityScope: WorktreeHeadIdentityScope
gitStatusScope: WorktreeHeadIdentityScope
}
function emptyBuckets(): ChangeBuckets {
@@ -41,7 +43,8 @@ function emptyBuckets(): ChangeBuckets {
structureRepoIds: new Set<string>(),
gitStatusRepoIds: new Set<string>(),
headIdentityRepoIds: new Set<string>(),
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
}
}
@@ -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
})
}
})
@@ -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<string, WorktreeHeadIdentit
['packed-refs', FULL_HEAD_IDENTITY_SCOPE],
['config.worktree', EMPTY_HEAD_IDENTITY_SCOPE]
])
// `config` is status-tier: an external `git push -u` writes only
// branch.<name>.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)) {
@@ -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<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
)
})
})
@@ -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<string>
pendingHeadIdentityRepoIds: Set<string>
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) &&
@@ -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 () => {
@@ -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(),
@@ -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<string> {
const { stdout } = await execFileAsync('git', args, { cwd })
return stdout
}
async function listedWorktreePaths(cwd: string): Promise<string[]> {
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<void> {
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<string[] | null> {
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)
})
@@ -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<string, string>()
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
}
+7 -2
View File
@@ -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)
}
}
@@ -168,6 +168,7 @@ describe('external worktree discovery for paired clients', () => {
pendingGitStatusRepoIds: new Set<string>(),
pendingHeadIdentityRepoIds: new Set<string>(),
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<string>(),
pendingHeadIdentityRepoIds: new Set<string>(),
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<string>(),
pendingHeadIdentityRepoIds: new Set<string>(),
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<string>(),
pendingHeadIdentityRepoIds: new Set<string>(),
pendingHeadIdentityScope: EMPTY_HEAD_IDENTITY_SCOPE,
pendingGitStatusScope: EMPTY_HEAD_IDENTITY_SCOPE,
headIdentityRefresh: createWorktreeHeadIdentityRefreshState(),
disposed: false
}
+4 -1
View File
@@ -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<string | null>
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
+9 -3
View File
@@ -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)
},
@@ -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<typeof vi.fn>
onGitStatusMetadataChangedSubscribe: ReturnType<typeof vi.fn>
@@ -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<HookParams>): 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({
@@ -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) {
@@ -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))
)
}
@@ -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({
+7
View File
@@ -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