fix(runtime): route folder-workspace commit and abort to one child repo instead of fanning out

Git has no cross-repo transaction. Fanning `git.commit` out across every staged
child repo could commit repos 1..k and fail on k+1, and neither {success,error}
nor {ok:true} can describe that half-done state. Three false-success paths came
out of it:

- an unresolvable or unreadable child was dropped to null and never reached the
  summarizer, so another repo committed and the RPC returned success while the
  user's staged files were never attempted
- a failed abort was logged and the method still returned {ok:true}, so the user
  saw success on a still-conflicted repo
- merged status reported the first conflicted child's operation, offering one
  abort button that silently meant whichever repo came first

Commit and abort now select exactly one child — by staged index and by live
conflict state respectively — and fail before mutating anything on 0, >1, or
any repo that could not be inspected. generateRuntimeCommitMessage routes the
same way; it previously threw selector_not_found for every folder workspace.

Also fixes deepest-first child ordering: it sorted on raw path length, so
\\wsl$\Ubuntu\work\repo\nested (30 chars) lost to its own ancestor
\\wsl.localhost\Ubuntu\work\repo (32) and the nested repo's files routed to
the ancestor. Sort on the normalized path instead.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-26 20:32:40 -07:00
co-authored by Orca
parent 7a8a1ff386
commit 7bd1f59edd
7 changed files with 492 additions and 146 deletions
+120 -91
View File
@@ -303,9 +303,13 @@ import type { GitStatusResult } from '../../shared/types'
import {
listFolderWorkspaceChildRepos,
matchFolderWorkspaceChildRepo,
mergeFolderWorkspaceGitStatus,
summarizeFolderWorkspaceFanOut
mergeFolderWorkspaceGitStatus
} from './folder-workspace-child-repos'
import {
selectFolderWorkspaceAbortTarget,
selectFolderWorkspaceCommitTarget,
type FolderWorkspaceChildCandidate
} from './folder-workspace-git-operation-routing'
import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd'
import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths'
import {
@@ -459,6 +463,7 @@ import {
commitRuntimeGitForTarget,
getRuntimeGitStatusForTarget,
RuntimeGitCommands,
type RuntimeGitTarget,
type RuntimeGitTargetPathGroup
} from './orca-runtime-git'
import {
@@ -7148,8 +7153,12 @@ export class OrcaRuntimeService {
commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = async (selector, message) =>
(await this.commitFolderWorkspaceGit(selector, message)) ??
(await this.gitCommands.commitRuntimeGit(selector, message))
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] =
this.gitCommands.generateRuntimeCommitMessage.bind(this.gitCommands)
generateRuntimeCommitMessage: RuntimeGitCommands['generateRuntimeCommitMessage'] = async (
selector,
settingsOverride
) =>
(await this.generateFolderWorkspaceCommitMessage(selector, settingsOverride)) ??
(await this.gitCommands.generateRuntimeCommitMessage(selector, settingsOverride))
discoverRuntimeCommitMessageModels: RuntimeGitCommands['discoverRuntimeCommitMessageModels'] =
this.gitCommands.discoverRuntimeCommitMessageModels.bind(this.gitCommands)
cancelRuntimeGenerateCommitMessage: RuntimeGitCommands['cancelRuntimeGenerateCommitMessage'] =
@@ -24218,20 +24227,71 @@ export class OrcaRuntimeService {
)
}
/**
* Inspect every child repo of a folder workspace and mark the ones matching
* `isSubject`. A repo that cannot be resolved or read is reported rather than
* dropped: the selection policy fails closed on it, because that unreadable repo
* could be exactly the one the user meant to act on.
*/
private async inspectFolderWorkspaceChildRepos(
folderPath: string,
isSubject: (status: GitStatusResult) => boolean
): Promise<FolderWorkspaceChildCandidate<RuntimeGitTarget>[]> {
const childRepos = listFolderWorkspaceChildRepos(this.requireStore().getRepos(), folderPath)
return Promise.all(
childRepos.map(async (repo) => {
const repoName = repo.displayName || basename(repo.path)
const target = await this.resolveChildRepoGitTarget(repo)
if (!target) {
return { repoName, error: 'no resolvable git worktree' }
}
try {
const status = await getRuntimeGitStatusForTarget(target)
return { target, repoName, selected: isSubject(status) }
} catch (error) {
return {
target,
repoName,
error: error instanceof Error ? error.message : 'status read failed'
}
}
})
)
}
/**
* The one child repo a staged-index operation belongs to. Null when the selector
* is not a folder workspace, so callers can fall through to the single-repo path.
*/
private async selectFolderWorkspaceStagedTarget(
selector: string
): Promise<{ ok: true; target: RuntimeGitTarget } | { ok: false; error: string } | null> {
const scope = await this.resolveFolderWorkspaceLaunchScope(selector)
const folderWorkspace = scope?.folderWorkspace
if (!folderWorkspace) {
return null
}
return selectFolderWorkspaceCommitTarget(
await this.inspectFolderWorkspaceChildRepos(folderWorkspace.folderPath, (status) =>
status.entries.some((entry) => entry.area === 'staged')
)
)
}
/**
* Commit for a folder-workspace selector. `git.commit` carries no path, so there
* is nothing to route on — instead commit every child repo that actually has
* something staged, which is the set the merged status just showed the user as
* staged. Child repos with an empty index are skipped rather than reporting
* "nothing to commit". Returns null when the selector is not a folder workspace.
* is nothing to route on — resolve the single child repo that has staged changes
* and commit that one. Git has no cross-repo transaction, so committing several
* cannot be undone as a unit if a later repo fails; 0 or >1 candidates is
* rejected before anything is mutated. Returns null when the selector is not a
* folder workspace.
*/
private async commitFolderWorkspaceGit(
selector: string,
message: string
): Promise<{ success: boolean; error?: string } | null> {
const scope = await this.resolveFolderWorkspaceLaunchScope(selector)
const folderWorkspace = scope?.folderWorkspace
if (!folderWorkspace) {
const selected = await this.selectFolderWorkspaceStagedTarget(selector)
if (!selected) {
return null
}
// Why: the single-repo path validates this before resolving; routing around it
@@ -24239,53 +24299,51 @@ export class OrcaRuntimeService {
if (message.trim().length === 0) {
throw new Error('Commit message is required')
}
const childRepos = listFolderWorkspaceChildRepos(
this.requireStore().getRepos(),
folderWorkspace.folderPath
)
const staged = await Promise.all(
childRepos.map(async (repo) => {
const target = await this.resolveChildRepoGitTarget(repo)
if (!target) {
return null
}
// Why: a status failure here silently drops the repo from the commit, so the
// user sees a success that skipped their staged files. Log which one.
const status = await getRuntimeGitStatusForTarget(target).catch((error: unknown) => {
console.warn('[runtime] folder-workspace commit skipped a child repo', {
repo: repo.path,
error
})
return null
})
const hasStaged = status?.entries.some((entry) => entry.area === 'staged') === true
return hasStaged ? { repo, target } : null
})
)
const targets = staged.filter((entry) => entry !== null)
if (targets.length === 0) {
// Why: matches what a single-repo commit reports for an empty index, so the
// renderer's existing failure copy stays accurate.
return { success: false, error: 'nothing to commit' }
if (!selected.ok) {
return { success: false, error: selected.error }
}
const results = await Promise.all(
targets.map(async ({ repo, target }) => {
const repoName = repo.displayName || basename(repo.path)
try {
const result = await commitRuntimeGitForTarget(target, message)
return { repoName, ...(result.success ? {} : { error: result.error ?? 'Commit failed' }) }
} catch (error) {
return { repoName, error: error instanceof Error ? error.message : 'Commit failed' }
}
})
try {
return await commitRuntimeGitForTarget(selected.target, message)
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Commit failed' }
}
}
/**
* Draft a commit message for a folder-workspace selector. Routes to the same
* child repo the commit itself would hit — otherwise the message is generated
* for one repo and committed to another. Re-enters by the child's own selector
* so SSH and WSL keep their tested single-repo path.
* Returns null when the selector is not a folder workspace.
*/
private async generateFolderWorkspaceCommitMessage(
selector: string,
settingsOverride?: Parameters<RuntimeGitCommands['generateRuntimeCommitMessage']>[1]
): Promise<Awaited<ReturnType<RuntimeGitCommands['generateRuntimeCommitMessage']>> | null> {
const selected = await this.selectFolderWorkspaceStagedTarget(selector)
if (!selected) {
return null
}
if (!selected.ok) {
return {
success: false,
error:
selected.error === 'nothing to commit'
? 'No staged changes to summarize.'
: selected.error
}
}
return this.gitCommands.generateRuntimeCommitMessage(
`id:${selected.target.worktree.id}`,
settingsOverride
)
return summarizeFolderWorkspaceFanOut(results)
}
/**
* Abort an in-progress merge/rebase for a folder-workspace selector. Like commit
* this carries no path, so it targets every child repo that is actually in that
* conflict state — aborting a repo that is not mid-operation would error.
* this carries no path, so it resolves the one child repo actually in that
* conflict state. A failed abort propagates — reporting `ok` for a repo that is
* still conflicted is how the user loses the conflict.
* Returns null when the selector is not a folder workspace.
*/
private async abortFolderWorkspaceGit(
@@ -24297,46 +24355,17 @@ export class OrcaRuntimeService {
if (!folderWorkspace) {
return null
}
const childRepos = listFolderWorkspaceChildRepos(
this.requireStore().getRepos(),
folderWorkspace.folderPath
)
const inOperation = await Promise.all(
childRepos.map(async (repo) => {
const target = await this.resolveChildRepoGitTarget(repo)
if (!target) {
return null
}
const detected = await this.gitCommands
.getRuntimeGitConflictOperation(`id:${target.worktree.id}`)
.catch((error: unknown) => {
console.warn('[runtime] folder-workspace abort could not read conflict state', {
repo: repo.path,
error
})
return 'unknown' as const
})
// Why: belt-and-braces. Git already refuses `merge --abort` on a mid-rebase
// repo (and vice versa), so this narrows what we run rather than what we
// prevent — it keeps N-1 pointless failing git invocations off the wire.
return detected === operation ? target : null
})
)
const targets = inOperation.filter((target) => target !== null)
// Why: aborting nothing is not an error — the conflict may have been resolved
// in a terminal already, and the renderer only needs the state to settle.
await Promise.all(
targets.map((target) =>
abortRuntimeGitForTarget(target, operation).catch((error: unknown) => {
// Why: one repo refusing the abort must not block the others, but a
// still-conflicted repo the user believes is clean needs a trail.
console.warn(`[runtime] folder-workspace ${operation} abort failed`, {
repo: target.worktree.path,
error
})
})
)
const selected = selectFolderWorkspaceAbortTarget(
await this.inspectFolderWorkspaceChildRepos(
folderWorkspace.folderPath,
(status) => status.conflictOperation === operation
),
operation
)
if (!selected.ok) {
throw new Error(selected.error)
}
await abortRuntimeGitForTarget(selected.target, operation)
return { ok: true }
}