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
cmd/entire/cli
- Mtrail_checkout_worktree.go+138/-2
Mtrail_checkout_worktree_test.go+179
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