Handle stale SSH repo worktree refreshes (#1508)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-06 13:01:09 -07:00
committed by GitHub
co-authored by Orca
parent 9c24425504
commit 28caefd82c
5 changed files with 327 additions and 44 deletions
+21
View File
@@ -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') {
+17 -1
View File
@@ -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<GitWorktreeInfo[]
})
)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
// Why: a silent catch turned issue #1453's underlying
// "git: unknown switch -z" into the opaque "not found in listing" toast.
// Surface the cause so future regressions show up immediately.
console.warn('[git/worktree] listWorktrees failed:', err)
console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err)
return []
}
}
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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()
})
})
+99 -21
View File
@@ -428,6 +428,10 @@ function unsubscribe(worktreePath: string, senderId: number): void {
// ── Remote watcher state ─────────────────────────────────────────────
// Key: `${connectionId}:${worktreePath}`, Value: unwatch function
const remoteWatchers = new Map<string, () => void>()
const loggedUnavailableRemoteWatchers = new Set<string>()
const pendingRemoteWatcherRetries = new Map<string, ReturnType<typeof setTimeout>>()
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<boolean> {
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<void> => {
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<void> {
}
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)
+67 -22
View File
@@ -55,6 +55,21 @@ function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string)
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
}
const loggedUnavailableSshGitProviders = new Set<string>()
const loggedWorktreeListFailures = new Set<string>()
function warnOnce(keySet: Set<string>, 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(