Push-refresh git status on repo metadata changes and shell command completion (#7086)

Branch switches and commits made inside terminals now surface through push
signals instead of waiting for the 30s terminal-only fallback poll:

- The git-common watch now also covers the primary checkout's HEAD,
  packed-refs, and index (a few stat calls per tick; no new native watchers).
- worktrees:changed events nudge the active worktree's coalesced git status
  runner (visibility-gated, 3s floor).
- OSC 133;D command completion dispatches a window event that nudges the
  same runner for the active worktree.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-02 01:32:05 -07:00 committed by GitHub
parent 6dafe8c870
commit b07ea4a1a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 477 additions and 14 deletions

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { join } from 'node:path'
import {
matchingWorktreeBaseRepoIds,
type WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
const COMMON_DIR = join('/repos', 'project', '.git')
function makeGitCommonTarget(): WorktreeBaseWatchTarget {
return {
key: `git-common:local:${COMMON_DIR}`,
kind: 'git-common',
path: COMMON_DIR,
repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]])
}
}
describe('matchingWorktreeBaseRepoIds (git-common)', () => {
it('matches linked-worktree metadata under worktrees/', () => {
const target = makeGitCommonTarget()
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'update',
path: join(COMMON_DIR, 'worktrees', 'wt-a', 'HEAD')
})
).toEqual(['repo-1'])
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'create',
path: join(COMMON_DIR, 'worktrees', 'wt-b')
})
).toEqual(['repo-1'])
})
it('matches primary-checkout branch/index metadata at the common-dir top level', () => {
const target = makeGitCommonTarget()
for (const file of ['HEAD', 'packed-refs', 'index']) {
expect(
matchingWorktreeBaseRepoIds(target, { type: 'update', path: join(COMMON_DIR, file) })
).toEqual(['repo-1'])
}
})
it('ignores non-status common-dir churn', () => {
const target = makeGitCommonTarget()
for (const path of [
join(COMMON_DIR, 'config'),
join(COMMON_DIR, 'FETCH_HEAD'),
join(COMMON_DIR, 'COMMIT_EDITMSG'),
join(COMMON_DIR, 'objects', 'ab', 'cdef'),
join(COMMON_DIR, 'refs', 'heads', 'main'),
join(COMMON_DIR, 'logs', 'HEAD'),
// Nested HEAD outside worktrees/ must not be mistaken for the primary's.
join(COMMON_DIR, 'modules', 'sub', 'HEAD')
]) {
expect(matchingWorktreeBaseRepoIds(target, { type: 'update', path })).toEqual([])
}
})
it('ignores paths outside the watch root', () => {
const target = makeGitCommonTarget()
expect(
matchingWorktreeBaseRepoIds(target, {
type: 'update',
path: join('/repos', 'project', 'HEAD')
})
).toEqual([])
})
})

View File

