add .worktreeinclude copying for trail worktrees · Entire
add .worktreeinclude copying for trail worktrees
2d904b3→main·
pfleidi·1w ago·2 files·+267 added/-0 removed
Ports entire-worktree's include mechanics: gitignore-style patterns from .worktreeinclude are matched against untracked ignored files and copied into a fresh worktree. Regular files only; per-file failures warn and skip.
Sessions
01KX4NNEH77T2PEP0ZWSA6TFJHView transcript
Changes
2
cmd/entire/cli
Mtrail_checkout_worktree.go+144
Mtrail_checkout_worktree_test.go+123
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
137 unmodified lines
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"huh "charm.land/huh/v2"
"github.com/go-git/go-git/v6/plumbing/format/gitignore"
"github.com/entireio/cli/cmd/entire/cli/interactive"
)
const worktreeIncludeFile = ".worktreeinclude"
// copyWorktreeIncludeFiles copies ignored files matching .worktreeinclude patterns from the main worktree root into a freshly created worktree.
// Per-file failures warn and skip; they never fail the checkout.
func copyWorktreeIncludeFiles(ctx context.Context, errW io.Writer, root, dest string) error {
patterns, err := loadWorktreeIncludePatterns(root)
if err != nil {
return err
}
if len(patterns) == 0 {
return nil
}
ignored, err := listIgnoredFiles(ctx, root)
if err != nil {
return err
}
for _, rel := range matchIncludePatterns(patterns, ignored) {
if err := copyIncludedFile(filepath.Join(root, rel), filepath.Join(dest, rel)); err != nil {
fmt.Fprintf(errW, "warning: skipped %s: %v\n", filepath.ToSlash(rel), err)
}
}
return nil
}
// loadWorktreeIncludePatterns reads .worktreeinclude from root. A missing file means nothing gets copied. Lines are gitignore-style patterns; blank lines and #-comments are skipped.
func loadWorktreeIncludePatterns(root string) ([]string, error) {
data, err := os.ReadFile(filepath.Join(root, worktreeIncludeFile)) //nolint:gosec // path derived from repo root
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", worktreeIncludeFile, err)
}
var patterns []string
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimRight(raw, "\r")
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns, nil
}
// listIgnoredFiles returns untracked files ignored by repo ignore rules, relative to root.
func listIgnoredFiles(ctx context.Context, root string) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--ignored", "--exclude-standard", "-z")
cmd.Dir = root
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list ignored files: %w", err)
}
var files []string
for _, f := range bytes.Split(output, []byte{0}) {
if len(f) > 0 {
files = append(files, string(f))
}
}
return files, nil
}
func matchIncludePatterns(patterns, files []string) []string {
ps := make([]gitignore.Pattern, 0, len(patterns))
for _, pattern := range patterns {
ps = append(ps, gitignore.ParsePattern(pattern, nil))
}
matcher := gitignore.NewMatcher(ps)
included := make([]string, 0, len(files))
for _, file := range files {
rel, ok := cleanRelativeIncludeFile(file)
if !ok || isManagedTrailWorktreePath(rel) || !matcher.Match(strings.Split(filepath.ToSlash(rel), "/"), false) {
continue
}
included = append(included, rel)
}
return included
}
func isManagedTrailWorktreePath(rel string) bool {
slash := filepath.ToSlash(rel)
return slash == trailWorktreesRelDir || strings.HasPrefix(slash, trailWorktreesRelDir+"/")
}
func cleanRelativeIncludeFile(rel string) (string, bool) {
if rel == "" || filepath.IsAbs(rel) {
return "", false
}
clean := filepath.Clean(filepath.FromSlash(rel))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", false
}
return clean, true
}
func copyIncludedFile(src, dst string) error {
srcInfo, err := os.Lstat(src)
if err != nil {
return err //nolint:wrapcheck // lstat error is sufficient for caller context
}
if !srcInfo.Mode().IsRegular() {
return errors.New("source is not a regular file")
}
in, err := os.Open(src) //nolint:gosec // src derived from repo root + .worktreeinclude
if err != nil {
return err //nolint:wrapcheck // open error is sufficient for caller context
}
defer in.Close()
openedInfo, err := in.Stat()
if err != nil {
return err //nolint:wrapcheck // stat error is sufficient for caller context
}
if !openedInfo.Mode().IsRegular() || !os.SameFile(srcInfo, openedInfo) {
return errors.New("source changed while opening")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err //nolint:wrapcheck // mkdir error is sufficient for caller context
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, srcInfo.Mode().Perm()) //nolint:gosec // dst is inside the new worktree
if err != nil {
return err //nolint:wrapcheck // openfile error is sufficient for caller context
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(dst)
return err //nolint:wrapcheck // copy error is sufficient for caller context
}
if err := out.Close(); err != nil {
_ = os.Remove(dst)
return err //nolint:wrapcheck // close error is sufficient for caller context
}
if err := os.Chmod(dst, srcInfo.Mode().Perm()); err != nil {
_ = os.Remove(dst)
return err //nolint:wrapcheck // chmod error is sufficient for caller context
}
return nil
}
Mcmd/entire/cli/trail_checkout_worktree.go+144
10 unmodified lines
11
12
13
14
15
16
17
18
134 unmodified lines
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
10 unmodified lines
"github.com/entireio/cli/cmd/entire/cli/testutil"
)
const testEnvFile = ".env"
func TestDefaultTrailWorktreePath(t *testing.T) {
t.Parallel()
134 unmodified lines
}
}
func TestMatchIncludePatterns(t *testing.T) {
t.Parallel()
files := []string{
testEnvFile,
"config/.env.local",
".entire/worktrees/other/.env",
"/abs/.env",
"../escape/.env",
"node_modules/pkg/x.js",
}
got := matchIncludePatterns([]string{testEnvFile, "*.local"}, files)
want := []string{testEnvFile, filepath.Join("config", ".env.local")}
if len(got) != len(want) {
t.Fatalf("matchIncludePatterns() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("matchIncludePatterns() = %v, want %v", got, want)
}
}
}
func TestLoadWorktreeIncludePatterns(t *testing.T) {
t.Parallel()
root := t.TempDir()
content := "# secrets\n\n.env\n*.local\n"
if err := os.WriteFile(filepath.Join(root, ".worktreeinclude"), []byte(content), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
got, err := loadWorktreeIncludePatterns(root)
if err != nil {
t.Fatalf("loadWorktreeIncludePatterns: %v", err)
}
want := []string{".env", "*.local"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("patterns = %v, want %v", got, want)
}
}
func TestLoadWorktreeIncludePatterns_MissingFile(t *testing.T) {
t.Parallel()
got, err := loadWorktreeIncludePatterns(t.TempDir())
if err != nil {
t.Fatalf("loadWorktreeIncludePatterns: %v", err)
}
if len(got) != 0 {
t.Fatalf("patterns = %v, want none", got)
}
}
func TestCopyWorktreeIncludeFiles(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
testutil.WriteFile(t, repoDir, ".gitignore", testEnvFile+"\n")
testutil.WriteFile(t, repoDir, ".worktreeinclude", testEnvFile+"\n")
testutil.WriteFile(t, repoDir, testEnvFile, "SECRET=1\n")
testutil.WriteFile(t, repoDir, "sub/"+testEnvFile, "SECRET=2\n")
testutil.GitAdd(t, repoDir, ".gitignore", ".worktreeinclude")
testutil.GitCommit(t, repoDir, "init")
dest := t.TempDir()
var errOut bytes.Buffer
if err := copyWorktreeIncludeFiles(context.Background(), &errOut, repoDir, dest); err != nil {
t.Fatalf("copyWorktreeIncludeFiles: %v; stderr: %s", err, errOut.String())
}
for _, rel := range []string{testEnvFile, "sub/" + testEnvFile} {
if _, err := os.Stat(filepath.Join(dest, filepath.FromSlash(rel))); err != nil {
t.Fatalf("copied file %s missing: %v", rel, err)
}
}
}
func TestCopyWorktreeIncludeFiles_NoIncludeFileCopiesNothing(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
testutil.WriteFile(t, repoDir, ".gitignore", testEnvFile+"\n")
testutil.WriteFile(t, repoDir, testEnvFile, "SECRET=1\n")
dest := t.TempDir()
var errOut bytes.Buffer
if err := copyWorktreeIncludeFiles(context.Background(), &errOut, repoDir, dest); err != nil {
t.Fatalf("copyWorktreeIncludeFiles: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, testEnvFile)); !os.IsNotExist(err) {
t.Fatalf("%s stat = %v, want not exist", testEnvFile, err)
}
}
func TestCopyWorktreeIncludeFiles_SkipsSymlinkWithWarning(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
const symlinkPath = "link.env"
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
testutil.WriteFile(t, repoDir, ".gitignore", symlinkPath+"\ntarget.txt\n")
testutil.WriteFile(t, repoDir, ".worktreeinclude", symlinkPath+"\n")
testutil.WriteFile(t, repoDir, "target.txt", "x\n")
if err := os.Symlink(filepath.Join(repoDir, "target.txt"), filepath.Join(repoDir, symlinkPath)); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
dest := t.TempDir()
var errOut bytes.Buffer
if err := copyWorktreeIncludeFiles(context.Background(), &errOut, repoDir, dest); err != nil {
t.Fatalf("copyWorktreeIncludeFiles: %v", err)
}
if !strings.Contains(errOut.String(), "warning: skipped "+symlinkPath) {
t.Fatalf("stderr = %q, want skip warning for %s", errOut.String(), symlinkPath)
}
if _, err := os.Stat(filepath.Join(dest, symlinkPath)); !os.IsNotExist(err) {
t.Fatalf("%s stat = %v, want not exist", symlinkPath, err)
}
}