diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index e948428fbda..8039abd3748 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -341,6 +341,27 @@ describe('listWorktrees', () => { expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) }) + it('returns no worktrees when the repo path is gone', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + gitExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('spawn git ENOENT'), { + code: 'ENOENT' + }) + ) + statMock.mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + + await expect(listWorktrees('/workspace/deleted-repo')).resolves.toEqual([]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain'], { + cwd: '/workspace/deleted-repo' + }) + expect(statMock).toHaveBeenCalledWith('/workspace/deleted-repo') + expect(warnSpy).toHaveBeenCalledWith( + '[git/worktree] repo path missing; skipping worktree list: /workspace/deleted-repo' + ) + warnSpy.mockRestore() + }) + it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { gitExecFileAsyncMock.mockImplementation((args: string[]) => { if (args.join(' ') === 'worktree list --porcelain') { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 1c8ac49bf75..ff6bc69fd83 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -8,6 +8,12 @@ type SparseWorktreeCreateError = Error & { cleanupFailed?: boolean } +function getErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -106,10 +112,20 @@ export async function listWorktrees(repoPath: string): Promise ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('registerFilesystemWatcherHandlers', () => { + const handlers: HandlerMap = {} + + beforeEach(() => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + }) + + it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + getSshFilesystemProviderMock.mockReturnValue(undefined) + + await expect( + handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + ).resolves.toBeUndefined() + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledWith( + '[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for /home/me/repo on connection conn-1' + ) + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + warnSpy.mockRestore() + vi.useRealTimers() + }) + + it('binds a pending SSH worktree watch after the filesystem provider appears', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sendMock = vi.fn() + const sender = { isDestroyed: () => false, send: sendMock, once: vi.fn(), id: 1 } + const unwatchMock = vi.fn() + const watchMock = vi.fn().mockResolvedValue(unwatchMock) + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + const onEvents = watchMock.mock.calls[0][1] + onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) + expect(sendMock).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ path: '/home/me/repo/file.txt', type: 'update' }] + }) + warnSpy.mockRestore() + handlers['fs:unwatchWorktree'](null, { worktreePath: '/home/me/repo', connectionId: 'conn-1' }) + vi.useRealTimers() + }) + + it('cancels pending SSH watch retries during watcher shutdown', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const watchMock = vi.fn() + getSshFilesystemProviderMock.mockReturnValueOnce(undefined) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + await closeAllWatchers() + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(watchMock).not.toHaveBeenCalled() + warnSpy.mockRestore() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index c3c79b33997..ec40832e730 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -428,6 +428,10 @@ function unsubscribe(worktreePath: string, senderId: number): void { // ── Remote watcher state ───────────────────────────────────────────── // Key: `${connectionId}:${worktreePath}`, Value: unwatch function const remoteWatchers = new Map void>() +const loggedUnavailableRemoteWatchers = new Set() +const pendingRemoteWatcherRetries = new Map>() +const REMOTE_WATCH_RETRY_MS = 1_000 +const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 function replaceRemoteWatcher(key: string, unwatch: () => void): void { const previous = remoteWatchers.get(key) @@ -441,6 +445,75 @@ function replaceRemoteWatcher(key: string, unwatch: () => void): void { remoteWatchers.set(key, unwatch) } +async function installRemoteWatcher( + sender: WebContents, + connectionId: string, + worktreePath: string +): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider || sender.isDestroyed()) { + return false + } + + const key = `${connectionId}:${worktreePath}` + const unwatch = await provider.watch(worktreePath, (events) => { + if (!sender.isDestroyed()) { + sender.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }) + replaceRemoteWatcher(key, unwatch) + loggedUnavailableRemoteWatchers.delete(key) + + sender.once('destroyed', () => { + const unwatchFn = remoteWatchers.get(key) + if (unwatchFn) { + unwatchFn() + remoteWatchers.delete(key) + } + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + }) + return true +} + +function scheduleRemoteWatcherRetry( + sender: WebContents, + connectionId: string, + worktreePath: string, + startedAt = Date.now() +): void { + const key = `${connectionId}:${worktreePath}` + if (pendingRemoteWatcherRetries.has(key)) { + return + } + + if (Date.now() - startedAt >= REMOTE_WATCH_RETRY_TIMEOUT_MS || sender.isDestroyed()) { + pendingRemoteWatcherRetries.delete(key) + loggedUnavailableRemoteWatchers.delete(key) + return + } + + const retryTimer = setTimeout(() => { + pendingRemoteWatcherRetries.delete(key) + void installRemoteWatcher(sender, connectionId, worktreePath) + .then((installed) => { + if (!installed) { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + } + }) + .catch(() => { + scheduleRemoteWatcherRetry(sender, connectionId, worktreePath, startedAt) + }) + }, REMOTE_WATCH_RETRY_MS) + pendingRemoteWatcherRetries.set(key, retryTimer) +} + // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { @@ -448,29 +521,22 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - if (!provider) { - throw new Error(`No filesystem provider for connection "${args.connectionId}"`) - } const key = `${args.connectionId}:${args.worktreePath}` - - const unwatch = await provider.watch(args.worktreePath, (events) => { - if (!event.sender.isDestroyed()) { - event.sender.send('fs:changed', { - worktreePath: args.worktreePath, - events - } satisfies FsChangedPayload) + const installed = await installRemoteWatcher( + event.sender, + args.connectionId, + args.worktreePath + ) + if (!installed) { + if (!loggedUnavailableRemoteWatchers.has(key)) { + loggedUnavailableRemoteWatchers.add(key) + console.warn( + `[filesystem-watcher] SSH filesystem provider unavailable; retrying watch for ${args.worktreePath} on connection ${args.connectionId}` + ) } - }) - replaceRemoteWatcher(key, unwatch) - - event.sender.once('destroyed', () => { - const unwatchFn = remoteWatchers.get(key) - if (unwatchFn) { - unwatchFn() - remoteWatchers.delete(key) - } - }) + scheduleRemoteWatcherRetry(event.sender, args.connectionId, args.worktreePath) + return + } return } await subscribe(args.worktreePath, event.sender) @@ -482,6 +548,12 @@ export function registerFilesystemWatcherHandlers(): void { (_event, args: { worktreePath: string; connectionId?: string }): void => { if (args.connectionId) { const key = `${args.connectionId}:${args.worktreePath}` + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + } + loggedUnavailableRemoteWatchers.delete(key) const unwatchFn = remoteWatchers.get(key) if (unwatchFn) { unwatchFn() @@ -503,6 +575,12 @@ export async function closeAllWatchers(): Promise { } pendingTeardowns.clear() + for (const timer of pendingRemoteWatcherRetries.values()) { + clearTimeout(timer) + } + pendingRemoteWatcherRetries.clear() + loggedUnavailableRemoteWatchers.clear() + for (const [rootKey, root] of watchedRoots) { if (root.batch.timer) { clearTimeout(root.batch.timer) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 77274212631..1d82f5e505e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -55,6 +55,21 @@ function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string) return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() }) } +const loggedUnavailableSshGitProviders = new Set() +const loggedWorktreeListFailures = new Set() + +function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (keySet.has(key)) { + return + } + keySet.add(key) + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, @@ -92,18 +107,31 @@ export function registerWorktreeHandlers( } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) return [] } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) gitWorktrees = await provider.listWorktrees(repo.path) } else { gitWorktrees = await listRepoWorktrees(repo) } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return gitWorktrees.map((gw) => { const worktreeId = `${repo.id}::${gw.path}` const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) return mergeWorktree(repo.id, gw, meta, repo.displayName) }) - } catch { + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) return [] } }) @@ -122,29 +150,46 @@ export function registerWorktreeHandlers( return [] } - let gitWorktrees - if (isFolderRepo(repo)) { - gitWorktrees = [createFolderWorktree(repo)] - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Return [] so the - // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) - // preserves its cached worktree list. This avoids a console error on every - // fetchAllWorktrees cycle while the connection is being (re-)established — - // worktrees will be properly populated when the SSH `connected` event fires - // and triggers a re-fetch. - if (!provider) { - return [] + try { + let gitWorktrees + if (isFolderRepo(repo)) { + gitWorktrees = [createFolderWorktree(repo)] + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + // Why: when SSH is disconnected the provider is null. Return [] so the + // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) + // preserves its cached worktree list. This avoids a console error on every + // fetchAllWorktrees cycle while the connection is being (re-)established — + // worktrees will be properly populated when the SSH `connected` event fires + // and triggers a re-fetch. + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return [] + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + gitWorktrees = await provider.listWorktrees(repo.path) + } else { + gitWorktrees = await listRepoWorktrees(repo) } - gitWorktrees = await provider.listWorktrees(repo.path) - } else { - gitWorktrees = await listRepoWorktrees(repo) + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return gitWorktrees.map((gw) => { + const worktreeId = `${repo.id}::${gw.path}` + const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) + return mergeWorktree(repo.id, gw, meta, repo.displayName) + }) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return [] } - return gitWorktrees.map((gw) => { - const worktreeId = `${repo.id}::${gw.path}` - const meta = resolveWorktreeMetaWithDiscoveryStamp(store, worktreeId) - return mergeWorktree(repo.id, gw, meta, repo.displayName) - }) }) ipcMain.handle(