harden worktree checks from review feedback · Entire

harden worktree checks from review feedback

11febb6→main·pfleidi·1w ago·2 files·+43 added/-1 removed

Use Lstat and require a directory when reusing a registered worktree path, so a planted symlink is rejected instead of passing the health check. Run 'git worktree list' from the main root and include git's stderr in the failure message.

Sessions

01KX6XRJEFTVG0ABV9GDFE0G00View transcript

?\ Add trail checkout --worktree SupportClaude Code·2 steps

Changes

2

313 unmodified lines

314
315
316
317
317
318
319
320
321
322
323
324
97 unmodified lines

422
423
424
425
426
427
428
429
430
431
432
433
434

313 unmodified lines

return err
    }
    if found {
        switch _, statErr := os.Stat(match.path); {
            // Lstat: a symlink planted at the registered path must not pass as a
            // healthy worktree directory.
            switch info, statErr := os.Lstat(match.path); {
            case statErr == nil && !info.IsDir():
                return fmt.Errorf("branch %q is registered to %s, which is not a directory", branch, match.path)
            case statErr == nil:
                if !match.managed {
                    return fmt.Errorf("branch %q is already checked out at %s", branch, match.path)
            }
            97 unmodified lines

// with found reporting whether any worktree does.
func findWorktreeForBranch(ctx context.Context, branch, root string) (match trailWorktreeMatch, found bool, err error) {
    cmd := exec.CommandContext(ctx, "git", "worktree", "list", "--porcelain")
    cmd.Dir = root
    output, err := cmd.Output()
    if err != nil {
        var exitErr *exec.ExitError
        if errors.As(err, &exitErr) && len(bytes.TrimSpace(exitErr.Stderr)) > 0 {
            return trailWorktreeMatch{}, false, fmt.Errorf("failed to list worktrees: %s: %w", bytes.TrimSpace(exitErr.Stderr), err)
        }
        return trailWorktreeMatch{}, false, fmt.Errorf("failed to list worktrees: %w", err)
    }
    // Empty currentRoot: match any worktree, including the current checkout.

Mcmd/entire/cli/trail_checkout_worktree.go+10/-1

601 unmodified lines

602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640

601 unmodified lines

}
}

func TestCheckoutTrailWorktree_RegisteredPathNotADirectory(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    runGit(t, repoDir, "branch", "feature/swapped")
    t.Chdir(repoDir)

var out1, err1 bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out1, &err1, "feature/swapped", false, 6); err != nil {
        t.Fatalf("first checkout: %v; stderr: %s", err, err1.String())
    }
    worktreePath := filepath.Join(repoDir, ".entire", "worktrees", "trail-6-feature-swapped")
    if err := os.RemoveAll(worktreePath); err != nil {
        t.Fatalf("remove worktree dir: %v", err)
    }
    if err := os.Symlink(repoDir, worktreePath); err != nil {
        t.Skipf("symlinks unsupported: %v", err)
    }

var out2, err2 bytes.Buffer
    err := checkoutTrailWorktree(context.Background(), &out2, &err2, "feature/swapped", false, 6)
    if err == nil || !strings.Contains(err.Error(), "is not a directory") {
        t.Fatalf("error = %v, want not-a-directory rejection", err)
    }
}

func TestFindWorktreeForBranch_SurfacesGitError(t *testing.T) {
    testutil.IsolateGitConfigEnv(t)

_, _, err := findWorktreeForBranch(context.Background(), "any", t.TempDir())
    if err == nil || !strings.Contains(err.Error(), "not a git repository") {
        t.Fatalf("error = %v, want git stderr in message", err)
    }
}

func TestCheckoutTrailWorktree_RejectsInvalidBranch(t *testing.T) {
    t.Parallel()