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,589 @@
1
+ package returnpkg
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bufio"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/amxv/procoder/internal/errs"
15
+ "github.com/amxv/procoder/internal/exchange"
16
+ "github.com/amxv/procoder/internal/gitx"
17
+ )
18
+
19
+ const (
20
+ defaultToolVersion = "dev"
21
+ exchangeRelPath = ".git/procoder/exchange.json"
22
+ returnJSONName = "procoder-return.json"
23
+ returnBundleName = "procoder-return.bundle"
24
+ returnZipNameFmt = "procoder-return-%s.zip"
25
+ successIntro = "Created return package."
26
+ )
27
+
28
+ type Options struct {
29
+ CWD string
30
+ ToolVersion string
31
+ Now func() time.Time
32
+ }
33
+
34
+ type Result struct {
35
+ ExchangeID string
36
+ ReturnPackagePath string
37
+ }
38
+
39
+ func Run(opts Options) (Result, error) {
40
+ cwd, err := resolveCWD(opts.CWD)
41
+ if err != nil {
42
+ return Result{}, errs.Wrap(errs.CodeInternal, "resolve current working directory", err)
43
+ }
44
+
45
+ repoRoot, gitDir, err := resolveRepo(cwd)
46
+ if err != nil {
47
+ return Result{}, err
48
+ }
49
+ runner := gitx.NewRunner(repoRoot)
50
+
51
+ exchangePath := filepath.Join(gitDir, "procoder", "exchange.json")
52
+ ex, err := readExchange(exchangePath)
53
+ if err != nil {
54
+ return Result{}, err
55
+ }
56
+
57
+ if err := validateCleanWorktree(runner); err != nil {
58
+ return Result{}, err
59
+ }
60
+
61
+ currentHeads, err := readRefSnapshot(runner, "refs/heads")
62
+ if err != nil {
63
+ return Result{}, errs.Wrap(errs.CodeInternal, "read current branch refs", err)
64
+ }
65
+ currentTags, err := readRefSnapshot(runner, "refs/tags")
66
+ if err != nil {
67
+ return Result{}, errs.Wrap(errs.CodeInternal, "read current tag refs", err)
68
+ }
69
+
70
+ taskUpdates, outOfScopeHeadChanges, err := compareHeadChanges(ex, currentHeads)
71
+ if err != nil {
72
+ return Result{}, err
73
+ }
74
+ tagChanges := diffRefChanges(ex.Context.Tags, currentTags)
75
+
76
+ if len(outOfScopeHeadChanges) > 0 {
77
+ return Result{}, errs.New(
78
+ errs.CodeRefOutOfScope,
79
+ "changed refs are outside the allowed task branch family",
80
+ errs.WithDetails(renderRefScopeDetails(outOfScopeHeadChanges, ex.Task.RefPrefix)...),
81
+ errs.WithHint("move your commits onto the task branch family, then rerun ./procoder-return"),
82
+ )
83
+ }
84
+
85
+ if len(tagChanges) > 0 {
86
+ return Result{}, errs.New(
87
+ errs.CodeRefOutOfScope,
88
+ "tag changes are not supported in procoder v1 return packages",
89
+ errs.WithDetails(renderTagChangeDetails(tagChanges)...),
90
+ errs.WithHint("remove tag changes, then rerun ./procoder-return"),
91
+ )
92
+ }
93
+
94
+ if len(taskUpdates) == 0 {
95
+ return Result{}, errs.New(
96
+ errs.CodeNoNewCommits,
97
+ "no new commits found in the task branch family",
98
+ errs.WithDetails(fmt.Sprintf("Task branch family: %s/*", ex.Task.RefPrefix)),
99
+ errs.WithHint("create at least one commit on the task branch family, then rerun ./procoder-return"),
100
+ )
101
+ }
102
+
103
+ if err := validateDescendants(runner, ex, taskUpdates); err != nil {
104
+ return Result{}, err
105
+ }
106
+
107
+ toolVersion := strings.TrimSpace(opts.ToolVersion)
108
+ if toolVersion == "" {
109
+ toolVersion = defaultToolVersion
110
+ }
111
+ nowFn := opts.Now
112
+ if nowFn == nil {
113
+ nowFn = func() time.Time { return time.Now().UTC() }
114
+ }
115
+
116
+ ret := exchange.Return{
117
+ Protocol: exchange.ReturnProtocolV1,
118
+ ExchangeID: ex.ExchangeID,
119
+ CreatedAt: nowFn().UTC(),
120
+ ToolVersion: toolVersion,
121
+ BundleFile: returnBundleName,
122
+ Task: exchange.ReturnTask{
123
+ RootRef: ex.Task.RootRef,
124
+ BaseOID: ex.Task.BaseOID,
125
+ },
126
+ Updates: taskUpdates,
127
+ }
128
+
129
+ tempDir, err := os.MkdirTemp("", "procoder-return-*")
130
+ if err != nil {
131
+ return Result{}, errs.Wrap(errs.CodeInternal, "create temporary return package directory", err)
132
+ }
133
+ defer os.RemoveAll(tempDir)
134
+
135
+ returnJSONPath := filepath.Join(tempDir, returnJSONName)
136
+ if err := exchange.WriteReturn(returnJSONPath, ret); err != nil {
137
+ return Result{}, errs.Wrap(errs.CodeInternal, "write return package metadata", err)
138
+ }
139
+
140
+ bundlePath := filepath.Join(tempDir, returnBundleName)
141
+ if err := createBundle(runner, bundlePath, ex.Task.BaseOID, taskUpdates); err != nil {
142
+ return Result{}, err
143
+ }
144
+
145
+ returnPackageName := fmt.Sprintf(returnZipNameFmt, ex.ExchangeID)
146
+ returnPackagePath := filepath.Join(repoRoot, returnPackageName)
147
+ if err := createZip(returnPackagePath, map[string]string{
148
+ returnJSONName: returnJSONPath,
149
+ returnBundleName: bundlePath,
150
+ }); err != nil {
151
+ return Result{}, errs.Wrap(errs.CodeInternal, "create return package zip", err)
152
+ }
153
+
154
+ if err := appendExclude(gitDir, returnPackageName); err != nil {
155
+ return Result{}, errs.Wrap(errs.CodeInternal, "update .git/info/exclude with return package", err)
156
+ }
157
+
158
+ absPath, err := filepath.Abs(returnPackagePath)
159
+ if err != nil {
160
+ return Result{}, errs.Wrap(errs.CodeInternal, "resolve absolute return package path", err)
161
+ }
162
+
163
+ return Result{
164
+ ExchangeID: ex.ExchangeID,
165
+ ReturnPackagePath: absPath,
166
+ }, nil
167
+ }
168
+
169
+ func FormatSuccess(result Result) string {
170
+ sandboxPath := "sandbox:" + filepath.ToSlash(result.ReturnPackagePath)
171
+ return strings.Join([]string{
172
+ successIntro,
173
+ fmt.Sprintf("Return package: %s", result.ReturnPackagePath),
174
+ fmt.Sprintf("Sandbox hint: %s", sandboxPath),
175
+ }, "\n")
176
+ }
177
+
178
+ type refChange struct {
179
+ Ref string
180
+ OldOID string
181
+ NewOID string
182
+ }
183
+
184
+ func compareHeadChanges(ex exchange.Exchange, currentHeads map[string]string) ([]exchange.RefUpdate, []refChange, error) {
185
+ baselineHeads := map[string]string{}
186
+ for ref, oid := range ex.Context.Heads {
187
+ baselineHeads[ref] = oid
188
+ }
189
+ if _, ok := baselineHeads[ex.Task.RootRef]; !ok {
190
+ baselineHeads[ex.Task.RootRef] = ex.Task.BaseOID
191
+ }
192
+
193
+ changes := diffRefChanges(baselineHeads, currentHeads)
194
+ taskUpdates := make([]exchange.RefUpdate, 0, len(changes))
195
+ outOfScope := make([]refChange, 0)
196
+
197
+ for _, change := range changes {
198
+ if !exchange.IsTaskRef(ex.ExchangeID, change.Ref) {
199
+ outOfScope = append(outOfScope, change)
200
+ continue
201
+ }
202
+ if change.NewOID == "" {
203
+ return nil, nil, errs.New(
204
+ errs.CodeRefOutOfScope,
205
+ "task branch deletions are not supported in procoder v1 return packages",
206
+ errs.WithDetails(
207
+ fmt.Sprintf("Deleted ref: %s", change.Ref),
208
+ fmt.Sprintf("Task branch family: %s/*", ex.Task.RefPrefix),
209
+ ),
210
+ errs.WithHint("restore the deleted task branch and rerun ./procoder-return"),
211
+ )
212
+ }
213
+ taskUpdates = append(taskUpdates, exchange.RefUpdate{
214
+ Ref: change.Ref,
215
+ OldOID: change.OldOID,
216
+ NewOID: change.NewOID,
217
+ })
218
+ }
219
+
220
+ sort.Slice(taskUpdates, func(i, j int) bool { return taskUpdates[i].Ref < taskUpdates[j].Ref })
221
+ sort.Slice(outOfScope, func(i, j int) bool { return outOfScope[i].Ref < outOfScope[j].Ref })
222
+ return taskUpdates, outOfScope, nil
223
+ }
224
+
225
+ func validateDescendants(runner gitx.Runner, ex exchange.Exchange, updates []exchange.RefUpdate) error {
226
+ for _, update := range updates {
227
+ result, err := runner.Run("merge-base", "--is-ancestor", ex.Task.BaseOID, update.NewOID)
228
+ if err == nil {
229
+ continue
230
+ }
231
+ if result.ExitCode == 1 {
232
+ return errs.New(
233
+ errs.CodeRefOutOfScope,
234
+ "changed task ref does not descend from the task base commit",
235
+ errs.WithDetails(
236
+ fmt.Sprintf("Ref: %s", update.Ref),
237
+ fmt.Sprintf("Task base OID: %s", ex.Task.BaseOID),
238
+ fmt.Sprintf("Ref OID: %s", update.NewOID),
239
+ ),
240
+ errs.WithHint("move this ref to a descendant of the task base commit, then rerun ./procoder-return"),
241
+ )
242
+ }
243
+ return errs.Wrap(errs.CodeInternal, fmt.Sprintf("validate task ref ancestry for %s", update.Ref), err)
244
+ }
245
+ return nil
246
+ }
247
+
248
+ func createBundle(runner gitx.Runner, bundlePath, baseOID string, updates []exchange.RefUpdate) error {
249
+ args := []string{"bundle", "create", bundlePath}
250
+ for _, update := range updates {
251
+ args = append(args, fmt.Sprintf("%s..%s", baseOID, update.Ref))
252
+ }
253
+
254
+ if _, err := runner.Run(args...); err != nil {
255
+ return errs.Wrap(errs.CodeInternal, "create procoder return bundle", err)
256
+ }
257
+ return nil
258
+ }
259
+
260
+ func createZip(target string, files map[string]string) error {
261
+ out, err := os.Create(target)
262
+ if err != nil {
263
+ return err
264
+ }
265
+ defer out.Close()
266
+
267
+ zw := zip.NewWriter(out)
268
+ defer zw.Close()
269
+
270
+ names := make([]string, 0, len(files))
271
+ for name := range files {
272
+ names = append(names, name)
273
+ }
274
+ sort.Strings(names)
275
+
276
+ for _, name := range names {
277
+ src := files[name]
278
+ info, err := os.Stat(src)
279
+ if err != nil {
280
+ return err
281
+ }
282
+ header, err := zip.FileInfoHeader(info)
283
+ if err != nil {
284
+ return err
285
+ }
286
+ header.Name = name
287
+ header.Method = zip.Deflate
288
+
289
+ writer, err := zw.CreateHeader(header)
290
+ if err != nil {
291
+ return err
292
+ }
293
+
294
+ in, err := os.Open(src)
295
+ if err != nil {
296
+ return err
297
+ }
298
+ if _, err := io.Copy(writer, in); err != nil {
299
+ _ = in.Close()
300
+ return err
301
+ }
302
+ _ = in.Close()
303
+ }
304
+
305
+ return nil
306
+ }
307
+
308
+ func validateCleanWorktree(runner gitx.Runner) error {
309
+ result, err := runner.Run("status", "--porcelain=v1", "--untracked-files=all")
310
+ if err != nil {
311
+ return errs.Wrap(errs.CodeInternal, "read repository status", err)
312
+ }
313
+ lines := splitNonEmptyLines(result.Stdout)
314
+ if len(lines) == 0 {
315
+ return nil
316
+ }
317
+
318
+ details := []string{"Found:"}
319
+ for _, line := range lines {
320
+ details = append(details, " "+line)
321
+ }
322
+ return errs.New(
323
+ errs.CodeWorktreeDirty,
324
+ "repository has uncommitted or untracked changes",
325
+ errs.WithDetails(details...),
326
+ errs.WithHint("commit or discard these changes, then rerun ./procoder-return"),
327
+ )
328
+ }
329
+
330
+ func readExchange(path string) (exchange.Exchange, error) {
331
+ ex, err := exchange.ReadExchange(path)
332
+ if err != nil {
333
+ return exchange.Exchange{}, errs.New(
334
+ errs.CodeInvalidExchange,
335
+ "missing or invalid exchange metadata",
336
+ errs.WithDetails(
337
+ fmt.Sprintf("Path: %s", exchangeRelPath),
338
+ ),
339
+ errs.WithHint("run ./procoder-return only inside a repository created by procoder prepare"),
340
+ )
341
+ }
342
+
343
+ if err := validateExchange(ex); err != nil {
344
+ return exchange.Exchange{}, err
345
+ }
346
+ return ex, nil
347
+ }
348
+
349
+ func validateExchange(ex exchange.Exchange) error {
350
+ if ex.ExchangeID == "" {
351
+ return invalidExchange("exchange id is missing")
352
+ }
353
+ if ex.Task.RootRef == "" || ex.Task.BaseOID == "" || ex.Task.RefPrefix == "" {
354
+ return invalidExchange("task metadata is incomplete")
355
+ }
356
+ if ex.Task.RootRef != exchange.TaskRootRef(ex.ExchangeID) {
357
+ return invalidExchange("task root ref does not match exchange id")
358
+ }
359
+ expectedPrefix := exchange.TaskRefPrefix(ex.ExchangeID)
360
+ if ex.Task.RefPrefix != expectedPrefix {
361
+ return invalidExchange("task ref prefix does not match exchange id")
362
+ }
363
+ if !exchange.IsTaskRef(ex.ExchangeID, ex.Task.RootRef) {
364
+ return invalidExchange("task root ref is outside the task branch family")
365
+ }
366
+ return nil
367
+ }
368
+
369
+ func invalidExchange(problem string) error {
370
+ return errs.New(
371
+ errs.CodeInvalidExchange,
372
+ problem,
373
+ errs.WithDetails(fmt.Sprintf("Path: %s", exchangeRelPath)),
374
+ errs.WithHint("run ./procoder-return only inside a repository created by procoder prepare"),
375
+ )
376
+ }
377
+
378
+ func resolveCWD(cwd string) (string, error) {
379
+ if strings.TrimSpace(cwd) == "" {
380
+ return os.Getwd()
381
+ }
382
+ return cwd, nil
383
+ }
384
+
385
+ func resolveRepo(cwd string) (string, string, error) {
386
+ root, err := readTrimmed(gitx.NewRunner(cwd), "rev-parse", "--show-toplevel")
387
+ if err != nil {
388
+ if isNotGitRepoError(err) {
389
+ return "", "", errs.New(
390
+ errs.CodeNotGitRepo,
391
+ "current directory is not a Git worktree",
392
+ errs.WithHint("run ./procoder-return inside a prepared task package repository"),
393
+ )
394
+ }
395
+ return "", "", errs.Wrap(errs.CodeInternal, "resolve repository root", err)
396
+ }
397
+
398
+ gitDirRaw, err := readTrimmed(gitx.NewRunner(root), "rev-parse", "--git-dir")
399
+ if err != nil {
400
+ return "", "", errs.Wrap(errs.CodeInternal, "resolve repository git directory", err)
401
+ }
402
+
403
+ gitDir := gitDirRaw
404
+ if !filepath.IsAbs(gitDirRaw) {
405
+ gitDir = filepath.Join(root, gitDirRaw)
406
+ }
407
+ return root, filepath.Clean(gitDir), nil
408
+ }
409
+
410
+ func readRefSnapshot(runner gitx.Runner, namespace string) (map[string]string, error) {
411
+ result, err := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", namespace)
412
+ if err != nil {
413
+ return nil, err
414
+ }
415
+
416
+ refs := make(map[string]string)
417
+ for _, line := range splitNonEmptyLines(result.Stdout) {
418
+ fields := strings.Fields(line)
419
+ if len(fields) < 2 {
420
+ continue
421
+ }
422
+ refs[fields[0]] = fields[1]
423
+ }
424
+ return refs, nil
425
+ }
426
+
427
+ func diffRefChanges(baseline, current map[string]string) []refChange {
428
+ normalizedBaseline := baseline
429
+ if normalizedBaseline == nil {
430
+ normalizedBaseline = map[string]string{}
431
+ }
432
+ normalizedCurrent := current
433
+ if normalizedCurrent == nil {
434
+ normalizedCurrent = map[string]string{}
435
+ }
436
+
437
+ keySet := map[string]struct{}{}
438
+ for ref := range normalizedBaseline {
439
+ keySet[ref] = struct{}{}
440
+ }
441
+ for ref := range normalizedCurrent {
442
+ keySet[ref] = struct{}{}
443
+ }
444
+
445
+ refs := make([]string, 0, len(keySet))
446
+ for ref := range keySet {
447
+ refs = append(refs, ref)
448
+ }
449
+ sort.Strings(refs)
450
+
451
+ changes := make([]refChange, 0, len(refs))
452
+ for _, ref := range refs {
453
+ oldOID, oldOK := normalizedBaseline[ref]
454
+ newOID, newOK := normalizedCurrent[ref]
455
+
456
+ if ref == "" {
457
+ continue
458
+ }
459
+ if !oldOK {
460
+ oldOID = ""
461
+ }
462
+ if !newOK {
463
+ newOID = ""
464
+ }
465
+
466
+ if oldOID == newOID {
467
+ continue
468
+ }
469
+ changes = append(changes, refChange{
470
+ Ref: ref,
471
+ OldOID: oldOID,
472
+ NewOID: newOID,
473
+ })
474
+ }
475
+ return changes
476
+ }
477
+
478
+ func renderRefScopeDetails(changes []refChange, taskPrefix string) []string {
479
+ details := []string{"Changed refs:"}
480
+ for _, change := range changes {
481
+ details = append(details, fmt.Sprintf(" %s (%s -> %s)", change.Ref, displayOID(change.OldOID), displayOID(change.NewOID)))
482
+ }
483
+ details = append(details,
484
+ "Allowed refs:",
485
+ fmt.Sprintf(" %s/*", taskPrefix),
486
+ )
487
+ return details
488
+ }
489
+
490
+ func renderTagChangeDetails(changes []refChange) []string {
491
+ details := []string{"Changed tags:"}
492
+ for _, change := range changes {
493
+ details = append(details, fmt.Sprintf(" %s (%s -> %s)", change.Ref, displayOID(change.OldOID), displayOID(change.NewOID)))
494
+ }
495
+ return details
496
+ }
497
+
498
+ func appendExclude(gitDir string, patterns ...string) error {
499
+ excludePath := filepath.Join(gitDir, "info", "exclude")
500
+ if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
501
+ return err
502
+ }
503
+
504
+ existing := make(map[string]struct{})
505
+ if data, err := os.ReadFile(excludePath); err == nil {
506
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
507
+ for scanner.Scan() {
508
+ line := strings.TrimSpace(scanner.Text())
509
+ if line == "" || strings.HasPrefix(line, "#") {
510
+ continue
511
+ }
512
+ existing[line] = struct{}{}
513
+ }
514
+ if err := scanner.Err(); err != nil {
515
+ return err
516
+ }
517
+ } else if !os.IsNotExist(err) {
518
+ return err
519
+ }
520
+
521
+ f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
522
+ if err != nil {
523
+ return err
524
+ }
525
+ defer f.Close()
526
+
527
+ for _, pattern := range patterns {
528
+ pattern = strings.TrimSpace(pattern)
529
+ if pattern == "" {
530
+ continue
531
+ }
532
+ if _, ok := existing[pattern]; ok {
533
+ continue
534
+ }
535
+ if _, err := fmt.Fprintln(f, pattern); err != nil {
536
+ return err
537
+ }
538
+ }
539
+
540
+ return nil
541
+ }
542
+
543
+ func readTrimmed(runner gitx.Runner, args ...string) (string, error) {
544
+ result, err := runner.Run(args...)
545
+ if err != nil {
546
+ return "", err
547
+ }
548
+ return strings.TrimSpace(result.Stdout), nil
549
+ }
550
+
551
+ func splitNonEmptyLines(s string) []string {
552
+ raw := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
553
+ lines := make([]string, 0, len(raw))
554
+ for _, line := range raw {
555
+ if line == "" {
556
+ continue
557
+ }
558
+ lines = append(lines, line)
559
+ }
560
+ return lines
561
+ }
562
+
563
+ func isNotGitRepoError(err error) bool {
564
+ typed, ok := errs.As(err)
565
+ if !ok {
566
+ return false
567
+ }
568
+ if typed.Code != errs.CodeGitCommandFailed {
569
+ return false
570
+ }
571
+
572
+ var payload strings.Builder
573
+ payload.WriteString(strings.ToLower(typed.Message))
574
+ for _, detail := range typed.Details {
575
+ payload.WriteByte('\n')
576
+ payload.WriteString(strings.ToLower(detail))
577
+ }
578
+
579
+ all := payload.String()
580
+ return strings.Contains(all, "not a git repository") ||
581
+ strings.Contains(all, "must be run in a work tree")
582
+ }
583
+
584
+ func displayOID(oid string) string {
585
+ if strings.TrimSpace(oid) == "" {
586
+ return "(none)"
587
+ }
588
+ return oid
589
+ }