devrites 5.2.4 → 5.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 (33) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/engine/internal/install/uninstall.go +3 -0
  4. package/engine/internal/lib/resolve.go +7 -0
  5. package/engine/internal/parallel/cli.go +62 -8
  6. package/engine/internal/parallel/git.go +46 -7
  7. package/engine/internal/parallel/lease.go +7 -0
  8. package/engine/internal/parallel/ops.go +256 -72
  9. package/engine/internal/parallel/ops_test.go +223 -6
  10. package/engine/internal/parallel/parallel_test.go +229 -6
  11. package/engine/internal/parallel/pathdisjoint.go +18 -3
  12. package/engine/internal/parallel/pathdisjoint_test.go +21 -0
  13. package/pack/.claude/skills/rite-autocomplete/SKILL.md +2 -3
  14. package/pack/.claude/skills/rite-build/SKILL.md +7 -3
  15. package/pack/.claude/skills/rite-build/reference/checkpoint.md +13 -15
  16. package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
  17. package/pack/.claude/skills/rite-build/reference/parallel-batch.md +75 -20
  18. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
  19. package/pack/generated/claude/skills/rite-build/SKILL.md +7 -3
  20. package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +13 -15
  21. package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
  22. package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +75 -20
  23. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
  24. package/pack/generated/codex/skills/rite-build/SKILL.md +7 -3
  25. package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +13 -15
  26. package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +2 -2
  27. package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +75 -20
  28. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
  29. package/pack/generated/omp/skills/rite-build/SKILL.md +7 -3
  30. package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +13 -15
  31. package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +2 -2
  32. package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +75 -20
  33. package/package.json +1 -1
@@ -6,6 +6,7 @@ import (
6
6
  "os"
7
7
  "path/filepath"
8
8
  "strings"
9
+ "sync"
9
10
  "testing"
10
11
  )
11
12
 
@@ -50,15 +51,18 @@ func greenBatch(t *testing.T, repo, base, slug, batch string) *Lease {
50
51
  }
51
52
 
52
53
  type seamRestore struct {
53
- origLease func(string, *Lease) error
54
- origRemove func(string, string) error
55
- origWarn func(string, ...any)
54
+ origLease func(string, *Lease) error
55
+ origRemove func(string, string) error
56
+ origSalvage func(LeaseSlice, string) (string, error)
57
+ origWarn func(string, ...any)
56
58
  }
57
59
 
58
60
  func swapSeams(t *testing.T) {
59
61
  t.Helper()
60
- s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origWarn: warnf}
61
- t.Cleanup(func() { writeLease, removeWorktree, warnf = s.origLease, s.origRemove, s.origWarn })
62
+ s := seamRestore{origLease: writeLease, origRemove: removeWorktree, origSalvage: salvage, origWarn: warnf}
63
+ t.Cleanup(func() {
64
+ writeLease, removeWorktree, salvage, warnf = s.origLease, s.origRemove, s.origSalvage, s.origWarn
65
+ })
62
66
  }
63
67
 
64
68
  // TestIntegrateFailedTransitionSurvivesTransientWriteFailure proves the
@@ -162,6 +166,219 @@ func TestIntegrateSurfacesStuckStagingWorktree(t *testing.T) {
162
166
  }
163
167
  }
164
168
 
