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,489 @@
1
+ package returnpkg
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "os/exec"
10
+ "path/filepath"
11
+ "sort"
12
+ "strings"
13
+ "testing"
14
+ "time"
15
+
16
+ "github.com/amxv/procoder/internal/errs"
17
+ "github.com/amxv/procoder/internal/exchange"
18
+ "github.com/amxv/procoder/internal/output"
19
+ "github.com/amxv/procoder/internal/prepare"
20
+ "github.com/amxv/procoder/internal/testutil/gitrepo"
21
+ )
22
+
23
+ func TestRunReturnHappyPath(t *testing.T) {
24
+ env := setupPreparedExportRepo(t)
25
+
26
+ baseOID := strings.TrimSpace(runGit(t, env.exportRoot, "rev-parse", env.exchange.Task.RootRef))
27
+ appendFile(t, filepath.Join(env.exportRoot, "README.md"), "remote change\n")
28
+ runGit(t, env.exportRoot, "add", "README.md")
29
+ runGit(t, env.exportRoot, "commit", "-m", "remote task work")
30
+ newOID := strings.TrimSpace(runGit(t, env.exportRoot, "rev-parse", env.exchange.Task.RootRef))
31
+
32
+ result, err := Run(Options{
33
+ CWD: env.exportRoot,
34
+ ToolVersion: "0.1.0-test",
35
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 13, 45, 0, 0, time.UTC) },
36
+ })
37
+ if err != nil {
38
+ t.Fatalf("Run returned error: %v", err)
39
+ }
40
+
41
+ expectedName := fmt.Sprintf("procoder-return-%s.zip", env.exchange.ExchangeID)
42
+ expectedPath := filepath.Join(env.exportRoot, expectedName)
43
+ if filepath.Base(result.ReturnPackagePath) != expectedName {
44
+ t.Fatalf("unexpected return package filename: got %q want %q", filepath.Base(result.ReturnPackagePath), expectedName)
45
+ }
46
+ if !filepath.IsAbs(result.ReturnPackagePath) {
47
+ t.Fatalf("expected absolute return package path, got %q", result.ReturnPackagePath)
48
+ }
49
+ gotInfo, err := os.Stat(result.ReturnPackagePath)
50
+ if err != nil {
51
+ t.Fatalf("expected return package at %q: %v", result.ReturnPackagePath, err)
52
+ }
53
+ wantInfo, err := os.Stat(expectedPath)
54
+ if err != nil {
55
+ t.Fatalf("expected return package at %q: %v", expectedPath, err)
56
+ }
57
+ if !os.SameFile(gotInfo, wantInfo) {
58
+ t.Fatalf("return package path mismatch: got %q want %q", result.ReturnPackagePath, expectedPath)
59
+ }
60
+
61
+ status := strings.TrimSpace(runGit(t, env.exportRoot, "status", "--porcelain=v1", "--untracked-files=all"))
62
+ if status != "" {
63
+ t.Fatalf("expected exported repo to remain clean, got status:\n%s", status)
64
+ }
65
+
66
+ exclude := mustReadFile(t, filepath.Join(env.exportRoot, ".git", "info", "exclude"))
67
+ if !strings.Contains(exclude, expectedName) {
68
+ t.Fatalf("expected export exclude to contain return package %q, got:\n%s", expectedName, exclude)
69
+ }
70
+
71
+ entries := listZipEntries(t, result.ReturnPackagePath)
72
+ expectedEntries := []string{returnBundleName, returnJSONName}
73
+ sort.Strings(entries)
74
+ sort.Strings(expectedEntries)
75
+ if strings.Join(entries, ",") != strings.Join(expectedEntries, ",") {
76
+ t.Fatalf("unexpected return package contents: got %v want %v", entries, expectedEntries)
77
+ }
78
+
79
+ extractDir := unzipFile(t, result.ReturnPackagePath)
80
+ returnRecordPath := filepath.Join(extractDir, returnJSONName)
81
+ returnRecord, err := exchange.ReadReturn(returnRecordPath)
82
+ if err != nil {
83
+ t.Fatalf("ReadReturn failed: %v", err)
84
+ }
85
+
86
+ if returnRecord.Protocol != exchange.ReturnProtocolV1 {
87
+ t.Fatalf("unexpected return protocol: got %q want %q", returnRecord.Protocol, exchange.ReturnProtocolV1)
88
+ }
89
+ if returnRecord.ExchangeID != env.exchange.ExchangeID {
90
+ t.Fatalf("unexpected exchange id: got %q want %q", returnRecord.ExchangeID, env.exchange.ExchangeID)
91
+ }
92
+ if returnRecord.BundleFile != returnBundleName {
93
+ t.Fatalf("unexpected bundle file: got %q want %q", returnRecord.BundleFile, returnBundleName)
94
+ }
95
+ if returnRecord.Task.RootRef != env.exchange.Task.RootRef {
96
+ t.Fatalf("unexpected task root ref: got %q want %q", returnRecord.Task.RootRef, env.exchange.Task.RootRef)
97
+ }
98
+ if returnRecord.Task.BaseOID != env.exchange.Task.BaseOID {
99
+ t.Fatalf("unexpected task base oid: got %q want %q", returnRecord.Task.BaseOID, env.exchange.Task.BaseOID)
100
+ }
101
+ if len(returnRecord.Updates) != 1 {
102
+ t.Fatalf("expected exactly one task ref update, got %d", len(returnRecord.Updates))
103
+ }
104
+ update := returnRecord.Updates[0]
105
+ if update.Ref != env.exchange.Task.RootRef {
106
+ t.Fatalf("unexpected updated ref: got %q want %q", update.Ref, env.exchange.Task.RootRef)
107
+ }
108
+ if update.OldOID != baseOID {
109
+ t.Fatalf("unexpected old oid: got %q want %q", update.OldOID, baseOID)
110
+ }
111
+ if update.NewOID != newOID {
112
+ t.Fatalf("unexpected new oid: got %q want %q", update.NewOID, newOID)
113
+ }
114
+
115
+ bundlePath := filepath.Join(extractDir, returnBundleName)
116
+ runGit(t, env.exportRoot, "bundle", "verify", bundlePath)
117
+
118
+ success := FormatSuccess(result)
119
+ if !strings.Contains(success, "Return package: "+result.ReturnPackagePath) {
120
+ t.Fatalf("expected absolute return package path in success output, got:\n%s", success)
121
+ }
122
+ if !strings.Contains(success, "sandbox:"+filepath.ToSlash(result.ReturnPackagePath)) {
123
+ t.Fatalf("expected sandbox hint in success output, got:\n%s", success)
124
+ }
125
+ }
126
+
127
+ func TestRunReturnAllowsSiblingTaskFamilyBranch(t *testing.T) {
128
+ env := setupPreparedExportRepo(t)
129
+
130
+ taskShort := strings.TrimPrefix(env.exchange.Task.RootRef, "refs/heads/")
131
+ siblingRef := env.exchange.Task.RefPrefix + "/experiment"
132
+ siblingShort := strings.TrimPrefix(siblingRef, "refs/heads/")
133
+
134
+ runGit(t, env.exportRoot, "branch", siblingShort, taskShort)
135
+ runGit(t, env.exportRoot, "checkout", siblingShort)
136
+ appendFile(t, filepath.Join(env.exportRoot, "README.md"), "sibling branch change\n")
137
+ runGit(t, env.exportRoot, "add", "README.md")
138
+ runGit(t, env.exportRoot, "commit", "-m", "experiment branch work")
139
+ runGit(t, env.exportRoot, "checkout", taskShort)
140
+
141
+ result, err := Run(Options{CWD: env.exportRoot})
142
+ if err != nil {
143
+ t.Fatalf("Run returned error: %v", err)
144
+ }
145
+
146
+ extractDir := unzipFile(t, result.ReturnPackagePath)
147
+ returnRecord, err := exchange.ReadReturn(filepath.Join(extractDir, returnJSONName))
148
+ if err != nil {
149
+ t.Fatalf("ReadReturn failed: %v", err)
150
+ }
151
+
152
+ update, ok := findUpdate(returnRecord.Updates, siblingRef)
153
+ if !ok {
154
+ t.Fatalf("expected sibling task-family ref %q in return updates, got %#v", siblingRef, returnRecord.Updates)
155
+ }
156
+ if update.OldOID != "" {
157
+ t.Fatalf("expected new sibling ref old oid to be empty, got %q", update.OldOID)
158
+ }
159
+ if strings.TrimSpace(update.NewOID) == "" {
160
+ t.Fatalf("expected new sibling ref new oid to be present")
161
+ }
162
+
163
+ runGit(t, env.exportRoot, "bundle", "verify", filepath.Join(extractDir, returnBundleName))
164
+ }
165
+
166
+ func TestRunReturnFailsDirtyWorktree(t *testing.T) {
167
+ env := setupPreparedExportRepo(t)
168
+ appendFile(t, filepath.Join(env.exportRoot, "README.md"), "dirty\n")
169
+
170
+ _, err := Run(Options{CWD: env.exportRoot})
171
+ assertErrorContains(t, err, errs.CodeWorktreeDirty,
172
+ "uncommitted or untracked changes",
173
+ "README.md",
174
+ "rerun ./procoder-return",
175
+ )
176
+ }
177
+
178
+ func TestRunReturnFailsUntrackedChanges(t *testing.T) {
179
+ env := setupPreparedExportRepo(t)
180
+ writeFile(t, filepath.Join(env.exportRoot, "scratch.txt"), "untracked\n")
181
+
182
+ _, err := Run(Options{CWD: env.exportRoot})
183
+ assertErrorContains(t, err, errs.CodeWorktreeDirty,
184
+ "?? scratch.txt",
185
+ "rerun ./procoder-return",
186
+ )
187
+ }
188
+
189
+ func TestRunReturnFailsNoNewCommits(t *testing.T) {
190
+ env := setupPreparedExportRepo(t)
191
+
192
+ _, err := Run(Options{CWD: env.exportRoot})
193
+ assertErrorContains(t, err, errs.CodeNoNewCommits,
194
+ "Task branch family: "+env.exchange.Task.RefPrefix+"/*",
195
+ "create at least one commit",
196
+ "rerun ./procoder-return",
197
+ )
198
+ }
199
+
200
+ func TestRunReturnFailsOutOfScopeBranchMutation(t *testing.T) {
201
+ env := setupPreparedExportRepo(t)
202
+
203
+ appendFile(t, filepath.Join(env.exportRoot, "README.md"), "task commit\n")
204
+ runGit(t, env.exportRoot, "add", "README.md")
205
+ runGit(t, env.exportRoot, "commit", "-m", "task commit")
206
+ runGit(t, env.exportRoot, "branch", "-f", "main", "HEAD")
207
+
208
+ _, err := Run(Options{CWD: env.exportRoot})
209
+ assertErrorContains(t, err, errs.CodeRefOutOfScope,
210
+ "outside the allowed task branch family",
211
+ "refs/heads/main",
212
+ env.exchange.Task.RefPrefix+"/*",
213
+ "rerun ./procoder-return",
214
+ )
215
+ }
216
+
217
+ func TestRunReturnFailsTagMutation(t *testing.T) {
218
+ env := setupPreparedExportRepo(t)
219
+ runGit(t, env.exportRoot, "tag", "v-new")
220
+
221
+ _, err := Run(Options{CWD: env.exportRoot})
222
+ assertErrorContains(t, err, errs.CodeRefOutOfScope,
223
+ "Changed tags:",
224
+ "refs/tags/v-new",
225
+ "rerun ./procoder-return",
226
+ )
227
+ }
228
+
229
+ func TestRunReturnFailsNonDescendantTaskRef(t *testing.T) {
230
+ env := setupPreparedExportRepo(t)
231
+
232
+ treeOID := strings.TrimSpace(runGit(t, env.exportRoot, "write-tree"))
233
+ orphanOID := strings.TrimSpace(runGitInput(t, env.exportRoot, "orphan task ref\n", "commit-tree", treeOID))
234
+ runGit(t, env.exportRoot, "update-ref", env.exchange.Task.RootRef, orphanOID)
235
+
236
+ _, err := Run(Options{CWD: env.exportRoot})
237
+ assertErrorContains(t, err, errs.CodeRefOutOfScope,
238
+ "does not descend from the task base commit",
239
+ "Ref: "+env.exchange.Task.RootRef,
240
+ "rerun ./procoder-return",
241
+ )
242
+ }
243
+
244
+ func TestRunReturnFailsMissingExchangeMetadata(t *testing.T) {
245
+ env := setupPreparedExportRepo(t)
246
+ exchangePath := filepath.Join(env.exportRoot, ".git", "procoder", "exchange.json")
247
+ if err := os.Remove(exchangePath); err != nil {
248
+ t.Fatalf("remove exchange metadata failed: %v", err)
249
+ }
250
+
251
+ _, err := Run(Options{CWD: env.exportRoot})
252
+ assertErrorContains(t, err, errs.CodeInvalidExchange,
253
+ "Path: .git/procoder/exchange.json",
254
+ "run ./procoder-return only inside a repository created by procoder prepare",
255
+ )
256
+ }
257
+
258
+ func TestRunReturnFailsInvalidExchangeMetadata(t *testing.T) {
259
+ env := setupPreparedExportRepo(t)
260
+ exchangePath := filepath.Join(env.exportRoot, ".git", "procoder", "exchange.json")
261
+ writeFile(t, exchangePath, "{ invalid json\n")
262
+
263
+ _, err := Run(Options{CWD: env.exportRoot})
264
+ assertErrorContains(t, err, errs.CodeInvalidExchange,
265
+ "Path: .git/procoder/exchange.json",
266
+ )
267
+ }
268
+
269
+ type preparedEnv struct {
270
+ sourceRepo *gitrepo.Repo
271
+ exportRoot string
272
+ exchange exchange.Exchange
273
+ }
274
+
275
+ func setupPreparedExportRepo(t *testing.T) preparedEnv {
276
+ t.Helper()
277
+
278
+ source := gitrepo.New(t)
279
+ source.WriteFile("README.md", "source content\n")
280
+ source.CommitAll("initial")
281
+ source.Git("branch", "feature/context")
282
+ source.Git("tag", "v1.0.0")
283
+
284
+ helperPath := writeHelperBinary(t)
285
+ prepareResult, err := prepare.Run(prepare.Options{
286
+ CWD: source.Dir,
287
+ ToolVersion: "0.1.0-test",
288
+ HelperPath: helperPath,
289
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 11, 30, 15, 0, time.UTC) },
290
+ Random: bytes.NewReader([]byte{0x0a, 0x0b, 0x0c}),
291
+ })
292
+ if err != nil {
293
+ t.Fatalf("prepare.Run failed: %v", err)
294
+ }
295
+
296
+ exportRoot := unzipTaskPackage(t, prepareResult.TaskPackagePath)
297
+ ex, err := exchange.ReadExchange(filepath.Join(exportRoot, ".git", "procoder", "exchange.json"))
298
+ if err != nil {
299
+ t.Fatalf("ReadExchange(export) failed: %v", err)
300
+ }
301
+
302
+ return preparedEnv{
303
+ sourceRepo: source,
304
+ exportRoot: exportRoot,
305
+ exchange: ex,
306
+ }
307
+ }
308
+
309
+ func assertErrorContains(t *testing.T, err error, wantCode errs.Code, contains ...string) {
310
+ t.Helper()
311
+
312
+ if err == nil {
313
+ t.Fatalf("expected error with code %s", wantCode)
314
+ }
315
+ typed, ok := errs.As(err)
316
+ if !ok {
317
+ t.Fatalf("expected typed error, got %T (%v)", err, err)
318
+ }
319
+ if typed.Code != wantCode {
320
+ t.Fatalf("unexpected error code: got %s want %s", typed.Code, wantCode)
321
+ }
322
+
323
+ formatted := output.FormatError(err)
324
+ for _, fragment := range contains {
325
+ if !strings.Contains(formatted, fragment) {
326
+ t.Fatalf("expected error output to contain %q, got:\n%s", fragment, formatted)
327
+ }
328
+ }
329
+ }
330
+
331
+ func writeHelperBinary(t *testing.T) string {
332
+ t.Helper()
333
+
334
+ path := filepath.Join(t.TempDir(), "procoder-return")
335
+ writeFile(t, path, "#!/bin/sh\nexit 0\n")
336
+ if err := os.Chmod(path, 0o755); err != nil {
337
+ t.Fatalf("chmod helper failed: %v", err)
338
+ }
339
+ return path
340
+ }
341
+
342
+ func listZipEntries(t *testing.T, zipPath string) []string {
343
+ t.Helper()
344
+
345
+ reader, err := zip.OpenReader(zipPath)
346
+ if err != nil {
347
+ t.Fatalf("zip.OpenReader failed: %v", err)
348
+ }
349
+ defer reader.Close()
350
+
351
+ names := make([]string, 0, len(reader.File))
352
+ for _, f := range reader.File {
353
+ names = append(names, f.Name)
354
+ }
355
+ return names
356
+ }
357
+
358
+ func unzipFile(t *testing.T, zipPath string) string {
359
+ t.Helper()
360
+
361
+ reader, err := zip.OpenReader(zipPath)
362
+ if err != nil {
363
+ t.Fatalf("zip.OpenReader failed: %v", err)
364
+ }
365
+ defer reader.Close()
366
+
367
+ dest := t.TempDir()
368
+ for _, file := range reader.File {
369
+ targetPath := filepath.Join(dest, file.Name)
370
+ safePrefix := filepath.Clean(dest) + string(os.PathSeparator)
371
+ if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
372
+ t.Fatalf("zip entry escapes destination: %q", file.Name)
373
+ }
374
+
375
+ if file.FileInfo().IsDir() {
376
+ if err := os.MkdirAll(targetPath, 0o755); err != nil {
377
+ t.Fatalf("mkdir failed: %v", err)
378
+ }
379
+ continue
380
+ }
381
+
382
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
383
+ t.Fatalf("mkdir file dir failed: %v", err)
384
+ }
385
+ in, err := file.Open()
386
+ if err != nil {
387
+ t.Fatalf("open zip entry failed: %v", err)
388
+ }
389
+ out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
390
+ if err != nil {
391
+ _ = in.Close()
392
+ t.Fatalf("open extracted file failed: %v", err)
393
+ }
394
+ if _, err := io.Copy(out, in); err != nil {
395
+ _ = out.Close()
396
+ _ = in.Close()
397
+ t.Fatalf("copy extracted file failed: %v", err)
398
+ }
399
+ _ = out.Close()
400
+ _ = in.Close()
401
+ }
402
+ return dest
403
+ }
404
+
405
+ func unzipTaskPackage(t *testing.T, zipPath string) string {
406
+ t.Helper()
407
+
408
+ dest := unzipFile(t, zipPath)
409
+ return filepath.Join(dest, filepath.Base(filepath.Dir(zipPath)))
410
+ }
411
+
412
+ func runGit(t *testing.T, dir string, args ...string) string {
413
+ t.Helper()
414
+
415
+ cmd := exec.Command("git", args...)
416
+ cmd.Dir = dir
417
+ var stdout bytes.Buffer
418
+ var stderr bytes.Buffer
419
+ cmd.Stdout = &stdout
420
+ cmd.Stderr = &stderr
421
+
422
+ if err := cmd.Run(); err != nil {
423
+ t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
424
+ }
425
+ return stdout.String()
426
+ }
427
+
428
+ func runGitInput(t *testing.T, dir, input string, args ...string) string {
429
+ t.Helper()
430
+
431
+ cmd := exec.Command("git", args...)
432
+ cmd.Dir = dir
433
+ cmd.Stdin = strings.NewReader(input)
434
+ var stdout bytes.Buffer
435
+ var stderr bytes.Buffer
436
+ cmd.Stdout = &stdout
437
+ cmd.Stderr = &stderr
438
+
439
+ if err := cmd.Run(); err != nil {
440
+ t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
441
+ }
442
+ return stdout.String()
443
+ }
444
+
445
+ func appendFile(t *testing.T, path, extra string) {
446
+ t.Helper()
447
+
448
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
449
+ if err != nil {
450
+ t.Fatalf("open %s for append failed: %v", path, err)
451
+ }
452
+ if _, err := f.WriteString(extra); err != nil {
453
+ _ = f.Close()
454
+ t.Fatalf("append to %s failed: %v", path, err)
455
+ }
456
+ if err := f.Close(); err != nil {
457
+ t.Fatalf("close %s failed: %v", path, err)
458
+ }
459
+ }
460
+
461
+ func writeFile(t *testing.T, path, content string) {
462
+ t.Helper()
463
+
464
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
465
+ t.Fatalf("mkdir %s failed: %v", filepath.Dir(path), err)
466
+ }
467
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
468
+ t.Fatalf("write %s failed: %v", path, err)
469
+ }
470
+ }
471
+
472
+ func mustReadFile(t *testing.T, path string) string {
473
+ t.Helper()
474
+
475
+ data, err := os.ReadFile(path)
476
+ if err != nil {
477
+ t.Fatalf("read %s failed: %v", path, err)
478
+ }
479
+ return string(data)
480
+ }
481
+
482
+ func findUpdate(updates []exchange.RefUpdate, ref string) (exchange.RefUpdate, bool) {
483
+ for _, update := range updates {
484
+ if update.Ref == ref {
485
+ return update, true
486
+ }
487
+ }
488
+ return exchange.RefUpdate{}, false
489
+ }
@@ -0,0 +1,113 @@
1
+ package gitrepo
2
+
3
+ import (
4
+ "bytes"
5
+ stderrors "errors"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ type Repo struct {
14
+ t *testing.T
15
+ Dir string
16
+ }
17
+
18
+ type Result struct {
19
+ Stdout string
20
+ Stderr string
21
+ ExitCode int
22
+ }
23
+
24
+ func New(t *testing.T) *Repo {
25
+ t.Helper()
26
+
27
+ dir := t.TempDir()
28
+ r := &Repo{
29
+ t: t,
30
+ Dir: dir,
31
+ }
32
+
33
+ result := r.Run("init", "--initial-branch=main")
34
+ if result.ExitCode != 0 {
35
+ result = r.Run("init")
36
+ if result.ExitCode != 0 {
37
+ t.Fatalf("git init failed:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
38
+ }
39
+ r.Git("branch", "-M", "main")
40
+ }
41
+
42
+ r.Git("config", "user.name", "Test User")
43
+ r.Git("config", "user.email", "test@example.com")
44
+ r.Git("config", "commit.gpgsign", "false")
45
+
46
+ return r
47
+ }
48
+
49
+ func (r *Repo) WriteFile(relPath, contents string) string {
50
+ r.t.Helper()
51
+
52
+ fullPath := filepath.Join(r.Dir, relPath)
53
+ if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
54
+ r.t.Fatalf("mkdir for %s failed: %v", relPath, err)
55
+ }
56
+ if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil {
57
+ r.t.Fatalf("write %s failed: %v", relPath, err)
58
+ }
59
+ return fullPath
60
+ }
61
+
62
+ func (r *Repo) CommitAll(message string) string {
63
+ r.t.Helper()
64
+
65
+ r.Git("add", "-A")
66
+ r.Git("commit", "-m", message)
67
+ return strings.TrimSpace(r.Git("rev-parse", "HEAD"))
68
+ }
69
+
70
+ func (r *Repo) Git(args ...string) string {
71
+ r.t.Helper()
72
+
73
+ result := r.Run(args...)
74
+ if result.ExitCode != 0 {
75
+ r.t.Fatalf(
76
+ "git %s failed with code %d\nstdout:\n%s\nstderr:\n%s",
77
+ strings.Join(args, " "),
78
+ result.ExitCode,
79
+ result.Stdout,
80
+ result.Stderr,
81
+ )
82
+ }
83
+ return result.Stdout
84
+ }
85
+
86
+ func (r *Repo) Run(args ...string) Result {
87
+ r.t.Helper()
88
+
89
+ cmd := exec.Command("git", args...)
90
+ cmd.Dir = r.Dir
91
+ var stdout bytes.Buffer
92
+ var stderr bytes.Buffer
93
+ cmd.Stdout = &stdout
94
+ cmd.Stderr = &stderr
95
+
96
+ err := cmd.Run()
97
+ return Result{
98
+ Stdout: stdout.String(),
99
+ Stderr: stderr.String(),
100
+ ExitCode: exitCode(err),
101
+ }
102
+ }
103
+
104
+ func exitCode(err error) int {
105
+ if err == nil {
106
+ return 0
107
+ }
108
+ var exitErr *exec.ExitError
109
+ if stderrors.As(err, &exitErr) {
110
+ return exitErr.ExitCode()
111
+ }
112
+ return -1
113
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "procoder-cli",
3
+ "version": "0.1.0",
4
+ "description": "Offline Git exchange CLI for local repositories and locked-down ChatGPT coding containers",
5
+ "license": "MIT",
6
+ "author": "amxv",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/amxv/procoder.git"
10
+ },
11
+ "homepage": "https://github.com/amxv/procoder#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/amxv/procoder/issues"
14
+ },
15
+ "bin": {
16
+ "procoder": "bin/procoder.js"
17
+ },
18
+ "config": {
19
+ "cliBinaryName": "procoder"
20
+ },
21
+ "files": [
22
+ "bin/procoder.js",
23
+ "scripts/postinstall.js",
24
+ "cmd",
25
+ "internal",
26
+ "go.mod",
27
+ "README.md",
28
+ "AGENTS.md",
29
+ "CONTRIBUTORS.md",
30
+ "Makefile"
31
+ ],
32
+ "scripts": {
33
+ "postinstall": "node scripts/postinstall.js",
34
+ "test": "node --test scripts/postinstall.test.js && node --check bin/procoder.js && node --check scripts/postinstall.js",
35
+ "lint": "npm run test"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "keywords": [
41
+ "git",
42
+ "offline",
43
+ "cli",
44
+ "chatgpt",
45
+ "exchange"
46
+ ]
47
+ }