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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/engine/internal/install/uninstall.go +3 -0
- package/engine/internal/lib/resolve.go +7 -0
- package/engine/internal/parallel/cli.go +62 -8
- package/engine/internal/parallel/git.go +46 -7
- package/engine/internal/parallel/lease.go +7 -0
- package/engine/internal/parallel/ops.go +256 -72
- package/engine/internal/parallel/ops_test.go +223 -6
- package/engine/internal/parallel/parallel_test.go +229 -6
- package/engine/internal/parallel/pathdisjoint.go +18 -3
- package/engine/internal/parallel/pathdisjoint_test.go +21 -0
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/.claude/skills/rite-build/SKILL.md +7 -3
- package/pack/.claude/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/claude/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/claude/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +2 -2
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/codex/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +2 -2
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +75 -20
- package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
- package/pack/generated/omp/skills/rite-build/SKILL.md +7 -3
- package/pack/generated/omp/skills/rite-build/reference/checkpoint.md +13 -15
- package/pack/generated/omp/skills/rite-build/reference/one-slice-cycle.md +2 -2
- package/pack/generated/omp/skills/rite-build/reference/parallel-batch.md +75 -20
- 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
|
|
54
|
-
origRemove
|
|
55
|
-
|
|
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() {
|
|
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
|
|
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
|
-
|
|
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 !=
|
|
125
|
-
t.Fatalf("abort
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
87
|
-
|
|
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;
|
|
62
|
-
|
|
63
|
-
|
|
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:
|
|
1
|
+
# Checkpoint: proven slices land as local WIP commits
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
43
|
-
`/rite-autocomplete`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|