mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 00:02:54 +00:00
Include disconnected SSH workspaces
This commit is contained in:
@@ -153,6 +153,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSparsePresets: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn()
|
||||
}
|
||||
@@ -202,6 +203,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
store.getSparsePresets,
|
||||
store.getSettings,
|
||||
store.getWorktreeMeta,
|
||||
store.getAllWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
store.removeWorktreeMeta,
|
||||
killAllProcessesForWorktreeMock,
|
||||
@@ -241,6 +243,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
workspaceDir: '/workspace'
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.getAllWorktreeMeta.mockReturnValue({})
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
getGitUsernameMock.mockReturnValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
@@ -327,6 +330,22 @@ describe('registerWorktreeHandlers', () => {
|
||||
])
|
||||
}
|
||||
|
||||
function makeWorktreeMeta(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
|
||||
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
|
||||
// rather than failing and forcing the user back to the name picker.
|
||||
@@ -746,6 +765,285 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns reconstructed rows when an SSH provider is unavailable', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({
|
||||
displayName: 'Feature workspace',
|
||||
comment: 'persisted comment',
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
linkedLinearIssue: 'LIN-123',
|
||||
isArchived: true,
|
||||
isUnread: true,
|
||||
isPinned: true,
|
||||
sortOrder: 7,
|
||||
lastActivityAt: 42,
|
||||
workspaceStatus: 'blocked',
|
||||
diffComments: [
|
||||
{
|
||||
id: 'comment-1',
|
||||
worktreeId: 'repo-ssh::/remote/feature-wt',
|
||||
filePath: 'src/app.ts',
|
||||
lineNumber: 10,
|
||||
body: 'check this',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1'
|
||||
})
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
repoId: 'repo-ssh',
|
||||
path: '/remote/feature-wt',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
isSparse: true,
|
||||
displayName: 'Feature workspace',
|
||||
comment: 'persisted comment',
|
||||
linkedIssue: 123,
|
||||
linkedPR: 456,
|
||||
linkedLinearIssue: 'LIN-123',
|
||||
isArchived: true,
|
||||
isUnread: true,
|
||||
isPinned: true,
|
||||
sortOrder: 7,
|
||||
lastActivityAt: 42,
|
||||
workspaceStatus: 'blocked',
|
||||
sparseDirectories: ['packages/web'],
|
||||
sparseBaseRef: 'origin/main',
|
||||
sparsePresetId: 'preset-1',
|
||||
diffComments: [
|
||||
expect.objectContaining({
|
||||
id: 'comment-1',
|
||||
filePath: 'src/app.ts'
|
||||
})
|
||||
]
|
||||
})
|
||||
])
|
||||
expect(store.getWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to reconstructed SSH rows when provider listing throws', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockRejectedValue(new Error('connection lost'))
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({
|
||||
displayName: 'Feature workspace',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo')
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
displayName: 'Feature workspace',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local listing failure behavior as an empty list', async () => {
|
||||
listWorktreesMock.mockRejectedValue(new Error('filesystem denied'))
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-1::/workspace/feature-wt': makeWorktreeMeta({
|
||||
displayName: 'Should not appear'
|
||||
})
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(listed).toEqual([])
|
||||
expect(store.getAllWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores malformed metadata keys during SSH fallback', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'not-a-worktree-id': makeWorktreeMeta({ displayName: 'Bad row' }),
|
||||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Good row' })
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
displayName: 'Good row'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('does not use the repo display name for sparse fallback rows with empty branches', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::/remote/custom-name': makeWorktreeMeta({
|
||||
sparseDirectories: ['packages/web']
|
||||
})
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||||
displayName: string
|
||||
isSparse?: boolean
|
||||
sparseDirectories?: string[]
|
||||
}[]
|
||||
|
||||
expect(listed[0]).toMatchObject({
|
||||
displayName: 'custom-name',
|
||||
isSparse: true,
|
||||
sparseDirectories: ['packages/web']
|
||||
})
|
||||
})
|
||||
|
||||
it('uses path equivalence to mark the reconstructed SSH main worktree', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: 'C:\\Remote\\Repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::c:/remote/repo': makeWorktreeMeta()
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as {
|
||||
isMainWorktree: boolean
|
||||
}[]
|
||||
|
||||
expect(listed[0].isMainWorktree).toBe(true)
|
||||
})
|
||||
|
||||
it('includes SSH fallback rows in listAll alongside healthy local rows', async () => {
|
||||
const sshRepo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'SSH Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const localRepo = {
|
||||
id: 'repo-local',
|
||||
path: '/workspace/local',
|
||||
displayName: 'Local Repo',
|
||||
badgeColor: '#111',
|
||||
addedAt: 0
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepo, localRepo])
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Remote cached' })
|
||||
})
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/local',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
const listed = await handlers['worktrees:listAll'](null, undefined)
|
||||
|
||||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(listed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'repo-ssh::/remote/feature-wt',
|
||||
displayName: 'Remote cached'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'repo-local::/workspace/local',
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('snapshots SSH fallback metadata once for listAll', async () => {
|
||||
const sshRepoA = {
|
||||
id: 'repo-ssh-a',
|
||||
path: '/remote/a',
|
||||
displayName: 'SSH A',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const sshRepoB = {
|
||||
id: 'repo-ssh-b',
|
||||
path: '/remote/b',
|
||||
displayName: 'SSH B',
|
||||
badgeColor: '#111',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-2'
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepoA, sshRepoB])
|
||||
store.getAllWorktreeMeta.mockReturnValue({
|
||||
'repo-ssh-a::/remote/a/one': makeWorktreeMeta({ displayName: 'One' }),
|
||||
'repo-ssh-b::/remote/b/two': makeWorktreeMeta({ displayName: 'Two' })
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:listAll'](null, undefined)
|
||||
|
||||
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }),
|
||||
expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' })
|
||||
])
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
|
||||
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
|
||||
// (e.g. a folder repo just added, or a pre-existing worktree in a
|
||||
|
||||
+94
-10
@@ -34,6 +34,7 @@ import {
|
||||
import {
|
||||
mergeWorktree,
|
||||
parseWorktreeId,
|
||||
areWorktreePathsEqual,
|
||||
formatWorktreeRemovalError,
|
||||
isOrphanedWorktreeError
|
||||
} from './worktree-logic'
|
||||
@@ -72,6 +73,7 @@ function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string)
|
||||
|
||||
const loggedUnavailableSshGitProviders = new Set<string>()
|
||||
const loggedWorktreeListFailures = new Set<string>()
|
||||
const loggedMalformedWorktreeMetaKeys = new Set<string>()
|
||||
|
||||
function warnOnce(keySet: Set<string>, key: string, message: string, error?: unknown): void {
|
||||
if (keySet.has(key)) {
|
||||
@@ -102,6 +104,68 @@ function rememberLocalWorktreeRoots(
|
||||
])
|
||||
}
|
||||
|
||||
type SshWorktreeMetaCandidate = {
|
||||
path: string
|
||||
meta: WorktreeMeta
|
||||
}
|
||||
|
||||
type SshWorktreeMetaIndex = Map<string, SshWorktreeMetaCandidate[]>
|
||||
|
||||
function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorktreeMetaIndex {
|
||||
const index: SshWorktreeMetaIndex = new Map()
|
||||
for (const [worktreeId, meta] of entries) {
|
||||
let parsed: { repoId: string; worktreePath: string }
|
||||
try {
|
||||
parsed = parseWorktreeId(worktreeId)
|
||||
} catch (err) {
|
||||
warnOnce(
|
||||
loggedMalformedWorktreeMetaKeys,
|
||||
worktreeId,
|
||||
`[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`,
|
||||
err
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates = index.get(parsed.repoId) ?? []
|
||||
candidates.push({ path: parsed.worktreePath, meta })
|
||||
index.set(parsed.repoId, candidates)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: areWorktreePathsEqual(path, repo.path),
|
||||
...(meta.sparseDirectories !== undefined ||
|
||||
meta.sparseBaseRef !== undefined ||
|
||||
meta.sparsePresetId !== undefined
|
||||
? { isSparse: true }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function listDisconnectedSshWorktrees(
|
||||
repo: Repo,
|
||||
metaIndex: SshWorktreeMetaIndex
|
||||
): ReturnType<typeof mergeWorktree>[] {
|
||||
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
|
||||
for (const candidate of metaIndex.get(repo.id) ?? []) {
|
||||
const worktree = mergeWorktree(
|
||||
repo.id,
|
||||
synthesizeSshGitWorktree(repo, candidate.path, candidate.meta),
|
||||
candidate.meta
|
||||
)
|
||||
byWorktreeId.delete(worktree.id)
|
||||
byWorktreeId.set(worktree.id, worktree)
|
||||
}
|
||||
return [...byWorktreeId.values()]
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
@@ -123,6 +187,9 @@ export function registerWorktreeHandlers(
|
||||
|
||||
ipcMain.handle('worktrees:listAll', async () => {
|
||||
const repos = store.getRepos()
|
||||
const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId)
|
||||
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
|
||||
: new Map()
|
||||
|
||||
// Why: repos are listed in parallel so total time = slowest repo, not
|
||||
// the sum of all repos. Each listRepoWorktrees spawns `git worktree list`.
|
||||
@@ -140,10 +207,20 @@ export function registerWorktreeHandlers(
|
||||
`${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 []
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
try {
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} catch (err) {
|
||||
warnOnce(
|
||||
loggedWorktreeListFailures,
|
||||
`${repo.id}:${repo.path}`,
|
||||
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
@@ -181,6 +258,9 @@ export function registerWorktreeHandlers(
|
||||
if (!repo) {
|
||||
return []
|
||||
}
|
||||
const sshWorktreeMetaIndex = repo.connectionId
|
||||
? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta()))
|
||||
: new Map()
|
||||
|
||||
try {
|
||||
let gitWorktrees
|
||||
@@ -188,22 +268,26 @@ export function registerWorktreeHandlers(
|
||||
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 []
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`)
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
try {
|
||||
gitWorktrees = await provider.listWorktrees(repo.path)
|
||||
} catch (err) {
|
||||
warnOnce(
|
||||
loggedWorktreeListFailures,
|
||||
`${repo.id}:${repo.path}`,
|
||||
`[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`,
|
||||
err
|
||||
)
|
||||
return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex)
|
||||
}
|
||||
} else {
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user