devrites 4.2.0 → 4.3.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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +4 -0
- package/docs/orchestration.md +6 -3
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- package/engine/internal/state/status.go +41 -16
- package/engine/main.go +72 -3
- package/engine/root_routing_test.go +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/.claude/skills/rite-build/SKILL.md +29 -36
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"sort"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// CreateOpts configures parallel worktree creation.
|
|
13
|
+
type CreateOpts struct {
|
|
14
|
+
Root string
|
|
15
|
+
Slug string
|
|
16
|
+
BatchID string
|
|
17
|
+
BaseSHA string
|
|
18
|
+
Session string
|
|
19
|
+
Slices []SlicePaths
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Create path-disjoint-gates, creates <=3 worktrees from base, and writes a running lease.
|
|
23
|
+
func Create(opts CreateOpts) (*Lease, error) {
|
|
24
|
+
if err := validateSlug(opts.Slug); err != nil {
|
|
25
|
+
return nil, err
|
|
26
|
+
}
|
|
27
|
+
if err := validateBatchID(opts.BatchID); err != nil {
|
|
28
|
+
return nil, err
|
|
29
|
+
}
|
|
30
|
+
if len(opts.Slices) < 2 || len(opts.Slices) > 3 {
|
|
31
|
+
return nil, fmt.Errorf("create requires 2 or 3 slices (got %d)", len(opts.Slices))
|
|
32
|
+
}
|
|
33
|
+
if _, err := CheckPathDisjoint(opts.Slices, opts.Root); err != nil {
|
|
34
|
+
return nil, err
|
|
35
|
+
}
|
|
36
|
+
base, err := revParse(opts.Root, opts.BaseSHA)
|
|
37
|
+
if err != nil {
|
|
38
|
+
return nil, fmt.Errorf("base sha: %w", err)
|
|
39
|
+
}
|
|
40
|
+
leasePath, err := LeasePath(opts.Root, opts.Slug)
|
|
41
|
+
if err != nil {
|
|
42
|
+
return nil, err
|
|
43
|
+
}
|
|
44
|
+
if _, err := os.Stat(leasePath); err == nil {
|
|
45
|
+
return nil, fmt.Errorf("lease already exists: %s", leasePath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
session := opts.Session
|
|
49
|
+
if session == "" {
|
|
50
|
+
session = fmt.Sprintf("pid:%d", os.Getpid())
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createdPaths := make([]string, 0, len(opts.Slices))
|
|
54
|
+
createdBranches := make([]string, 0, len(opts.Slices))
|
|
55
|
+
cleanupPartial := func() {
|
|
56
|
+
for _, p := range createdPaths {
|
|
57
|
+
_ = worktreeRemove(opts.Root, p)
|
|
58
|
+
}
|
|
59
|
+
for _, b := range createdBranches {
|
|
60
|
+
_ = deleteBranch(opts.Root, b)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
slices := make([]LeaseSlice, 0, len(opts.Slices))
|
|
65
|
+
for _, sp := range opts.Slices {
|
|
66
|
+
if err := validateSliceID(sp.ID); err != nil {
|
|
67
|
+
cleanupPartial()
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
paths, err := validateSlicePaths(sp.Paths, fmt.Sprintf("slice %q", sp.ID), opts.Root)
|
|
71
|
+
if err != nil {
|
|
72
|
+
cleanupPartial()
|
|
73
|
+
return nil, err
|
|
74
|
+
}
|
|
75
|
+
branch := WorkerBranch(opts.Slug, opts.BatchID, sp.ID)
|
|
76
|
+
wt := WorkerWorktreePath(opts.Root, opts.BatchID, sp.ID)
|
|
77
|
+
if _, err := os.Stat(wt); err == nil {
|
|
78
|
+
cleanupPartial()
|
|
79
|
+
return nil, fmt.Errorf("worktree path already exists: %s", wt)
|
|
80
|
+
}
|
|
81
|
+
if err := os.MkdirAll(filepath.Dir(wt), 0o755); err != nil {
|
|
82
|
+
cleanupPartial()
|
|
83
|
+
return nil, err
|
|
84
|
+
}
|
|
85
|
+
if err := worktreeAdd(opts.Root, wt, branch, base); err != nil {
|
|
86
|
+
cleanupPartial()
|
|
87
|
+
return nil, fmt.Errorf("worktree add %s: %w", sp.ID, err)
|
|
88
|
+
}
|
|
89
|
+
createdPaths = append(createdPaths, wt)
|
|
90
|
+
createdBranches = append(createdBranches, branch)
|
|
91
|
+
slices = append(slices, LeaseSlice{
|
|
92
|
+
ID: sp.ID,
|
|
93
|
+
Paths: paths,
|
|
94
|
+
WorktreePath: wt,
|
|
95
|
+
Branch: branch,
|
|
96
|
+
WrightStatus: WrightPending,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lease := &Lease{
|
|
101
|
+
BatchID: opts.BatchID,
|
|
102
|
+
CreatedAt: NowUTC(),
|
|
103
|
+
BaseSHA: base,
|
|
104
|
+
N: len(slices),
|
|
105
|
+
Status: StatusRunning,
|
|
106
|
+
ControlPIDOrSession: session,
|
|
107
|
+
Slices: slices,
|
|
108
|
+
}
|
|
109
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
110
|
+
cleanupPartial()
|
|
111
|
+
return nil, err
|
|
112
|
+
}
|
|
113
|
+
return lease, nil
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// RecordGreen marks a slice green with its transfer commit.
|
|
117
|
+
func RecordGreen(repoRoot, slug, sliceID, commit string) (*Lease, error) {
|
|
118
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
119
|
+
if err != nil {
|
|
120
|
+
return nil, err
|
|
121
|
+
}
|
|
122
|
+
lease, err := ReadLease(leasePath)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return nil, err
|
|
125
|
+
}
|
|
126
|
+
if lease.Status != StatusRunning {
|
|
127
|
+
return nil, fmt.Errorf("record-green requires status=running (have %s)", lease.Status)
|
|
128
|
+
}
|
|
129
|
+
sha, err := revParse(repoRoot, commit)
|
|
130
|
+
if err != nil {
|
|
131
|
+
return nil, fmt.Errorf("transfer commit: %w", err)
|
|
132
|
+
}
|
|
133
|
+
found := false
|
|
134
|
+
for i := range lease.Slices {
|
|
135
|
+
if lease.Slices[i].ID == sliceID {
|
|
136
|
+
lease.Slices[i].WrightStatus = WrightGreen
|
|
137
|
+
lease.Slices[i].TransferCommit = sha
|
|
138
|
+
found = true
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if !found {
|
|
143
|
+
return nil, fmt.Errorf("slice not in lease: %s", sliceID)
|
|
144
|
+
}
|
|
145
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
146
|
+
return nil, err
|
|
147
|
+
}
|
|
148
|
+
return lease, nil
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Abort marks the lease aborted and restores control tip to base_sha.
|
|
152
|
+
func Abort(repoRoot, slug string, force bool) (*Lease, error) {
|
|
153
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return nil, err
|
|
156
|
+
}
|
|
157
|
+
lease, err := ReadLease(leasePath)
|
|
158
|
+
if err != nil {
|
|
159
|
+
return nil, err
|
|
160
|
+
}
|
|
161
|
+
base, err := revParse(repoRoot, lease.BaseSHA)
|
|
162
|
+
if err != nil {
|
|
163
|
+
return nil, err
|
|
164
|
+
}
|
|
165
|
+
if err := ensureControlAtBase(repoRoot, base, force); err != nil {
|
|
166
|
+
return nil, err
|
|
167
|
+
}
|
|
168
|
+
lease.Status = StatusAborted
|
|
169
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
170
|
+
return nil, err
|
|
171
|
+
}
|
|
172
|
+
return lease, nil
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func ensureControlAtBase(repoRoot, base string, force bool) error {
|
|
176
|
+
head, err := headSHA(repoRoot)
|
|
177
|
+
if err != nil {
|
|
178
|
+
return err
|
|
179
|
+
}
|
|
180
|
+
if head == base {
|
|
181
|
+
return nil
|
|
182
|
+
}
|
|
183
|
+
dirty, err := porcelainDirty(repoRoot)
|
|
184
|
+
if err != nil {
|
|
185
|
+
return err
|
|
186
|
+
}
|
|
187
|
+
if dirty && !force {
|
|
188
|
+
return fmt.Errorf("control tree dirty; refuse reset to base (pass --force to override)")
|
|
189
|
+
}
|
|
190
|
+
if err := resetHard(repoRoot, base); err != nil {
|
|
191
|
+
return err
|
|
192
|
+
}
|
|
193
|
+
head, err = headSHA(repoRoot)
|
|
194
|
+
if err != nil {
|
|
195
|
+
return err
|
|
196
|
+
}
|
|
197
|
+
if head != base {
|
|
198
|
+
return fmt.Errorf("failed to leave control at base %s", base)
|
|
199
|
+
}
|
|
200
|
+
return nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// IntegrateOpts configures staging integrate.
|
|
204
|
+
type IntegrateOpts struct {
|
|
205
|
+
Root string
|
|
206
|
+
Slug string
|
|
207
|
+
ApplyToControl bool
|
|
208
|
+
Force bool
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Integrate all-or-nothing applies sibling transfer commits onto a staging branch.
|
|
212
|
+
func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
|
|
213
|
+
leasePath, err := LeasePath(opts.Root, opts.Slug)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return "", nil, err
|
|
216
|
+
}
|
|
217
|
+
lease, err = ReadLease(leasePath)
|
|
218
|
+
if err != nil {
|
|
219
|
+
return "", nil, err
|
|
220
|
+
}
|
|
221
|
+
if lease.Status != StatusRunning {
|
|
222
|
+
return "", nil, fmt.Errorf("integrate requires status=running (have %s)", lease.Status)
|
|
223
|
+
}
|
|
224
|
+
base, err := revParse(opts.Root, lease.BaseSHA)
|
|
225
|
+
if err != nil {
|
|
226
|
+
return "", nil, err
|
|
227
|
+
}
|
|
228
|
+
if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
|
|
229
|
+
return "", nil, err
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for _, sl := range lease.Slices {
|
|
233
|
+
if sl.WrightStatus != WrightGreen {
|
|
234
|
+
return "", nil, fmt.Errorf("slice %s is not green", sl.ID)
|
|
235
|
+
}
|
|
236
|
+
if sl.TransferCommit == "" {
|
|
237
|
+
return "", nil, fmt.Errorf("slice %s missing transfer_commit", sl.ID)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
ibranch := IntegrateBranchName(opts.Slug, lease.BatchID)
|
|
242
|
+
stageWT := filepath.Join(ScratchRoot(opts.Root), lease.BatchID, "integrate")
|
|
243
|
+
_ = os.RemoveAll(stageWT)
|
|
244
|
+
if exists, _ := branchExists(opts.Root, ibranch); exists {
|
|
245
|
+
_ = deleteBranch(opts.Root, ibranch)
|
|
246
|
+
}
|
|
247
|
+
if err := os.MkdirAll(filepath.Dir(stageWT), 0o755); err != nil {
|
|
248
|
+
return "", nil, err
|
|
249
|
+
}
|
|
250
|
+
if err := worktreeAdd(opts.Root, stageWT, ibranch, base); err != nil {
|
|
251
|
+
return "", nil, fmt.Errorf("staging worktree: %w", err)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fail := func(reason error) (string, *Lease, error) {
|
|
255
|
+
_ = worktreeRemove(opts.Root, stageWT)
|
|
256
|
+
_ = deleteBranch(opts.Root, ibranch)
|
|
257
|
+
_, _ = git(opts.Root, "worktree", "prune")
|
|
258
|
+
lease.Status = StatusIntegrateFailed
|
|
259
|
+
_ = WriteLease(leasePath, lease)
|
|
260
|
+
_ = ensureControlAtBase(opts.Root, base, true)
|
|
261
|
+
return "", lease, reason
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for _, sl := range lease.Slices {
|
|
265
|
+
tc, err := revParse(opts.Root, sl.TransferCommit)
|
|
266
|
+
if err != nil {
|
|
267
|
+
return fail(fmt.Errorf("slice %s transfer: %w", sl.ID, err))
|
|
268
|
+
}
|
|
269
|
+
ok, err := isAncestor(opts.Root, base, tc)
|
|
270
|
+
if err != nil {
|
|
271
|
+
return fail(err)
|
|
272
|
+
}
|
|
273
|
+
if !ok {
|
|
274
|
+
return fail(fmt.Errorf("transfer_commit for %s is not a descendant of base", sl.ID))
|
|
275
|
+
}
|
|
276
|
+
got, err := diffNames(opts.Root, base, tc)
|
|
277
|
+
if err != nil {
|
|
278
|
+
return fail(err)
|
|
279
|
+
}
|
|
280
|
+
want := append([]string(nil), sl.Paths...)
|
|
281
|
+
sort.Strings(want)
|
|
282
|
+
sort.Strings(got)
|
|
283
|
+
if !pathListsEqual(want, got) {
|
|
284
|
+
return fail(fmt.Errorf("exact-path proof failed for slice %s: want=%v got=%v", sl.ID, want, got))
|
|
285
|
+
}
|
|
286
|
+
for _, p := range got {
|
|
287
|
+
if p == ".devrites" || strings.HasPrefix(p, ".devrites/") {
|
|
288
|
+
return fail(fmt.Errorf("path proof failed: .devrites path in transfer: %s", p))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
stageHead, err := headSHA(stageWT)
|
|
293
|
+
if err != nil {
|
|
294
|
+
return fail(err)
|
|
295
|
+
}
|
|
296
|
+
canFF, err := isAncestor(opts.Root, stageHead, tc)
|
|
297
|
+
if err != nil {
|
|
298
|
+
return fail(err)
|
|
299
|
+
}
|
|
300
|
+
if canFF {
|
|
301
|
+
if err := mergeFFOnly(stageWT, tc); err != nil {
|
|
302
|
+
return fail(fmt.Errorf("ff-only integrate failed for slice %s: %w", sl.ID, err))
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
if err := cherryPickRange(stageWT, base, tc); err != nil {
|
|
306
|
+
cherryPickAbort(stageWT)
|
|
307
|
+
return fail(fmt.Errorf("cherry-pick replay failed for slice %s: %w", sl.ID, err))
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
tip, err = headSHA(stageWT)
|
|
313
|
+
if err != nil {
|
|
314
|
+
return fail(err)
|
|
315
|
+
}
|
|
316
|
+
_ = worktreeRemove(opts.Root, stageWT)
|
|
317
|
+
_, _ = git(opts.Root, "worktree", "prune")
|
|
318
|
+
|
|
319
|
+
if opts.ApplyToControl {
|
|
320
|
+
if err := mergeFFOnly(opts.Root, tip); err != nil {
|
|
321
|
+
return fail(fmt.Errorf("control fast-forward to integrate tip failed: %w", err))
|
|
322
|
+
}
|
|
323
|
+
} else if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
|
|
324
|
+
return fail(err)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
lease.Status = StatusComplete
|
|
328
|
+
if err := WriteLease(leasePath, lease); err != nil {
|
|
329
|
+
return "", nil, err
|
|
330
|
+
}
|
|
331
|
+
return tip, lease, nil
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
func pathListsEqual(a, b []string) bool {
|
|
335
|
+
if len(a) != len(b) {
|
|
336
|
+
return false
|
|
337
|
+
}
|
|
338
|
+
for i := range a {
|
|
339
|
+
if a[i] != b[i] {
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Cleanup removes worker worktrees/branches and clears the lease (complete or --force).
|
|
347
|
+
func Cleanup(repoRoot, slug string, force bool) error {
|
|
348
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
349
|
+
if err != nil {
|
|
350
|
+
return err
|
|
351
|
+
}
|
|
352
|
+
if _, err := os.Stat(leasePath); os.IsNotExist(err) {
|
|
353
|
+
return nil
|
|
354
|
+
}
|
|
355
|
+
lease, err := ReadLease(leasePath)
|
|
356
|
+
if err != nil {
|
|
357
|
+
return err
|
|
358
|
+
}
|
|
359
|
+
if lease.Status != StatusComplete && !force {
|
|
360
|
+
return fmt.Errorf("cleanup refuses status=%s without --force (abort keeps workers for diagnosis)", lease.Status)
|
|
361
|
+
}
|
|
362
|
+
for _, sl := range lease.Slices {
|
|
363
|
+
if sl.WorktreePath != "" {
|
|
364
|
+
_ = worktreeRemove(repoRoot, sl.WorktreePath)
|
|
365
|
+
_ = os.RemoveAll(sl.WorktreePath)
|
|
366
|
+
}
|
|
367
|
+
if sl.Branch != "" {
|
|
368
|
+
_ = deleteBranch(repoRoot, sl.Branch)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
ibranch := IntegrateBranchName(slug, lease.BatchID)
|
|
372
|
+
_ = deleteBranch(repoRoot, ibranch)
|
|
373
|
+
_ = os.RemoveAll(filepath.Join(ScratchRoot(repoRoot), lease.BatchID))
|
|
374
|
+
_, _ = git(repoRoot, "worktree", "prune")
|
|
375
|
+
return ClearLease(leasePath)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// StatusReport returns a human-readable lease status.
|
|
379
|
+
func StatusReport(repoRoot, slug string) (string, error) {
|
|
380
|
+
leasePath, err := LeasePath(repoRoot, slug)
|
|
381
|
+
if err != nil {
|
|
382
|
+
return "", err
|
|
383
|
+
}
|
|
384
|
+
if _, err := os.Stat(leasePath); os.IsNotExist(err) {
|
|
385
|
+
return fmt.Sprintf("status: no lease at %s\n", leasePath), nil
|
|
386
|
+
}
|
|
387
|
+
lease, err := ReadLease(leasePath)
|
|
388
|
+
if err != nil {
|
|
389
|
+
return "", err
|
|
390
|
+
}
|
|
391
|
+
var b strings.Builder
|
|
392
|
+
fmt.Fprintf(&b, "lease: %s\n", leasePath)
|
|
393
|
+
fmt.Fprintf(&b, "status: %s\n", lease.Status)
|
|
394
|
+
fmt.Fprintf(&b, "base_sha: %s\n", lease.BaseSHA)
|
|
395
|
+
for _, sl := range lease.Slices {
|
|
396
|
+
tc := sl.TransferCommit
|
|
397
|
+
if tc == "" {
|
|
398
|
+
tc = "-"
|
|
399
|
+
}
|
|
400
|
+
fmt.Fprintf(&b, "slice %s: wright=%s wt=%s branch=%s transfer=%s\n",
|
|
401
|
+
sl.ID, sl.WrightStatus, sl.WorktreePath, sl.Branch, tc)
|
|
402
|
+
}
|
|
403
|
+
if head, err := headSHA(repoRoot); err == nil {
|
|
404
|
+
fmt.Fprintf(&b, "control_HEAD: %s\n", head)
|
|
405
|
+
}
|
|
406
|
+
return b.String(), nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// LeaseJSON returns indented lease JSON.
|
|
410
|
+
func LeaseJSON(lease *Lease) (string, error) {
|
|
411
|
+
b, err := json.MarshalIndent(lease, "", " ")
|
|
412
|
+
if err != nil {
|
|
413
|
+
return "", err
|
|
414
|
+
}
|
|
415
|
+
return string(b), nil
|
|
416
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
package parallel
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func gitOk(t *testing.T, dir string, args ...string) string {
|
|
12
|
+
t.Helper()
|
|
13
|
+
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
|
14
|
+
cmd.Env = append(os.Environ(),
|
|
15
|
+
"GIT_CONFIG_NOSYSTEM=1",
|
|
16
|
+
"GIT_TERMINAL_PROMPT=0",
|
|
17
|
+
"GIT_AUTHOR_NAME=DevRites Test",
|
|
18
|
+
"GIT_AUTHOR_EMAIL=devrites@example.invalid",
|
|
19
|
+
"GIT_COMMITTER_NAME=DevRites Test",
|
|
20
|
+
"GIT_COMMITTER_EMAIL=devrites@example.invalid",
|
|
21
|
+
)
|
|
22
|
+
out, err := cmd.CombinedOutput()
|
|
23
|
+
if err != nil {
|
|
24
|
+
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
25
|
+
}
|
|
26
|
+
return strings.TrimSpace(string(out))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func setupRepo(t *testing.T) (repo, base string) {
|
|
30
|
+
t.Helper()
|
|
31
|
+
repo = t.TempDir()
|
|
32
|
+
gitOk(t, repo, "init")
|
|
33
|
+
gitOk(t, repo, "checkout", "-b", "main")
|
|
34
|
+
if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
|
|
35
|
+
t.Fatal(err)
|
|
36
|
+
}
|
|
37
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "a.go"), []byte("package main\n"), 0o644); err != nil {
|
|
38
|
+
t.Fatal(err)
|
|
39
|
+
}
|
|
40
|
+
if err := os.WriteFile(filepath.Join(repo, "src", "b.go"), []byte("package main\n"), 0o644); err != nil {
|
|
41
|
+
t.Fatal(err)
|
|
42
|
+
}
|
|
43
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("base\n"), 0o644); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
gitOk(t, repo, "add", ".")
|
|
47
|
+
gitOk(t, repo, "commit", "-m", "base")
|
|
48
|
+
base = gitOk(t, repo, "rev-parse", "HEAD")
|
|
49
|
+
return repo, base
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestLeaseRoundTrip(t *testing.T) {
|
|
53
|
+
t.Parallel()
|
|
54
|
+
dir := t.TempDir()
|
|
55
|
+
path := filepath.Join(dir, "parallel-lease.md")
|
|
56
|
+
lease := &Lease{
|
|
57
|
+
BatchID: "batch1",
|
|
58
|
+
CreatedAt: "2026-08-23T00:00:00Z",
|
|
59
|
+
BaseSHA: "abcdef1",
|
|
60
|
+
N: 2,
|
|
61
|
+
Status: StatusRunning,
|
|
62
|
+
ControlPIDOrSession: "test",
|
|
63
|
+
Slices: []LeaseSlice{
|
|
64
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}, WorktreePath: "/tmp/a", Branch: "devrites/parallel/x/batch1/slice-a", WrightStatus: WrightPending},
|
|
65
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}, WorktreePath: "/tmp/b", Branch: "devrites/parallel/x/batch1/slice-b", WrightStatus: WrightPending},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
if err := WriteLease(path, lease); err != nil {
|
|
69
|
+
t.Fatal(err)
|
|
70
|
+
}
|
|
71
|
+
got, err := ReadLease(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
if got.BatchID != "batch1" || got.Status != StatusRunning || len(got.Slices) != 2 {
|
|
76
|
+
t.Fatalf("unexpected lease: %#v", got)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestCreateAbortCleanup(t *testing.T) {
|
|
81
|
+
repo, base := setupRepo(t)
|
|
82
|
+
slug := "demo-feature"
|
|
83
|
+
batch := "batch1"
|
|
84
|
+
lease, err := Create(CreateOpts{
|
|
85
|
+
Root: repo,
|
|
86
|
+
Slug: slug,
|
|
87
|
+
BatchID: batch,
|
|
88
|
+
BaseSHA: base,
|
|
89
|
+
Slices: []SlicePaths{
|
|
90
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
91
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
if err != nil {
|
|
95
|
+
t.Fatal(err)
|
|
96
|
+
}
|
|
97
|
+
if lease.Status != StatusRunning || lease.N != 2 {
|
|
98
|
+
t.Fatalf("lease=%#v", lease)
|
|
99
|
+
}
|
|
100
|
+
wtA := lease.Slices[0].WorktreePath
|
|
101
|
+
wtB := lease.Slices[1].WorktreePath
|
|
102
|
+
for _, wt := range []string{wtA, wtB} {
|
|
103
|
+
if _, err := os.Stat(wt); err != nil {
|
|
104
|
+
t.Fatalf("missing worktree %s: %v", wt, err)
|
|
105
|
+
}
|
|
106
|
+
if head := gitOk(t, wt, "rev-parse", "HEAD"); head != base {
|
|
107
|
+
t.Fatalf("worktree head %s want %s", head, base)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
111
|
+
t.Fatalf("control moved after create: %s", head)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Drift control tip, then abort should restore base.
|
|
115
|
+
if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
gitOk(t, repo, "add", "README.md")
|
|
119
|
+
gitOk(t, repo, "commit", "-m", "drift")
|
|
120
|
+
if _, err := Abort(repo, slug, true); err != nil {
|
|
121
|
+
t.Fatal(err)
|
|
122
|
+
}
|
|
123
|
+
if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
|
|
124
|
+
t.Fatalf("abort left control at %s want %s", head, base)
|
|
125
|
+
}
|
|
126
|
+
leasePath, _ := LeasePath(repo, slug)
|
|
127
|
+
lease, err = ReadLease(leasePath)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
if lease.Status != StatusAborted {
|
|
132
|
+
t.Fatalf("status=%s", lease.Status)
|
|
133
|
+
}
|
|
134
|
+
if _, err := os.Stat(wtA); err != nil {
|
|
135
|
+
t.Fatalf("abort should preserve worktree: %v", err)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if err := Cleanup(repo, slug, true); err != nil {
|
|
139
|
+
t.Fatal(err)
|
|
140
|
+
}
|
|
141
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
142
|
+
t.Fatalf("cleanup should remove worktree A")
|
|
143
|
+
}
|
|
144
|
+
if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
|
|
145
|
+
t.Fatalf("cleanup should clear lease")
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func TestCreateRefusesOverlap(t *testing.T) {
|
|
150
|
+
repo, base := setupRepo(t)
|
|
151
|
+
_, err := Create(CreateOpts{
|
|
152
|
+
Root: repo,
|
|
153
|
+
Slug: "demo",
|
|
154
|
+
BatchID: "batch1",
|
|
155
|
+
BaseSHA: base,
|
|
156
|
+
Slices: []SlicePaths{
|
|
157
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
158
|
+
{ID: "slice-b", Paths: []string{"src/a.go"}},
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
if err == nil || !strings.Contains(err.Error(), "overlap") {
|
|
162
|
+
t.Fatalf("expected overlap refusal, got %v", err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestIntegrateDivergentSiblings(t *testing.T) {
|
|
167
|
+
repo, base := setupRepo(t)
|
|
168
|
+
slug := "demo-feature"
|
|
169
|
+
batch := "batch1"
|
|
170
|
+
lease, err := Create(CreateOpts{
|
|
171
|
+
Root: repo,
|
|
172
|
+
Slug: slug,
|
|
173
|
+
BatchID: batch,
|
|
174
|
+
BaseSHA: base,
|
|
175
|
+
Slices: []SlicePaths{
|
|
176
|
+
{ID: "slice-a", Paths: []string{"src/a.go"}},
|
|
177
|
+
{ID: "slice-b", Paths: []string{"src/b.go"}},
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
if err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
wtA := lease.Slices[0].WorktreePath
|
|
184
|
+
wtB := lease.Slices[1].WorktreePath
|
|
185
|
+
|
|
186
|
+
if err := os.WriteFile(filepath.Join(wtA, "src", "a.go"), []byte("package main\n\nfunc A() {}\n"), 0o644); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
gitOk(t, wtA, "add", "src/a.go")
|
|
190
|
+
gitOk(t, wtA, "commit", "-m", "slice-a green")
|
|
191
|
+
tcA := gitOk(t, wtA, "rev-parse", "HEAD")
|
|
192
|
+
|
|
193
|
+
if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc B() {}\n"), 0o644); err != nil {
|
|
194
|
+
t.Fatal(err)
|
|
195
|
+
}
|
|
196
|
+
gitOk(t, wtB, "add", "src/b.go")
|
|
197
|
+
gitOk(t, wtB, "commit", "-m", "slice-b green")
|
|
198
|
+
tcB := gitOk(t, wtB, "rev-parse", "HEAD")
|
|
199
|
+
|
|
200
|
+
if _, err := RecordGreen(repo, slug, "slice-a", tcA); err != nil {
|
|
201
|
+
t.Fatal(err)
|
|
202
|
+
}
|
|
203
|
+
if _, err := RecordGreen(repo, slug, "slice-b", tcB); err != nil {
|
|
204
|
+
t.Fatal(err)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true})
|
|
208
|
+
if err != nil {
|
|
209
|
+
t.Fatal(err)
|
|
210
|
+
}
|
|
211
|
+
if lease.Status != StatusComplete {
|
|
212
|
+
t.Fatalf("status=%s", lease.Status)
|
|
213
|
+
}
|
|
214
|
+
if tip == "" || tip == base {
|
|
215
|
+
t.Fatalf("expected advanced tip, got %s", tip)
|
|
216
|
+
}
|
|
217
|
+
head := gitOk(t, repo, "rev-parse", "HEAD")
|
|
218
|
+
if head != tip {
|
|
219
|
+
t.Fatalf("control head %s want tip %s", head, tip)
|
|
220
|
+
}
|
|
221
|
+
a := mustRead(t, filepath.Join(repo, "src", "a.go"))
|
|
222
|
+
b := mustRead(t, filepath.Join(repo, "src", "b.go"))
|
|
223
|
+
if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
|
|
224
|
+
t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if err := Cleanup(repo, slug, false); err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if _, err := os.Stat(wtA); !os.IsNotExist(err) {
|
|
231
|
+
t.Fatalf("cleanup should remove workers")
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func mustRead(t *testing.T, path string) string {
|
|
236
|
+
t.Helper()
|
|
237
|
+
b, err := os.ReadFile(path)
|
|
238
|
+
if err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
return string(b)
|
|
242
|
+
}
|