169
+ // TestCleanupNeverTouchesForeignWorktreePath proves a lease whose
170
+ // worktree_path points outside the batch scratch layout is never salvaged
171
+ // into nor deleted — the lease field is advisory, not a RemoveAll license.
172
+ func TestCleanupNeverTouchesForeignWorktreePath(t *testing.T) {
173
+ repo, base := setupRepo(t)
174
+ slug, batch := "demo-feature", "batch1"
175
+ lease, err := Create(CreateOpts{
176
+ Root: repo,
177
+ Slug: slug,
178
+ BatchID: batch,
179
+ BaseSHA: base,
180
+ Slices: []SlicePaths{
181
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
182
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
183
+ },
184
+ })
185
+ if err != nil {
186
+ t.Fatal(err)
187
+ }
188
+ foreign := filepath.Join(t.TempDir(), "precious")
189
+ if err := os.MkdirAll(foreign, 0o755); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ if err := os.WriteFile(filepath.Join(foreign, "keep.txt"), []byte("x"), 0o644); err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ lease.Slices[0].WorktreePath = foreign
196
+ // A forged branch outside the batch namespace must survive too.
197
+ gitOk(t, repo, "branch", "user/topic", base)
198
+ lease.Slices[0].Branch = "user/topic"
199
+ leasePath, err := LeasePath(repo, slug)
200
+ if err != nil {
201
+ t.Fatal(err)
202
+ }
203
+ if err := WriteLease(leasePath, lease); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+
207
+ var warnings []string
208
+ swapSeams(t)
209
+ warnf = func(format string, args ...any) {
210
+ warnings = append(warnings, fmt.Sprintf(format, args...))
211
+ }
212
+ if _, err := Cleanup(repo, slug, true); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ if _, err := os.Stat(filepath.Join(foreign, "keep.txt")); err != nil {
216
+ t.Fatalf("foreign path was touched: %v", err)
217
+ }
218
+ if tip := gitOk(t, repo, "rev-parse", "user/topic"); tip != base {
219
+ t.Fatalf("foreign branch user/topic moved to %s, want %s", tip, base)
220
+ }
221
+ joined := strings.Join(warnings, "\n")
222
+ if !strings.Contains(joined, "outside") {
223
+ t.Fatalf("expected warnings naming foreign path and branch, got:\n%s", warnings)
224
+ }
225
+ }
226
+
227
+ // TestCleanupFailedSalvageKeepsWorktreeDir proves a salvage error keeps the
228
+ // worktree on disk as the only remaining copy — the batch-level scratch
229
+ // removal must skip kept slice dirs instead of destroying them.
230
+ func TestCleanupFailedSalvageKeepsWorktreeDir(t *testing.T) {
231
+ repo, base := setupRepo(t)
232
+ slug, batch := "demo-feature", "batch1"
233
+ if _, err := Create(CreateOpts{
234
+ Root: repo,
235
+ Slug: slug,
236
+ BatchID: batch,
237
+ BaseSHA: base,
238
+ Slices: []SlicePaths{
239
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
240
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
241
+ },
242
+ }); err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ wtA := WorkerWorktreePath(repo, batch, "slice-a")
246
+ wtB := WorkerWorktreePath(repo, batch, "slice-b")
247
+
248
+ swapSeams(t)
249
+ realSalvage := salvage
250
+ salvage = func(sl LeaseSlice, batchID string) (string, error) {
251
+ if sl.ID == "slice-a" {
252
+ return "", errors.New("injected salvage failure")
253
+ }
254
+ return realSalvage(sl, batchID)
255
+ }
256
+ salvaged, err := Cleanup(repo, slug, true)
257
+ if err != nil {
258
+ t.Fatal(err)
259
+ }
260
+ if _, err := os.Stat(wtA); err != nil {
261
+ t.Fatalf("kept worktree was removed: %v", err)
262
+ }
263
+ if _, err := os.Stat(wtB); !os.IsNotExist(err) {
264
+ t.Fatalf("clean slice-b worktree should be removed, stat err=%v", err)
265
+ }
266
+ if _, err := git(repo, "rev-parse", "--verify", "devrites/parallel/"+slug+"/"+batch+"/slice-a"); err != nil {
267
+ t.Fatalf("slice-a branch should be kept: %v", err)
268
+ }
269
+ found := false
270
+ for _, s := range salvaged {
271
+ if s.SliceID == "slice-a" && s.Worktree == wtA {
272
+ found = true
273
+ }
274
+ }
275
+ if !found {
276
+ t.Fatalf("salvage output should report kept worktree for slice-a, got %+v", salvaged)
277
+ }
278
+ }
279
+
280
+ // TestConcurrentRecordGreenKeepsEverySlice proves the feature lock serializes
281
+ // concurrent record-green calls so no sibling's transfer commit is lost to a
282
+ // read-modify-write race on the lease.
283
+ func TestConcurrentRecordGreenKeepsEverySlice(t *testing.T) {
284
+ repo, base := setupRepo(t)
285
+ slug, batch := "demo-feature", "batch1"
286
+ lease, err := Create(CreateOpts{
287
+ Root: repo,
288
+ Slug: slug,
289
+ BatchID: batch,
290
+ BaseSHA: base,
291
+ Slices: []SlicePaths{
292
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
293
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
294
+ },
295
+ })
296
+ if err != nil {
297
+ t.Fatal(err)
298
+ }
299
+ tcA := commitIn(t, lease.Slices[0].WorktreePath, "src/a.go", "A")
300
+ tcB := commitIn(t, lease.Slices[1].WorktreePath, "src/b.go", "B")
301
+
302
+ var wg sync.WaitGroup
303
+ errs := make(chan error, 2)
304
+ for _, item := range [][2]string{{"slice-a", tcA}, {"slice-b", tcB}} {
305
+ wg.Add(1)
306
+ go func(sliceID, commit string) {
307
+ defer wg.Done()
308
+ _, err := RecordGreen(repo, slug, sliceID, commit)
309
+ errs <- err
310
+ }(item[0], item[1])
311
+ }
312
+ wg.Wait()
313
+ close(errs)
314
+ for err := range errs {
315
+ if err != nil {
316
+ t.Fatal(err)
317
+ }
318
+ }
319
+
320
+ leasePath, err := LeasePath(repo, slug)
321
+ if err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ onDisk, err := ReadLease(leasePath)
325
+ if err != nil {
326
+ t.Fatal(err)
327
+ }
328
+ got := map[string]string{}
329
+ for _, sl := range onDisk.Slices {
330
+ got[sl.ID] = sl.TransferCommit
331
+ }
332
+ if got["slice-a"] != tcA || got["slice-b"] != tcB {
333
+ t.Fatalf("lost update: transfer commits %v want a=%s b=%s", got, tcA, tcB)
334
+ }
335
+ }
336
+
337
+ // TestConcurrentCreateSingleWinner proves the feature lock serializes create
338
+ // so two racing creates cannot both pass the lease-existence check.
339
+ func TestConcurrentCreateSingleWinner(t *testing.T) {
340
+ repo, base := setupRepo(t)
341
+ opts := CreateOpts{
342
+ Root: repo,
343
+ Slug: "demo-feature",
344
+ BatchID: "batch1",
345
+ BaseSHA: base,
346
+ Slices: []SlicePaths{
347
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
348
+ {ID: "slice-b", Paths: []string{"src/b.go"}},
349
+ },
350
+ }
351
+ var wg sync.WaitGroup
352
+ errs := make(chan error, 2)
353
+ wins := make(chan *Lease, 2)
354
+ for i := 0; i < 2; i++ {
355
+ wg.Add(1)
356
+ go func() {
357
+ defer wg.Done()
358
+ lease, err := Create(opts)
359
+ if err != nil {
360
+ errs <- err
361
+ return
362
+ }
363
+ wins <- lease
364
+ }()
365
+ }
366
+ wg.Wait()
367
+ close(errs)
368
+ close(wins)
369
+ succeeded := 0
370
+ for range wins {
371
+ succeeded++
372
+ }
373
+ failed := 0
374
+ for range errs {
375
+ failed++
376
+ }
377
+ if succeeded != 1 || failed != 1 {
378
+ t.Fatalf("concurrent create: %d succeeded, %d failed; want exactly 1/1", succeeded, failed)
379
+ }
380
+ }
381
+
165
382
  // TestCleanupWarnsOnFailedBranchCleanup proves Cleanup reports branch cleanup
