add ignore-rule handling for trail worktrees · Entire

add ignore-rule handling for trail worktrees

253ac7f→main·

pfleidi·1w ago·2 files·+213 added/-0 removed

Ensures .entire/worktrees/ is git-ignored before a worktree checkout: silent no-op when already ignored, interactive .gitignore-first confirm with a .git/info/exclude fallback, and an announced exclude write for non-interactive runs.

Sessions

01KX4N8Y055D1V266G2HV9ZARFView transcript

[?
Add trail checkout --worktree SupportClaude Code·1 step](/content/gh/entireio/cli/session/c5215fe7-28db-4a83-9d2e-482217df292d#timeline-01KX4N8Y055D1V266G2HV9ZARF/index.html)

Changes

2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
26 unmodified lines

45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156

package cli

import (
    "context"
    "errors"
    "fmt"
    "io"
    "os"
    "os/exec"
    "path/filepath"
    "strings"

huh "charm.land/huh/v2"

"github.com/entireio/cli/cmd/entire/cli/interactive"
)

const (

26 unmodified lines

func shellQuotePath(path string) string {
    return "'" + strings.ReplaceAll(path, "'", `\'`) + "'"
}

// gitCommonDirForTrailWorktree returns the absolute git common dir, which is
// the main repo's .git directory even when run from a linked worktree.
func gitCommonDirForTrailWorktree(ctx context.Context) (string, error) {
    cmd := exec.CommandContext(ctx, "git", "rev-parse", "--git-common-dir")
    output, err := cmd.Output()
    if err != nil {
        return "", fmt.Errorf("failed to get git common dir: %w", err)
    }
    gitDir := strings.TrimSpace(string(output))
    if !filepath.IsAbs(gitDir) {
        cwd, wdErr := os.Getwd() //nolint:forbidigo // must resolve relative git common dir in cwd context
        if wdErr != nil {
            return "", fmt.Errorf("failed to get current directory: %w", wdErr)
        }
        gitDir = filepath.Join(cwd, gitDir)
    }
    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
    }
    if filepath.Base(gitDir) != ".git" {
        return "", fmt.Errorf("git common dir %q is not a .git directory", gitDir)
    }
    return filepath.Dir(gitDir), nil
}

// ensureTrailWorktreeIgnoreRule makes sure .entire/worktrees/ is git-ignored.
// Already ignored (any mechanism) → silent no-op. Interactively it offers the
// shared .gitignore first; declining, aborting, --force, or a non-TTY all fall
// back to the local-only .git/info/exclude. Either write prints a notice.
func ensureTrailWorktreeIgnoreRule(ctx context.Context, w io.Writer, root string, force bool) error {
    check := exec.CommandContext(ctx, "git", "check-ignore", "-q", trailWorktreesRelDir+"/")
    check.Dir = root
    err := check.Run()
    if err == nil {
        return nil
    }
    var exitErr *exec.ExitError
    if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 {
        return fmt.Errorf("failed to check ignore status of %s: %w", trailWorktreesRelDir, err)
    }

const rule = trailWorktreesRelDir + "/"
    useGitignore := false
    if !force && interactive.CanPromptInteractively() {
        confirmed := true
        form := NewAccessibleForm(
            huh.NewGroup(
                huh.NewConfirm().
                    Title("Add .entire/worktrees/ to .gitignore?").
                    Description("Choosing No adds a local-only rule to .git/info/exclude instead.").
                    Value(&confirmed),
                ),
            )
        if err := form.Run(); err != nil {
            if !errors.Is(err, huh.ErrUserAborted) {
                return fmt.Errorf("failed to get confirmation: %w", err)
            }
            confirmed = false
            }
        useGitignore = confirmed
    }

if useGitignore {
        if err := appendIgnoreRule(filepath.Join(root, ".gitignore"), rule); err != nil {
            return err
        }
        fmt.Fprintln(w, "Added .entire/worktrees/ to .gitignore — commit this when convenient.")
        return nil
    }
    gitDir, err := gitCommonDirForTrailWorktree(ctx)
    if err != nil {
        return err
    }
    if err := appendIgnoreRule(filepath.Join(gitDir, "info", "exclude"), rule); err != nil {
        return err
    }
    fmt.Fprintln(w, "Added .entire/worktrees/ to .git/info/exclude (local to this clone).")
    return nil
}

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)
    }
    for _, line := range strings.Split(string(content), "\n") {
        if strings.TrimSpace(line) == rule {
            return nil
        }
    }
    if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
        return fmt.Errorf("failed to create %s: %w", filepath.Dir(path), err)
    }
    prefix := ""
    if len(content) > 0 && !strings.HasSuffix(string(content), "\n") {
        prefix = "\n"
    }
    updated := string(content) + prefix + rule + "\n"
    if err := os.WriteFile(path, []byte(updated), 0o600); err != nil { //nolint:gosec // path derived from repo root / git common dir
        return fmt.Errorf("failed to update %s: %w", path, err)
    }
    return nil
}

Mcmd/entire/cli/trail_checkout_worktree.go+118

1
2
3
4
5
6
7
8
9
10
11
12
13
14
47 unmodified lines

62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153

package cli

import (
    "bytes"
    "context"
    "os"
    "path/filepath"
    "strings"
    "testing"

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

func TestDefaultTrailWorktreePath(t *testing.T) {
47 unmodified lines

t.Fatalf("shellQuotePath() = %q, want %q", got, want)
    }
}

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

path := filepath.Join(t.TempDir(), "sub", "exclude")
    for range 2 {
        if err := appendIgnoreRule(path, ".entire/worktrees/"); err != nil {
            t.Fatalf("appendIgnoreRule: %v", err)
        }
    }
    content, err := os.ReadFile(path)
    if err != nil {
        t.Fatalf("read: %v", err)
    }
    if got := strings.Count(string(content), ".entire/worktrees/"); got != 1 {
        t.Fatalf("rule count = %d, want 1; content: %q", got, string(content))
    }
    if !strings.HasSuffix(string(content), "\n") {
        t.Fatalf("content %q missing trailing newline", string(content))
    }
}

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

path := filepath.Join(t.TempDir(), "gitignore")
    if err := os.WriteFile(path, []byte("node_modules"), 0o600); err != nil {
        t.Fatalf("seed: %v", err)
    }
    if err := appendIgnoreRule(path, ".entire/worktrees/"); err != nil {
        t.Fatalf("appendIgnoreRule: %v", err)
    }
    content, err := os.ReadFile(path)
    if err != nil {
        t.Fatalf("read: %v", err)
    }
    if got, want := string(content), "node_modules\n.entire/worktrees/\n"; got != want {
        t.Fatalf("content = %q, want %q", got, want)
    }
}

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

repoDir := t.TempDir()
    testutil.InitRepo(t, repoDir)
    t.Chdir(repoDir)

var out bytes.Buffer
    if err := ensureTrailWorktreeIgnoreRule(context.Background(), &out, repoDir, false); err != nil {
        t.Fatalf("ensureTrailWorktreeIgnoreRule: %v", err)
    }
    content, err := os.ReadFile(filepath.Join(repoDir, ".git", "info", "exclude"))
    if err != nil {
        t.Fatalf("read exclude: %v", err)
    }
    if !strings.Contains(string(content), ".entire/worktrees/") {
        t.Fatalf("exclude = %q, want .entire/worktrees/ rule", string(content))
    }
    if !strings.Contains(out.String(), ".git/info/exclude") {
        t.Fatalf("output = %q, want notice mentioning .git/info/exclude", out.String())
    }
    if _, err := os.Stat(filepath.Join(repoDir, ".gitignore")); !os.IsNotExist(err) {
        t.Fatalf(".gitignore stat = %v, want not exist", err)
    }
}

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

repoDir := t.TempDir()
    testutil.InitRepo(t, repoDir)
    testutil.WriteFile(t, repoDir, ".gitignore", ".entire/worktrees/\n")
    t.Chdir(repoDir)

var out bytes.Buffer
    if err := ensureTrailWorktreeIgnoreRule(context.Background(), &out, repoDir, false); err != nil {
        t.Fatalf("ensureTrailWorktreeIgnoreRule: %v", err)
    }
    if out.Len() != 0 {
        t.Fatalf("output = %q, want silence", out.String())
    }
    if _, err := os.Stat(filepath.Join(repoDir, ".git", "info", "exclude")); err == nil {
        content, readErr := os.ReadFile(filepath.Join(repoDir, ".git", "info", "exclude"))
        if readErr == nil && strings.Contains(string(content), ".entire/worktrees/") {
            t.Fatalf("exclude gained the rule despite .gitignore already covering it")
        }
    }
}