From 7a8a1ff3869ce95932706262840eb189243fec4d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:47:54 -0700 Subject: [PATCH] fix(runtime): dedupe folder-workspace child repos by path The same directory can be registered twice (imported directly and picked up by a folder scan). Both copies fanned out, so the merged status listed every file twice and a commit ran against the repo twice. Also records the bulk-path grouping's real failure semantics: resolution is all-or-nothing, execution is not. Co-authored-by: Orca --- .../runtime/folder-workspace-child-repos.test.ts | 7 +++++++ src/main/runtime/folder-workspace-child-repos.ts | 14 +++++++++++++- src/main/runtime/orca-runtime.ts | 5 ++++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/folder-workspace-child-repos.test.ts b/src/main/runtime/folder-workspace-child-repos.test.ts index 0c339c96bbe..9248e4ca423 100644 --- a/src/main/runtime/folder-workspace-child-repos.test.ts +++ b/src/main/runtime/folder-workspace-child-repos.test.ts @@ -31,6 +31,13 @@ describe('listFolderWorkspaceChildRepos', () => { it('excludes the folder repo itself even when registered as a git repo', () => { expect(listFolderWorkspaceChildRepos([repo('self', FOLDER)], FOLDER)).toEqual([]) }) + + it('keeps one entry when the same directory is registered twice', () => { + // Why: a duplicate registration would list every file twice in the merged + // status and run the commit against that repo twice. + const dupe = repo('api-again', API.path) + expect(listFolderWorkspaceChildRepos([API, dupe], FOLDER).map((e) => e.id)).toEqual(['api']) + }) }) describe('matchFolderWorkspaceChildRepo', () => { diff --git a/src/main/runtime/folder-workspace-child-repos.ts b/src/main/runtime/folder-workspace-child-repos.ts index 0079d951798..770e3b72928 100644 --- a/src/main/runtime/folder-workspace-child-repos.ts +++ b/src/main/runtime/folder-workspace-child-repos.ts @@ -2,6 +2,7 @@ import type { GitStatusEntry, GitStatusResult, Repo } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { isPathInsideOrEqual, + normalizeRuntimePathForComparison, relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' @@ -12,12 +13,23 @@ import { * must be routed to whichever child repo owns the requested path. */ export function listFolderWorkspaceChildRepos(repos: readonly Repo[], folderPath: string): Repo[] { - return repos + const matching = repos .filter( (candidate) => !isFolderRepo(candidate) && isPathInsideOrEqual(folderPath, candidate.path) ) .filter((candidate) => relativePathInsideRoot(folderPath, candidate.path) !== '') .sort((left, right) => right.path.length - left.path.length) + // Why: the same directory can be registered twice (imported once directly and + // once by a folder scan). Without this the merged status lists every file twice + // and a commit runs against the repo twice. + const byPath = new Map() + for (const repo of matching) { + const key = normalizeRuntimePathForComparison(repo.path) + if (!byPath.has(key)) { + byPath.set(key, repo) + } + } + return [...byPath.values()] } export type FolderWorkspaceChildRepoMatch = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5a8d215f7b1..6f9ba347e0a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7214,7 +7214,10 @@ export class OrcaRuntimeService { } const repos = this.requireStore().getRepos() // Why: one bulk request can span several child repos, so paths are grouped by - // owning repo and each group runs as its own git invocation. + // owning repo and each group runs as its own git invocation. Resolution is + // all-or-nothing — one unowned path rejects the batch before any git runs — + // but execution is not: the caller's loop has no rollback, so a repo failing + // midway leaves earlier repos already staged. const byRepoId = new Map() for (const relativePath of relativePaths) { const match = matchFolderWorkspaceChildRepo(repos, folderPath, relativePath)