mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
Handle stale SSH repo worktree refreshes (#1508)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user