mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
perf(repos): update watcher targets incrementally
This commit is contained in:
@@ -6,6 +6,7 @@ const { reposMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('./repos-remote-test-harness')
|
||||
return { reposMocks: moduleMocks.createReposIpcMocks(), moduleMocks }
|
||||
})
|
||||
const scheduleWorktreeWatcherSyncMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks))
|
||||
vi.mock('../git/repo', async (importOriginal) =>
|
||||
@@ -21,6 +22,9 @@ vi.mock('./registered-worktree-roots-cache', () =>
|
||||
vi.mock('../worktree-root-preparation', () =>
|
||||
moduleMocks.worktreeRootPreparationModuleMock(reposMocks)
|
||||
)
|
||||
vi.mock('./worktree-base-directory-watcher', () => ({
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () =>
|
||||
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
|
||||
@@ -54,6 +58,7 @@ describe('repos:add + repos:clone', () => {
|
||||
captureHandlers(handleMock)
|
||||
resetLocalRepoMocks(reposMocks)
|
||||
mockWindow.webContents.send.mockReset()
|
||||
scheduleWorktreeWatcherSyncMock.mockClear()
|
||||
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never)
|
||||
})
|
||||
@@ -193,16 +198,26 @@ describe('repos:add + repos:clone', () => {
|
||||
mockStore.getProjects.mockReturnValue([project])
|
||||
mockStore.getProjectHostSetups.mockReturnValue([setup])
|
||||
mockStore.updateRepo.mockReturnValue(aligned)
|
||||
const preparation = Promise.withResolvers<void>()
|
||||
prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise)
|
||||
|
||||
await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
const setupResult = handlers.get('projectHostSetups:setupExistingFolder')!(null, {
|
||||
projectId: project.id,
|
||||
hostId: 'local',
|
||||
path: existing.path,
|
||||
kind: 'git',
|
||||
setupMethod: 'imported-existing-folder'
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned)
|
||||
)
|
||||
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
|
||||
preparation.resolve()
|
||||
await setupResult
|
||||
|
||||
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned)
|
||||
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({
|
||||
dirtyRepoIdentities: [{ repoId: aligned.id, hostId: 'local' }]
|
||||
})
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -331,7 +346,7 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
|
||||
it('prepares and invalidates roots when repos:update changes worktree base path', async () => {
|
||||
const updated = {
|
||||
id: 'repo-update-root',
|
||||
path: '/tmp/repo-update-root',
|
||||
@@ -342,7 +357,7 @@ describe('repos:add + repos:clone', () => {
|
||||
}
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
|
||||
const result = handlers.get('repos:update')!(null, {
|
||||
const result = await handlers.get('repos:update')!(null, {
|
||||
repoId: updated.id,
|
||||
updates: { worktreeBasePath: ' ../worktrees ' }
|
||||
})
|
||||
@@ -355,7 +370,36 @@ describe('repos:add + repos:clone', () => {
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility through local repos:update', () => {
|
||||
it('waits for deferred root preparation before scheduling the watcher', async () => {
|
||||
const updated = {
|
||||
id: 'repo-deferred-root',
|
||||
path: '/tmp/repo-deferred-root',
|
||||
displayName: 'repo-deferred-root',
|
||||
kind: 'git',
|
||||
badgeColor: '#22c55e',
|
||||
worktreeBasePath: '../worktrees'
|
||||
}
|
||||
const preparation = Promise.withResolvers<void>()
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise)
|
||||
|
||||
const result = handlers.get('repos:update')!(null, {
|
||||
repoId: updated.id,
|
||||
updates: { worktreeBasePath: '../worktrees' }
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, updated)
|
||||
)
|
||||
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
|
||||
|
||||
preparation.resolve()
|
||||
await expect(result).resolves.toBe(updated)
|
||||
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({
|
||||
dirtyRepoIdentities: [{ repoId: updated.id, hostId: 'local' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility through local repos:update', async () => {
|
||||
const updated = {
|
||||
id: 'repo-agent-visibility',
|
||||
path: '/tmp/repo-agent-visibility',
|
||||
@@ -366,7 +410,7 @@ describe('repos:add + repos:clone', () => {
|
||||
}
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
|
||||
const result = handlers.get('repos:update')!(null, {
|
||||
const result = await handlers.get('repos:update')!(null, {
|
||||
repoId: updated.id,
|
||||
updates: { agentWorktreeVisibility: 'show' }
|
||||
})
|
||||
@@ -377,7 +421,7 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('validates source definitions and preferences through local repos:update', () => {
|
||||
it('validates source definitions and preferences through local repos:update', async () => {
|
||||
const updated = {
|
||||
id: 'repo-source-visibility',
|
||||
path: '/tmp/repo-source-visibility',
|
||||
@@ -387,7 +431,7 @@ describe('repos:add + repos:clone', () => {
|
||||
}
|
||||
mockStore.updateRepo.mockReturnValue(updated)
|
||||
|
||||
handlers.get('repos:update')!(null, {
|
||||
await handlers.get('repos:update')!(null, {
|
||||
repoId: updated.id,
|
||||
updates: {
|
||||
customWorktreeVisibilitySources: [
|
||||
@@ -410,7 +454,7 @@ describe('repos:add + repos:clone', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
|
||||
it('prepares and invalidates roots when project host setup update changes worktree base path', async () => {
|
||||
const repo = {
|
||||
id: 'repo-setup-update-root',
|
||||
path: '/tmp/repo-setup-update-root',
|
||||
@@ -426,12 +470,12 @@ describe('repos:add + repos:clone', () => {
|
||||
}
|
||||
mockStore.updateProjectHostSetup.mockReturnValue(result)
|
||||
|
||||
expect(
|
||||
await expect(
|
||||
handlers.get('projectHostSetups:update')!(null, {
|
||||
setupId: 'setup-1',
|
||||
updates: { worktreeBasePath: '../worktrees' }
|
||||
})
|
||||
).toBe(result)
|
||||
).resolves.toBe(result)
|
||||
|
||||
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, repo)
|
||||
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
|
||||
|
||||
+47
-31
@@ -91,6 +91,7 @@ import { getActiveMultiplexer } from './ssh'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { track } from '../telemetry/client'
|
||||
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
|
||||
import { getWorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache'
|
||||
import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
@@ -1329,7 +1330,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
|
||||
// Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh
|
||||
// closure per list call would stack up (and re-broadcast) for the length of a slow sweep.
|
||||
const broadcastReposChanged = (): void => notifyReposChanged(mainWindow)
|
||||
const broadcastReposChanged = (): void => notifyReposChanged(mainWindow, null)
|
||||
|
||||
ipcMain.handle('repos:list', () => {
|
||||
enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
|
||||
@@ -1375,14 +1376,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (!result) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:update',
|
||||
(_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => {
|
||||
async (_event, rawArgs: ProjectHostSetupUpdateArgs): Promise<ProjectHostSetupUpdateResult> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupUpdateIpcArgs,
|
||||
rawArgs,
|
||||
@@ -1393,10 +1394,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
if ('worktreeBasePath' in args.updates && result.repo) {
|
||||
void prepareLocalWorktreeRootForRepo(store, result.repo)
|
||||
await prepareLocalWorktreeRootForRepo(store, result.repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, result.repo ? [result.repo] : null)
|
||||
return result
|
||||
}
|
||||
)
|
||||
@@ -1413,7 +1414,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, result.repo ? [result.repo] : null)
|
||||
return result
|
||||
}
|
||||
)
|
||||
@@ -1468,11 +1469,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
throw err
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
if (result.alreadyExisted) {
|
||||
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
|
||||
}
|
||||
notifyReposChanged(mainWindow, [aligned.repo])
|
||||
return aligned
|
||||
}
|
||||
)
|
||||
@@ -1525,7 +1526,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
...args,
|
||||
creatorProvenance: { kind: 'host' }
|
||||
})
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
return workspace
|
||||
}
|
||||
)
|
||||
@@ -1564,7 +1565,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates)
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@@ -1578,7 +1579,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
@@ -1596,7 +1597,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
parentGroupId: args.parentGroupId ?? null,
|
||||
createdFrom: args.createdFrom ?? 'manual'
|
||||
})
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
return group
|
||||
})
|
||||
|
||||
@@ -1608,7 +1609,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
const updated = store.updateProjectGroup(args.groupId, args.updates)
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
@@ -1621,7 +1622,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
const deleted = store.deleteProjectGroup(args.groupId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
}
|
||||
return deleted
|
||||
})
|
||||
@@ -1634,7 +1635,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
|
||||
if (moved) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
}
|
||||
return moved
|
||||
})
|
||||
@@ -1698,6 +1699,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
error: 'Repository was not found in the nested repo scan result'
|
||||
})
|
||||
)
|
||||
const watchChangedRepos: Repo[] = []
|
||||
const importedProjectIdsByRepoPath = new Map<string, string>()
|
||||
const importTargetResolver = createNestedRepoImportTargetResolver()
|
||||
|
||||
@@ -1779,6 +1781,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
: {})
|
||||
}
|
||||
store.addRepo(repo)
|
||||
watchChangedRepos.push(repo)
|
||||
await prepareLocalWorktreeRootForRepo(store, repo)
|
||||
if (args.connectionId) {
|
||||
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
|
||||
@@ -1807,7 +1810,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
}
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, watchChangedRepos.length > 0 ? watchChangedRepos : null)
|
||||
const rootGroup = groupResolver.getRootGroup()
|
||||
return {
|
||||
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
|
||||
@@ -1833,7 +1836,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
await prepareLocalWorktreeRootForRepo(store, result.repo)
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [result.repo])
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
|
||||
return { repo: result.repo }
|
||||
}
|
||||
@@ -1854,7 +1857,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [result.repo])
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
|
||||
return { repo: result.repo }
|
||||
}
|
||||
@@ -1875,7 +1878,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [result.repo])
|
||||
return result
|
||||
}
|
||||
)
|
||||
@@ -2037,7 +2040,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
store.addRepo(repo)
|
||||
await prepareLocalWorktreeRootForRepo(store, repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [repo])
|
||||
// Why: repos:create git-inits when kind is 'git', so repoKind is the true git-vs-folder signal.
|
||||
emitRepoAdded('folder_picker', false, repoKind === 'git')
|
||||
return { repo }
|
||||
@@ -2051,7 +2054,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
|
||||
const applied = store.reorderRepos(ids)
|
||||
if (applied) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
return { status: 'applied' }
|
||||
}
|
||||
return { status: 'rejected' }
|
||||
@@ -2071,7 +2074,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
|
||||
const applied = store.reorderReposForHost(ids, hostId)
|
||||
if (applied) {
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, null)
|
||||
return { status: 'applied' }
|
||||
}
|
||||
return { status: 'rejected' }
|
||||
@@ -2079,9 +2082,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
const removedRepo = store.getRepo(args.repoId)
|
||||
store.removeProject(args.repoId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null)
|
||||
})
|
||||
|
||||
// Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow).
|
||||
@@ -2092,15 +2096,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (!hostId) {
|
||||
throw new Error(`Invalid host ID: ${args.hostId}`)
|
||||
}
|
||||
const removedRepo = store
|
||||
.getRepos()
|
||||
.find((repo) => repo.id === args.repoId && getRepoExecutionHostId(repo) === hostId)
|
||||
store.removeProjectForHost(args.repoId, hostId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'repos:update',
|
||||
(
|
||||
async (
|
||||
_event,
|
||||
args: {
|
||||
repoId: string
|
||||
@@ -2289,10 +2296,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
: store.updateRepo(args.repoId, updates)
|
||||
if (updated) {
|
||||
if ('worktreeBasePath' in updates) {
|
||||
void prepareLocalWorktreeRootForRepo(store, updated)
|
||||
await prepareLocalWorktreeRootForRepo(store, updated)
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [updated])
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@@ -2528,7 +2535,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if (updated) {
|
||||
await prepareLocalWorktreeRootForRepo(store, updated)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [updated])
|
||||
// Why: folder→git upgrade is a real new git repo provisioning event.
|
||||
emitRepoAdded('clone_url', false, true)
|
||||
return updated
|
||||
@@ -2554,7 +2561,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
store.addRepo(repo)
|
||||
await prepareLocalWorktreeRootForRepo(store, repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [repo])
|
||||
emitRepoAdded('clone_url', false, true)
|
||||
return repo
|
||||
} finally {
|
||||
@@ -2574,7 +2581,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
args: { connectionId: string; url: string; destination: string }
|
||||
): Promise<Repo> => {
|
||||
const repo = await cloneRemoteRepo(store, mainWindow, args)
|
||||
notifyReposChanged(mainWindow)
|
||||
notifyReposChanged(mainWindow, [repo])
|
||||
return repo
|
||||
}
|
||||
)
|
||||
@@ -2771,7 +2778,10 @@ function getRepoForExecutionHost(
|
||||
)
|
||||
}
|
||||
|
||||
export function notifyReposChanged(mainWindow: BrowserWindow): void {
|
||||
export function notifyReposChanged(
|
||||
mainWindow: BrowserWindow,
|
||||
watcherChangedRepos?: readonly Repo[] | null
|
||||
): void {
|
||||
wakeFolderRepoGitUpgradeWatch()
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:changed')
|
||||
@@ -2784,7 +2794,13 @@ export function notifyReposChanged(mainWindow: BrowserWindow): void {
|
||||
// Why: a broadcast failure must never fail the mutation the user actually asked for.
|
||||
console.error('[repos] failed to notify remote clients of repo change', err)
|
||||
}
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
|
||||
if (watcherChangedRepos !== null) {
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync(
|
||||
watcherChangedRepos
|
||||
? { dirtyRepoIdentities: watcherChangedRepos.map(getWorktreeBaseRepoIdentity) }
|
||||
: { fullRebuild: true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
|
||||
@@ -11,7 +11,8 @@ const {
|
||||
previewWarpThemeImportMock,
|
||||
prepareLocalWorktreeRootsForReposMock,
|
||||
resolveEnvironmentMock,
|
||||
rebuildAppMenuMock
|
||||
rebuildAppMenuMock,
|
||||
scheduleWorktreeWatcherSyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
applyAppIconMock: vi.fn(),
|
||||
applyAgentStatusHooksEnabledMock: vi.fn(),
|
||||
@@ -23,7 +24,8 @@ const {
|
||||
previewWarpThemeImportMock: vi.fn(),
|
||||
prepareLocalWorktreeRootsForReposMock: vi.fn(),
|
||||
resolveEnvironmentMock: vi.fn(),
|
||||
rebuildAppMenuMock: vi.fn()
|
||||
rebuildAppMenuMock: vi.fn(),
|
||||
scheduleWorktreeWatcherSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
@@ -57,6 +59,10 @@ vi.mock('../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootsForRepos: prepareLocalWorktreeRootsForReposMock
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-base-directory-watcher', () => ({
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock
|
||||
}))
|
||||
|
||||
vi.mock('../menu/register-app-menu', () => ({
|
||||
rebuildAppMenu: rebuildAppMenuMock
|
||||
}))
|
||||
@@ -100,6 +106,7 @@ describe('registerSettingsHandlers', () => {
|
||||
return { id: 'windows-2' }
|
||||
})
|
||||
rebuildAppMenuMock.mockClear()
|
||||
scheduleWorktreeWatcherSyncMock.mockClear()
|
||||
browserWindowGetAllWindowsMock.mockReset()
|
||||
store.getSettings.mockReset()
|
||||
store.updateSettings.mockReset()
|
||||
@@ -395,6 +402,7 @@ describe('registerSettingsHandlers', () => {
|
||||
await handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' })
|
||||
|
||||
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
|
||||
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
|
||||
})
|
||||
|
||||
it('prepares local worktree roots when workspace nesting changes', async () => {
|
||||
@@ -410,6 +418,29 @@ describe('registerSettingsHandlers', () => {
|
||||
await handler(settingsInvokeEvent, { nestWorkspaces: true })
|
||||
|
||||
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
|
||||
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
|
||||
})
|
||||
|
||||
it('waits for deferred root creation before scheduling a full watcher rebuild', async () => {
|
||||
const preparation = Promise.withResolvers<void>()
|
||||
store.getSettings.mockReturnValue({ workspaceDir: '/old/workspaces', nestWorkspaces: false })
|
||||
store.updateSettings.mockReturnValue({ workspaceDir: '/new/workspaces', nestWorkspaces: false })
|
||||
prepareLocalWorktreeRootsForReposMock.mockReturnValueOnce(preparation.promise)
|
||||
registerSettingsHandlers(store as never)
|
||||
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
|
||||
_event: unknown,
|
||||
args: unknown
|
||||
) => Promise<unknown>
|
||||
|
||||
const result = handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' })
|
||||
await vi.waitFor(() =>
|
||||
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
|
||||
)
|
||||
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
|
||||
|
||||
preparation.resolve()
|
||||
await result
|
||||
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
|
||||
})
|
||||
|
||||
it('does not prepare local worktree roots when workspace layout values do not change', async () => {
|
||||
@@ -425,6 +456,7 @@ describe('registerSettingsHandlers', () => {
|
||||
await handler(settingsInvokeEvent, { workspaceDir: '/workspaces', nestWorkspaces: false })
|
||||
|
||||
expect(prepareLocalWorktreeRootsForReposMock).not.toHaveBeenCalled()
|
||||
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not accept floating workspace trust grants from renderer settings IPC', async () => {
|
||||
|
||||
@@ -232,8 +232,8 @@ export function registerSettingsHandlers(
|
||||
('workspaceDir' in sanitizedArgs && before.workspaceDir !== result.workspaceDir) ||
|
||||
('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces)
|
||||
) {
|
||||
void prepareLocalWorktreeRootsForRepos(store)
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
|
||||
await prepareLocalWorktreeRootsForRepos(store)
|
||||
scheduleCurrentWorktreeBaseDirectoryWatcherSync({ fullRebuild: true })
|
||||
}
|
||||
if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) {
|
||||
rebuildAppMenu()
|
||||
|
||||
@@ -30,6 +30,7 @@ export type WorktreeBaseWatchTarget = {
|
||||
kind: WorktreeBaseWatchKind
|
||||
path: string
|
||||
connectionId?: string
|
||||
providerGeneration?: number
|
||||
repos: Map<string, WorktreeBaseRepoWatchConfig>
|
||||
/** Exact upstream leaf selected by accepted active-worktree status. */
|
||||
gitStatusRefPaths?: ReadonlySet<string>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import { resolveWorktreeBaseDirectoryWatchTargetsForRepo } from './worktree-base-directory-watch-targets'
|
||||
import { getWorktreePathSettings } from './worktree-logic'
|
||||
|
||||
export type WorktreeBaseRepoIdentity = {
|
||||
repoId: string
|
||||
hostId: string
|
||||
}
|
||||
|
||||
export type WorktreeBaseDirectoryWatchTargetBuildOptions = {
|
||||
fullRebuild?: boolean
|
||||
dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[]
|
||||
dirtyConnectionIds?: readonly string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
type CachedRepoTargets = {
|
||||
fingerprint: string
|
||||
targets: Map<string, WorktreeBaseWatchTarget>
|
||||
}
|
||||
export type WorktreeBaseDirectoryWatchTargetRetry = {
|
||||
identity: WorktreeBaseRepoIdentity
|
||||
version: string
|
||||
connectionId?: string
|
||||
providerGeneration: number
|
||||
}
|
||||
|
||||
export type WorktreeBaseDirectoryWatchTargetBuildResult = {
|
||||
targets: Map<string, WorktreeBaseWatchTarget>
|
||||
retries: WorktreeBaseDirectoryWatchTargetRetry[]
|
||||
}
|
||||
|
||||
const repoTargetCache = new Map<string, CachedRepoTargets>()
|
||||
|
||||
export function getWorktreeBaseRepoIdentity(repo: Repo): WorktreeBaseRepoIdentity {
|
||||
return {
|
||||
repoId: repo.id,
|
||||
hostId: getRepoExecutionHostId(repo)
|
||||
}
|
||||
}
|
||||
|
||||
function getIdentityKey(identity: WorktreeBaseRepoIdentity): string {
|
||||
return `${identity.hostId}\0${identity.repoId}`
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('Worktree base directory watch target build aborted')
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoTargetFingerprint(repo: Repo, settings: GlobalSettings): string {
|
||||
const pathSettings = getWorktreePathSettings(repo, settings)
|
||||
return JSON.stringify([
|
||||
repo.id,
|
||||
getRepoExecutionHostId(repo),
|
||||
repo.path,
|
||||
repo.kind ?? 'git',
|
||||
repo.connectionId ?? null,
|
||||
repo.worktreeBasePath ?? null,
|
||||
pathSettings.workspaceDir,
|
||||
pathSettings.nestWorkspaces,
|
||||
repo.connectionId ? getSshFilesystemProviderGeneration(repo.connectionId) : 0
|
||||
])
|
||||
}
|
||||
|
||||
function mergeRepoTargets(
|
||||
targets: Map<string, WorktreeBaseWatchTarget>,
|
||||
repoTargets: Map<string, WorktreeBaseWatchTarget>
|
||||
): void {
|
||||
for (const [key, target] of repoTargets) {
|
||||
const existing = targets.get(key)
|
||||
if (existing) {
|
||||
for (const [repoId, config] of target.repos) {
|
||||
existing.repos.set(repoId, config)
|
||||
}
|
||||
continue
|
||||
}
|
||||
targets.set(key, { ...target, repos: new Map(target.repos) })
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildWorktreeBaseDirectoryWatchTargets(
|
||||
store: Store,
|
||||
options: WorktreeBaseDirectoryWatchTargetBuildOptions = {}
|
||||
): Promise<WorktreeBaseDirectoryWatchTargetBuildResult> {
|
||||
const settings = store.getSettings()
|
||||
const signal = options.signal
|
||||
const liveRepos = new Map(
|
||||
store
|
||||
.getRepos()
|
||||
.map((repo) => [getIdentityKey(getWorktreeBaseRepoIdentity(repo)), repo] as const)
|
||||
)
|
||||
const fullRebuild = options.fullRebuild === true || repoTargetCache.size === 0
|
||||
const dirtyRepoKeys = new Set(options.dirtyRepoIdentities?.map(getIdentityKey) ?? [])
|
||||
const dirtyConnectionIds = new Set(options.dirtyConnectionIds ?? [])
|
||||
const nextCache = new Map<string, CachedRepoTargets>(repoTargetCache)
|
||||
const retries: WorktreeBaseDirectoryWatchTargetRetry[] = []
|
||||
|
||||
for (const key of nextCache.keys()) {
|
||||
if (!liveRepos.has(key)) {
|
||||
nextCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [identityKey, repo] of liveRepos) {
|
||||
throwIfAborted(signal)
|
||||
const fingerprint = getRepoTargetFingerprint(repo, settings)
|
||||
const cached = nextCache.get(identityKey)
|
||||
const isDirty =
|
||||
fullRebuild ||
|
||||
dirtyRepoKeys.has(identityKey) ||
|
||||
(repo.connectionId ? dirtyConnectionIds.has(repo.connectionId) : false) ||
|
||||
cached?.fingerprint !== fingerprint
|
||||
if (!isDirty) {
|
||||
continue
|
||||
}
|
||||
const resolution = await resolveWorktreeBaseDirectoryWatchTargetsForRepo(repo, settings, signal)
|
||||
throwIfAborted(signal)
|
||||
if (resolution.transientFailure) {
|
||||
retries.push({
|
||||
identity: getWorktreeBaseRepoIdentity(repo),
|
||||
version: fingerprint,
|
||||
...(repo.connectionId ? { connectionId: repo.connectionId } : {}),
|
||||
providerGeneration: repo.connectionId
|
||||
? getSshFilesystemProviderGeneration(repo.connectionId)
|
||||
: 0
|
||||
})
|
||||
continue
|
||||
}
|
||||
nextCache.set(identityKey, { fingerprint, targets: resolution.targets })
|
||||
}
|
||||
|
||||
throwIfAborted(signal)
|
||||
repoTargetCache.clear()
|
||||
for (const [key, entry] of nextCache) {
|
||||
repoTargetCache.set(key, entry)
|
||||
}
|
||||
|
||||
const targets = new Map<string, WorktreeBaseWatchTarget>()
|
||||
for (const entry of repoTargetCache.values()) {
|
||||
mergeRepoTargets(targets, entry.targets)
|
||||
}
|
||||
return { targets, retries }
|
||||
}
|
||||
|
||||
export function clearWorktreeBaseDirectoryWatchTargetCache(): void {
|
||||
repoTargetCache.clear()
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { normalize } from 'node:path'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import type { Stats } from 'node:fs'
|
||||
import type { Store } from '../persistence'
|
||||
import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
@@ -15,14 +14,17 @@ import {
|
||||
resolveRuntimePath
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
getSshFilesystemProviderGeneration
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
computeWorkspaceRoot,
|
||||
getWorktreePathSettings,
|
||||
hasRepoWorktreeBasePath
|
||||
} from './worktree-logic'
|
||||
import { shouldEmitBoundedWarning } from './bounded-warning-dedupe'
|
||||
import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory'
|
||||
import { probeWorktreeCommonGitDirectory } from './worktree-common-git-directory'
|
||||
import type {
|
||||
WorktreeBaseRepoWatchConfig,
|
||||
WorktreeBaseWatchKind,
|
||||
@@ -31,6 +33,19 @@ import type {
|
||||
|
||||
const missingRootWarnings = new Set<string>()
|
||||
const skippedWslWarnings = new Set<string>()
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('Worktree base directory watch target build aborted')
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeWatchKey(pathValue: string): string {
|
||||
return normalizeRuntimePathForComparison(normalize(pathValue))
|
||||
@@ -70,9 +85,11 @@ async function addTarget(
|
||||
kind: WorktreeBaseWatchKind,
|
||||
pathValue: string,
|
||||
config: WorktreeBaseRepoWatchConfig,
|
||||
connectionId?: string
|
||||
connectionId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const watchedPath = await canonicalizeExistingPath(pathValue, connectionId)
|
||||
throwIfAborted(signal)
|
||||
const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}`
|
||||
const existing = targets.get(key)
|
||||
if (existing) {
|
||||
@@ -83,7 +100,12 @@ async function addTarget(
|
||||
key,
|
||||
kind,
|
||||
path: watchedPath,
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(connectionId
|
||||
? {
|
||||
connectionId,
|
||||
providerGeneration: getSshFilesystemProviderGeneration(connectionId)
|
||||
}
|
||||
: {}),
|
||||
repos: new Map([[config.repoId, config]])
|
||||
})
|
||||
}
|
||||
@@ -125,8 +147,9 @@ async function maybeAddBaseTarget(
|
||||
targets: Map<string, WorktreeBaseWatchTarget>,
|
||||
repo: Repo,
|
||||
settings: GlobalSettings,
|
||||
connectionId?: string
|
||||
): Promise<void> {
|
||||
connectionId?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const pathSettings = getWorktreePathSettings(repo, settings)
|
||||
const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId)
|
||||
// Why: WSL UNC roots are unreliable for native watching; avoid project-level polling.
|
||||
@@ -137,7 +160,7 @@ async function maybeAddBaseTarget(
|
||||
`[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}`
|
||||
)
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -147,23 +170,27 @@ async function maybeAddBaseTarget(
|
||||
}
|
||||
const remoteProvider = getRemoteProvider(connectionId)
|
||||
if (connectionId && !remoteProvider) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
let transientFailure = false
|
||||
try {
|
||||
const rootStat = remoteProvider
|
||||
? await remoteProvider.stat(workspaceRoot)
|
||||
: await stat(workspaceRoot)
|
||||
if (isDirectoryStat(rootStat)) {
|
||||
await addTarget(targets, 'base', workspaceRoot, config, connectionId)
|
||||
await addTarget(targets, 'base', workspaceRoot, config, connectionId, signal)
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
throwIfAborted(signal)
|
||||
transientFailure = !isMissingPathError(error)
|
||||
const key = normalizeWatchKey(workspaceRoot)
|
||||
if (shouldEmitBoundedWarning(missingRootWarnings, key)) {
|
||||
console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`)
|
||||
}
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
|
||||
const commonDir = await resolveWorktreeCommonGitDirectory(
|
||||
const commonDirProbe = await probeWorktreeCommonGitDirectory(
|
||||
repo,
|
||||
remoteProvider
|
||||
? {
|
||||
@@ -172,28 +199,36 @@ async function maybeAddBaseTarget(
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
if (commonDir) {
|
||||
await addTarget(targets, 'git-common', commonDir, config, connectionId)
|
||||
throwIfAborted(signal)
|
||||
if (commonDirProbe.path) {
|
||||
await addTarget(targets, 'git-common', commonDirProbe.path, config, connectionId, signal)
|
||||
}
|
||||
return transientFailure || commonDirProbe.transientFailure
|
||||
}
|
||||
|
||||
export async function buildWorktreeBaseDirectoryWatchTargets(
|
||||
store: Store
|
||||
): Promise<Map<string, WorktreeBaseWatchTarget>> {
|
||||
const settings = store.getSettings()
|
||||
export type WorktreeBaseDirectoryRepoTargetResolution = {
|
||||
targets: Map<string, WorktreeBaseWatchTarget>
|
||||
transientFailure: boolean
|
||||
}
|
||||
|
||||
export async function resolveWorktreeBaseDirectoryWatchTargetsForRepo(
|
||||
repo: Repo,
|
||||
settings: GlobalSettings,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<WorktreeBaseDirectoryRepoTargetResolution> {
|
||||
const targets = new Map<string, WorktreeBaseWatchTarget>()
|
||||
for (const repo of store.getRepos()) {
|
||||
if (isFolderRepo(repo)) {
|
||||
continue
|
||||
}
|
||||
const executionHostId = getRepoExecutionHostId(repo)
|
||||
if (executionHostId === LOCAL_EXECUTION_HOST_ID) {
|
||||
await maybeAddBaseTarget(targets, repo, settings)
|
||||
} else if (repo.connectionId) {
|
||||
await maybeAddBaseTarget(targets, repo, settings, repo.connectionId)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
return { targets, transientFailure: false }
|
||||
}
|
||||
return targets
|
||||
const executionHostId = getRepoExecutionHostId(repo)
|
||||
const transientFailure =
|
||||
executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
? await maybeAddBaseTarget(targets, repo, settings, undefined, signal)
|
||||
: repo.connectionId
|
||||
? await maybeAddBaseTarget(targets, repo, settings, repo.connectionId, signal)
|
||||
: false
|
||||
throwIfAborted(signal)
|
||||
return { targets, transientFailure }
|
||||
}
|
||||
|
||||
export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void {
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import { join, sep } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
|
||||
const { providerGenerations, providerGenerationListeners } = vi.hoisted(() => ({
|
||||
providerGenerations: new Map<string, number>(),
|
||||
providerGenerationListeners: new Set<(connectionId: string) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(async () => ''),
|
||||
realpath: vi.fn(async (path: string) => path),
|
||||
stat: vi.fn(async () => ({ isDirectory: () => true }))
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-base-directory-poller', () => ({
|
||||
createWorktreePollerWindowVisibility: vi.fn(() => ({
|
||||
isWindowVisible: () => true,
|
||||
onWindowBecameVisible: () => () => {}
|
||||
})),
|
||||
startWorktreeBaseDirectoryPoller: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-remote', () => ({
|
||||
notifyWorktreeGitStatusMetadataChanged: vi.fn(),
|
||||
notifyWorktreeHeadIdentitiesChanged: vi.fn(),
|
||||
notifyWorktreesChanged: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getSshFilesystemProviderGeneration: vi.fn(
|
||||
(connectionId: string) => providerGenerations.get(connectionId) ?? 0
|
||||
),
|
||||
onSshFilesystemProviderGenerationChanged: vi.fn((listener: (connectionId: string) => void) => {
|
||||
providerGenerationListeners.add(listener)
|
||||
return () => providerGenerationListeners.delete(listener)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-head-identity-reader', () => ({
|
||||
readGitCommonHeadIdentities: vi.fn(async () => [])
|
||||
}))
|
||||
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
|
||||
import { notifyWorktreesChanged } from './worktree-remote'
|
||||
import {
|
||||
disposeWorktreeBaseDirectoryWatchers,
|
||||
scheduleWorktreeBaseDirectoryWatcherSync,
|
||||
setWorktreeBaseDirectoryWatcherSyncContext,
|
||||
syncWorktreeBaseDirectoryWatchers
|
||||
} from './worktree-base-directory-watcher'
|
||||
|
||||
const absolutePath = (...parts: string[]): string => join(sep, ...parts)
|
||||
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
|
||||
const settings = {
|
||||
workspaceDir: WORKTREE_ROOT,
|
||||
nestWorkspaces: true
|
||||
} as GlobalSettings
|
||||
const unsubscribeMocks = new Map<string, ReturnType<typeof vi.fn>>()
|
||||
const pollerRepoGetters = new Map<string, () => ReadonlyMap<string, unknown>>()
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id: 'repo-1',
|
||||
path: absolutePath('workspace', 'projects', 'project'),
|
||||
displayName: 'Project',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 1,
|
||||
...overrides
|
||||
} as Repo
|
||||
}
|
||||
|
||||
function makeRepoFleet(): Repo[] {
|
||||
return [
|
||||
...Array.from({ length: 50 }, (_, index) =>
|
||||
makeRepo({
|
||||
id: `repo-${index}`,
|
||||
path: absolutePath('workspace', 'projects', `local-${index}`)
|
||||
})
|
||||
),
|
||||
...Array.from({ length: 25 }, (_, index) =>
|
||||
makeRepo({ id: `repo-${index}`, path: `/srv/a/repo-${index}`, connectionId: 'ssh-a' })
|
||||
),
|
||||
...Array.from({ length: 25 }, (_, index) =>
|
||||
makeRepo({
|
||||
id: `repo-${index + 25}`,
|
||||
path: `/srv/b/repo-${index + 25}`,
|
||||
connectionId: 'ssh-b'
|
||||
})
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function makeStore(repos: Repo[]) {
|
||||
return {
|
||||
getSettings: () => settings,
|
||||
getRepos: () => repos
|
||||
}
|
||||
}
|
||||
|
||||
function makeWindow(options: { destroyed?: () => boolean } = {}) {
|
||||
return {
|
||||
isDestroyed: () => options.destroyed?.() ?? false,
|
||||
webContents: { send: vi.fn() }
|
||||
}
|
||||
}
|
||||
|
||||
function makeRemoteFilesystemProvider() {
|
||||
const unwatch = vi.fn()
|
||||
const callbacks = new Map<string, (events: never[]) => void>()
|
||||
return {
|
||||
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
|
||||
realpath: vi.fn(async (path: string) => path),
|
||||
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
|
||||
watch: vi.fn(async (path: string, callback: (events: never[]) => void) => {
|
||||
callbacks.set(path, callback)
|
||||
return unwatch
|
||||
}),
|
||||
unwatch,
|
||||
callbacks
|
||||
}
|
||||
}
|
||||
|
||||
describe('incremental worktree base directory watcher synchronization', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
settings.workspaceDir = WORKTREE_ROOT
|
||||
settings.nestWorkspaces = true
|
||||
unsubscribeMocks.clear()
|
||||
pollerRepoGetters.clear()
|
||||
providerGenerations.clear()
|
||||
providerGenerationListeners.clear()
|
||||
vi.mocked(stat).mockImplementation(async () => ({ isDirectory: () => true }) as never)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
|
||||
vi.mocked(realpath).mockImplementation(async (path) => String(path))
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(async (target, getRepos) => {
|
||||
const unsubscribe = vi.fn(async () => {})
|
||||
unsubscribeMocks.set(target.path, unsubscribe)
|
||||
pollerRepoGetters.set(target.path, getRepos)
|
||||
return { unsubscribe }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeWorktreeBaseDirectoryWatchers()
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function useFleetProviders() {
|
||||
const providerA = makeRemoteFilesystemProvider()
|
||||
const providerB = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockImplementation((connectionId) =>
|
||||
connectionId === 'ssh-a' ? (providerA as never) : (providerB as never)
|
||||
)
|
||||
return { providerA, providerB }
|
||||
}
|
||||
|
||||
it('probes only one host-qualified repo in a 100-repo local and SSH fleet', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const store = makeStore(repos)
|
||||
const mainWindow = makeWindow()
|
||||
const { providerA, providerB } = useFleetProviders()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
vi.mocked(stat).mockClear()
|
||||
vi.mocked(realpath).mockClear()
|
||||
providerA.stat.mockClear()
|
||||
providerB.stat.mockClear()
|
||||
|
||||
repos[3] = { ...repos[3], path: absolutePath('workspace', 'renamed', 'local-3') }
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }]
|
||||
})
|
||||
|
||||
expect(stat).toHaveBeenCalledTimes(2)
|
||||
expect(realpath).toHaveBeenCalledTimes(2)
|
||||
expect(providerA.stat).not.toHaveBeenCalled()
|
||||
expect(providerB.stat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes only a deleted repo membership without re-probing the fleet', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const deletedPath = repos[3].path
|
||||
const store = makeStore(repos)
|
||||
const { providerA, providerB } = useFleetProviders()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
vi.mocked(stat).mockClear()
|
||||
providerA.stat.mockClear()
|
||||
providerB.stat.mockClear()
|
||||
|
||||
repos.splice(3, 1)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }]
|
||||
})
|
||||
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
expect(providerA.stat).not.toHaveBeenCalled()
|
||||
expect(providerB.stat).not.toHaveBeenCalled()
|
||||
expect([...pollerRepoGetters.get(WORKTREE_ROOT)!().keys()]).toHaveLength(49)
|
||||
expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-3')).toBe(false)
|
||||
expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-4')).toBe(true)
|
||||
expect(unsubscribeMocks.get(join(deletedPath, '.git'))).toHaveBeenCalledOnce()
|
||||
expect(unsubscribeMocks.get(join(repos[3].path, '.git'))).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fully rebuilds every target for root and nesting settings', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const store = makeStore(repos)
|
||||
const { providerA, providerB } = useFleetProviders()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
|
||||
const clearProbeCounts = () => {
|
||||
vi.mocked(stat).mockClear()
|
||||
providerA.stat.mockClear()
|
||||
providerB.stat.mockClear()
|
||||
}
|
||||
const expectFullProbe = () => {
|
||||
expect(stat).toHaveBeenCalledTimes(100)
|
||||
expect(providerA.stat).toHaveBeenCalledTimes(50)
|
||||
expect(providerB.stat).toHaveBeenCalledTimes(50)
|
||||
}
|
||||
clearProbeCounts()
|
||||
settings.nestWorkspaces = false
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
fullRebuild: true
|
||||
})
|
||||
expectFullProbe()
|
||||
|
||||
clearProbeCounts()
|
||||
settings.workspaceDir = absolutePath('workspace', 'new-worktrees')
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
fullRebuild: true
|
||||
})
|
||||
expectFullProbe()
|
||||
})
|
||||
|
||||
it('invalidates only a reconnected SSH host, then only a path-changed repo', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const store = makeStore(repos)
|
||||
const mainWindow = makeWindow()
|
||||
const { providerA, providerB } = useFleetProviders()
|
||||
setWorktreeBaseDirectoryWatcherSyncContext(store as never, mainWindow as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
vi.mocked(stat).mockClear()
|
||||
providerA.stat.mockClear()
|
||||
providerB.stat.mockClear()
|
||||
providerA.watch.mockClear()
|
||||
|
||||
providerGenerations.set('ssh-a', 1)
|
||||
for (const listener of providerGenerationListeners) {
|
||||
listener('ssh-a')
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => expect(providerA.watch).toHaveBeenCalledTimes(26))
|
||||
expect(providerA.stat).toHaveBeenCalledTimes(50)
|
||||
expect(providerB.stat).not.toHaveBeenCalled()
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
|
||||
providerA.stat.mockClear()
|
||||
const changedIndex = repos.findIndex(
|
||||
(repo) => repo.connectionId === 'ssh-b' && repo.id === 'repo-25'
|
||||
)
|
||||
repos[changedIndex] = { ...repos[changedIndex], path: '/srv/b/renamed-repo-25' }
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-25', hostId: 'ssh:ssh-b' }]
|
||||
})
|
||||
expect(providerA.stat).not.toHaveBeenCalled()
|
||||
expect(providerB.stat).toHaveBeenCalledTimes(2)
|
||||
expect(stat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains local watches across a transient target probe and installs after retry', async () => {
|
||||
const repo = makeRepo()
|
||||
const store = makeStore([repo])
|
||||
const mainWindow = makeWindow()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT)
|
||||
const recoveredRoot = absolutePath('workspace', 'recovered-worktrees')
|
||||
let rootProbeFails = true
|
||||
vi.mocked(stat).mockImplementation(async (path) => {
|
||||
if (String(path) === recoveredRoot && rootProbeFails) {
|
||||
throw Object.assign(new Error('temporarily unavailable'), { code: 'EACCES' })
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockClear()
|
||||
|
||||
repo.worktreeBasePath = recoveredRoot
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
expect(oldRootUnsubscribe).not.toHaveBeenCalled()
|
||||
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
|
||||
|
||||
rootProbeFails = false
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() =>
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: recoveredRoot }),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
)
|
||||
expect(oldRootUnsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains SSH watches across transient probes and retries the affected repo', async () => {
|
||||
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
|
||||
const store = makeStore([repo])
|
||||
const provider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
provider.stat.mockReset().mockRejectedValue(new Error('relay unavailable'))
|
||||
provider.unwatch.mockClear()
|
||||
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'ssh:ssh-a' }]
|
||||
})
|
||||
expect(provider.unwatch).not.toHaveBeenCalled()
|
||||
provider.stat.mockResolvedValue({ type: 'directory', size: 0, mtime: 0 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() => expect(provider.stat).toHaveBeenCalledTimes(4))
|
||||
expect(provider.unwatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an old local watch until a path replacement subscribes', async () => {
|
||||
const repo = makeRepo()
|
||||
const store = makeStore([repo])
|
||||
const mainWindow = makeWindow()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT)
|
||||
const replacementRoot = absolutePath('workspace', 'replacement-worktrees')
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValueOnce(
|
||||
new Error('native watcher capacity')
|
||||
)
|
||||
|
||||
repo.worktreeBasePath = replacementRoot
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
expect(oldRootUnsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() => expect(oldRootUnsubscribe).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('keeps old SSH subscriptions until a replacement retry succeeds', async () => {
|
||||
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
|
||||
const store = makeStore([repo])
|
||||
const oldProvider = makeRemoteFilesystemProvider()
|
||||
const newProvider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
oldProvider.unwatch.mockClear()
|
||||
newProvider.watch.mockRejectedValue(new Error('watch transport unavailable'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(newProvider as never)
|
||||
providerGenerations.set('ssh-a', 1)
|
||||
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
expect(oldProvider.unwatch).not.toHaveBeenCalled()
|
||||
expect(newProvider.watch).toHaveBeenCalledTimes(2)
|
||||
|
||||
newProvider.watch.mockResolvedValue(newProvider.unwatch)
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() => expect(oldProvider.unwatch).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('refreshes retained reconnect routing for shared-root membership and window changes', async () => {
|
||||
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
|
||||
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
|
||||
const repos = [repoOne, repoTwo]
|
||||
const store = makeStore(repos)
|
||||
let firstWindowDestroyed = false
|
||||
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
|
||||
const nextWindow = makeWindow()
|
||||
const oldProvider = makeRemoteFilesystemProvider()
|
||||
const replacementProvider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
|
||||
|
||||
repos.splice(0, 1)
|
||||
repos.push(makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/a/three' }))
|
||||
replacementProvider.watch.mockRejectedValue(new Error('replacement unavailable'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(replacementProvider as never)
|
||||
providerGenerations.set('ssh-a', 1)
|
||||
firstWindowDestroyed = true
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
|
||||
oldProvider.callbacks.get('/srv/a')?.([
|
||||
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
|
||||
] as never[])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-three')
|
||||
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
|
||||
})
|
||||
|
||||
it('refreshes changed-key retention after failed replacements exhaust', async () => {
|
||||
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
|
||||
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
|
||||
const repos = [repoOne, repoTwo]
|
||||
const store = makeStore(repos)
|
||||
let firstWindowDestroyed = false
|
||||
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
|
||||
const nextWindow = makeWindow()
|
||||
const provider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
|
||||
|
||||
repos.splice(
|
||||
0,
|
||||
repos.length,
|
||||
{ ...repoTwo, path: '/srv/b/two' },
|
||||
makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/b/three' })
|
||||
)
|
||||
provider.watch.mockImplementation(async (path, callback) => {
|
||||
if (path.startsWith('/srv/b')) {
|
||||
throw new Error('replacement unavailable')
|
||||
}
|
||||
provider.callbacks.set(path, callback)
|
||||
return provider.unwatch
|
||||
})
|
||||
firstWindowDestroyed = true
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
const exhaustedAttempts = provider.watch.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(provider.watch).toHaveBeenCalledTimes(exhaustedAttempts)
|
||||
|
||||
provider.callbacks.get('/srv/a')?.([
|
||||
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
|
||||
] as never[])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
|
||||
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-three')
|
||||
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
|
||||
})
|
||||
|
||||
it('unions distinct failed replacement memberships for one retained shared watch', async () => {
|
||||
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
|
||||
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
|
||||
const repos = [repoOne, repoTwo]
|
||||
const store = makeStore(repos)
|
||||
let firstWindowDestroyed = false
|
||||
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
|
||||
const nextWindow = makeWindow()
|
||||
const provider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
|
||||
|
||||
repos[0] = { ...repoOne, path: '/srv/b/one' }
|
||||
repos[1] = { ...repoTwo, path: '/srv/c/two' }
|
||||
provider.watch.mockImplementation(async (path, callback) => {
|
||||
if (path.startsWith('/srv/b') || path.startsWith('/srv/c')) {
|
||||
throw new Error('replacement unavailable')
|
||||
}
|
||||
provider.callbacks.set(path, callback)
|
||||
return provider.unwatch
|
||||
})
|
||||
firstWindowDestroyed = true
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
|
||||
provider.callbacks.get('/srv/a')?.([
|
||||
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
|
||||
] as never[])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one')
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
|
||||
})
|
||||
|
||||
it('keeps inverse-merge failed replacements scoped to each old watch', async () => {
|
||||
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/old-a/one' })
|
||||
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/old-b/two' })
|
||||
const repos = [repoOne, repoTwo]
|
||||
const store = makeStore(repos)
|
||||
const provider = makeRemoteFilesystemProvider()
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
|
||||
repos[0] = { ...repoOne, path: '/new/one' }
|
||||
repos[1] = { ...repoTwo, path: '/new/two' }
|
||||
provider.watch.mockImplementation(async (path, callback) => {
|
||||
if (path.startsWith('/new')) {
|
||||
throw new Error('replacement unavailable')
|
||||
}
|
||||
provider.callbacks.set(path, callback)
|
||||
return provider.unwatch
|
||||
})
|
||||
const nextWindow = makeWindow()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
|
||||
provider.callbacks.get('/old-a')?.([
|
||||
{ kind: 'create', absolutePath: '/old-a/external-worktree/.git' }
|
||||
] as never[])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one')
|
||||
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-two')
|
||||
|
||||
vi.mocked(notifyWorktreesChanged).mockClear()
|
||||
provider.callbacks.get('/old-b')?.([
|
||||
{ kind: 'create', absolutePath: '/old-b/external-worktree/.git' }
|
||||
] as never[])
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
|
||||
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
|
||||
})
|
||||
|
||||
it('refreshes a same-version retry callback when the main window changes', async () => {
|
||||
const repo = makeRepo()
|
||||
const store = makeStore([repo])
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
vi.mocked(stat).mockImplementation(async (path) => {
|
||||
if (String(path).endsWith('.git')) {
|
||||
throw missing
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller)
|
||||
.mockRejectedValueOnce(new Error('watch unavailable in first window'))
|
||||
.mockRejectedValueOnce(new Error('watch unavailable in next window'))
|
||||
let firstWindowDestroyed = false
|
||||
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
|
||||
const nextWindow = makeWindow()
|
||||
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
firstWindowDestroyed = true
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3))
|
||||
})
|
||||
|
||||
it('bounds repeated subscription failures', async () => {
|
||||
const repo = makeRepo()
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
vi.mocked(stat).mockImplementation(async (path) => {
|
||||
if (String(path).endsWith('.git')) {
|
||||
throw missing
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied'))
|
||||
await syncWorktreeBaseDirectoryWatchers(makeStore([repo]) as never, makeWindow() as never)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2))
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3))
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4))
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('cancels subscription retries after target deletion and disposal', async () => {
|
||||
const repos = [makeRepo()]
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
vi.mocked(stat).mockImplementation(async (path) => {
|
||||
if (String(path).endsWith('.git')) {
|
||||
throw missing
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied'))
|
||||
const store = makeStore(repos)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
repos.splice(0, 1)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce()
|
||||
|
||||
repos.push(makeRepo({ id: 'repo-dispose' }))
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-dispose', hostId: 'local' }]
|
||||
})
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
|
||||
await disposeWorktreeBaseDirectoryWatchers()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels stale provider-generation retries', async () => {
|
||||
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
|
||||
const store = makeStore([repo])
|
||||
const failingProvider = makeRemoteFilesystemProvider()
|
||||
failingProvider.watch.mockRejectedValue(new Error('watch unavailable'))
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(failingProvider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
|
||||
const currentProvider = makeRemoteFilesystemProvider()
|
||||
providerGenerations.set('ssh-a', 1)
|
||||
vi.mocked(getSshFilesystemProvider).mockReturnValue(currentProvider as never)
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyConnectionIds: ['ssh-a']
|
||||
})
|
||||
expect(currentProvider.watch).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(currentProvider.watch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('merges an aborted first invalidation into one non-overlapping trailing pass', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const store = makeStore(repos)
|
||||
useFleetProviders()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
|
||||
const firstProbeGate = Promise.withResolvers<void>()
|
||||
let activeProbes = 0
|
||||
let maxActiveProbes = 0
|
||||
let deferFirstProbe = true
|
||||
vi.mocked(stat).mockClear()
|
||||
vi.mocked(stat).mockImplementation(async () => {
|
||||
activeProbes++
|
||||
maxActiveProbes = Math.max(maxActiveProbes, activeProbes)
|
||||
if (deferFirstProbe) {
|
||||
deferFirstProbe = false
|
||||
await firstProbeGate.promise
|
||||
}
|
||||
activeProbes--
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
|
||||
repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-v1') }
|
||||
const first = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }]
|
||||
})
|
||||
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
|
||||
repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-v2') }
|
||||
const second = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
|
||||
})
|
||||
repos[2] = { ...repos[2], path: absolutePath('workspace', 'projects', 'repo-2-v3') }
|
||||
const third = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-2', hostId: 'local' }]
|
||||
})
|
||||
firstProbeGate.resolve()
|
||||
await Promise.all([first, second, third])
|
||||
|
||||
expect(stat).toHaveBeenCalledTimes(7)
|
||||
expect(maxActiveProbes).toBe(1)
|
||||
})
|
||||
|
||||
it('carries the active invalidation into a scheduled trailing pass', async () => {
|
||||
const repos = makeRepoFleet()
|
||||
const store = makeStore(repos)
|
||||
const mainWindow = makeWindow()
|
||||
useFleetProviders()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
const firstProbeGate = Promise.withResolvers<void>()
|
||||
let deferFirstProbe = true
|
||||
vi.mocked(stat).mockClear()
|
||||
vi.mocked(stat).mockImplementation(async () => {
|
||||
if (deferFirstProbe) {
|
||||
deferFirstProbe = false
|
||||
await firstProbeGate.promise
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
|
||||
repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-active') }
|
||||
const active = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }]
|
||||
})
|
||||
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
|
||||
repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-scheduled') }
|
||||
scheduleWorktreeBaseDirectoryWatcherSync(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
|
||||
})
|
||||
firstProbeGate.resolve()
|
||||
await active
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.waitFor(() => expect(stat).toHaveBeenCalledTimes(5))
|
||||
})
|
||||
|
||||
it('cancels retry timers before waiting for a blocked synchronization to drain', async () => {
|
||||
const repo = makeRepo()
|
||||
const store = makeStore([repo])
|
||||
const mainWindow = makeWindow()
|
||||
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
vi.mocked(stat).mockImplementation(async (path) => {
|
||||
if (String(path).endsWith('.git')) {
|
||||
throw missing
|
||||
}
|
||||
return { isDirectory: () => true } as never
|
||||
})
|
||||
const blockedInstall = Promise.withResolvers<{ unsubscribe: () => Promise<void> }>()
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller)
|
||||
.mockRejectedValueOnce(new Error('initial watch unavailable'))
|
||||
.mockImplementationOnce(async () => blockedInstall.promise)
|
||||
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
const blockedSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2))
|
||||
|
||||
const disposal = disposeWorktreeBaseDirectoryWatchers()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
|
||||
blockedInstall.resolve({ unsubscribe: vi.fn(async () => {}) })
|
||||
await Promise.all([blockedSync, disposal])
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cannot reinstall a stale path after its subscription completes', async () => {
|
||||
const repo = makeRepo()
|
||||
const store = makeStore([repo])
|
||||
const mainWindow = makeWindow()
|
||||
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockClear()
|
||||
const staleRoot = absolutePath('workspace', 'stale-worktrees')
|
||||
const currentRoot = absolutePath('workspace', 'current-worktrees')
|
||||
const staleUnsubscribe = vi.fn(async () => {})
|
||||
const staleInstall = Promise.withResolvers<{ unsubscribe: () => Promise<void> }>()
|
||||
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementationOnce(
|
||||
async () => staleInstall.promise
|
||||
)
|
||||
|
||||
repo.worktreeBasePath = staleRoot
|
||||
const staleSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: staleRoot }),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Object)
|
||||
)
|
||||
)
|
||||
repo.worktreeBasePath = currentRoot
|
||||
const currentSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
|
||||
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
|
||||
})
|
||||
staleInstall.resolve({ unsubscribe: staleUnsubscribe })
|
||||
await Promise.all([staleSync, currentSync])
|
||||
|
||||
expect(staleUnsubscribe).toHaveBeenCalledOnce()
|
||||
expect(pollerRepoGetters.has(staleRoot)).toBe(false)
|
||||
expect(pollerRepoGetters.has(currentRoot)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
WorktreeBaseRepoWatchConfig,
|
||||
WorktreeBaseWatchTarget
|
||||
} from './worktree-base-directory-event-filter'
|
||||
import { buildWorktreeBaseDirectoryWatchTargets } from './worktree-base-directory-watch-target-cache'
|
||||
import type { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries'
|
||||
import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue'
|
||||
|
||||
export type WorktreeBaseDirectoryWatchReplacementResult = 'ready' | 'failed' | 'stale'
|
||||
|
||||
export async function reconcileWorktreeBaseDirectoryWatchers(
|
||||
request: PendingWorktreeBaseDirectoryWatcherSync,
|
||||
signal: AbortSignal,
|
||||
activeWatches: ReadonlyMap<string, WorktreeBaseWatchTarget>,
|
||||
retries: WorktreeBaseDirectoryWatcherRetries,
|
||||
replaceWatch: (
|
||||
target: WorktreeBaseWatchTarget,
|
||||
signal: AbortSignal
|
||||
) => Promise<WorktreeBaseDirectoryWatchReplacementResult>,
|
||||
removeWatch: (key: string) => Promise<void>,
|
||||
refreshRetainedWatch: (key: string, repos: Map<string, WorktreeBaseRepoWatchConfig>) => void
|
||||
): Promise<void> {
|
||||
const buildResult = await buildWorktreeBaseDirectoryWatchTargets(request.store, {
|
||||
fullRebuild: request.fullRebuild,
|
||||
dirtyRepoIdentities: [...request.dirtyRepoIdentities.values()],
|
||||
dirtyConnectionIds: [...request.dirtyConnectionIds],
|
||||
signal
|
||||
})
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const { targets } = buildResult
|
||||
retries.reconcileIntent(targets, buildResult.retries, request.store, request.mainWindow)
|
||||
|
||||
const failedTargets: WorktreeBaseWatchTarget[] = []
|
||||
for (const target of targets.values()) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const result = await replaceWatch(target, signal)
|
||||
retries.recordSubscriptionResult(target, result, request.store, request.mainWindow)
|
||||
if (result === 'failed') {
|
||||
failedTargets.push(target)
|
||||
}
|
||||
}
|
||||
for (const [key, watch] of activeWatches) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
const failedReplacementRepos = new Map<string, WorktreeBaseRepoWatchConfig>()
|
||||
for (const target of failedTargets) {
|
||||
if (
|
||||
target.kind === watch.kind &&
|
||||
target.connectionId === watch.connectionId &&
|
||||
[...target.repos.keys()].some((repoId) => watch.repos.has(repoId))
|
||||
) {
|
||||
for (const [repoId, config] of target.repos) {
|
||||
if (watch.repos.has(repoId)) {
|
||||
failedReplacementRepos.set(repoId, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targets.has(key)) {
|
||||
if (failedReplacementRepos.size > 0) {
|
||||
refreshRetainedWatch(key, failedReplacementRepos)
|
||||
} else {
|
||||
await removeWatch(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { Store } from '../persistence'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import {
|
||||
getWorktreeBaseRepoIdentity,
|
||||
type WorktreeBaseDirectoryWatchTargetRetry,
|
||||
type WorktreeBaseRepoIdentity
|
||||
} from './worktree-base-directory-watch-target-cache'
|
||||
import { WorktreeBaseDirectoryWatcherRetryScheduler } from './worktree-base-directory-watcher-retry-scheduler'
|
||||
|
||||
export class WorktreeBaseDirectoryWatcherRetries {
|
||||
private readonly scheduler = new WorktreeBaseDirectoryWatcherRetryScheduler()
|
||||
private readonly desiredTargetVersions = new Map<string, string>()
|
||||
|
||||
constructor(
|
||||
private readonly requestSync: (
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
identities: WorktreeBaseRepoIdentity[]
|
||||
) => void
|
||||
) {}
|
||||
|
||||
private targetVersion(target: WorktreeBaseWatchTarget): string {
|
||||
return `${target.providerGeneration ?? 0}\0${target.path}`
|
||||
}
|
||||
|
||||
reconcileIntent(
|
||||
targets: Map<string, WorktreeBaseWatchTarget>,
|
||||
retries: WorktreeBaseDirectoryWatchTargetRetry[],
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
this.desiredTargetVersions.clear()
|
||||
const validRetryKeys = new Set<string>()
|
||||
for (const target of targets.values()) {
|
||||
this.desiredTargetVersions.set(target.key, this.targetVersion(target))
|
||||
validRetryKeys.add(`subscribe:${target.key}`)
|
||||
}
|
||||
for (const retry of retries) {
|
||||
validRetryKeys.add(`resolve:${retry.identity.hostId}\0${retry.identity.repoId}`)
|
||||
}
|
||||
this.scheduler.retainKeys(validRetryKeys)
|
||||
|
||||
for (const retry of retries) {
|
||||
const retryKey = `resolve:${retry.identity.hostId}\0${retry.identity.repoId}`
|
||||
this.scheduler.schedule(retryKey, retry.version, () => {
|
||||
const repoStillExists = store.getRepos().some((repo) => {
|
||||
const repoHostId = getRepoExecutionHostId(repo)
|
||||
return repo.id === retry.identity.repoId && repoHostId === retry.identity.hostId
|
||||
})
|
||||
const providerIsCurrent =
|
||||
!retry.connectionId ||
|
||||
getSshFilesystemProviderGeneration(retry.connectionId) === retry.providerGeneration
|
||||
if (!repoStillExists || !providerIsCurrent || mainWindow.isDestroyed()) {
|
||||
this.scheduler.clear(retryKey)
|
||||
return
|
||||
}
|
||||
this.requestSync(store, mainWindow, [retry.identity])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
recordSubscriptionResult(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
result: 'ready' | 'failed' | 'stale',
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
const retryKey = `subscribe:${target.key}`
|
||||
if (result === 'ready') {
|
||||
this.scheduler.clear(retryKey)
|
||||
return
|
||||
}
|
||||
if (result !== 'failed') {
|
||||
return
|
||||
}
|
||||
const identities = store
|
||||
.getRepos()
|
||||
.filter((repo) => {
|
||||
const identity = getWorktreeBaseRepoIdentity(repo)
|
||||
return (
|
||||
target.repos.has(repo.id) &&
|
||||
(target.connectionId
|
||||
? repo.connectionId === target.connectionId
|
||||
: identity.hostId === 'local')
|
||||
)
|
||||
})
|
||||
.map(getWorktreeBaseRepoIdentity)
|
||||
const version = this.targetVersion(target)
|
||||
this.scheduler.schedule(retryKey, version, () => {
|
||||
if (this.desiredTargetVersions.get(target.key) !== version || mainWindow.isDestroyed()) {
|
||||
this.scheduler.clear(retryKey)
|
||||
return
|
||||
}
|
||||
this.requestSync(store, mainWindow, identities)
|
||||
})
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this.scheduler.activate()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.scheduler.dispose()
|
||||
this.desiredTargetVersions.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
type RetryEntry = {
|
||||
version: string
|
||||
attempts: number
|
||||
timer: NodeJS.Timeout | null
|
||||
action: () => void
|
||||
}
|
||||
|
||||
const RETRY_DELAYS_MS = [250, 1_000, 4_000] as const
|
||||
|
||||
export class WorktreeBaseDirectoryWatcherRetryScheduler {
|
||||
private readonly entries = new Map<string, RetryEntry>()
|
||||
private disposed = false
|
||||
|
||||
schedule(key: string, version: string, action: () => void): boolean {
|
||||
if (this.disposed) {
|
||||
return false
|
||||
}
|
||||
const previous = this.entries.get(key)
|
||||
if (previous?.version === version && previous.timer) {
|
||||
previous.action = action
|
||||
return true
|
||||
}
|
||||
if (previous?.version !== version) {
|
||||
clearTimeout(previous?.timer ?? undefined)
|
||||
this.entries.delete(key)
|
||||
}
|
||||
const attempts = previous?.version === version ? previous.attempts : 0
|
||||
if (attempts >= RETRY_DELAYS_MS.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const entry: RetryEntry = {
|
||||
version,
|
||||
attempts: attempts + 1,
|
||||
timer: null,
|
||||
action
|
||||
}
|
||||
entry.timer = setTimeout(() => {
|
||||
if (this.disposed || this.entries.get(key) !== entry) {
|
||||
return
|
||||
}
|
||||
entry.timer = null
|
||||
entry.action()
|
||||
}, RETRY_DELAYS_MS[attempts])
|
||||
this.entries.set(key, entry)
|
||||
return true
|
||||
}
|
||||
|
||||
clear(key: string): void {
|
||||
const entry = this.entries.get(key)
|
||||
clearTimeout(entry?.timer ?? undefined)
|
||||
this.entries.delete(key)
|
||||
}
|
||||
|
||||
retainKeys(keys: ReadonlySet<string>): void {
|
||||
for (const key of this.entries.keys()) {
|
||||
if (!keys.has(key)) {
|
||||
this.clear(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
this.disposed = false
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
for (const entry of this.entries.values()) {
|
||||
clearTimeout(entry.timer ?? undefined)
|
||||
}
|
||||
this.entries.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { WorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache'
|
||||
|
||||
export type WorktreeBaseDirectoryWatcherSyncRequest = {
|
||||
fullRebuild?: boolean
|
||||
dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[]
|
||||
dirtyConnectionIds?: readonly string[]
|
||||
}
|
||||
|
||||
export type PendingWorktreeBaseDirectoryWatcherSync = {
|
||||
store: Store
|
||||
mainWindow: BrowserWindow
|
||||
fullRebuild: boolean
|
||||
dirtyRepoIdentities: Map<string, WorktreeBaseRepoIdentity>
|
||||
dirtyConnectionIds: Set<string>
|
||||
}
|
||||
|
||||
type RunSyncPass = (
|
||||
request: PendingWorktreeBaseDirectoryWatcherSync,
|
||||
signal: AbortSignal
|
||||
) => Promise<void>
|
||||
|
||||
export class WorktreeBaseDirectoryWatcherSyncQueue {
|
||||
private scheduledTimer: NodeJS.Timeout | null = null
|
||||
private scheduledRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
|
||||
private pendingRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
|
||||
private flight: Promise<void> | null = null
|
||||
private activeAbort: AbortController | null = null
|
||||
private activeRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
|
||||
|
||||
constructor(private readonly runPass: RunSyncPass) {}
|
||||
|
||||
private mergeRequest(
|
||||
current: PendingWorktreeBaseDirectoryWatcherSync | null,
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
request: WorktreeBaseDirectoryWatcherSyncRequest | undefined
|
||||
): PendingWorktreeBaseDirectoryWatcherSync {
|
||||
const merged = current ?? {
|
||||
store,
|
||||
mainWindow,
|
||||
fullRebuild: false,
|
||||
dirtyRepoIdentities: new Map(),
|
||||
dirtyConnectionIds: new Set()
|
||||
}
|
||||
merged.store = store
|
||||
merged.mainWindow = mainWindow
|
||||
merged.fullRebuild ||= request === undefined || request.fullRebuild === true
|
||||
for (const identity of request?.dirtyRepoIdentities ?? []) {
|
||||
merged.dirtyRepoIdentities.set(`${identity.hostId}\0${identity.repoId}`, identity)
|
||||
}
|
||||
for (const connectionId of request?.dirtyConnectionIds ?? []) {
|
||||
merged.dirtyConnectionIds.add(connectionId)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
private mergeActiveRequest(
|
||||
current: PendingWorktreeBaseDirectoryWatcherSync | null
|
||||
): PendingWorktreeBaseDirectoryWatcherSync | null {
|
||||
if (!this.activeRequest) {
|
||||
return current
|
||||
}
|
||||
return this.mergeRequest(current, this.activeRequest.store, this.activeRequest.mainWindow, {
|
||||
fullRebuild: this.activeRequest.fullRebuild,
|
||||
dirtyRepoIdentities: [...this.activeRequest.dirtyRepoIdentities.values()],
|
||||
dirtyConnectionIds: [...this.activeRequest.dirtyConnectionIds]
|
||||
})
|
||||
}
|
||||
|
||||
private ensureFlight(): Promise<void> {
|
||||
if (!this.flight) {
|
||||
this.flight = (async () => {
|
||||
while (this.pendingRequest) {
|
||||
const request = this.pendingRequest
|
||||
this.pendingRequest = null
|
||||
this.activeRequest = request
|
||||
const controller = new AbortController()
|
||||
this.activeAbort = controller
|
||||
try {
|
||||
await this.runPass(request, controller.signal)
|
||||
} finally {
|
||||
if (this.activeRequest === request) {
|
||||
this.activeRequest = null
|
||||
}
|
||||
if (this.activeAbort === controller) {
|
||||
this.activeAbort = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
this.flight = null
|
||||
if (this.pendingRequest) {
|
||||
void this.ensureFlight()
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.flight
|
||||
}
|
||||
|
||||
async sync(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
request?: WorktreeBaseDirectoryWatcherSyncRequest
|
||||
): Promise<void> {
|
||||
this.pendingRequest = this.mergeActiveRequest(this.pendingRequest)
|
||||
this.pendingRequest = this.mergeRequest(this.pendingRequest, store, mainWindow, request)
|
||||
this.activeAbort?.abort(new Error('Superseded by a newer worktree watcher synchronization'))
|
||||
do {
|
||||
await this.ensureFlight()
|
||||
} while (this.pendingRequest || this.flight)
|
||||
}
|
||||
|
||||
schedule(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
request?: WorktreeBaseDirectoryWatcherSyncRequest
|
||||
): void {
|
||||
this.scheduledRequest = this.mergeActiveRequest(this.scheduledRequest)
|
||||
this.scheduledRequest = this.mergeRequest(this.scheduledRequest, store, mainWindow, request)
|
||||
this.activeAbort?.abort(new Error('Superseded by a scheduled worktree watcher synchronization'))
|
||||
clearTimeout(this.scheduledTimer ?? undefined)
|
||||
this.scheduledTimer = setTimeout(() => {
|
||||
this.scheduledTimer = null
|
||||
const nextRequest = this.scheduledRequest
|
||||
this.scheduledRequest = null
|
||||
if (!nextRequest || nextRequest.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
void this.sync(nextRequest.store, nextRequest.mainWindow, {
|
||||
fullRebuild: nextRequest.fullRebuild,
|
||||
dirtyRepoIdentities: [...nextRequest.dirtyRepoIdentities.values()],
|
||||
dirtyConnectionIds: [...nextRequest.dirtyConnectionIds]
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.pendingRequest = null
|
||||
this.scheduledRequest = null
|
||||
this.activeAbort?.abort(new Error('Worktree base directory watchers disposed'))
|
||||
if (this.scheduledTimer) {
|
||||
clearTimeout(this.scheduledTimer)
|
||||
this.scheduledTimer = null
|
||||
}
|
||||
await this.flight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { onSshFilesystemProviderGenerationChanged } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
WorktreeBaseDirectoryWatcherSyncQueue,
|
||||
type PendingWorktreeBaseDirectoryWatcherSync,
|
||||
type WorktreeBaseDirectoryWatcherSyncRequest
|
||||
} from './worktree-base-directory-watcher-sync-queue'
|
||||
|
||||
export type WorktreeBaseDirectoryWatcherSync = {
|
||||
sync: (
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
request?: WorktreeBaseDirectoryWatcherSyncRequest
|
||||
) => Promise<void>
|
||||
setContext: (store: Store, mainWindow: BrowserWindow) => void
|
||||
schedule: (
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow,
|
||||
request?: WorktreeBaseDirectoryWatcherSyncRequest
|
||||
) => void
|
||||
scheduleCurrent: (request?: WorktreeBaseDirectoryWatcherSyncRequest) => void
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
export function createWorktreeBaseDirectoryWatcherSync(
|
||||
runPass: (request: PendingWorktreeBaseDirectoryWatcherSync, signal: AbortSignal) => Promise<void>
|
||||
): WorktreeBaseDirectoryWatcherSync {
|
||||
const queue = new WorktreeBaseDirectoryWatcherSyncQueue(runPass)
|
||||
let latestContext: { mainWindow: BrowserWindow; store: Store } | null = null
|
||||
let unsubscribeFromProviderGenerationChanges: (() => void) | null = null
|
||||
|
||||
const scheduleCurrent = (request?: WorktreeBaseDirectoryWatcherSyncRequest): void => {
|
||||
if (!latestContext || latestContext.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
queue.schedule(latestContext.store, latestContext.mainWindow, request)
|
||||
}
|
||||
|
||||
return {
|
||||
sync: (store, mainWindow, request) => queue.sync(store, mainWindow, request),
|
||||
setContext: (store, mainWindow) => {
|
||||
latestContext = { store, mainWindow }
|
||||
unsubscribeFromProviderGenerationChanges ??= onSshFilesystemProviderGenerationChanged(
|
||||
(connectionId) => scheduleCurrent({ dirtyConnectionIds: [connectionId] })
|
||||
)
|
||||
// Why: lean BrowserWindow test doubles omit once; real windows clear stale chrome authority.
|
||||
if (typeof mainWindow.once === 'function') {
|
||||
mainWindow.once('closed', () => {
|
||||
if (latestContext?.mainWindow === mainWindow) {
|
||||
latestContext = null
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
schedule: (store, mainWindow, request) => queue.schedule(store, mainWindow, request),
|
||||
scheduleCurrent,
|
||||
dispose: async () => {
|
||||
latestContext = null
|
||||
unsubscribeFromProviderGenerationChanges?.()
|
||||
unsubscribeFromProviderGenerationChanges = null
|
||||
await queue.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { WorktreeBaseDirectoryWatcherSyncRequest }
|
||||
@@ -28,7 +28,9 @@ vi.mock('./worktree-remote', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn()
|
||||
getSshFilesystemProvider: vi.fn(),
|
||||
getSshFilesystemProviderGeneration: vi.fn(() => 0),
|
||||
onSshFilesystemProviderGenerationChanged: vi.fn(() => () => {})
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-head-identity-reader', () => ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import {
|
||||
createWorktreeHeadIdentityRefreshState,
|
||||
@@ -17,10 +16,15 @@ import {
|
||||
supportsWorktreeHeadIdentityRefresh
|
||||
} from './worktree-base-directory-notifications'
|
||||
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
|
||||
import { clearWorktreeBaseDirectoryWatchTargetCache } from './worktree-base-directory-watch-target-cache'
|
||||
import { clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets'
|
||||
import {
|
||||
buildWorktreeBaseDirectoryWatchTargets,
|
||||
clearWorktreeBaseDirectoryWatchTargetWarnings
|
||||
} from './worktree-base-directory-watch-targets'
|
||||
reconcileWorktreeBaseDirectoryWatchers,
|
||||
type WorktreeBaseDirectoryWatchReplacementResult
|
||||
} from './worktree-base-directory-watcher-reconciler'
|
||||
import { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries'
|
||||
import { createWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync'
|
||||
import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue'
|
||||
import {
|
||||
createWorktreePollerWindowVisibility,
|
||||
startWorktreeBaseDirectoryPoller
|
||||
@@ -49,9 +53,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
|
||||
}
|
||||
|
||||
const activeWatches = new Map<string, ActiveWatch>()
|
||||
let syncGeneration = 0
|
||||
let scheduledSync: ReturnType<typeof setTimeout> | null = null
|
||||
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
|
||||
|
||||
export function setWorktreeGitStatusRefWatch(
|
||||
args: GitStatusRefBindingRequest,
|
||||
resolveUpstreamRef: (signal: AbortSignal) => Promise<string | undefined>
|
||||
@@ -197,31 +199,57 @@ async function subscribeTarget(
|
||||
return activeWatch
|
||||
}
|
||||
|
||||
type ReplaceWatchResult = WorktreeBaseDirectoryWatchReplacementResult
|
||||
|
||||
async function disposeWatch(watch: ActiveWatch): Promise<void> {
|
||||
watch.disposed = true
|
||||
clearTimeout(watch.notifyTimer ?? undefined)
|
||||
clearPendingWorktreeBaseNotifications(watch)
|
||||
await watch.subscription.unsubscribe().catch((error) => {
|
||||
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
async function replaceWatch(
|
||||
target: WorktreeBaseWatchTarget,
|
||||
mainWindow: BrowserWindow,
|
||||
generation: number
|
||||
): Promise<void> {
|
||||
signal: AbortSignal
|
||||
): Promise<ReplaceWatchResult> {
|
||||
const previous = activeWatches.get(target.key)
|
||||
if (previous) {
|
||||
// Keep the surviving subscription's routing current while a replacement is pending.
|
||||
previous.repos = target.repos
|
||||
previous.mainWindow = mainWindow
|
||||
applyActiveGitStatusRefBinding(previous)
|
||||
return
|
||||
if (previous.providerGeneration === target.providerGeneration) {
|
||||
return 'ready'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const activeWatch = await subscribeTarget(target, mainWindow)
|
||||
if (generation !== syncGeneration) {
|
||||
activeWatch.disposed = true
|
||||
await activeWatch.subscription.unsubscribe().catch((error) => {
|
||||
console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error)
|
||||
})
|
||||
return
|
||||
if (signal.aborted) {
|
||||
await disposeWatch(activeWatch)
|
||||
return 'stale'
|
||||
}
|
||||
applyActiveGitStatusRefBinding(activeWatch)
|
||||
activeWatches.set(target.key, activeWatch)
|
||||
if (previous) {
|
||||
await disposeWatch(previous)
|
||||
if (signal.aborted) {
|
||||
if (activeWatches.get(target.key) === activeWatch) {
|
||||
activeWatches.delete(target.key)
|
||||
}
|
||||
await disposeWatch(activeWatch)
|
||||
return 'stale'
|
||||
}
|
||||
}
|
||||
return 'ready'
|
||||
} catch (error) {
|
||||
console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error)
|
||||
if (!signal.aborted) {
|
||||
console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error)
|
||||
return 'failed'
|
||||
}
|
||||
return 'stale'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,92 +259,63 @@ async function removeWatch(key: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
activeWatches.delete(key)
|
||||
watch.disposed = true
|
||||
clearTimeout(watch.notifyTimer ?? undefined)
|
||||
clearPendingWorktreeBaseNotifications(watch)
|
||||
await watch.subscription.unsubscribe().catch((error) => {
|
||||
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
|
||||
})
|
||||
await disposeWatch(watch)
|
||||
}
|
||||
|
||||
export async function syncWorktreeBaseDirectoryWatchers(
|
||||
store: Store,
|
||||
function refreshRetainedWatch(
|
||||
key: string,
|
||||
repos: WorktreeBaseWatchTarget['repos'],
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
const watch = activeWatches.get(key)
|
||||
if (!watch) {
|
||||
return
|
||||
}
|
||||
watch.repos = repos
|
||||
watch.mainWindow = mainWindow
|
||||
applyActiveGitStatusRefBinding(watch)
|
||||
}
|
||||
|
||||
async function runSyncPass(
|
||||
request: PendingWorktreeBaseDirectoryWatcherSync,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const generation = ++syncGeneration
|
||||
const targets = await buildWorktreeBaseDirectoryWatchTargets(store)
|
||||
if (generation !== syncGeneration) {
|
||||
watcherRetries.activate()
|
||||
if (request.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
for (const key of activeWatches.keys()) {
|
||||
if (generation !== syncGeneration) {
|
||||
return
|
||||
}
|
||||
if (!targets.has(key)) {
|
||||
await removeWatch(key)
|
||||
if (generation !== syncGeneration) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const target of targets.values()) {
|
||||
if (generation !== syncGeneration) {
|
||||
return
|
||||
}
|
||||
await replaceWatch(target, mainWindow, generation)
|
||||
if (generation !== syncGeneration) {
|
||||
return
|
||||
try {
|
||||
await reconcileWorktreeBaseDirectoryWatchers(
|
||||
request,
|
||||
signal,
|
||||
activeWatches,
|
||||
watcherRetries,
|
||||
(target, replacementSignal) => replaceWatch(target, request.mainWindow, replacementSignal),
|
||||
removeWatch,
|
||||
(key, repos) => refreshRetainedWatch(key, repos, request.mainWindow)
|
||||
)
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.warn('[worktree-base-watcher] failed to synchronize watch targets:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorktreeBaseDirectoryWatcherSyncContext(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
latestSyncContext = { store, mainWindow }
|
||||
// Why: older integration tests use lean BrowserWindow stubs; real windows still
|
||||
// clear this context on close so stale watcher syncs cannot target dead chrome.
|
||||
if (typeof mainWindow.once === 'function') {
|
||||
mainWindow.once('closed', () => {
|
||||
if (latestSyncContext?.mainWindow === mainWindow) {
|
||||
latestSyncContext = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const watcherSync = createWorktreeBaseDirectoryWatcherSync(runSyncPass)
|
||||
const watcherRetries = new WorktreeBaseDirectoryWatcherRetries((store, mainWindow, identities) => {
|
||||
void watcherSync.sync(store, mainWindow, { dirtyRepoIdentities: identities })
|
||||
})
|
||||
|
||||
export function scheduleWorktreeBaseDirectoryWatcherSync(
|
||||
store: Store,
|
||||
mainWindow: BrowserWindow
|
||||
): void {
|
||||
if (scheduledSync) {
|
||||
clearTimeout(scheduledSync)
|
||||
}
|
||||
scheduledSync = setTimeout(() => {
|
||||
scheduledSync = null
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
void syncWorktreeBaseDirectoryWatchers(store, mainWindow)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void {
|
||||
if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow)
|
||||
}
|
||||
export const syncWorktreeBaseDirectoryWatchers = watcherSync.sync
|
||||
export const setWorktreeBaseDirectoryWatcherSyncContext = watcherSync.setContext
|
||||
export const scheduleWorktreeBaseDirectoryWatcherSync = watcherSync.schedule
|
||||
export const scheduleCurrentWorktreeBaseDirectoryWatcherSync = watcherSync.scheduleCurrent
|
||||
|
||||
export async function disposeWorktreeBaseDirectoryWatchers(): Promise<void> {
|
||||
syncGeneration++
|
||||
latestSyncContext = null
|
||||
clearActiveGitStatusRefBinding()
|
||||
if (scheduledSync) {
|
||||
clearTimeout(scheduledSync)
|
||||
scheduledSync = null
|
||||
}
|
||||
watcherRetries.dispose()
|
||||
await watcherSync.dispose()
|
||||
await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key)))
|
||||
clearWorktreeBaseDirectoryWatchTargetCache()
|
||||
clearWorktreeBaseDirectoryWatchTargetWarnings()
|
||||
}
|
||||
|
||||
@@ -34,33 +34,49 @@ function runtimeDirname(pathValue: string): string {
|
||||
}
|
||||
return normalized.slice(0, index)
|
||||
}
|
||||
export type WorktreeCommonGitDirectoryProbe = {
|
||||
path: string | null
|
||||
transientFailure: boolean
|
||||
}
|
||||
|
||||
export async function resolveWorktreeCommonGitDirectory(
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
||||
)
|
||||
}
|
||||
|
||||
export async function probeWorktreeCommonGitDirectory(
|
||||
repo: Repo,
|
||||
access: GitDirectoryAccess = {}
|
||||
): Promise<string | null> {
|
||||
): Promise<WorktreeCommonGitDirectoryProbe> {
|
||||
const dotGitPath = resolveRuntimePath(repo.path, '.git')
|
||||
const statPath = access.stat ?? stat
|
||||
const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8'))
|
||||
try {
|
||||
const dotGitStat = await statPath(dotGitPath)
|
||||
if (isDirectoryStat(dotGitStat)) {
|
||||
return dotGitPath
|
||||
return { path: dotGitPath, transientFailure: false }
|
||||
}
|
||||
if (!isFileStat(dotGitStat)) {
|
||||
return null
|
||||
return { path: null, transientFailure: false }
|
||||
}
|
||||
const content = await readText(dotGitPath)
|
||||
const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim()
|
||||
if (!gitDir) {
|
||||
return null
|
||||
return { path: null, transientFailure: false }
|
||||
}
|
||||
const resolvedGitDir = resolveRuntimePath(repo.path, gitDir)
|
||||
return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees'
|
||||
? runtimeDirname(runtimeDirname(resolvedGitDir))
|
||||
: resolvedGitDir
|
||||
return {
|
||||
path:
|
||||
getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees'
|
||||
? runtimeDirname(runtimeDirname(resolvedGitDir))
|
||||
: resolvedGitDir,
|
||||
transientFailure: false
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error)
|
||||
return null
|
||||
return { path: null, transientFailure: !isMissingPathError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getSshFilesystemProvider,
|
||||
getSshFilesystemProviderGeneration,
|
||||
onSshFilesystemProviderGenerationChanged,
|
||||
onSshFilesystemProviderRegistered,
|
||||
registerSshFilesystemProvider,
|
||||
unregisterSshFilesystemProvider
|
||||
@@ -66,4 +68,28 @@ describe('onSshFilesystemProviderRegistered', () => {
|
||||
unregisterSshFilesystemProvider('conn-4')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('advances the provider generation across reconnect and disconnect boundaries', () => {
|
||||
const before = getSshFilesystemProviderGeneration('conn-generation')
|
||||
registerSshFilesystemProvider('conn-generation', provider)
|
||||
const registered = getSshFilesystemProviderGeneration('conn-generation')
|
||||
registerSshFilesystemProvider('conn-generation', {} as IFilesystemProvider)
|
||||
const reconnected = getSshFilesystemProviderGeneration('conn-generation')
|
||||
unregisterSshFilesystemProvider('conn-generation')
|
||||
|
||||
expect(registered).toBe(before + 1)
|
||||
expect(reconnected).toBe(registered + 1)
|
||||
expect(getSshFilesystemProviderGeneration('conn-generation')).toBe(reconnected + 1)
|
||||
})
|
||||
|
||||
it('notifies generation subscribers for both reconnect and disconnect', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = onSshFilesystemProviderGenerationChanged(listener)
|
||||
registerSshFilesystemProvider('conn-lifecycle', provider)
|
||||
unregisterSshFilesystemProvider('conn-lifecycle')
|
||||
|
||||
expect(listener).toHaveBeenNthCalledWith(1, 'conn-lifecycle')
|
||||
expect(listener).toHaveBeenNthCalledWith(2, 'conn-lifecycle')
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IFilesystemProvider } from './types'
|
||||
|
||||
const sshProviders = new Map<string, IFilesystemProvider>()
|
||||
const sshProviderGenerations = new Map<string, number>()
|
||||
|
||||
export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
|
||||
@@ -8,6 +9,27 @@ export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
|
||||
// Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old
|
||||
// transport (file watches) needs a signal to rebuild it — nothing else marks that boundary.
|
||||
const registrationListeners = new Set<(connectionId: string) => void>()
|
||||
const generationChangeListeners = new Set<(connectionId: string) => void>()
|
||||
|
||||
function notifyGenerationChanged(connectionId: string): void {
|
||||
for (const listener of generationChangeListeners) {
|
||||
try {
|
||||
listener(connectionId)
|
||||
} catch (error) {
|
||||
// Why: provider lifecycle must not fail because a cache invalidator threw.
|
||||
console.warn('[ssh-filesystem] provider generation listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function onSshFilesystemProviderGenerationChanged(
|
||||
listener: (connectionId: string) => void
|
||||
): () => void {
|
||||
generationChangeListeners.add(listener)
|
||||
return () => {
|
||||
generationChangeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function onSshFilesystemProviderRegistered(
|
||||
listener: (connectionId: string) => void
|
||||
@@ -22,7 +44,9 @@ export function registerSshFilesystemProvider(
|
||||
connectionId: string,
|
||||
provider: IFilesystemProvider
|
||||
): void {
|
||||
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
|
||||
sshProviders.set(connectionId, provider)
|
||||
notifyGenerationChanged(connectionId)
|
||||
for (const listener of registrationListeners) {
|
||||
try {
|
||||
listener(connectionId)
|
||||
@@ -34,13 +58,20 @@ export function registerSshFilesystemProvider(
|
||||
}
|
||||
|
||||
export function unregisterSshFilesystemProvider(connectionId: string): void {
|
||||
sshProviders.delete(connectionId)
|
||||
if (sshProviders.delete(connectionId)) {
|
||||
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
|
||||
notifyGenerationChanged(connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined {
|
||||
return sshProviders.get(connectionId)
|
||||
}
|
||||
|
||||
export function getSshFilesystemProviderGeneration(connectionId: string): number {
|
||||
return sshProviderGenerations.get(connectionId) ?? 0
|
||||
}
|
||||
|
||||
export function requireSshFilesystemProvider(connectionId: string): IFilesystemProvider {
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider) {
|
||||
|
||||
Reference in New Issue
Block a user