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,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
|
+
}
|