166
383
  // failures instead of erasing the lease while orphans remain.
167
384
  func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
@@ -194,7 +411,7 @@ func TestCleanupWarnsOnFailedBranchCleanup(t *testing.T) {
194
411
  warnings = append(warnings, fmt.Sprintf(format, args...))
195
412
  }
196
413
 
197
- if err := Cleanup(repo, slug, true); err != nil {
414
+ if _, err := Cleanup(repo, slug, true); err != nil {
198
415
  t.Fatalf("cleanup continues after reporting failures: %v", err)
199
416
  }
200
417
  joined := strings.Join(warnings, "\n")
@@ -112,17 +112,19 @@ func TestCreateAbortCleanup(t *testing.T) {
112
112
  t.Fatalf("control moved after create: %s", head)
113
113
  }
114
114
 
115
- // Drift control tip, then abort should restore base.
115
+ // Drift control tip; abort must never rewind external commits — the drift
116
+ // commit survives and the lease still marks aborted.
116
117
  if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("drift\n"), 0o644); err != nil {
117
118
  t.Fatal(err)
118
119
  }
119
120
  gitOk(t, repo, "add", "README.md")
120
121
  gitOk(t, repo, "commit", "-m", "drift")
121
- if _, err := Abort(repo, slug, true); err != nil {
122
+ drift := gitOk(t, repo, "rev-parse", "HEAD")
123
+ if _, err := Abort(repo, slug); err != nil {
122
124
  t.Fatal(err)
123
125
  }
124
- if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
125
- t.Fatalf("abort left control at %s want %s", head, base)
126
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != drift {
127
+ t.Fatalf("abort rewound control: head %s want drift tip %s", head, drift)
126
128
  }
127
129
  leasePath, _ := LeasePath(repo, slug)
128
130
  lease, err = ReadLease(leasePath)
@@ -136,7 +138,7 @@ func TestCreateAbortCleanup(t *testing.T) {
136
138
  t.Fatalf("abort should preserve worktree: %v", err)
137
139
  }
138
140
 
139
- if err := Cleanup(repo, slug, true); err != nil {
141
+ if _, err := Cleanup(repo, slug, true); err != nil {
140
142
  t.Fatal(err)
141
143
  }
142
144
  if _, err := os.Stat(wtA); !os.IsNotExist(err) {
@@ -147,6 +149,88 @@ func TestCreateAbortCleanup(t *testing.T) {
147
149
  }
148
150
  }
149
151
 
152
+ // TestCleanupSalvagesAbortedWork proves a forced cleanup of an aborted batch
153
+ // never destroys wright work: committed transfers keep their branches and
154
+ // uncommitted allowlisted changes are committed onto the slice branch before
155
+ // the worktree is removed.
156
+ func TestCleanupSalvagesAbortedWork(t *testing.T) {
157
+ repo, base := setupRepo(t)
158
+ slug, batch := "demo-feature", "batch1"
159
+ lease, err := Create(CreateOpts{
160
+ Root: repo,
161
+ Slug: slug,
162
+ BatchID: batch,
163
+ BaseSHA: base,
164
+ Slices: []SlicePaths{
165
+ {ID: "slice-a", Paths: []string{"src/a.go"}},
166
+ {ID: "slice-b", Paths: []string{"src/b.go", "src/b_new.go"}},
167
+ {ID: "slice-c", Paths: []string{"src/c.go"}},
168
+ },
169
+ })
170
+ if err != nil {
171
+ t.Fatal(err)
172
+ }
173
+ wtA := lease.Slices[0].WorktreePath
174
+ wtB := lease.Slices[1].WorktreePath
175
+ wtC := lease.Slices[2].WorktreePath
176
+
177
+ // slice-a: committed transfer (red at review — work must survive).
178
+ tcA := commitIn(t, wtA, "src/a.go", "A")
179
+ // slice-b: uncommitted WIP — modified tracked file plus a new untracked
180
+ // file inside the allowlist.
181
+ if err := os.WriteFile(filepath.Join(wtB, "src", "b.go"), []byte("package main\n\nfunc Bwip() {}\n"), 0o644); err != nil {
182
+ t.Fatal(err)
183
+ }
184
+ if err := os.WriteFile(filepath.Join(wtB, "src", "b_new.go"), []byte("package main\n\nfunc Bnew() {}\n"), 0o644); err != nil {
185
+ t.Fatal(err)
186
+ }
187
+ // slice-c: untouched — nothing to salvage, branch should be deleted.
188
+
189
+ if _, err := Abort(repo, slug); err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ salvaged, err := Cleanup(repo, slug, true)
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+
197
+ bySlice := map[string]Salvage{}
198
+ for _, s := range salvaged {
199
+ bySlice[s.SliceID] = s
200
+ }
201
+ if got := bySlice["slice-a"].Commit; got != tcA {
202
+ t.Fatalf("slice-a salvage commit %s want transfer %s", got, tcA)
203
+ }
204
+ sb := bySlice["slice-b"]
205
+ if sb.Branch == "" || sb.Commit == "" || sb.Commit == base {
206
+ t.Fatalf("slice-b should salvage WIP onto its branch, got %+v", sb)
207
+ }
208
+ if _, ok := bySlice["slice-c"]; ok {
209
+ t.Fatalf("slice-c carried no work; its branch should be deleted, got %+v", bySlice["slice-c"])
210
+ }
211
+
212
+ // The salvaged WIP commit actually contains the uncommitted changes.
213
+ if names := gitOk(t, repo, "diff", "--name-only", base, sb.Commit); !strings.Contains(names, "src/b.go") || !strings.Contains(names, "src/b_new.go") {
214
+ t.Fatalf("salvage commit missing WIP paths: %s", names)
215
+ }
216
+ if body := gitOk(t, repo, "show", sb.Commit+":src/b_new.go"); !strings.Contains(body, "Bnew") {
217
+ t.Fatalf("salvaged file content missing: %q", body)
218
+ }
219
+
220
+ // Worktrees removed, lease cleared, kept branches still resolve.
221
+ for _, wt := range []string{wtA, wtB, wtC} {
222
+ if _, err := os.Stat(wt); !os.IsNotExist(err) {
223
+ t.Fatalf("cleanup should remove worktree %s", wt)
224
+ }
225
+ }
226
+ leasePath, _ := LeasePath(repo, slug)
227
+ if _, err := os.Stat(leasePath); !os.IsNotExist(err) {
228
+ t.Fatalf("cleanup should clear lease")
229
+ }
230
+ gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+bySlice["slice-a"].Branch)
231
+ gitOk(t, repo, "rev-parse", "--verify", "refs/heads/"+sb.Branch)
232
+ }
233
+
150
234
  func TestCreateAcceptsFourSlices(t *testing.T) {
151
235
  t.Parallel()
152
236
  repo, _ := setupRepo(t)
@@ -322,13 +406,20 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
322
406
  if head != tip {
323
407
  t.Fatalf("control head %s want tip %s", head, tip)
324
408
  }
409
+ if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
410
+ t.Fatalf("control should gain exactly one squash commit, got %s", n)
411
+ }
412
+ subj := gitOk(t, repo, "log", "-1", "--format=%s")
413
+ if !strings.HasPrefix(subj, "WIP(demo-feature):") {
414
+ t.Fatalf("squash subject %q missing WIP(demo-feature): prefix", subj)
415
+ }
325
416
  a := mustRead(t, filepath.Join(repo, "src", "a.go"))
326
417
  b := mustRead(t, filepath.Join(repo, "src", "b.go"))
327
418
  if !strings.Contains(a, "func A") || !strings.Contains(b, "func B") {
328
419
  t.Fatalf("integrated contents missing: a=%q b=%q", a, b)
329
420
  }
330
421
 
331
- if err := Cleanup(repo, slug, false); err != nil {
422
+ if _, err := Cleanup(repo, slug, false); err != nil {
332
423
  t.Fatal(err)
333
424
  }
334
425
  if _, err := os.Stat(wtA); !os.IsNotExist(err) {
@@ -336,6 +427,138 @@ func TestIntegrateDivergentSiblings(t *testing.T) {
336
427
  }
337
428
  }
338
429
 
430
+ // TestIntegrateWithoutApplyKeepsLeaseRunning proves a flag-less integrate
431
+ // stages the squash commit on the integrate branch but leaves the lease
432
+ // running and control at base, so a later non-forced cleanup cannot discard
433
+ // the only refs holding the integrated tree.
434
+ func TestIntegrateWithoutApplyKeepsLeaseRunning(t *testing.T) {
435
+ repo, base := setupRepo(t)
436
+ slug, batch := "demo-feature", "batch1"
437
+ greenBatch(t, repo, base, slug, batch)
438
+
439
+ tip, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug})
440
+ if err != nil {
441
+ t.Fatal(err)
442
+ }
443
+ if tip == "" || tip == base {
444
+ t.Fatalf("expected staged squash tip, got %s", tip)
445
+ }
446
+ if lease.Status != StatusRunning {
447
+ t.Fatalf("status=%s want running without --apply-to-control", lease.Status)
448
+ }
449
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
450
+ t.Fatalf("control moved without apply: %s", head)
451
+ }
452
+ ibranch := IntegrateBranchName(slug, batch)
453
+ if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
454
+ t.Fatalf("integrate branch %s want squash tip %s", got, tip)
455
+ }
456
+ if _, err := Cleanup(repo, slug, false); err == nil {
457
+ t.Fatal("cleanup on running lease must refuse without --force")
458
+ }
459
+ // Forced cleanup salvages then removes; the integrate branch holds the
460
+ // squash tip, so it is kept and reported rather than deleted.
461
+ if _, err := Cleanup(repo, slug, true); err != nil {
462
+ t.Fatal(err)
463
+ }
464
+ if got := gitOk(t, repo, "rev-parse", ibranch); got != tip {
465
+ t.Fatalf("forced cleanup dropped integrate branch: %s want %s", got, tip)
466
+ }
467
+ }
468
+
469
+ // TestIntegrateFailedLeaseRepairsAndRetries proves integrate-failed is not a
470
+ // dead end: a repaired transfer re-records on the failed lease and a retried
471
+ // integrate completes into one squash commit on control.
472
+ func TestIntegrateFailedLeaseRepairsAndRetries(t *testing.T) {
473
+ repo, base := setupRepo(t)
474
+ slug, batch := "demo-feature", "batch1"
475
+ greenBatch(t, repo, base, slug, batch)
476
+ realTC := gitOk(t, repo, "rev-parse", WorkerBranch(slug, batch, "slice-a"))
477
+
478
+ leasePath, err := LeasePath(repo, slug)
479
+ if err != nil {
480
+ t.Fatal(err)
481
+ }
482
+ bad, err := ReadLease(leasePath)
483
+ if err != nil {
484
+ t.Fatal(err)
485
+ }
486
+ bad.Slices[0].TransferCommit = strings.Repeat("deadbeef", 5)
487
+ if err := WriteLease(leasePath, bad); err != nil {
488
+ t.Fatal(err)
489
+ }
490
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
491
+ t.Fatal("expected integrate failure")
492
+ }
493
+ onDisk, err := ReadLease(leasePath)
494
+ if err != nil {
495
+ t.Fatal(err)
496
+ }
497
+ if onDisk.Status != StatusIntegrateFailed {
498
+ t.Fatalf("status=%s want integrate-failed", onDisk.Status)
499
+ }
500
+
501
+ if _, err := RecordGreen(repo, slug, "slice-a", realTC); err != nil {
502
+ t.Fatalf("record-green on integrate-failed lease: %v", err)
503
+ }
504
+ if _, lease, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
505
+ t.Fatalf("integrate retry: %v", err)
506
+ } else if lease.Status != StatusComplete {
507
+ t.Fatalf("status=%s want complete", lease.Status)
508
+ }
509
+ if n := gitOk(t, repo, "rev-list", "--count", base+"..HEAD"); n != "1" {
510
+ t.Fatalf("expected one squash commit on control, got %s", n)
511
+ }
512
+ }
513
+
514
+ // TestIntegrateRefusesDirtyControlOnSlicePaths proves --apply-to-control
515
+ // neither absorbs nor clobbers uncommitted user work on slice paths: overlap
516
+ // refuses with the lease still running, while unrelated dirty files are left
517
+ // alone and the FF proceeds.
518
+ func TestIntegrateRefusesDirtyControlOnSlicePaths(t *testing.T) {
519
+ repo, base := setupRepo(t)
520
+ slug, batch := "demo-feature", "batch1"
521
+ greenBatch(t, repo, base, slug, batch)
522
+
523
+ if err := os.MkdirAll(filepath.Join(repo, "src"), 0o755); err != nil {
524
+ t.Fatal(err)
525
+ }
526
+ userWIP := filepath.Join(repo, "src", "a.go")
527
+ if err := os.WriteFile(userWIP, []byte("user wip\n"), 0o644); err != nil {
528
+ t.Fatal(err)
529
+ }
530
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err == nil {
531
+ t.Fatal("expected dirty-control refusal")
532
+ }
533
+ leasePath, err := LeasePath(repo, slug)
534
+ if err != nil {
535
+ t.Fatal(err)
536
+ }
537
+ lease, err := ReadLease(leasePath)
538
+ if err != nil {
539
+ t.Fatal(err)
540
+ }
541
+ if lease.Status != StatusRunning {
542
+ t.Fatalf("lease status=%s want running after precondition refusal", lease.Status)
543
+ }
544
+ if head := gitOk(t, repo, "rev-parse", "HEAD"); head != base {
545
+ t.Fatalf("control moved despite refusal: %s", head)
546
+ }
547
+
548
+ // Unrelated uncommitted work is not part of the batch and must survive.
549
+ gitOk(t, repo, "checkout", "--", "src/a.go")
550
+ keep := filepath.Join(repo, "unrelated.txt")
551
+ if err := os.WriteFile(keep, []byte("keep me\n"), 0o644); err != nil {
552
+ t.Fatal(err)
553
+ }
554
+ if _, _, err := Integrate(IntegrateOpts{Root: repo, Slug: slug, ApplyToControl: true}); err != nil {
555
+ t.Fatalf("integrate with non-overlapping dirty file: %v", err)
556
+ }
557
+ if b, err := os.ReadFile(keep); err != nil || string(b) != "keep me\n" {
558
+ t.Fatalf("unrelated dirty file lost: err=%v body=%q", err, b)
559
+ }
560
+ }
561
+
339
562
  func mustRead(t *testing.T, path string) string {
340
563
  t.Helper()
341
564
  b, err := os.ReadFile(path)
@@ -68,15 +68,30 @@ func validateSlicePaths(paths []string, label, root string) ([]string, error) {
68
68
  if path == ".devrites" || strings.HasPrefix(path, ".devrites/") {
69
69
  return nil, fmt.Errorf("%s: path must not include .devrites: %q", label, path)
70
70
  }
71
+ // Same for git internals: a transfer commit can never carry .git/**
72
+ // anyway, and inside a worktree .git is a file, so claiming it is
73
+ // pure footgun.
74
+ if path == ".git" || strings.HasPrefix(path, ".git/") {
75
+ return nil, fmt.Errorf("%s: path must not include .git: %q", label, path)
76
+ }
71
77
  if _, ok := seen[path]; ok {
72
78
  return nil, fmt.Errorf("%s: duplicate path %q", label, path)
73
79
  }
74
80
  seen[path] = struct{}{}
75
81
  normalized = append(normalized, path)
76
82
  if root != "" {
77
- full := filepath.Join(root, filepath.FromSlash(path))
78
- if info, err := os.Lstat(full); err == nil && info.Mode()&os.ModeSymlink != 0 {
79
- return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
83
+ // Check every component, not just the leaf: a symlinked directory
84
+ // in the chain would let a worktree write outside itself.
85
+ cur := root
86
+ for _, seg := range strings.Split(path, "/") {
87
+ cur = filepath.Join(cur, seg)
88
+ info, err := os.Lstat(cur)
89
+ if err != nil {
90
+ break // missing components are fine; git creates them
91
+ }
92
+ if info.Mode()&os.ModeSymlink != 0 {
93
+ return nil, fmt.Errorf("%s: symlink path is not allowed: %q", label, path)
94
+ }
80
95
  }
81
96
  }
82
97
  }
@@ -87,6 +87,27 @@ func TestCheckPathDisjointSymlinkRoot(t *testing.T) {
87
87
  }
88
88
  }
89
89
 
90
+ // A symlinked directory inside a claimed path must also be rejected: the
91
+ // leaf Lstat alone misses it, and a worktree would then write outside itself.
92
+ func TestCheckPathDisjointSymlinkedDir(t *testing.T) {
93
+ t.Parallel()
94
+ dir := t.TempDir()
95
+ target := filepath.Join(dir, "outside")
96
+ if err := os.MkdirAll(target, 0o755); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ if err := os.Symlink(target, filepath.Join(dir, "linkdir")); err != nil {
100
+ t.Skip("symlinks unavailable")
101
+ }
102
+ _, err := CheckPathDisjoint([]SlicePaths{
103
+ {ID: "a", Paths: []string{"linkdir/x.go"}},
104
+ {ID: "b", Paths: []string{"other.go"}},
105
+ }, dir)
106
+ if err == nil || !strings.Contains(err.Error(), "symlink") {
107
+ t.Fatalf("expected symlink error for symlinked dir, got %v", err)
108
+ }
109
+ }
110
+
90
111
  func TestParseSlicesJSONShapes(t *testing.T) {
91
112
  t.Parallel()
92
113
  a, err := ParseSlicesJSON([]byte(`{"slices":[{"id":"a","paths":["x.go"]},{"id":"b","paths":["y.go"]}]}`))
@@ -83,9 +83,8 @@ executable controller/harness/bundle bytes or a missing writer, read
83
83
  3. **Arm AFK once.** Apply the loop's
84
84
  [one-write AFK contract](reference/loop.md#arm-afk-once): preserve valid
85
85
  existing bytes or create the bounded advisory-only sentinel once; never
86
- rewrite it after Vet. Preserve an existing sentinel byte-for-byte. Touch
87
- `.devrites/CHECKPOINT` so proven slices get local crash-survivable WIP
88
- checkpoints for Ship to collapse. **Completion:** valid read-only AFK and checkpoint sentinels exist.
86
+ rewrite it after Vet. Preserve an existing sentinel byte-for-byte.
87
+ **Completion:** a valid read-only AFK sentinel exists.
89
88
  4. **Drive phases.** Follow [the loop](reference/loop.md): `/rite-spec` →
90
89
  `/rite-clarify` → `/rite-temper` → `/rite-define` → `/rite-vet` →
91
90
  `/rite-build` × pending slices → `/rite-prove` → `/rite-polish` →
@@ -58,9 +58,13 @@ Wright applies anti-slop; root verifies returns and never patches source.
58
58
  ## `--parallel N` (opt-in)
59
59
 
60
60
  Omitted/`1` ≡ serial; `2`–`10` → path-disjoint fan-out when eligible; else hard refuse.
61
- All-green serial integrate; one red/gap aborts. Abort with agent-owned plan
62
- gaps continues via Spec Drift Guard (batch sweep, one folded plan repair + one
63
- vet recheck inline); do not emit a human `Fix`. AFK charges after integrate only. Running lease blocks another
61
+ All-green serial integrate; a red/gap sibling gets a bounded repair round in
62
+ its own worktree never rebuilt from scratch while budget remains.
63
+ Plan-owned gaps still route through Spec Drift Guard (batch sweep, one folded
64
+ plan repair + one vet recheck inline); do not emit a human `Fix`. Exhausted
65
+ repair blocks and preserves everything; `cleanup --force` (human discard
66
+ path only) salvages slice branches before removing worktrees.
67
+ AFK charges after integrate only. Running lease blocks another
64
68
  `/rite-build`. Details: `parallel-batch.md`.
65
69
 
66
70
  ## Execute and reply
@@ -1,13 +1,8 @@
1
- # Checkpoint: crash-survivable WIP commits (opt-in)
1
+ # Checkpoint: proven slices land as local WIP commits
2
2
 
3
- `.devrites/` markdown survives compaction, but a slice's source normally stays
4
- uncommitted until `/rite-ship`. A crash during unattended Build can lose that source.
5
- Checkpoint mode commits each proven slice as a local `WIP`, so it survives the session.
6
-
7
- ## When it's on
8
- Opt in with `.devrites/CHECKPOINT`, the mirror of `.devrites/AFK`. When absent,
9
- `/rite-build` makes no checkpoint. Autocomplete sets it for long unattended runs.
10
- **Local-only:** never push a checkpoint or let scratch work trigger CI.
3
+ A slice's source never stays uncommitted after its gates are green: at RECORD
4
+ the orchestrator commits the proven slice as a local `WIP`, so the working
5
+ tree stays clean and the work survives a crash or compaction.
11
6
 
12
7
  ## The checkpoint commit: orchestrator, at RECORD, after gates are green
13
8
  A checkpoint records a **proven** slice only after its gates are green. Stage the exact
@@ -16,7 +11,6 @@ Never stage `touched-files.md`, a directory, glob, unrelated path, or user chang
16
11
  Verify the staged set, then commit locally with a `[devrites-context]` body:
17
12
 
18
13
  ```bash
19
- [ -f .devrites/CHECKPOINT ] || exit 0 # sentinel absent → no-op, silent
20
14
  git commit -m "WIP(<slug>): <slice>" -m "$(cat <<'BODY'
21
15
  [devrites-context]
22
16
  decisions: <one-line delta this slice added to decisions.md>
@@ -26,6 +20,10 @@ BODY
26
20
  )"
27
21
  ```
28
22
 
23
+ When host reconciliation already landed the isolated transfer commit, nothing
24
+ is staged and the step no-ops. **Local-only:** never push a checkpoint or let
25
+ scratch work trigger CI.
26
+
29
27
  ## Restore
30
28
  After a crash, a fresh session may read the last `WIP(<slug>)` body as crash context.
31
29
  Authoritative state remains in `.devrites/`: validate the body against that workspace;
@@ -39,8 +37,8 @@ one atomic feature commit before the Conventional-Commit ladder: see the collaps
39
37
  [git-ship.md](../../rite-ship/reference/git-ship.md). Result: one clean commit, bisect
40
38
  stays green.
41
39
 
42
- ## Autocomplete clean-baseline use
43
- `/rite-autocomplete` may arm checkpoints after verifying a clean or accepted baseline.
44
- They authorize neither red-gate continuation nor Ship. Each wright returns after one
45
- slice; HITL stops, while explicit `.devrites/AFK` lets the controlling Build root chain
46
- another green slice only under its cap and pause rules.
40
+ ## Autocomplete
41
+ Checkpoints are unconditional; `/rite-autocomplete` needs no sentinel arming.
42
+ Each wright returns after one slice; HITL stops, while explicit `.devrites/AFK`
43
+ lets the controlling Build root chain another green slice only under its cap
44
+ and pause rules.