fix(repos): a catalog answer sent before an add no longer drops the project that add made (#37384)

This commit is contained in:
Neil
2026-11-01 23:16:37 -08:00
committed by GitHub
parent 5c27709ca3
commit 4f0aaccc5f
3 changed files with 104 additions and 4 deletions
@@ -0,0 +1,91 @@
// A catalog answer that left before an add must not drop the project that add just made.
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import { getEmptyProjectPlaceholderRepoIds } from '../../../../shared/sidebar-list/empty-project-placeholder-repos'
import { mergeFetchedRepoCatalog, type FetchedRepoCatalog } from './repo-catalog-merge'
const callRuntimeRpc = vi.hoisted(() => vi.fn())
vi.mock('../../runtime/runtime-rpc-client', () => ({ callRuntimeRpc }))
function repo(id: string, overrides: Partial<Repo>): Repo {
return {
id,
path: `/home/me/${id}`,
displayName: id,
badgeColor: '#000',
addedAt: 1,
...overrides
}
}
function staleAnswer(
hostId: FetchedRepoCatalog['hostId'],
listed: readonly Repo[],
reposAtRequest: readonly Repo[]
): FetchedRepoCatalog {
return {
repos: listed,
projectHostSetupCompatibility: { projects: [], setups: [] },
hostId,
reposAtRequest
}
}
const hosts: [string, FetchedRepoCatalog['hostId'], Partial<Repo>][] = [
['a WSL host', 'runtime:wsl-ubuntu', { executionHostId: 'runtime:wsl-ubuntu' }],
[
'an SSH target on a paired server',
'runtime:server-1',
{ executionHostId: 'runtime:server-1', connectionId: 'ssh-box' }
],
['this computer', 'local', {}]
]
describe('a catalog answer that predates an add', () => {
afterEach(() => callRuntimeRpc.mockReset())
it.each(hosts)('keeps the project just added on %s', (_label, hostId, owner) => {
const old = repo('old', owner)
const added = repo('added', owner)
const merged = mergeFetchedRepoCatalog(staleAnswer(hostId, [old], [old]), [old, added]).repos
expect(merged.map((entry) => entry.id)).toEqual(['old', 'added'])
})
it.each(hosts)('still drops a project %s stopped listing', (_label, hostId, owner) => {
const old = repo('old', owner)
const gone = repo('gone', owner)
const merged = mergeFetchedRepoCatalog(staleAnswer(hostId, [old], [old, gone]), [
old,
gone
]).repos
expect(merged.map((entry) => entry.id)).toEqual(['old'])
})
it('a stopped WSL host still shows the added project, from store data alone', () => {
const owner: Partial<Repo> = { executionHostId: 'runtime:wsl-ubuntu' }
const old = repo('old', owner)
const added = repo('added', owner)
const connect = vi.fn()
vi.stubGlobal('window', { api: { runtimeEnvironments: { connect, call: vi.fn() } } })
try {
const repos = mergeFetchedRepoCatalog(staleAnswer('runtime:wsl-ubuntu', [old], [old]), [
old,
added
]).repos
// Its worktrees never loaded: the host stopped before they could be read.
const placeholders = getEmptyProjectPlaceholderRepoIds({
groupBy: 'repo',
repos,
worktreesByRepo: {},
visibleWorktrees: [],
filterRepoIds: []
})
expect(placeholders.has('added')).toBe(true)
// Showing the row reaches no host, so it cannot start the distro.
expect(callRuntimeRpc).not.toHaveBeenCalled()
expect(connect).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -11,13 +11,21 @@ import { mergeByIdentity } from '../catalog-identity'
export function mergeFetchedReposForHost(
previous: readonly Repo[],
fetched: readonly Repo[],
hostId: string
hostId: string,
/** The rows when the request went out; omitted, every row of the host is the answer's to drop. */
reposAtRequest?: readonly Repo[]
): readonly Repo[] {
const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched)
const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoRowIdentity))
const requested = reposAtRequest ? new Set(reposAtRequest.map(getRepoRowIdentity)) : null
const preserved = previous.filter((repo) => {
const existingHostId = getRepoExecutionHostId(repo)
return existingHostId !== hostId || fetchedIdentities.has(getRepoRowIdentity(repo))
const identity = getRepoRowIdentity(repo)
return (
getRepoExecutionHostId(repo) !== hostId ||
fetchedIdentities.has(identity) ||
// Added after the request: an answer that predates it cannot drop it.
(requested !== null && !requested.has(identity))
)
})
return reconcileFetchedRepos(
previous,
@@ -58,7 +58,8 @@ export function mergeFetchedRepoCatalog(
const repos = mergeFetchedReposForHost(
currentRepos,
withoutPendingRuntimeRepoRemovals(catalog.repos, catalog.hostId),
catalog.hostId
catalog.hostId,
catalog.reposAtRequest
)
return {
repos,