Skip to content

fix(util)!: send diagnostics to stderr and exit non-zero on failed repositories - #60

Open
bircni wants to merge 4 commits into
mainfrom
fix/stderr-logging-and-exit-codes
Open

fix(util)!: send diagnostics to stderr and exit non-zero on failed repositories#60
bircni wants to merge 4 commits into
mainfrom
fix/stderr-logging-and-exit-codes

Conversation

@bircni

@bircni bircni commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Warning

Two breaking changes: diagnostics move from stdout to stderr, and a scan that finds
an unreadable repository now exits 1. See Compatibility.

Four commits, each independently buildable.

1. --json was not reliably machine-parseable

The logger ran in TerminalMode::Mixed, which puts everything below Warn on stdout:

21:06:24 [DEBUG] git_statuses::scan: Failed to open repository at …
{"failed":["broken"],"repositories":[…
json.decoder.JSONDecodeError: Extra data: line 1 column 3

A release build only escaped this by accident — the single log::info! sits in
repositories_table, which the JSON path never reaches. Any new info-level record
anywhere would have broken every consumer silently.

Diagnostics now go to stderr; stdout carries the report alone.

2. The process always exited 0

Even when every repository failed to open. A script had no way to distinguish a clean
scan from a total failure. run now returns an Outcome, and a non-empty failed list
becomes exit code 1. The failures were already counted and printed — this stops
discarding the fact.

3 & 4. Cleanups

  • fetch_originfetch_remote: it fetches whatever get_remote_name returns, which
    is origin only when one exists. Documented why it shells out to git (credential
    helpers, SSH agent, proxy config — matching that via git2 means reimplementing auth).
  • Dropped Deserialize on RepoInfo and Status; the JSON output is write-only.
  • Coverage gate 95% → 90%. It measures total line coverage, so deleting well-covered
    code lowers it even when nothing became less tested — that nearly blocked refactor: remove redundant per-repository work and make output testable #56. Actual
    coverage is 96.88%; the lower bound is headroom, not a target.

On the tests

The new tests/ target exists because these invariants only hold at the process
boundary
. My first attempt asserted them in-process against run — and it passed with
the bug still present, because the printer writes to the test's buffer while the logger
writes to the real stdout. Each of the four new tests was confirmed to fail with its fix
reverted.

Compatibility

  • Anything reading diagnostics from stdout must read stderr instead. Output already
    being parsed is unaffected — and now reliable rather than accidentally correct.
  • Scanning a tree containing one broken checkout now exits 1, so scripts checking
    exit status will start seeing failure.

README documents both, including the now-working git-statuses --json | jq … pipeline.

bircni added 4 commits August 24, 2026 18:32
The logger ran in `TerminalMode::Mixed`, which puts everything below
`Warn` on stdout. In a debug build that meant `--json` emitted debug
records ahead of the opening brace:

  21:06:24 [DEBUG] git_statuses::scan: Failed to open repository at ...
  {"failed":["broken"],"repositories":[...

so nothing downstream could parse it. A release build only escaped this by
accident: the single `log::info!` sits in `repositories_table`, which the
JSON path never reaches. Any new info-level record anywhere would have
broken every consumer silently.

Diagnostics now go to stderr and stdout carries the report alone.

These tests live in a new `tests/` target because the invariant only
exists at the process boundary. Driving `run` in-process cannot catch it:
the printer writes to the buffer the test owns while the logger writes to
the real stdout, so an in-process test passes either way.

BREAKING CHANGE: anything capturing diagnostics from stdout must read
stderr instead. Output that was already being parsed is unaffected, and
is now reliable rather than accidentally correct.
The process always exited 0, even when every repository in the scan
failed to open. A script piping the output had no way to tell a clean
scan from a total failure - the only signal was a warning on stderr.

`run` now returns an `Outcome` describing what it reported, and `main`
turns a non-empty failed list into exit code 1. The failures were already
being counted and printed; this just stops throwing the fact away.

BREAKING CHANGE: a scan that finds a repository it cannot process now
exits 1 instead of 0. Scanning a tree that contains one broken checkout
will start reporting failure to any script that checks the exit status.
…rives

`fetch_origin` fetches whatever remote `get_remote_name` returns, which is
"origin" only when one exists - the name promised something the function
does not do. It is now `fetch_remote`, with a note on why it shells out to
`git` rather than using `git2`: the user's credential helpers, SSH agent
and proxy settings apply as they do on the command line, and matching that
through `git2` would mean reimplementing authentication.

`RepoInfo` and `Status` derived `Deserialize`, but nothing ever reads them
back - the JSON output is write-only.
The gate measures total line coverage, so deleting well-covered code
lowers it even when nothing became less tested. That nearly blocked #56,
where removing dead code dropped the ratio despite the change adding
assertions - and the incentive it creates is to keep dead code or write
filler tests.

Coverage sits at 96% today; the lower bound is headroom for refactors that
remove tested code, not a target.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant