add trail worktree checkout core · Entire

add trail worktree checkout core

d9efb82→main· pfleidi·1w ago·2 files·+317 added/-2 removed

Creates a worktree for a trail branch under .entire/worktrees at the main repo root (a sibling even when run from a linked worktree), reuses an existing managed worktree, fetches remote-only branches, applies the ignore rule, copies .worktreeinclude files, and prints a shell-safe cd hint.

Sessions

01KX4P6Y1BDRXV1JDJZ6DZJJ24View transcript

Changes

2

67 unmodified lines

return filepath.Clean(gitDir), nil
}

func trailWorktreeBaseRoot(ctx context.Context) (string, error) { //nolint:unused // used by later tasks
    gitDir, err := gitCommonDirForTrailWorktree(ctx)
    if err != nil {
        return "", err
    }
}

func appendIgnoreRule(path, rule string) error { //nolint:unparam // rule parameter defined in brief signature
    content, err := os.ReadFile(path) //nolint:gosec // path derived from repo root / git common dir
    if err != nil && !errors.Is(err, os.ErrNotExist) {
        return fmt.Errorf("failed to read %s: %w", path, err)
    }
    return nil
}

// checkoutTrailWorktree checks branch out into a managed worktree under
// <main-root>/.entire/worktrees instead of switching the current checkout.
func checkoutTrailWorktree(ctx context.Context, w, errW io.Writer, branch string, force bool, trailNumber int) error { //nolint:unparam // force is false in every caller today; it forwards to ensureTrailWorktreeBranchAvailable and ensureTrailWorktreeIgnoreRule, which branch on it
    if err := ValidateBranchName(ctx, branch); err != nil {
        return err
    }

if existing, ok, err := findWorktreeForBranch(ctx, branch); err != nil {
        return err
    } else if ok {
        fmt.Fprintf(w, "Worktree already exists at %s\n", existing)
        fmt.Fprintf(w, "cd %s\n", shellQuotePath(existing))
        return nil
    }

proceed, err := ensureTrailWorktreeBranchAvailable(ctx, w, branch, force)
    if err != nil {
        return err
    }
    if !proceed {
        fmt.Fprintf(w, "Checkout of branch %s cancelled.\n", branch)
        return nil
    }

root, err := trailWorktreeBaseRoot(ctx)
    if err != nil {
        return fmt.Errorf("failed to find main worktree root: %w", err)
    }
    if err := ensureTrailWorktreeIgnoreRule(ctx, w, root, force); err != nil {
        return err
    }

worktreePath := defaultTrailWorktreePath(root, branch, trailNumber)
    if err := os.MkdirAll(filepath.Dir(worktreePath), 0o750); err != nil {
        return fmt.Errorf("failed to create worktree parent: %w", err)
    }
    add := exec.CommandContext(ctx, "git", "worktree", "add", worktreePath, branch)
    add.Dir = root
    if output, err := add.CombinedOutput(); err != nil {
        return fmt.Errorf("failed to create worktree: %s: %w", strings.TrimSpace(string(output)), err)
    }

if err := copyWorktreeIncludeFiles(ctx, errW, root, worktreePath); err != nil {
        fmt.Fprintf(errW, "warning: could not copy %s files: %v\n", worktreeIncludeFile, err)
    }

fmt.Fprintf(w, "Worktree ready at %s\n", worktreePath)
    fmt.Fprintf(w, "cd %s\n", shellQuotePath(worktreePath))
    return nil
}

// findWorktreeForBranch reports whether branch is already checked out in a
// worktree managed under <main-root>/.entire/worktrees.
func findWorktreeForBranch(ctx context.Context, branch string) (string, bool, error) {
    root, err := trailWorktreeBaseRoot(ctx)
    if err != nil {
        return "", false, fmt.Errorf("failed to find main worktree root: %w", err)
    }
    managedRoot := normalizeWorktreePath(filepath.Join(root, filepath.FromSlash(trailWorktreesRelDir)))

cmd := exec.CommandContext(ctx, "git", "worktree", "list", "--porcelain")
    output, err := cmd.Output()
    if err != nil {
        return "", false, fmt.Errorf("failed to list worktrees: %w", err)
    }
    var currentPath string
    for _, line := range strings.Split(string(output), "\n") {
        line = strings.TrimSpace(line)
        if line == "" {
            currentPath = ""
            continue
        }
        if strings.HasPrefix(line, "worktree ") {
            currentPath = strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
            continue
        }
        if strings.TrimPrefix(line, "branch ") == "refs/heads/"+branch && currentPath != "" {
            normalized := normalizeWorktreePath(currentPath)
            if normalized == managedRoot || strings.HasPrefix(normalized, managedRoot+string(filepath.Separator)) {
                return currentPath, true, nil
            }
        }
    }
    return "", false, nil
}

