mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user