procoder-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ package errs
2
+
3
+ import (
4
+ stderrors "errors"
5
+ "fmt"
6
+ )
7
+
8
+ type Code string
9
+
10
+ const (
11
+ CodeNotGitRepo Code = "NOT_GIT_REPO"
12
+ CodeWorktreeDirty Code = "WORKTREE_DIRTY"
13
+ CodeUntrackedFilesPresent Code = "UNTRACKED_FILES_PRESENT"
14
+ CodeSubmodulesUnsupported Code = "SUBMODULES_UNSUPPORTED"
15
+ CodeLFSUnsupported Code = "LFS_UNSUPPORTED"
16
+ CodeInvalidExchange Code = "INVALID_EXCHANGE"
17
+ CodeInvalidReturnPackage Code = "INVALID_RETURN_PACKAGE"
18
+ CodeBundleVerifyFailed Code = "BUNDLE_VERIFY_FAILED"
19
+ CodeRefOutOfScope Code = "REF_OUT_OF_SCOPE"
20
+ CodeNoNewCommits Code = "NO_NEW_COMMITS"
21
+ CodeBranchMoved Code = "BRANCH_MOVED"
22
+ CodeRefExists Code = "REF_EXISTS"
23
+ CodeTargetBranchCheckedOut Code = "TARGET_BRANCH_CHECKED_OUT"
24
+ CodeUnknownCommand Code = "UNKNOWN_COMMAND"
25
+ CodeNotImplemented Code = "NOT_IMPLEMENTED"
26
+ CodeGitCommandFailed Code = "GIT_COMMAND_FAILED"
27
+ CodeGitUnavailable Code = "GIT_UNAVAILABLE"
28
+ CodeInternal Code = "INTERNAL"
29
+ )
30
+
31
+ type Error struct {
32
+ Code Code
33
+ Message string
34
+ Hint string
35
+ Details []string
36
+ Err error
37
+ }
38
+
39
+ func (e *Error) Error() string {
40
+ if e == nil {
41
+ return ""
42
+ }
43
+ if e.Message == "" {
44
+ return string(e.Code)
45
+ }
46
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
47
+ }
48
+
49
+ func (e *Error) Unwrap() error {
50
+ if e == nil {
51
+ return nil
52
+ }
53
+ return e.Err
54
+ }
55
+
56
+ type Option func(*Error)
57
+
58
+ func New(code Code, message string, opts ...Option) *Error {
59
+ e := &Error{
60
+ Code: code,
61
+ Message: message,
62
+ }
63
+ for _, opt := range opts {
64
+ opt(e)
65
+ }
66
+ return e
67
+ }
68
+
69
+ func Wrap(code Code, message string, err error, opts ...Option) *Error {
70
+ opts = append([]Option{WithCause(err)}, opts...)
71
+ return New(code, message, opts...)
72
+ }
73
+
74
+ func WithHint(hint string) Option {
75
+ return func(e *Error) {
76
+ e.Hint = hint
77
+ }
78
+ }
79
+
80
+ func WithDetails(details ...string) Option {
81
+ return func(e *Error) {
82
+ e.Details = append(e.Details, details...)
83
+ }
84
+ }
85
+
86
+ func WithDetailf(format string, args ...any) Option {
87
+ return WithDetails(fmt.Sprintf(format, args...))
88
+ }
89
+
90
+ func WithCause(err error) Option {
91
+ return func(e *Error) {
92
+ e.Err = err
93
+ }
94
+ }
95
+
96
+ func As(err error) (*Error, bool) {
97
+ if err == nil {
98
+ return nil, false
99
+ }
100
+ var typed *Error
101
+ if stderrors.As(err, &typed) {
102
+ return typed, true
103
+ }
104
+ return nil, false
105
+ }
106
+
107
+ func CodeOf(err error) Code {
108
+ if typed, ok := As(err); ok {
109
+ if typed.Code != "" {
110
+ return typed.Code
111
+ }
112
+ }
113
+ return CodeInternal
114
+ }
@@ -0,0 +1,165 @@
1
+ package exchange
2
+
3
+ import (
4
+ "bytes"
5
+ "path/filepath"
6
+ "reflect"
7
+ "regexp"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ func TestGenerateID(t *testing.T) {
13
+ now := time.Date(2026, time.March, 20, 11, 30, 15, 0, time.UTC)
14
+ id, err := GenerateID(now, bytes.NewReader([]byte{0xAA, 0x01, 0xBC}))
15
+ if err != nil {
16
+ t.Fatalf("GenerateID returned error: %v", err)
17
+ }
18
+
19
+ const expected = "20260320-113015-aa01bc"
20
+ if id != expected {
21
+ t.Fatalf("unexpected id: got %q want %q", id, expected)
22
+ }
23
+
24
+ matched, err := regexp.MatchString(`^\d{8}-\d{6}-[0-9a-f]{6}$`, id)
25
+ if err != nil {
26
+ t.Fatalf("regexp compile failed: %v", err)
27
+ }
28
+ if !matched {
29
+ t.Fatalf("id does not match expected format: %q", id)
30
+ }
31
+ }
32
+
33
+ func TestGenerateIDInsufficientRandomBytes(t *testing.T) {
34
+ _, err := GenerateID(time.Now(), bytes.NewReader([]byte{0xAB}))
35
+ if err == nil {
36
+ t.Fatal("expected error for insufficient random bytes")
37
+ }
38
+ }
39
+
40
+ func TestExchangeAndReturnJSONRoundTrip(t *testing.T) {
41
+ t.Parallel()
42
+
43
+ now := time.Date(2026, time.March, 20, 10, 30, 0, 0, time.UTC)
44
+ ex := Exchange{
45
+ Protocol: ExchangeProtocolV1,
46
+ ExchangeID: "20260320-103000-a1b2c3",
47
+ CreatedAt: now,
48
+ ToolVersion: "0.1.0",
49
+ Source: ExchangeSource{
50
+ HeadRef: "refs/heads/main",
51
+ HeadOID: "abc123",
52
+ },
53
+ Task: ExchangeTask{
54
+ RootRef: "refs/heads/procoder/20260320-103000-a1b2c3/task",
55
+ RefPrefix: "refs/heads/procoder/20260320-103000-a1b2c3",
56
+ BaseOID: "abc123",
57
+ },
58
+ Context: ExchangeContext{
59
+ Heads: map[string]string{
60
+ "refs/heads/main": "abc123",
61
+ },
62
+ Tags: map[string]string{
63
+ "refs/tags/v1.0.0": "def456",
64
+ },
65
+ },
66
+ }
67
+
68
+ ret := Return{
69
+ Protocol: ReturnProtocolV1,
70
+ ExchangeID: ex.ExchangeID,
71
+ CreatedAt: now.Add(30 * time.Minute),
72
+ ToolVersion: "0.1.0",
73
+ BundleFile: "procoder-return.bundle",
74
+ Task: ReturnTask{
75
+ RootRef: ex.Task.RootRef,
76
+ BaseOID: ex.Task.BaseOID,
77
+ },
78
+ Updates: []RefUpdate{
79
+ {
80
+ Ref: ex.Task.RootRef,
81
+ OldOID: "abc123",
82
+ NewOID: "def456",
83
+ },
84
+ },
85
+ }
86
+
87
+ exPath := filepath.Join(t.TempDir(), "exchange.json")
88
+ if err := WriteExchange(exPath, ex); err != nil {
89
+ t.Fatalf("WriteExchange failed: %v", err)
90
+ }
91
+ gotEx, err := ReadExchange(exPath)
92
+ if err != nil {
93
+ t.Fatalf("ReadExchange failed: %v", err)
94
+ }
95
+ if !reflect.DeepEqual(ex, gotEx) {
96
+ t.Fatalf("exchange mismatch after round trip:\n got: %#v\nwant: %#v", gotEx, ex)
97
+ }
98
+
99
+ retPath := filepath.Join(t.TempDir(), "procoder-return.json")
100
+ if err := WriteReturn(retPath, ret); err != nil {
101
+ t.Fatalf("WriteReturn failed: %v", err)
102
+ }
103
+ gotRet, err := ReadReturn(retPath)
104
+ if err != nil {
105
+ t.Fatalf("ReadReturn failed: %v", err)
106
+ }
107
+ if !reflect.DeepEqual(ret, gotRet) {
108
+ t.Fatalf("return mismatch after round trip:\n got: %#v\nwant: %#v", gotRet, ret)
109
+ }
110
+ }
111
+
112
+ func TestIsTaskRef(t *testing.T) {
113
+ exchangeID := "20260320-113015-a1b2c3"
114
+ root := TaskRootRef(exchangeID)
115
+ prefix := TaskRefPrefix(exchangeID)
116
+
117
+ if !IsTaskRef(exchangeID, root) {
118
+ t.Fatalf("expected root ref to be allowed: %q", root)
119
+ }
120
+ if IsTaskRef(exchangeID, prefix) {
121
+ t.Fatalf("did not expect prefix-only ref to be allowed: %q", prefix)
122
+ }
123
+ sibling := prefix + "/experiment"
124
+ if !IsTaskRef(exchangeID, sibling) {
125
+ t.Fatalf("expected sibling task-family ref to be allowed: %q", sibling)
126
+ }
127
+ if IsTaskRef(exchangeID, "refs/heads/main") {
128
+ t.Fatal("did not expect refs/heads/main to be allowed")
129
+ }
130
+ }
131
+
132
+ func TestTaskRefHelpersFailClosedForInvalidExchangeID(t *testing.T) {
133
+ t.Parallel()
134
+
135
+ refInNamespace := "refs/heads/procoder/20260320-113015-a1b2c3/task"
136
+ testCases := []struct {
137
+ name string
138
+ id string
139
+ }{
140
+ {name: "empty", id: ""},
141
+ {name: "spaces", id: " "},
142
+ {name: "date-only", id: "20260320"},
143
+ {name: "missing-random", id: "20260320-113015"},
144
+ {name: "contains-slash", id: "20260320-113015-a1b2c3/child"},
145
+ {name: "path-traversal", id: "../20260320-113015-a1b2c3"},
146
+ }
147
+
148
+ for _, tc := range testCases {
149
+ tc := tc
150
+ t.Run(tc.name, func(t *testing.T) {
151
+ t.Parallel()
152
+
153
+ root := TaskRootRef(tc.id)
154
+ if root != "" {
155
+ t.Fatalf("expected empty root for invalid exchange ID %q, got %q", tc.id, root)
156
+ }
157
+ if prefix := TaskRefPrefix(tc.id); prefix != "" {
158
+ t.Fatalf("expected empty prefix for invalid exchange ID %q, got %q", tc.id, prefix)
159
+ }
160
+ if IsTaskRef(tc.id, refInNamespace) {
161
+ t.Fatalf("expected IsTaskRef false for invalid exchange ID %q", tc.id)
162
+ }
163
+ })
164
+ }
165
+ }
@@ -0,0 +1,66 @@
1
+ package exchange
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "fmt"
7
+ "io"
8
+ "regexp"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ const (
14
+ TaskRefNamespace = "refs/heads/procoder"
15
+ taskBranchName = "task"
16
+ )
17
+
18
+ var exchangeIDPattern = regexp.MustCompile(`^\d{8}-\d{6}-[0-9a-f]{6}$`)
19
+
20
+ func NewID() (string, error) {
21
+ return GenerateID(time.Now().UTC(), rand.Reader)
22
+ }
23
+
24
+ func GenerateID(now time.Time, random io.Reader) (string, error) {
25
+ if random == nil {
26
+ return "", fmt.Errorf("random source is nil")
27
+ }
28
+ buf := make([]byte, 3)
29
+ if _, err := io.ReadFull(random, buf); err != nil {
30
+ return "", fmt.Errorf("read random bytes: %w", err)
31
+ }
32
+ stamp := now.UTC().Format("20060102-150405")
33
+ return fmt.Sprintf("%s-%s", stamp, hex.EncodeToString(buf)), nil
34
+ }
35
+
36
+ func TaskRootRef(exchangeID string) string {
37
+ prefix := TaskRefPrefix(exchangeID)
38
+ if prefix == "" {
39
+ return ""
40
+ }
41
+ return prefix + "/" + taskBranchName
42
+ }
43
+
44
+ func TaskRefPrefix(exchangeID string) string {
45
+ normalized, ok := normalizeExchangeID(exchangeID)
46
+ if !ok {
47
+ return ""
48
+ }
49
+ return TaskRefNamespace + "/" + normalized
50
+ }
51
+
52
+ func IsTaskRef(exchangeID, ref string) bool {
53
+ prefix := TaskRefPrefix(exchangeID)
54
+ if prefix == "" {
55
+ return false
56
+ }
57
+ return strings.HasPrefix(ref, prefix+"/")
58
+ }
59
+
60
+ func normalizeExchangeID(exchangeID string) (string, bool) {
61
+ exchangeID = strings.TrimSpace(exchangeID)
62
+ if !exchangeIDPattern.MatchString(exchangeID) {
63
+ return "", false
64
+ }
65
+ return exchangeID, true
66
+ }
@@ -0,0 +1,64 @@
1
+ package exchange
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ )
9
+
10
+ func WriteExchange(path string, e Exchange) error {
11
+ if e.Protocol == "" {
12
+ e.Protocol = ExchangeProtocolV1
13
+ }
14
+ return writeJSON(path, e)
15
+ }
16
+
17
+ func ReadExchange(path string) (Exchange, error) {
18
+ var e Exchange
19
+ if err := readJSON(path, &e); err != nil {
20
+ return Exchange{}, err
21
+ }
22
+ return e, nil
23
+ }
24
+
25
+ func WriteReturn(path string, r Return) error {
26
+ if r.Protocol == "" {
27
+ r.Protocol = ReturnProtocolV1
28
+ }
29
+ return writeJSON(path, r)
30
+ }
31
+
32
+ func ReadReturn(path string) (Return, error) {
33
+ var r Return
34
+ if err := readJSON(path, &r); err != nil {
35
+ return Return{}, err
36
+ }
37
+ return r, nil
38
+ }
39
+
40
+ func writeJSON(path string, value any) error {
41
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
42
+ return fmt.Errorf("create directory for %s: %w", path, err)
43
+ }
44
+ encoded, err := json.MarshalIndent(value, "", " ")
45
+ if err != nil {
46
+ return fmt.Errorf("marshal json for %s: %w", path, err)
47
+ }
48
+ encoded = append(encoded, '\n')
49
+ if err := os.WriteFile(path, encoded, 0o644); err != nil {
50
+ return fmt.Errorf("write %s: %w", path, err)
51
+ }
52
+ return nil
53
+ }
54
+
55
+ func readJSON(path string, out any) error {
56
+ data, err := os.ReadFile(path)
57
+ if err != nil {
58
+ return fmt.Errorf("read %s: %w", path, err)
59
+ }
60
+ if err := json.Unmarshal(data, out); err != nil {
61
+ return fmt.Errorf("decode %s: %w", path, err)
62
+ }
63
+ return nil
64
+ }
@@ -0,0 +1,55 @@
1
+ package exchange
2
+
3
+ import "time"
4
+
5
+ const (
6
+ ExchangeProtocolV1 = "procoder-exchange/v1"
7
+ ReturnProtocolV1 = "procoder-return/v1"
8
+ )
9
+
10
+ type Exchange struct {
11
+ Protocol string `json:"protocol"`
12
+ ExchangeID string `json:"exchange_id"`
13
+ CreatedAt time.Time `json:"created_at"`
14
+ ToolVersion string `json:"tool_version"`
15
+ Source ExchangeSource `json:"source"`
16
+ Task ExchangeTask `json:"task"`
17
+ Context ExchangeContext `json:"context"`
18
+ }
19
+
20
+ type ExchangeSource struct {
21
+ HeadRef string `json:"head_ref"`
22
+ HeadOID string `json:"head_oid"`
23
+ }
24
+
25
+ type ExchangeTask struct {
26
+ RootRef string `json:"root_ref"`
27
+ RefPrefix string `json:"ref_prefix"`
28
+ BaseOID string `json:"base_oid"`
29
+ }
30
+
31
+ type ExchangeContext struct {
32
+ Heads map[string]string `json:"heads,omitempty"`
33
+ Tags map[string]string `json:"tags,omitempty"`
34
+ }
35
+
36
+ type Return struct {
37
+ Protocol string `json:"protocol"`
38
+ ExchangeID string `json:"exchange_id"`
39
+ CreatedAt time.Time `json:"created_at"`
40
+ ToolVersion string `json:"tool_version"`
41
+ BundleFile string `json:"bundle_file"`
42
+ Task ReturnTask `json:"task"`
43
+ Updates []RefUpdate `json:"updates"`
44
+ }
45
+
46
+ type ReturnTask struct {
47
+ RootRef string `json:"root_ref"`
48
+ BaseOID string `json:"base_oid"`
49
+ }
50
+
51
+ type RefUpdate struct {
52
+ Ref string `json:"ref"`
53
+ OldOID string `json:"old_oid"`
54
+ NewOID string `json:"new_oid"`
55
+ }
@@ -0,0 +1,105 @@
1
+ package gitx
2
+
3
+ import (
4
+ "bytes"
5
+ stderrors "errors"
6
+ "fmt"
7
+ "io"
8
+ "os/exec"
9
+ "strconv"
10
+ "strings"
11
+
12
+ "github.com/amxv/procoder/internal/errs"
13
+ )
14
+
15
+ type Runner struct {
16
+ Dir string
17
+ Env []string
18
+ }
19
+
20
+ type Result struct {
21
+ Args []string
22
+ Stdout string
23
+ Stderr string
24
+ ExitCode int
25
+ }
26
+
27
+ func NewRunner(dir string) Runner {
28
+ return Runner{Dir: dir}
29
+ }
30
+
31
+ func (r Runner) Run(args ...string) (Result, error) {
32
+ return r.run(nil, args...)
33
+ }
34
+
35
+ func (r Runner) RunWithInput(input string, args ...string) (Result, error) {
36
+ return r.run(strings.NewReader(input), args...)
37
+ }
38
+
39
+ func (r Runner) run(stdin io.Reader, args ...string) (Result, error) {
40
+ cmd := exec.Command("git", args...)
41
+ cmd.Dir = r.Dir
42
+ if len(r.Env) > 0 {
43
+ cmd.Env = append(cmd.Environ(), r.Env...)
44
+ }
45
+ if stdin != nil {
46
+ cmd.Stdin = stdin
47
+ }
48
+
49
+ var stdout bytes.Buffer
50
+ var stderr bytes.Buffer
51
+ cmd.Stdout = &stdout
52
+ cmd.Stderr = &stderr
53
+
54
+ result := Result{
55
+ Args: args,
56
+ }
57
+
58
+ err := cmd.Run()
59
+ result.Stdout = stdout.String()
60
+ result.Stderr = stderr.String()
61
+ result.ExitCode = getExitCode(err)
62
+ if err == nil {
63
+ return result, nil
64
+ }
65
+
66
+ var execErr *exec.Error
67
+ if stderrors.As(err, &execErr) {
68
+ return result, errs.Wrap(
69
+ errs.CodeGitUnavailable,
70
+ "git executable is unavailable",
71
+ err,
72
+ errs.WithHint("install Git and retry"),
73
+ )
74
+ }
75
+
76
+ commandText := "git"
77
+ if len(args) > 0 {
78
+ commandText += " " + strings.Join(args, " ")
79
+ }
80
+ details := []string{
81
+ "Command: " + commandText,
82
+ "Exit code: " + strconv.Itoa(result.ExitCode),
83
+ }
84
+ stderrText := strings.TrimRight(result.Stderr, "\n")
85
+ if stderrText != "" {
86
+ details = append(details, "Stderr: "+stderrText)
87
+ }
88
+ return result, errs.Wrap(
89
+ errs.CodeGitCommandFailed,
90
+ fmt.Sprintf("git command failed: %s", commandText),
91
+ err,
92
+ errs.WithDetails(details...),
93
+ )
94
+ }
95
+
96
+ func getExitCode(err error) int {
97
+ if err == nil {
98
+ return 0
99
+ }
100
+ var exitErr *exec.ExitError
101
+ if stderrors.As(err, &exitErr) {
102
+ return exitErr.ExitCode()
103
+ }
104
+ return -1
105
+ }
@@ -0,0 +1,51 @@
1
+ package gitx
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/amxv/procoder/internal/errs"
8
+ "github.com/amxv/procoder/internal/testutil/gitrepo"
9
+ )
10
+
11
+ func TestRunnerRunSuccess(t *testing.T) {
12
+ repo := gitrepo.New(t)
13
+ repo.WriteFile("README.md", "hello\n")
14
+ headOID := repo.CommitAll("initial commit")
15
+
16
+ runner := NewRunner(repo.Dir)
17
+ result, err := runner.Run("rev-parse", "HEAD")
18
+ if err != nil {
19
+ t.Fatalf("Run returned error: %v", err)
20
+ }
21
+ if result.ExitCode != 0 {
22
+ t.Fatalf("expected exit code 0, got %d", result.ExitCode)
23
+ }
24
+ if strings.TrimSpace(result.Stdout) != headOID {
25
+ t.Fatalf("unexpected stdout: got %q want %q", strings.TrimSpace(result.Stdout), headOID)
26
+ }
27
+ }
28
+
29
+ func TestRunnerRunFailure(t *testing.T) {
30
+ repo := gitrepo.New(t)
31
+ runner := NewRunner(repo.Dir)
32
+
33
+ result, err := runner.Run("rev-parse", "refs/heads/does-not-exist")
34
+ if err == nil {
35
+ t.Fatal("expected error")
36
+ }
37
+ if result.ExitCode == 0 {
38
+ t.Fatalf("expected non-zero exit code, got %d", result.ExitCode)
39
+ }
40
+
41
+ typed, ok := errs.As(err)
42
+ if !ok {
43
+ t.Fatalf("expected typed error, got %T", err)
44
+ }
45
+ if typed.Code != errs.CodeGitCommandFailed {
46
+ t.Fatalf("expected code %s, got %s", errs.CodeGitCommandFailed, typed.Code)
47
+ }
48
+ if !strings.Contains(strings.Join(typed.Details, "\n"), "Exit code:") {
49
+ t.Fatalf("expected exit code detail, got %v", typed.Details)
50
+ }
51
+ }
@@ -0,0 +1,49 @@
1
+ package output
2
+
3
+ import (
4
+ "fmt"
5
+ "io"
6
+ "strings"
7
+
8
+ "github.com/amxv/procoder/internal/errs"
9
+ )
10
+
11
+ func WriteError(w io.Writer, err error) {
12
+ if err == nil {
13
+ return
14
+ }
15
+ _, _ = io.WriteString(w, FormatError(err))
16
+ }
17
+
18
+ func FormatError(err error) string {
19
+ if err == nil {
20
+ return ""
21
+ }
22
+
23
+ var b strings.Builder
24
+ if typed, ok := errs.As(err); ok {
25
+ code := typed.Code
26
+ if code == "" {
27
+ code = errs.CodeInternal
28
+ }
29
+ message := typed.Message
30
+ if message == "" {
31
+ message = "unexpected error"
32
+ }
33
+ _, _ = fmt.Fprintf(&b, "%s: %s\n", code, message)
34
+ for _, detail := range typed.Details {
35
+ detail = strings.TrimSpace(detail)
36
+ if detail == "" {
37
+ continue
38
+ }
39
+ _, _ = fmt.Fprintf(&b, "%s\n", detail)
40
+ }
41
+ if typed.Hint != "" {
42
+ _, _ = fmt.Fprintf(&b, "Hint: %s\n", typed.Hint)
43
+ }
44
+ return b.String()
45
+ }
46
+
47
+ _, _ = fmt.Fprintf(&b, "%s: %s\n", errs.CodeInternal, err.Error())
48
+ return b.String()
49
+ }
@@ -0,0 +1,41 @@
1
+ package output
2
+
3
+ import (
4
+ "errors"
5
+ "strings"
6
+ "testing"
7
+
8
+ "github.com/amxv/procoder/internal/errs"
9
+ )
10
+
11
+ func TestFormatErrorTyped(t *testing.T) {
12
+ err := errs.New(
13
+ errs.CodeBranchMoved,
14
+ "cannot update refs/heads/procoder/20260320-abc123",
15
+ errs.WithDetails(
16
+ "Expected old OID: abc123",
17
+ "Current local OID: def456",
18
+ ),
19
+ errs.WithHint("rerun with --namespace procoder-import"),
20
+ )
21
+
22
+ got := FormatError(err)
23
+ wantLines := []string{
24
+ "BRANCH_MOVED: cannot update refs/heads/procoder/20260320-abc123",
25
+ "Expected old OID: abc123",
26
+ "Current local OID: def456",
27
+ "Hint: rerun with --namespace procoder-import",
28
+ }
29
+ for _, line := range wantLines {
30
+ if !strings.Contains(got, line) {
31
+ t.Fatalf("missing line %q in output:\n%s", line, got)
32
+ }
33
+ }
34
+ }
35
+
36
+ func TestFormatErrorUntyped(t *testing.T) {
37
+ got := FormatError(errors.New("boom"))
38
+ if got != "INTERNAL: boom\n" {
39
+ t.Fatalf("unexpected fallback format: %q", got)
40
+ }
41
+ }