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.
- package/AGENTS.md +122 -0
- package/CONTRIBUTORS.md +79 -0
- package/Makefile +82 -0
- package/README.md +125 -0
- package/bin/procoder.js +28 -0
- package/cmd/procoder/main.go +15 -0
- package/cmd/procoder-return/main.go +67 -0
- package/cmd/procoder-return/main_test.go +77 -0
- package/go.mod +3 -0
- package/internal/app/app.go +224 -0
- package/internal/app/app_test.go +253 -0
- package/internal/app/roundtrip_test.go +215 -0
- package/internal/apply/apply.go +1069 -0
- package/internal/apply/apply_test.go +794 -0
- package/internal/errs/errors.go +114 -0
- package/internal/exchange/exchange_test.go +165 -0
- package/internal/exchange/id.go +66 -0
- package/internal/exchange/json.go +64 -0
- package/internal/exchange/types.go +55 -0
- package/internal/gitx/gitx.go +105 -0
- package/internal/gitx/gitx_test.go +51 -0
- package/internal/output/errors.go +49 -0
- package/internal/output/errors_test.go +41 -0
- package/internal/prepare/prepare.go +788 -0
- package/internal/prepare/prepare_test.go +416 -0
- package/internal/returnpkg/returnpkg.go +589 -0
- package/internal/returnpkg/returnpkg_test.go +489 -0
- package/internal/testutil/gitrepo/repo.go +113 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +263 -0
|
@@ -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
|
+
}
|