procoder-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +122 -0
- package/CONTRIBUTORS.md +79 -0
- package/Makefile +82 -0
- package/README.md +125 -0
- package/bin/procoder.js +28 -0
- package/cmd/procoder/main.go +15 -0
- package/cmd/procoder-return/main.go +67 -0
- package/cmd/procoder-return/main_test.go +77 -0
- package/go.mod +3 -0
- package/internal/app/app.go +224 -0
- package/internal/app/app_test.go +253 -0
- package/internal/app/roundtrip_test.go +215 -0
- package/internal/apply/apply.go +1069 -0
- package/internal/apply/apply_test.go +794 -0
- package/internal/errs/errors.go +114 -0
- package/internal/exchange/exchange_test.go +165 -0
- package/internal/exchange/id.go +66 -0
- package/internal/exchange/json.go +64 -0
- package/internal/exchange/types.go +55 -0
- package/internal/gitx/gitx.go +105 -0
- package/internal/gitx/gitx_test.go +51 -0
- package/internal/output/errors.go +49 -0
- package/internal/output/errors_test.go +41 -0
- package/internal/prepare/prepare.go +788 -0
- package/internal/prepare/prepare_test.go +416 -0
- package/internal/returnpkg/returnpkg.go +589 -0
- package/internal/returnpkg/returnpkg_test.go +489 -0
- package/internal/testutil/gitrepo/repo.go +113 -0
- package/package.json +47 -0
- package/scripts/postinstall.js +263 -0
|
@@ -0,0 +1,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
|
+
}
|