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,416 @@
1
+ package prepare
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bytes"
6
+ "io"
7
+ "os"
8
+ "os/exec"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/amxv/procoder/internal/errs"
15
+ "github.com/amxv/procoder/internal/exchange"
16
+ "github.com/amxv/procoder/internal/testutil/gitrepo"
17
+ )
18
+
19
+ func TestRunPrepareHappyPath(t *testing.T) {
20
+ repo := gitrepo.New(t)
21
+ repo.WriteFile("README.md", "hello\n")
22
+ headOID := strings.TrimSpace(repo.CommitAll("initial commit"))
23
+ repo.Git("branch", "feature/alpha")
24
+ repo.Git("tag", "v1.2.3")
25
+
26
+ headRefBefore := strings.TrimSpace(repo.Git("symbolic-ref", "--quiet", "HEAD"))
27
+
28
+ helperPath := writeHelperBinary(t)
29
+ fixedNow := time.Date(2026, time.March, 20, 11, 30, 15, 0, time.UTC)
30
+ result, err := Run(Options{
31
+ CWD: repo.Dir,
32
+ ToolVersion: "0.1.0-test",
33
+ HelperPath: helperPath,
34
+ Now: func() time.Time { return fixedNow },
35
+ Random: bytes.NewReader([]byte{0x01, 0x02, 0x03}),
36
+ })
37
+ if err != nil {
38
+ t.Fatalf("Run returned error: %v", err)
39
+ }
40
+
41
+ const expectedID = "20260320-113015-010203"
42
+ expectedTaskRef := "refs/heads/procoder/" + expectedID + "/task"
43
+ expectedTaskPrefix := "refs/heads/procoder/" + expectedID
44
+ expectedTaskPackageName := "procoder-task-" + expectedID + ".zip"
45
+ expectedTaskPackagePath := filepath.Join(repo.Dir, expectedTaskPackageName)
46
+ if result.ExchangeID != expectedID {
47
+ t.Fatalf("unexpected exchange id: got %q want %q", result.ExchangeID, expectedID)
48
+ }
49
+ if result.TaskRootRef != expectedTaskRef {
50
+ t.Fatalf("unexpected task ref: got %q want %q", result.TaskRootRef, expectedTaskRef)
51
+ }
52
+
53
+ if filepath.Base(result.TaskPackagePath) != expectedTaskPackageName {
54
+ t.Fatalf("unexpected task package filename: got %q want %q", filepath.Base(result.TaskPackagePath), expectedTaskPackageName)
55
+ }
56
+ if !filepath.IsAbs(result.TaskPackagePath) {
57
+ t.Fatalf("expected absolute task package path, got %q", result.TaskPackagePath)
58
+ }
59
+ gotInfo, err := os.Stat(result.TaskPackagePath)
60
+ if err != nil {
61
+ t.Fatalf("expected task package zip to exist at %q: %v", result.TaskPackagePath, err)
62
+ }
63
+ wantInfo, err := os.Stat(expectedTaskPackagePath)
64
+ if err != nil {
65
+ t.Fatalf("expected task package zip to exist at %q: %v", expectedTaskPackagePath, err)
66
+ }
67
+ if !os.SameFile(gotInfo, wantInfo) {
68
+ t.Fatalf("task package was not written to repo root: got %q want %q", result.TaskPackagePath, expectedTaskPackagePath)
69
+ }
70
+ sourceStatus := strings.TrimSpace(runGit(t, repo.Dir, "status", "--porcelain=v1", "--untracked-files=all"))
71
+ if sourceStatus != "" {
72
+ t.Fatalf("expected source repo to be clean after prepare, got status:\n%s", sourceStatus)
73
+ }
74
+
75
+ headRefAfter := strings.TrimSpace(repo.Git("symbolic-ref", "--quiet", "HEAD"))
76
+ if headRefAfter != headRefBefore {
77
+ t.Fatalf("current checkout changed: before %q after %q", headRefBefore, headRefAfter)
78
+ }
79
+
80
+ taskRefOID := strings.TrimSpace(repo.Git("rev-parse", expectedTaskRef))
81
+ if taskRefOID != headOID {
82
+ t.Fatalf("task branch points to unexpected commit: got %q want %q", taskRefOID, headOID)
83
+ }
84
+
85
+ localExchangePath := filepath.Join(repo.Dir, ".git", "procoder", "exchanges", expectedID, "exchange.json")
86
+ localExchange, err := exchange.ReadExchange(localExchangePath)
87
+ if err != nil {
88
+ t.Fatalf("ReadExchange(local) failed: %v", err)
89
+ }
90
+ if localExchange.ExchangeID != expectedID {
91
+ t.Fatalf("unexpected local exchange id: got %q want %q", localExchange.ExchangeID, expectedID)
92
+ }
93
+ if localExchange.Task.RootRef != expectedTaskRef {
94
+ t.Fatalf("unexpected local task ref: got %q want %q", localExchange.Task.RootRef, expectedTaskRef)
95
+ }
96
+ if localExchange.Task.RefPrefix != expectedTaskPrefix {
97
+ t.Fatalf("unexpected local task ref prefix: got %q want %q", localExchange.Task.RefPrefix, expectedTaskPrefix)
98
+ }
99
+
100
+ expectedHeads := readRefsInRepo(t, repo.Dir, "refs/heads")
101
+ expectedTags := readRefsInRepo(t, repo.Dir, "refs/tags")
102
+
103
+ extractedRoot := unzipTaskPackage(t, result.TaskPackagePath)
104
+ exportExchangePath := filepath.Join(extractedRoot, ".git", "procoder", "exchange.json")
105
+ exportExchange, err := exchange.ReadExchange(exportExchangePath)
106
+ if err != nil {
107
+ t.Fatalf("ReadExchange(export) failed: %v", err)
108
+ }
109
+ if exportExchange.ExchangeID != expectedID {
110
+ t.Fatalf("unexpected exported exchange id: got %q want %q", exportExchange.ExchangeID, expectedID)
111
+ }
112
+
113
+ exportHeadRef := strings.TrimSpace(runGit(t, extractedRoot, "symbolic-ref", "--quiet", "HEAD"))
114
+ if exportHeadRef != expectedTaskRef {
115
+ t.Fatalf("export repo is on wrong branch: got %q want %q", exportHeadRef, expectedTaskRef)
116
+ }
117
+ exportStatus := strings.TrimSpace(runGit(t, extractedRoot, "status", "--porcelain=v1", "--untracked-files=all"))
118
+ if exportStatus != "" {
119
+ t.Fatalf("expected exported repo to be clean after prepare, got status:\n%s", exportStatus)
120
+ }
121
+ exportUserName := strings.TrimSpace(runGit(t, extractedRoot, "config", "--get", "user.name"))
122
+ if exportUserName == "" {
123
+ t.Fatalf("expected export user.name to be configured")
124
+ }
125
+ exportUserEmail := strings.TrimSpace(runGit(t, extractedRoot, "config", "--get", "user.email"))
126
+ if exportUserEmail == "" {
127
+ t.Fatalf("expected export user.email to be configured")
128
+ }
129
+ exportGPGSign := strings.TrimSpace(runGit(t, extractedRoot, "config", "--get", "commit.gpgsign"))
130
+ if exportGPGSign != "false" {
131
+ t.Fatalf("expected export commit.gpgsign=false, got %q", exportGPGSign)
132
+ }
133
+
134
+ gotHeads := readRefsInRepo(t, extractedRoot, "refs/heads")
135
+ gotTags := readRefsInRepo(t, extractedRoot, "refs/tags")
136
+ if diff := diffRefMaps(expectedHeads, gotHeads); diff != "" {
137
+ t.Fatalf("heads mismatch:\n%s", diff)
138
+ }
139
+ if diff := diffRefMaps(expectedTags, gotTags); diff != "" {
140
+ t.Fatalf("tags mismatch:\n%s", diff)
141
+ }
142
+
143
+ helperExportPath := filepath.Join(extractedRoot, "procoder-return")
144
+ helperInfo, err := os.Stat(helperExportPath)
145
+ if err != nil {
146
+ t.Fatalf("expected exported helper at %q: %v", helperExportPath, err)
147
+ }
148
+ if helperInfo.Mode()&0o111 == 0 {
149
+ t.Fatalf("expected exported helper to be executable, mode=%o", helperInfo.Mode())
150
+ }
151
+
152
+ sourceExclude := mustReadFile(t, filepath.Join(repo.Dir, ".git", "info", "exclude"))
153
+ if !strings.Contains(sourceExclude, expectedTaskPackageName) {
154
+ t.Fatalf("expected source exclude to contain task package name %q, got:\n%s", expectedTaskPackageName, sourceExclude)
155
+ }
156
+
157
+ exportExclude := mustReadFile(t, filepath.Join(extractedRoot, ".git", "info", "exclude"))
158
+ for _, expectedPattern := range []string{"procoder-return", "procoder-return-*.zip"} {
159
+ if !strings.Contains(exportExclude, expectedPattern) {
160
+ t.Fatalf("expected export exclude to contain %q, got:\n%s", expectedPattern, exportExclude)
161
+ }
162
+ }
163
+
164
+ if _, err := os.Stat(filepath.Join(extractedRoot, ".git", "hooks")); !os.IsNotExist(err) {
165
+ t.Fatalf("expected export hooks directory to be removed, stat err=%v", err)
166
+ }
167
+ if _, err := os.Stat(filepath.Join(extractedRoot, ".git", "logs")); !os.IsNotExist(err) {
168
+ t.Fatalf("expected export logs directory to be removed, stat err=%v", err)
169
+ }
170
+ }
171
+
172
+ func TestRunPrepareFailsNotGitWorktree(t *testing.T) {
173
+ helperPath := writeHelperBinary(t)
174
+ _, err := Run(Options{
175
+ CWD: t.TempDir(),
176
+ HelperPath: helperPath,
177
+ })
178
+ assertCode(t, err, errs.CodeNotGitRepo)
179
+ }
180
+
181
+ func TestRunPrepareFailsDirtyRepo(t *testing.T) {
182
+ repo := gitrepo.New(t)
183
+ repo.WriteFile("README.md", "one\n")
184
+ repo.CommitAll("initial")
185
+ repo.WriteFile("README.md", "two\n")
186
+
187
+ helperPath := writeHelperBinary(t)
188
+ _, err := Run(Options{
189
+ CWD: repo.Dir,
190
+ HelperPath: helperPath,
191
+ })
192
+ assertCode(t, err, errs.CodeWorktreeDirty)
193
+ }
194
+
195
+ func TestRunPrepareFailsUntrackedFiles(t *testing.T) {
196
+ repo := gitrepo.New(t)
197
+ repo.WriteFile("README.md", "one\n")
198
+ repo.CommitAll("initial")
199
+ repo.WriteFile("scratch.txt", "untracked\n")
200
+
201
+ helperPath := writeHelperBinary(t)
202
+ _, err := Run(Options{
203
+ CWD: repo.Dir,
204
+ HelperPath: helperPath,
205
+ })
206
+ assertCode(t, err, errs.CodeUntrackedFilesPresent)
207
+ }
208
+
209
+ func TestRunPrepareFailsWithSubmodules(t *testing.T) {
210
+ repo := gitrepo.New(t)
211
+ repo.WriteFile("README.md", "one\n")
212
+ repo.CommitAll("initial")
213
+
214
+ subRepo := gitrepo.New(t)
215
+ subRepo.WriteFile("mod.txt", "submodule\n")
216
+ subHeadOID := strings.TrimSpace(subRepo.CommitAll("submodule commit"))
217
+
218
+ repo.Git("update-index", "--add", "--cacheinfo", "160000,"+subHeadOID+",vendor/submodule")
219
+ repo.Git("commit", "-m", "add submodule gitlink")
220
+
221
+ helperPath := writeHelperBinary(t)
222
+ _, err := Run(Options{
223
+ CWD: repo.Dir,
224
+ HelperPath: helperPath,
225
+ })
226
+ assertCode(t, err, errs.CodeSubmodulesUnsupported)
227
+ }
228
+
229
+ func TestRunPrepareFailsWhenLFSIsDetected(t *testing.T) {
230
+ repo := gitrepo.New(t)
231
+ repo.WriteFile("README.md", "one\n")
232
+ repo.CommitAll("initial")
233
+ repo.WriteFile(".gitattributes", "*.bin filter=lfs diff=lfs merge=lfs -text\n")
234
+ repo.CommitAll("enable lfs")
235
+
236
+ helperPath := writeHelperBinary(t)
237
+ _, err := Run(Options{
238
+ CWD: repo.Dir,
239
+ HelperPath: helperPath,
240
+ })
241
+ assertCode(t, err, errs.CodeLFSUnsupported)
242
+ }
243
+
244
+ func TestHelperCandidatePathsPreferPackagedHelperAssetNextToExecutable(t *testing.T) {
245
+ executablePath := filepath.Join("/tmp", "procoder-install", "bin", "procoder-bin")
246
+ candidates := helperCandidatePaths("", "", executablePath)
247
+
248
+ if len(candidates) != 2 {
249
+ t.Fatalf("unexpected candidate count: got %d want 2 (%v)", len(candidates), candidates)
250
+ }
251
+ if got, want := candidates[0], filepath.Join("/tmp", "procoder-install", "bin", "procoder-return_linux_amd64"); got != want {
252
+ t.Fatalf("unexpected first candidate: got %q want %q", got, want)
253
+ }
254
+ if got, want := candidates[1], filepath.Join("/tmp", "procoder-install", "bin", "procoder-return"); got != want {
255
+ t.Fatalf("unexpected second candidate: got %q want %q", got, want)
256
+ }
257
+ }
258
+
259
+ func TestResolveHelperBinaryFromCandidatesPrefersPackagedHelperAsset(t *testing.T) {
260
+ installDir := t.TempDir()
261
+ executablePath := filepath.Join(installDir, "procoder-bin")
262
+ helperAssetPath := filepath.Join(installDir, "procoder-return_linux_amd64")
263
+ helperBinaryPath := filepath.Join(installDir, "procoder-return")
264
+
265
+ if err := os.WriteFile(helperAssetPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
266
+ t.Fatalf("write packaged helper failed: %v", err)
267
+ }
268
+ if err := os.WriteFile(helperBinaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
269
+ t.Fatalf("write sibling helper failed: %v", err)
270
+ }
271
+
272
+ resolved, err := resolveHelperBinaryFromCandidates(helperCandidatePaths("", "", executablePath))
273
+ if err != nil {
274
+ t.Fatalf("resolveHelperBinaryFromCandidates returned error: %v", err)
275
+ }
276
+
277
+ if resolved != helperAssetPath {
278
+ t.Fatalf("unexpected helper path: got %q want %q", resolved, helperAssetPath)
279
+ }
280
+ }
281
+
282
+ func assertCode(t *testing.T, err error, want errs.Code) {
283
+ t.Helper()
284
+ if err == nil {
285
+ t.Fatalf("expected error with code %s", want)
286
+ }
287
+ typed, ok := errs.As(err)
288
+ if !ok {
289
+ t.Fatalf("expected typed error, got %T (%v)", err, err)
290
+ }
291
+ if typed.Code != want {
292
+ t.Fatalf("unexpected error code: got %s want %s\nerror: %v", typed.Code, want, err)
293
+ }
294
+ }
295
+
296
+ func writeHelperBinary(t *testing.T) string {
297
+ t.Helper()
298
+
299
+ path := filepath.Join(t.TempDir(), "procoder-return")
300
+ if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
301
+ t.Fatalf("write helper binary failed: %v", err)
302
+ }
303
+ return path
304
+ }
305
+
306
+ func unzipTaskPackage(t *testing.T, zipPath string) string {
307
+ t.Helper()
308
+
309
+ reader, err := zip.OpenReader(zipPath)
310
+ if err != nil {
311
+ t.Fatalf("zip.OpenReader failed: %v", err)
312
+ }
313
+ defer reader.Close()
314
+
315
+ dest := t.TempDir()
316
+ for _, file := range reader.File {
317
+ targetPath := filepath.Join(dest, file.Name)
318
+ cleanDest := filepath.Clean(dest) + string(os.PathSeparator)
319
+ if !strings.HasPrefix(filepath.Clean(targetPath), cleanDest) {
320
+ t.Fatalf("zip entry escapes destination: %q", file.Name)
321
+ }
322
+
323
+ if file.FileInfo().IsDir() {
324
+ if err := os.MkdirAll(targetPath, 0o755); err != nil {
325
+ t.Fatalf("mkdir for zip dir failed: %v", err)
326
+ }
327
+ continue
328
+ }
329
+
330
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
331
+ t.Fatalf("mkdir for zip file failed: %v", err)
332
+ }
333
+ in, err := file.Open()
334
+ if err != nil {
335
+ t.Fatalf("open zip file failed: %v", err)
336
+ }
337
+ out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, file.Mode())
338
+ if err != nil {
339
+ in.Close()
340
+ t.Fatalf("create extracted file failed: %v", err)
341
+ }
342
+ if _, err := io.Copy(out, in); err != nil {
343
+ _ = out.Close()
344
+ _ = in.Close()
345
+ t.Fatalf("copy extracted file failed: %v", err)
346
+ }
347
+ _ = out.Close()
348
+ _ = in.Close()
349
+ }
350
+
351
+ return filepath.Join(dest, filepath.Base(filepath.Dir(zipPath)))
352
+ }
353
+
354
+ func readRefsInRepo(t *testing.T, dir, namespace string) map[string]string {
355
+ t.Helper()
356
+
357
+ out := strings.TrimSpace(runGit(t, dir, "for-each-ref", "--format=%(refname) %(objectname)", namespace))
358
+ refs := make(map[string]string)
359
+ if out == "" {
360
+ return refs
361
+ }
362
+ for _, line := range strings.Split(out, "\n") {
363
+ fields := strings.Fields(line)
364
+ if len(fields) < 2 {
365
+ continue
366
+ }
367
+ refs[fields[0]] = fields[1]
368
+ }
369
+ return refs
370
+ }
371
+
372
+ func diffRefMaps(want, got map[string]string) string {
373
+ var lines []string
374
+ for ref, wantOID := range want {
375
+ gotOID, ok := got[ref]
376
+ if !ok {
377
+ lines = append(lines, "missing "+ref)
378
+ continue
379
+ }
380
+ if gotOID != wantOID {
381
+ lines = append(lines, "mismatch "+ref+": got "+gotOID+" want "+wantOID)
382
+ }
383
+ }
384
+ for ref := range got {
385
+ if _, ok := want[ref]; !ok {
386
+ lines = append(lines, "unexpected "+ref)
387
+ }
388
+ }
389
+ return strings.Join(lines, "\n")
390
+ }
391
+
392
+ func runGit(t *testing.T, dir string, args ...string) string {
393
+ t.Helper()
394
+
395
+ cmd := exec.Command("git", args...)
396
+ cmd.Dir = dir
397
+ var stdout bytes.Buffer
398
+ var stderr bytes.Buffer
399
+ cmd.Stdout = &stdout
400
+ cmd.Stderr = &stderr
401
+
402
+ if err := cmd.Run(); err != nil {
403
+ t.Fatalf("git %s failed:\nstdout:\n%s\nstderr:\n%s\nerr:%v", strings.Join(args, " "), stdout.String(), stderr.String(), err)
404
+ }
405
+ return stdout.String()
406
+ }
407
+
408
+ func mustReadFile(t *testing.T, path string) string {
409
+ t.Helper()
410
+
411
+ data, err := os.ReadFile(path)
412
+ if err != nil {
413
+ t.Fatalf("read %s failed: %v", path, err)
414
+ }
415
+ return string(data)
416
+ }