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 <help@stably.ai>
This commit is contained in:
Neil
2026-07-26 19:47:54 -07:00
co-authored by Orca
parent 13874b60ee
commit 7a8a1ff386
3 changed files with 24 additions and 2 deletions
@@ -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', () => {
@@ -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<string, Repo>()
for (const repo of matching) {
const key = normalizeRuntimePathForComparison(repo.path)
if (!byPath.has(key)) {
byPath.set(key, repo)
}
}
return [...byPath.values()]
}
export type FolderWorkspaceChildRepoMatch = {
+4 -1
View File
@@ -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<string, { repo: Repo; relativePaths: string[] }>()
for (const relativePath of relativePaths) {
const match = matchFolderWorkspaceChildRepo(repos, folderPath, relativePath)