@ -82,11 +82,22 @@ function matchingBaseRepoIds(
return repoIds
}
// Why: branch switches and commits in the primary checkout rewrite these
// top-level common-dir files; matching them keeps root-checkout branch/status
// as fresh as linked worktrees. Deeper churn (objects, refs, logs) is ignored.
const GIT_COMMON_PRIMARY_METADATA_FILES = new Set(['HEAD', 'packed-refs', 'index'])
// Why: Git records linked worktrees under the common dir's `worktrees`
// metadata, which is lower churn than watching checkout contents.
function matchingGitCommonRepoIds(target: WorktreeBaseWatchTarget, eventPath: string): string[] {
const parts = pathRelativeToWorktreeWatchRoot(target.path, eventPath)
if (!parts || parts[0] !== 'worktrees') {
if (!parts) {
return []
}
if (
parts[0] !== 'worktrees' &&
!(parts.length === 1 && GIT_COMMON_PRIMARY_METADATA_FILES.has(parts[0]))
) {
return []
}
return [...target.repos.keys()]

View File

@ -204,6 +204,33 @@ describe('worktree base directory poller', () => {
)
})
it('reports primary-checkout HEAD changes via polling', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
// Force the non-darwin poll path so this test is deterministic on all CI.
{ pollIntervalMs: POLL_MS, platform: 'linux' }
)
cleanups.push(() => poller.unsubscribe())
const headFile = join(commonDir, 'HEAD')
await writeFile(headFile, 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === headFile)
)
// A branch switch rewrites HEAD in place; the mtime diff must surface it.
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(headFile, 'ref: refs/heads/feature')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === headFile)
)
})
it('emits deletes for all known worktrees when the root vanishes', async () => {
const root = await makeRoot()
const worktree = join(root, 'external-5')
@ -255,6 +282,34 @@ describe('worktree base directory poller', () => {
)
})
it('covers primary-checkout metadata alongside the narrow stream', async () => {
const commonDir = await makeRoot()
await mkdir(join(commonDir, 'worktrees'))
const received: WorktreeBasePollEvent[][] = []
const target = makeTarget('git-common', commonDir)
const poller = await startWorktreeBaseDirectoryPoller(
target,
() => target.repos,
(events) => received.push(events),
{ pollIntervalMs: POLL_MS, platform: 'darwin' }
)
cleanups.push(() => poller.unsubscribe())
// The narrow stream is rooted at worktrees/, so top-level HEAD writes
// must arrive through the companion metadata poll.
const headFile = join(commonDir, 'HEAD')
await writeFile(headFile, 'ref: refs/heads/main')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'create' && event.path === headFile)
)
await new Promise((resolve) => setTimeout(resolve, 10))
await writeFile(headFile, 'ref: refs/heads/feature')
await waitForEvents(received, (flat) =>
flat.some((event) => event.type === 'update' && event.path === headFile)
)
})
it('arms via existence polling when the worktrees dir appears later', async () => {
const commonDir = await makeRoot()
const received: WorktreeBasePollEvent[][] = []

View File

@ -6,14 +6,37 @@ import type {
WorktreeBaseSubscription
} from './worktree-base-directory-poller'
// Watches a repo's `<common>/.git/worktrees` metadata — the only subtree the
// git-common event filter consumes.
// macOS: a narrow native stream rooted there — a tiny, rare-churn tree —
// gives instant detection with zero idle cost and zero wide-scope fseventsd
// delivery. Other platforms: dir-listing poll (no fseventsd to protect, and
// Watches a repo's `<common>/.git/worktrees` metadata plus the primary
// checkout's shallow branch/index files — the only paths the git-common event
// filter consumes.
// macOS: a narrow native stream rooted at `worktrees/` — a tiny, rare-churn
// tree — gives instant detection with zero idle cost and zero wide-scope
// fseventsd delivery; the primary files are covered by a few stat calls per
// tick (a native stream would have to span the whole common dir, objects
// included). Other platforms: dir-listing poll (no fseventsd to protect, and
// on Windows an open directory handle on `worktrees/` could interfere with
// `git worktree prune` removing it).
// Why: branch switches and commits made in the primary checkout rewrite these
// top-level files (linked-worktree equivalents live under `worktrees/`).
// Deliberately excludes FETCH_HEAD-style churn that carries no status change.
const PRIMARY_CHECKOUT_METADATA_FILES = ['HEAD', 'packed-refs', 'index']
async function snapshotPrimaryCheckoutMetadata(
commonDirPath: string
): Promise<Map<string, number>> {
const mtimes = new Map<string, number>()
for (const name of PRIMARY_CHECKOUT_METADATA_FILES) {
const filePath = join(commonDirPath, name)
try {
mtimes.set(filePath, (await stat(filePath)).mtimeMs)
} catch {
// Missing file (e.g. no packed-refs yet) diffs into a create later.
}
}
return mtimes
}
async function snapshotGitCommon(commonDirPath: string): Promise<Map<string, number>> {
const mtimes = new Map<string, number>()
const worktreesDir = join(commonDirPath, 'worktrees')
@ -59,15 +82,15 @@ function diffGitCommon(
return events
}
async function startGitCommonPoller(
target: WorktreeBaseWatchTarget,
async function startSnapshotDiffPoller(
takeSnapshot: () => Promise<Map<string, number>>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let snapshot = await snapshotGitCommon(target.path)
let snapshot = await takeSnapshot()
const timer = setInterval(() => {
if (disposed || ticking) {
@ -75,7 +98,7 @@ async function startGitCommonPoller(
}
ticking = true
onFullScan?.()
void snapshotGitCommon(target.path)
void takeSnapshot()
.then((next) => {
if (disposed) {
return
@ -103,6 +126,19 @@ async function startGitCommonPoller(
}
}
async function snapshotGitCommonAndPrimaryMetadata(
commonDirPath: string
): Promise<Map<string, number>> {
const [worktrees, primary] = await Promise.all([
snapshotGitCommon(commonDirPath),
snapshotPrimaryCheckoutMetadata(commonDirPath)
])
for (const [path, mtime] of primary) {
worktrees.set(path, mtime)
}
return worktrees
}
async function startGitCommonNarrowWatch(
target: WorktreeBaseWatchTarget,
onEvents: (events: WorktreeBasePollEvent[]) => void,
@ -227,7 +263,25 @@ export async function startGitCommonWatch(
onFullScan?: () => void
): Promise<WorktreeBaseSubscription> {
if (platform === 'darwin') {
return startGitCommonNarrowWatch(target, onEvents, pollIntervalMs)
const [narrowWatch, primaryMetadataPoll] = await Promise.all([
startGitCommonNarrowWatch(target, onEvents, pollIntervalMs),
startSnapshotDiffPoller(
() => snapshotPrimaryCheckoutMetadata(target.path),
onEvents,
pollIntervalMs,
onFullScan
)
])
return {
unsubscribe: async () => {
await Promise.all([narrowWatch.unsubscribe(), primaryMetadataPoll.unsubscribe()])
}
}
}
return startGitCommonPoller(target, onEvents, pollIntervalMs, onFullScan)
return startSnapshotDiffPoller(
() => snapshotGitCommonAndPrimaryMetadata(target.path),
onEvents,
pollIntervalMs,
onFullScan
)
}

View File

@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 HookParams = {
activeRepoId: string | null
activeWorktreeId: string | null
enabled: boolean
fetchStatus: () => void
}
async function renderHookOnce(params: HookParams): Promise<{
emitWorktreesChanged: (repoId: string) => void
emitCommandFinished: (worktreeId: string) => void
onChangedSubscribe: ReturnType<typeof vi.fn>
windowListeners: Map<string, EventListener>
cleanups: (() => void)[]
}> {
vi.resetModules()
const cleanups: (() => void)[] = []
vi.doMock('react', async () => {
const actual = await vi.importActual<typeof React>('react')
return {
...actual,
useEffect: (effect: () => void | (() => void)) => {
const cleanup = effect()
if (typeof cleanup === 'function') {
cleanups.push(cleanup)
}
},
useRef: <T>(initial: T) => ({ current: initial })
}
})
let worktreesChangedCallback: WorktreesChangedCallback | null = null
const onChangedUnsubscribe = vi.fn()
const onChangedSubscribe = vi.fn((callback: WorktreesChangedCallback) => {
worktreesChangedCallback = callback
return onChangedUnsubscribe
})
const windowListeners = new Map<string, EventListener>()
vi.stubGlobal('window', {
api: {
worktrees: {
onChanged: onChangedSubscribe
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
windowListeners.set(type, listener)
}),
removeEventListener: vi.fn((type: string) => {
windowListeners.delete(type)
})
})
vi.stubGlobal('document', {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
const { useGitStatusPushSignalRefresh } = await import('./git-status-push-signal-refresh')
function PushSignalRefreshHarness(props: HookParams): null {
useGitStatusPushSignalRefresh(props)
return null
}
PushSignalRefreshHarness(params)
return {
emitWorktreesChanged: (repoId: string) => worktreesChangedCallback?.({ repoId }),
emitCommandFinished: (worktreeId: string) => {
const listener = windowListeners.get(ORCA_TERMINAL_COMMAND_FINISHED_EVENT)
listener?.({ detail: { worktreeId } } as unknown as Event)
},
onChangedSubscribe,
windowListeners,
cleanups
}
}
describe('useGitStatusPushSignalRefresh', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('nudges status when the active repo reports a worktrees change', async () => {
const fetchStatus = vi.fn()
const harness = await renderHookOnce({
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
enabled: true,
fetchStatus
})
harness.emitWorktreesChanged('repo-1')
expect(fetchStatus).toHaveBeenCalledTimes(1)
harness.emitWorktreesChanged('repo-other')
expect(fetchStatus).toHaveBeenCalledTimes(1)
})
it('nudges status when a terminal command finishes in the active worktree', async () => {
const fetchStatus = vi.fn()
const harness = await renderHookOnce({
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
enabled: true,
fetchStatus
})
harness.emitCommandFinished('wt-1')
expect(fetchStatus).toHaveBeenCalledTimes(1)
harness.emitCommandFinished('wt-other')
expect(fetchStatus).toHaveBeenCalledTimes(1)
})
it('drops nudges while the window is hidden', async () => {
const fetchStatus = vi.fn()
const harness = await renderHookOnce({
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
enabled: true,
fetchStatus
})
;(document as unknown as { visibilityState: string }).visibilityState = 'hidden'
harness.emitWorktreesChanged('repo-1')
harness.emitCommandFinished('wt-1')
expect(fetchStatus).not.toHaveBeenCalled()
})
it('subscribes to nothing while disabled', async () => {
const fetchStatus = vi.fn()
const harness = await renderHookOnce({
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
enabled: false,
fetchStatus
})
expect(harness.onChangedSubscribe).not.toHaveBeenCalled()
expect(harness.windowListeners.size).toBe(0)
})
it('unsubscribes both signals on cleanup', async () => {
const fetchStatus = vi.fn()
const harness = await renderHookOnce({
activeRepoId: 'repo-1',
activeWorktreeId: 'wt-1',
enabled: true,
fetchStatus
})
expect(harness.cleanups.length).toBe(2)
for (const cleanup of harness.cleanups) {
cleanup()
}
expect(harness.windowListeners.size).toBe(0)
})
})

View File

@ -0,0 +1,65 @@
import { useEffect, useRef } from 'react'
import { isWindowVisible } from '@/lib/window-visibility-interval'
import {
ORCA_TERMINAL_COMMAND_FINISHED_EVENT,
type TerminalCommandFinishedEventDetail
} from '@/hooks/terminal-command-finished-event'
type UseGitStatusPushSignalRefreshParams = {
activeRepoId: string | null
activeWorktreeId: string | null
enabled: boolean
fetchStatus: () => void
}
// Why: these push signals close the latency gap left by the slow terminal-only
// fallback poll — branch switches and commits made inside shells surface at
// the coalescer's floor instead of waiting out the fallback cadence. Bursts
// are safe: the main-process watcher debounces and fetchStatus feeds a
// coalesced runner with a minimum interval.
export function useGitStatusPushSignalRefresh({
activeRepoId,
activeWorktreeId,
enabled,
fetchStatus
}: UseGitStatusPushSignalRefreshParams): void {
const fetchStatusRef = useRef(fetchStatus)
fetchStatusRef.current = fetchStatus
useEffect(() => {
if (!enabled || !activeRepoId) {
return
}
// Why: remote web surfaces have no preload bridge; the fallback poll
// still covers them.
const subscribeToWorktreesChanged = window.api?.worktrees?.onChanged
if (!subscribeToWorktreesChanged) {
return
}
// Repo metadata (HEAD/index/worktrees) changed on disk. Hidden windows
// skip the nudge; the visibility interval refreshes immediately on reveal.
return subscribeToWorktreesChanged(({ repoId }) => {
if (repoId !== activeRepoId || !isWindowVisible()) {
return
}
fetchStatusRef.current()
})
}, [enabled, activeRepoId])
useEffect(() => {
if (!enabled || !activeWorktreeId) {
return
}
const handleCommandFinished = (event: Event): void => {
const detail = (event as CustomEvent<TerminalCommandFinishedEventDetail>).detail
if (detail?.worktreeId !== activeWorktreeId || !isWindowVisible()) {
return
}
fetchStatusRef.current()
}
window.addEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
return () => {
window.removeEventListener(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, handleCommandFinished)
}
}, [enabled, activeWorktreeId])
}

View File

@ -114,6 +114,9 @@ async function usePollingOnce(
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
},
worktrees: {
onChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn(),
@ -362,6 +365,9 @@ describe('useGitStatusPolling', () => {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
},
worktrees: {
onChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
@ -471,6 +477,9 @@ describe('useGitStatusPolling', () => {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
},
worktrees: {
onChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
@ -592,6 +601,9 @@ describe('useGitStatusPolling', () => {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
},
worktrees: {
onChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn((type: string, listener: EventListener) => {
@ -707,6 +719,9 @@ describe('useGitStatusPolling', () => {
watchWorktree: vi.fn().mockResolvedValue(undefined),
unwatchWorktree: vi.fn().mockResolvedValue(undefined),
onFsChanged: vi.fn(() => vi.fn())
},
worktrees: {
onChanged: vi.fn(() => vi.fn())
}
},
addEventListener: vi.fn(),

View File

@ -14,11 +14,13 @@ import {
} from '@/lib/passive-macos-app-data-access'
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
import { useGitStatusFileWatchRefresh } from './git-status-file-watch-refresh'
import { useGitStatusPushSignalRefresh } from './git-status-push-signal-refresh'
const MIN_STATUS_REFRESH_INTERVAL_MS = 3000
const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS
// Why: file-watch refreshes cover content changes; terminal-only polling is
// just a fallback for branch switches made inside shells.
// Why: file-watch refreshes cover content changes and push signals (repo
// metadata watch, shell command completion) cover branch switches; the
// terminal-only poll is a last-resort backstop for shells without either.
const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000
export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
@ -182,6 +184,13 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void {
worktreePath
})
useGitStatusPushSignalRefresh({
activeRepoId,
activeWorktreeId,
enabled: shouldPollActiveWorktreeGitStatus,
fetchStatus
})
// Why: poll conflict operation for non-active worktrees that have a stale
// non-unknown operation. This is a lightweight fs-only check (no git status)
// so it won't cause performance issues even with many worktrees.

View File

@ -85,6 +85,7 @@ import {
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { e2eConfig } from '@/lib/e2e-config'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
@ -1373,6 +1374,9 @@ export function connectPanePty(
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
// Why: the finished command may have moved HEAD or the index (e.g.
// `git checkout`); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()

View File

@ -0,0 +1,16 @@
export const ORCA_TERMINAL_COMMAND_FINISHED_EVENT = 'orca:terminal-command-finished'
export type TerminalCommandFinishedEventDetail = {
worktreeId: string
}
// Why: the OSC 133;D handler lives in a per-pane closure; a window event lets
// decoupled consumers (e.g. git status refresh) react to shell commands
// finishing without reaching into terminal internals.
export function dispatchTerminalCommandFinishedEvent(worktreeId: string): void {
window.dispatchEvent(
new CustomEvent<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId }
})
)
}