Files
orca/src/main/git/worktree-add-object-store-error.ts
T
Neil 311325b9ea fix(worktrees): explain the object-store failure instead of leaking git's argv
What the user hits today: creating a workspace on a branch whose repository is
missing objects fails with a raw dump of git's stderr glued to the command Orca
ran — "Error invoking remote method 'worktrees:create': Error: Command failed:
git worktree add /Users/<name>/dev/worktrees/<branch> '<branch>' fatal: unable
to read tree (0413351...)". It leaks the user's home directory and full argv
into a toast (and into bug reports), and it says nothing about what to do next.
The create-time preflight does not catch it either: it peels the branch with
`^{commit}`, which reads only the commit object, so a repo whose commit object
survives while its root tree does not passes the preflight cleanly and then dies
inside the checkout.

After this change the same failure reads: "Orca could not create this workspace
because the repository object database is missing objects. Git reported: unable
to read tree (0413351...). The commit for "<branch>" is present but its root
tree object is missing, so Git could not check any files out. Run git fsck in
the repository to confirm what is missing, then re-fetch from the remote or
re-clone to restore it." No path, no argv. The sidebar row keeps the short title
and the creation panel shows the full diagnosis, which the row would truncate.

Not a preflight. Peeling `^{tree}` on every create costs a whole extra git
process (~15 ms on a 2.9 GiB repo) and still proves nothing about subtrees or
blobs, so it would slow every success for a partial guarantee. Git's stderr from
the failed command stays the authority; the `^{commit}` / `^{tree}` / promisor
probes run only after it fails, and only to add detail.

Evidence discipline, because this is exactly where the claim can outrun the
observation: the "commit is present but its root tree is missing" sentence is
emitted only when the commit peel actually succeeded, since a failed `^{tree}`
peel is equally what an unreadable *commit* produces. Probes distinguish "git
answered no" (exit 1 with empty stderr) from "we never got an answer" (any other
status, or an exit 1 git explained on stderr — mode-000 objects, dead SSH
transport, killed process), and the latter degrades to the neutral wording
rather than asserting absence. Partial-clone detection has the same three-way
verdict.

Covered on every create path:
- `worktree add` dies reading the tree directly.
- Sparse creates pass --no-checkout, so add exits 0 and the failure lands in the
  follow-up checkout; probes there target the repo, which outlives the rollback.
- SSH/remote creates diagnose client-side against the repo on the execution
  host, so no wire change and any relay version benefits.
- Diagnosis probes are bounded (10s): on a partial clone the `^{tree}` peel can
  trigger a promisor fetch, and an unreachable remote would otherwise turn a
  bounded failure into a create that never settles. The remote path passes the
  bound explicitly rather than inheriting the mux's 30s ambient default.
- Redacted messages keep the `git_failed` telemetry bucket, and unreadable-object
  creates still classify as `permission_denied`; the classifier reads the raw git
  text off the error's cause chain, which never leaves the main process.
- The renderer runs the same classifier, so a pre-fix host or older relay that
  still rethrows git verbatim gets redacted client-side.

Tests were written first and verified failing against the raw-leak behavior. The
regex set is pinned by a real-git reproduction that builds a repo, deletes its
root tree, and asserts on the actual binary's stderr rather than remembered
wording — including the sparse path, where git 2.44 words the same missing root
tree as "unable to parse commit".
2026-08-25 05:56:35 -07:00

66 lines
2.3 KiB
TypeScript

import {
classifyGitObjectStoreFailure,
formatGitObjectStoreFailureMessage,
isGitObjectStoreFailureMessage
} from '../../shared/git-object-store-failure'
import { readGitExecErrorText } from './git-exec-error-text'
import { diagnoseWorktreeObjectStore } from './worktree-object-store-diagnosis'
type GitRunner = (args: string[]) => Promise<{ stdout: string }>
export type WorktreeAddObjectStoreContext = {
/** Runs git in the repo that owns the object store; SSH passes the relay executor. */
runGit: GitRunner
/** Branch the user asked for, shown back to them. */
branch: string
/** Rev whose tree the checkout needed: the existing branch, or the resolved base. */
checkoutRef: string
}
/**
* Replace a raw `git worktree add` object-store failure with a redacted, diagnosed one.
*
* Returns `null` when the error is something else, so callers rethrow unchanged.
* Shared by the local/WSL and SSH create paths, which duplicate the same preflight
* and must not diverge on how the failure reads.
*/
export async function describeWorktreeAddObjectStoreFailure(
error: unknown,
context: WorktreeAddObjectStoreContext
): Promise<Error | null> {
const text = readGitExecErrorText(error)
// Why: a diagnosis quotes git's wording back, so re-classifying one would re-probe and
// nest it. The sparse path can hand us an error already described by `addWorktree`.
if (isGitObjectStoreFailureMessage(text)) {
return null
}
const failure = classifyGitObjectStoreFailure(text)
if (!failure) {
return null
}
// Diagnosis must never mask the failure we already proved, so probe errors are swallowed.
const diagnosis = await diagnoseWorktreeObjectStore(context.runGit, context.checkoutRef).catch(
() =>
({
commit: 'unverifiable',
rootTree: 'unverifiable',
partialClone: 'unverifiable'
}) as const
)
const described = new Error(
formatGitObjectStoreFailureMessage({
failure,
branch: context.branch,
commit: diagnosis.commit,
rootTree: diagnosis.rootTree,
partialClone: diagnosis.partialClone
})
)
// Why keep the original: main-process logs and telemetry still want the raw argv; only
// the message crossing IPC to the renderer is redacted.
described.cause = error
return described
}