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,589 @@
|
|
|
1
|
+
package returnpkg
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"bufio"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"sort"
|
|
11
|
+
"strings"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/amxv/procoder/internal/errs"
|
|
15
|
+
"github.com/amxv/procoder/internal/exchange"
|
|
16
|
+
"github.com/amxv/procoder/internal/gitx"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const (
|
|
20
|
+
defaultToolVersion = "dev"
|
|
21
|
+
exchangeRelPath = ".git/procoder/exchange.json"
|
|
22
|
+
returnJSONName = "procoder-return.json"
|
|
23
|
+
returnBundleName = "procoder-return.bundle"
|
|
24
|
+
returnZipNameFmt = "procoder-return-%s.zip"
|
|
25
|
+
successIntro = "Created return package."
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
type Options struct {
|
|
29
|
+
CWD string
|
|
30
|
+
ToolVersion string
|
|
31
|
+
Now func() time.Time
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Result struct {
|
|
35
|
+
ExchangeID string
|
|
36
|
+
ReturnPackagePath string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func Run(opts Options) (Result, error) {
|
|
40
|
+
cwd, err := resolveCWD(opts.CWD)
|
|
41
|
+
if err != nil {
|
|
42
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "resolve current working directory", err)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
repoRoot, gitDir, err := resolveRepo(cwd)
|
|
46
|
+
if err != nil {
|
|
47
|
+
return Result{}, err
|
|
48
|
+
}
|
|
49
|
+
runner := gitx.NewRunner(repoRoot)
|
|
50
|
+
|
|
51
|
+
exchangePath := filepath.Join(gitDir, "procoder", "exchange.json")
|
|
52
|
+
ex, err := readExchange(exchangePath)
|
|
53
|
+
if err != nil {
|
|
54
|
+
return Result{}, err
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if err := validateCleanWorktree(runner); err != nil {
|
|
58
|
+
return Result{}, err
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
currentHeads, err := readRefSnapshot(runner, "refs/heads")
|
|
62
|
+
if err != nil {
|
|
63
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "read current branch refs", err)
|
|
64
|
+
}
|
|
65
|
+
currentTags, err := readRefSnapshot(runner, "refs/tags")
|
|
66
|
+
if err != nil {
|
|
67
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "read current tag refs", err)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
taskUpdates, outOfScopeHeadChanges, err := compareHeadChanges(ex, currentHeads)
|
|
71
|
+
if err != nil {
|
|
72
|
+
return Result{}, err
|
|
73
|
+
}
|
|
74
|
+
tagChanges := diffRefChanges(ex.Context.Tags, currentTags)
|
|
75
|
+
|
|
76
|
+
if len(outOfScopeHeadChanges) > 0 {
|
|
77
|
+
return Result{}, errs.New(
|
|
78
|
+
errs.CodeRefOutOfScope,
|
|
79
|
+
"changed refs are outside the allowed task branch family",
|
|
80
|
+
errs.WithDetails(renderRefScopeDetails(outOfScopeHeadChanges, ex.Task.RefPrefix)...),
|
|
81
|
+
errs.WithHint("move your commits onto the task branch family, then rerun ./procoder-return"),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if len(tagChanges) > 0 {
|
|
86
|
+
return Result{}, errs.New(
|
|
87
|
+
errs.CodeRefOutOfScope,
|
|
88
|
+
"tag changes are not supported in procoder v1 return packages",
|
|
89
|
+
errs.WithDetails(renderTagChangeDetails(tagChanges)...),
|
|
90
|
+
errs.WithHint("remove tag changes, then rerun ./procoder-return"),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if len(taskUpdates) == 0 {
|
|
95
|
+
return Result{}, errs.New(
|
|
96
|
+
errs.CodeNoNewCommits,
|
|
97
|
+
"no new commits found in the task branch family",
|
|
98
|
+
errs.WithDetails(fmt.Sprintf("Task branch family: %s/*", ex.Task.RefPrefix)),
|
|
99
|
+
errs.WithHint("create at least one commit on the task branch family, then rerun ./procoder-return"),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if err := validateDescendants(runner, ex, taskUpdates); err != nil {
|
|
104
|
+
return Result{}, err
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
toolVersion := strings.TrimSpace(opts.ToolVersion)
|
|
108
|
+
if toolVersion == "" {
|
|
109
|
+
toolVersion = defaultToolVersion
|
|
110
|
+
}
|
|
111
|
+
nowFn := opts.Now
|
|
112
|
+
if nowFn == nil {
|
|
113
|
+
nowFn = func() time.Time { return time.Now().UTC() }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ret := exchange.Return{
|
|
117
|
+
Protocol: exchange.ReturnProtocolV1,
|
|
118
|
+
ExchangeID: ex.ExchangeID,
|
|
119
|
+
CreatedAt: nowFn().UTC(),
|
|
120
|
+
ToolVersion: toolVersion,
|
|
121
|
+
BundleFile: returnBundleName,
|
|
122
|
+
Task: exchange.ReturnTask{
|
|
123
|
+
RootRef: ex.Task.RootRef,
|
|
124
|
+
BaseOID: ex.Task.BaseOID,
|
|
125
|
+
},
|
|
126
|
+
Updates: taskUpdates,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
tempDir, err := os.MkdirTemp("", "procoder-return-*")
|
|
130
|
+
if err != nil {
|
|
131
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "create temporary return package directory", err)
|
|
132
|
+
}
|
|
133
|
+
defer os.RemoveAll(tempDir)
|
|
134
|
+
|
|
135
|
+
returnJSONPath := filepath.Join(tempDir, returnJSONName)
|
|
136
|
+
if err := exchange.WriteReturn(returnJSONPath, ret); err != nil {
|
|
137
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "write return package metadata", err)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
bundlePath := filepath.Join(tempDir, returnBundleName)
|
|
141
|
+
if err := createBundle(runner, bundlePath, ex.Task.BaseOID, taskUpdates); err != nil {
|
|
142
|
+
return Result{}, err
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
returnPackageName := fmt.Sprintf(returnZipNameFmt, ex.ExchangeID)
|
|
146
|
+
returnPackagePath := filepath.Join(repoRoot, returnPackageName)
|
|
147
|
+
if err := createZip(returnPackagePath, map[string]string{
|
|
148
|
+
returnJSONName: returnJSONPath,
|
|
149
|
+
returnBundleName: bundlePath,
|
|
150
|
+
}); err != nil {
|
|
151
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "create return package zip", err)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if err := appendExclude(gitDir, returnPackageName); err != nil {
|
|
155
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "update .git/info/exclude with return package", err)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
absPath, err := filepath.Abs(returnPackagePath)
|
|
159
|
+
if err != nil {
|
|
160
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "resolve absolute return package path", err)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return Result{
|
|
164
|
+
ExchangeID: ex.ExchangeID,
|
|
165
|
+
ReturnPackagePath: absPath,
|
|
166
|
+
}, nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func FormatSuccess(result Result) string {
|
|
170
|
+
sandboxPath := "sandbox:" + filepath.ToSlash(result.ReturnPackagePath)
|
|
171
|
+
return strings.Join([]string{
|
|
172
|
+
successIntro,
|
|
173
|
+
fmt.Sprintf("Return package: %s", result.ReturnPackagePath),
|
|
174
|
+
fmt.Sprintf("Sandbox hint: %s", sandboxPath),
|
|
175
|
+
}, "\n")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type refChange struct {
|
|
179
|
+
Ref string
|
|
180
|
+
OldOID string
|
|
181
|
+
NewOID string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func compareHeadChanges(ex exchange.Exchange, currentHeads map[string]string) ([]exchange.RefUpdate, []refChange, error) {
|
|
185
|
+
baselineHeads := map[string]string{}
|
|
186
|
+
for ref, oid := range ex.Context.Heads {
|
|
187
|
+
baselineHeads[ref] = oid
|
|
188
|
+
}
|
|
189
|
+
if _, ok := baselineHeads[ex.Task.RootRef]; !ok {
|
|
190
|
+
baselineHeads[ex.Task.RootRef] = ex.Task.BaseOID
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
changes := diffRefChanges(baselineHeads, currentHeads)
|
|
194
|
+
taskUpdates := make([]exchange.RefUpdate, 0, len(changes))
|
|
195
|
+
outOfScope := make([]refChange, 0)
|
|
196
|
+
|
|
197
|
+
for _, change := range changes {
|
|
198
|
+
if !exchange.IsTaskRef(ex.ExchangeID, change.Ref) {
|
|
199
|
+
outOfScope = append(outOfScope, change)
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if change.NewOID == "" {
|
|
203
|
+
return nil, nil, errs.New(
|
|
204
|
+
errs.CodeRefOutOfScope,
|
|
205
|
+
"task branch deletions are not supported in procoder v1 return packages",
|
|
206
|
+
errs.WithDetails(
|
|
207
|
+
fmt.Sprintf("Deleted ref: %s", change.Ref),
|
|
208
|
+
fmt.Sprintf("Task branch family: %s/*", ex.Task.RefPrefix),
|
|
209
|
+
),
|
|
210
|
+
errs.WithHint("restore the deleted task branch and rerun ./procoder-return"),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
taskUpdates = append(taskUpdates, exchange.RefUpdate{
|
|
214
|
+
Ref: change.Ref,
|
|
215
|
+
OldOID: change.OldOID,
|
|
216
|
+
NewOID: change.NewOID,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
sort.Slice(taskUpdates, func(i, j int) bool { return taskUpdates[i].Ref < taskUpdates[j].Ref })
|
|
221
|
+
sort.Slice(outOfScope, func(i, j int) bool { return outOfScope[i].Ref < outOfScope[j].Ref })
|
|
222
|
+
return taskUpdates, outOfScope, nil
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func validateDescendants(runner gitx.Runner, ex exchange.Exchange, updates []exchange.RefUpdate) error {
|
|
226
|
+
for _, update := range updates {
|
|
227
|
+
result, err := runner.Run("merge-base", "--is-ancestor", ex.Task.BaseOID, update.NewOID)
|
|
228
|
+
if err == nil {
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if result.ExitCode == 1 {
|
|
232
|
+
return errs.New(
|
|
233
|
+
errs.CodeRefOutOfScope,
|
|
234
|
+
"changed task ref does not descend from the task base commit",
|
|
235
|
+
errs.WithDetails(
|
|
236
|
+
fmt.Sprintf("Ref: %s", update.Ref),
|
|
237
|
+
fmt.Sprintf("Task base OID: %s", ex.Task.BaseOID),
|
|
238
|
+
fmt.Sprintf("Ref OID: %s", update.NewOID),
|
|
239
|
+
),
|
|
240
|
+
errs.WithHint("move this ref to a descendant of the task base commit, then rerun ./procoder-return"),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return errs.Wrap(errs.CodeInternal, fmt.Sprintf("validate task ref ancestry for %s", update.Ref), err)
|
|
244
|
+
}
|
|
245
|
+
return nil
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func createBundle(runner gitx.Runner, bundlePath, baseOID string, updates []exchange.RefUpdate) error {
|
|
249
|
+
args := []string{"bundle", "create", bundlePath}
|
|
250
|
+
for _, update := range updates {
|
|
251
|
+
args = append(args, fmt.Sprintf("%s..%s", baseOID, update.Ref))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if _, err := runner.Run(args...); err != nil {
|
|
255
|
+
return errs.Wrap(errs.CodeInternal, "create procoder return bundle", err)
|
|
256
|
+
}
|
|
257
|
+
return nil
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func createZip(target string, files map[string]string) error {
|
|
261
|
+
out, err := os.Create(target)
|
|
262
|
+
if err != nil {
|
|
263
|
+
return err
|
|
264
|
+
}
|
|
265
|
+
defer out.Close()
|
|
266
|
+
|
|
267
|
+
zw := zip.NewWriter(out)
|
|
268
|
+
defer zw.Close()
|
|
269
|
+
|
|
270
|
+
names := make([]string, 0, len(files))
|
|
271
|
+
for name := range files {
|
|
272
|
+
names = append(names, name)
|
|
273
|
+
}
|
|
274
|
+
sort.Strings(names)
|
|
275
|
+
|
|
276
|
+
for _, name := range names {
|
|
277
|
+
src := files[name]
|
|
278
|
+
info, err := os.Stat(src)
|
|
279
|
+
if err != nil {
|
|
280
|
+
return err
|
|
281
|
+
}
|
|
282
|
+
header, err := zip.FileInfoHeader(info)
|
|
283
|
+
if err != nil {
|
|
284
|
+
return err
|
|
285
|
+
}
|
|
286
|
+
header.Name = name
|
|
287
|
+
header.Method = zip.Deflate
|
|
288
|
+
|
|
289
|
+
writer, err := zw.CreateHeader(header)
|
|
290
|
+
if err != nil {
|
|
291
|
+
return err
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
in, err := os.Open(src)
|
|
295
|
+
if err != nil {
|
|
296
|
+
return err
|
|
297
|
+
}
|
|
298
|
+
if _, err := io.Copy(writer, in); err != nil {
|
|
299
|
+
_ = in.Close()
|
|
300
|
+
return err
|
|
301
|
+
}
|
|
302
|
+
_ = in.Close()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return nil
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func validateCleanWorktree(runner gitx.Runner) error {
|
|
309
|
+
result, err := runner.Run("status", "--porcelain=v1", "--untracked-files=all")
|
|
310
|
+
if err != nil {
|
|
311
|
+
return errs.Wrap(errs.CodeInternal, "read repository status", err)
|
|
312
|
+
}
|
|
313
|
+
lines := splitNonEmptyLines(result.Stdout)
|
|
314
|
+
if len(lines) == 0 {
|
|
315
|
+
return nil
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
details := []string{"Found:"}
|
|
319
|
+
for _, line := range lines {
|
|
320
|
+
details = append(details, " "+line)
|
|
321
|
+
}
|
|
322
|
+
return errs.New(
|
|
323
|
+
errs.CodeWorktreeDirty,
|
|
324
|
+
"repository has uncommitted or untracked changes",
|
|
325
|
+
errs.WithDetails(details...),
|
|
326
|
+
errs.WithHint("commit or discard these changes, then rerun ./procoder-return"),
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
func readExchange(path string) (exchange.Exchange, error) {
|
|
331
|
+
ex, err := exchange.ReadExchange(path)
|
|
332
|
+
if err != nil {
|
|
333
|
+
return exchange.Exchange{}, errs.New(
|
|
334
|
+
errs.CodeInvalidExchange,
|
|
335
|
+
"missing or invalid exchange metadata",
|
|
336
|
+
errs.WithDetails(
|
|
337
|
+
fmt.Sprintf("Path: %s", exchangeRelPath),
|
|
338
|
+
),
|
|
339
|
+
errs.WithHint("run ./procoder-return only inside a repository created by procoder prepare"),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if err := validateExchange(ex); err != nil {
|
|
344
|
+
return exchange.Exchange{}, err
|
|
345
|
+
}
|
|
346
|
+
return ex, nil
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
func validateExchange(ex exchange.Exchange) error {
|
|
350
|
+
if ex.ExchangeID == "" {
|
|
351
|
+
return invalidExchange("exchange id is missing")
|
|
352
|
+
}
|
|
353
|
+
if ex.Task.RootRef == "" || ex.Task.BaseOID == "" || ex.Task.RefPrefix == "" {
|
|
354
|
+
return invalidExchange("task metadata is incomplete")
|
|
355
|
+
}
|
|
356
|
+
if ex.Task.RootRef != exchange.TaskRootRef(ex.ExchangeID) {
|
|
357
|
+
return invalidExchange("task root ref does not match exchange id")
|
|
358
|
+
}
|
|
359
|
+
expectedPrefix := exchange.TaskRefPrefix(ex.ExchangeID)
|
|
360
|
+
if ex.Task.RefPrefix != expectedPrefix {
|
|
361
|
+
return invalidExchange("task ref prefix does not match exchange id")
|
|
362
|
+
}
|
|
363
|
+
if !exchange.IsTaskRef(ex.ExchangeID, ex.Task.RootRef) {
|
|
364
|
+
return invalidExchange("task root ref is outside the task branch family")
|
|
365
|
+
}
|
|
366
|
+
return nil
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
func invalidExchange(problem string) error {
|
|
370
|
+
return errs.New(
|
|
371
|
+
errs.CodeInvalidExchange,
|
|
372
|
+
problem,
|
|
373
|
+
errs.WithDetails(fmt.Sprintf("Path: %s", exchangeRelPath)),
|
|
374
|
+
errs.WithHint("run ./procoder-return only inside a repository created by procoder prepare"),
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func resolveCWD(cwd string) (string, error) {
|
|
379
|
+
if strings.TrimSpace(cwd) == "" {
|
|
380
|
+
return os.Getwd()
|
|
381
|
+
}
|
|
382
|
+
return cwd, nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
func resolveRepo(cwd string) (string, string, error) {
|
|
386
|
+
root, err := readTrimmed(gitx.NewRunner(cwd), "rev-parse", "--show-toplevel")
|
|
387
|
+
if err != nil {
|
|
388
|
+
if isNotGitRepoError(err) {
|
|
389
|
+
return "", "", errs.New(
|
|
390
|
+
errs.CodeNotGitRepo,
|
|
391
|
+
"current directory is not a Git worktree",
|
|
392
|
+
errs.WithHint("run ./procoder-return inside a prepared task package repository"),
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
return "", "", errs.Wrap(errs.CodeInternal, "resolve repository root", err)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
gitDirRaw, err := readTrimmed(gitx.NewRunner(root), "rev-parse", "--git-dir")
|
|
399
|
+
if err != nil {
|
|
400
|
+
return "", "", errs.Wrap(errs.CodeInternal, "resolve repository git directory", err)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
gitDir := gitDirRaw
|
|
404
|
+
if !filepath.IsAbs(gitDirRaw) {
|
|
405
|
+
gitDir = filepath.Join(root, gitDirRaw)
|
|
406
|
+
}
|
|
407
|
+
return root, filepath.Clean(gitDir), nil
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func readRefSnapshot(runner gitx.Runner, namespace string) (map[string]string, error) {
|
|
411
|
+
result, err := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", namespace)
|
|
412
|
+
if err != nil {
|
|
413
|
+
return nil, err
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
refs := make(map[string]string)
|
|
417
|
+
for _, line := range splitNonEmptyLines(result.Stdout) {
|
|
418
|
+
fields := strings.Fields(line)
|
|
419
|
+
if len(fields) < 2 {
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
refs[fields[0]] = fields[1]
|
|
423
|
+
}
|
|
424
|
+
return refs, nil
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
func diffRefChanges(baseline, current map[string]string) []refChange {
|
|
428
|
+
normalizedBaseline := baseline
|
|
429
|
+
if normalizedBaseline == nil {
|
|
430
|
+
normalizedBaseline = map[string]string{}
|
|
431
|
+
}
|
|
432
|
+
normalizedCurrent := current
|
|
433
|
+
if normalizedCurrent == nil {
|
|
434
|
+
normalizedCurrent = map[string]string{}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
keySet := map[string]struct{}{}
|
|
438
|
+
for ref := range normalizedBaseline {
|
|
439
|
+
keySet[ref] = struct{}{}
|
|
440
|
+
}
|
|
441
|
+
for ref := range normalizedCurrent {
|
|
442
|
+
keySet[ref] = struct{}{}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
refs := make([]string, 0, len(keySet))
|
|
446
|
+
for ref := range keySet {
|
|
447
|
+
refs = append(refs, ref)
|
|
448
|
+
}
|
|
449
|
+
sort.Strings(refs)
|
|
450
|
+
|
|
451
|
+
changes := make([]refChange, 0, len(refs))
|
|
452
|
+
for _, ref := range refs {
|
|
453
|
+
oldOID, oldOK := normalizedBaseline[ref]
|
|
454
|
+
newOID, newOK := normalizedCurrent[ref]
|
|
455
|
+
|
|
456
|
+
if ref == "" {
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
if !oldOK {
|
|
460
|
+
oldOID = ""
|
|
461
|
+
}
|
|
462
|
+
if !newOK {
|
|
463
|
+
newOID = ""
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if oldOID == newOID {
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
changes = append(changes, refChange{
|
|
470
|
+
Ref: ref,
|
|
471
|
+
OldOID: oldOID,
|
|
472
|
+
NewOID: newOID,
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
return changes
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
func renderRefScopeDetails(changes []refChange, taskPrefix string) []string {
|
|
479
|
+
details := []string{"Changed refs:"}
|
|
480
|
+
for _, change := range changes {
|
|
481
|
+
details = append(details, fmt.Sprintf(" %s (%s -> %s)", change.Ref, displayOID(change.OldOID), displayOID(change.NewOID)))
|
|
482
|
+
}
|
|
483
|
+
details = append(details,
|
|
484
|
+
"Allowed refs:",
|
|
485
|
+
fmt.Sprintf(" %s/*", taskPrefix),
|
|
486
|
+
)
|
|
487
|
+
return details
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
func renderTagChangeDetails(changes []refChange) []string {
|
|
491
|
+
details := []string{"Changed tags:"}
|
|
492
|
+
for _, change := range changes {
|
|
493
|
+
details = append(details, fmt.Sprintf(" %s (%s -> %s)", change.Ref, displayOID(change.OldOID), displayOID(change.NewOID)))
|
|
494
|
+
}
|
|
495
|
+
return details
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
func appendExclude(gitDir string, patterns ...string) error {
|
|
499
|
+
excludePath := filepath.Join(gitDir, "info", "exclude")
|
|
500
|
+
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
|
501
|
+
return err
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
existing := make(map[string]struct{})
|
|
505
|
+
if data, err := os.ReadFile(excludePath); err == nil {
|
|
506
|
+
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
|
507
|
+
for scanner.Scan() {
|
|
508
|
+
line := strings.TrimSpace(scanner.Text())
|
|
509
|
+
if line == "" || strings.HasPrefix(line, "#") {
|
|
510
|
+
continue
|
|
511
|
+
}
|
|
512
|
+
existing[line] = struct{}{}
|
|
513
|
+
}
|
|
514
|
+
if err := scanner.Err(); err != nil {
|
|
515
|
+
return err
|
|
516
|
+
}
|
|
517
|
+
} else if !os.IsNotExist(err) {
|
|
518
|
+
return err
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
522
|
+
if err != nil {
|
|
523
|
+
return err
|
|
524
|
+
}
|
|
525
|
+
defer f.Close()
|
|
526
|
+
|
|
527
|
+
for _, pattern := range patterns {
|
|
528
|
+
pattern = strings.TrimSpace(pattern)
|
|
529
|
+
if pattern == "" {
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
if _, ok := existing[pattern]; ok {
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if _, err := fmt.Fprintln(f, pattern); err != nil {
|
|
536
|
+
return err
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return nil
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
func readTrimmed(runner gitx.Runner, args ...string) (string, error) {
|
|
544
|
+
result, err := runner.Run(args...)
|
|
545
|
+
if err != nil {
|
|
546
|
+
return "", err
|
|
547
|
+
}
|
|
548
|
+
return strings.TrimSpace(result.Stdout), nil
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
func splitNonEmptyLines(s string) []string {
|
|
552
|
+
raw := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
|
553
|
+
lines := make([]string, 0, len(raw))
|
|
554
|
+
for _, line := range raw {
|
|
555
|
+
if line == "" {
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
lines = append(lines, line)
|
|
559
|
+
}
|
|
560
|
+
return lines
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
func isNotGitRepoError(err error) bool {
|
|
564
|
+
typed, ok := errs.As(err)
|
|
565
|
+
if !ok {
|
|
566
|
+
return false
|
|
567
|
+
}
|
|
568
|
+
if typed.Code != errs.CodeGitCommandFailed {
|
|
569
|
+
return false
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
var payload strings.Builder
|
|
573
|
+
payload.WriteString(strings.ToLower(typed.Message))
|
|
574
|
+
for _, detail := range typed.Details {
|
|
575
|
+
payload.WriteByte('\n')
|
|
576
|
+
payload.WriteString(strings.ToLower(detail))
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
all := payload.String()
|
|
580
|
+
return strings.Contains(all, "not a git repository") ||
|
|
581
|
+
strings.Contains(all, "must be run in a work tree")
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
func displayOID(oid string) string {
|
|
585
|
+
if strings.TrimSpace(oid) == "" {
|
|
586
|
+
return "(none)"
|
|
587
|
+
}
|
|
588
|
+
return oid
|
|
589
|
+
}
|