Skip to content

Commit 233e007

Browse files
committed
Put narration on stderr and make the outcome codes combine (GH-13)
Four decisions the original boundary left implicit, taken now because all four are free today and expensive later: Info has no callers, the exit numbers have not shipped, and IsTTY has no call sites. Table is now the only Writer method on stdout. Progress is narration, not the product, and it must not land in the file when a user redirects stdout. In JSON mode the cost was concrete: an info object carries none of the keys a data object does, so jq -r .group yielded null for it. Every line on stdout is now a data record, which the tests assert directly rather than by inspection. This diverges from specs-cli, where commands already depend on Info being on stdout; inheriting the defect to stay symmetrical was the wrong trade. The outcome codes are disjoint bits that OR together, so a dry run that finds drift and also cannot reach a repository exits 6 instead of forcing a precedence rule that throws half the answer away. Skipped moves from 3 to 4 to make room. Error stays outside the bit space and stays exclusive: when a run fails the live state is unknown, so "failed and drifted" is not a statement labelsync can honestly make — and with Error in the mask, 3 would have to mean exactly that. The cost is that callers test bits rather than equality, which is written down next to the table. exit.Err carries a code out to main, because 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 successful result and the diff is already on stdout. Unwrap is load-bearing — a carrier that hid the error it wraps would strip error_kind from exactly the failures that carry a code — and Of maps a carrier holding a failure to Error even when Code was left unset, since a zero exit on a failed run is the one wrong answer a pipeline cannot detect. IsTTY takes any rather than an io.Writer. The prune guard has to ask about stdin, and the old signature let IsTTY(os.Stdin) compile only because *os.File happens to have a Write method. The two gated decisions ask about different streams: the countdown about stderr, where it draws, and the prompt about stdin, because the hang it prevents is a read with nobody to answer it. The architecture page absorbs the reasoning that produced all of this, and gains the Cobra wiring and review checklist that #14 has to follow.
1 parent 43473d4 commit 233e007

13 files changed

Lines changed: 618 additions & 89 deletions

File tree

‎docs/content/docs/architecture/output.md‎

Lines changed: 308 additions & 27 deletions
Large diffs are not rendered by default.

‎docs/design.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,7 +592,11 @@ which makes it useless as a check.
592592
| `0` | In sync — no changes needed / applied successfully with no drift |
593593
| `1` | Error (config invalid, auth failure, unrecoverable API error) |
594594
| `2` | Drift detected — `--dry-run` found pending actions |
595-
| `3` | Applied successfully, but one or more repositories were skipped |
595+
| `4` | Applied successfully, but one or more repositories were skipped |
596+
597+
The outcome codes are disjoint bits and combine: a dry run that finds drift *and* cannot reach a
598+
repository exits `6`. `1` stays exclusive — a failed run cannot also report on a live state it never
599+
established.
596600

597601
### Non-interactive guard
598602

‎internal/util/exit/exit.go‎

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,114 @@
1-
// Package exit holds the process exit codes labelsync returns.
1+
// Package exit holds the process exit codes labelsync returns, and the error
2+
// type that carries one out of a command.
23
//
34
// The scheme is borrowed from `terraform plan -detailed-exitcode`: a dry run
45
// that finds pending work exits non-zero without that meaning "the tool broke".
56
// Without it a CI dry-run can only ever pass, which makes it useless as a check.
67
//
7-
// The codes are a public contract — CI pipelines branch on them — so they may be
8-
// added to, never renumbered.
8+
// # Outcomes combine; failure does not
9+
//
10+
// A single run can satisfy more than one outcome — a dry run that finds pending
11+
// actions and also cannot reach a repository is both [Drift] and [Skipped]. The
12+
// outcome codes are therefore disjoint bits that OR together, and that run exits
13+
// 6. Ranking them would mean throwing half the answer away.
14+
//
15+
// [Error] is deliberately not part of that bit space. It is the classic Unix
16+
// generic failure and it is exclusive: when a run fails, the live state is
17+
// unknown, so "failed and drifted" is not a statement labelsync can honestly
18+
// make. A failure exits 1 and nothing else — which is also what keeps every
19+
// combination meaningful, since with Error in the mask 3 would have to mean
20+
// "failed and drifted", the very claim the failure invalidates.
21+
//
22+
// The numbers are a public contract — CI pipelines branch on them — so bits may
23+
// be added, never reassigned, and Error stays 1 forever.
924
package exit
1025

