reject unnumbered trails and stale worktrees · Entire
reject unnumbered trails and stale worktrees
6d7465e→main·
pfleidi·1w ago·2 files·+80 added/-11 removed
Worktree checkout now requires a trail number: sanitized branch names are lossy, so the number is the directory's only disambiguator, and sibling commands already reject unnumbered trails.
A branch registered to a hand-deleted worktree directory now fails with a 'git worktree prune' hint instead of pointing the cd hint at a missing path; pruning stays the user's call.
Sessions
01KX6KG59RY4J2V681T4KR8AGCView transcript
[?
Add trail checkout --worktree SupportClaude Code·1 step](/content/gh/entireio/cli/session/c5215fe7-28db-4a83-9d2e-482217df292d#timeline-01KX6KG59RY4J2V681T4KR8AGC/index.html)
Changes
2
cmd/entire/cli
Mtrail_checkout_worktree.go+22/-9
Mtrail_checkout_worktree_test.go+58/-2
26 unmodified lines
27
28
29
30
31
32
33
30
31
32
33
285 unmodified lines
319
320
321
322
323
324
325
326
327
328
329
330
8 unmodified lines
339
340
341
339
340
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
342
343
344
358
359
360
26 unmodified lines
)
func defaultTrailWorktreePath(repoRoot, branch string, trailNumber int) string {
name := sanitizeTrailWorktreeName(branch)
if trailNumber > 0 {
name = fmt.Sprintf("trail-%d-%s", trailNumber, name)
}
name := fmt.Sprintf("trail-%d-%s", trailNumber, sanitizeTrailWorktreeName(branch))
return filepath.Join(repoRoot, filepath.FromSlash(trailWorktreesRelDir), name)
}
285 unmodified lines
// <main-root>/.entire/worktrees instead of switching the current checkout.
// The final output line is a shell-safe `cd '<path>'` hint.
func checkoutTrailWorktree(ctx context.Context, w, errW io.Writer, branch string, force bool, trailNumber int) error {
// The trail number disambiguates the worktree directory: sanitized branch
// names are lossy (feature/x and feature-x collide), so an unnumbered
// trail cannot get a unique location.
if trailNumber <= 0 {
return fmt.Errorf("trail for branch %q has no number yet; cannot check out into a worktree", branch)
}
if err := ValidateBranchName(ctx, branch); err != nil {
return err
}
8 unmodified lines
return err
}
if found {
if !match.managed {
return fmt.Errorf("branch %q is already checked out at %s", branch, match.path)
}
switch _, statErr := os.Stat(match.path); {
case statErr == nil:
if !match.managed {
return fmt.Errorf("branch %q is already checked out at %s", branch, match.path)
}
fmt.Fprintf(w, "Worktree already exists at %s\n", match.path)
fmt.Fprintf(w, "cd %s\n", shellQuote(match.path))
return nil
case errors.Is(statErr, fs.ErrNotExist):
// The worktree directory was deleted by hand, leaving a stale git
// registration that blocks a fresh `git worktree add` for the
// branch. Cleaning it up is the user's call.
return fmt.Errorf("branch %q is registered to a missing worktree at %s; run 'git worktree prune' to clear it", branch, match.path)
default:
return fmt.Errorf("failed to check worktree at %s: %w", match.path, statErr)
}
fmt.Fprintf(w, "Worktree already exists at %s\n", match.path)
fmt.Fprintf(w, "cd %s\n", shellQuote(match.path))
return nil
}
proceed, err := ensureTrailWorktreeBranchAvailable(ctx, w, branch, force)
Mcmd/entire/cli/trail_checkout_worktree.go+22/-9
24 unmodified lines
25
26
27
28
29
28
29
30
31
32
425 unmodified lines
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
24 unmodified lines
trailNumber int
want string
}{
{"with number", "peter/feature.auth", 123, filepath.Join("/repo", ".entire", "worktrees", "trail-123-peter-feature.auth")},
{"without number", "feature/other", 0, filepath.Join("/repo", ".entire", "worktrees", "feature-other")},
{"slash branch", "peter/feature.auth", 123, filepath.Join("/repo", ".entire", "worktrees", "trail-123-peter-feature.auth")},
{"plain branch", "feature-other", 7, filepath.Join("/repo", ".entire", "worktrees", "trail-7-feature-other")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
425 unmodified lines
}
func TestCheckoutTrailWorktree_RejectsUnnumberedTrail(t *testing.T) {
repoDir := newTrailWorktreeTestRepo(t)
runGit(t, repoDir, "branch", "feature/unnumbered")
t.Chdir(repoDir)
var out, errOut bytes.Buffer
err := checkoutTrailWorktree(context.Background(), &out, &errOut, "feature/unnumbered", false, 0)
if err == nil || !strings.Contains(err.Error(), "has no number yet") {
t.Fatalf("error = %v, want no-number rejection", err)
}
if _, statErr := os.Stat(filepath.Join(repoDir, ".entire", "worktrees")); !os.IsNotExist(statErr) {
t.Fatalf(".entire/worktrees stat = %v, want not exist", statErr)
}
}
func TestCheckoutTrailWorktree_StaleManagedWorktreeErrorsWithPruneHint(t *testing.T) {
repoDir := newTrailWorktreeTestRepo(t)
runGit(t, repoDir, "branch", "feature/stale")
t.Chdir(repoDir)
var out1, err1 bytes.Buffer
if err := checkoutTrailWorktree(context.Background(), &out1, &err1, "feature/stale", false, 4); err != nil {
t.Fatalf("first checkout: %v; stderr: %s", err, err1.String())
}
worktreePath := filepath.Join(repoDir, ".entire", "worktrees", "trail-4-feature-stale")
if err := os.RemoveAll(worktreePath); err != nil {
t.Fatalf("remove worktree dir: %v", err)
}
var out2, err2 bytes.Buffer
err := checkoutTrailWorktree(context.Background(), &out2, &err2, "feature/stale", false, 4)
if err == nil || !strings.Contains(err.Error(), "git worktree prune") {
t.Fatalf("error = %v, want prune hint", err)
}
if _, statErr := os.Stat(worktreePath); !os.IsNotExist(statErr) {
t.Fatalf("worktree path stat = %v, want not recreated", statErr)
}
}
func TestCheckoutTrailWorktree_StaleNonManagedWorktreeErrors(t *testing.T) {
repoDir := newTrailWorktreeTestRepo(t)
runGit(t, repoDir, "branch", "feature/manual")
manualPath := filepath.Join(t.TempDir(), "manual")
runGit(t, repoDir, "worktree", "add", manualPath, "feature/manual")
if err := os.RemoveAll(manualPath); err != nil {
t.Fatalf("remove manual worktree: %v", err)
}
t.Chdir(repoDir)
var out, errOut bytes.Buffer
err := checkoutTrailWorktree(context.Background(), &out, &errOut, "feature/manual", false, 5)
if err == nil || !strings.Contains(err.Error(), "git worktree prune") {
t.Fatalf("error = %v, want prune hint", err)
}
}
func TestCheckoutTrailWorktree_RejectsInvalidBranch(t *testing.T) {
t.Parallel()