Skip to content

Commit c4638ed

Browse files
committed
refactor(cli): unify error formatting at the process boundary
Previously the entry point did: os.Stderr.WriteString("Error: " + err.Error() + "\n") which works for ad-hoc errors but loses structure when the error is a *git.OpError. Users saw messages like: Error: git: checkout branch failed: exit status 1 (command: git checkout main) which buries the actually useful part (the op and the underlying reason) behind a long one-liner. This patch adds writeCLIError(w, err, verbose): it uses errors.As to detect *git.OpError and prints a two-line summary: Error: checkout branch failed already on main When GGC_VERBOSE=1 is set, a third line shows the raw git command for debugging. All other errors keep the historical single-line format, so no existing test or user workflow changes. Adds main_cli_error_test.go covering plain, OpError, verbose, and errors.Join-wrapped OpError cases.
1 parent 81a50b2 commit c4638ed

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

main.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package main
33

44
import (
55
"context"
6+
"errors"
7+
"fmt"
8+
"io"
69
"os"
710
"os/signal"
811
"runtime/debug"
@@ -76,7 +79,26 @@ func RunApp(args []string) error {
7679

7780
func main() {
7881
if err := RunApp(os.Args[1:]); err != nil {
79-
_, _ = os.Stderr.WriteString("Error: " + err.Error() + "\n")
82+
writeCLIError(os.Stderr, err, os.Getenv("GGC_VERBOSE") == "1")
8083
os.Exit(1)
8184
}
8285
}
86+
87+
// writeCLIError renders a terminal-facing error consistently across the CLI.
88+
//
89+
// For *git.OpError we print a two-line summary (what failed, then the
90+
// underlying message). The raw git command is only shown when GGC_VERBOSE=1
91+
// because it can be long and is usually noise in normal use. Non-git errors
92+
// keep their historical single-line format so we don't churn existing tests
93+
// or user expectations.
94+
func writeCLIError(w io.Writer, err error, verbose bool) {
95+
var opErr *git.OpError
96+
if errors.As(err, &opErr) {
97+
_, _ = fmt.Fprintf(w, "Error: %s failed\n %s\n", opErr.Op, opErr.Err)
98+
if verbose && opErr.Command != "" {
99+
_, _ = fmt.Fprintf(w, " command: %s\n", opErr.Command)
100+
}
101+
return
102+
}
103+
_, _ = fmt.Fprintf(w, "Error: %s\n", err.Error())
104+
}

main_cli_error_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"strings"
7+
"testing"
8+
9+
"github.com/bmf-san/ggc/v8/internal/git"
10+
)
11+
12+
func TestWriteCLIError_PlainError(t *testing.T) {
13+
var buf bytes.Buffer
14+
writeCLIError(&buf, errors.New("boom"), false)
15+
got := buf.String()
16+
want := "Error: boom\n"
17+
if got != want {
18+
t.Fatalf("plain error: got %q, want %q", got, want)
19+
}
20+
}
21+
22+
func TestWriteCLIError_OpErrorHidesCommandByDefault(t *testing.T) {
23+
var buf bytes.Buffer
24+
err := git.NewOpError("checkout branch", "git checkout main", errors.New("already on main"))
25+
writeCLIError(&buf, err, false)
26+
got := buf.String()
27+
28+
if !strings.Contains(got, "Error: checkout branch failed") {
29+
t.Errorf("missing op summary: %q", got)
30+
}
31+
if !strings.Contains(got, "already on main") {
32+
t.Errorf("missing underlying message: %q", got)
33+
}
34+
if strings.Contains(got, "git checkout main") {
35+
t.Errorf("raw command leaked without verbose mode: %q", got)
36+
}
37+
}
38+
39+
func TestWriteCLIError_OpErrorVerboseShowsCommand(t *testing.T) {
40+
var buf bytes.Buffer
41+
err := git.NewOpError("checkout branch", "git checkout main", errors.New("already on main"))
42+
writeCLIError(&buf, err, true)
43+
got := buf.String()
44+
45+
if !strings.Contains(got, "command: git checkout main") {
46+
t.Errorf("verbose mode should include command: %q", got)
47+
}
48+
}
49+
50+
func TestWriteCLIError_WrappedOpError(t *testing.T) {
51+
var buf bytes.Buffer
52+
inner := git.NewOpError("push", "git push origin main", errors.New("rejected"))
53+
wrapped := errors.Join(inner, errors.New("post-run cleanup also failed"))
54+
writeCLIError(&buf, wrapped, false)
55+
got := buf.String()
56+
57+
if !strings.Contains(got, "Error: push failed") {
58+
t.Errorf("errors.As through join should still find OpError: %q", got)
59+
}
60+
}

0 commit comments

Comments
 (0)