26+
import (
27+
"errors"
28+
"strconv"
29+
)
30+
31+
// Code is a process exit code. The outcome codes are single bits and combine
32+
// with |; see the package doc.
33+
type Code int
34+
1135
const (
1236
// OK means the run succeeded and left nothing outstanding: either the live
1337
// state already matched the config, or every planned action was applied and
1438
// no repository was skipped.
15-
OK = 0
39+
OK Code = 0
1640

1741
// Error means the run failed. Config invalid, no token, an unrecoverable API
1842
// error, or a rate-limit wait that would exceed --max-wait. Nothing about the
19-
// live state can be inferred from this code.
20-
Error = 1
43+
// live state can be inferred from this code, which is why it is exclusive
44+
// rather than a bit combined with the outcomes below.
45+
Error Code = 1
2146

2247
// Drift means the run completed without writing and found pending actions:
2348
// `sync --dry-run` against repositories whose labels disagree with the
2449
// config. This is the code a pull-request check fails on.
25-
Drift = 2
50+
Drift Code = 1 << 1
2651

2752
// Skipped means the plan was applied successfully, but one or more
2853
// repositories could not be reached — missing, archived, or outside the
2954
// token's scopes. Those failures are collected per repository rather than
3055
// aborting the run, so the work that could be done was done; this code is how
3156
// the caller learns it was not the whole set.
32-
Skipped = 3
57+
Skipped Code = 1 << 2
3358
)
59+
60+
// Err carries a code out of a command, because RunE returns an error and
61+
// nothing else.
62+
//
63+
// A nil Err field means silent: the command has already reported everything the
64+
// user needs through the output.Writer, and there is no failure to print. A dry
65+
// run that found drift is not an error — the drift was the successful result,
66+
// and the diff is already on stdout — but it still has to exit 2.
67+
//
68+
// An ordinary failure needs no carrier at all: [Of] maps every other error to
69+
// [Error].
70+
type Err struct {
71+
// Code is the code the process should exit with. Combine outcome bits with |.
72+
Code Code
73+
74+
// Err is the underlying failure, or nil when the non-zero code reports an
75+
// outcome rather than a failure.
76+
Err error
77+
}
78+
79+
// Error implements error. The message is only ever read by a caller that
80+
// formats the carrier itself — main prints the wrapped error, or nothing.
81+
func (e *Err) Error() string {
82+
if e.Err != nil {
83+
return e.Err.Error()
84+
}
85+
86+
return "exit code " + e.Code.String()
87+
}
88+
89+
// Unwrap exposes the underlying failure to errors.Is and errors.As, so putting
90+
// an error in a carrier does not hide its sentinel from labelsync.KindOf.
91+
func (e *Err) Unwrap() error { return e.Err }
92+
93+
// Of returns the code the process should exit with: OK for a nil error, the
94+
// carried code for an [Err], and Error for anything else.
95+
//
96+
// A carrier holding a real failure never reports success, even when it was
97+
// built with Code left unset — a zero exit on a failed run is the one mistake
98+
// here that a pipeline cannot detect.
99+
func Of(err error) Code {
100+
if err == nil {
101+
return OK
102+
}
103+
104+
var carrier *Err
105+
if errors.As(err, &carrier) && carrier.Code != OK {
106+
return carrier.Code
107+
}
108+
109+
return Error
110+
}
111+
112+
// String renders a code for a debug log or a test failure. A combined code
113+
// renders as its sum, which is what the shell sees.
114+
func (c Code) String() string { return strconv.Itoa(int(c)) }

‎internal/util/exit/exit_test.go‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package exit_test
22

33
import (
4+
"errors"
5+
"fmt"
46
"testing"
57

8+
"github.com/specsnl/labelsync/internal/labelsync"
69
"github.com/specsnl/labelsync/internal/util/exit"
710
)
811