// ensureTrailWorktreeBranchAvailable makes sure branch exists locally,
// fetching it from origin when it only exists there. It returns false when
// the user declined the fetch prompt. --force and non-interactive runs fetch
// without prompting.
func ensureTrailWorktreeBranchAvailable(ctx context.Context, w io.Writer, branch string, force bool) (bool, error) {
    exists, err := BranchExistsLocally(ctx, branch)
    if err != nil {
        return false, fmt.Errorf("failed to check branch: %w", err)
    }
    if exists {
        return true, nil
    }

remoteExists, err := BranchExistsOnRemote(ctx, branch)
    if err != nil {
        return false, fmt.Errorf("failed to check remote branch: %w", err)
    }
    if !remoteExists {
        return false, fmt.Errorf("branch %q not found locally or on origin", branch)
    }
    if !force && interactive.CanPromptInteractively() {
        shouldFetch, err := promptFetchFromRemote(branch)
        if err != nil {
            return false, err
        }
        if !shouldFetch {
            return false, nil
        }
    }

fmt.Fprintf(w, "Fetching branch '%s' from origin...\n", branch)
    if err := fetchTrailWorktreeBranch(ctx, branch); err != nil {
        return false, err
    }
    return true, nil
}

// fetchTrailWorktreeBranch fetches origin's branch directly into refs/heads
// so `git worktree add` can check it out without touching the current
// checkout.
func fetchTrailWorktreeBranch(ctx context.Context, branch string) error {
    refSpec := fmt.Sprintf("refs/heads/%s:refs/heads/%s", branch, branch)
    cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec)
    if output, err := cmd.CombinedOutput(); err != nil {
        return fmt.Errorf("failed to fetch branch from origin: %s: %w", strings.TrimSpace(string(output)), err)
    }
    return nil
}
``

Mcmd/entire/cli/trail_checkout_worktree.go+138/-2

```go
3 unmodified lines

"""
package cli_test
import (
    "bytes"
    "context"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/testutil"
)

"""
"""

"""

"""

""

```go
func newTrailWorktreeTestRepo(t *testing.T) string {
    t.Helper()
    testutil.IsolateGitConfigEnv(t)
    paths.ClearWorktreeRootCache()
    t.Cleanup(paths.ClearWorktreeRootCache)

repoDir := t.TempDir()
    testutil.InitRepo(t, repoDir)
    testutil.WriteFile(t, repoDir, "README.md", "test\n")
    testutil.GitAdd(t, repoDir, "README.md")
    testutil.GitCommit(t, repoDir, "initial")
    return repoDir
}

func runTrailWorktreeGit(t *testing.T, dir string, args ...string) {
    t.Helper()
    cmd := exec.CommandContext(context.Background(), "git", args...)
    cmd.Dir = dir
    output, err := cmd.CombinedOutput()
    if err != nil {
        t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
    }
}

func currentBranchInDir(t *testing.T, dir string) string {
    t.Helper()
    cmd := exec.CommandContext(context.Background(), "git", "branch", "--show-current")
    cmd.Dir = dir
    output, err := cmd.Output()
    if err != nil {
        t.Fatalf("git branch --show-current failed: %v", err)
    }
    return strings.TrimSpace(string(output))
}

func TestCheckoutTrailWorktree_CreatesWorktree(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    runTrailWorktreeGit(t, repoDir, "branch", "feature/test")
    testutil.WriteFile(t, repoDir, ".worktreeinclude", ".env\n")
    testutil.WriteFile(t, repoDir, ".env", "SECRET=1\n")
    testutil.WriteFile(t, repoDir, ".gitignore", ".env\n")
    testutil.GitAdd(t, repoDir, ".worktreeinclude", ".gitignore")
    testutil.GitCommit(t, repoDir, "add include config")
    startBranch := currentBranchInDir(t, repoDir)
    t.Chdir(repoDir)

var out, errOut bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out, &errOut, "feature/test", false, 7); err != nil {
        t.Fatalf("checkoutTrailWorktree: %v; stderr: %s", err, errOut.String())
    }

wantPath := filepath.Join(repoDir, ".entire", "worktrees", "trail-7-feature-test")
    if !strings.Contains(out.String(), "cd "+shellQuotePath(wantPath)) {
        t.Fatalf("output = %q, want cd hint for %q", out.String(), wantPath)
    }
    if got := currentBranchInDir(t, repoDir); got != startBranch {
        t.Fatalf("current branch = %q, want unchanged %q", got, startBranch)
    }
    if got := currentBranchInDir(t, wantPath); got != "feature/test" {
        t.Fatalf("worktree branch = %q, want feature/test", got)
    }
    if _, err := os.Stat(filepath.Join(wantPath, ".env")); err != nil {
        t.Fatalf(".worktreeinclude copy missing: %v", err)
    }
    excludeContent, err := os.ReadFile(filepath.Join(repoDir, ".git", "info", "exclude"))
    if err != nil {
        t.Fatalf("read exclude: %v", err)
    }
    if !strings.Contains(string(excludeContent), ".entire/worktrees/") {
        t.Fatalf("exclude = %q, want .entire/worktrees/ rule", string(excludeContent))
    }
}

