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.
Files changed (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +4 -0
  4. package/docs/orchestration.md +6 -3
  5. package/engine/internal/lib/cli_observe.go +66 -0
  6. package/engine/internal/lib/observe_summary.go +79 -0
  7. package/engine/internal/lib/skilltrust.go +196 -0
  8. package/engine/internal/lib/taskgraph.go +162 -0
  9. package/engine/internal/lib/taskgraph_test.go +150 -0
  10. package/engine/internal/lib/workspace_read.go +29 -0
  11. package/engine/internal/parallel/cli.go +467 -0
  12. package/engine/internal/parallel/git.go +181 -0
  13. package/engine/internal/parallel/lease.go +244 -0
  14. package/engine/internal/parallel/ops.go +416 -0
  15. package/engine/internal/parallel/parallel_test.go +242 -0
  16. package/engine/internal/parallel/pathdisjoint.go +129 -0
  17. package/engine/internal/parallel/pathdisjoint_test.go +97 -0
  18. package/engine/internal/state/status.go +41 -16
  19. package/engine/main.go +72 -3
  20. package/engine/root_routing_test.go +1 -1
  21. package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  22. package/pack/.claude/skills/rite-build/SKILL.md +29 -36
  23. package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
  24. package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  25. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  26. package/pack/.claude/skills/rite-build/reference/output.md +2 -0
  27. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
  28. package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
  29. package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  30. package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  31. package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
  32. package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
  33. package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  34. package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  35. package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
  36. package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
  37. package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  38. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
  39. package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
  40. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
  41. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
  42. package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
  43. package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
  44. package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
  45. package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
  46. package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
  47. package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
  48. package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
  49. package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
  50. package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
  51. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
  52. package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
  53. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
  54. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
  55. package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
  56. package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
  57. package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
  58. package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
  59. package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
  60. 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
+ }