@@ -13,13 +16,13 @@ import (
1316
func TestCodes_AreStable(t *testing.T) {
1417
for _, tc := range []struct {
1518
name string
16-
got int
17-
want int
19+
got exit.Code
20+
want exit.Code
1821
}{
1922
{"OK", exit.OK, 0},
2023
{"Error", exit.Error, 1},
2124
{"Drift", exit.Drift, 2},
22-
{"Skipped", exit.Skipped, 3},
25+
{"Skipped", exit.Skipped, 4},
2326
} {
2427
t.Run(tc.name, func(t *testing.T) {
2528
if tc.got != tc.want {
@@ -29,22 +32,107 @@ func TestCodes_AreStable(t *testing.T) {
2932
}
3033
}
3134

32-
func TestCodes_AreDistinct(t *testing.T) {
33-
seen := map[int]string{}
35+
// The outcome codes have to be single, non-overlapping bits or they cannot be
36+
// OR'd: two outcomes sharing a bit would make 6 ambiguous, and an outcome with
37+
// two bits set would collide with a combination of others.
38+
func TestOutcomeCodes_AreDisjointSingleBits(t *testing.T) {
39+
var union exit.Code
3440

3541
for _, tc := range []struct {
3642
name string
37-
code int
43+
code exit.Code
3844
}{
39-
{"OK", exit.OK},
40-
{"Error", exit.Error},
4145
{"Drift", exit.Drift},
4246
{"Skipped", exit.Skipped},
4347
} {
44-
if other, dup := seen[tc.code]; dup {
45-
t.Errorf("exit.%s and exit.%s both equal %d", other, tc.name, tc.code)
48+
if tc.code&(tc.code-1) != 0 {
49+
t.Errorf("exit.%s = %d has more than one bit set", tc.name, tc.code)
4650
}
4751

48-
seen[tc.code] = tc.name
52+
if union&tc.code != 0 {
53+
t.Errorf("exit.%s = %d overlaps an earlier outcome code", tc.name, tc.code)
54+
}
55+
56+
union |= tc.code
57+
}
58+
59+
// Error is not in the bit space: it must not be reachable by combining
60+
// outcomes, or a pipeline masking for failure would see one.
61+
if union&exit.Error != 0 {
62+
t.Errorf("outcome codes %d overlap exit.Error", union)
63+
}
64+
}
65+
66+
// The combination the scheme exists for: a dry run that finds drift and also
67+
// cannot reach a repository reports both, rather than the more alarming one.
68+
func TestCodes_Combine(t *testing.T) {
69+
code := exit.OK
70+
code |= exit.Drift
71+
code |= exit.Skipped
72+
73+
if code != 6 {
74+
t.Errorf("Drift|Skipped = %d, want 6", code)
75+
}
76+
77+
if code&exit.Drift == 0 || code&exit.Skipped == 0 {
78+
t.Errorf("%d does not carry both outcomes", code)
79+
}
80+
}
81+
82+
func TestOf(t *testing.T) {
83+
sentinel := fmt.Errorf("%w: specsnl/old-thing", labelsync.ErrRepoInaccessible)
84+
85+
for _, tc := range []struct {
86+
name string
87+
err error
88+
want exit.Code
89+
}{
90+
{"nil is success", nil, exit.OK},
91+
{"a plain error is a failure", errors.New("boom"), exit.Error},
92+
{"a wrapped sentinel is a failure", sentinel, exit.Error},
93+
{"a carrier reports its code", &exit.Err{Code: exit.Drift}, exit.Drift},
94+
{"a carrier reports a combination", &exit.Err{Code: exit.Drift | exit.Skipped}, 6},
95+
{"a carrier with a failure", &exit.Err{Code: exit.Error, Err: sentinel}, exit.Error},
96+
// Left unset by mistake: a failed run must not exit zero, because that is
97+
// the one wrong answer a pipeline cannot detect.
98+
{"a carrier with no code but a failure", &exit.Err{Err: sentinel}, exit.Error},
99+
{"a wrapped carrier", fmt.Errorf("syncing: %w", &exit.Err{Code: exit.Skipped}), exit.Skipped},
100+
} {
101+
t.Run(tc.name, func(t *testing.T) {
102+
if got := exit.Of(tc.err); got != tc.want {
103+
t.Errorf("Of(%v) = %d, want %d", tc.err, got, tc.want)
104+
}
105+
})
106+
}
107+
}
108+
109+
// A carrier must not hide the sentinel it wraps, or the error_kind field in JSON
110+
// output would go missing for exactly the failures that carry a code.
111+
func TestErr_UnwrapsToTheSentinel(t *testing.T) {
112+
err := error(&exit.Err{
113+
Code: exit.Error,
114+
Err: fmt.Errorf("%w: specsnl/old-thing", labelsync.ErrRepoInaccessible),
115+
})
116+
117+
if !errors.Is(err, labelsync.ErrRepoInaccessible) {
118+
t.Error("errors.Is could not see through the carrier")
119+
}
120+
121+
if kind := labelsync.KindOf(err); kind != "repo_inaccessible" {
122+
t.Errorf("KindOf = %q, want %q", kind, "repo_inaccessible")
123+
}
124+
}
125+
126+
// A silent carrier still has to satisfy error, because it travels as one. The
127+
// message is a fallback for a caller that formats it directly; main prints
128+
// nothing for this case.
129+
func TestErr_Error(t *testing.T) {
130+
if got := (&exit.Err{Code: exit.Drift}).Error(); got != "exit code 2" {
131+
t.Errorf("Error() = %q, want %q", got, "exit code 2")
132+
}
133+
134+
wrapped := &exit.Err{Code: exit.Error, Err: errors.New("boom")}
135+
if got := wrapped.Error(); got != "boom" {
136+
t.Errorf("Error() = %q, want %q", got, "boom")
49137
}
50138
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
{"level":"info","message":"resolving 3 groups"}
12
{"level":"warn","message":"skipping specsnl/old-thing: archived"}
23
{"error_kind":"repo_inaccessible","level":"error","message":"repository is inaccessible: specsnl/old-thing"}
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
{"level":"info","message":"resolving 3 groups"}
21
{"group":"websites","repositories":"12","source":"org: specsnl"}
32
{"group":"platform","repositories":"3","source":"repos:"}
43
{"group":"archive","repositories":"0","source":"org: specsnl (excluded)"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
info resolving 3 groups
12
warn skipping specsnl/old-thing: archived
23
error repository is inaccessible: specsnl/old-thing
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
info resolving 3 groups
12
warn skipping specsnl/old-thing: archived
23
error repository is inaccessible: specsnl/old-thing

‎internal/util/output/testdata/pretty_stdout_color.golden‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
info resolving 3 groups
21
┌─────────────────────────────────────────────────┐
32
│ Group Repositories Source │
43
│ ─────────────────────────────────────────────── │

‎internal/util/output/testdata/pretty_stdout_nocolor.golden‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
info resolving 3 groups
21
┌─────────────────────────────────────────────────┐
32
│ Group Repositories Source │
43
│ ─────────────────────────────────────────────── │

0 commit comments

Comments
 (0)