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,794 @@
1
+ package apply
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "encoding/json"
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/returnpkg"
21
+ "github.com/amxv/procoder/internal/testutil/gitrepo"
22
+ )
23
+
24
+ func TestRunDryRunHappyPathWithRealReturnPackage(t *testing.T) {
25
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
26
+
27
+ plan, err := RunDryRun(Options{
28
+ CWD: fixture.sourceRepo.Dir,
29
+ ReturnPackagePath: fixture.returnPackagePath,
30
+ })
31
+ if err != nil {
32
+ t.Fatalf("RunDryRun returned error: %v", err)
33
+ }
34
+
35
+ if plan.ExchangeID != fixture.exchange.ExchangeID {
36
+ t.Fatalf("unexpected exchange id: got %q want %q", plan.ExchangeID, fixture.exchange.ExchangeID)
37
+ }
38
+ if plan.Summary.Updates != 1 || plan.Summary.Creates != 0 || plan.Summary.Conflicts != 0 {
39
+ t.Fatalf("unexpected summary: %#v", plan.Summary)
40
+ }
41
+
42
+ entry, ok := findEntry(plan.Entries, fixture.exchange.Task.RootRef)
43
+ if !ok {
44
+ t.Fatalf("expected task root entry in plan: %#v", plan.Entries)
45
+ }
46
+ if entry.Action != ActionUpdate {
47
+ t.Fatalf("expected update action, got %s", entry.Action)
48
+ }
49
+ if entry.OldOID != fixture.returnRecord.Updates[0].OldOID {
50
+ t.Fatalf("unexpected old oid: got %q want %q", entry.OldOID, fixture.returnRecord.Updates[0].OldOID)
51
+ }
52
+ if entry.NewOID != fixture.returnRecord.Updates[0].NewOID {
53
+ t.Fatalf("unexpected new oid: got %q want %q", entry.NewOID, fixture.returnRecord.Updates[0].NewOID)
54
+ }
55
+
56
+ formatted := FormatDryRun(plan)
57
+ for _, fragment := range []string{
58
+ "Dry-run apply plan.",
59
+ "Checks:",
60
+ "Ref plan:",
61
+ "Summary:",
62
+ "UPDATE",
63
+ "No refs were updated (dry-run).",
64
+ } {
65
+ if !strings.Contains(formatted, fragment) {
66
+ t.Fatalf("expected formatted plan to contain %q, got:\n%s", fragment, formatted)
67
+ }
68
+ }
69
+
70
+ importRefs := strings.TrimSpace(runGit(t, fixture.sourceRepo.Dir, "for-each-ref", "--format=%(refname)", "refs/procoder/import"))
71
+ if importRefs != "" {
72
+ t.Fatalf("expected temporary import refs to be cleaned up, got:\n%s", importRefs)
73
+ }
74
+ }
75
+
76
+ func TestRunDryRunFailsInvalidReturnPackage(t *testing.T) {
77
+ repo := gitrepo.New(t)
78
+ invalidPath := filepath.Join(t.TempDir(), "broken.zip")
79
+ if err := os.WriteFile(invalidPath, []byte("not-a-zip"), 0o644); err != nil {
80
+ t.Fatalf("write invalid zip failed: %v", err)
81
+ }
82
+
83
+ _, err := RunDryRun(Options{
84
+ CWD: repo.Dir,
85
+ ReturnPackagePath: invalidPath,
86
+ })
87
+ assertErrorContains(t, err, errs.CodeInvalidReturnPackage, "not a valid zip archive")
88
+ }
89
+
90
+ func TestRunDryRunFailsInvalidJSON(t *testing.T) {
91
+ repo := gitrepo.New(t)
92
+ invalidZip := filepath.Join(t.TempDir(), "invalid-json.zip")
93
+ writeZip(t, invalidZip, map[string][]byte{
94
+ returnJSONName: []byte("{invalid json\n"),
95
+ returnBundleName: []byte("bundle placeholder"),
96
+ })
97
+
98
+ _, err := RunDryRun(Options{
99
+ CWD: repo.Dir,
100
+ ReturnPackagePath: invalidZip,
101
+ })
102
+ assertErrorContains(t, err, errs.CodeInvalidReturnPackage, "missing or invalid procoder-return.json")
103
+ }
104
+
105
+ func TestRunDryRunFailsBundleVerification(t *testing.T) {
106
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
107
+
108
+ tamperedPath := rewriteReturnPackage(t, fixture.returnPackagePath, func(files map[string][]byte) {
109
+ files[returnBundleName] = []byte("this is not a git bundle")
110
+ })
111
+
112
+ _, err := RunDryRun(Options{
113
+ CWD: fixture.sourceRepo.Dir,
114
+ ReturnPackagePath: tamperedPath,
115
+ })
116
+ assertErrorContains(t, err, errs.CodeBundleVerifyFailed, "return bundle verification failed")
117
+ }
118
+
119
+ func TestRunDryRunFailsMismatchedFetchedOID(t *testing.T) {
120
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
121
+
122
+ tamperedPath := rewriteReturnPackage(t, fixture.returnPackagePath, func(files map[string][]byte) {
123
+ var ret exchange.Return
124
+ if err := json.Unmarshal(files[returnJSONName], &ret); err != nil {
125
+ t.Fatalf("decode return json failed: %v", err)
126
+ }
127
+ if len(ret.Updates) == 0 {
128
+ t.Fatalf("expected updates in return json")
129
+ }
130
+ ret.Updates[0].NewOID = strings.Repeat("f", 40)
131
+ encoded, err := json.MarshalIndent(ret, "", " ")
132
+ if err != nil {
133
+ t.Fatalf("encode return json failed: %v", err)
134
+ }
135
+ files[returnJSONName] = append(encoded, '\n')
136
+ })
137
+
138
+ _, err := RunDryRun(Options{
139
+ CWD: fixture.sourceRepo.Dir,
140
+ ReturnPackagePath: tamperedPath,
141
+ })
142
+ assertErrorContains(t, err, errs.CodeInvalidReturnPackage, "fetched ref tip does not match procoder-return.json")
143
+ }
144
+
145
+ func TestRunDryRunNamespaceMappingInPlan(t *testing.T) {
146
+ fixture := setupReturnFixture(t, mutateSiblingTaskBranchCommit)
147
+
148
+ const namespace = "procoder-import"
149
+ plan, err := RunDryRun(Options{
150
+ CWD: fixture.sourceRepo.Dir,
151
+ ReturnPackagePath: fixture.returnPackagePath,
152
+ Namespace: namespace,
153
+ })
154
+ if err != nil {
155
+ t.Fatalf("RunDryRun returned error: %v", err)
156
+ }
157
+
158
+ if len(plan.Entries) == 0 {
159
+ t.Fatalf("expected plan entries")
160
+ }
161
+ for _, entry := range plan.Entries {
162
+ if !entry.Remapped {
163
+ t.Fatalf("expected remapped entry in namespace mode: %#v", entry)
164
+ }
165
+ wantPrefix := "refs/heads/" + namespace + "/" + fixture.exchange.ExchangeID + "/"
166
+ if !strings.HasPrefix(entry.DestinationRef, wantPrefix) {
167
+ t.Fatalf("unexpected destination ref: got %q want prefix %q", entry.DestinationRef, wantPrefix)
168
+ }
169
+ }
170
+
171
+ formatted := FormatDryRun(plan)
172
+ if !strings.Contains(formatted, "REMAP") {
173
+ t.Fatalf("expected remap lines in dry-run output, got:\n%s", formatted)
174
+ }
175
+ }
176
+
177
+ func TestRunDryRunDetectsBranchMovedConflict(t *testing.T) {
178
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
179
+
180
+ fixture.sourceRepo.WriteFile("local-main.txt", "local branch moved\n")
181
+ localHead := strings.TrimSpace(fixture.sourceRepo.CommitAll("local branch movement"))
182
+ fixture.sourceRepo.Git("update-ref", fixture.exchange.Task.RootRef, localHead)
183
+
184
+ plan, err := RunDryRun(Options{
185
+ CWD: fixture.sourceRepo.Dir,
186
+ ReturnPackagePath: fixture.returnPackagePath,
187
+ })
188
+ if err != nil {
189
+ t.Fatalf("RunDryRun returned error: %v", err)
190
+ }
191
+
192
+ entry, ok := findEntry(plan.Entries, fixture.exchange.Task.RootRef)
193
+ if !ok {
194
+ t.Fatalf("expected task root entry in plan")
195
+ }
196
+ if entry.Action != ActionConflict {
197
+ t.Fatalf("expected conflict action, got %s", entry.Action)
198
+ }
199
+ if entry.ConflictCode != errs.CodeBranchMoved {
200
+ t.Fatalf("expected BRANCH_MOVED conflict, got %s", entry.ConflictCode)
201
+ }
202
+ if plan.Summary.Conflicts == 0 {
203
+ t.Fatalf("expected conflict count > 0")
204
+ }
205
+
206
+ formatted := FormatDryRun(plan)
207
+ if !strings.Contains(formatted, "CONFLICT BRANCH_MOVED") {
208
+ t.Fatalf("expected branch moved line in output, got:\n%s", formatted)
209
+ }
210
+ }
211
+
212
+ func TestRunDryRunDetectsNamespaceRefExistsConflict(t *testing.T) {
213
+ fixture := setupReturnFixture(t, mutateSiblingTaskBranchCommit)
214
+
215
+ const namespace = "procoder-import"
216
+ if len(fixture.returnRecord.Updates) == 0 {
217
+ t.Fatalf("expected updates in return record")
218
+ }
219
+
220
+ targetRef := namespaceDestRef(t, fixture.returnRecord.Updates[0].Ref, fixture.exchange.ExchangeID, namespace)
221
+ existingOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", "HEAD"))
222
+ fixture.sourceRepo.Git("update-ref", targetRef, existingOID)
223
+
224
+ plan, err := RunDryRun(Options{
225
+ CWD: fixture.sourceRepo.Dir,
226
+ ReturnPackagePath: fixture.returnPackagePath,
227
+ Namespace: namespace,
228
+ })
229
+ if err != nil {
230
+ t.Fatalf("RunDryRun returned error: %v", err)
231
+ }
232
+
233
+ entry, ok := findEntry(plan.Entries, targetRef)
234
+ if !ok {
235
+ t.Fatalf("expected namespace destination entry %q", targetRef)
236
+ }
237
+ if entry.Action != ActionConflict {
238
+ t.Fatalf("expected conflict action, got %s", entry.Action)
239
+ }
240
+ if entry.ConflictCode != errs.CodeRefExists {
241
+ t.Fatalf("expected REF_EXISTS conflict, got %s", entry.ConflictCode)
242
+ }
243
+ }
244
+
245
+ func TestRunApplySuccessNormalReturnPackage(t *testing.T) {
246
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
247
+
248
+ taskUpdate, ok := findUpdate(fixture.returnRecord.Updates, fixture.exchange.Task.RootRef)
249
+ if !ok {
250
+ t.Fatalf("expected task root update in return record")
251
+ }
252
+
253
+ result, err := Run(Options{
254
+ CWD: fixture.sourceRepo.Dir,
255
+ ReturnPackagePath: fixture.returnPackagePath,
256
+ })
257
+ if err != nil {
258
+ t.Fatalf("Run returned error: %v", err)
259
+ }
260
+
261
+ if result.Plan.Summary.Updates != 1 || result.Plan.Summary.Creates != 0 || result.Plan.Summary.Conflicts != 0 {
262
+ t.Fatalf("unexpected apply summary: %#v", result.Plan.Summary)
263
+ }
264
+ if result.CheckedOutRef != "" {
265
+ t.Fatalf("did not expect checkout in default mode, got %q", result.CheckedOutRef)
266
+ }
267
+
268
+ gotTaskOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", fixture.exchange.Task.RootRef))
269
+ if gotTaskOID != taskUpdate.NewOID {
270
+ t.Fatalf("task branch was not updated: got %q want %q", gotTaskOID, taskUpdate.NewOID)
271
+ }
272
+
273
+ headRef := strings.TrimSpace(fixture.sourceRepo.Git("symbolic-ref", "--quiet", "HEAD"))
274
+ if headRef != "refs/heads/main" {
275
+ t.Fatalf("expected checkout to remain on main, got %q", headRef)
276
+ }
277
+
278
+ assertNoImportRefs(t, fixture.sourceRepo.Dir)
279
+ }
280
+
281
+ func TestRunApplySuccessNamespaceImport(t *testing.T) {
282
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
283
+ taskUpdate, ok := findUpdate(fixture.returnRecord.Updates, fixture.exchange.Task.RootRef)
284
+ if !ok {
285
+ t.Fatalf("expected task root update in return record")
286
+ }
287
+
288
+ const namespace = "procoder-import"
289
+ result, err := Run(Options{
290
+ CWD: fixture.sourceRepo.Dir,
291
+ ReturnPackagePath: fixture.returnPackagePath,
292
+ Namespace: namespace,
293
+ })
294
+ if err != nil {
295
+ t.Fatalf("Run returned error: %v", err)
296
+ }
297
+
298
+ if result.Plan.Summary.Creates == 0 {
299
+ t.Fatalf("expected namespace import to create destination refs, summary=%#v", result.Plan.Summary)
300
+ }
301
+
302
+ destRef := namespaceDestRef(t, fixture.exchange.Task.RootRef, fixture.exchange.ExchangeID, namespace)
303
+ gotDestOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", destRef))
304
+ if gotDestOID != taskUpdate.NewOID {
305
+ t.Fatalf("namespace destination ref mismatch: got %q want %q", gotDestOID, taskUpdate.NewOID)
306
+ }
307
+
308
+ originalOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", fixture.exchange.Task.RootRef))
309
+ if originalOID != taskUpdate.OldOID {
310
+ t.Fatalf("expected original task ref to stay unchanged: got %q want %q", originalOID, taskUpdate.OldOID)
311
+ }
312
+
313
+ assertNoImportRefs(t, fixture.sourceRepo.Dir)
314
+ }
315
+
316
+ func TestRunApplyFailsBranchMovedWithNamespaceHint(t *testing.T) {
317
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
318
+
319
+ fixture.sourceRepo.WriteFile("branch-moved.txt", "local move\n")
320
+ localHead := strings.TrimSpace(fixture.sourceRepo.CommitAll("local branch movement"))
321
+ fixture.sourceRepo.Git("update-ref", fixture.exchange.Task.RootRef, localHead)
322
+
323
+ _, err := Run(Options{
324
+ CWD: fixture.sourceRepo.Dir,
325
+ ReturnPackagePath: fixture.returnPackagePath,
326
+ })
327
+ assertErrorContains(t, err, errs.CodeBranchMoved,
328
+ "cannot update "+fixture.exchange.Task.RootRef,
329
+ "Expected old OID:",
330
+ "Current local OID:",
331
+ "--namespace",
332
+ )
333
+ }
334
+
335
+ func TestRunApplyFailsExistingDestinationRefWithNamespaceHint(t *testing.T) {
336
+ fixture := setupReturnFixture(t, mutateSiblingTaskBranchCommit)
337
+
338
+ if len(fixture.returnRecord.Updates) == 0 {
339
+ t.Fatalf("expected updates in return record")
340
+ }
341
+ const namespace = "procoder-import"
342
+ targetRef := namespaceDestRef(t, fixture.returnRecord.Updates[0].Ref, fixture.exchange.ExchangeID, namespace)
343
+ existingOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", "HEAD"))
344
+ fixture.sourceRepo.Git("update-ref", targetRef, existingOID)
345
+
346
+ _, err := Run(Options{
347
+ CWD: fixture.sourceRepo.Dir,
348
+ ReturnPackagePath: fixture.returnPackagePath,
349
+ Namespace: namespace,
350
+ })
351
+ assertErrorContains(t, err, errs.CodeRefExists,
352
+ "cannot create "+targetRef,
353
+ "Existing local OID:",
354
+ "--namespace",
355
+ )
356
+ }
357
+
358
+ func TestRunApplyFailsCheckedOutDestinationRef(t *testing.T) {
359
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
360
+
361
+ taskShort := strings.TrimPrefix(fixture.exchange.Task.RootRef, "refs/heads/")
362
+ fixture.sourceRepo.Git("checkout", taskShort)
363
+
364
+ _, err := Run(Options{
365
+ CWD: fixture.sourceRepo.Dir,
366
+ ReturnPackagePath: fixture.returnPackagePath,
367
+ })
368
+ assertErrorContains(t, err, errs.CodeTargetBranchCheckedOut,
369
+ "cannot update checked-out branch "+fixture.exchange.Task.RootRef,
370
+ "Current HEAD: "+fixture.exchange.Task.RootRef,
371
+ )
372
+ }
373
+
374
+ func TestRunApplyCheckoutSuccessPath(t *testing.T) {
375
+ fixture := setupReturnFixture(t, mutateTaskRootCommit)
376
+ taskUpdate, ok := findUpdate(fixture.returnRecord.Updates, fixture.exchange.Task.RootRef)
377
+ if !ok {
378
+ t.Fatalf("expected task root update in return record")
379
+ }
380
+
381
+ result, err := Run(Options{
382
+ CWD: fixture.sourceRepo.Dir,
383
+ ReturnPackagePath: fixture.returnPackagePath,
384
+ Checkout: true,
385
+ })
386
+ if err != nil {
387
+ t.Fatalf("Run returned error: %v", err)
388
+ }
389
+
390
+ if result.CheckedOutRef != fixture.exchange.Task.RootRef {
391
+ t.Fatalf("unexpected checked-out ref: got %q want %q", result.CheckedOutRef, fixture.exchange.Task.RootRef)
392
+ }
393
+
394
+ headRef := strings.TrimSpace(fixture.sourceRepo.Git("symbolic-ref", "--quiet", "HEAD"))
395
+ if headRef != fixture.exchange.Task.RootRef {
396
+ t.Fatalf("expected checkout to switch to task ref: got %q want %q", headRef, fixture.exchange.Task.RootRef)
397
+ }
398
+
399
+ gotTaskOID := strings.TrimSpace(fixture.sourceRepo.Git("rev-parse", fixture.exchange.Task.RootRef))
400
+ if gotTaskOID != taskUpdate.NewOID {
401
+ t.Fatalf("task branch was not updated: got %q want %q", gotTaskOID, taskUpdate.NewOID)
402
+ }
403
+ }
404
+
405
+ func TestApplyEndToEndPrepareReturnApply(t *testing.T) {
406
+ source := gitrepo.New(t)
407
+ source.WriteFile("README.md", "source\n")
408
+ source.CommitAll("initial")
409
+
410
+ helperPath := writeHelperBinary(t)
411
+ prepResult, err := prepare.Run(prepare.Options{
412
+ CWD: source.Dir,
413
+ ToolVersion: "0.1.0-test",
414
+ HelperPath: helperPath,
415
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 15, 0, 0, 0, time.UTC) },
416
+ Random: bytes.NewReader([]byte{0x0d, 0x0e, 0x0f}),
417
+ })
418
+ if err != nil {
419
+ t.Fatalf("prepare.Run failed: %v", err)
420
+ }
421
+
422
+ exportRoot := unzipTaskPackage(t, prepResult.TaskPackagePath)
423
+ ex, err := exchange.ReadExchange(filepath.Join(exportRoot, ".git", "procoder", "exchange.json"))
424
+ if err != nil {
425
+ t.Fatalf("ReadExchange(export) failed: %v", err)
426
+ }
427
+
428
+ appendFile(t, filepath.Join(exportRoot, "README.md"), "remote change\n")
429
+ runGit(t, exportRoot, "add", "README.md")
430
+ runGit(t, exportRoot, "commit", "-m", "remote task update")
431
+
432
+ retResult, err := returnpkg.Run(returnpkg.Options{
433
+ CWD: exportRoot,
434
+ ToolVersion: "0.1.0-test",
435
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 15, 30, 0, 0, time.UTC) },
436
+ })
437
+ if err != nil {
438
+ t.Fatalf("returnpkg.Run failed: %v", err)
439
+ }
440
+
441
+ retRecord := readReturnRecordFromZip(t, retResult.ReturnPackagePath)
442
+ taskUpdate, ok := findUpdate(retRecord.Updates, ex.Task.RootRef)
443
+ if !ok {
444
+ t.Fatalf("expected task root update in return record")
445
+ }
446
+
447
+ result, err := Run(Options{
448
+ CWD: source.Dir,
449
+ ReturnPackagePath: retResult.ReturnPackagePath,
450
+ })
451
+ if err != nil {
452
+ t.Fatalf("apply.Run failed: %v", err)
453
+ }
454
+
455
+ if result.Plan.ExchangeID != ex.ExchangeID {
456
+ t.Fatalf("unexpected exchange id: got %q want %q", result.Plan.ExchangeID, ex.ExchangeID)
457
+ }
458
+ if got := strings.TrimSpace(source.Git("rev-parse", ex.Task.RootRef)); got != taskUpdate.NewOID {
459
+ t.Fatalf("task ref mismatch after apply: got %q want %q", got, taskUpdate.NewOID)
460
+ }
461
+ assertNoImportRefs(t, source.Dir)
462
+ }
463
+
464
+ type returnFixture struct {
465
+ sourceRepo *gitrepo.Repo
466
+ exportRoot string
467
+ exchange exchange.Exchange
468
+ returnPackagePath string
469
+ returnRecord exchange.Return
470
+ }
471
+
472
+ func setupReturnFixture(t *testing.T, mutate func(t *testing.T, exportRoot string, ex exchange.Exchange)) returnFixture {
473
+ t.Helper()
474
+
475
+ source := gitrepo.New(t)
476
+ source.WriteFile("README.md", "source\n")
477
+ source.CommitAll("initial")
478
+ source.Git("branch", "feature/context")
479
+ source.Git("tag", "v1.0.0")
480
+
481
+ helperPath := writeHelperBinary(t)
482
+ prepResult, err := prepare.Run(prepare.Options{
483
+ CWD: source.Dir,
484
+ ToolVersion: "0.1.0-test",
485
+ HelperPath: helperPath,
486
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 11, 30, 15, 0, time.UTC) },
487
+ Random: bytes.NewReader([]byte{0x0a, 0x0b, 0x0c}),
488
+ })
489
+ if err != nil {
490
+ t.Fatalf("prepare.Run failed: %v", err)
491
+ }
492
+
493
+ exportRoot := unzipTaskPackage(t, prepResult.TaskPackagePath)
494
+ ex, err := exchange.ReadExchange(filepath.Join(exportRoot, ".git", "procoder", "exchange.json"))
495
+ if err != nil {
496
+ t.Fatalf("ReadExchange(export) failed: %v", err)
497
+ }
498
+
499
+ if mutate != nil {
500
+ mutate(t, exportRoot, ex)
501
+ }
502
+
503
+ retResult, err := returnpkg.Run(returnpkg.Options{
504
+ CWD: exportRoot,
505
+ ToolVersion: "0.1.0-test",
506
+ Now: func() time.Time { return time.Date(2026, time.March, 20, 12, 5, 0, 0, time.UTC) },
507
+ })
508
+ if err != nil {
509
+ t.Fatalf("returnpkg.Run failed: %v", err)
510
+ }
511
+
512
+ retRecord := readReturnRecordFromZip(t, retResult.ReturnPackagePath)
513
+ return returnFixture{
514
+ sourceRepo: source,
515
+ exportRoot: exportRoot,
516
+ exchange: ex,
517
+ returnPackagePath: retResult.ReturnPackagePath,
518
+ returnRecord: retRecord,
519
+ }
520
+ }
521
+
522
+ func mutateTaskRootCommit(t *testing.T, exportRoot string, ex exchange.Exchange) {
523
+ t.Helper()
524
+
525
+ appendFile(t, filepath.Join(exportRoot, "README.md"), "remote task change\n")
526
+ runGit(t, exportRoot, "add", "README.md")
527
+ runGit(t, exportRoot, "commit", "-m", "task update")
528
+
529
+ currentRef := strings.TrimSpace(runGit(t, exportRoot, "symbolic-ref", "--quiet", "HEAD"))
530
+ if currentRef != ex.Task.RootRef {
531
+ t.Fatalf("expected export repo on task root ref %q, got %q", ex.Task.RootRef, currentRef)
532
+ }
533
+ }
534
+
535
+ func mutateSiblingTaskBranchCommit(t *testing.T, exportRoot string, ex exchange.Exchange) {
536
+ t.Helper()
537
+
538
+ taskShort := strings.TrimPrefix(ex.Task.RootRef, "refs/heads/")
539
+ siblingRef := ex.Task.RefPrefix + "/experiment"
540
+ siblingShort := strings.TrimPrefix(siblingRef, "refs/heads/")
541
+
542
+ runGit(t, exportRoot, "branch", siblingShort, taskShort)
543
+ runGit(t, exportRoot, "checkout", siblingShort)
544
+ appendFile(t, filepath.Join(exportRoot, "README.md"), "sibling work\n")
545
+ runGit(t, exportRoot, "add", "README.md")
546
+ runGit(t, exportRoot, "commit", "-m", "experiment")
547
+ runGit(t, exportRoot, "checkout", taskShort)
548
+ }
549
+
550
+ func namespaceDestRef(t *testing.T, sourceRef, exchangeID, namespace string) string {
551
+ t.Helper()
552
+
553
+ prefix := exchange.TaskRefPrefix(exchangeID)
554
+ if !strings.HasPrefix(sourceRef, prefix+"/") {
555
+ t.Fatalf("source ref %q is outside task family prefix %q", sourceRef, prefix)
556
+ }
557
+ suffix := strings.TrimPrefix(sourceRef, prefix)
558
+ return "refs/heads/" + namespace + "/" + exchangeID + suffix
559
+ }
560
+
561
+ func findEntry(entries []PlanEntry, destinationRef string) (PlanEntry, bool) {
562
+ for _, entry := range entries {
563
+ if entry.DestinationRef == destinationRef || entry.SourceRef == destinationRef {
564
+ return entry, true
565
+ }
566
+ }
567
+ return PlanEntry{}, false
568
+ }
569
+
570
+ func findUpdate(updates []exchange.RefUpdate, ref string) (exchange.RefUpdate, bool) {
571
+ for _, update := range updates {
572
+ if update.Ref == ref {
573
+ return update, true
574
+ }
575
+ }
576
+ return exchange.RefUpdate{}, false
577
+ }
578
+
579
+ func assertNoImportRefs(t *testing.T, dir string) {
580
+ t.Helper()
581
+
582
+ importRefs := strings.TrimSpace(runGit(t, dir, "for-each-ref", "--format=%(refname)", "refs/procoder/import"))
583
+ if importRefs != "" {
584
+ t.Fatalf("expected temporary import refs to be cleaned up, got:\n%s", importRefs)
585
+ }
586
+ }
587
+
588
+ func readReturnRecordFromZip(t *testing.T, zipPath string) exchange.Return {
589
+ t.Helper()
590
+
591
+ extracted := unzipFile(t, zipPath)
592
+ record, err := exchange.ReadReturn(filepath.Join(extracted, returnJSONName))
593
+ if err != nil {
594
+ t.Fatalf("ReadReturn failed: %v", err)
595
+ }
596
+ return record
597
+ }
598
+
599
+ func rewriteReturnPackage(t *testing.T, src string, mutate func(files map[string][]byte)) string {
600
+ t.Helper()
601
+
602
+ files := readZipFiles(t, src)
603
+ mutate(files)
604
+ dst := filepath.Join(t.TempDir(), filepath.Base(src))
605
+ writeZip(t, dst, files)
606
+ return dst
607
+ }
608
+
609
+ func readZipFiles(t *testing.T, zipPath string) map[string][]byte {
610
+ t.Helper()
611
+
612
+ reader, err := zip.OpenReader(zipPath)
613
+ if err != nil {
614
+ t.Fatalf("zip.OpenReader failed: %v", err)
615
+ }
616
+ defer reader.Close()
617
+
618
+ files := make(map[string][]byte)
619
+ for _, file := range reader.File {
620
+ if file.FileInfo().IsDir() {
621
+ continue
622
+ }
623
+ in, err := file.Open()
624
+ if err != nil {
625
+ t.Fatalf("open zip entry failed: %v", err)
626
+ }
627
+ data, err := io.ReadAll(in)
628
+ _ = in.Close()
629
+ if err != nil {
630
+ t.Fatalf("read zip entry failed: %v", err)
631
+ }
632
+ files[file.Name] = data
633
+ }
634
+ return files
635
+ }
636
+
637
+ func writeZip(t *testing.T, zipPath string, files map[string][]byte) {
638
+ t.Helper()
639
+
640
+ out, err := os.Create(zipPath)
641
+ if err != nil {
642
+ t.Fatalf("create zip failed: %v", err)
643
+ }
644
+ defer out.Close()
645
+
646
+ zw := zip.NewWriter(out)
647
+ defer zw.Close()
648
+
649
+ names := make([]string, 0, len(files))
650
+ for name := range files {
651
+ names = append(names, name)
652
+ }
653
+ sort.Strings(names)
654
+
655
+ for _, name := range names {
656
+ w, err := zw.Create(name)
657
+ if err != nil {
658
+ t.Fatalf("create zip entry %q failed: %v", name, err)
659
+ }
660
+ if _, err := w.Write(files[name]); err != nil {
661
+ t.Fatalf("write zip entry %q failed: %v", name, err)
662
+ }
663
+ }
664
+ }
665
+
666
+ func writeHelperBinary(t *testing.T) string {
667
+ t.Helper()
668
+
669
+ path := filepath.Join(t.TempDir(), "procoder-return")
670
+ writeFile(t, path, "#!/bin/sh\nexit 0\n")
671
+ if err := os.Chmod(path, 0o755); err != nil {
672
+ t.Fatalf("chmod helper failed: %v", err)
673
+ }
674
+ return path
675
+ }
676
+
677
+ func unzipTaskPackage(t *testing.T, zipPath string) string {
678
+ t.Helper()
679
+
680
+ dest := unzipFile(t, zipPath)
681
+ return filepath.Join(dest, filepath.Base(filepath.Dir(zipPath)))
682
+ }
683
+
684
+ func unzipFile(t *testing.T, zipPath string) string {
685
+ t.Helper()
686
+
687
+ reader, err := zip.OpenReader(zipPath)
688
+ if err != nil {
689
+ t.Fatalf("zip.OpenReader failed: %v", err)
690
+ }
691
+ defer reader.Close()
692
+
693
+ dest := t.TempDir()
694
+ for _, file := range reader.File {
695
+ targetPath := filepath.Join(dest, file.Name)
696
+ safePrefix := filepath.Clean(dest) + string(os.PathSeparator)
697
+ if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
698
+ t.Fatalf("zip entry escapes destination: %q", file.Name)
699
+ }
700
+
701
+ if file.FileInfo().IsDir() {
702
+ if err := os.MkdirAll(targetPath, 0o755); err != nil {
703
+ t.Fatalf("mkdir failed: %v", err)
704
+ }
705
+ continue
706
+ }
707
+
708
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
709
+ t.Fatalf("mkdir file dir failed: %v", err)
710
+ }
711
+ in, err := file.Open()
712
+ if err != nil {
713
+ t.Fatalf("open zip entry failed: %v", err)
714
+ }
715
+ out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
716
+ if err != nil {
717
+ _ = in.Close()
718
+ t.Fatalf("open extracted file failed: %v", err)
719
+ }
720
+ if _, err := io.Copy(out, in); err != nil {
721
+ _ = out.Close()
722
+ _ = in.Close()
723
+ t.Fatalf("copy extracted file failed: %v", err)
724
+ }
725
+ _ = out.Close()
726
+ _ = in.Close()
727
+ }
728
+ return dest
729
+ }
730
+
731
+ func runGit(t *testing.T, dir string, args ...string) string {
732
+ t.Helper()
733
+
734
+ cmd := exec.Command("git", args...)
735
+ cmd.Dir = dir
736
+ var stdout bytes.Buffer
737
+ var stderr bytes.Buffer
738
+ cmd.Stdout = &stdout
739
+ cmd.Stderr = &stderr
740
+
741
+ if err := cmd.Run(); err != nil {
742
+ t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
743
+ }
744
+ return stdout.String()
745
+ }
746
+
747
+ func appendFile(t *testing.T, path, extra string) {
748
+ t.Helper()
749
+
750
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
751
+ if err != nil {
752
+ t.Fatalf("open %s for append failed: %v", path, err)
753
+ }
754
+ if _, err := f.WriteString(extra); err != nil {
755
+ _ = f.Close()
756
+ t.Fatalf("append to %s failed: %v", path, err)
757
+ }
758
+ if err := f.Close(); err != nil {
759
+ t.Fatalf("close %s failed: %v", path, err)
760
+ }
761
+ }
762
+
763
+ func writeFile(t *testing.T, path, content string) {
764
+ t.Helper()
765
+
766
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
767
+ t.Fatalf("mkdir %s failed: %v", filepath.Dir(path), err)
768
+ }
769
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
770
+ t.Fatalf("write %s failed: %v", path, err)
771
+ }
772
+ }
773
+
774
+ func assertErrorContains(t *testing.T, err error, wantCode errs.Code, contains ...string) {
775
+ t.Helper()
776
+
777
+ if err == nil {
778
+ t.Fatalf("expected error with code %s", wantCode)
779
+ }
780
+ typed, ok := errs.As(err)
781
+ if !ok {
782
+ t.Fatalf("expected typed error, got %T (%v)", err, err)
783
+ }
784
+ if typed.Code != wantCode {
785
+ t.Fatalf("unexpected error code: got %s want %s", typed.Code, wantCode)
786
+ }
787
+
788
+ formatted := output.FormatError(err)
789
+ for _, fragment := range contains {
790
+ if !strings.Contains(formatted, fragment) {
791
+ t.Fatalf("expected error output to contain %q, got:\n%s", fragment, formatted)
792
+ }
793
+ }
794
+ }