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,1069 @@
1
+ package apply
2
+
3
+ import (
4
+ "archive/zip"
5
+ "crypto/rand"
6
+ "encoding/hex"
7
+ "fmt"
8
+ "io"
9
+ "os"
10
+ "path/filepath"
11
+ "sort"
12
+ "strings"
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
+ returnJSONName = "procoder-return.json"
21
+ returnBundleName = "procoder-return.bundle"
22
+ importNamespacePrefix = "refs/procoder/import"
23
+ )
24
+
25
+ type Options struct {
26
+ CWD string
27
+ ReturnPackagePath string
28
+ Namespace string
29
+ Checkout bool
30
+ }
31
+
32
+ type Action string
33
+
34
+ const (
35
+ ActionCreate Action = "create"
36
+ ActionUpdate Action = "update"
37
+ ActionConflict Action = "conflict"
38
+ )
39
+
40
+ type Check struct {
41
+ Name string
42
+ Detail string
43
+ }
44
+
45
+ type PlanEntry struct {
46
+ Action Action
47
+ SourceRef string
48
+ ImportedRef string
49
+ DestinationRef string
50
+ OldOID string
51
+ NewOID string
52
+ CurrentOID string
53
+ Remapped bool
54
+ ConflictCode errs.Code
55
+ }
56
+
57
+ type Summary struct {
58
+ Creates int
59
+ Updates int
60
+ Conflicts int
61
+ }
62
+
63
+ type Plan struct {
64
+ ExchangeID string
65
+ ReturnPackagePath string
66
+ Namespace string
67
+ Checks []Check
68
+ Entries []PlanEntry
69
+ Summary Summary
70
+ }
71
+
72
+ type Result struct {
73
+ Plan Plan
74
+ CheckedOutRef string
75
+ }
76
+
77
+ type importedUpdate struct {
78
+ Update exchange.RefUpdate
79
+ ImportRef string
80
+ Destination string
81
+ }
82
+
83
+ type preparedRun struct {
84
+ Runner gitx.Runner
85
+ Namespace string
86
+ ReturnRecord exchange.Return
87
+ Plan Plan
88
+ ReturnPackage string
89
+ ImportedPrefix string
90
+ }
91
+
92
+ func RunDryRun(opts Options) (plan Plan, runErr error) {
93
+ prepared, cleanup, err := prepareRun(opts)
94
+ if err != nil {
95
+ return Plan{}, err
96
+ }
97
+ defer func() {
98
+ cleanupErr := cleanup()
99
+ if cleanupErr != nil && runErr == nil {
100
+ runErr = cleanupErr
101
+ }
102
+ }()
103
+
104
+ return prepared.Plan, nil
105
+ }
106
+
107
+ func Run(opts Options) (result Result, runErr error) {
108
+ prepared, cleanup, err := prepareRun(opts)
109
+ if err != nil {
110
+ return Result{}, err
111
+ }
112
+ defer func() {
113
+ cleanupErr := cleanup()
114
+ if cleanupErr != nil && runErr == nil {
115
+ runErr = cleanupErr
116
+ }
117
+ }()
118
+
119
+ if conflict, ok := actionableConflict(prepared.Plan.Entries); ok {
120
+ return Result{}, conflictToError(conflict, prepared.Namespace)
121
+ }
122
+
123
+ if err := validateCheckedOutTargets(prepared.Runner, prepared.Plan.Entries, prepared.Namespace); err != nil {
124
+ return Result{}, err
125
+ }
126
+
127
+ if err := applyPlanAtomically(prepared.Runner, prepared.Plan.Entries); err != nil {
128
+ if translated := translateWriteFailure(prepared.Runner, prepared.Plan.Entries, prepared.Namespace); translated != nil {
129
+ return Result{}, translated
130
+ }
131
+ return Result{}, errs.Wrap(errs.CodeInternal, "apply ref updates", err)
132
+ }
133
+
134
+ checkedOutRef := ""
135
+ if opts.Checkout {
136
+ defaultRef, err := defaultCheckoutRef(prepared.ReturnRecord, prepared.Namespace)
137
+ if err != nil {
138
+ return Result{}, err
139
+ }
140
+ if err := checkoutBranch(prepared.Runner, defaultRef); err != nil {
141
+ return Result{}, err
142
+ }
143
+ checkedOutRef = defaultRef
144
+ }
145
+
146
+ return Result{
147
+ Plan: prepared.Plan,
148
+ CheckedOutRef: checkedOutRef,
149
+ }, nil
150
+ }
151
+
152
+ func FormatSuccess(result Result) string {
153
+ lines := []string{
154
+ "Applied return package.",
155
+ fmt.Sprintf("Exchange: %s", result.Plan.ExchangeID),
156
+ fmt.Sprintf("Return package: %s", result.Plan.ReturnPackagePath),
157
+ fmt.Sprintf("Namespace: %s", displayNamespace(result.Plan.Namespace)),
158
+ "",
159
+ "Summary:",
160
+ fmt.Sprintf(" creates: %d", result.Plan.Summary.Creates),
161
+ fmt.Sprintf(" updates: %d", result.Plan.Summary.Updates),
162
+ fmt.Sprintf(" conflicts: %d", result.Plan.Summary.Conflicts),
163
+ }
164
+ if strings.TrimSpace(result.CheckedOutRef) != "" {
165
+ lines = append(lines, fmt.Sprintf("Checked out: %s", result.CheckedOutRef))
166
+ }
167
+ return strings.Join(lines, "\n")
168
+ }
169
+
170
+ func prepareRun(opts Options) (prepared preparedRun, cleanup func() error, err error) {
171
+ cwd, err := resolveCWD(opts.CWD)
172
+ if err != nil {
173
+ return preparedRun{}, nil, errs.Wrap(errs.CodeInternal, "resolve current working directory", err)
174
+ }
175
+
176
+ repoRoot, err := resolveRepo(cwd)
177
+ if err != nil {
178
+ return preparedRun{}, nil, err
179
+ }
180
+ runner := gitx.NewRunner(repoRoot)
181
+
182
+ returnPackagePath, err := resolveReturnPackagePath(cwd, opts.ReturnPackagePath)
183
+ if err != nil {
184
+ return preparedRun{}, nil, err
185
+ }
186
+
187
+ namespacePrefix, err := normalizeNamespacePrefix(runner, opts.Namespace)
188
+ if err != nil {
189
+ return preparedRun{}, nil, err
190
+ }
191
+
192
+ extractedDir, cleanupExtract, err := extractReturnPackage(returnPackagePath)
193
+ if err != nil {
194
+ return preparedRun{}, nil, err
195
+ }
196
+
197
+ returnRecordPath := filepath.Join(extractedDir, returnJSONName)
198
+ ret, err := readAndValidateReturnRecord(returnRecordPath)
199
+ if err != nil {
200
+ cleanupExtract()
201
+ return preparedRun{}, nil, err
202
+ }
203
+
204
+ bundlePath, err := resolveBundlePath(extractedDir, ret.BundleFile)
205
+ if err != nil {
206
+ cleanupExtract()
207
+ return preparedRun{}, nil, err
208
+ }
209
+ if err := verifyBundle(runner, bundlePath); err != nil {
210
+ cleanupExtract()
211
+ return preparedRun{}, nil, err
212
+ }
213
+
214
+ importNonce, err := randomNonce()
215
+ if err != nil {
216
+ cleanupExtract()
217
+ return preparedRun{}, nil, errs.Wrap(errs.CodeInternal, "create temporary import namespace", err)
218
+ }
219
+ importPrefix := importNamespacePrefix + "/" + importNonce
220
+
221
+ updates, err := buildImportedUpdates(ret, importPrefix, namespacePrefix)
222
+ if err != nil {
223
+ cleanupExtract()
224
+ return preparedRun{}, nil, err
225
+ }
226
+ if err := fetchBundleRefs(runner, bundlePath, updates); err != nil {
227
+ cleanupExtract()
228
+ return preparedRun{}, nil, err
229
+ }
230
+
231
+ if err := verifyImportedTips(runner, importPrefix, updates); err != nil {
232
+ _ = deleteImportRefs(runner, importPrefix)
233
+ cleanupExtract()
234
+ return preparedRun{}, nil, err
235
+ }
236
+
237
+ plan, err := buildPlan(runner, returnPackagePath, namespacePrefix, ret, updates)
238
+ if err != nil {
239
+ _ = deleteImportRefs(runner, importPrefix)
240
+ cleanupExtract()
241
+ return preparedRun{}, nil, err
242
+ }
243
+
244
+ plan.Checks = []Check{
245
+ {Name: "return package structure", Detail: "found procoder-return.json and procoder-return.bundle"},
246
+ {Name: "return record shape", Detail: "task-family refs match exchange and /task model"},
247
+ {Name: "bundle verification", Detail: "git bundle verify passed"},
248
+ {Name: "temporary import", Detail: "bundle refs fetched to internal namespace"},
249
+ {Name: "fetched ref tips", Detail: "imported OIDs exactly match procoder-return.json"},
250
+ }
251
+
252
+ cleanup = func() error {
253
+ importCleanupErr := deleteImportRefs(runner, importPrefix)
254
+ cleanupExtract()
255
+ if importCleanupErr != nil {
256
+ return errs.Wrap(errs.CodeInternal, "clean temporary import refs", importCleanupErr)
257
+ }
258
+ return nil
259
+ }
260
+
261
+ return preparedRun{
262
+ Runner: runner,
263
+ Namespace: namespacePrefix,
264
+ ReturnRecord: ret,
265
+ Plan: plan,
266
+ ReturnPackage: returnPackagePath,
267
+ ImportedPrefix: importPrefix,
268
+ }, cleanup, nil
269
+ }
270
+
271
+ func FormatDryRun(plan Plan) string {
272
+ lines := []string{
273
+ "Dry-run apply plan.",
274
+ fmt.Sprintf("Exchange: %s", plan.ExchangeID),
275
+ fmt.Sprintf("Return package: %s", plan.ReturnPackagePath),
276
+ fmt.Sprintf("Namespace: %s", displayNamespace(plan.Namespace)),
277
+ "",
278
+ "Checks:",
279
+ }
280
+ for _, check := range plan.Checks {
281
+ line := fmt.Sprintf(" OK %s", check.Name)
282
+ if strings.TrimSpace(check.Detail) != "" {
283
+ line += " - " + check.Detail
284
+ }
285
+ lines = append(lines, line)
286
+ }
287
+
288
+ lines = append(lines, "", "Ref plan:")
289
+ for _, entry := range plan.Entries {
290
+ if entry.Remapped {
291
+ lines = append(lines, fmt.Sprintf(" REMAP %s -> %s", entry.SourceRef, entry.DestinationRef))
292
+ }
293
+
294
+ switch entry.Action {
295
+ case ActionCreate:
296
+ lines = append(lines, fmt.Sprintf(" CREATE %s new=%s", entry.DestinationRef, entry.NewOID))
297
+ case ActionUpdate:
298
+ lines = append(lines, fmt.Sprintf(" UPDATE %s old=%s new=%s", entry.DestinationRef, entry.OldOID, entry.NewOID))
299
+ case ActionConflict:
300
+ lines = append(lines, formatConflictLine(entry))
301
+ default:
302
+ lines = append(lines, fmt.Sprintf(" UNKNOWN %s", entry.DestinationRef))
303
+ }
304
+ }
305
+
306
+ lines = append(lines,
307
+ "",
308
+ "Summary:",
309
+ fmt.Sprintf(" creates: %d", plan.Summary.Creates),
310
+ fmt.Sprintf(" updates: %d", plan.Summary.Updates),
311
+ fmt.Sprintf(" conflicts: %d", plan.Summary.Conflicts),
312
+ )
313
+ if plan.Summary.Conflicts > 0 {
314
+ if plan.Namespace == "" {
315
+ lines = append(lines, " result: conflicts detected; consider rerunning with --namespace <prefix>")
316
+ } else {
317
+ lines = append(lines, " result: conflicts detected in namespace mapping")
318
+ }
319
+ } else {
320
+ lines = append(lines, " result: no conflicts detected")
321
+ }
322
+ lines = append(lines, "No refs were updated (dry-run).")
323
+
324
+ return strings.Join(lines, "\n")
325
+ }
326
+
327
+ func formatConflictLine(entry PlanEntry) string {
328
+ currentOID := displayOID(entry.CurrentOID)
329
+ switch entry.ConflictCode {
330
+ case errs.CodeBranchMoved:
331
+ return fmt.Sprintf(
332
+ " CONFLICT BRANCH_MOVED %s expected-old=%s current=%s incoming=%s",
333
+ entry.DestinationRef,
334
+ displayOID(entry.OldOID),
335
+ currentOID,
336
+ displayOID(entry.NewOID),
337
+ )
338
+ case errs.CodeRefExists:
339
+ return fmt.Sprintf(
340
+ " CONFLICT REF_EXISTS %s existing=%s incoming=%s",
341
+ entry.DestinationRef,
342
+ currentOID,
343
+ displayOID(entry.NewOID),
344
+ )
345
+ default:
346
+ return fmt.Sprintf(" CONFLICT %s %s", entry.ConflictCode, entry.DestinationRef)
347
+ }
348
+ }
349
+
350
+ func applyPlanAtomically(runner gitx.Runner, entries []PlanEntry) error {
351
+ commands := make([]string, 0, len(entries)+3)
352
+ commands = append(commands, "start")
353
+ for _, entry := range entries {
354
+ switch entry.Action {
355
+ case ActionCreate:
356
+ commands = append(commands, fmt.Sprintf("create %s %s", entry.DestinationRef, entry.NewOID))
357
+ case ActionUpdate:
358
+ commands = append(commands, fmt.Sprintf("update %s %s %s", entry.DestinationRef, entry.NewOID, entry.OldOID))
359
+ }
360
+ }
361
+ commands = append(commands, "prepare", "commit", "")
362
+
363
+ input := strings.Join(commands, "\n")
364
+ if _, err := runner.RunWithInput(input, "update-ref", "--stdin"); err != nil {
365
+ return err
366
+ }
367
+ return nil
368
+ }
369
+
370
+ func actionableConflict(entries []PlanEntry) (PlanEntry, bool) {
371
+ for _, entry := range entries {
372
+ if entry.Action == ActionConflict {
373
+ return entry, true
374
+ }
375
+ }
376
+ return PlanEntry{}, false
377
+ }
378
+
379
+ func conflictToError(entry PlanEntry, namespace string) error {
380
+ switch entry.ConflictCode {
381
+ case errs.CodeBranchMoved:
382
+ return errs.New(
383
+ errs.CodeBranchMoved,
384
+ fmt.Sprintf("cannot update %s", entry.DestinationRef),
385
+ errs.WithDetails(
386
+ fmt.Sprintf("Expected old OID: %s", displayOID(entry.OldOID)),
387
+ fmt.Sprintf("Current local OID: %s", displayOID(entry.CurrentOID)),
388
+ fmt.Sprintf("Incoming OID: %s", displayOID(entry.NewOID)),
389
+ ),
390
+ errs.WithHint(namespaceHint(namespace)),
391
+ )
392
+ case errs.CodeRefExists:
393
+ return errs.New(
394
+ errs.CodeRefExists,
395
+ fmt.Sprintf("cannot create %s because the destination ref already exists", entry.DestinationRef),
396
+ errs.WithDetails(
397
+ fmt.Sprintf("Existing local OID: %s", displayOID(entry.CurrentOID)),
398
+ fmt.Sprintf("Incoming OID: %s", displayOID(entry.NewOID)),
399
+ ),
400
+ errs.WithHint(namespaceHint(namespace)),
401
+ )
402
+ default:
403
+ return errs.New(
404
+ errs.CodeInternal,
405
+ fmt.Sprintf("cannot apply %s due to an unsupported conflict type", entry.DestinationRef),
406
+ )
407
+ }
408
+ }
409
+
410
+ func namespaceHint(namespace string) string {
411
+ if strings.TrimSpace(namespace) == "" {
412
+ return "rerun with --namespace procoder-import to import under a new ref prefix"
413
+ }
414
+ return "rerun with a different --namespace prefix to import under a new ref prefix"
415
+ }
416
+
417
+ func validateCheckedOutTargets(runner gitx.Runner, entries []PlanEntry, namespace string) error {
418
+ headRef, err := readCurrentHeadRef(runner)
419
+ if err != nil {
420
+ return err
421
+ }
422
+ if headRef == "" {
423
+ return nil
424
+ }
425
+
426
+ for _, entry := range entries {
427
+ if entry.Action != ActionUpdate {
428
+ continue
429
+ }
430
+ if entry.DestinationRef != headRef {
431
+ continue
432
+ }
433
+ return errs.New(
434
+ errs.CodeTargetBranchCheckedOut,
435
+ fmt.Sprintf("cannot update checked-out branch %s", entry.DestinationRef),
436
+ errs.WithDetails(fmt.Sprintf("Current HEAD: %s", headRef)),
437
+ errs.WithHint(namespaceHint(namespace)),
438
+ )
439
+ }
440
+ return nil
441
+ }
442
+
443
+ func readCurrentHeadRef(runner gitx.Runner) (string, error) {
444
+ result, err := runner.Run("symbolic-ref", "--quiet", "HEAD")
445
+ if err == nil {
446
+ return strings.TrimSpace(result.Stdout), nil
447
+ }
448
+ if result.ExitCode == 1 {
449
+ return "", nil
450
+ }
451
+ return "", errs.Wrap(errs.CodeInternal, "resolve current checked-out branch", err)
452
+ }
453
+
454
+ func defaultCheckoutRef(ret exchange.Return, namespacePrefix string) (string, error) {
455
+ return destinationRef(ret.Task.RootRef, ret.ExchangeID, namespacePrefix)
456
+ }
457
+
458
+ func checkoutBranch(runner gitx.Runner, ref string) error {
459
+ if !strings.HasPrefix(ref, "refs/heads/") {
460
+ return errs.New(
461
+ errs.CodeInternal,
462
+ fmt.Sprintf("cannot checkout non-branch ref %s", ref),
463
+ )
464
+ }
465
+
466
+ shortRef := strings.TrimPrefix(ref, "refs/heads/")
467
+ if _, err := runner.Run("checkout", "--quiet", shortRef); err != nil {
468
+ return errs.Wrap(
469
+ errs.CodeInternal,
470
+ fmt.Sprintf("check out updated branch %s", ref),
471
+ err,
472
+ )
473
+ }
474
+ return nil
475
+ }
476
+
477
+ func translateWriteFailure(runner gitx.Runner, entries []PlanEntry, namespace string) error {
478
+ if err := validateCheckedOutTargets(runner, entries, namespace); err != nil {
479
+ return err
480
+ }
481
+
482
+ for _, entry := range entries {
483
+ if entry.Action != ActionCreate && entry.Action != ActionUpdate {
484
+ continue
485
+ }
486
+
487
+ currentOID, exists, err := resolveRefOID(runner, entry.DestinationRef)
488
+ if err != nil {
489
+ return nil
490
+ }
491
+
492
+ if entry.Action == ActionCreate {
493
+ if exists {
494
+ entry.CurrentOID = currentOID
495
+ entry.ConflictCode = errs.CodeRefExists
496
+ return conflictToError(entry, namespace)
497
+ }
498
+ continue
499
+ }
500
+
501
+ if !exists || currentOID != entry.OldOID {
502
+ entry.CurrentOID = currentOID
503
+ entry.ConflictCode = errs.CodeBranchMoved
504
+ return conflictToError(entry, namespace)
505
+ }
506
+ }
507
+
508
+ return nil
509
+ }
510
+
511
+ func resolveCWD(cwd string) (string, error) {
512
+ if strings.TrimSpace(cwd) == "" {
513
+ return os.Getwd()
514
+ }
515
+ return cwd, nil
516
+ }
517
+
518
+ func resolveRepo(cwd string) (string, error) {
519
+ root, err := readTrimmed(gitx.NewRunner(cwd), "rev-parse", "--show-toplevel")
520
+ if err != nil {
521
+ if isNotGitRepoError(err) {
522
+ return "", errs.New(
523
+ errs.CodeNotGitRepo,
524
+ "current directory is not a Git worktree",
525
+ errs.WithHint("run `procoder apply` inside a Git repository"),
526
+ )
527
+ }
528
+ return "", errs.Wrap(errs.CodeInternal, "resolve repository root", err)
529
+ }
530
+ return root, nil
531
+ }
532
+
533
+ func resolveReturnPackagePath(cwd, packagePath string) (string, error) {
534
+ packagePath = strings.TrimSpace(packagePath)
535
+ if packagePath == "" {
536
+ return "", errs.New(
537
+ errs.CodeUnknownCommand,
538
+ "missing return package path for `procoder apply`",
539
+ errs.WithHint("run `procoder apply --help`"),
540
+ )
541
+ }
542
+
543
+ if !filepath.IsAbs(packagePath) {
544
+ packagePath = filepath.Join(cwd, packagePath)
545
+ }
546
+ absPath, err := filepath.Abs(packagePath)
547
+ if err != nil {
548
+ return "", errs.Wrap(errs.CodeInternal, "resolve return package path", err)
549
+ }
550
+
551
+ info, err := os.Stat(absPath)
552
+ if err != nil {
553
+ if os.IsNotExist(err) {
554
+ return "", errs.New(
555
+ errs.CodeInvalidReturnPackage,
556
+ "return package path does not exist",
557
+ errs.WithDetails("Path: "+absPath),
558
+ )
559
+ }
560
+ return "", errs.Wrap(errs.CodeInternal, "stat return package path", err)
561
+ }
562
+ if info.IsDir() {
563
+ return "", errs.New(
564
+ errs.CodeInvalidReturnPackage,
565
+ "return package path must be a zip file",
566
+ errs.WithDetails("Path: "+absPath),
567
+ )
568
+ }
569
+ return absPath, nil
570
+ }
571
+
572
+ func normalizeNamespacePrefix(runner gitx.Runner, namespace string) (string, error) {
573
+ raw := strings.TrimSpace(namespace)
574
+ if raw == "" {
575
+ return "", nil
576
+ }
577
+
578
+ prefix := strings.TrimPrefix(raw, "refs/heads/")
579
+ prefix = strings.Trim(prefix, "/")
580
+ if prefix == "" {
581
+ return "", errs.New(
582
+ errs.CodeUnknownCommand,
583
+ fmt.Sprintf("invalid namespace prefix %q", raw),
584
+ errs.WithHint("use a Git-valid namespace prefix, for example `procoder-import`"),
585
+ )
586
+ }
587
+
588
+ candidate := "refs/heads/" + prefix + "/task"
589
+ if _, err := runner.Run("check-ref-format", candidate); err != nil {
590
+ return "", errs.New(
591
+ errs.CodeUnknownCommand,
592
+ fmt.Sprintf("invalid namespace prefix %q", raw),
593
+ errs.WithHint("use a Git-valid namespace prefix, for example `procoder-import`"),
594
+ )
595
+ }
596
+
597
+ return prefix, nil
598
+ }
599
+
600
+ func extractReturnPackage(returnPackagePath string) (string, func(), error) {
601
+ reader, err := zip.OpenReader(returnPackagePath)
602
+ if err != nil {
603
+ return "", nil, errs.New(
604
+ errs.CodeInvalidReturnPackage,
605
+ "return package is not a valid zip archive",
606
+ errs.WithDetails("Path: "+returnPackagePath),
607
+ )
608
+ }
609
+ defer reader.Close()
610
+
611
+ destDir, err := os.MkdirTemp("", "procoder-apply-*")
612
+ if err != nil {
613
+ return "", nil, errs.Wrap(errs.CodeInternal, "create temporary return package directory", err)
614
+ }
615
+
616
+ cleanup := func() {
617
+ _ = os.RemoveAll(destDir)
618
+ }
619
+
620
+ for _, file := range reader.File {
621
+ if err := extractZipEntry(destDir, file); err != nil {
622
+ cleanup()
623
+ return "", nil, err
624
+ }
625
+ }
626
+
627
+ if _, err := os.Stat(filepath.Join(destDir, returnJSONName)); err != nil {
628
+ cleanup()
629
+ if os.IsNotExist(err) {
630
+ return "", nil, errs.New(
631
+ errs.CodeInvalidReturnPackage,
632
+ "missing procoder-return.json in return package",
633
+ errs.WithDetails("Path: "+returnPackagePath),
634
+ )
635
+ }
636
+ return "", nil, errs.Wrap(errs.CodeInternal, "inspect extracted return metadata", err)
637
+ }
638
+
639
+ return destDir, cleanup, nil
640
+ }
641
+
642
+ func extractZipEntry(destDir string, file *zip.File) error {
643
+ cleanName := filepath.Clean(file.Name)
644
+ if strings.HasPrefix(cleanName, "../") || cleanName == ".." || filepath.IsAbs(file.Name) {
645
+ return errs.New(
646
+ errs.CodeInvalidReturnPackage,
647
+ "return package contains an invalid zip entry path",
648
+ errs.WithDetails("Entry: "+file.Name),
649
+ )
650
+ }
651
+
652
+ targetPath := filepath.Join(destDir, cleanName)
653
+ safePrefix := filepath.Clean(destDir) + string(os.PathSeparator)
654
+ if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
655
+ return errs.New(
656
+ errs.CodeInvalidReturnPackage,
657
+ "return package contains an invalid zip entry path",
658
+ errs.WithDetails("Entry: "+file.Name),
659
+ )
660
+ }
661
+
662
+ if file.FileInfo().IsDir() {
663
+ if err := os.MkdirAll(targetPath, 0o755); err != nil {
664
+ return errs.Wrap(errs.CodeInternal, "create extracted return package directory", err)
665
+ }
666
+ return nil
667
+ }
668
+
669
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
670
+ return errs.Wrap(errs.CodeInternal, "create extracted return package directory", err)
671
+ }
672
+
673
+ in, err := file.Open()
674
+ if err != nil {
675
+ return errs.Wrap(errs.CodeInternal, "open zip entry", err)
676
+ }
677
+ defer in.Close()
678
+
679
+ mode := file.Mode()
680
+ if mode == 0 {
681
+ mode = 0o644
682
+ }
683
+ out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
684
+ if err != nil {
685
+ return errs.Wrap(errs.CodeInternal, "create extracted file", err)
686
+ }
687
+ defer out.Close()
688
+
689
+ if _, err := io.Copy(out, in); err != nil {
690
+ return errs.Wrap(errs.CodeInternal, "copy extracted zip entry", err)
691
+ }
692
+ return nil
693
+ }
694
+
695
+ func readAndValidateReturnRecord(returnRecordPath string) (exchange.Return, error) {
696
+ ret, err := exchange.ReadReturn(returnRecordPath)
697
+ if err != nil {
698
+ return exchange.Return{}, errs.New(
699
+ errs.CodeInvalidReturnPackage,
700
+ "missing or invalid procoder-return.json",
701
+ errs.WithDetails("Path: "+returnRecordPath),
702
+ )
703
+ }
704
+ if err := validateReturnRecord(ret); err != nil {
705
+ return exchange.Return{}, err
706
+ }
707
+ return ret, nil
708
+ }
709
+
710
+ func validateReturnRecord(ret exchange.Return) error {
711
+ if strings.TrimSpace(ret.Protocol) != exchange.ReturnProtocolV1 {
712
+ return invalidReturn("procoder-return.json has an unsupported protocol")
713
+ }
714
+ if exchange.TaskRootRef(ret.ExchangeID) == "" {
715
+ return invalidReturn("procoder-return.json has an invalid exchange_id")
716
+ }
717
+ if ret.Task.RootRef != exchange.TaskRootRef(ret.ExchangeID) {
718
+ return invalidReturn("procoder-return.json task.root_ref does not match exchange_id /task model")
719
+ }
720
+ if strings.TrimSpace(ret.Task.BaseOID) == "" {
721
+ return invalidReturn("procoder-return.json task.base_oid is required")
722
+ }
723
+ if strings.TrimSpace(ret.BundleFile) == "" {
724
+ return invalidReturn("procoder-return.json bundle_file is required")
725
+ }
726
+ if filepath.Base(ret.BundleFile) != ret.BundleFile {
727
+ return invalidReturn("procoder-return.json bundle_file must be a file name")
728
+ }
729
+ if ret.BundleFile != returnBundleName {
730
+ return invalidReturn(fmt.Sprintf("procoder-return.json bundle_file must be %q", returnBundleName))
731
+ }
732
+ if len(ret.Updates) == 0 {
733
+ return invalidReturn("procoder-return.json updates must not be empty")
734
+ }
735
+
736
+ taskPrefix := exchange.TaskRefPrefix(ret.ExchangeID)
737
+ seen := make(map[string]struct{}, len(ret.Updates))
738
+ for _, update := range ret.Updates {
739
+ if strings.TrimSpace(update.Ref) == "" {
740
+ return invalidReturn("procoder-return.json includes an update with an empty ref")
741
+ }
742
+ if !exchange.IsTaskRef(ret.ExchangeID, update.Ref) || !strings.HasPrefix(update.Ref, taskPrefix+"/") {
743
+ return invalidReturn("procoder-return.json includes a ref outside the allowed task branch family")
744
+ }
745
+ if strings.TrimSpace(update.NewOID) == "" {
746
+ return invalidReturn(fmt.Sprintf("procoder-return.json update for %s is missing new_oid", update.Ref))
747
+ }
748
+ if _, exists := seen[update.Ref]; exists {
749
+ return invalidReturn(fmt.Sprintf("procoder-return.json contains duplicate ref updates for %s", update.Ref))
750
+ }
751
+ seen[update.Ref] = struct{}{}
752
+ }
753
+
754
+ return nil
755
+ }
756
+
757
+ func invalidReturn(problem string) error {
758
+ return errs.New(errs.CodeInvalidReturnPackage, problem)
759
+ }
760
+
761
+ func resolveBundlePath(extractedDir, bundleFile string) (string, error) {
762
+ bundlePath := filepath.Join(extractedDir, bundleFile)
763
+ if _, err := os.Stat(bundlePath); err != nil {
764
+ if os.IsNotExist(err) {
765
+ return "", errs.New(
766
+ errs.CodeInvalidReturnPackage,
767
+ "return package is missing procoder-return.bundle",
768
+ errs.WithDetails("Path: "+bundlePath),
769
+ )
770
+ }
771
+ return "", errs.Wrap(errs.CodeInternal, "inspect extracted return bundle", err)
772
+ }
773
+ return bundlePath, nil
774
+ }
775
+
776
+ func verifyBundle(runner gitx.Runner, bundlePath string) error {
777
+ result, err := runner.Run("bundle", "verify", bundlePath)
778
+ if err == nil {
779
+ return nil
780
+ }
781
+
782
+ details := []string{"Bundle: " + bundlePath}
783
+ if stderr := strings.TrimSpace(result.Stderr); stderr != "" {
784
+ details = append(details, "Git output: "+stderr)
785
+ }
786
+ return errs.New(
787
+ errs.CodeBundleVerifyFailed,
788
+ "return bundle verification failed",
789
+ errs.WithDetails(details...),
790
+ errs.WithHint("ensure this return package matches the local exchange repository and retry"),
791
+ )
792
+ }
793
+
794
+ func randomNonce() (string, error) {
795
+ buf := make([]byte, 4)
796
+ if _, err := io.ReadFull(rand.Reader, buf); err != nil {
797
+ return "", err
798
+ }
799
+ return hex.EncodeToString(buf), nil
800
+ }
801
+
802
+ func buildImportedUpdates(ret exchange.Return, importPrefix, namespacePrefix string) ([]importedUpdate, error) {
803
+ updates := make([]exchange.RefUpdate, len(ret.Updates))
804
+ copy(updates, ret.Updates)
805
+ sort.Slice(updates, func(i, j int) bool { return updates[i].Ref < updates[j].Ref })
806
+
807
+ mappings := make([]importedUpdate, 0, len(updates))
808
+ for _, update := range updates {
809
+ destination, err := destinationRef(update.Ref, ret.ExchangeID, namespacePrefix)
810
+ if err != nil {
811
+ return nil, err
812
+ }
813
+ mappings = append(mappings, importedUpdate{
814
+ Update: update,
815
+ ImportRef: importPrefix + "/" + strings.TrimPrefix(update.Ref, "refs/"),
816
+ Destination: destination,
817
+ })
818
+ }
819
+ return mappings, nil
820
+ }
821
+
822
+ func destinationRef(sourceRef, exchangeID, namespacePrefix string) (string, error) {
823
+ if namespacePrefix == "" {
824
+ return sourceRef, nil
825
+ }
826
+
827
+ taskPrefix := exchange.TaskRefPrefix(exchangeID)
828
+ if !strings.HasPrefix(sourceRef, taskPrefix+"/") {
829
+ return "", errs.New(
830
+ errs.CodeInvalidReturnPackage,
831
+ "return package includes a ref outside the allowed task branch family",
832
+ errs.WithDetails("Ref: "+sourceRef),
833
+ )
834
+ }
835
+ suffix := strings.TrimPrefix(sourceRef, taskPrefix)
836
+ return "refs/heads/" + namespacePrefix + "/" + exchangeID + suffix, nil
837
+ }
838
+
839
+ func fetchBundleRefs(runner gitx.Runner, bundlePath string, updates []importedUpdate) error {
840
+ args := []string{"fetch", "--no-tags", bundlePath}
841
+ for _, update := range updates {
842
+ args = append(args, fmt.Sprintf("+%s:%s", update.Update.Ref, update.ImportRef))
843
+ }
844
+
845
+ if _, err := runner.Run(args...); err != nil {
846
+ return errs.New(
847
+ errs.CodeInvalidReturnPackage,
848
+ "failed to fetch expected refs from procoder-return.bundle",
849
+ errs.WithDetails("Bundle: "+bundlePath),
850
+ )
851
+ }
852
+ return nil
853
+ }
854
+
855
+ func verifyImportedTips(runner gitx.Runner, importPrefix string, updates []importedUpdate) error {
856
+ fetched, err := readRefSnapshot(runner, importPrefix)
857
+ if err != nil {
858
+ return errs.Wrap(errs.CodeInternal, "read imported refs", err)
859
+ }
860
+
861
+ if len(fetched) != len(updates) {
862
+ return errs.New(
863
+ errs.CodeInvalidReturnPackage,
864
+ "fetched ref set does not match procoder-return.json",
865
+ errs.WithDetails(
866
+ fmt.Sprintf("Expected refs: %d", len(updates)),
867
+ fmt.Sprintf("Fetched refs: %d", len(fetched)),
868
+ ),
869
+ )
870
+ }
871
+
872
+ for _, update := range updates {
873
+ gotOID, ok := fetched[update.ImportRef]
874
+ if !ok {
875
+ return errs.New(
876
+ errs.CodeInvalidReturnPackage,
877
+ "fetched refs do not match procoder-return.json",
878
+ errs.WithDetails("Missing import ref: "+update.ImportRef),
879
+ )
880
+ }
881
+ if gotOID != update.Update.NewOID {
882
+ return errs.New(
883
+ errs.CodeInvalidReturnPackage,
884
+ "fetched ref tip does not match procoder-return.json",
885
+ errs.WithDetails(
886
+ "Ref: "+update.Update.Ref,
887
+ "Expected OID: "+update.Update.NewOID,
888
+ "Fetched OID: "+gotOID,
889
+ ),
890
+ )
891
+ }
892
+ }
893
+
894
+ return nil
895
+ }
896
+
897
+ func buildPlan(runner gitx.Runner, returnPackagePath, namespacePrefix string, ret exchange.Return, updates []importedUpdate) (Plan, error) {
898
+ plan := Plan{
899
+ ExchangeID: ret.ExchangeID,
900
+ ReturnPackagePath: returnPackagePath,
901
+ Namespace: namespacePrefix,
902
+ Entries: make([]PlanEntry, 0, len(updates)),
903
+ }
904
+
905
+ for _, update := range updates {
906
+ currentOID, exists, err := resolveRefOID(runner, update.Destination)
907
+ if err != nil {
908
+ return Plan{}, err
909
+ }
910
+
911
+ entry := PlanEntry{
912
+ SourceRef: update.Update.Ref,
913
+ ImportedRef: update.ImportRef,
914
+ DestinationRef: update.Destination,
915
+ OldOID: update.Update.OldOID,
916
+ NewOID: update.Update.NewOID,
917
+ CurrentOID: currentOID,
918
+ Remapped: update.Destination != update.Update.Ref,
919
+ }
920
+
921
+ if strings.TrimSpace(update.Update.OldOID) == "" {
922
+ if exists {
923
+ entry.Action = ActionConflict
924
+ entry.ConflictCode = errs.CodeRefExists
925
+ plan.Summary.Conflicts++
926
+ } else {
927
+ entry.Action = ActionCreate
928
+ plan.Summary.Creates++
929
+ }
930
+ } else {
931
+ if !exists {
932
+ if entry.Remapped {
933
+ entry.Action = ActionCreate
934
+ plan.Summary.Creates++
935
+ } else {
936
+ entry.Action = ActionConflict
937
+ entry.ConflictCode = errs.CodeBranchMoved
938
+ plan.Summary.Conflicts++
939
+ }
940
+ } else if currentOID != update.Update.OldOID {
941
+ entry.Action = ActionConflict
942
+ entry.ConflictCode = errs.CodeBranchMoved
943
+ plan.Summary.Conflicts++
944
+ } else {
945
+ entry.Action = ActionUpdate
946
+ plan.Summary.Updates++
947
+ }
948
+ }
949
+
950
+ plan.Entries = append(plan.Entries, entry)
951
+ }
952
+
953
+ return plan, nil
954
+ }
955
+
956
+ func resolveRefOID(runner gitx.Runner, ref string) (oid string, exists bool, err error) {
957
+ result, runErr := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", ref)
958
+ if runErr != nil {
959
+ return "", false, errs.Wrap(errs.CodeInternal, fmt.Sprintf("resolve local ref %s", ref), runErr)
960
+ }
961
+
962
+ for _, line := range splitNonEmptyLines(result.Stdout) {
963
+ fields := strings.Fields(line)
964
+ if len(fields) < 2 {
965
+ continue
966
+ }
967
+ if fields[0] == ref {
968
+ return fields[1], true, nil
969
+ }
970
+ }
971
+ return "", false, nil
972
+ }
973
+
974
+ func deleteImportRefs(runner gitx.Runner, importPrefix string) error {
975
+ refs, err := readRefNames(runner, importPrefix)
976
+ if err != nil {
977
+ return err
978
+ }
979
+
980
+ for _, ref := range refs {
981
+ if _, err := runner.Run("update-ref", "-d", ref); err != nil {
982
+ return err
983
+ }
984
+ }
985
+ return nil
986
+ }
987
+
988
+ func readRefNames(runner gitx.Runner, namespace string) ([]string, error) {
989
+ result, err := runner.Run("for-each-ref", "--format=%(refname)", namespace)
990
+ if err != nil {
991
+ return nil, errs.Wrap(errs.CodeInternal, "read refs", err)
992
+ }
993
+
994
+ lines := splitNonEmptyLines(result.Stdout)
995
+ sort.Strings(lines)
996
+ return lines, nil
997
+ }
998
+
999
+ func readRefSnapshot(runner gitx.Runner, namespace string) (map[string]string, error) {
1000
+ result, err := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", namespace)
1001
+ if err != nil {
1002
+ return nil, err
1003
+ }
1004
+
1005
+ refs := make(map[string]string)
1006
+ for _, line := range splitNonEmptyLines(result.Stdout) {
1007
+ fields := strings.Fields(line)
1008
+ if len(fields) < 2 {
1009
+ continue
1010
+ }
1011
+ refs[fields[0]] = fields[1]
1012
+ }
1013
+ return refs, nil
1014
+ }
1015
+
1016
+ func readTrimmed(runner gitx.Runner, args ...string) (string, error) {
1017
+ result, err := runner.Run(args...)
1018
+ if err != nil {
1019
+ return "", err
1020
+ }
1021
+ return strings.TrimSpace(result.Stdout), nil
1022
+ }
1023
+
1024
+ func splitNonEmptyLines(s string) []string {
1025
+ raw := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
1026
+ lines := make([]string, 0, len(raw))
1027
+ for _, line := range raw {
1028
+ if line == "" {
1029
+ continue
1030
+ }
1031
+ lines = append(lines, line)
1032
+ }
1033
+ return lines
1034
+ }
1035
+
1036
+ func isNotGitRepoError(err error) bool {
1037
+ typed, ok := errs.As(err)
1038
+ if !ok {
1039
+ return false
1040
+ }
1041
+ if typed.Code != errs.CodeGitCommandFailed {
1042
+ return false
1043
+ }
1044
+
1045
+ var payload strings.Builder
1046
+ payload.WriteString(strings.ToLower(typed.Message))
1047
+ for _, detail := range typed.Details {
1048
+ payload.WriteByte('\n')
1049
+ payload.WriteString(strings.ToLower(detail))
1050
+ }
1051
+
1052
+ all := payload.String()
1053
+ return strings.Contains(all, "not a git repository") ||
1054
+ strings.Contains(all, "must be run in a work tree")
1055
+ }
1056
+
1057
+ func displayOID(oid string) string {
1058
+ if strings.TrimSpace(oid) == "" {
1059
+ return "(none)"
1060
+ }
1061
+ return oid
1062
+ }
1063
+
1064
+ func displayNamespace(namespace string) string {
1065
+ if strings.TrimSpace(namespace) == "" {
1066
+ return "(default task-family refs)"
1067
+ }
1068
+ return "refs/heads/" + namespace + "/<exchange-id>/..."
1069
+ }