devrites 5.2.4 → 5.4.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 +12 -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 +9 -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 +87 -20
  18. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +2 -3
  19. package/pack/generated/claude/skills/rite-build/SKILL.md +9 -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 +87 -20
  23. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +2 -3
  24. package/pack/generated/codex/skills/rite-build/SKILL.md +9 -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 +87 -20
  28. package/pack/generated/omp/skills/rite-autocomplete/SKILL.md +2 -3
  29. package/pack/generated/omp/skills/rite-build/SKILL.md +9 -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 +87 -20
  33. package/package.json +1 -1
@@ -7,6 +7,9 @@ import (
7
7
  "path/filepath"
8
8
  "sort"
9
9
  "strings"
10
+
11
+ "github.com/devrites/devrites/internal/devritespaths"
12
+ "github.com/devrites/devrites/internal/state"
10
13
  )
11
14
 
12
15
  // Test seams: indirection over the real primitives so discriminating tests
@@ -14,6 +17,7 @@ import (
14
17
  var (
15
18
  writeLease = WriteLease
16
19
  removeWorktree = worktreeRemove
20
+ salvage = salvageSlice
17
21
  warnf = func(format string, args ...any) {
18
22
  fmt.Fprintf(os.Stderr, "warning: parallel: "+format+"\n", args...)
19
23
  }
@@ -29,11 +33,35 @@ type CreateOpts struct {
29
33
  Slices []SlicePaths
30
34
  }
31
35
 
36
+ // withLeaseLock serializes lease read-modify-write across engine processes:
37
+ // the host may issue record-green/integrate calls concurrently, and a
38
+ // lock-free pair of them would lose one update.
39
+ func withLeaseLock(repoRoot, slug string, fn func() error) error {
40
+ if err := validateSlug(slug); err != nil {
41
+ return err
42
+ }
43
+ return state.WithFeatureLock(
44
+ filepath.Join(repoRoot, devritespaths.DevritesRootName), slug, fn)
45
+ }
46
+
32
47
  // Create path-disjoint-gates, creates 2-10 worktrees from base, and writes a running lease.
33
- func Create(opts CreateOpts) (*Lease, error) {
48
+ func Create(opts CreateOpts) (lease *Lease, err error) {
49
+ err = withLeaseLock(opts.Root, opts.Slug, func() error {
50
+ lease, err = createLocked(opts)
51
+ return err
52
+ })
53
+ return lease, err
54
+ }
55
+
56
+ func createLocked(opts CreateOpts) (*Lease, error) {
34
57
  if err := validateSlug(opts.Slug); err != nil {
35
58
  return nil, err
36
59
  }
60
+ // Store absolute worktree paths in the lease so cleanup's destructive ops
61
+ // never depend on the caller's working directory.
62
+ if abs, err := filepath.Abs(opts.Root); err == nil {
63
+ opts.Root = abs
64
+ }
37
65
  if err := validateBatchID(opts.BatchID); err != nil {
38
66
  return nil, err
39
67
  }
@@ -123,11 +151,20 @@ func Create(opts CreateOpts) (*Lease, error) {
123
151
  cleanupPartial()
124
152
  return nil, err
125
153
  }
154
+ ensureScratchExcluded(opts.Root)
126
155
  return lease, nil
127
156
  }
128
157
 
129
158
  // RecordGreen marks a slice green with its transfer commit.
130
- func RecordGreen(repoRoot, slug, sliceID, commit string) (*Lease, error) {
159
+ func RecordGreen(repoRoot, slug, sliceID, commit string) (lease *Lease, err error) {
160
+ err = withLeaseLock(repoRoot, slug, func() error {
161
+ lease, err = recordGreenLocked(repoRoot, slug, sliceID, commit)
162
+ return err
163
+ })
164
+ return lease, err
165
+ }
166
+
167
+ func recordGreenLocked(repoRoot, slug, sliceID, commit string) (*Lease, error) {
131
168
  leasePath, err := LeasePath(repoRoot, slug)
132
169
  if err != nil {
133
170
  return nil, err
@@ -136,8 +173,8 @@ func RecordGreen(repoRoot, slug, sliceID, commit string) (*Lease, error) {
136
173
  if err != nil {
137
174
  return nil, err
138
175
  }
139
- if lease.Status != StatusRunning {
140
- return nil, fmt.Errorf("record-green requires status=running (have %s)", lease.Status)
176
+ if lease.Status != StatusRunning && lease.Status != StatusIntegrateFailed {
177
+ return nil, fmt.Errorf("record-green requires status=running|integrate-failed (have %s)", lease.Status)
141
178
  }
142
179
  sha, err := revParse(repoRoot, commit)
143
180
  if err != nil {
@@ -161,8 +198,17 @@ func RecordGreen(repoRoot, slug, sliceID, commit string) (*Lease, error) {
161
198
  return lease, nil
162
199
  }
163
200
 
164
- // Abort marks the lease aborted and restores control tip to base_sha.
165
- func Abort(repoRoot, slug string, force bool) (*Lease, error) {
201
+ // Abort marks the lease aborted. Control is never rewound: a moved HEAD is
202
+ // external work, so divergence is reported, not reset.
203
+ func Abort(repoRoot, slug string) (lease *Lease, err error) {
204
+ err = withLeaseLock(repoRoot, slug, func() error {
205
+ lease, err = abortLocked(repoRoot, slug)
206
+ return err
207
+ })
208
+ return lease, err
209
+ }
210
+
211
+ func abortLocked(repoRoot, slug string) (*Lease, error) {
166
212
  leasePath, err := LeasePath(repoRoot, slug)
167
213
  if err != nil {
168
214
  return nil, err
@@ -171,12 +217,15 @@ func Abort(repoRoot, slug string, force bool) (*Lease, error) {
171
217
  if err != nil {
172
218
  return nil, err
173
219
  }
220
+ if lease.Status == StatusComplete {
221
+ return nil, fmt.Errorf("abort refused: lease already complete — the batch landed in control; nothing to discard")
222
+ }
174
223
  base, err := revParse(repoRoot, lease.BaseSHA)
175
224
  if err != nil {
176
225
  return nil, err
177
226
  }
178
- if err := ensureControlAtBase(repoRoot, base, force); err != nil {
179
- return nil, err
227
+ if err := ensureControlAtBase(repoRoot, base); err != nil {
228
+ warnf("control not at base: %v", err)
180
229
  }
181
230
  lease.Status = StatusAborted
182
231
  if err := WriteLease(leasePath, lease); err != nil {
@@ -185,7 +234,10 @@ func Abort(repoRoot, slug string, force bool) (*Lease, error) {
185
234
  return lease, nil
186
235
  }
187
236
 
188
- func ensureControlAtBase(repoRoot, base string, force bool) error {
237
+ // ensureControlAtBase verifies the control tip still equals the batch base.
238
+ // It never mutates: a moved or dirty control belongs to the user, so callers
239
+ // only learn about divergence — they must not repair it by rewinding.
240
+ func ensureControlAtBase(repoRoot, base string) error {
189
241
  head, err := headSHA(repoRoot)
190
242
  if err != nil {
191
243
  return err
@@ -193,24 +245,7 @@ func ensureControlAtBase(repoRoot, base string, force bool) error {
193
245
  if head == base {
194
246
  return nil
195
247
  }
196
- dirty, err := porcelainDirty(repoRoot)
197
- if err != nil {
198
- return err
199
- }
200
- if dirty && !force {
201
- return fmt.Errorf("control tree dirty; refuse reset to base (pass --force to override)")
202
- }
203
- if err := resetHard(repoRoot, base); err != nil {
204
- return err
205
- }
206
- head, err = headSHA(repoRoot)
207
- if err != nil {
208
- return err
209
- }
210
- if head != base {
211
- return fmt.Errorf("failed to leave control at base %s", base)
212
- }
213
- return nil
248
+ return fmt.Errorf("control head %s moved past base %s; refusing to rewind external commits", head, base)
214
249
  }
215
250
 
216
251
  // IntegrateOpts configures staging integrate.
@@ -218,11 +253,18 @@ type IntegrateOpts struct {
218
253
  Root string
219
254
  Slug string
220
255
  ApplyToControl bool
221
- Force bool
222
256
  }
223
257
 
224
258
  // Integrate all-or-nothing applies sibling transfer commits onto a staging branch.
225
259
  func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
260
+ err = withLeaseLock(opts.Root, opts.Slug, func() error {
261
+ tip, lease, err = integrateLocked(opts)
262
+ return err
263
+ })
264
+ return tip, lease, err
265
+ }
266
+
267
+ func integrateLocked(opts IntegrateOpts) (tip string, lease *Lease, err error) {
226
268
  leasePath, err := LeasePath(opts.Root, opts.Slug)
227
269
  if err != nil {
228
270
  return "", nil, err
@@ -231,17 +273,36 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
231
273
  if err != nil {
232
274
  return "", nil, err
233
275
  }
234
- if lease.Status != StatusRunning {
235
- return "", nil, fmt.Errorf("integrate requires status=running (have %s)", lease.Status)
276
+ if lease.Status != StatusRunning && lease.Status != StatusIntegrateFailed {
277
+ return "", nil, fmt.Errorf("integrate requires status=running|integrate-failed (have %s)", lease.Status)
236
278
  }
237
279
  base, err := revParse(opts.Root, lease.BaseSHA)
238
280
  if err != nil {
239
281
  return "", nil, err
240
282
  }
241
- if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
283
+ if err := ensureControlAtBase(opts.Root, base); err != nil {
242
284
  return "", nil, err
243
285
  }
244
286
 
287
+ union := make([]string, 0, len(lease.Slices))
288
+ ids := make([]string, 0, len(lease.Slices))
289
+ for _, sl := range lease.Slices {
290
+ union = append(union, sl.Paths...)
291
+ ids = append(ids, sl.ID)
292
+ }
293
+ if opts.ApplyToControl {
294
+ // FF into control must not silently absorb or clobber the user's
295
+ // uncommitted work on slice paths. Non-overlapping dirty files are
296
+ // left alone — they are not part of the batch.
297
+ dirty, err := porcelainDirtyPaths(opts.Root, union)
298
+ if err != nil {
299
+ return "", nil, err
300
+ }
301
+ if dirty {
302
+ return "", nil, fmt.Errorf("control has uncommitted changes on slice paths; commit or stash them before integrate --apply-to-control")
303
+ }
304
+ }
305
+
245
306
  for _, sl := range lease.Slices {
246
307
  if sl.WrightStatus != WrightGreen {
247
308
  return "", nil, fmt.Errorf("slice %s is not green", sl.ID)
@@ -284,8 +345,8 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
284
345
  leasePath, StatusRunning, retryErr)
285
346
  }
286
347
  }
287
- if err := ensureControlAtBase(opts.Root, base, true); err != nil {
288
- warnf("control reset to base: %v", err)
348
+ if err := ensureControlAtBase(opts.Root, base); err != nil {
349
+ warnf("control diverged from base during integrate: %v", err)
289
350
  }
290
351
  return "", lease, reason
291
352
  }
@@ -318,29 +379,16 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
318
379
  }
319
380
  }
320
381
 
321
- stageHead, err := headSHA(stageWT)
322
- if err != nil {
323
- return fail(err)
324
- }
325
- canFF, err := isAncestor(opts.Root, stageHead, tc)
326
- if err != nil {
327
- return fail(err)
328
- }
329
- if canFF {
330
- if err := mergeFFOnly(stageWT, tc); err != nil {
331
- return fail(fmt.Errorf("ff-only integrate failed for slice %s: %w", sl.ID, err))
332
- }
333
- } else {
334
- if err := cherryPickRange(stageWT, base, tc); err != nil {
335
- cherryPickAbort(stageWT)
336
- return fail(fmt.Errorf("cherry-pick replay failed for slice %s: %w", sl.ID, err))
337
- }
382
+ if err := cherryPickNoCommit(stageWT, base, tc); err != nil {
383
+ cherryPickAbort(stageWT)
384
+ return fail(fmt.Errorf("squash apply failed for slice %s: %w", sl.ID, err))
338
385
  }
339
386
  }
340
387
 
341
- tip, err = headSHA(stageWT)
342
- if err != nil {
343
- return fail(err)
388
+ msg := fmt.Sprintf("WIP(%s): parallel batch %s (%s)\n\n[devrites-context]\nslices: %s\nbase: %s",
389
+ opts.Slug, lease.BatchID, strings.Join(ids, ", "), strings.Join(ids, ", "), base)
390
+ if tip, err = stagePathsAndCommit(stageWT, msg, union); err != nil {
391
+ return fail(fmt.Errorf("squash commit: %w", err))
344
392
  }
345
393
  if err := removeWorktree(opts.Root, stageWT); err != nil {
346
394
  if _, statErr := os.Stat(stageWT); statErr == nil {
@@ -359,14 +407,16 @@ func Integrate(opts IntegrateOpts) (tip string, lease *Lease, err error) {
359
407
  if err := mergeFFOnly(opts.Root, tip); err != nil {
360
408
  return fail(fmt.Errorf("control fast-forward to integrate tip failed: %w", err))
361
409
  }
362
- } else if err := ensureControlAtBase(opts.Root, base, opts.Force); err != nil {
410
+ lease.Status = StatusComplete
411
+ if err := WriteLease(leasePath, lease); err != nil {
412
+ return "", nil, err
413
+ }
414
+ } else if err := ensureControlAtBase(opts.Root, base); err != nil {
363
415
  return fail(err)
364
416
  }
365
-
366
- lease.Status = StatusComplete
367
- if err := WriteLease(leasePath, lease); err != nil {
368
- return "", nil, err
369
- }
417
+ // Without --apply-to-control the lease stays running: the squash commit
418
+ // lives only on the integrate branch, and a running lease keeps a
419
+ // non-forced cleanup from discarding the refs that hold it.
370
420
  return tip, lease, nil
371
421
  }
372
422
 
@@ -382,24 +432,105 @@ func pathListsEqual(a, b []string) bool {
382
432
  return true
383
433
  }
384
434
 
385
- // Cleanup removes worker worktrees/branches and clears the lease (complete or --force).
386
- func Cleanup(repoRoot, slug string, force bool) error {
387
- leasePath, err := LeasePath(repoRoot, slug)
435
+ // Salvage records one worker branch kept by a non-complete cleanup: the
436
+ // rejected work stays reachable for mining instead of being discarded.
437
+ // Worktree is set when salvage itself failed and the worktree was left on
438
+ // disk as the only remaining copy.
439
+ type Salvage struct {
440
+ SliceID string `json:"slice_id"`
441
+ Branch string `json:"branch"`
442
+ Commit string `json:"commit"`
443
+ Worktree string `json:"worktree,omitempty"`
444
+ }
445
+
446
+ // ownedWorktreePath reports whether the lease's worktree path is exactly the
447
+ // deterministic <scratch>/<batch>/<slice> location Create wrote. Anything else
448
+ // is not ours: cleanup must neither salvage into it nor delete it.
449
+ func ownedWorktreePath(repoRoot, batchID string, sl LeaseSlice) bool {
450
+ got, err1 := filepath.Abs(sl.WorktreePath)
451
+ want, err2 := filepath.Abs(WorkerWorktreePath(repoRoot, batchID, sl.ID))
452
+ return err1 == nil && err2 == nil && got == want
453
+ }
454
+
455
+ // salvageSlice commits any uncommitted allowlisted changes in a worker
456
+ // worktree onto its slice branch so forced cleanup loses no wright work.
457
+ func salvageSlice(sl LeaseSlice, batchID string) (string, error) {
458
+ if sl.WorktreePath == "" || len(sl.Paths) == 0 {
459
+ return "", nil
460
+ }
461
+ if _, err := os.Stat(sl.WorktreePath); err != nil {
462
+ // Worktree already gone; committed work stays reachable on the branch.
463
+ return "", nil
464
+ }
465
+ dirty, err := porcelainDirtyPaths(sl.WorktreePath, sl.Paths)
388
466
  if err != nil {
467
+ return "", err
468
+ }
469
+ if !dirty {
470
+ return "", nil
471
+ }
472
+ return stagePathsAndCommit(sl.WorktreePath,
473
+ fmt.Sprintf("devrites: salvage %s WIP (%s)", sl.ID, batchID), sl.Paths)
474
+ }
475
+
476
+ // Cleanup removes worker worktrees and clears the lease. A complete batch
477
+ // deletes the merged slice branches; a forced non-complete cleanup first
478
+ // salvages each sibling — uncommitted allowlisted changes become a commit on
479
+ // the slice branch — and keeps every branch whose tip moved past base as the
480
+ // durable rejected-work ref.
481
+ func Cleanup(repoRoot, slug string, force bool) (salvaged []Salvage, err error) {
482
+ err = withLeaseLock(repoRoot, slug, func() error {
483
+ salvaged, err = cleanupLocked(repoRoot, slug, force)
389
484
  return err
485
+ })
486
+ return salvaged, err
487
+ }
488
+
489
+ func cleanupLocked(repoRoot, slug string, force bool) ([]Salvage, error) {
490
+ leasePath, err := LeasePath(repoRoot, slug)
491
+ if err != nil {
492
+ return nil, err
390
493
  }
391
494
  if _, err := os.Stat(leasePath); os.IsNotExist(err) {
392
- return nil
495
+ return nil, nil
393
496
  }
394
497
  lease, err := ReadLease(leasePath)
395
498
  if err != nil {
396
- return err
499
+ return nil, err
397
500
  }
398
501
  if lease.Status != StatusComplete && !force {
399
- return fmt.Errorf("cleanup refuses status=%s without --force (abort keeps workers for diagnosis)", lease.Status)
502
+ return nil, fmt.Errorf("cleanup refuses status=%s without --force (non-complete cleanup salvages work onto slice branches)", lease.Status)
400
503
  }
504
+ complete := lease.Status == StatusComplete
505
+ base := ""
506
+ if !complete {
507
+ if b, err := revParse(repoRoot, lease.BaseSHA); err != nil {
508
+ warnf("base sha %s: %v", lease.BaseSHA, err)
509
+ } else {
510
+ base = b
511
+ }
512
+ }
513
+ var salvaged []Salvage
514
+ keptWorktrees := map[string]bool{}
401
515
  for _, sl := range lease.Slices {
402
- if sl.WorktreePath != "" {
516
+ salvageFailed := false
517
+ // Only the deterministic scratch layout is ours to touch. A forged or
518
+ // stale lease worktree_path is never salvaged into (git ops would run
519
+ // in an arbitrary repo) and never removed (RemoveAll would delete an
520
+ // arbitrary directory).
521
+ owned := sl.WorktreePath != "" && ownedWorktreePath(repoRoot, lease.BatchID, sl)
522
+ if sl.WorktreePath != "" && !owned {
523
+ warnf("slice %s worktree_path %q is outside %s; leaving it untouched",
524
+ sl.ID, sl.WorktreePath, ScratchRoot(repoRoot))
525
+ }
526
+ if !complete && owned {
527
+ if _, err := salvage(sl, lease.BatchID); err != nil {
528
+ warnf("salvage %s: %v", sl.ID, err)
529
+ salvageFailed = true
530
+ keptWorktrees[sl.ID] = true
531
+ }
532
+ }
533
+ if owned && !salvageFailed {
403
534
  if err := removeWorktree(repoRoot, sl.WorktreePath); err != nil {
404
535
  warnf("worktree cleanup %s: %v", sl.WorktreePath, err)
405
536
  }
@@ -407,23 +538,76 @@ func Cleanup(repoRoot, slug string, force bool) error {
407
538
  warnf("worktree dir cleanup %s: %v", sl.WorktreePath, err)
408
539
  }
409
540
  }
541
+ if sl.Branch != "" && !ownedBranchName(slug, lease.BatchID, sl.Branch) {
542
+ warnf("slice %s branch %q is outside devrites/parallel/%s/%s/; leaving it untouched",
543
+ sl.ID, sl.Branch, slug, lease.BatchID)
544
+ if salvageFailed {
545
+ salvaged = append(salvaged, Salvage{SliceID: sl.ID, Worktree: sl.WorktreePath})
546
+ }
547
+ continue
548
+ }
410
549
  if sl.Branch != "" {
411
- if err := deleteBranch(repoRoot, sl.Branch); err != nil {
550
+ keep := salvageFailed
551
+ tip := ""
552
+ if !complete {
553
+ if t, err := revParse(repoRoot, sl.Branch); err != nil {
554
+ warnf("branch tip %s: %v", sl.Branch, err)
555
+ keep = true // fail toward preservation
556
+ } else {
557
+ tip = t
558
+ keep = keep || base == "" || tip != base
559
+ }
560
+ }
561
+ if keep {
562
+ s := Salvage{SliceID: sl.ID, Branch: sl.Branch, Commit: tip}
563
+ if salvageFailed {
564
+ s.Worktree = sl.WorktreePath
565
+ }
566
+ salvaged = append(salvaged, s)
567
+ } else if err := deleteBranch(repoRoot, sl.Branch); err != nil {
412
568
  warnf("branch cleanup %s: %v", sl.Branch, err)
413
569
  }
570
+ } else if salvageFailed {
571
+ salvaged = append(salvaged, Salvage{SliceID: sl.ID, Worktree: sl.WorktreePath})
414
572
  }
415
573
  }
416
574
  ibranch := IntegrateBranchName(slug, lease.BatchID)
417
- if err := deleteBranch(repoRoot, ibranch); err != nil {
418
- warnf("integrate branch cleanup %s: %v", ibranch, err)
575
+ if itip, err := revParse(repoRoot, ibranch); err == nil {
576
+ keep := true
577
+ if head, herr := headSHA(repoRoot); herr == nil {
578
+ if anc, aerr := isAncestor(repoRoot, itip, head); aerr == nil && anc {
579
+ keep = false
580
+ }
581
+ }
582
+ if keep {
583
+ salvaged = append(salvaged, Salvage{SliceID: "integrate", Branch: ibranch, Commit: itip})
584
+ } else if err := deleteBranch(repoRoot, ibranch); err != nil {
585
+ warnf("integrate branch cleanup %s: %v", ibranch, err)
586
+ }
419
587
  }
420
- if err := os.RemoveAll(filepath.Join(ScratchRoot(repoRoot), lease.BatchID)); err != nil {
421
- warnf("scratch cleanup: %v", err)
588
+ batchDir := filepath.Join(ScratchRoot(repoRoot), lease.BatchID)
589
+ if len(keptWorktrees) == 0 {
590
+ if err := os.RemoveAll(batchDir); err != nil {
591
+ warnf("scratch cleanup: %v", err)
592
+ }
593
+ } else if entries, err := os.ReadDir(batchDir); err == nil {
594
+ // A failed salvage keeps its worktree as the only copy — remove the
595
+ // batch dir's other entries but never a kept slice dir.
596
+ for _, e := range entries {
597
+ if keptWorktrees[e.Name()] {
598
+ continue
599
+ }
600
+ if err := os.RemoveAll(filepath.Join(batchDir, e.Name())); err != nil {
601
+ warnf("scratch entry cleanup %s: %v", e.Name(), err)
602
+ }
603
+ }
604
+ } else {
605
+ warnf("scratch cleanup %s: %v", batchDir, err)
422
606
  }
423
607
  if _, err := git(repoRoot, "worktree", "prune"); err != nil {
424
608
  warnf("worktree prune: %v", err)
425
609
  }
426
- return ClearLease(leasePath)
610
+ return salvaged, ClearLease(leasePath)
427
611
  }
428
612
 
429
613
  // StatusReport returns a human-readable lease status.