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,788 @@
1
+ package prepare
2
+
3
+ import (
4
+ "archive/zip"
5
+ "bufio"
6
+ "crypto/rand"
7
+ "fmt"
8
+ "io"
9
+ "io/fs"
10
+ "os"
11
+ "path/filepath"
12
+ "runtime"
13
+ "sort"
14
+ "strings"
15
+ "time"
16
+
17
+ "github.com/amxv/procoder/internal/errs"
18
+ "github.com/amxv/procoder/internal/exchange"
19
+ "github.com/amxv/procoder/internal/gitx"
20
+ )
21
+
22
+ const (
23
+ helperEnvVar = "PROCODER_RETURN_HELPER"
24
+ defaultToolVersion = "dev"
25
+ defaultHelperName = "procoder-return"
26
+ defaultHelperAsset = "procoder-return_linux_amd64"
27
+ defaultReturnGlob = "procoder-return-*.zip"
28
+ defaultTaskNameFmt = "procoder-task-%s.zip"
29
+ defaultUserName = "procoder"
30
+ defaultUserEmail = "procoder@local"
31
+ successMessageIntro = "Prepared exchange."
32
+ )
33
+
34
+ type Options struct {
35
+ CWD string
36
+ ToolVersion string
37
+ HelperPath string
38
+ Now func() time.Time
39
+ Random io.Reader
40
+ }
41
+
42
+ type Result struct {
43
+ ExchangeID string
44
+ TaskRootRef string
45
+ TaskPackagePath string
46
+ }
47
+
48
+ func Run(opts Options) (Result, error) {
49
+ cwd, err := resolveCWD(opts.CWD)
50
+ if err != nil {
51
+ return Result{}, errs.Wrap(errs.CodeInternal, "resolve current working directory", err)
52
+ }
53
+
54
+ repoRoot, gitDir, err := resolveRepo(cwd)
55
+ if err != nil {
56
+ return Result{}, err
57
+ }
58
+ sourceGit := gitx.NewRunner(repoRoot)
59
+
60
+ if err := validateSourceRepo(repoRoot, sourceGit); err != nil {
61
+ return Result{}, err
62
+ }
63
+
64
+ headRef, err := readTrimmed(sourceGit, "symbolic-ref", "--quiet", "HEAD")
65
+ if err != nil {
66
+ return Result{}, errs.Wrap(
67
+ errs.CodeInternal,
68
+ "resolve current branch",
69
+ err,
70
+ errs.WithHint("check that HEAD points to a branch and retry"),
71
+ )
72
+ }
73
+ headOID, err := readTrimmed(sourceGit, "rev-parse", "HEAD")
74
+ if err != nil {
75
+ return Result{}, errs.Wrap(errs.CodeInternal, "resolve current HEAD commit", err)
76
+ }
77
+
78
+ heads, err := readRefSnapshot(sourceGit, "refs/heads")
79
+ if err != nil {
80
+ return Result{}, errs.Wrap(errs.CodeInternal, "read local branch snapshot", err)
81
+ }
82
+ tags, err := readRefSnapshot(sourceGit, "refs/tags")
83
+ if err != nil {
84
+ return Result{}, errs.Wrap(errs.CodeInternal, "read local tag snapshot", err)
85
+ }
86
+
87
+ nowFn := opts.Now
88
+ if nowFn == nil {
89
+ nowFn = func() time.Time { return time.Now().UTC() }
90
+ }
91
+ nowUTC := nowFn().UTC()
92
+ random := opts.Random
93
+ if random == nil {
94
+ random = rand.Reader
95
+ }
96
+
97
+ exchangeID, err := exchange.GenerateID(nowUTC, random)
98
+ if err != nil {
99
+ return Result{}, errs.Wrap(errs.CodeInternal, "generate exchange id", err)
100
+ }
101
+ taskRootRef := exchange.TaskRootRef(exchangeID)
102
+ if taskRootRef == "" {
103
+ return Result{}, errs.New(errs.CodeInternal, "generated an invalid exchange id")
104
+ }
105
+ taskRefPrefix := exchange.TaskRefPrefix(exchangeID)
106
+ if taskRefPrefix == "" {
107
+ return Result{}, errs.New(errs.CodeInternal, "generated an invalid task ref prefix")
108
+ }
109
+
110
+ if err := createTaskBranch(sourceGit, taskRootRef, headOID); err != nil {
111
+ return Result{}, err
112
+ }
113
+
114
+ toolVersion := strings.TrimSpace(opts.ToolVersion)
115
+ if toolVersion == "" {
116
+ toolVersion = defaultToolVersion
117
+ }
118
+
119
+ exchangeRecord := exchange.Exchange{
120
+ Protocol: exchange.ExchangeProtocolV1,
121
+ ExchangeID: exchangeID,
122
+ CreatedAt: nowUTC,
123
+ ToolVersion: toolVersion,
124
+ Source: exchange.ExchangeSource{
125
+ HeadRef: headRef,
126
+ HeadOID: headOID,
127
+ },
128
+ Task: exchange.ExchangeTask{
129
+ RootRef: taskRootRef,
130
+ RefPrefix: taskRefPrefix,
131
+ BaseOID: headOID,
132
+ },
133
+ Context: exchange.ExchangeContext{
134
+ Heads: heads,
135
+ Tags: tags,
136
+ },
137
+ }
138
+
139
+ localExchangePath := filepath.Join(gitDir, "procoder", "exchanges", exchangeID, "exchange.json")
140
+ if err := exchange.WriteExchange(localExchangePath, exchangeRecord); err != nil {
141
+ return Result{}, errs.Wrap(errs.CodeInternal, "write local exchange record", err)
142
+ }
143
+
144
+ tempDir, err := os.MkdirTemp("", "procoder-export-*")
145
+ if err != nil {
146
+ return Result{}, errs.Wrap(errs.CodeInternal, "create temporary export directory", err)
147
+ }
148
+ defer os.RemoveAll(tempDir)
149
+
150
+ exportRoot := filepath.Join(tempDir, filepath.Base(repoRoot))
151
+ if err := os.MkdirAll(exportRoot, 0o755); err != nil {
152
+ return Result{}, errs.Wrap(errs.CodeInternal, "create export repository path", err)
153
+ }
154
+ exportGit := gitx.NewRunner(exportRoot)
155
+
156
+ if err := initExportRepo(exportGit); err != nil {
157
+ return Result{}, err
158
+ }
159
+ if err := fetchContextRefs(exportGit, repoRoot); err != nil {
160
+ return Result{}, err
161
+ }
162
+
163
+ exportGitDir, err := resolveGitDir(exportRoot)
164
+ if err != nil {
165
+ return Result{}, err
166
+ }
167
+ if err := stripGitState(exportGitDir); err != nil {
168
+ return Result{}, errs.Wrap(errs.CodeInternal, "sanitize export git metadata", err)
169
+ }
170
+
171
+ if err := configureCommitIdentity(sourceGit, exportGit); err != nil {
172
+ return Result{}, err
173
+ }
174
+ if _, err := exportGit.Run("config", "commit.gpgsign", "false"); err != nil {
175
+ return Result{}, errs.Wrap(errs.CodeInternal, "configure export commit.gpgsign", err)
176
+ }
177
+
178
+ shortTaskRef := strings.TrimPrefix(taskRootRef, "refs/heads/")
179
+ if _, err := exportGit.Run("checkout", "--quiet", shortTaskRef); err != nil {
180
+ return Result{}, errs.Wrap(
181
+ errs.CodeInternal,
182
+ fmt.Sprintf("check out task branch %s in export repository", shortTaskRef),
183
+ err,
184
+ )
185
+ }
186
+
187
+ exportExchangePath := filepath.Join(exportGitDir, "procoder", "exchange.json")
188
+ if err := exchange.WriteExchange(exportExchangePath, exchangeRecord); err != nil {
189
+ return Result{}, errs.Wrap(errs.CodeInternal, "write exported exchange record", err)
190
+ }
191
+
192
+ helperPath, err := resolveHelperBinary(opts.HelperPath)
193
+ if err != nil {
194
+ return Result{}, err
195
+ }
196
+ exportHelperPath := filepath.Join(exportRoot, defaultHelperName)
197
+ if err := copyExecutable(helperPath, exportHelperPath); err != nil {
198
+ return Result{}, errs.Wrap(errs.CodeInternal, "inject procoder-return helper", err)
199
+ }
200
+ if err := appendExclude(exportGitDir, defaultHelperName, defaultReturnGlob); err != nil {
201
+ return Result{}, errs.Wrap(errs.CodeInternal, "update export .git/info/exclude", err)
202
+ }
203
+ if err := stripGitState(exportGitDir); err != nil {
204
+ return Result{}, errs.Wrap(errs.CodeInternal, "finalize export git sanitization", err)
205
+ }
206
+
207
+ taskPackageName := fmt.Sprintf(defaultTaskNameFmt, exchangeID)
208
+ taskPackagePath := filepath.Join(repoRoot, taskPackageName)
209
+ if err := createZip(taskPackagePath, exportRoot, filepath.Base(repoRoot)); err != nil {
210
+ return Result{}, errs.Wrap(errs.CodeInternal, "create task package zip", err)
211
+ }
212
+ if err := appendExclude(gitDir, taskPackageName); err != nil {
213
+ return Result{}, errs.Wrap(errs.CodeInternal, "update source .git/info/exclude", err)
214
+ }
215
+
216
+ absTaskPackage, err := filepath.Abs(taskPackagePath)
217
+ if err != nil {
218
+ return Result{}, errs.Wrap(errs.CodeInternal, "resolve absolute task package path", err)
219
+ }
220
+
221
+ return Result{
222
+ ExchangeID: exchangeID,
223
+ TaskRootRef: taskRootRef,
224
+ TaskPackagePath: absTaskPackage,
225
+ }, nil
226
+ }
227
+
228
+ func FormatSuccess(result Result) string {
229
+ return strings.Join([]string{
230
+ successMessageIntro,
231
+ fmt.Sprintf("Task branch: %s", result.TaskRootRef),
232
+ fmt.Sprintf("Task package: %s", result.TaskPackagePath),
233
+ }, "\n")
234
+ }
235
+
236
+ func resolveCWD(cwd string) (string, error) {
237
+ if strings.TrimSpace(cwd) == "" {
238
+ return os.Getwd()
239
+ }
240
+ return cwd, nil
241
+ }
242
+
243
+ func resolveRepo(cwd string) (string, string, error) {
244
+ root, err := readTrimmed(gitx.NewRunner(cwd), "rev-parse", "--show-toplevel")
245
+ if err != nil {
246
+ if isNotGitRepoError(err) {
247
+ return "", "", errs.New(
248
+ errs.CodeNotGitRepo,
249
+ "current directory is not a Git worktree",
250
+ errs.WithHint("run `procoder prepare` inside a Git repository"),
251
+ )
252
+ }
253
+ return "", "", errs.Wrap(errs.CodeInternal, "resolve repository root", err)
254
+ }
255
+
256
+ gitDir, err := resolveGitDir(root)
257
+ if err != nil {
258
+ return "", "", err
259
+ }
260
+ return root, gitDir, nil
261
+ }
262
+
263
+ func resolveGitDir(repoRoot string) (string, error) {
264
+ gitDirRaw, err := readTrimmed(gitx.NewRunner(repoRoot), "rev-parse", "--git-dir")
265
+ if err != nil {
266
+ if isNotGitRepoError(err) {
267
+ return "", errs.New(
268
+ errs.CodeNotGitRepo,
269
+ "current directory is not a Git worktree",
270
+ errs.WithHint("run `procoder prepare` inside a Git repository"),
271
+ )
272
+ }
273
+ return "", errs.Wrap(errs.CodeInternal, "resolve repository git directory", err)
274
+ }
275
+
276
+ if filepath.IsAbs(gitDirRaw) {
277
+ return filepath.Clean(gitDirRaw), nil
278
+ }
279
+ return filepath.Clean(filepath.Join(repoRoot, gitDirRaw)), nil
280
+ }
281
+
282
+ func validateSourceRepo(repoRoot string, runner gitx.Runner) error {
283
+ submodules, err := detectSubmodules(runner)
284
+ if err != nil {
285
+ return err
286
+ }
287
+ if len(submodules) > 0 {
288
+ sort.Strings(submodules)
289
+ details := []string{"Submodule paths:"}
290
+ for _, path := range submodules {
291
+ details = append(details, " "+path)
292
+ }
293
+ return errs.New(
294
+ errs.CodeSubmodulesUnsupported,
295
+ "submodules are not supported in procoder v1",
296
+ errs.WithDetails(details...),
297
+ errs.WithHint("remove submodule entries before running `procoder prepare`"),
298
+ )
299
+ }
300
+
301
+ status, err := runner.Run("status", "--porcelain=v1", "--untracked-files=all")
302
+ if err != nil {
303
+ if isNotGitRepoError(err) {
304
+ return errs.New(
305
+ errs.CodeNotGitRepo,
306
+ "current directory is not a Git worktree",
307
+ errs.WithHint("run `procoder prepare` inside a Git repository"),
308
+ )
309
+ }
310
+ return errs.Wrap(errs.CodeInternal, "read repository status", err)
311
+ }
312
+
313
+ var dirty []string
314
+ var untracked []string
315
+ for _, line := range splitNonEmptyLines(status.Stdout) {
316
+ if strings.HasPrefix(line, "?? ") {
317
+ untracked = append(untracked, strings.TrimPrefix(line, "?? "))
318
+ continue
319
+ }
320
+ dirty = append(dirty, line)
321
+ }
322
+
323
+ if len(untracked) > 0 {
324
+ sort.Strings(untracked)
325
+ details := []string{"Untracked files:"}
326
+ for _, path := range untracked {
327
+ details = append(details, " "+path)
328
+ }
329
+ return errs.New(
330
+ errs.CodeUntrackedFilesPresent,
331
+ "repository has untracked files",
332
+ errs.WithDetails(details...),
333
+ errs.WithHint("add, commit, or ignore untracked files, then retry"),
334
+ )
335
+ }
336
+
337
+ if len(dirty) > 0 {
338
+ details := []string{"Uncommitted changes:"}
339
+ for _, line := range dirty {
340
+ details = append(details, " "+line)
341
+ }
342
+ return errs.New(
343
+ errs.CodeWorktreeDirty,
344
+ "repository has staged or unstaged changes",
345
+ errs.WithDetails(details...),
346
+ errs.WithHint("commit or discard local changes, then retry"),
347
+ )
348
+ }
349
+
350
+ lfsSignals, err := detectLFSSignals(repoRoot)
351
+ if err != nil {
352
+ return errs.Wrap(errs.CodeInternal, "detect Git LFS usage", err)
353
+ }
354
+ if len(lfsSignals) > 0 {
355
+ sort.Strings(lfsSignals)
356
+ details := []string{"Git LFS signals:"}
357
+ for _, signal := range lfsSignals {
358
+ details = append(details, " "+signal)
359
+ }
360
+ return errs.New(
361
+ errs.CodeLFSUnsupported,
362
+ "Git LFS is not supported in procoder v1",
363
+ errs.WithDetails(details...),
364
+ errs.WithHint("remove Git LFS filters from attributes/config and retry"),
365
+ )
366
+ }
367
+
368
+ return nil
369
+ }
370
+
371
+ func detectSubmodules(runner gitx.Runner) ([]string, error) {
372
+ result, err := runner.Run("ls-files", "--stage")
373
+ if err != nil {
374
+ return nil, errs.Wrap(errs.CodeInternal, "inspect index entries for submodules", err)
375
+ }
376
+
377
+ var paths []string
378
+ for _, line := range splitNonEmptyLines(result.Stdout) {
379
+ stagePart, pathPart, ok := strings.Cut(line, "\t")
380
+ if !ok {
381
+ continue
382
+ }
383
+ fields := strings.Fields(stagePart)
384
+ if len(fields) == 0 {
385
+ continue
386
+ }
387
+ if fields[0] == "160000" {
388
+ paths = append(paths, pathPart)
389
+ }
390
+ }
391
+ return paths, nil
392
+ }
393
+
394
+ func detectLFSSignals(repoRoot string) ([]string, error) {
395
+ var signals []string
396
+
397
+ lfsConfigPath := filepath.Join(repoRoot, ".lfsconfig")
398
+ if _, err := os.Stat(lfsConfigPath); err == nil {
399
+ signals = append(signals, ".lfsconfig")
400
+ } else if !os.IsNotExist(err) {
401
+ return nil, err
402
+ }
403
+
404
+ err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, walkErr error) error {
405
+ if walkErr != nil {
406
+ return walkErr
407
+ }
408
+ if d.IsDir() && d.Name() == ".git" {
409
+ return filepath.SkipDir
410
+ }
411
+ if d.IsDir() || d.Name() != ".gitattributes" {
412
+ return nil
413
+ }
414
+
415
+ data, err := os.ReadFile(path)
416
+ if err != nil {
417
+ return err
418
+ }
419
+ content := string(data)
420
+ if strings.Contains(content, "filter=lfs") || strings.Contains(content, "diff=lfs") || strings.Contains(content, "merge=lfs") {
421
+ rel, relErr := filepath.Rel(repoRoot, path)
422
+ if relErr != nil {
423
+ return relErr
424
+ }
425
+ signals = append(signals, filepath.ToSlash(rel))
426
+ }
427
+ return nil
428
+ })
429
+ if err != nil {
430
+ return nil, err
431
+ }
432
+
433
+ return signals, nil
434
+ }
435
+
436
+ func createTaskBranch(runner gitx.Runner, taskRef, headOID string) error {
437
+ if _, err := runner.Run("update-ref", taskRef, headOID, ""); err != nil {
438
+ return errs.Wrap(
439
+ errs.CodeInternal,
440
+ fmt.Sprintf("create task branch %s", taskRef),
441
+ err,
442
+ errs.WithHint("retry `procoder prepare` to create a new exchange"),
443
+ )
444
+ }
445
+ return nil
446
+ }
447
+
448
+ func fetchContextRefs(exportRunner gitx.Runner, sourceRepo string) error {
449
+ if _, err := exportRunner.Run("fetch", "--no-tags", sourceRepo, "+refs/heads/*:refs/heads/*"); err != nil {
450
+ return errs.Wrap(errs.CodeInternal, "fetch local branches into export repository", err)
451
+ }
452
+ if _, err := exportRunner.Run("fetch", "--no-tags", sourceRepo, "+refs/tags/*:refs/tags/*"); err != nil {
453
+ return errs.Wrap(errs.CodeInternal, "fetch local tags into export repository", err)
454
+ }
455
+ return nil
456
+ }
457
+
458
+ func initExportRepo(exportGit gitx.Runner) error {
459
+ if _, err := exportGit.Run("init", "--initial-branch=procoder-export-empty"); err == nil {
460
+ return nil
461
+ }
462
+
463
+ if _, err := exportGit.Run("init"); err != nil {
464
+ return errs.Wrap(errs.CodeInternal, "initialize export repository", err)
465
+ }
466
+ if _, err := exportGit.Run("checkout", "--orphan", "procoder-export-empty"); err != nil {
467
+ return errs.Wrap(errs.CodeInternal, "create export placeholder branch", err)
468
+ }
469
+ return nil
470
+ }
471
+
472
+ func stripGitState(gitDir string) error {
473
+ for _, rel := range []string{"hooks", "logs", "refs/remotes"} {
474
+ if err := os.RemoveAll(filepath.Join(gitDir, rel)); err != nil {
475
+ return err
476
+ }
477
+ }
478
+ if err := os.Remove(filepath.Join(gitDir, "FETCH_HEAD")); err != nil && !os.IsNotExist(err) {
479
+ return err
480
+ }
481
+ return nil
482
+ }
483
+
484
+ func configureCommitIdentity(source, target gitx.Runner) error {
485
+ userName, err := readGitConfigValue(source, "--local", "user.name")
486
+ if err != nil {
487
+ return errs.Wrap(errs.CodeInternal, "read source user.name", err)
488
+ }
489
+ if userName == "" {
490
+ userName, err = readGitConfigValue(source, "--global", "user.name")
491
+ if err != nil {
492
+ return errs.Wrap(errs.CodeInternal, "read global user.name", err)
493
+ }
494
+ }
495
+ if userName == "" {
496
+ userName = defaultUserName
497
+ }
498
+
499
+ userEmail, err := readGitConfigValue(source, "--local", "user.email")
500
+ if err != nil {
501
+ return errs.Wrap(errs.CodeInternal, "read source user.email", err)
502
+ }
503
+ if userEmail == "" {
504
+ userEmail, err = readGitConfigValue(source, "--global", "user.email")
505
+ if err != nil {
506
+ return errs.Wrap(errs.CodeInternal, "read global user.email", err)
507
+ }
508
+ }
509
+ if userEmail == "" {
510
+ userEmail = defaultUserEmail
511
+ }
512
+
513
+ if _, err := target.Run("config", "user.name", userName); err != nil {
514
+ return errs.Wrap(errs.CodeInternal, "set export user.name", err)
515
+ }
516
+ if _, err := target.Run("config", "user.email", userEmail); err != nil {
517
+ return errs.Wrap(errs.CodeInternal, "set export user.email", err)
518
+ }
519
+ return nil
520
+ }
521
+
522
+ func readGitConfigValue(runner gitx.Runner, scope, key string) (string, error) {
523
+ result, err := runner.Run("config", scope, "--get", key)
524
+ if err == nil {
525
+ return strings.TrimSpace(result.Stdout), nil
526
+ }
527
+ if result.ExitCode == 1 {
528
+ return "", nil
529
+ }
530
+ return "", err
531
+ }
532
+
533
+ func resolveHelperBinary(explicit string) (string, error) {
534
+ envValue := os.Getenv(helperEnvVar)
535
+ executablePath, _ := os.Executable()
536
+ candidates := helperCandidatePaths(explicit, envValue, executablePath)
537
+ if resolved, err := resolveHelperBinaryFromCandidates(candidates); err == nil {
538
+ return resolved, nil
539
+ }
540
+
541
+ return "", errs.New(
542
+ errs.CodeInternal,
543
+ "procoder-return helper binary is not available",
544
+ errs.WithHint("install or build procoder-return_linux_amd64, or set PROCODER_RETURN_HELPER to a prebuilt helper path and retry"),
545
+ )
546
+ }
547
+
548
+ func helperCandidatePaths(explicit, envValue, executablePath string) []string {
549
+ var candidates []string
550
+ if v := strings.TrimSpace(explicit); v != "" {
551
+ candidates = append(candidates, v)
552
+ }
553
+ if v := strings.TrimSpace(envValue); v != "" {
554
+ candidates = append(candidates, v)
555
+ }
556
+ if v := strings.TrimSpace(executablePath); v != "" {
557
+ exeDir := filepath.Dir(v)
558
+ candidates = append(candidates,
559
+ filepath.Join(exeDir, defaultHelperAsset),
560
+ filepath.Join(exeDir, defaultHelperName),
561
+ )
562
+ }
563
+
564
+ seen := make(map[string]struct{}, len(candidates))
565
+ ordered := make([]string, 0, len(candidates))
566
+ for _, candidate := range candidates {
567
+ candidate = filepath.Clean(candidate)
568
+ if _, ok := seen[candidate]; ok {
569
+ continue
570
+ }
571
+ seen[candidate] = struct{}{}
572
+ ordered = append(ordered, candidate)
573
+ }
574
+ return ordered
575
+ }
576
+
577
+ func resolveHelperBinaryFromCandidates(candidates []string) (string, error) {
578
+ for _, candidate := range candidates {
579
+ if !isUsableHelperBinary(candidate) {
580
+ continue
581
+ }
582
+ absPath, err := filepath.Abs(candidate)
583
+ if err != nil {
584
+ return "", err
585
+ }
586
+ return absPath, nil
587
+ }
588
+ return "", os.ErrNotExist
589
+ }
590
+
591
+ func isUsableHelperBinary(path string) bool {
592
+ info, err := os.Stat(path)
593
+ if err != nil {
594
+ return false
595
+ }
596
+ if info.IsDir() {
597
+ return false
598
+ }
599
+ if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
600
+ return false
601
+ }
602
+ return true
603
+ }
604
+
605
+ func copyExecutable(src, dst string) error {
606
+ in, err := os.Open(src)
607
+ if err != nil {
608
+ return err
609
+ }
610
+ defer in.Close()
611
+
612
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
613
+ return err
614
+ }
615
+
616
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
617
+ if err != nil {
618
+ return err
619
+ }
620
+ defer out.Close()
621
+
622
+ if _, err := io.Copy(out, in); err != nil {
623
+ return err
624
+ }
625
+ return out.Chmod(0o755)
626
+ }
627
+
628
+ func appendExclude(gitDir string, patterns ...string) error {
629
+ excludePath := filepath.Join(gitDir, "info", "exclude")
630
+ if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
631
+ return err
632
+ }
633
+
634
+ existing := make(map[string]struct{})
635
+ if data, err := os.ReadFile(excludePath); err == nil {
636
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
637
+ for scanner.Scan() {
638
+ line := strings.TrimSpace(scanner.Text())
639
+ if line == "" || strings.HasPrefix(line, "#") {
640
+ continue
641
+ }
642
+ existing[line] = struct{}{}
643
+ }
644
+ if err := scanner.Err(); err != nil {
645
+ return err
646
+ }
647
+ } else if !os.IsNotExist(err) {
648
+ return err
649
+ }
650
+
651
+ f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
652
+ if err != nil {
653
+ return err
654
+ }
655
+ defer f.Close()
656
+
657
+ for _, pattern := range patterns {
658
+ pattern = strings.TrimSpace(pattern)
659
+ if pattern == "" {
660
+ continue
661
+ }
662
+ if _, ok := existing[pattern]; ok {
663
+ continue
664
+ }
665
+ if _, err := fmt.Fprintln(f, pattern); err != nil {
666
+ return err
667
+ }
668
+ }
669
+ return nil
670
+ }
671
+
672
+ func createZip(targetZip, sourceDir, rootPrefix string) error {
673
+ file, err := os.Create(targetZip)
674
+ if err != nil {
675
+ return err
676
+ }
677
+ defer file.Close()
678
+
679
+ zw := zip.NewWriter(file)
680
+ defer zw.Close()
681
+
682
+ return filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error {
683
+ if walkErr != nil {
684
+ return walkErr
685
+ }
686
+
687
+ rel, err := filepath.Rel(sourceDir, path)
688
+ if err != nil {
689
+ return err
690
+ }
691
+ if rel == "." {
692
+ return nil
693
+ }
694
+
695
+ zipPath := filepath.ToSlash(filepath.Join(rootPrefix, rel))
696
+ info, err := d.Info()
697
+ if err != nil {
698
+ return err
699
+ }
700
+ header, err := zip.FileInfoHeader(info)
701
+ if err != nil {
702
+ return err
703
+ }
704
+ header.Name = zipPath
705
+ if d.IsDir() {
706
+ header.Name += "/"
707
+ } else {
708
+ header.Method = zip.Deflate
709
+ }
710
+
711
+ writer, err := zw.CreateHeader(header)
712
+ if err != nil {
713
+ return err
714
+ }
715
+ if d.IsDir() {
716
+ return nil
717
+ }
718
+
719
+ in, err := os.Open(path)
720
+ if err != nil {
721
+ return err
722
+ }
723
+ defer in.Close()
724
+
725
+ if _, err := io.Copy(writer, in); err != nil {
726
+ return err
727
+ }
728
+ return nil
729
+ })
730
+ }
731
+
732
+ func readRefSnapshot(runner gitx.Runner, namespace string) (map[string]string, error) {
733
+ result, err := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", namespace)
734
+ if err != nil {
735
+ return nil, err
736
+ }
737
+
738
+ refs := make(map[string]string)
739
+ for _, line := range splitNonEmptyLines(result.Stdout) {
740
+ fields := strings.Fields(line)
741
+ if len(fields) < 2 {
742
+ continue
743
+ }
744
+ refs[fields[0]] = fields[1]
745
+ }
746
+ return refs, nil
747
+ }
748
+
749
+ func readTrimmed(runner gitx.Runner, args ...string) (string, error) {
750
+ result, err := runner.Run(args...)
751
+ if err != nil {
752
+ return "", err
753
+ }
754
+ return strings.TrimSpace(result.Stdout), nil
755
+ }
756
+
757
+ func splitNonEmptyLines(s string) []string {
758
+ raw := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
759
+ lines := make([]string, 0, len(raw))
760
+ for _, line := range raw {
761
+ if line == "" {
762
+ continue
763
+ }
764
+ lines = append(lines, line)
765
+ }
766
+ return lines
767
+ }
768
+
769
+ func isNotGitRepoError(err error) bool {
770
+ typed, ok := errs.As(err)
771
+ if !ok {
772
+ return false
773
+ }
774
+ if typed.Code != errs.CodeGitCommandFailed {
775
+ return false
776
+ }
777
+
778
+ var payload strings.Builder
779
+ payload.WriteString(strings.ToLower(typed.Message))
780
+ for _, detail := range typed.Details {
781
+ payload.WriteByte('\n')
782
+ payload.WriteString(strings.ToLower(detail))
783
+ }
784
+
785
+ all := payload.String()
786
+ return strings.Contains(all, "not a git repository") ||
787
+ strings.Contains(all, "must be run in a work tree")
788
+ }