fix(worktrees): show CLI-created local worktrees in the sidebar while a remote runtime is active (#6628)
* fix(worktrees): refresh local worktrees in the sidebar while a remote runtime is active When a remote runtime is active, a local `worktrees:changed` event for an unbound repo was dropped by the renderer guard in useIpcEvents. Worktrees created outside Orca for that repo (e.g. `orca worktree create` from a CLI or automation flow) therefore stayed invisible in the sidebar until an app restart, even though their sessions were already running. The guard existed because an unbound repo's list fetch routes to the active runtime (settingsForKnownRepoOwner's unbound fall-through), so refreshing with local worktree ids could query — and purge against — the remote host. Instead of dropping the event, pin the refresh to the local host (forceLocalOwner): fetch the worktree list against the local owner and merge additively. The merge is host-scoped and the deletion-purge is skipped on this path, so it only ever adds local-host worktrees and never overwrites the active runtime's worktree state. A genuinely-removed local worktree is reclaimed by the next unguarded full refresh. * test(e2e): regression — CLI-created worktree visible while a remote runtime is active Drives the real `orca worktree create` path: the CLI RuntimeClient calls `worktree.create` over the app's socket, registering a managed worktree and firing the `worktrees:changed` IPC the renderer listens for. Stages a remote runtime as active by injecting `activeRuntimeEnvironmentId` into the renderer store, so no real remote host is needed. Fails on the prior behavior (the worktree never appears while a runtime is active) and passes with this fix. * fix(worktrees): pin local lineage refresh during runtime activity Co-authored-by: Orca <help@stably.ai> * review: trim comments to house style, normalize queue coalescing to booleans * review: sweep rename-grace expiry before early returns in worktrees:changed handler * review: document accepted workspace-space gap, drop imprecise 'additive' wording * fix(worktrees): route duplicate local repo events locally * fix(worktrees): tag local worktree events at origin, gate purge skip on runtime overlap * test: pin origin-based forceLocalOwner with a no-runtime local event assertion --------- Co-authored-by: brennanb2025 <brennankbenson@gmail.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
parent
bc57cf8787
commit
a356b9d5c2
|
|
@ -4319,9 +4319,16 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('refreshes active runtime worktrees from remote client events', async () => {
|
||||
it('routes local and runtime worktree events to their owning hosts', async () => {
|
||||
const fetchWorktrees = vi.fn()
|
||||
const fetchWorktreeLineage = vi.fn()
|
||||
// Mutable so the test can drop the runtime mid-run and prove the local flag
|
||||
// is origin-based, not a sample of runtime state.
|
||||
const mockSettings: { activeRuntimeEnvironmentId: string | null; terminalFontSize: number } = {
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
terminalFontSize: 13
|
||||
}
|
||||
let localWorktreesOnChanged: ((data: { repoId: string }) => void) | undefined
|
||||
let runtimeOnResponse: ((response: unknown) => void) | undefined
|
||||
const runtimeSubscribe = vi.fn(async (_args, callbacks) => {
|
||||
runtimeOnResponse = (callbacks as { onResponse: (response: unknown) => void }).onResponse
|
||||
|
|
@ -4384,7 +4391,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
enqueueSshCredentialRequest: vi.fn(),
|
||||
removeSshCredentialRequest: vi.fn(),
|
||||
clearTabPtyId: vi.fn(),
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1', terminalFontSize: 13 }
|
||||
settings: mockSettings
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
|
@ -4416,7 +4423,10 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
api: {
|
||||
repos: { onChanged: () => () => {} },
|
||||
worktrees: {
|
||||
onChanged: () => () => {},
|
||||
onChanged: (callback: (data: { repoId: string }) => void) => {
|
||||
localWorktreesOnChanged = callback
|
||||
return () => {}
|
||||
},
|
||||
onBaseStatus: () => () => {},
|
||||
onRemoteBranchConflict: () => () => {}
|
||||
},
|
||||
|
|
@ -4527,6 +4537,31 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
if (!localWorktreesOnChanged) {
|
||||
throw new Error('Expected local worktree event callback')
|
||||
}
|
||||
localWorktreesOnChanged({ repoId: 'repo-1' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', { forceLocalOwner: true })
|
||||
expect(fetchWorktreeLineage).toHaveBeenCalledWith({ forceLocalOwner: true })
|
||||
|
||||
fetchWorktrees.mockClear()
|
||||
fetchWorktreeLineage.mockClear()
|
||||
// With no runtime active the flag must still be true — it marks the event's
|
||||
// local origin; sampling runtime state here would regress to false.
|
||||
mockSettings.activeRuntimeEnvironmentId = null
|
||||
localWorktreesOnChanged({ repoId: 'repo-1' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', { forceLocalOwner: true })
|
||||
expect(fetchWorktreeLineage).toHaveBeenCalledWith({ forceLocalOwner: true })
|
||||
|
||||
fetchWorktrees.mockClear()
|
||||
fetchWorktreeLineage.mockClear()
|
||||
mockSettings.activeRuntimeEnvironmentId = 'env-1'
|
||||
if (!runtimeOnResponse) {
|
||||
throw new Error('Expected runtime client event callbacks')
|
||||
}
|
||||
|
|
@ -4537,8 +4572,8 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
|||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1')
|
||||
expect(fetchWorktreeLineage).toHaveBeenCalledTimes(1)
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith('repo-1', undefined)
|
||||
expect(fetchWorktreeLineage).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -848,8 +848,11 @@ export function useIpcEvents(): void {
|
|||
|
||||
const handleWorktreesChanged = async (
|
||||
repoId: string,
|
||||
renamed?: { oldWorktreeId: string; newWorktreeId: string }
|
||||
renamed?: { oldWorktreeId: string; newWorktreeId: string },
|
||||
options?: { forceLocalOwner?: boolean }
|
||||
): Promise<void> => {
|
||||
const localRefreshStartedWithRuntime =
|
||||
options?.forceLocalOwner === true && isRuntimeEnvironmentActive()
|
||||
// Why: capture active-ness before migration moves the pointer; re-key maps before the diff so a rename isn't a deletion.
|
||||
const renamedWasActive =
|
||||
renamed != null && useAppStore.getState().activeWorktreeId === renamed.oldWorktreeId
|
||||
|
|
@ -865,18 +868,40 @@ export function useIpcEvents(): void {
|
|||
const before =
|
||||
getAuthoritativeDetectedWorktreeIds(state, repoId) ??
|
||||
getVisibleWorktreeIdsForRepo(state, repoId)
|
||||
await state.fetchWorktrees(repoId)
|
||||
await useAppStore.getState().fetchWorktreeLineage()
|
||||
await state.fetchWorktrees(
|
||||
repoId,
|
||||
options?.forceLocalOwner ? { forceLocalOwner: true } : undefined
|
||||
)
|
||||
await useAppStore
|
||||
.getState()
|
||||
.fetchWorktreeLineage(options?.forceLocalOwner ? { forceLocalOwner: true } : undefined)
|
||||
// Why: an id change unmounts the active pane; re-activate so the tab reconciles, else it vanishes until re-select.
|
||||
if (renamedWasActive && renamed) {
|
||||
useAppStore.getState().setActiveWorktree(renamed.newWorktreeId)
|
||||
}
|
||||
// Sweep expired rename-grace entries before any early return, else forced-local
|
||||
// (or non-authoritative) events let the map grow for the session.
|
||||
const now = Date.now()
|
||||
for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) {
|
||||
if (expiry <= now) {
|
||||
recentlyRenamedWorktreeIdExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
// Why: the deletion diff below is repo-wide, but a forced-local scan overlapping
|
||||
// a runtime cannot prove remote absence (legacy runtime rows may lack hostId).
|
||||
// fetchWorktrees still purges removed local rows host-scoped; accepted gap: the
|
||||
// workspace-space entry survives until the next local-only rescan.
|
||||
if (
|
||||
options?.forceLocalOwner &&
|
||||
(localRefreshStartedWithRuntime || isRuntimeEnvironmentActive())
|
||||
) {
|
||||
return
|
||||
}
|
||||
const afterState = useAppStore.getState()
|
||||
const after = getAuthoritativeDetectedWorktreeIds(afterState, repoId)
|
||||
if (!after) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const removed: string[] = []
|
||||
for (const id of before) {
|
||||
if (after.has(id)) {
|
||||
|
|
@ -889,11 +914,6 @@ export function useIpcEvents(): void {
|
|||
}
|
||||
removed.push(id)
|
||||
}
|
||||
for (const [id, expiry] of recentlyRenamedWorktreeIdExpiry) {
|
||||
if (expiry <= now) {
|
||||
recentlyRenamedWorktreeIdExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
console.warn(
|
||||
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
|
||||
|
|
@ -1094,12 +1114,14 @@ export function useIpcEvents(): void {
|
|||
repoId: string
|
||||
renamed?: { oldWorktreeId: string; newWorktreeId: string }
|
||||
}) => {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
// Why: local worktree events carry local repo ids; fetching the runtime with them can purge or overwrite server state.
|
||||
return
|
||||
}
|
||||
// A folder rename changes the worktree id; handleWorktreesChanged re-keys state and shields it from the deletion diff.
|
||||
worktreeChangeRefreshQueue.enqueue(data)
|
||||
// Why: preserve this event's local origin across queue delays and runtime
|
||||
// focus changes; otherwise an unbound repo can refresh from the wrong host.
|
||||
// A folder rename changes the worktree id; handleWorktreesChanged re-keys
|
||||
// state and shields it from the deletion diff.
|
||||
worktreeChangeRefreshQueue.enqueue({
|
||||
...data,
|
||||
forceLocalOwner: true
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -1108,7 +1130,8 @@ export function useIpcEvents(): void {
|
|||
unsubs.push(
|
||||
window.api.worktrees.onHeadIdentitiesChanged((data) => {
|
||||
if (isRuntimeEnvironmentActive()) {
|
||||
// Why: local worktree events carry local repo ids (see onChanged).
|
||||
// Why: local worktree events carry local repo ids; the local-pinned list
|
||||
// refresh (onChanged) covers local rows while a runtime is active.
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ describe('createWorktreeChangeRefreshQueue', () => {
|
|||
queue.enqueue({ repoId: 'repo-1' })
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
expect(handler).toHaveBeenCalledWith('repo-1', undefined)
|
||||
expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: undefined })
|
||||
|
||||
firstRefresh.resolve()
|
||||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, { forceLocalOwner: undefined })
|
||||
})
|
||||
|
||||
it('does not overlap refreshes for the same repo', async () => {
|
||||
|
|
@ -73,8 +73,8 @@ describe('createWorktreeChangeRefreshQueue', () => {
|
|||
queue.enqueue({ repoId: 'repo-2' })
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2)
|
||||
expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, { forceLocalOwner: undefined })
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-2', undefined, { forceLocalOwner: undefined })
|
||||
|
||||
repoOneRefresh.resolve()
|
||||
await flushPromises()
|
||||
|
|
@ -100,8 +100,10 @@ describe('createWorktreeChangeRefreshQueue', () => {
|
|||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed)
|
||||
expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined })
|
||||
expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, {
|
||||
forceLocalOwner: undefined
|
||||
})
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
|
|
@ -116,8 +118,8 @@ describe('createWorktreeChangeRefreshQueue', () => {
|
|||
queue.enqueue({ repoId: 'repo-1', renamed })
|
||||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed)
|
||||
expect(handler).toHaveBeenNthCalledWith(1, 'repo-1', undefined, { forceLocalOwner: undefined })
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined })
|
||||
})
|
||||
|
||||
it('keeps a plain refresh queued after a rename', async () => {
|
||||
|
|
@ -136,8 +138,39 @@ describe('createWorktreeChangeRefreshQueue', () => {
|
|||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed)
|
||||
expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', renamed, { forceLocalOwner: undefined })
|
||||
expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, { forceLocalOwner: undefined })
|
||||
})
|
||||
|
||||
it('threads forceLocalOwner through to the handler', async () => {
|
||||
const handler = vi.fn(() => Promise.resolve())
|
||||
const queue = createWorktreeChangeRefreshQueue(handler)
|
||||
|
||||
queue.enqueue({ repoId: 'repo-1', forceLocalOwner: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('repo-1', undefined, { forceLocalOwner: true })
|
||||
})
|
||||
|
||||
it('does not coalesce a local-pinned refresh into a runtime-routed one', async () => {
|
||||
const firstRefresh = deferred()
|
||||
const handler = vi.fn().mockReturnValueOnce(firstRefresh.promise).mockResolvedValue(undefined)
|
||||
const queue = createWorktreeChangeRefreshQueue(handler)
|
||||
|
||||
// First refresh starts draining immediately; the next two queue behind it.
|
||||
// A plain refresh and a local-pinned refresh differ, so both are kept.
|
||||
queue.enqueue({ repoId: 'repo-1', forceLocalOwner: false })
|
||||
queue.enqueue({ repoId: 'repo-1', forceLocalOwner: false })
|
||||
queue.enqueue({ repoId: 'repo-1', forceLocalOwner: true })
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstRefresh.resolve()
|
||||
await flushPromises()
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(3)
|
||||
expect(handler).toHaveBeenNthCalledWith(2, 'repo-1', undefined, { forceLocalOwner: false })
|
||||
expect(handler).toHaveBeenNthCalledWith(3, 'repo-1', undefined, { forceLocalOwner: true })
|
||||
})
|
||||
|
||||
it('drops queued trailing refreshes after disposal', async () => {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,20 @@ type WorktreeRename = {
|
|||
type WorktreeChangeEvent = {
|
||||
repoId: string
|
||||
renamed?: WorktreeRename
|
||||
// Why: set on local worktrees:changed while a remote runtime is active, so the
|
||||
// refresh pins to the local host instead of dropping the event (see useIpcEvents).
|
||||
forceLocalOwner?: boolean
|
||||
}
|
||||
|
||||
type WorktreeChangeRefreshHandler = (repoId: string, renamed?: WorktreeRename) => Promise<void>
|
||||
type WorktreeChangeRefreshHandler = (
|
||||
repoId: string,
|
||||
renamed?: WorktreeRename,
|
||||
options?: { forceLocalOwner?: boolean }
|
||||
) => Promise<void>
|
||||
|
||||
type QueuedWorktreeChange = {
|
||||
renamed?: WorktreeRename
|
||||
forceLocalOwner?: boolean
|
||||
}
|
||||
|
||||
type RepoRefreshState = {
|
||||
|
|
@ -36,7 +44,7 @@ export function createWorktreeChangeRefreshQueue(
|
|||
while (!disposed && state.queue.length > 0) {
|
||||
const next = state.queue.shift()
|
||||
try {
|
||||
await handler(repoId, next?.renamed)
|
||||
await handler(repoId, next?.renamed, { forceLocalOwner: next?.forceLocalOwner })
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh changed worktrees:', error)
|
||||
}
|
||||
|
|
@ -68,13 +76,19 @@ export function createWorktreeChangeRefreshQueue(
|
|||
}
|
||||
|
||||
if (event.renamed) {
|
||||
state.queue.push({ renamed: event.renamed })
|
||||
state.queue.push({ renamed: event.renamed, forceLocalOwner: event.forceLocalOwner })
|
||||
} else {
|
||||
const lastQueued = state.queue.at(-1)
|
||||
// Why: Windows/OneDrive can emit a burst for one checkout change. Keep a
|
||||
// trailing refresh, but do not fan out adjacent identical repo scans.
|
||||
if (!lastQueued || lastQueued.renamed !== undefined) {
|
||||
state.queue.push({})
|
||||
// A differing forceLocalOwner is not identical — keep it as its own scan
|
||||
// so a local-pinned refresh is never coalesced into a runtime-routed one.
|
||||
if (
|
||||
!lastQueued ||
|
||||
lastQueued.renamed !== undefined ||
|
||||
Boolean(lastQueued.forceLocalOwner) !== Boolean(event.forceLocalOwner)
|
||||
) {
|
||||
state.queue.push({ forceLocalOwner: event.forceLocalOwner })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,9 +117,12 @@ export type WorktreeSlice = {
|
|||
*/
|
||||
hasHydratedWorktreePurge: boolean
|
||||
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
|
||||
fetchWorktrees: (repoId: string, options?: { requireAuthoritative?: boolean }) => Promise<boolean>
|
||||
fetchWorktrees: (
|
||||
repoId: string,
|
||||
options?: { requireAuthoritative?: boolean; forceLocalOwner?: boolean }
|
||||
) => Promise<boolean>
|
||||
fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise<void>
|
||||
fetchWorktreeLineage: () => Promise<void>
|
||||
fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise<void>
|
||||
updateWorktreeLineage: (
|
||||
worktreeId: string,
|
||||
args: { parentWorktreeId?: string; noParent?: boolean }
|
||||
|
|
|
|||
|
|
@ -1222,6 +1222,82 @@ describe('fetchWorktrees', () => {
|
|||
expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins the list fetch to the local host when forceLocalOwner is set', async () => {
|
||||
// Regression: a local `worktrees:changed` event for an unbound
|
||||
// repo while a remote runtime is active must refresh against the local
|
||||
// host, not the runtime — otherwise CLI-created local worktrees stay
|
||||
// invisible in the sidebar until an app restart.
|
||||
const store = createTestStore()
|
||||
const local = makeWorktree({
|
||||
id: 'repo1::/local/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/local/wt1',
|
||||
branch: 'refs/heads/local'
|
||||
})
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [local]))
|
||||
|
||||
await store.getState().fetchWorktrees('repo1', { forceLocalOwner: true })
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1).toEqual([local])
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins a duplicate repo id to its local owner without replacing runtime worktrees', async () => {
|
||||
const store = createTestStore()
|
||||
const local = makeWorktree({
|
||||
id: 'same-repo::/local/wt',
|
||||
repoId: 'same-repo',
|
||||
path: '/local/wt',
|
||||
hostId: 'local'
|
||||
})
|
||||
const remote = makeWorktree({
|
||||
id: 'same-repo::/remote/wt',
|
||||
repoId: 'same-repo',
|
||||
path: '/remote/wt',
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
repos: [
|
||||
{
|
||||
id: 'same-repo',
|
||||
path: '/repos/local',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{
|
||||
id: 'same-repo',
|
||||
path: '/repos/remote',
|
||||
displayName: 'remote',
|
||||
badgeColor: '#111',
|
||||
addedAt: 1,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'same-repo': [remote] },
|
||||
detectedWorktreesByRepo: {
|
||||
'same-repo': makeDetectedResult('same-repo', [remote])
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('same-repo', [local]))
|
||||
|
||||
await store.getState().fetchWorktrees('same-repo', { forceLocalOwner: true })
|
||||
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local])
|
||||
expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: remote.id, hostId: 'runtime:env-1' }),
|
||||
expect.objectContaining({ id: local.id, hostId: 'local' })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches SSH repo worktrees through local IPC even when a runtime is focused', async () => {
|
||||
const store = createTestStore()
|
||||
const sshWorktree = makeWorktree({
|
||||
|
|
@ -1247,7 +1323,7 @@ describe('fetchWorktrees', () => {
|
|||
makeDetectedResult('repo-ssh', [sshWorktree], { source: 'git' })
|
||||
)
|
||||
|
||||
await store.getState().fetchWorktrees('repo-ssh')
|
||||
await store.getState().fetchWorktrees('repo-ssh', { forceLocalOwner: true })
|
||||
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
|
|
@ -1286,11 +1362,12 @@ describe('fetchWorktrees', () => {
|
|||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
await store.getState().fetchWorktrees('repo-remote')
|
||||
await store.getState().fetchWorktrees('repo-remote', { forceLocalOwner: true })
|
||||
|
||||
expect(store.getState().worktreesByRepo['repo-remote']).toEqual([
|
||||
{ ...remote, hostId: 'runtime:env-1' }
|
||||
])
|
||||
expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stamps runtime worktrees with the owning project host setup', async () => {
|
||||
|
|
@ -1794,6 +1871,22 @@ describe('worktree lineage state', () => {
|
|||
expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage })
|
||||
})
|
||||
|
||||
it('pins lineage refresh to the local host when forceLocalOwner is set', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
worktreesByRepo: {}
|
||||
} as Partial<AppState>)
|
||||
mockApi.worktrees.listLineage.mockResolvedValue({ [lineage.worktreeId]: lineage })
|
||||
|
||||
await store.getState().fetchWorktreeLineage({ forceLocalOwner: true })
|
||||
|
||||
expect(mockApi.worktrees.listLineage).toHaveBeenCalledTimes(1)
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage })
|
||||
})
|
||||
|
||||
it('updates lineage through the active remote runtime environment', async () => {
|
||||
const store = createTestStore()
|
||||
const lineage = makeLineage()
|
||||
|
|
|
|||
|
|
@ -2330,10 +2330,22 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
try {
|
||||
const ownerState = get()
|
||||
const requestStartedWorktrees = ownerState.worktreesByRepo[repoId]
|
||||
const hostId = repoHostId(ownerState, repoId)
|
||||
const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId)
|
||||
const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId)
|
||||
const hasLocalOwner = repoOwners.some(
|
||||
(repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Why: a local event may share its repo id with the focused runtime; prefer
|
||||
// the local owner without redirecting runtime/SSH-only repos.
|
||||
const useLocalOwner =
|
||||
options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0)
|
||||
const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId)
|
||||
const ownerWasMissingAtStart = repoOwners.length === 0
|
||||
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
|
||||
const settings = settingsForRepoOwner(ownerState, repoId, hostId)
|
||||
const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId)
|
||||
const settings =
|
||||
useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId
|
||||
? { ...ownerSettings, activeRuntimeEnvironmentId: null }
|
||||
: ownerSettings
|
||||
const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
|
||||
executionHostId: hostId,
|
||||
requireAuthoritative: options?.requireAuthoritative
|
||||
|
|
@ -2693,10 +2705,17 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
set({ hasHydratedWorktreePurge: true })
|
||||
},
|
||||
|
||||
fetchWorktreeLineage: async () => {
|
||||
fetchWorktreeLineage: async (options) => {
|
||||
try {
|
||||
// Why: lineage is a focused-host refresh; host-merge so other hosts' fetched lineage is preserved.
|
||||
await refreshWorktreeLineageForSettings(get().settings, set, {
|
||||
const ownerSettings = get().settings
|
||||
// Why: local worktree-change events while a runtime is focused are paired
|
||||
// with a forced-local list refresh; lineage must follow the same owner.
|
||||
const settings =
|
||||
options?.forceLocalOwner && ownerSettings?.activeRuntimeEnvironmentId
|
||||
? { ...ownerSettings, activeRuntimeEnvironmentId: null }
|
||||
: ownerSettings
|
||||
await refreshWorktreeLineageForSettings(settings, set, {
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Regression: a worktree created via the CLI (`orca worktree
|
||||
* create`) must appear in the sidebar even while a remote runtime is active.
|
||||
*
|
||||
* The faithful trigger is the real CLI path — the RuntimeClient connects to the
|
||||
* running app's socket and calls `worktree.create`, which registers a managed
|
||||
* worktree and fires the `worktrees:changed` IPC the renderer listens for.
|
||||
* Before the fix, the renderer dropped that IPC whenever a remote runtime was
|
||||
* active (an unbound repo's list fetch would route to the runtime), so the
|
||||
* worktree never appeared until an app restart. The "remote runtime active"
|
||||
* condition is injected into the renderer store, so no real remote host is
|
||||
* needed.
|
||||
*/
|
||||
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForSessionReady, waitForActiveWorktree } from './helpers/store'
|
||||
import { RuntimeClient } from '../../src/cli/runtime-client'
|
||||
|
||||
test.describe('worktree visibility with a remote runtime active', () => {
|
||||
test('a CLI-created worktree appears in the sidebar while a remote runtime is active', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
|
||||
const repoId = await orcaPage.evaluate(() => {
|
||||
const repos = window.__store?.getState().repos ?? []
|
||||
// This case reproduces only for a local-host repo — one whose execution
|
||||
// host resolves to local (executionHostId unset or 'local') and which has
|
||||
// no connection binding. That is the repo whose list fetch an active
|
||||
// runtime would otherwise route away from local. Select it explicitly so
|
||||
// a future fixture change can't silently drop coverage.
|
||||
const target = repos.find(
|
||||
(repo) => (repo.executionHostId ?? 'local') === 'local' && !repo.connectionId
|
||||
)
|
||||
if (!target) {
|
||||
throw new Error('expected a seeded local-host repo')
|
||||
}
|
||||
return target.id
|
||||
})
|
||||
|
||||
// The CLI talks to the running app over the socket recorded in its userData
|
||||
// dir — exactly what `orca worktree create` does from a terminal.
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const client = new RuntimeClient(userDataDir, 30_000, null, null)
|
||||
const createViaCli = async (name: string): Promise<string> => {
|
||||
const response = await client.call<{ worktree: { id: string } }>('worktree.create', {
|
||||
repo: `id:${repoId}`,
|
||||
name,
|
||||
noParent: true,
|
||||
activate: false
|
||||
})
|
||||
return response.result.worktree.id
|
||||
}
|
||||
const worktreeRow = (worktreeId: string) =>
|
||||
orcaPage.locator(`[data-worktree-id=${JSON.stringify(worktreeId)}]`).first()
|
||||
|
||||
// Guard: with no runtime active, a CLI-created worktree appears. This proves
|
||||
// the create+notify path works, so the assertion below isolates the bug
|
||||
// rather than masking a broken harness as a fixed regression.
|
||||
const controlId = await createViaCli(`wt-control-${Date.now()}`)
|
||||
await expect(worktreeRow(controlId)).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Stage a remote runtime as active — the condition that triggered the drop.
|
||||
await orcaPage.evaluate(() => {
|
||||
window.__store?.setState((current) => ({
|
||||
settings: { ...current.settings, activeRuntimeEnvironmentId: 'e2e-fake-runtime' }
|
||||
}))
|
||||
})
|
||||
|
||||
// The fix: a CLI-created worktree must still appear, with no app restart.
|
||||
const targetId = await createViaCli(`wt-runtime-active-${Date.now()}`)
|
||||
await expect(
|
||||
worktreeRow(targetId),
|
||||
'a CLI-created worktree must appear even while a remote runtime is active'
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue