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,1069 @@
|
|
|
1
|
+
package apply
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"archive/zip"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"sort"
|
|
12
|
+
"strings"
|
|
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
|
+
returnJSONName = "procoder-return.json"
|
|
21
|
+
returnBundleName = "procoder-return.bundle"
|
|
22
|
+
importNamespacePrefix = "refs/procoder/import"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
type Options struct {
|
|
26
|
+
CWD string
|
|
27
|
+
ReturnPackagePath string
|
|
28
|
+
Namespace string
|
|
29
|
+
Checkout bool
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Action string
|
|
33
|
+
|
|
34
|
+
const (
|
|
35
|
+
ActionCreate Action = "create"
|
|
36
|
+
ActionUpdate Action = "update"
|
|
37
|
+
ActionConflict Action = "conflict"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
type Check struct {
|
|
41
|
+
Name string
|
|
42
|
+
Detail string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type PlanEntry struct {
|
|
46
|
+
Action Action
|
|
47
|
+
SourceRef string
|
|
48
|
+
ImportedRef string
|
|
49
|
+
DestinationRef string
|
|
50
|
+
OldOID string
|
|
51
|
+
NewOID string
|
|
52
|
+
CurrentOID string
|
|
53
|
+
Remapped bool
|
|
54
|
+
ConflictCode errs.Code
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Summary struct {
|
|
58
|
+
Creates int
|
|
59
|
+
Updates int
|
|
60
|
+
Conflicts int
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Plan struct {
|
|
64
|
+
ExchangeID string
|
|
65
|
+
ReturnPackagePath string
|
|
66
|
+
Namespace string
|
|
67
|
+
Checks []Check
|
|
68
|
+
Entries []PlanEntry
|
|
69
|
+
Summary Summary
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Result struct {
|
|
73
|
+
Plan Plan
|
|
74
|
+
CheckedOutRef string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type importedUpdate struct {
|
|
78
|
+
Update exchange.RefUpdate
|
|
79
|
+
ImportRef string
|
|
80
|
+
Destination string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type preparedRun struct {
|
|
84
|
+
Runner gitx.Runner
|
|
85
|
+
Namespace string
|
|
86
|
+
ReturnRecord exchange.Return
|
|
87
|
+
Plan Plan
|
|
88
|
+
ReturnPackage string
|
|
89
|
+
ImportedPrefix string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func RunDryRun(opts Options) (plan Plan, runErr error) {
|
|
93
|
+
prepared, cleanup, err := prepareRun(opts)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return Plan{}, err
|
|
96
|
+
}
|
|
97
|
+
defer func() {
|
|
98
|
+
cleanupErr := cleanup()
|
|
99
|
+
if cleanupErr != nil && runErr == nil {
|
|
100
|
+
runErr = cleanupErr
|
|
101
|
+
}
|
|
102
|
+
}()
|
|
103
|
+
|
|
104
|
+
return prepared.Plan, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func Run(opts Options) (result Result, runErr error) {
|
|
108
|
+
prepared, cleanup, err := prepareRun(opts)
|
|
109
|
+
if err != nil {
|
|
110
|
+
return Result{}, err
|
|
111
|
+
}
|
|
112
|
+
defer func() {
|
|
113
|
+
cleanupErr := cleanup()
|
|
114
|
+
if cleanupErr != nil && runErr == nil {
|
|
115
|
+
runErr = cleanupErr
|
|
116
|
+
}
|
|
117
|
+
}()
|
|
118
|
+
|
|
119
|
+
if conflict, ok := actionableConflict(prepared.Plan.Entries); ok {
|
|
120
|
+
return Result{}, conflictToError(conflict, prepared.Namespace)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if err := validateCheckedOutTargets(prepared.Runner, prepared.Plan.Entries, prepared.Namespace); err != nil {
|
|
124
|
+
return Result{}, err
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if err := applyPlanAtomically(prepared.Runner, prepared.Plan.Entries); err != nil {
|
|
128
|
+
if translated := translateWriteFailure(prepared.Runner, prepared.Plan.Entries, prepared.Namespace); translated != nil {
|
|
129
|
+
return Result{}, translated
|
|
130
|
+
}
|
|
131
|
+
return Result{}, errs.Wrap(errs.CodeInternal, "apply ref updates", err)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
checkedOutRef := ""
|
|
135
|
+
if opts.Checkout {
|
|
136
|
+
defaultRef, err := defaultCheckoutRef(prepared.ReturnRecord, prepared.Namespace)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return Result{}, err
|
|
139
|
+
}
|
|
140
|
+
if err := checkoutBranch(prepared.Runner, defaultRef); err != nil {
|
|
141
|
+
return Result{}, err
|
|
142
|
+
}
|
|
143
|
+
checkedOutRef = defaultRef
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return Result{
|
|
147
|
+
Plan: prepared.Plan,
|
|
148
|
+
CheckedOutRef: checkedOutRef,
|
|
149
|
+
}, nil
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func FormatSuccess(result Result) string {
|
|
153
|
+
lines := []string{
|
|
154
|
+
"Applied return package.",
|
|
155
|
+
fmt.Sprintf("Exchange: %s", result.Plan.ExchangeID),
|
|
156
|
+
fmt.Sprintf("Return package: %s", result.Plan.ReturnPackagePath),
|
|
157
|
+
fmt.Sprintf("Namespace: %s", displayNamespace(result.Plan.Namespace)),
|
|
158
|
+
"",
|
|
159
|
+
"Summary:",
|
|
160
|
+
fmt.Sprintf(" creates: %d", result.Plan.Summary.Creates),
|
|
161
|
+
fmt.Sprintf(" updates: %d", result.Plan.Summary.Updates),
|
|
162
|
+
fmt.Sprintf(" conflicts: %d", result.Plan.Summary.Conflicts),
|
|
163
|
+
}
|
|
164
|
+
if strings.TrimSpace(result.CheckedOutRef) != "" {
|
|
165
|
+
lines = append(lines, fmt.Sprintf("Checked out: %s", result.CheckedOutRef))
|
|
166
|
+
}
|
|
167
|
+
return strings.Join(lines, "\n")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func prepareRun(opts Options) (prepared preparedRun, cleanup func() error, err error) {
|
|
171
|
+
cwd, err := resolveCWD(opts.CWD)
|
|
172
|
+
if err != nil {
|
|
173
|
+
return preparedRun{}, nil, errs.Wrap(errs.CodeInternal, "resolve current working directory", err)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
repoRoot, err := resolveRepo(cwd)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return preparedRun{}, nil, err
|
|
179
|
+
}
|
|
180
|
+
runner := gitx.NewRunner(repoRoot)
|
|
181
|
+
|
|
182
|
+
returnPackagePath, err := resolveReturnPackagePath(cwd, opts.ReturnPackagePath)
|
|
183
|
+
if err != nil {
|
|
184
|
+
return preparedRun{}, nil, err
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
namespacePrefix, err := normalizeNamespacePrefix(runner, opts.Namespace)
|
|
188
|
+
if err != nil {
|
|
189
|
+
return preparedRun{}, nil, err
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
extractedDir, cleanupExtract, err := extractReturnPackage(returnPackagePath)
|
|
193
|
+
if err != nil {
|
|
194
|
+
return preparedRun{}, nil, err
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
returnRecordPath := filepath.Join(extractedDir, returnJSONName)
|
|
198
|
+
ret, err := readAndValidateReturnRecord(returnRecordPath)
|
|
199
|
+
if err != nil {
|
|
200
|
+
cleanupExtract()
|
|
201
|
+
return preparedRun{}, nil, err
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
bundlePath, err := resolveBundlePath(extractedDir, ret.BundleFile)
|
|
205
|
+
if err != nil {
|
|
206
|
+
cleanupExtract()
|
|
207
|
+
return preparedRun{}, nil, err
|
|
208
|
+
}
|
|
209
|
+
if err := verifyBundle(runner, bundlePath); err != nil {
|
|
210
|
+
cleanupExtract()
|
|
211
|
+
return preparedRun{}, nil, err
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
importNonce, err := randomNonce()
|
|
215
|
+
if err != nil {
|
|
216
|
+
cleanupExtract()
|
|
217
|
+
return preparedRun{}, nil, errs.Wrap(errs.CodeInternal, "create temporary import namespace", err)
|
|
218
|
+
}
|
|
219
|
+
importPrefix := importNamespacePrefix + "/" + importNonce
|
|
220
|
+
|
|
221
|
+
updates, err := buildImportedUpdates(ret, importPrefix, namespacePrefix)
|
|
222
|
+
if err != nil {
|
|
223
|
+
cleanupExtract()
|
|
224
|
+
return preparedRun{}, nil, err
|
|
225
|
+
}
|
|
226
|
+
if err := fetchBundleRefs(runner, bundlePath, updates); err != nil {
|
|
227
|
+
cleanupExtract()
|
|
228
|
+
return preparedRun{}, nil, err
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if err := verifyImportedTips(runner, importPrefix, updates); err != nil {
|
|
232
|
+
_ = deleteImportRefs(runner, importPrefix)
|
|
233
|
+
cleanupExtract()
|
|
234
|
+
return preparedRun{}, nil, err
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
plan, err := buildPlan(runner, returnPackagePath, namespacePrefix, ret, updates)
|
|
238
|
+
if err != nil {
|
|
239
|
+
_ = deleteImportRefs(runner, importPrefix)
|
|
240
|
+
cleanupExtract()
|
|
241
|
+
return preparedRun{}, nil, err
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
plan.Checks = []Check{
|
|
245
|
+
{Name: "return package structure", Detail: "found procoder-return.json and procoder-return.bundle"},
|
|
246
|
+
{Name: "return record shape", Detail: "task-family refs match exchange and /task model"},
|
|
247
|
+
{Name: "bundle verification", Detail: "git bundle verify passed"},
|
|
248
|
+
{Name: "temporary import", Detail: "bundle refs fetched to internal namespace"},
|
|
249
|
+
{Name: "fetched ref tips", Detail: "imported OIDs exactly match procoder-return.json"},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
cleanup = func() error {
|
|
253
|
+
importCleanupErr := deleteImportRefs(runner, importPrefix)
|
|
254
|
+
cleanupExtract()
|
|
255
|
+
if importCleanupErr != nil {
|
|
256
|
+
return errs.Wrap(errs.CodeInternal, "clean temporary import refs", importCleanupErr)
|
|
257
|
+
}
|
|
258
|
+
return nil
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return preparedRun{
|
|
262
|
+
Runner: runner,
|
|
263
|
+
Namespace: namespacePrefix,
|
|
264
|
+
ReturnRecord: ret,
|
|
265
|
+
Plan: plan,
|
|
266
|
+
ReturnPackage: returnPackagePath,
|
|
267
|
+
ImportedPrefix: importPrefix,
|
|
268
|
+
}, cleanup, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func FormatDryRun(plan Plan) string {
|
|
272
|
+
lines := []string{
|
|
273
|
+
"Dry-run apply plan.",
|
|
274
|
+
fmt.Sprintf("Exchange: %s", plan.ExchangeID),
|
|
275
|
+
fmt.Sprintf("Return package: %s", plan.ReturnPackagePath),
|
|
276
|
+
fmt.Sprintf("Namespace: %s", displayNamespace(plan.Namespace)),
|
|
277
|
+
"",
|
|
278
|
+
"Checks:",
|
|
279
|
+
}
|
|
280
|
+
for _, check := range plan.Checks {
|
|
281
|
+
line := fmt.Sprintf(" OK %s", check.Name)
|
|
282
|
+
if strings.TrimSpace(check.Detail) != "" {
|
|
283
|
+
line += " - " + check.Detail
|
|
284
|
+
}
|
|
285
|
+
lines = append(lines, line)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
lines = append(lines, "", "Ref plan:")
|
|
289
|
+
for _, entry := range plan.Entries {
|
|
290
|
+
if entry.Remapped {
|
|
291
|
+
lines = append(lines, fmt.Sprintf(" REMAP %s -> %s", entry.SourceRef, entry.DestinationRef))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
switch entry.Action {
|
|
295
|
+
case ActionCreate:
|
|
296
|
+
lines = append(lines, fmt.Sprintf(" CREATE %s new=%s", entry.DestinationRef, entry.NewOID))
|
|
297
|
+
case ActionUpdate:
|
|
298
|
+
lines = append(lines, fmt.Sprintf(" UPDATE %s old=%s new=%s", entry.DestinationRef, entry.OldOID, entry.NewOID))
|
|
299
|
+
case ActionConflict:
|
|
300
|
+
lines = append(lines, formatConflictLine(entry))
|
|
301
|
+
default:
|
|
302
|
+
lines = append(lines, fmt.Sprintf(" UNKNOWN %s", entry.DestinationRef))
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
lines = append(lines,
|
|
307
|
+
"",
|
|
308
|
+
"Summary:",
|
|
309
|
+
fmt.Sprintf(" creates: %d", plan.Summary.Creates),
|
|
310
|
+
fmt.Sprintf(" updates: %d", plan.Summary.Updates),
|
|
311
|
+
fmt.Sprintf(" conflicts: %d", plan.Summary.Conflicts),
|
|
312
|
+
)
|
|
313
|
+
if plan.Summary.Conflicts > 0 {
|
|
314
|
+
if plan.Namespace == "" {
|
|
315
|
+
lines = append(lines, " result: conflicts detected; consider rerunning with --namespace <prefix>")
|
|
316
|
+
} else {
|
|
317
|
+
lines = append(lines, " result: conflicts detected in namespace mapping")
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
lines = append(lines, " result: no conflicts detected")
|
|
321
|
+
}
|
|
322
|
+
lines = append(lines, "No refs were updated (dry-run).")
|
|
323
|
+
|
|
324
|
+
return strings.Join(lines, "\n")
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
func formatConflictLine(entry PlanEntry) string {
|
|
328
|
+
currentOID := displayOID(entry.CurrentOID)
|
|
329
|
+
switch entry.ConflictCode {
|
|
330
|
+
case errs.CodeBranchMoved:
|
|
331
|
+
return fmt.Sprintf(
|
|
332
|
+
" CONFLICT BRANCH_MOVED %s expected-old=%s current=%s incoming=%s",
|
|
333
|
+
entry.DestinationRef,
|
|
334
|
+
displayOID(entry.OldOID),
|
|
335
|
+
currentOID,
|
|
336
|
+
displayOID(entry.NewOID),
|
|
337
|
+
)
|
|
338
|
+
case errs.CodeRefExists:
|
|
339
|
+
return fmt.Sprintf(
|
|
340
|
+
" CONFLICT REF_EXISTS %s existing=%s incoming=%s",
|
|
341
|
+
entry.DestinationRef,
|
|
342
|
+
currentOID,
|
|
343
|
+
displayOID(entry.NewOID),
|
|
344
|
+
)
|
|
345
|
+
default:
|
|
346
|
+
return fmt.Sprintf(" CONFLICT %s %s", entry.ConflictCode, entry.DestinationRef)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
func applyPlanAtomically(runner gitx.Runner, entries []PlanEntry) error {
|
|
351
|
+
commands := make([]string, 0, len(entries)+3)
|
|
352
|
+
commands = append(commands, "start")
|
|
353
|
+
for _, entry := range entries {
|
|
354
|
+
switch entry.Action {
|
|
355
|
+
case ActionCreate:
|
|
356
|
+
commands = append(commands, fmt.Sprintf("create %s %s", entry.DestinationRef, entry.NewOID))
|
|
357
|
+
case ActionUpdate:
|
|
358
|
+
commands = append(commands, fmt.Sprintf("update %s %s %s", entry.DestinationRef, entry.NewOID, entry.OldOID))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
commands = append(commands, "prepare", "commit", "")
|
|
362
|
+
|
|
363
|
+
input := strings.Join(commands, "\n")
|
|
364
|
+
if _, err := runner.RunWithInput(input, "update-ref", "--stdin"); err != nil {
|
|
365
|
+
return err
|
|
366
|
+
}
|
|
367
|
+
return nil
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func actionableConflict(entries []PlanEntry) (PlanEntry, bool) {
|
|
371
|
+
for _, entry := range entries {
|
|
372
|
+
if entry.Action == ActionConflict {
|
|
373
|
+
return entry, true
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return PlanEntry{}, false
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func conflictToError(entry PlanEntry, namespace string) error {
|
|
380
|
+
switch entry.ConflictCode {
|
|
381
|
+
case errs.CodeBranchMoved:
|
|
382
|
+
return errs.New(
|
|
383
|
+
errs.CodeBranchMoved,
|
|
384
|
+
fmt.Sprintf("cannot update %s", entry.DestinationRef),
|
|
385
|
+
errs.WithDetails(
|
|
386
|
+
fmt.Sprintf("Expected old OID: %s", displayOID(entry.OldOID)),
|
|
387
|
+
fmt.Sprintf("Current local OID: %s", displayOID(entry.CurrentOID)),
|
|
388
|
+
fmt.Sprintf("Incoming OID: %s", displayOID(entry.NewOID)),
|
|
389
|
+
),
|
|
390
|
+
errs.WithHint(namespaceHint(namespace)),
|
|
391
|
+
)
|
|
392
|
+
case errs.CodeRefExists:
|
|
393
|
+
return errs.New(
|
|
394
|
+
errs.CodeRefExists,
|
|
395
|
+
fmt.Sprintf("cannot create %s because the destination ref already exists", entry.DestinationRef),
|
|
396
|
+
errs.WithDetails(
|
|
397
|
+
fmt.Sprintf("Existing local OID: %s", displayOID(entry.CurrentOID)),
|
|
398
|
+
fmt.Sprintf("Incoming OID: %s", displayOID(entry.NewOID)),
|
|
399
|
+
),
|
|
400
|
+
errs.WithHint(namespaceHint(namespace)),
|
|
401
|
+
)
|
|
402
|
+
default:
|
|
403
|
+
return errs.New(
|
|
404
|
+
errs.CodeInternal,
|
|
405
|
+
fmt.Sprintf("cannot apply %s due to an unsupported conflict type", entry.DestinationRef),
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
func namespaceHint(namespace string) string {
|
|
411
|
+
if strings.TrimSpace(namespace) == "" {
|
|
412
|
+
return "rerun with --namespace procoder-import to import under a new ref prefix"
|
|
413
|
+
}
|
|
414
|
+
return "rerun with a different --namespace prefix to import under a new ref prefix"
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func validateCheckedOutTargets(runner gitx.Runner, entries []PlanEntry, namespace string) error {
|
|
418
|
+
headRef, err := readCurrentHeadRef(runner)
|
|
419
|
+
if err != nil {
|
|
420
|
+
return err
|
|
421
|
+
}
|
|
422
|
+
if headRef == "" {
|
|
423
|
+
return nil
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for _, entry := range entries {
|
|
427
|
+
if entry.Action != ActionUpdate {
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
if entry.DestinationRef != headRef {
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
return errs.New(
|
|
434
|
+
errs.CodeTargetBranchCheckedOut,
|
|
435
|
+
fmt.Sprintf("cannot update checked-out branch %s", entry.DestinationRef),
|
|
436
|
+
errs.WithDetails(fmt.Sprintf("Current HEAD: %s", headRef)),
|
|
437
|
+
errs.WithHint(namespaceHint(namespace)),
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
return nil
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func readCurrentHeadRef(runner gitx.Runner) (string, error) {
|
|
444
|
+
result, err := runner.Run("symbolic-ref", "--quiet", "HEAD")
|
|
445
|
+
if err == nil {
|
|
446
|
+
return strings.TrimSpace(result.Stdout), nil
|
|
447
|
+
}
|
|
448
|
+
if result.ExitCode == 1 {
|
|
449
|
+
return "", nil
|
|
450
|
+
}
|
|
451
|
+
return "", errs.Wrap(errs.CodeInternal, "resolve current checked-out branch", err)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
func defaultCheckoutRef(ret exchange.Return, namespacePrefix string) (string, error) {
|
|
455
|
+
return destinationRef(ret.Task.RootRef, ret.ExchangeID, namespacePrefix)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func checkoutBranch(runner gitx.Runner, ref string) error {
|
|
459
|
+
if !strings.HasPrefix(ref, "refs/heads/") {
|
|
460
|
+
return errs.New(
|
|
461
|
+
errs.CodeInternal,
|
|
462
|
+
fmt.Sprintf("cannot checkout non-branch ref %s", ref),
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
shortRef := strings.TrimPrefix(ref, "refs/heads/")
|
|
467
|
+
if _, err := runner.Run("checkout", "--quiet", shortRef); err != nil {
|
|
468
|
+
return errs.Wrap(
|
|
469
|
+
errs.CodeInternal,
|
|
470
|
+
fmt.Sprintf("check out updated branch %s", ref),
|
|
471
|
+
err,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
return nil
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
func translateWriteFailure(runner gitx.Runner, entries []PlanEntry, namespace string) error {
|
|
478
|
+
if err := validateCheckedOutTargets(runner, entries, namespace); err != nil {
|
|
479
|
+
return err
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
for _, entry := range entries {
|
|
483
|
+
if entry.Action != ActionCreate && entry.Action != ActionUpdate {
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
currentOID, exists, err := resolveRefOID(runner, entry.DestinationRef)
|
|
488
|
+
if err != nil {
|
|
489
|
+
return nil
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if entry.Action == ActionCreate {
|
|
493
|
+
if exists {
|
|
494
|
+
entry.CurrentOID = currentOID
|
|
495
|
+
entry.ConflictCode = errs.CodeRefExists
|
|
496
|
+
return conflictToError(entry, namespace)
|
|
497
|
+
}
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if !exists || currentOID != entry.OldOID {
|
|
502
|
+
entry.CurrentOID = currentOID
|
|
503
|
+
entry.ConflictCode = errs.CodeBranchMoved
|
|
504
|
+
return conflictToError(entry, namespace)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return nil
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
func resolveCWD(cwd string) (string, error) {
|
|
512
|
+
if strings.TrimSpace(cwd) == "" {
|
|
513
|
+
return os.Getwd()
|
|
514
|
+
}
|
|
515
|
+
return cwd, nil
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
func resolveRepo(cwd string) (string, error) {
|
|
519
|
+
root, err := readTrimmed(gitx.NewRunner(cwd), "rev-parse", "--show-toplevel")
|
|
520
|
+
if err != nil {
|
|
521
|
+
if isNotGitRepoError(err) {
|
|
522
|
+
return "", errs.New(
|
|
523
|
+
errs.CodeNotGitRepo,
|
|
524
|
+
"current directory is not a Git worktree",
|
|
525
|
+
errs.WithHint("run `procoder apply` inside a Git repository"),
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
return "", errs.Wrap(errs.CodeInternal, "resolve repository root", err)
|
|
529
|
+
}
|
|
530
|
+
return root, nil
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
func resolveReturnPackagePath(cwd, packagePath string) (string, error) {
|
|
534
|
+
packagePath = strings.TrimSpace(packagePath)
|
|
535
|
+
if packagePath == "" {
|
|
536
|
+
return "", errs.New(
|
|
537
|
+
errs.CodeUnknownCommand,
|
|
538
|
+
"missing return package path for `procoder apply`",
|
|
539
|
+
errs.WithHint("run `procoder apply --help`"),
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if !filepath.IsAbs(packagePath) {
|
|
544
|
+
packagePath = filepath.Join(cwd, packagePath)
|
|
545
|
+
}
|
|
546
|
+
absPath, err := filepath.Abs(packagePath)
|
|
547
|
+
if err != nil {
|
|
548
|
+
return "", errs.Wrap(errs.CodeInternal, "resolve return package path", err)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
info, err := os.Stat(absPath)
|
|
552
|
+
if err != nil {
|
|
553
|
+
if os.IsNotExist(err) {
|
|
554
|
+
return "", errs.New(
|
|
555
|
+
errs.CodeInvalidReturnPackage,
|
|
556
|
+
"return package path does not exist",
|
|
557
|
+
errs.WithDetails("Path: "+absPath),
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
return "", errs.Wrap(errs.CodeInternal, "stat return package path", err)
|
|
561
|
+
}
|
|
562
|
+
if info.IsDir() {
|
|
563
|
+
return "", errs.New(
|
|
564
|
+
errs.CodeInvalidReturnPackage,
|
|
565
|
+
"return package path must be a zip file",
|
|
566
|
+
errs.WithDetails("Path: "+absPath),
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
return absPath, nil
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func normalizeNamespacePrefix(runner gitx.Runner, namespace string) (string, error) {
|
|
573
|
+
raw := strings.TrimSpace(namespace)
|
|
574
|
+
if raw == "" {
|
|
575
|
+
return "", nil
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
prefix := strings.TrimPrefix(raw, "refs/heads/")
|
|
579
|
+
prefix = strings.Trim(prefix, "/")
|
|
580
|
+
if prefix == "" {
|
|
581
|
+
return "", errs.New(
|
|
582
|
+
errs.CodeUnknownCommand,
|
|
583
|
+
fmt.Sprintf("invalid namespace prefix %q", raw),
|
|
584
|
+
errs.WithHint("use a Git-valid namespace prefix, for example `procoder-import`"),
|
|
585
|
+
)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
candidate := "refs/heads/" + prefix + "/task"
|
|
589
|
+
if _, err := runner.Run("check-ref-format", candidate); err != nil {
|
|
590
|
+
return "", errs.New(
|
|
591
|
+
errs.CodeUnknownCommand,
|
|
592
|
+
fmt.Sprintf("invalid namespace prefix %q", raw),
|
|
593
|
+
errs.WithHint("use a Git-valid namespace prefix, for example `procoder-import`"),
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return prefix, nil
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
func extractReturnPackage(returnPackagePath string) (string, func(), error) {
|
|
601
|
+
reader, err := zip.OpenReader(returnPackagePath)
|
|
602
|
+
if err != nil {
|
|
603
|
+
return "", nil, errs.New(
|
|
604
|
+
errs.CodeInvalidReturnPackage,
|
|
605
|
+
"return package is not a valid zip archive",
|
|
606
|
+
errs.WithDetails("Path: "+returnPackagePath),
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
defer reader.Close()
|
|
610
|
+
|
|
611
|
+
destDir, err := os.MkdirTemp("", "procoder-apply-*")
|
|
612
|
+
if err != nil {
|
|
613
|
+
return "", nil, errs.Wrap(errs.CodeInternal, "create temporary return package directory", err)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
cleanup := func() {
|
|
617
|
+
_ = os.RemoveAll(destDir)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
for _, file := range reader.File {
|
|
621
|
+
if err := extractZipEntry(destDir, file); err != nil {
|
|
622
|
+
cleanup()
|
|
623
|
+
return "", nil, err
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if _, err := os.Stat(filepath.Join(destDir, returnJSONName)); err != nil {
|
|
628
|
+
cleanup()
|
|
629
|
+
if os.IsNotExist(err) {
|
|
630
|
+
return "", nil, errs.New(
|
|
631
|
+
errs.CodeInvalidReturnPackage,
|
|
632
|
+
"missing procoder-return.json in return package",
|
|
633
|
+
errs.WithDetails("Path: "+returnPackagePath),
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
return "", nil, errs.Wrap(errs.CodeInternal, "inspect extracted return metadata", err)
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return destDir, cleanup, nil
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
func extractZipEntry(destDir string, file *zip.File) error {
|
|
643
|
+
cleanName := filepath.Clean(file.Name)
|
|
644
|
+
if strings.HasPrefix(cleanName, "../") || cleanName == ".." || filepath.IsAbs(file.Name) {
|
|
645
|
+
return errs.New(
|
|
646
|
+
errs.CodeInvalidReturnPackage,
|
|
647
|
+
"return package contains an invalid zip entry path",
|
|
648
|
+
errs.WithDetails("Entry: "+file.Name),
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
targetPath := filepath.Join(destDir, cleanName)
|
|
653
|
+
safePrefix := filepath.Clean(destDir) + string(os.PathSeparator)
|
|
654
|
+
if !strings.HasPrefix(filepath.Clean(targetPath), safePrefix) {
|
|
655
|
+
return errs.New(
|
|
656
|
+
errs.CodeInvalidReturnPackage,
|
|
657
|
+
"return package contains an invalid zip entry path",
|
|
658
|
+
errs.WithDetails("Entry: "+file.Name),
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if file.FileInfo().IsDir() {
|
|
663
|
+
if err := os.MkdirAll(targetPath, 0o755); err != nil {
|
|
664
|
+
return errs.Wrap(errs.CodeInternal, "create extracted return package directory", err)
|
|
665
|
+
}
|
|
666
|
+
return nil
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
670
|
+
return errs.Wrap(errs.CodeInternal, "create extracted return package directory", err)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
in, err := file.Open()
|
|
674
|
+
if err != nil {
|
|
675
|
+
return errs.Wrap(errs.CodeInternal, "open zip entry", err)
|
|
676
|
+
}
|
|
677
|
+
defer in.Close()
|
|
678
|
+
|
|
679
|
+
mode := file.Mode()
|
|
680
|
+
if mode == 0 {
|
|
681
|
+
mode = 0o644
|
|
682
|
+
}
|
|
683
|
+
out, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
|
684
|
+
if err != nil {
|
|
685
|
+
return errs.Wrap(errs.CodeInternal, "create extracted file", err)
|
|
686
|
+
}
|
|
687
|
+
defer out.Close()
|
|
688
|
+
|
|
689
|
+
if _, err := io.Copy(out, in); err != nil {
|
|
690
|
+
return errs.Wrap(errs.CodeInternal, "copy extracted zip entry", err)
|
|
691
|
+
}
|
|
692
|
+
return nil
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
func readAndValidateReturnRecord(returnRecordPath string) (exchange.Return, error) {
|
|
696
|
+
ret, err := exchange.ReadReturn(returnRecordPath)
|
|
697
|
+
if err != nil {
|
|
698
|
+
return exchange.Return{}, errs.New(
|
|
699
|
+
errs.CodeInvalidReturnPackage,
|
|
700
|
+
"missing or invalid procoder-return.json",
|
|
701
|
+
errs.WithDetails("Path: "+returnRecordPath),
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
if err := validateReturnRecord(ret); err != nil {
|
|
705
|
+
return exchange.Return{}, err
|
|
706
|
+
}
|
|
707
|
+
return ret, nil
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
func validateReturnRecord(ret exchange.Return) error {
|
|
711
|
+
if strings.TrimSpace(ret.Protocol) != exchange.ReturnProtocolV1 {
|
|
712
|
+
return invalidReturn("procoder-return.json has an unsupported protocol")
|
|
713
|
+
}
|
|
714
|
+
if exchange.TaskRootRef(ret.ExchangeID) == "" {
|
|
715
|
+
return invalidReturn("procoder-return.json has an invalid exchange_id")
|
|
716
|
+
}
|
|
717
|
+
if ret.Task.RootRef != exchange.TaskRootRef(ret.ExchangeID) {
|
|
718
|
+
return invalidReturn("procoder-return.json task.root_ref does not match exchange_id /task model")
|
|
719
|
+
}
|
|
720
|
+
if strings.TrimSpace(ret.Task.BaseOID) == "" {
|
|
721
|
+
return invalidReturn("procoder-return.json task.base_oid is required")
|
|
722
|
+
}
|
|
723
|
+
if strings.TrimSpace(ret.BundleFile) == "" {
|
|
724
|
+
return invalidReturn("procoder-return.json bundle_file is required")
|
|
725
|
+
}
|
|
726
|
+
if filepath.Base(ret.BundleFile) != ret.BundleFile {
|
|
727
|
+
return invalidReturn("procoder-return.json bundle_file must be a file name")
|
|
728
|
+
}
|
|
729
|
+
if ret.BundleFile != returnBundleName {
|
|
730
|
+
return invalidReturn(fmt.Sprintf("procoder-return.json bundle_file must be %q", returnBundleName))
|
|
731
|
+
}
|
|
732
|
+
if len(ret.Updates) == 0 {
|
|
733
|
+
return invalidReturn("procoder-return.json updates must not be empty")
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
taskPrefix := exchange.TaskRefPrefix(ret.ExchangeID)
|
|
737
|
+
seen := make(map[string]struct{}, len(ret.Updates))
|
|
738
|
+
for _, update := range ret.Updates {
|
|
739
|
+
if strings.TrimSpace(update.Ref) == "" {
|
|
740
|
+
return invalidReturn("procoder-return.json includes an update with an empty ref")
|
|
741
|
+
}
|
|
742
|
+
if !exchange.IsTaskRef(ret.ExchangeID, update.Ref) || !strings.HasPrefix(update.Ref, taskPrefix+"/") {
|
|
743
|
+
return invalidReturn("procoder-return.json includes a ref outside the allowed task branch family")
|
|
744
|
+
}
|
|
745
|
+
if strings.TrimSpace(update.NewOID) == "" {
|
|
746
|
+
return invalidReturn(fmt.Sprintf("procoder-return.json update for %s is missing new_oid", update.Ref))
|
|
747
|
+
}
|
|
748
|
+
if _, exists := seen[update.Ref]; exists {
|
|
749
|
+
return invalidReturn(fmt.Sprintf("procoder-return.json contains duplicate ref updates for %s", update.Ref))
|
|
750
|
+
}
|
|
751
|
+
seen[update.Ref] = struct{}{}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return nil
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
func invalidReturn(problem string) error {
|
|
758
|
+
return errs.New(errs.CodeInvalidReturnPackage, problem)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
func resolveBundlePath(extractedDir, bundleFile string) (string, error) {
|
|
762
|
+
bundlePath := filepath.Join(extractedDir, bundleFile)
|
|
763
|
+
if _, err := os.Stat(bundlePath); err != nil {
|
|
764
|
+
if os.IsNotExist(err) {
|
|
765
|
+
return "", errs.New(
|
|
766
|
+
errs.CodeInvalidReturnPackage,
|
|
767
|
+
"return package is missing procoder-return.bundle",
|
|
768
|
+
errs.WithDetails("Path: "+bundlePath),
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
return "", errs.Wrap(errs.CodeInternal, "inspect extracted return bundle", err)
|
|
772
|
+
}
|
|
773
|
+
return bundlePath, nil
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
func verifyBundle(runner gitx.Runner, bundlePath string) error {
|
|
777
|
+
result, err := runner.Run("bundle", "verify", bundlePath)
|
|
778
|
+
if err == nil {
|
|
779
|
+
return nil
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
details := []string{"Bundle: " + bundlePath}
|
|
783
|
+
if stderr := strings.TrimSpace(result.Stderr); stderr != "" {
|
|
784
|
+
details = append(details, "Git output: "+stderr)
|
|
785
|
+
}
|
|
786
|
+
return errs.New(
|
|
787
|
+
errs.CodeBundleVerifyFailed,
|
|
788
|
+
"return bundle verification failed",
|
|
789
|
+
errs.WithDetails(details...),
|
|
790
|
+
errs.WithHint("ensure this return package matches the local exchange repository and retry"),
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
func randomNonce() (string, error) {
|
|
795
|
+
buf := make([]byte, 4)
|
|
796
|
+
if _, err := io.ReadFull(rand.Reader, buf); err != nil {
|
|
797
|
+
return "", err
|
|
798
|
+
}
|
|
799
|
+
return hex.EncodeToString(buf), nil
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
func buildImportedUpdates(ret exchange.Return, importPrefix, namespacePrefix string) ([]importedUpdate, error) {
|
|
803
|
+
updates := make([]exchange.RefUpdate, len(ret.Updates))
|
|
804
|
+
copy(updates, ret.Updates)
|
|
805
|
+
sort.Slice(updates, func(i, j int) bool { return updates[i].Ref < updates[j].Ref })
|
|
806
|
+
|
|
807
|
+
mappings := make([]importedUpdate, 0, len(updates))
|
|
808
|
+
for _, update := range updates {
|
|
809
|
+
destination, err := destinationRef(update.Ref, ret.ExchangeID, namespacePrefix)
|
|
810
|
+
if err != nil {
|
|
811
|
+
return nil, err
|
|
812
|
+
}
|
|
813
|
+
mappings = append(mappings, importedUpdate{
|
|
814
|
+
Update: update,
|
|
815
|
+
ImportRef: importPrefix + "/" + strings.TrimPrefix(update.Ref, "refs/"),
|
|
816
|
+
Destination: destination,
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
return mappings, nil
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
func destinationRef(sourceRef, exchangeID, namespacePrefix string) (string, error) {
|
|
823
|
+
if namespacePrefix == "" {
|
|
824
|
+
return sourceRef, nil
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
taskPrefix := exchange.TaskRefPrefix(exchangeID)
|
|
828
|
+
if !strings.HasPrefix(sourceRef, taskPrefix+"/") {
|
|
829
|
+
return "", errs.New(
|
|
830
|
+
errs.CodeInvalidReturnPackage,
|
|
831
|
+
"return package includes a ref outside the allowed task branch family",
|
|
832
|
+
errs.WithDetails("Ref: "+sourceRef),
|
|
833
|
+
)
|
|
834
|
+
}
|
|
835
|
+
suffix := strings.TrimPrefix(sourceRef, taskPrefix)
|
|
836
|
+
return "refs/heads/" + namespacePrefix + "/" + exchangeID + suffix, nil
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
func fetchBundleRefs(runner gitx.Runner, bundlePath string, updates []importedUpdate) error {
|
|
840
|
+
args := []string{"fetch", "--no-tags", bundlePath}
|
|
841
|
+
for _, update := range updates {
|
|
842
|
+
args = append(args, fmt.Sprintf("+%s:%s", update.Update.Ref, update.ImportRef))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if _, err := runner.Run(args...); err != nil {
|
|
846
|
+
return errs.New(
|
|
847
|
+
errs.CodeInvalidReturnPackage,
|
|
848
|
+
"failed to fetch expected refs from procoder-return.bundle",
|
|
849
|
+
errs.WithDetails("Bundle: "+bundlePath),
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
return nil
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
func verifyImportedTips(runner gitx.Runner, importPrefix string, updates []importedUpdate) error {
|
|
856
|
+
fetched, err := readRefSnapshot(runner, importPrefix)
|
|
857
|
+
if err != nil {
|
|
858
|
+
return errs.Wrap(errs.CodeInternal, "read imported refs", err)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if len(fetched) != len(updates) {
|
|
862
|
+
return errs.New(
|
|
863
|
+
errs.CodeInvalidReturnPackage,
|
|
864
|
+
"fetched ref set does not match procoder-return.json",
|
|
865
|
+
errs.WithDetails(
|
|
866
|
+
fmt.Sprintf("Expected refs: %d", len(updates)),
|
|
867
|
+
fmt.Sprintf("Fetched refs: %d", len(fetched)),
|
|
868
|
+
),
|
|
869
|
+
)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
for _, update := range updates {
|
|
873
|
+
gotOID, ok := fetched[update.ImportRef]
|
|
874
|
+
if !ok {
|
|
875
|
+
return errs.New(
|
|
876
|
+
errs.CodeInvalidReturnPackage,
|
|
877
|
+
"fetched refs do not match procoder-return.json",
|
|
878
|
+
errs.WithDetails("Missing import ref: "+update.ImportRef),
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
if gotOID != update.Update.NewOID {
|
|
882
|
+
return errs.New(
|
|
883
|
+
errs.CodeInvalidReturnPackage,
|
|
884
|
+
"fetched ref tip does not match procoder-return.json",
|
|
885
|
+
errs.WithDetails(
|
|
886
|
+
"Ref: "+update.Update.Ref,
|
|
887
|
+
"Expected OID: "+update.Update.NewOID,
|
|
888
|
+
"Fetched OID: "+gotOID,
|
|
889
|
+
),
|
|
890
|
+
)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return nil
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
func buildPlan(runner gitx.Runner, returnPackagePath, namespacePrefix string, ret exchange.Return, updates []importedUpdate) (Plan, error) {
|
|
898
|
+
plan := Plan{
|
|
899
|
+
ExchangeID: ret.ExchangeID,
|
|
900
|
+
ReturnPackagePath: returnPackagePath,
|
|
901
|
+
Namespace: namespacePrefix,
|
|
902
|
+
Entries: make([]PlanEntry, 0, len(updates)),
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
for _, update := range updates {
|
|
906
|
+
currentOID, exists, err := resolveRefOID(runner, update.Destination)
|
|
907
|
+
if err != nil {
|
|
908
|
+
return Plan{}, err
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
entry := PlanEntry{
|
|
912
|
+
SourceRef: update.Update.Ref,
|
|
913
|
+
ImportedRef: update.ImportRef,
|
|
914
|
+
DestinationRef: update.Destination,
|
|
915
|
+
OldOID: update.Update.OldOID,
|
|
916
|
+
NewOID: update.Update.NewOID,
|
|
917
|
+
CurrentOID: currentOID,
|
|
918
|
+
Remapped: update.Destination != update.Update.Ref,
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if strings.TrimSpace(update.Update.OldOID) == "" {
|
|
922
|
+
if exists {
|
|
923
|
+
entry.Action = ActionConflict
|
|
924
|
+
entry.ConflictCode = errs.CodeRefExists
|
|
925
|
+
plan.Summary.Conflicts++
|
|
926
|
+
} else {
|
|
927
|
+
entry.Action = ActionCreate
|
|
928
|
+
plan.Summary.Creates++
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
if !exists {
|
|
932
|
+
if entry.Remapped {
|
|
933
|
+
entry.Action = ActionCreate
|
|
934
|
+
plan.Summary.Creates++
|
|
935
|
+
} else {
|
|
936
|
+
entry.Action = ActionConflict
|
|
937
|
+
entry.ConflictCode = errs.CodeBranchMoved
|
|
938
|
+
plan.Summary.Conflicts++
|
|
939
|
+
}
|
|
940
|
+
} else if currentOID != update.Update.OldOID {
|
|
941
|
+
entry.Action = ActionConflict
|
|
942
|
+
entry.ConflictCode = errs.CodeBranchMoved
|
|
943
|
+
plan.Summary.Conflicts++
|
|
944
|
+
} else {
|
|
945
|
+
entry.Action = ActionUpdate
|
|
946
|
+
plan.Summary.Updates++
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
plan.Entries = append(plan.Entries, entry)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
return plan, nil
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
func resolveRefOID(runner gitx.Runner, ref string) (oid string, exists bool, err error) {
|
|
957
|
+
result, runErr := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", ref)
|
|
958
|
+
if runErr != nil {
|
|
959
|
+
return "", false, errs.Wrap(errs.CodeInternal, fmt.Sprintf("resolve local ref %s", ref), runErr)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
for _, line := range splitNonEmptyLines(result.Stdout) {
|
|
963
|
+
fields := strings.Fields(line)
|
|
964
|
+
if len(fields) < 2 {
|
|
965
|
+
continue
|
|
966
|
+
}
|
|
967
|
+
if fields[0] == ref {
|
|
968
|
+
return fields[1], true, nil
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return "", false, nil
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
func deleteImportRefs(runner gitx.Runner, importPrefix string) error {
|
|
975
|
+
refs, err := readRefNames(runner, importPrefix)
|
|
976
|
+
if err != nil {
|
|
977
|
+
return err
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
for _, ref := range refs {
|
|
981
|
+
if _, err := runner.Run("update-ref", "-d", ref); err != nil {
|
|
982
|
+
return err
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return nil
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
func readRefNames(runner gitx.Runner, namespace string) ([]string, error) {
|
|
989
|
+
result, err := runner.Run("for-each-ref", "--format=%(refname)", namespace)
|
|
990
|
+
if err != nil {
|
|
991
|
+
return nil, errs.Wrap(errs.CodeInternal, "read refs", err)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
lines := splitNonEmptyLines(result.Stdout)
|
|
995
|
+
sort.Strings(lines)
|
|
996
|
+
return lines, nil
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
func readRefSnapshot(runner gitx.Runner, namespace string) (map[string]string, error) {
|
|
1000
|
+
result, err := runner.Run("for-each-ref", "--format=%(refname) %(objectname)", namespace)
|
|
1001
|
+
if err != nil {
|
|
1002
|
+
return nil, err
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
refs := make(map[string]string)
|
|
1006
|
+
for _, line := range splitNonEmptyLines(result.Stdout) {
|
|
1007
|
+
fields := strings.Fields(line)
|
|
1008
|
+
if len(fields) < 2 {
|
|
1009
|
+
continue
|
|
1010
|
+
}
|
|
1011
|
+
refs[fields[0]] = fields[1]
|
|
1012
|
+
}
|
|
1013
|
+
return refs, nil
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
func readTrimmed(runner gitx.Runner, args ...string) (string, error) {
|
|
1017
|
+
result, err := runner.Run(args...)
|
|
1018
|
+
if err != nil {
|
|
1019
|
+
return "", err
|
|
1020
|
+
}
|
|
1021
|
+
return strings.TrimSpace(result.Stdout), nil
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
func splitNonEmptyLines(s string) []string {
|
|
1025
|
+
raw := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
|
1026
|
+
lines := make([]string, 0, len(raw))
|
|
1027
|
+
for _, line := range raw {
|
|
1028
|
+
if line == "" {
|
|
1029
|
+
continue
|
|
1030
|
+
}
|
|
1031
|
+
lines = append(lines, line)
|
|
1032
|
+
}
|
|
1033
|
+
return lines
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
func isNotGitRepoError(err error) bool {
|
|
1037
|
+
typed, ok := errs.As(err)
|
|
1038
|
+
if !ok {
|
|
1039
|
+
return false
|
|
1040
|
+
}
|
|
1041
|
+
if typed.Code != errs.CodeGitCommandFailed {
|
|
1042
|
+
return false
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
var payload strings.Builder
|
|
1046
|
+
payload.WriteString(strings.ToLower(typed.Message))
|
|
1047
|
+
for _, detail := range typed.Details {
|
|
1048
|
+
payload.WriteByte('\n')
|
|
1049
|
+
payload.WriteString(strings.ToLower(detail))
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
all := payload.String()
|
|
1053
|
+
return strings.Contains(all, "not a git repository") ||
|
|
1054
|
+
strings.Contains(all, "must be run in a work tree")
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
func displayOID(oid string) string {
|
|
1058
|
+
if strings.TrimSpace(oid) == "" {
|
|
1059
|
+
return "(none)"
|
|
1060
|
+
}
|
|
1061
|
+
return oid
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
func displayNamespace(namespace string) string {
|
|
1065
|
+
if strings.TrimSpace(namespace) == "" {
|
|
1066
|
+
return "(default task-family refs)"
|
|
1067
|
+
}
|
|
1068
|
+
return "refs/heads/" + namespace + "/<exchange-id>/..."
|
|
1069
|
+
}
|