func TestCheckoutTrailWorktree_FromLinkedWorktreeCreatesSibling(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    runTrailWorktreeGit(t, repoDir, "branch", "feature/first")
    runTrailWorktreeGit(t, repoDir, "branch", "feature/second")
    t.Chdir(repoDir)

var out1, err1 bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out1, &err1, "feature/first", false, 7); err != nil {
        t.Fatalf("first checkout: %v; stderr: %s", err, err1.String())
    }
    firstPath := filepath.Join(repoDir, ".entire", "worktrees", "trail-7-feature-first")
    t.Chdir(firstPath)

var out2, err2 bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out2, &err2, "feature/second", false, 8); err != nil {
        t.Fatalf("second checkout: %v; stderr: %s", err, err2.String())
    }

wantPath := filepath.Join(repoDir, ".entire", "worktrees", "trail-8-feature-second")
    if _, err := os.Stat(wantPath); err != nil {
        t.Fatalf("sibling worktree missing: %v", err)
    }
    nested := filepath.Join(firstPath, ".entire", "worktrees", "trail-8-feature-second")
    if _, err := os.Stat(nested); !os.IsNotExist(err) {
        t.Fatalf("nested worktree stat = %v, want not exist", err)
    }
}

func TestCheckoutTrailWorktree_ReusesExistingWorktree(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    runTrailWorktreeGit(t, repoDir, "branch", "feature/reuse")
    t.Chdir(repoDir)

var out1, err1 bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out1, &err1, "feature/reuse", false, 9); err != nil {
        t.Fatalf("first checkout: %v; stderr: %s", err, err1.String())
    }
    var out2, err2 bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out2, &err2, "feature/reuse", false, 9); err != nil {
        t.Fatalf("second checkout: %v; stderr: %s", err, err2.String())
    }
    if !strings.Contains(out2.String(), "Worktree already exists") {
        t.Fatalf("second output = %q, want existing-worktree message", out2.String())
    }
}

func TestCheckoutTrailWorktree_FetchesRemoteOnlyBranch(t *testing.T) {
    testutil.IsolateGitConfigEnv(t)
    paths.ClearWorktreeRootCache()
    t.Cleanup(paths.ClearWorktreeRootCache)

tmp := t.TempDir()
    originDir := filepath.Join(tmp, "origin.git")
    seedDir := filepath.Join(tmp, "seed")
    repoDir := filepath.Join(tmp, "local")
    runTrailWorktreeGit(t, tmp, "init", "--bare", originDir)
    testutil.InitRepo(t, seedDir)
    testutil.WriteFile(t, seedDir, "README.md", "test\n")
    testutil.GitAdd(t, seedDir, "README.md")
    testutil.GitCommit(t, seedDir, "initial")
    runTrailWorktreeGit(t, seedDir, "checkout", "-b", "feature/remote")
    testutil.WriteFile(t, seedDir, "remote.txt", "remote\n")
    testutil.GitAdd(t, seedDir, "remote.txt")
    testutil.GitCommit(t, seedDir, "remote branch")
    runTrailWorktreeGit(t, seedDir, "remote", "add", "origin", originDir)
    runTrailWorktreeGit(t, seedDir, "push", "origin", "--all")
    runTrailWorktreeGit(t, tmp, "clone", originDir, repoDir)
    t.Chdir(repoDir)

var out, errOut bytes.Buffer
    if err := checkoutTrailWorktree(context.Background(), &out, &errOut, "feature/remote", false, 12); err != nil {
        t.Fatalf("checkoutTrailWorktree: %v; stderr: %s", err, errOut.String())
    }

wantPath := filepath.Join(repoDir, ".entire", "worktrees", "trail-12-feature-remote")
    if got := currentBranchInDir(t, wantPath); got != "feature/remote" {
        t.Fatalf("worktree branch = %q, want feature/remote", got)
    }
    if _, err := os.Stat(filepath.Join(wantPath, "remote.txt")); err != nil {
        t.Fatalf("remote branch file missing: %v", err)
    }
}

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

var out, errOut bytes.Buffer
    err := checkoutTrailWorktree(context.Background(), &out, &errOut, "-bad", false, 1)
    if err == nil || !strings.Contains(err.Error(), "invalid branch") {
        t.Fatalf("error = %v, want invalid branch", err)
    }
}

func TestCheckoutTrailWorktree_UnknownBranch(t *testing.T) {
    repoDir := newTrailWorktreeTestRepo(t)
    t.Chdir(repoDir)

var out, errOut bytes.Buffer
    err := checkoutTrailWorktree(context.Background(), &out, &errOut, "feature/nope", false, 3)
    if err == nil || !strings.Contains(err.Error(), "not found locally or on origin") {
        t.Fatalf("error = %v, want branch-not-found", err)
    }
}
``

Mcmd/entire/cli/trail_checkout_worktree_test.go+179