Skip to content

internal/util: exit codes and the output.Writer #13

Description

@Ilyes512

Goal

internal/util/exit and internal/util/output. All user-facing output goes through
output.Writer, with pretty and json implementations selected by --output.

log/slog is a debug-only diagnostic channel on stderr, silent on a normal run, and never
used for user-facing reporting. Getting that boundary right here means no later package has to
think about it.

Design reference

docs/design.md § Output, § Exit codes

Scope

  • exit: the codes — 0 in sync, 1 error, and the outcome bits 2 drift detected and
    4 applied with skipped repositories — as named constants with doc comments explaining each
  • output.Writer interface, plus pretty (lipgloss) and json (NDJSON) implementations
  • A table renderer for the pretty diff, and TTY detection so styling degrades cleanly
  • Added during review. Table is the only Writer method on stdout; Info reports
    progress on stderr. Narration is not the product, and on stdout it puts an object with no
    data keys into the NDJSON stream, so jq -r .group yields null for it. This diverges from
    specs-cli, deliberately: nothing here called Info yet, so there was no contract to break
  • Added during review. The outcome codes are disjoint bits that OR together, so a dry run
    that finds drift and cannot reach a repository exits 6 rather than forcing a precedence
    rule that throws half the answer away. exit.Error stays outside the bit space and stays
    exclusive: a failed run cannot also report on a live state it never established. This is what
    renumbered Skipped from 3 to 4 — free now, breaking after the first release
  • Added during review. exit.Err and exit.Of: the carrier that gets a code out of a
    command, since RunE returns an error and nothing else. A nil Err field means silent —
    exit 2 on a drifting dry run must not print an error line, because the drift was the
    result. Unwrap keeps the sentinel visible to labelsync.KindOf. Pure internal/util/exit
    with no Cobra dependency, so it lands here and unblocks internal/cmd: Cobra root, App, persistent flags, version #14 rather than the reverse
  • Added during review. Table takes typed rows — output.Table(w, rows, cols...) — rather
    than (headers []string, rows [][]string). Parallel slices let a row disagree with its
    headers with nothing to catch it, and forced every JSON value to be a string because the
    pretty renderer needed one. Now the two audiences get different projections of the same value:
    cells from the columns, JSON from marshalling the row, so a count stays a number and a column
    can be formatted or computed without the record paying for it. JSONKey goes with the old
    signature — with keys on the struct there is no heading left to normalise. Consumers groups: resolve and list group to repository membership #39 and
    cache clear and cache info #42 updated
  • Added during review. IsTTY takes any rather than io.Writer. The prune guard in Prune mode: report, MultiSelect, --prune=all, and the non-TTY guard #44
    has to ask about stdin; the old signature let IsTTY(os.Stdin) compile only because
    *os.File happens to have a Write method, and read as though stdin were out of scope
  • slog wired to stderr, enabled only by --debug
  • Note the errcheck exclusion already configured in .golangci.yml: output is best-effort
    because the Writer interface has no error channel

Tests

Golden files for both the pretty and JSON renderings. Assert the JSON writer emits one object per
line so the stream is parseable mid-run.

Depends on

#12

Done when

task lint and task test pass, every box above is ticked, and the change ships with its tests and documentation in the same PR (per AGENTS.md).


Implemented in #51. task lint, task test, and task md:check all pass.

Documentation landed as a new architecture page,
Output & Exit Codes.
No README change: nothing here is reachable by a user until the command tree exists (#14), so the
exit codes and the --output contract belong in the README alongside the commands that produce
them.

The boxes marked added during review came out of writing that page: putting the reasoning next
to the code surfaced five decisions the original scope had left implicit. Every one is cheap now and
expensive later — Info has no callers, Table has no non-test callers, the exit numbers have not
shipped, and IsTTY has no call sites — which is why they were taken here rather than deferred.

Consequences elsewhere, all updated: #14 (the main handler and the Cobra Silence* wiring),
#41 (the exit-code table, and the first place a run can be both drift and skipped), #37 (the
countdown gates on stderr, where it draws, not stdout), #44 (the prune guard gates on stdin),
#39 and #42 (the two Table call sites).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions