Skip to content

fix(vcs): surface git's stderr on GitCommandError - #5620

Closed
IAmJSD wants to merge 7 commits into
pingdotgg:mainfrom
Infrawrench:surface-git-stderr
Closed

fix(vcs): surface git's stderr on GitCommandError#5620
IAmJSD wants to merge 7 commits into
pingdotgg:mainfrom
Infrawrench:surface-git-stderr

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Aug 7, 2026

Copy link
Copy Markdown

Problem

When a git command exits non-zero, GitCommandError records stdoutLength and stderrLength — how much git wrote, never what it wrote. The text is captured by the process runner and then dropped.

That leaves the reason nowhere to be found:

  • not in the error (only the lengths),
  • not in the RPC response the client receives,
  • and not in the server log — SourceControlRepositoryService returns clone failures to the caller rather than logging them, so boot-service.log stays empty for the entire failure.

The result is that an ordinary, self-explanatory git failure becomes opaque.

What it looks like today

Cloning onto a headless server whose host has no key registered with the remote. The RPC response, in full:

{"_tag":"SourceControlRepositoryError","provider":"unknown","operation":"cloneRepository",
 "detail":"The source control operation could not be completed.",
 "cause":{"name":"GitCommandError",
          "message":"Git command failed in SourceControlRepositoryService.cloneRepository (/root): Git command exited with a non-zero status."}}

Git had already said exactly what was wrong. Recovering it required patching dist/bin.mjs on the server to console.error the discarded text:

[infrawrench-patch] git failed: {"cwd":"/root",
 "args":["clone","git@github.com:owner/repo.git","projects"],"exitCode":128,
 "stderr":"Cloning into 'projects'...\ngit@github.com: Permission denied (publickey).\r\nfatal: Could not read from remote repository.\n"}

One line, and the problem is obvious. Getting to it took an afternoon.

Change

Add an optional stderrTail to GitCommandError and populate it at both non-zero-exit sites in GitVcsDriverCore, then include it in the error's message so it reaches anywhere the error is already rendered.

Two details that seemed worth getting right rather than dumping the buffer:

  • Truncated from the end, not the start. Git puts the reason on its last lines, behind a potentially long transfer log — keeping the head would reliably keep the useless half.
  • Credential-bearing URLs redacted (//user:token@host//user:***@host). Git echoes back the remote it was handed, which may embed a token, and this value now travels into logs and RPC responses.

The 2000-character cap is a judgement call; happy to change it, or to gate the whole field behind a debug flag if you'd rather it not be on by default.

Notes

  • stderrTail is Schema.optional, so existing clients and any serialized errors are unaffected.
  • I have not run the full suite — the monorepo needs a vp/pnpm setup I did not want to guess at. The change is confined to one new helper plus two field additions, and I have parse-checked both files. Happy to fix whatever CI says.
  • Related, but deliberately not in this PR: SourceControlRepositoryService never logs clone failures, and the web client renders a generic string in place of the detail the server sends. Either would be a good follow-up; this one is the smallest change that makes the failure knowable at all.

Note

Medium Risk
Changes how git failures are classified and what clients see on RPC errors (auth/remote semantics), though the design intentionally avoids leaking stderr or credentials.

Overview
Non-zero git exits in GitVcsDriverCore are now classified from stderr (authentication, not-found, or command-failed) and surfaced on GitCommandError via an optional failureKind, with detail replaced by fixed, non-secret copy instead of a generic exit message or raw git output.

Classification runs only when failures are turned into errors (not when allowNonZeroExit returns raw output). Those paths set LC_ALL=C so English heuristics stay reliable; allowNonZeroExit keeps the caller’s locale.

Failed commands log bounded debug metadata (kinds and stdout/stderr lengths), not command output. Tests add a stubbed failing-git layer and cover auth vs local “permission denied”, not-found vs “access rights”, locale behavior, and that tokens never appear in detail, message, or cause.

Reviewed by Cursor Bugbot for commit dc88184. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Surface git stderr in GitCommandError with failure classification

  • Adds classifyGitFailure in GitVcsDriverCore.ts which inspects lowercased stderr to categorize failures as 'authentication', 'not-found', or 'command-failed'.
  • Populates a new optional failureKind field on GitCommandError (in git.ts) and sets a stable, classification-derived detail string instead of a generic non-zero exit message.
  • Forces LC_ALL=C when git output will be classified to ensure consistent stderr text for heuristic matching; preserves caller locale when allowNonZeroExit is true.
  • Emits a structured debug log for failed commands (operation, exit code, stderr/stdout lengths, failureKind) without logging raw output to avoid leaking secrets.
  • Behavioral Change: GitCommandError.detail now reflects the failure class (e.g. credential advice) rather than a raw non-zero exit message when classification applies.

Macroscope summarized dc88184.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d29a81b9-43e8-424c-bbdc-ba6b35fa4b4c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:S 10-29 changed lines (additions + deletions). labels Aug 7, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effect service conventions: the new stderrTail field puts raw git command output into a direct error attribute and into the caller-visible message. Convention requires direct error attributes and messages to stay safe and bounded (normalized categories plus lengths/counts), with the exact underlying value preserved only as cause. This also reintroduces data that #3253 deliberately removed and that GitVcsDriverCore.test.ts still asserts against. Details inline.

Posted via Macroscope — Effect Service Conventions

Comment thread packages/contracts/src/git.ts Outdated
Comment thread packages/contracts/src/git.ts Outdated
Comment thread apps/server/src/vcs/GitVcsDriverCore.ts Outdated
@IAmJSD

IAmJSD commented Aug 7, 2026

Copy link
Copy Markdown
Author

Thanks — all three are fair, and the second one is the important correction. I've dropped the original approach entirely rather than patch around it.

stderrTail and the message change are gone; the error's public shape is now identical to main. Both non-zero-exit sites instead call Effect.logDebug with the command context and its stdout/stderr, so the diagnostic is reachable when someone goes looking without becoming part of the serialized error.

Specifically on each point:

  1. Raw output as a direct attribute — agreed, and I hadn't found [codex] Enrich Git VCS driver errors #3253. Reintroducing it also regressed the GitVcsDriverCore.test.ts case asserting a secret passed as a git argument never reaches error.message, which I should have caught before opening this.
  2. message from structural attributes only — reverted; the getter is untouched.
  3. Redaction insufficient — correct, and I think it argues against sanitizing into a structured field at all rather than for a better regex. Single-token remotes, query-string tokens, and hook output all defeat it, and "redacted" on a field that isn't reliably redacted is worse than not having the field.

The remaining change is the smallest thing that makes the failure knowable: with the error keeping only lengths and clone failures returned to the caller rather than logged, git's reason currently exists nowhere. Debug level felt like the right default given the content can carry credentials — happy to move it behind an explicit diagnostics flag instead if you'd prefer it off by default.

@github-actions github-actions Bot added size:M 30-99 changed lines (additions + deletions). and removed size:S 10-29 changed lines (additions + deletions). labels Aug 7, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One convention issue: the raw git stdout/stderr that #3253 removed from GitCommandError is now re-attached beside the sanitized error as a debug log payload. Convention requires log payloads to be as safe and bounded as the error attributes themselves.

Posted via Macroscope — Effect Service Conventions

Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
@IAmJSD

IAmJSD commented Aug 7, 2026

Copy link
Copy Markdown
Author

Thanks — all three are fair, and the second one is the important correction. I've dropped the original approach entirely rather than patch around it.

stderrTail and the message change are gone; the error's public shape is now identical to main. Both non-zero-exit sites instead call Effect.logDebug with the command context and its stdout/stderr, so the diagnostic is reachable when someone goes looking without becoming part of the serialized error.

Specifically on each point:

1. **Raw output as a direct attribute** — agreed, and I hadn't found [[codex] Enrich Git VCS driver errors #3253](https://github.com/pingdotgg/t3code/pull/3253). Reintroducing it also regressed the `GitVcsDriverCore.test.ts` case asserting a secret passed as a git argument never reaches `error.message`, which I should have caught before opening this.

2. **`message` from structural attributes only** — reverted; the getter is untouched.

3. **Redaction insufficient** — correct, and I think it argues against sanitizing into a structured field at all rather than for a better regex. Single-token remotes, query-string tokens, and hook output all defeat it, and "redacted" on a field that isn't reliably redacted is worse than not having the field.

The remaining change is the smallest thing that makes the failure knowable: with the error keeping only lengths and clone failures returned to the caller rather than logged, git's reason currently exists nowhere. Debug level felt like the right default given the content can carry credentials — happy to move it behind an explicit diagnostics flag instead if you'd prefer it off by default.

^ this was claude lol

@IAmJSD

IAmJSD commented Aug 7, 2026

Copy link
Copy Markdown
Author

Fixed — the log payload is now a normalized category plus lengths, and the exact output moved to cause.

The annotation reuses the vocabulary VcsProcess.classifyNonZeroExit already established (authentication / not-found / command-failed), so a log line says what kind of failure it was without carrying the text that says so. You were right that the previous revision just relocated the problem: stdout/stderr are capped only by maxOutputBytes, which is megabytes at some call sites, so "bounded" was never true of that payload.

GitCommandError's direct attributes are untouched, so the existing assertions still hold — a secret passed as a git argument reaches neither error.message nor an stderr property. isMissingGitCwdError is also unaffected: it guards on cause instanceof PlatformError, which a string fails exactly as the previous undefined did.

Net effect is now three lines of behaviour: a debug log with a category and counts at each non-zero-exit site, and the output on cause where it can be inspected without crossing a boundary as a direct attribute.

Comment thread apps/server/src/vcs/GitVcsDriverCore.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved dc88184

This PR improves git error classification and messaging without changing core git operation behavior. The schema change is additive (optional failureKind field), tests are extensive, and the implementation is security-conscious by avoiding leaking stderr content in error messages.

You can customize Macroscope's approvability policy. Learn more.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Aug 7, 2026
Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Aug 7, 2026
IAmJSD and others added 6 commits August 10, 2026 07:29
A failing git command records only `stdoutLength` and `stderrLength` — how
much git wrote, never what it wrote. The text is captured and then dropped,
so the reason exists nowhere: not in the error, not in the RPC response, and
not in the server log (clone failures are returned to the caller rather than
logged).

In practice this turns an ordinary, self-explanatory git failure into an
opaque one. A clone whose remote refuses the key surfaces as "The source
control operation could not be completed", with git's own
"Permission denied (publickey)" discarded a few frames earlier.

Add an optional `stderrTail` carrying the end of stderr, and include it in
the error message. Truncated from the end, since git puts the reason on its
last lines behind a long transfer log, and credential-bearing URLs are
redacted because git echoes back the remote it was handed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses review: drop `stderrTail` and the message change entirely, and log
the output at the failure site instead.

The first attempt put git's stderr on `GitCommandError` as a bounded,
redacted attribute. That was wrong on three counts, all correctly flagged:
it reintroduces raw command output into a value that crosses RPC, UI and
persistence boundaries; it makes `message` unbounded rather than derived from
stable structural attributes; and the redaction only covered `//user:pass@`
remotes, missing single-token URLs, query-string tokens, and anything git
echoes back from an argument or hook. It also regressed the existing test
asserting a secret passed as a git argument never reaches `error.message`,
and effectively reverted pingdotgg#3253, which removed stderr from this error for
exactly these reasons.

The error's public shape is now untouched. Both non-zero-exit sites call
`Effect.logDebug` with the command context and its stdout/stderr, so the
diagnostic is reachable when someone goes looking without becoming part of
the serialized error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the remaining review finding: a log payload has to be as safe and
bounded as a direct error attribute, and the previous revision copied raw
stdout/stderr into one. Git echoes back its arguments and hook output, and
the buffers are capped only by `maxOutputBytes` — megabytes at some call
sites — so that payload was neither safe nor bounded.

The log annotation is now a normalized category plus lengths, reusing the
vocabulary `VcsProcess.classifyNonZeroExit` already established
(authentication / not-found / command-failed). The exact text is preserved
on the error's `cause`, as the convention prescribes.

`GitCommandError`'s direct attributes are unchanged, so the existing
assertion that a secret passed as a git argument reaches neither
`error.message` nor an `stderr` property still holds, and
`isMissingGitCwdError` is unaffected — it guards on
`cause instanceof PlatformError`, which a string fails exactly as the
previous `undefined` did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Attaching stderr to `GitCommandError.cause` put unredacted git output on a
field that is part of the error's RPC schema, so it reached clients on all
six `WsVcs*` methods that declare `GitCommandError`. Git echoes back the
remote it was handed and any hook output, so that text can carry a token.

Take the approach `VcsProcessExitError.fromProcessExit` already uses for the
same problem: classify stderr into a bounded `failureKind`, turn that into a
fixed caller-facing `detail`, and let the text go. An authentication failure
now says so in `detail` — which was the point of the change — while nothing
git wrote crosses the boundary.

`GitVcsDriverCore.test.ts` now asserts `cause` is free of a secret passed as
a git argument; the existing case only covered `message` and `detail`, which
is why this regressed unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`classifyGitFailure` matched a bare "permission denied", which git also
writes for local filesystem errors — `git init` into an unwritable directory
reaches it too. That was harmless while the classification only annotated a
log line, but it now drives the caller-facing `detail`, so an unwritable
directory would have been answered with advice about remote credentials.

Match the forms that are specific to a remote instead: ssh names the methods
it tried ("Permission denied (publickey)."), and GitHub over https writes
"remote: Permission to owner/repo.git denied to user".

Also widens the not-found sentence, which promised the missing thing was a
repository while the match is broad enough to catch `path 'x' does not exist
in 'HEAD'`.

The two new cases were asserting against real git rather than the stub —
they sit outside `it.layer(TestLayer)` now, and assert on recorded spawns so
a bypassed stub fails instead of silently passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bugbot named `.git/index.lock` and `FETCH_HEAD` specifically. Both already
classify as command-failed, but only the clone-target phrasing was covered,
so nothing pinned the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IAmJSD
IAmJSD force-pushed the surface-git-stderr branch from b40a853 to b68ec7f Compare August 10, 2026 06:30

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b68ec7f. Configure here.

Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
Comment thread apps/server/src/vcs/GitVcsDriverCore.ts
GitHub/GitLab SSH failures for a missing repo include both "not found" and
the "access rights" footer; matching the footer first mislabeled them as
auth. Also force LC_ALL=C on paths that classify failures so translated
stderr cannot miss the English heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@macroscopeapp
macroscopeapp Bot dismissed their stale review August 10, 2026 06:47

Dismissing prior approval to re-evaluate dc88184

@t3dotgg

t3dotgg commented Sep 4, 2026

Copy link
Copy Markdown
Member

Note

🤖 GPT-6 Astra (preview) responding on behalf of Theo

This was closed as part of an automated cleanup pass. If you believe it was closed in error, reply here and we will get it reopened.

Closing in favor of #8645. It gives specific failure reasons for Git commands and excludes hook output from classification while keeping raw stderr private. This branch now uses broader categories, despite the original title. Review continues on the retained PR, with the classification-only locale behavior and its tests recorded there.

@t3dotgg t3dotgg closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants