diff --git a/README.md b/README.md index f95fde6..c8496f9 100644 --- a/README.md +++ b/README.md @@ -486,18 +486,16 @@ strategy = "squash" # squash, rebase, or merge ### Preserve Settings -Automatically copy git-ignored files (`.env`, `.envrc`, etc.) from an existing worktree when checking out a new one with `wt checkout`. Useful for keeping local configuration in sync across worktrees. +Symlink files from the repo root into new worktrees created with `wt checkout`. Useful for keeping local configuration (`.env`, `.envrc`, etc.) in sync across worktrees — edits in any worktree are instantly visible in all others. ```toml [preserve] -patterns = [".env", ".env.*", ".envrc", "docker-compose.override.yml"] -exclude = ["node_modules", ".cache", "vendor"] +paths = [".env", ".envrc"] ``` -- **patterns** — glob patterns matched against file basenames; only git-ignored files are considered -- **exclude** — path segments to skip (any component match excludes the file) +- **paths** — relative paths from the repo root to symlink (e.g., `".env"`, `"config/.env"`) -Files are copied from the worktree on the default branch, e.g. `main` (or the first available worktree). Existing files are never overwritten. Symlinks are skipped. Use `--no-preserve` on `wt checkout` to skip. +Paths that don't exist in the repo root are silently skipped. Existing files in the target worktree are never overwritten. Use `--no-preserve` on `wt checkout` to skip. ### Self-Hosted Instances @@ -563,8 +561,7 @@ delete_local_branches = true # replaces global default = "gitlab" # replaces global [preserve] -patterns = [".env.local"] # appended to global (deduplicated) -exclude = ["dist"] # appended to global (deduplicated) +paths = [".env.local"] # appended to global (deduplicated) # Hooks merge by name — add new hooks or override global ones [hooks.setup] diff --git a/cmd/wt/checkout_cmd.go b/cmd/wt/checkout_cmd.go index 9ca9fe8..d3e9367 100644 --- a/cmd/wt/checkout_cmd.go +++ b/cmd/wt/checkout_cmd.go @@ -2,9 +2,7 @@ package main import ( "context" - "errors" "fmt" - "path/filepath" "slices" "github.com/spf13/cobra" @@ -208,7 +206,7 @@ func checkoutInRepo(ctx context.Context, repo registry.Repo, branch string, opts } } - preserveWorktreeFiles(ctx, gitDir, wtPath, opts.NoPreserve, cfg.Preserve) + preserveWorktreeFiles(ctx, repo.Path, wtPath, opts.NoPreserve, cfg.Preserve) action := hooks.ActionOpen if opts.NewBranch { @@ -340,29 +338,19 @@ func setUpstreamTracking(ctx context.Context, gitDir, branch string, newBranch, } } -// preserveWorktreeFiles copies git-ignored files from an existing worktree to the new one. -func preserveWorktreeFiles(ctx context.Context, gitDir, wtPath string, noPreserve bool, preserveCfg config.PreserveConfig) { - if noPreserve || len(preserveCfg.Patterns) == 0 { +// preserveWorktreeFiles symlinks preserved files from the repo root into the new worktree. +func preserveWorktreeFiles(ctx context.Context, repoPath, wtPath string, noPreserve bool, preserveCfg config.PreserveConfig) { + if noPreserve || len(preserveCfg.Paths) == 0 { return } l := log.FromContext(ctx) - sourceWT, err := preserve.FindSourceWorktree(ctx, gitDir, wtPath) - if err != nil { - if errors.Is(err, preserve.ErrNoSourceWorktree) { - l.Debug("preserve: no source worktree found") - } else { - l.Printf("Warning: preserve: %v\n", err) - } - return - } - - copied, err := preserve.PreserveFiles(ctx, preserveCfg, sourceWT, wtPath) + linked, err := preserve.PreserveFiles(ctx, preserveCfg, repoPath, wtPath) if err != nil { l.Printf("Warning: preserve files failed: %v\n", err) - } else if len(copied) > 0 { - l.Printf("Preserved %d file(s) from %s\n", len(copied), filepath.Base(sourceWT)) - for _, f := range copied { + } else if len(linked) > 0 { + l.Printf("Preserved %d file(s)\n", len(linked)) + for _, f := range linked { l.Debug(" preserved", "file", f) } } diff --git a/cmd/wt/checkout_integration_test.go b/cmd/wt/checkout_integration_test.go index d68a8f7..e7ca67c 100644 --- a/cmd/wt/checkout_integration_test.go +++ b/cmd/wt/checkout_integration_test.go @@ -2114,11 +2114,11 @@ func TestCheckout_ExplicitOriginRemoteRef(t *testing.T) { } } -// TestCheckout_PreserveFiles tests that git-ignored files matching preserve patterns -// are copied from the existing worktree into the new one. +// TestCheckout_PreserveFiles tests that listed paths are symlinked from the +// repo root into the new worktree. // -// Scenario: Repo has .env, .envrc, and .env.production (all git-ignored), user runs `wt checkout -b feature` -// Expected: All three are copied to new worktree (all match configured patterns) +// Scenario: Repo has .env and .envrc at root, user runs `wt checkout -b feature` +// Expected: Both are symlinked into new worktree, edits propagate func TestCheckout_PreserveFiles(t *testing.T) { t.Parallel() @@ -2127,33 +2127,13 @@ func TestCheckout_PreserveFiles(t *testing.T) { repoPath := setupTestRepo(t, tmpDir, "test-repo") - // Add .gitignore - if err := os.WriteFile(filepath.Join(repoPath, ".gitignore"), []byte(".env\n.env.*\n.envrc\n"), 0644); err != nil { - t.Fatalf("failed to write .gitignore: %v", err) - } - cmds := [][]string{ - {"git", "add", ".gitignore"}, - {"git", "commit", "-m", "Add gitignore"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = repoPath - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v\n%s", args, err, out) - } - } - - // Create git-ignored files in main worktree + // Create files at repo root if err := os.WriteFile(filepath.Join(repoPath, ".env"), []byte("SECRET=abc\n"), 0644); err != nil { t.Fatalf("failed to write .env: %v", err) } if err := os.WriteFile(filepath.Join(repoPath, ".envrc"), []byte("dotenv\n"), 0644); err != nil { t.Fatalf("failed to write .envrc: %v", err) } - // Also create .env.production (matches .env.* pattern) - if err := os.WriteFile(filepath.Join(repoPath, ".env.production"), []byte("PROD=true\n"), 0644); err != nil { - t.Fatalf("failed to write .env.production: %v", err) - } regFile := filepath.Join(tmpDir, ".wt", "repos.json") os.MkdirAll(filepath.Dir(regFile), 0755) @@ -2174,7 +2154,7 @@ func TestCheckout_PreserveFiles(t *testing.T) { BaseRef: "local", }, Preserve: config.PreserveConfig{ - Patterns: []string{".env", ".env.*", ".envrc"}, + Paths: []string{".env", ".envrc"}, }, } @@ -2189,25 +2169,50 @@ func TestCheckout_PreserveFiles(t *testing.T) { wtPath := filepath.Join(tmpDir, "test-repo-feature") - // Verify all matching files were copied - for _, file := range []string{".env", ".envrc", ".env.production"} { - data, err := os.ReadFile(filepath.Join(wtPath, file)) + // Verify symlinks were created and content is accessible + for _, file := range []string{".env", ".envrc"} { + dst := filepath.Join(wtPath, file) + + // Should be a symlink + info, err := os.Lstat(dst) if err != nil { t.Errorf("preserved file %s should exist in new worktree: %v", file, err) continue } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("%s should be a symlink", file) + } + + // Content should be accessible through symlink + data, err := os.ReadFile(dst) + if err != nil { + t.Errorf("failed to read %s through symlink: %v", file, err) + continue + } srcData, _ := os.ReadFile(filepath.Join(repoPath, file)) if string(data) != string(srcData) { t.Errorf("preserved file %s content mismatch: got %q, want %q", file, data, srcData) } } + + // Verify edits propagate + if err := os.WriteFile(filepath.Join(wtPath, ".env"), []byte("MODIFIED=true\n"), 0644); err != nil { + t.Fatalf("failed to write through symlink: %v", err) + } + srcData, err := os.ReadFile(filepath.Join(repoPath, ".env")) + if err != nil { + t.Fatalf("failed to read source: %v", err) + } + if string(srcData) != "MODIFIED=true\n" { + t.Errorf("edit did not propagate to source: got %q", srcData) + } } -// TestCheckout_PreserveNoOverwrite tests that preserved files never overwrite +// TestCheckout_PreserveNoOverwrite tests that preserve never overwrites // existing files in the target worktree. // // Scenario: Target worktree already has a .env file, source has a different one -// Expected: Existing .env in target is not overwritten +// Expected: Existing .env in target is not overwritten, warning logged func TestCheckout_PreserveNoOverwrite(t *testing.T) { t.Parallel() @@ -2216,23 +2221,7 @@ func TestCheckout_PreserveNoOverwrite(t *testing.T) { repoPath := setupTestRepo(t, tmpDir, "test-repo") - // Add .gitignore - if err := os.WriteFile(filepath.Join(repoPath, ".gitignore"), []byte(".env\n"), 0644); err != nil { - t.Fatalf("failed to write .gitignore: %v", err) - } - cmds := [][]string{ - {"git", "add", ".gitignore"}, - {"git", "commit", "-m", "Add gitignore"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = repoPath - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v\n%s", args, err, out) - } - } - - // Create .env in main worktree + // Create .env at repo root if err := os.WriteFile(filepath.Join(repoPath, ".env"), []byte("SOURCE_SECRET=old\n"), 0644); err != nil { t.Fatalf("failed to write .env: %v", err) } @@ -2241,28 +2230,27 @@ func TestCheckout_PreserveNoOverwrite(t *testing.T) { wtPath := filepath.Join(tmpDir, "test-repo-feature") preserveCfg := config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{".env"}, } - // Directly test the preserve function since we need to create the .env before checkout ctx := testContext(t) - // First create the worktree directory with an existing .env + // Create the worktree directory with an existing .env os.MkdirAll(wtPath, 0755) if err := os.WriteFile(filepath.Join(wtPath, ".env"), []byte("EXISTING=keep\n"), 0644); err != nil { t.Fatalf("failed to write existing .env: %v", err) } // Run preserve - copied, err := preserve.PreserveFiles(ctx, preserveCfg, repoPath, wtPath) + linked, err := preserve.PreserveFiles(ctx, preserveCfg, repoPath, wtPath) if err != nil { t.Fatalf("PreserveFiles failed: %v", err) } - // .env should NOT be in copied list (it was skipped) - for _, f := range copied { + // .env should NOT be in linked list (it was skipped) + for _, f := range linked { if f == ".env" { - t.Error(".env should not have been copied (file already exists)") + t.Error(".env should not have been linked (file already exists)") } } @@ -2279,7 +2267,7 @@ func TestCheckout_PreserveNoOverwrite(t *testing.T) { // TestCheckout_NoPreserveFlag tests that --no-preserve skips file preservation. // // Scenario: User runs `wt checkout -b feature --no-preserve` with preserve config -// Expected: No files are copied despite matching patterns +// Expected: No files are symlinked despite configured paths func TestCheckout_NoPreserveFlag(t *testing.T) { t.Parallel() @@ -2288,22 +2276,7 @@ func TestCheckout_NoPreserveFlag(t *testing.T) { repoPath := setupTestRepo(t, tmpDir, "test-repo") - // Add .gitignore and create ignored file - if err := os.WriteFile(filepath.Join(repoPath, ".gitignore"), []byte(".env\n"), 0644); err != nil { - t.Fatalf("failed to write .gitignore: %v", err) - } - cmds := [][]string{ - {"git", "add", ".gitignore"}, - {"git", "commit", "-m", "Add gitignore"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = repoPath - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v\n%s", args, err, out) - } - } - + // Create file at repo root if err := os.WriteFile(filepath.Join(repoPath, ".env"), []byte("SECRET=abc\n"), 0644); err != nil { t.Fatalf("failed to write .env: %v", err) } @@ -2327,7 +2300,7 @@ func TestCheckout_NoPreserveFlag(t *testing.T) { BaseRef: "local", }, Preserve: config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{".env"}, }, } @@ -2342,101 +2315,12 @@ func TestCheckout_NoPreserveFlag(t *testing.T) { wtPath := filepath.Join(tmpDir, "test-repo-feature") - // Verify .env was NOT copied - if _, err := os.Stat(filepath.Join(wtPath, ".env")); !os.IsNotExist(err) { + // Verify .env was NOT symlinked + if _, err := os.Lstat(filepath.Join(wtPath, ".env")); !os.IsNotExist(err) { t.Error(".env should NOT exist in new worktree when --no-preserve is used") } } -// TestCheckout_PreserveExclude tests that exclude patterns filter out matching path segments. -// -// Scenario: .env inside node_modules is ignored when "node_modules" is in exclude -// Expected: Only non-excluded .env files are copied -func TestCheckout_PreserveExclude(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - tmpDir = resolvePath(t, tmpDir) - - repoPath := setupTestRepo(t, tmpDir, "test-repo") - - // Add .gitignore that ignores .env and node_modules - if err := os.WriteFile(filepath.Join(repoPath, ".gitignore"), []byte(".env\nnode_modules/\n"), 0644); err != nil { - t.Fatalf("failed to write .gitignore: %v", err) - } - cmds := [][]string{ - {"git", "add", ".gitignore"}, - {"git", "commit", "-m", "Add gitignore"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = repoPath - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to run %v: %v\n%s", args, err, out) - } - } - - // Create root .env (should be preserved) - if err := os.WriteFile(filepath.Join(repoPath, ".env"), []byte("ROOT=true\n"), 0644); err != nil { - t.Fatalf("failed to write .env: %v", err) - } - - // Create node_modules/.env (should be excluded) - os.MkdirAll(filepath.Join(repoPath, "node_modules"), 0755) - if err := os.WriteFile(filepath.Join(repoPath, "node_modules", ".env"), []byte("MODULES=true\n"), 0644); err != nil { - t.Fatalf("failed to write node_modules/.env: %v", err) - } - - regFile := filepath.Join(tmpDir, ".wt", "repos.json") - os.MkdirAll(filepath.Dir(regFile), 0755) - - reg := ®istry.Registry{ - Repos: []registry.Repo{ - {Name: "test-repo", Path: repoPath, WorktreeFormat: "../{repo}-{branch}"}, - }, - } - if err := reg.Save(regFile); err != nil { - t.Fatalf("failed to save registry: %v", err) - } - - cfg := &config.Config{ - RegistryPath: regFile, - Checkout: config.CheckoutConfig{ - WorktreeFormat: "../{repo}-{branch}", - BaseRef: "local", - }, - Preserve: config.PreserveConfig{ - Patterns: []string{".env"}, - Exclude: []string{"node_modules"}, - }, - } - - ctx := testContextWithConfig(t, cfg, repoPath) - cmd := newCheckoutCmd() - cmd.SetContext(ctx) - cmd.SetArgs([]string{"-b", "feature"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("checkout command failed: %v", err) - } - - wtPath := filepath.Join(tmpDir, "test-repo-feature") - - // Root .env should be preserved - data, err := os.ReadFile(filepath.Join(wtPath, ".env")) - if err != nil { - t.Fatalf("root .env should exist: %v", err) - } - if string(data) != "ROOT=true\n" { - t.Errorf("root .env content = %q, want %q", data, "ROOT=true\n") - } - - // node_modules/.env should NOT be preserved - if _, err := os.Stat(filepath.Join(wtPath, "node_modules", ".env")); !os.IsNotExist(err) { - t.Error("node_modules/.env should NOT be preserved (excluded)") - } -} - // TestCheckout_AutoStash_NoChanges tests that --autostash with clean working tree succeeds. // // Scenario: User runs `wt checkout feature --autostash` with no uncommitted changes diff --git a/cmd/wt/config_cmd.go b/cmd/wt/config_cmd.go index e870d68..5ef45f2 100644 --- a/cmd/wt/config_cmd.go +++ b/cmd/wt/config_cmd.go @@ -381,17 +381,12 @@ func renderConfigText(w io.Writer, cfg *config.Config, local *config.LocalConfig }) // [preserve] - patternAnn := "(global)" - if local != nil && len(local.Preserve.Patterns) > 0 { - patternAnn = "(local)" - } - excludeAnn := "(global)" - if local != nil && len(local.Preserve.Exclude) > 0 { - excludeAnn = "(local)" + pathsAnn := "(global)" + if local != nil && len(local.Preserve.Paths) > 0 { + pathsAnn = "(local)" } printSection("[preserve]", []kv{ - {"patterns", "[" + strings.Join(cfg.Preserve.Patterns, ", ") + "]", patternAnn}, - {"exclude", "[" + strings.Join(cfg.Preserve.Exclude, ", ") + "]", excludeAnn}, + {"paths", "[" + strings.Join(cfg.Preserve.Paths, ", ") + "]", pathsAnn}, }) // [hooks] diff --git a/docs/FUTURE_FEATURES.md b/docs/FUTURE_FEATURES.md index 226f279..655852b 100644 --- a/docs/FUTURE_FEATURES.md +++ b/docs/FUTURE_FEATURES.md @@ -4,31 +4,9 @@ This document outlines potential features to improve `wt` based on common patter ## High Priority -### File Preservation on Worktree Creation +### ~~File Preservation on Worktree Creation~~ (Shipped) -**Problem:** When creating a new worktree, users must manually copy configuration files like `.env`, `.envrc`, or `docker-compose.override.yml`. These files are typically gitignored and contain local development settings. - -**Solution:** Automatically copy matching files from an existing worktree (or main repo) when creating a new worktree. - -**Config:** -```toml -[preserve] -patterns = [".env*", ".envrc", "docker-compose.override.yml"] -exclude = ["node_modules", "vendor", ".venv", "dist", "build", "__pycache__"] -``` - -**Behavior:** -- On `wt checkout`, find gitignored files matching `patterns` -- Skip files in directories matching `exclude` -- Copy to new worktree (never overwrite existing) -- Use atomic file operations to avoid race conditions - -**CLI:** -```bash -wt checkout -b feat/auth # Auto-preserve from current worktree -wt checkout -b feat/auth --from main # Preserve from specific worktree -wt checkout -b feat/auth --no-preserve # Skip file preservation -``` +Implemented via `[preserve]` config with symlink-based approach. Listed paths are symlinked from the repo root into new worktrees on `wt checkout`. Use `--no-preserve` to skip. --- @@ -171,10 +149,9 @@ None of these features require breaking changes. All can be implemented as addit ### Config Schema Additions ```toml -# File preservation -[preserve] -patterns = [".env*", ".envrc"] -exclude = ["node_modules", "vendor"] +# File preservation (shipped) +# [preserve] +# paths = [".env", ".envrc"] # Pruning stale_threshold = "30d" diff --git a/docs/TESTS.md b/docs/TESTS.md index e9e5504..b1223a1 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -290,10 +290,9 @@ Generated: 2026-03-01 | `TestCheckout_ExplicitUpstreamRemoteRef` | Tests --base with upstream/branch syntax. | | `TestCheckout_LocalBaseRefWithFetchWarning` | Tests that --fetch with local base_ref prints warning. | | `TestCheckout_ExplicitOriginRemoteRef` | Tests --base with origin/branch syntax. | -| `TestCheckout_PreserveFiles` | Tests that git-ignored files matching preserve patterns | -| `TestCheckout_PreserveNoOverwrite` | Tests that preserved files never overwrite | +| `TestCheckout_PreserveFiles` | Tests that listed paths are symlinked from repo root into new worktree. | +| `TestCheckout_PreserveNoOverwrite` | Tests that preserve never overwrites existing files in target. | | `TestCheckout_NoPreserveFlag` | Tests that --no-preserve skips file preservation. | -| `TestCheckout_PreserveExclude` | Tests that exclude patterns filter out matching path segments. | | `TestCheckout_AutoStash_NoChanges` | Tests that --autostash with clean working tree succeeds. | | `TestCheckout_AutoStash_UntrackedFiles` | Tests that untracked files are stashed and popped. | | `TestCheckout_AutoStash_StagedAndModified` | Tests autostash with a mix of staged and modified files. | diff --git a/internal/config/config.go b/internal/config/config.go index ec87db5..2881cfc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,10 +94,9 @@ type PruneConfig struct { } // PreserveConfig holds file preservation settings for worktree creation. -// Matching git-ignored files are copied from an existing worktree into new ones. +// Listed paths are symlinked from the repo root into new worktrees. type PreserveConfig struct { - Patterns []string `toml:"patterns"` // Glob patterns matched against file basenames - Exclude []string `toml:"exclude"` // Path segments to exclude (e.g., "node_modules") + Paths []string `toml:"paths"` // Relative paths from repo root to symlink (e.g., ".env", "config/.env") } // CloneConfig holds clone-related configuration @@ -320,7 +319,7 @@ func Load() (Config, error) { if err := validateEnum(cfg.DefaultSort, "default_sort", ValidDefaultSortModes); err != nil { return Default(), err } - if err := validatePreservePatterns(cfg.Preserve.Patterns, ""); err != nil { + if err := validatePreservePaths(cfg.Preserve.Paths, ""); err != nil { return Default(), err } if err := ValidateHookTriggers(cfg.Hooks.Hooks); err != nil { @@ -599,14 +598,13 @@ worktree_format = ".worktrees/{branch}" # description = "Log removed branches" # on = ["prune"] -# Preserve settings - auto-copy git-ignored files into new worktrees -# Copies matching files from an existing worktree (preferring the default branch) into newly created ones. -# Only git-ignored files are considered. Existing files are never overwritten. +# Preserve settings - symlink files from repo root into new worktrees +# Listed paths (relative to repo root) are symlinked into newly created worktrees. +# Edits in any worktree are instantly visible in all others. # Use --no-preserve on checkout to skip for a single invocation. # # [preserve] -# patterns = [".env", ".env.*", ".envrc", "docker-compose.override.yml"] -# exclude = ["node_modules", ".cache", "vendor"] +# paths = [".env", ".envrc"] # Forge settings - configure forge type, default org, and multi-account auth # Used for PR operations and "wt pr checkout org/repo" when cloning diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ec3efaf..d4f3fe8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -616,28 +616,32 @@ func TestValidateEnum(t *testing.T) { } } -func TestValidatePreservePatterns(t *testing.T) { +func TestValidatePreservePaths(t *testing.T) { t.Parallel() tests := []struct { - name string - patterns []string - context string - wantErr bool + name string + paths []string + context string + wantErr bool }{ - {"valid patterns", []string{".env", ".env.*", "*.local"}, "", false}, - {"empty patterns", []string{}, "", false}, - {"nil patterns", nil, "", false}, - {"invalid pattern", []string{"[invalid"}, "", true}, - {"with context info", []string{"[bad"}, ".wt.toml", true}, + {"valid relative paths", []string{".env", "config/.env"}, "", false}, + {"empty paths", []string{}, "", false}, + {"nil paths", nil, "", false}, + {"absolute path rejected", []string{"/etc/passwd"}, "", true}, + {"parent escape rejected", []string{"../../../etc/passwd"}, "", true}, + {"mid-path parent escape rejected", []string{"foo/../../etc/passwd"}, "", true}, + {"empty string rejected", []string{""}, "", true}, + {"dot rejected", []string{"."}, "", true}, + {"with context info", []string{"/abs"}, ".wt.toml", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validatePreservePatterns(tt.patterns, tt.context) + err := validatePreservePaths(tt.paths, tt.context) if (err != nil) != tt.wantErr { - t.Errorf("validatePreservePatterns(%v, %q) error = %v, wantErr %v", tt.patterns, tt.context, err, tt.wantErr) + t.Errorf("validatePreservePaths(%v, %q) error = %v, wantErr %v", tt.paths, tt.context, err, tt.wantErr) } }) } @@ -952,8 +956,7 @@ delete_local_branches = true stale_days = 30 [preserve] -patterns = [".env", ".env.*"] -exclude = ["node_modules"] +paths = [".env", ".envrc"] [hosts] "gitlab.corp.com" = "gitlab" @@ -1016,8 +1019,8 @@ on = ["checkout"] if err := validateEnum(cfg.Clone.Mode, "clone.mode", ValidCloneModes); err != nil { t.Fatalf("clone.mode validation failed: %v", err) } - if err := validatePreservePatterns(cfg.Preserve.Patterns, ""); err != nil { - t.Fatalf("preserve.patterns validation failed: %v", err) + if err := validatePreservePaths(cfg.Preserve.Paths, ""); err != nil { + t.Fatalf("preserve.paths validation failed: %v", err) } // Apply defaults @@ -1075,8 +1078,8 @@ on = ["checkout"] if cfg.Prune.StaleDays != 30 { t.Errorf("Prune.StaleDays = %d, want 30", cfg.Prune.StaleDays) } - if len(cfg.Preserve.Patterns) != 2 { - t.Errorf("len(Preserve.Patterns) = %d, want 2", len(cfg.Preserve.Patterns)) + if len(cfg.Preserve.Paths) != 2 { + t.Errorf("len(Preserve.Paths) = %d, want 2", len(cfg.Preserve.Paths)) } if len(cfg.Hosts) != 2 { t.Errorf("len(Hosts) = %d, want 2", len(cfg.Hosts)) diff --git a/internal/config/local.go b/internal/config/local.go index 8129021..2b7b65f 100644 --- a/internal/config/local.go +++ b/internal/config/local.go @@ -101,7 +101,7 @@ func LoadLocal(repoPath string) (*LocalConfig, error) { if err := validateEnum(local.Checkout.BaseRef, "checkout.base_ref", ValidBaseRefs); err != nil { return nil, fmt.Errorf("%w in %s", err, configFile) } - if err := validatePreservePatterns(local.Preserve.Patterns, configFile); err != nil { + if err := validatePreservePaths(local.Preserve.Paths, configFile); err != nil { return nil, err } if err := ValidateHookTriggers(local.Hooks.Hooks); err != nil { @@ -136,10 +136,9 @@ const defaultLocalConfig = `# wt local config (per-repo overrides) # [prune] # delete_local_branches = false -# Preserve settings (patterns here are added to global patterns) +# Preserve settings (paths here are added to global paths) # [preserve] -# patterns = [".env.local"] -# exclude = ["dist"] +# paths = [".env.local"] # Forge settings # [forge] diff --git a/internal/config/local_test.go b/internal/config/local_test.go index 06fb1ac..66cc2f5 100644 --- a/internal/config/local_test.go +++ b/internal/config/local_test.go @@ -57,8 +57,7 @@ strategy = "rebase" delete_local_branches = true [preserve] -patterns = [".env.local"] -exclude = ["dist"] +paths = [".env.local"] [forge] default = "gitlab" @@ -105,11 +104,8 @@ enabled = false if local.Forge.Default != "gitlab" { t.Errorf("forge.default = %q, want gitlab", local.Forge.Default) } - if len(local.Preserve.Patterns) != 1 || local.Preserve.Patterns[0] != ".env.local" { - t.Errorf("preserve.patterns = %v, want [.env.local]", local.Preserve.Patterns) - } - if len(local.Preserve.Exclude) != 1 || local.Preserve.Exclude[0] != "dist" { - t.Errorf("preserve.exclude = %v, want [dist]", local.Preserve.Exclude) + if len(local.Preserve.Paths) != 1 || local.Preserve.Paths[0] != ".env.local" { + t.Errorf("preserve.paths = %v, want [.env.local]", local.Preserve.Paths) } // Check hooks @@ -177,12 +173,12 @@ base_ref = "invalid" } } -func TestLoadLocal_InvalidPreservePattern(t *testing.T) { +func TestLoadLocal_InvalidPreservePath(t *testing.T) { t.Parallel() dir := t.TempDir() content := `[preserve] -patterns = ["[invalid"] +paths = ["/absolute/path"] ` if err := os.WriteFile(filepath.Join(dir, LocalConfigFileName), []byte(content), 0644); err != nil { t.Fatalf("write file: %v", err) @@ -190,7 +186,7 @@ patterns = ["[invalid"] _, err := LoadLocal(dir) if err == nil { - t.Fatal("expected error for invalid preserve pattern") + t.Fatal("expected error for absolute preserve path") } } diff --git a/internal/config/merge.go b/internal/config/merge.go index 6e042f7..0379024 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -54,11 +54,8 @@ func MergeLocal(global *Config, local *LocalConfig) *Config { } // Merge preserve (append with dedup) - if len(local.Preserve.Patterns) > 0 { - merged.Preserve.Patterns = appendUnique(global.Preserve.Patterns, local.Preserve.Patterns) - } - if len(local.Preserve.Exclude) > 0 { - merged.Preserve.Exclude = appendUnique(global.Preserve.Exclude, local.Preserve.Exclude) + if len(local.Preserve.Paths) > 0 { + merged.Preserve.Paths = appendUnique(global.Preserve.Paths, local.Preserve.Paths) } return &merged diff --git a/internal/config/merge_test.go b/internal/config/merge_test.go index fd6c2d8..a54a693 100644 --- a/internal/config/merge_test.go +++ b/internal/config/merge_test.go @@ -241,8 +241,7 @@ func TestMergeLocal_PreserveAppendDedup(t *testing.T) { global := &Config{ Preserve: PreserveConfig{ - Patterns: []string{".env", ".env.*"}, - Exclude: []string{"node_modules", ".cache"}, + Paths: []string{".env", ".envrc"}, }, Checkout: CheckoutConfig{WorktreeFormat: DefaultWorktreeFormat}, Forge: ForgeConfig{Default: "github"}, @@ -251,38 +250,26 @@ func TestMergeLocal_PreserveAppendDedup(t *testing.T) { local := &LocalConfig{ Preserve: PreserveConfig{ - Patterns: []string{".env.*", ".envrc"}, // .env.* is a dup - Exclude: []string{"vendor", ".cache"}, // .cache is a dup + Paths: []string{".envrc", ".env.local"}, // .envrc is a dup }, } result := MergeLocal(global, local) - // Patterns: .env, .env.*, .envrc (deduped) - expectedPatterns := []string{".env", ".env.*", ".envrc"} - if len(result.Preserve.Patterns) != len(expectedPatterns) { - t.Fatalf("patterns = %v, want %v", result.Preserve.Patterns, expectedPatterns) + // Paths: .env, .envrc, .env.local (deduped) + expectedPaths := []string{".env", ".envrc", ".env.local"} + if len(result.Preserve.Paths) != len(expectedPaths) { + t.Fatalf("paths = %v, want %v", result.Preserve.Paths, expectedPaths) } - for i, p := range expectedPatterns { - if result.Preserve.Patterns[i] != p { - t.Errorf("patterns[%d] = %q, want %q", i, result.Preserve.Patterns[i], p) - } - } - - // Exclude: node_modules, .cache, vendor (deduped) - expectedExclude := []string{"node_modules", ".cache", "vendor"} - if len(result.Preserve.Exclude) != len(expectedExclude) { - t.Fatalf("exclude = %v, want %v", result.Preserve.Exclude, expectedExclude) - } - for i, e := range expectedExclude { - if result.Preserve.Exclude[i] != e { - t.Errorf("exclude[%d] = %q, want %q", i, result.Preserve.Exclude[i], e) + for i, p := range expectedPaths { + if result.Preserve.Paths[i] != p { + t.Errorf("paths[%d] = %q, want %q", i, result.Preserve.Paths[i], p) } } // Verify global wasn't mutated - if len(global.Preserve.Patterns) != 2 { - t.Error("global patterns should be unchanged") + if len(global.Preserve.Paths) != 2 { + t.Error("global paths should be unchanged") } } diff --git a/internal/config/validate.go b/internal/config/validate.go index cf73295..e25eb84 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -48,14 +48,28 @@ func ValidateHookTriggers(hooksMap map[string]Hook) error { return nil } -// validatePreservePatterns checks that all patterns are valid filepath.Match syntax. -func validatePreservePatterns(patterns []string, contextInfo string) error { - for i, pat := range patterns { - if _, err := filepath.Match(pat, ""); err != nil { +// validatePreservePaths checks that all paths are relative and don't escape the repo root. +func validatePreservePaths(paths []string, contextInfo string) error { + for i, p := range paths { + cleaned := filepath.Clean(p) + + var reason string + switch { + case p == "": + reason = "must not be empty" + case cleaned == ".": + reason = "must be a file path, not directory root" + case filepath.IsAbs(p): + reason = "must be a relative path" + case strings.HasPrefix(cleaned, ".."): + reason = "must not escape repo root" + } + + if reason != "" { if contextInfo != "" { - return fmt.Errorf("invalid preserve.patterns[%d] %q in %s: %w", i, pat, contextInfo, err) + return fmt.Errorf("invalid preserve.paths[%d] %q in %s: %s", i, p, contextInfo, reason) } - return fmt.Errorf("invalid preserve.patterns[%d] %q: %w", i, pat, err) + return fmt.Errorf("invalid preserve.paths[%d] %q: %s", i, p, reason) } } return nil diff --git a/internal/preserve/preserve.go b/internal/preserve/preserve.go index 71d5bc4..c57241d 100644 --- a/internal/preserve/preserve.go +++ b/internal/preserve/preserve.go @@ -4,106 +4,34 @@ import ( "context" "errors" "fmt" - "io" "os" "path/filepath" - "slices" - "strings" - "github.com/raphi011/wt/internal/cmd" "github.com/raphi011/wt/internal/config" - "github.com/raphi011/wt/internal/git" "github.com/raphi011/wt/internal/log" ) -// ErrNoSourceWorktree is returned when no suitable source worktree is found -// for file preservation. This is a benign condition (e.g., first worktree). -var ErrNoSourceWorktree = errors.New("no source worktree found") - -// FindSourceWorktree finds an existing worktree to copy preserved files from. -// It prefers the worktree on the default branch, falling back to the first -// worktree that isn't the target. -func FindSourceWorktree(ctx context.Context, gitDir, targetPath string) (string, error) { - worktrees, err := git.ListWorktreesFromRepo(ctx, gitDir) - if err != nil { - return "", fmt.Errorf("list worktrees: %w", err) - } - - defaultBranch := git.GetDefaultBranch(ctx, gitDir) - - // Prefer the worktree on the default branch - for _, wt := range worktrees { - if wt.Branch == defaultBranch && wt.Path != targetPath { - return wt.Path, nil - } - } - - // Fall back to first worktree that isn't the target - for _, wt := range worktrees { - if wt.Path != targetPath { - return wt.Path, nil - } - } - - return "", ErrNoSourceWorktree -} - -// FindIgnoredFiles returns paths (relative to worktreeDir) of all git-ignored -// files present in the worktree. -func FindIgnoredFiles(ctx context.Context, worktreeDir string) ([]string, error) { - output, err := cmd.OutputContext(ctx, worktreeDir, "git", - "ls-files", "--others", "--ignored", "--exclude-standard") - if err != nil { - return nil, fmt.Errorf("git ls-files: %w", err) - } - - raw := strings.TrimSpace(string(output)) - if raw == "" { - return nil, nil - } - - return strings.Split(raw, "\n"), nil -} - -// matchesPattern returns true if the file at relPath should be preserved -// based on the given patterns and exclusions. -// Patterns are matched against the file's basename. -// If any path segment matches an exclude entry, the file is skipped. -func matchesPattern(relPath string, patterns, exclude []string) bool { - // Check exclusions first — if any path segment matches, skip - for seg := range strings.SplitSeq(filepath.ToSlash(relPath), "/") { - if slices.Contains(exclude, seg) { - return false - } - } - - base := filepath.Base(relPath) - for _, pat := range patterns { - matched, err := filepath.Match(pat, base) - if err != nil { - continue // invalid patterns are caught at config load time +// ErrDestExists is returned when the destination file already exists. +var ErrDestExists = errors.New("destination already exists") + +// LinkFile creates a relative symlink from src to dst, creating parent +// directories as needed. Returns true if the link was created. +// Returns (false, nil) if src doesn't exist (caller should skip silently). +// Returns (false, ErrDestExists) if dst already exists. +func LinkFile(src, dst string) (bool, error) { + // Check source exists + if _, err := os.Lstat(src); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil } - if matched { - return true - } - } - - return false -} - -// CopyFile copies src to dst, creating parent directories as needed. -// Uses O_CREATE|O_EXCL to skip files that already exist (never overwrite). -// Preserves the source file's permission bits. Symlinks are skipped. -// Returns true if the file was copied, false if it was skipped (already exists or symlink). -func CopyFile(src, dst string) (bool, error) { - srcInfo, err := os.Lstat(src) - if err != nil { return false, err } - // Skip symlinks — only copy regular files - if srcInfo.Mode()&os.ModeSymlink != 0 { - return false, nil + // Check destination doesn't exist + if _, err := os.Lstat(dst); err == nil { + return false, ErrDestExists + } else if !errors.Is(err, os.ErrNotExist) { + return false, err } // Create parent directories @@ -111,69 +39,54 @@ func CopyFile(src, dst string) (bool, error) { return false, err } - // O_EXCL: fail if file exists (never overwrite) - dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, srcInfo.Mode()) - if err != nil { - if errors.Is(err, os.ErrExist) { - return false, nil // skip existing files - } - return false, err - } - - srcFile, err := os.Open(src) + // Compute relative path from dst's directory to src + relPath, err := filepath.Rel(filepath.Dir(dst), src) if err != nil { - dstFile.Close() - os.Remove(dst) // clean up empty dst return false, err } - defer srcFile.Close() - if _, err := io.Copy(dstFile, srcFile); err != nil { - dstFile.Close() - os.Remove(dst) // clean up partial dst - return false, err - } - - if err := dstFile.Close(); err != nil { - os.Remove(dst) // clean up on flush failure + if err := os.Symlink(relPath, dst); err != nil { return false, err } return true, nil } -// PreserveFiles copies git-ignored files matching the configured patterns -// from sourceDir into targetDir. Returns the list of relative paths that -// were copied. Individual file copy failures are logged and skipped; the -// returned error only indicates failure to enumerate ignored files. +// PreserveFiles symlinks files listed in cfg.Paths from sourceDir into +// targetDir. Returns the list of relative paths that were linked. func PreserveFiles(ctx context.Context, cfg config.PreserveConfig, sourceDir, targetDir string) ([]string, error) { l := log.FromContext(ctx) - ignored, err := FindIgnoredFiles(ctx, sourceDir) - if err != nil { - return nil, err - } + var linked []string + var lastErr error + failCount := 0 - var copied []string + for _, path := range cfg.Paths { + src := filepath.Join(sourceDir, path) + dst := filepath.Join(targetDir, path) - for _, relPath := range ignored { - if !matchesPattern(relPath, cfg.Patterns, cfg.Exclude) { - continue - } - - src := filepath.Join(sourceDir, relPath) - dst := filepath.Join(targetDir, relPath) - - ok, err := CopyFile(src, dst) + ok, err := LinkFile(src, dst) if err != nil { - l.Printf("Warning: preserve: failed to copy %s: %v\n", relPath, err) + if errors.Is(err, ErrDestExists) { + l.Printf("Warning: preserve: skipped %s (already exists in worktree)\n", path) + continue + } + l.Printf("Warning: preserve: failed to link %s: %v\n", path, err) + lastErr = err + failCount++ continue } if ok { - copied = append(copied, relPath) + linked = append(linked, path) + } else { + l.Debug("preserve: source not found, skipping", "path", path) } } - return copied, nil + if failCount > 0 && len(linked) == 0 { + return nil, fmt.Errorf("all %d preserve path(s) failed (last error: %w)", failCount, lastErr) + } + + return linked, nil } diff --git a/internal/preserve/preserve_integration_test.go b/internal/preserve/preserve_integration_test.go index 2631c7b..57dd949 100644 --- a/internal/preserve/preserve_integration_test.go +++ b/internal/preserve/preserve_integration_test.go @@ -4,12 +4,9 @@ package preserve import ( "context" - "errors" "io" "os" - "os/exec" "path/filepath" - "slices" "testing" "github.com/raphi011/wt/internal/config" @@ -27,48 +24,6 @@ func resolvePath(t *testing.T, path string) string { return resolved } -// runGit runs a git command in the given directory. -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_CONFIG_GLOBAL=/dev/null", - "GIT_CONFIG_SYSTEM=/dev/null", - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } -} - -// setupTestRepo creates a git repository with an initial commit and .gitignore. -// Returns the repo path. -func setupTestRepo(t *testing.T, dir string) string { - t.Helper() - - repoDir := filepath.Join(dir, "repo") - if err := os.MkdirAll(repoDir, 0o755); err != nil { - t.Fatalf("mkdir repo: %v", err) - } - - runGit(t, repoDir, "init", "--initial-branch=main") - runGit(t, repoDir, "config", "user.email", "test@test.com") - runGit(t, repoDir, "config", "user.name", "Test User") - runGit(t, repoDir, "config", "commit.gpgsign", "false") - - // Create .gitignore - if err := os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte(".env\n.env.*\n*.local\nbuild/\n"), 0o644); err != nil { - t.Fatalf("write .gitignore: %v", err) - } - - // Initial commit - runGit(t, repoDir, "add", ".") - runGit(t, repoDir, "commit", "-m", "initial") - - return resolvePath(t, repoDir) -} - // testContext returns a context with a discarded logger and stderr output writer. func testContext() context.Context { ctx := context.Background() @@ -77,309 +32,182 @@ func testContext() context.Context { return ctx } -func TestFindSourceWorktree(t *testing.T) { +func TestPreserveFiles(t *testing.T) { t.Parallel() - t.Run("returns default branch worktree", func(t *testing.T) { + t.Run("symlinks listed files from source to target", func(t *testing.T) { t.Parallel() tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) ctx := testContext() - // Create a worktree on a feature branch - featureDir := filepath.Join(tmpDir, "repo-feature") - runGit(t, repoDir, "worktree", "add", "-b", "feature", featureDir) - - // FindSourceWorktree from the feature worktree should return the main worktree - source, err := FindSourceWorktree(ctx, repoDir, featureDir) - if err != nil { - t.Fatalf("FindSourceWorktree() error: %v", err) - } - if source != repoDir { - t.Errorf("FindSourceWorktree() = %q, want %q (default branch worktree)", source, repoDir) - } - }) - - t.Run("falls back to first non-target worktree", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - - // Create two feature worktrees (no default branch worktree will match - // if we search from one of them and the repo has no origin/HEAD) - feat1Dir := filepath.Join(tmpDir, "repo-feat1") - feat2Dir := filepath.Join(tmpDir, "repo-feat2") - runGit(t, repoDir, "worktree", "add", "-b", "feat1", feat1Dir) - runGit(t, repoDir, "worktree", "add", "-b", "feat2", feat2Dir) - - // From feat2, should find feat1 or main (whichever comes first that isn't feat2) - source, err := FindSourceWorktree(ctx, repoDir, feat2Dir) - if err != nil { - t.Fatalf("FindSourceWorktree() error: %v", err) + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) } - if source == feat2Dir { - t.Error("FindSourceWorktree() returned the target worktree itself") - } - }) - - t.Run("returns error when only one worktree exists", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - - // Only the main worktree exists — no source available - _, err := FindSourceWorktree(ctx, repoDir, repoDir) - if !errors.Is(err, ErrNoSourceWorktree) { - t.Errorf("FindSourceWorktree() error = %v, want ErrNoSourceWorktree", err) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) } - }) -} -func TestFindIgnoredFiles(t *testing.T) { - t.Parallel() - - t.Run("returns ignored files", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - - // Create some ignored files - if err := os.WriteFile(filepath.Join(repoDir, ".env"), []byte("SECRET=x\n"), 0o644); err != nil { + // Create source files + if err := os.WriteFile(filepath.Join(sourceDir, ".env"), []byte("DB_HOST=localhost\n"), 0o644); err != nil { t.Fatalf("write .env: %v", err) } - if err := os.WriteFile(filepath.Join(repoDir, ".env.local"), []byte("LOCAL=y\n"), 0o644); err != nil { - t.Fatalf("write .env.local: %v", err) - } - - files, err := FindIgnoredFiles(ctx, repoDir) - if err != nil { - t.Fatalf("FindIgnoredFiles() error: %v", err) + if err := os.WriteFile(filepath.Join(sourceDir, ".envrc"), []byte("dotenv\n"), 0o644); err != nil { + t.Fatalf("write .envrc: %v", err) } - if !slices.Contains(files, ".env") { - t.Errorf("expected .env in ignored files, got %v", files) - } - if !slices.Contains(files, ".env.local") { - t.Errorf("expected .env.local in ignored files, got %v", files) + cfg := config.PreserveConfig{ + Paths: []string{".env", ".envrc"}, } - }) - - t.Run("returns nil for no ignored files", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - // No ignored files created — only tracked files - files, err := FindIgnoredFiles(ctx, repoDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { - t.Fatalf("FindIgnoredFiles() error: %v", err) + t.Fatalf("PreserveFiles() error: %v", err) } - if files != nil { - t.Errorf("expected nil, got %v", files) + if len(linked) != 2 { + t.Fatalf("expected 2 linked files, got %d: %v", len(linked), linked) } - }) - - t.Run("returns nested ignored files", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - // Create nested ignored directory - buildDir := filepath.Join(repoDir, "build") - if err := os.MkdirAll(buildDir, 0o755); err != nil { - t.Fatalf("mkdir build: %v", err) - } - if err := os.WriteFile(filepath.Join(buildDir, "output.bin"), []byte("binary"), 0o644); err != nil { - t.Fatalf("write build/output.bin: %v", err) + // Verify symlinks were created + for _, path := range []string{".env", ".envrc"} { + dst := filepath.Join(targetDir, path) + info, err := os.Lstat(dst) + if err != nil { + t.Errorf("symlink %s should exist: %v", path, err) + continue + } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("%s should be a symlink", path) + } } - files, err := FindIgnoredFiles(ctx, repoDir) + // Verify content is accessible + data, err := os.ReadFile(filepath.Join(targetDir, ".env")) if err != nil { - t.Fatalf("FindIgnoredFiles() error: %v", err) + t.Fatalf("read .env through symlink: %v", err) } - - if !slices.Contains(files, "build/output.bin") { - t.Errorf("expected build/output.bin in ignored files, got %v", files) + if string(data) != "DB_HOST=localhost\n" { + t.Errorf(".env content = %q, want %q", data, "DB_HOST=localhost\n") } }) -} -func TestPreserveFiles(t *testing.T) { - t.Parallel() - - t.Run("copies matching ignored files to target", func(t *testing.T) { + t.Run("skips missing source files silently", func(t *testing.T) { t.Parallel() tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) ctx := testContext() - // Create ignored files in source - if err := os.WriteFile(filepath.Join(repoDir, ".env"), []byte("DB_HOST=localhost\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) } - if err := os.WriteFile(filepath.Join(repoDir, ".env.local"), []byte("LOCAL=1\n"), 0o644); err != nil { - t.Fatalf("write .env.local: %v", err) - } - - // Create target directory - targetDir := filepath.Join(tmpDir, "target") if err := os.MkdirAll(targetDir, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } cfg := config.PreserveConfig{ - Patterns: []string{".env", ".env.*"}, + Paths: []string{".env"}, // doesn't exist in source } - copied, err := PreserveFiles(ctx, cfg, repoDir, targetDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { t.Fatalf("PreserveFiles() error: %v", err) } - - if len(copied) != 2 { - t.Fatalf("expected 2 copied files, got %d: %v", len(copied), copied) - } - - // Verify files were actually copied - data, err := os.ReadFile(filepath.Join(targetDir, ".env")) - if err != nil { - t.Fatalf("read .env in target: %v", err) - } - if string(data) != "DB_HOST=localhost\n" { - t.Errorf(".env content = %q, want %q", data, "DB_HOST=localhost\n") + if len(linked) != 0 { + t.Errorf("expected 0 linked files, got %d: %v", len(linked), linked) } }) - t.Run("respects exclude patterns", func(t *testing.T) { + t.Run("skips files that already exist in target", func(t *testing.T) { t.Parallel() tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) ctx := testContext() - // Add node_modules to .gitignore - gitignore := filepath.Join(repoDir, ".gitignore") - if err := os.WriteFile(gitignore, []byte(".env\nnode_modules/\n"), 0o644); err != nil { - t.Fatalf("write .gitignore: %v", err) - } - runGit(t, repoDir, "add", ".gitignore") - runGit(t, repoDir, "commit", "-m", "update gitignore") - - // Create ignored files — one in excluded dir, one not - if err := os.WriteFile(filepath.Join(repoDir, ".env"), []byte("ROOT=1\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) } - nmDir := filepath.Join(repoDir, "node_modules") - if err := os.MkdirAll(nmDir, 0o755); err != nil { - t.Fatalf("mkdir node_modules: %v", err) - } - if err := os.WriteFile(filepath.Join(nmDir, ".env"), []byte("NM=1\n"), 0o644); err != nil { - t.Fatalf("write node_modules/.env: %v", err) - } - - targetDir := filepath.Join(tmpDir, "target") if err := os.MkdirAll(targetDir, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } - cfg := config.PreserveConfig{ - Patterns: []string{".env"}, - Exclude: []string{"node_modules"}, + // Create source file + if err := os.WriteFile(filepath.Join(sourceDir, ".env"), []byte("SOURCE=1\n"), 0o644); err != nil { + t.Fatalf("write source .env: %v", err) } - copied, err := PreserveFiles(ctx, cfg, repoDir, targetDir) - if err != nil { - t.Fatalf("PreserveFiles() error: %v", err) + // Create existing file in target with different content + if err := os.WriteFile(filepath.Join(targetDir, ".env"), []byte("TARGET=1\n"), 0o644); err != nil { + t.Fatalf("write target .env: %v", err) } - // Should only copy root .env, not node_modules/.env - if len(copied) != 1 { - t.Fatalf("expected 1 copied file, got %d: %v", len(copied), copied) - } - if copied[0] != ".env" { - t.Errorf("copied[0] = %q, want .env", copied[0]) + cfg := config.PreserveConfig{ + Paths: []string{".env"}, } - }) - - t.Run("returns empty for no matching files", func(t *testing.T) { - t.Parallel() - - tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) - ctx := testContext() - targetDir := filepath.Join(tmpDir, "target") - if err := os.MkdirAll(targetDir, 0o755); err != nil { - t.Fatalf("mkdir target: %v", err) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) + if err != nil { + t.Fatalf("PreserveFiles() error: %v", err) } - cfg := config.PreserveConfig{ - Patterns: []string{".env"}, + if len(linked) != 0 { + t.Errorf("expected 0 linked files (existing), got %d: %v", len(linked), linked) } - copied, err := PreserveFiles(ctx, cfg, repoDir, targetDir) + // Target content should be unchanged + data, err := os.ReadFile(filepath.Join(targetDir, ".env")) if err != nil { - t.Fatalf("PreserveFiles() error: %v", err) + t.Fatalf("read target .env: %v", err) } - if len(copied) != 0 { - t.Errorf("expected 0 copied files, got %d: %v", len(copied), copied) + if string(data) != "TARGET=1\n" { + t.Errorf("target .env was overwritten: got %q", data) } }) - t.Run("skips files that already exist in target", func(t *testing.T) { + t.Run("edits propagate through symlink", func(t *testing.T) { t.Parallel() tmpDir := resolvePath(t, t.TempDir()) - repoDir := setupTestRepo(t, tmpDir) ctx := testContext() - // Create ignored file in source - if err := os.WriteFile(filepath.Join(repoDir, ".env"), []byte("SOURCE=1\n"), 0o644); err != nil { - t.Fatalf("write .env: %v", err) + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("mkdir source: %v", err) } - - // Create same file in target with different content - targetDir := filepath.Join(tmpDir, "target") if err := os.MkdirAll(targetDir, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } - if err := os.WriteFile(filepath.Join(targetDir, ".env"), []byte("TARGET=1\n"), 0o644); err != nil { - t.Fatalf("write target .env: %v", err) + + if err := os.WriteFile(filepath.Join(sourceDir, ".env"), []byte("ORIGINAL\n"), 0o644); err != nil { + t.Fatalf("write .env: %v", err) } cfg := config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{".env"}, } - copied, err := PreserveFiles(ctx, cfg, repoDir, targetDir) - if err != nil { + if _, err := PreserveFiles(ctx, cfg, sourceDir, targetDir); err != nil { t.Fatalf("PreserveFiles() error: %v", err) } - // Should not have copied (file exists) - if len(copied) != 0 { - t.Errorf("expected 0 copied files (existing), got %d: %v", len(copied), copied) + // Edit via symlink in target + if err := os.WriteFile(filepath.Join(targetDir, ".env"), []byte("MODIFIED\n"), 0o644); err != nil { + t.Fatalf("write through symlink: %v", err) } - // Target content should be unchanged - data, err := os.ReadFile(filepath.Join(targetDir, ".env")) + // Source should reflect the change + data, err := os.ReadFile(filepath.Join(sourceDir, ".env")) if err != nil { - t.Fatalf("read target .env: %v", err) + t.Fatalf("read source .env: %v", err) } - if string(data) != "TARGET=1\n" { - t.Errorf("target .env was overwritten: got %q", data) + if string(data) != "MODIFIED\n" { + t.Errorf("edit did not propagate: source = %q, want %q", data, "MODIFIED\n") } }) } diff --git a/internal/preserve/preserve_test.go b/internal/preserve/preserve_test.go index c539355..752f5c1 100644 --- a/internal/preserve/preserve_test.go +++ b/internal/preserve/preserve_test.go @@ -7,7 +7,6 @@ import ( "errors" "io" "os" - "os/exec" "path/filepath" "testing" @@ -15,108 +14,15 @@ import ( "github.com/raphi011/wt/internal/log" ) -func TestMatchesPattern(t *testing.T) { +func TestLinkFile(t *testing.T) { t.Parallel() - tests := []struct { - name string - relPath string - patterns []string - exclude []string - want bool - }{ - { - name: "exact basename match", - relPath: ".env", - patterns: []string{".env"}, - want: true, - }, - { - name: "glob pattern match", - relPath: ".env.local", - patterns: []string{".env.*"}, - want: true, - }, - { - name: "nested file matches basename", - relPath: "config/.env", - patterns: []string{".env"}, - want: true, - }, - { - name: "no match", - relPath: "main.go", - patterns: []string{".env", ".envrc"}, - want: false, - }, - { - name: "excluded path segment", - relPath: "node_modules/.env", - patterns: []string{".env"}, - exclude: []string{"node_modules"}, - want: false, - }, - { - name: "deeply nested excluded segment", - relPath: "packages/app/node_modules/.cache/.env", - patterns: []string{".env"}, - exclude: []string{"node_modules"}, - want: false, - }, - { - name: "exclude does not match basename", - relPath: ".env", - patterns: []string{".env"}, - exclude: []string{"vendor"}, - want: true, - }, - { - name: "multiple patterns first matches", - relPath: ".envrc", - patterns: []string{".env", ".envrc", "docker-compose.override.yml"}, - want: true, - }, - { - name: "docker-compose override", - relPath: "docker-compose.override.yml", - patterns: []string{"docker-compose.override.yml"}, - want: true, - }, - { - name: "empty patterns", - relPath: ".env", - patterns: nil, - want: false, - }, - { - name: "invalid glob pattern does not match", - relPath: ".env", - patterns: []string{"[invalid"}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := matchesPattern(tt.relPath, tt.patterns, tt.exclude) - if got != tt.want { - t.Errorf("matchesPattern(%q, %v, %v) = %v, want %v", - tt.relPath, tt.patterns, tt.exclude, got, tt.want) - } - }) - } -} - -func TestCopyFile(t *testing.T) { - t.Parallel() - - t.Run("copies file with contents", func(t *testing.T) { + t.Run("creates relative symlink", func(t *testing.T) { t.Parallel() - tmpDir := t.TempDir() + tmpDir := resolveTempDir(t) - src := filepath.Join(tmpDir, "src", ".env") - dst := filepath.Join(tmpDir, "dst", ".env") + src := filepath.Join(tmpDir, "repo", ".env") + dst := filepath.Join(tmpDir, "worktree", ".env") if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) @@ -125,29 +31,48 @@ func TestCopyFile(t *testing.T) { t.Fatalf("setup: write file failed: %v", err) } - copied, err := CopyFile(src, dst) + linked, err := LinkFile(src, dst) + if err != nil { + t.Fatalf("LinkFile() error = %v", err) + } + if !linked { + t.Error("LinkFile() should return true for new link") + } + + // Verify it's a symlink + info, err := os.Lstat(dst) if err != nil { - t.Fatalf("CopyFile() error = %v", err) + t.Fatalf("failed to lstat dst: %v", err) } - if !copied { - t.Error("CopyFile() should return true for new file") + if info.Mode()&os.ModeSymlink == 0 { + t.Error("dst should be a symlink") } + // Verify symlink target is relative + target, err := os.Readlink(dst) + if err != nil { + t.Fatalf("failed to readlink dst: %v", err) + } + if filepath.IsAbs(target) { + t.Errorf("symlink target should be relative, got %q", target) + } + + // Verify content is accessible through symlink got, err := os.ReadFile(dst) if err != nil { - t.Fatalf("failed to read dst: %v", err) + t.Fatalf("failed to read through symlink: %v", err) } if string(got) != "SECRET=abc123\n" { t.Errorf("got %q, want %q", got, "SECRET=abc123\n") } }) - t.Run("skips existing file", func(t *testing.T) { + t.Run("skips when destination exists", func(t *testing.T) { t.Parallel() - tmpDir := t.TempDir() + tmpDir := resolveTempDir(t) - src := filepath.Join(tmpDir, "src", ".env") - dst := filepath.Join(tmpDir, "dst", ".env") + src := filepath.Join(tmpDir, "repo", ".env") + dst := filepath.Join(tmpDir, "worktree", ".env") if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) @@ -155,19 +80,19 @@ func TestCopyFile(t *testing.T) { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } - if err := os.WriteFile(src, []byte("NEW_CONTENT\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) + if err := os.WriteFile(src, []byte("NEW\n"), 0644); err != nil { + t.Fatalf("setup: write src failed: %v", err) } - if err := os.WriteFile(dst, []byte("EXISTING_CONTENT\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) + if err := os.WriteFile(dst, []byte("EXISTING\n"), 0644); err != nil { + t.Fatalf("setup: write dst failed: %v", err) } - copied, err := CopyFile(src, dst) - if err != nil { - t.Fatalf("CopyFile() should not error on existing: %v", err) + linked, err := LinkFile(src, dst) + if !errors.Is(err, ErrDestExists) { + t.Fatalf("LinkFile() error = %v, want ErrDestExists", err) } - if copied { - t.Error("CopyFile() should return false for existing file") + if linked { + t.Error("LinkFile() should return false when destination exists") } // Existing content should be preserved @@ -175,17 +100,17 @@ func TestCopyFile(t *testing.T) { if err != nil { t.Fatalf("failed to read dst: %v", err) } - if string(got) != "EXISTING_CONTENT\n" { - t.Errorf("existing file was overwritten: got %q, want %q", got, "EXISTING_CONTENT\n") + if string(got) != "EXISTING\n" { + t.Errorf("existing file was modified: got %q, want %q", got, "EXISTING\n") } }) t.Run("creates nested directories", func(t *testing.T) { t.Parallel() - tmpDir := t.TempDir() + tmpDir := resolveTempDir(t) - src := filepath.Join(tmpDir, "src", "deep", "nested", ".env") - dst := filepath.Join(tmpDir, "dst", "deep", "nested", ".env") + src := filepath.Join(tmpDir, "repo", "config", "deep", ".env") + dst := filepath.Join(tmpDir, "worktree", "config", "deep", ".env") if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) @@ -194,312 +119,69 @@ func TestCopyFile(t *testing.T) { t.Fatalf("setup: write file failed: %v", err) } - copied, err := CopyFile(src, dst) + linked, err := LinkFile(src, dst) if err != nil { - t.Fatalf("CopyFile() error = %v", err) + t.Fatalf("LinkFile() error = %v", err) } - if !copied { - t.Error("CopyFile() should return true for new file") + if !linked { + t.Error("LinkFile() should return true for new link") } got, err := os.ReadFile(dst) if err != nil { - t.Fatalf("failed to read dst: %v", err) + t.Fatalf("failed to read through symlink: %v", err) } if string(got) != "NESTED=true\n" { t.Errorf("got %q, want %q", got, "NESTED=true\n") } }) - t.Run("preserves file permissions", func(t *testing.T) { + t.Run("returns false when source does not exist", func(t *testing.T) { t.Parallel() - tmpDir := t.TempDir() - - src := filepath.Join(tmpDir, "src", "script.sh") - dst := filepath.Join(tmpDir, "dst", "script.sh") - - if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { - t.Fatalf("setup: mkdir failed: %v", err) - } - if err := os.WriteFile(src, []byte("#!/bin/sh\n"), 0755); err != nil { - t.Fatalf("setup: write file failed: %v", err) - } - - if _, err := CopyFile(src, dst); err != nil { - t.Fatalf("CopyFile() error = %v", err) - } - - info, err := os.Stat(dst) - if err != nil { - t.Fatalf("failed to stat dst: %v", err) - } - if info.Mode().Perm() != 0755 { - t.Errorf("permissions = %o, want %o", info.Mode().Perm(), 0755) - } - }) - - t.Run("returns error for non-existent source", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() + tmpDir := resolveTempDir(t) src := filepath.Join(tmpDir, "does-not-exist") dst := filepath.Join(tmpDir, "dst", "file") - _, err := CopyFile(src, dst) - if err == nil { - t.Fatal("CopyFile() should return error for non-existent source") + linked, err := LinkFile(src, dst) + if err != nil { + t.Fatalf("LinkFile() should not error for missing source: %v", err) + } + if linked { + t.Error("LinkFile() should return false for missing source") } }) - t.Run("cleans up dst when src cannot be opened", func(t *testing.T) { + t.Run("edits propagate through symlink", func(t *testing.T) { t.Parallel() - tmpDir := t.TempDir() + tmpDir := resolveTempDir(t) - src := filepath.Join(tmpDir, "src", "secret") - dst := filepath.Join(tmpDir, "dst", "secret") + src := filepath.Join(tmpDir, "repo", ".env") + dst := filepath.Join(tmpDir, "worktree", ".env") if err := os.MkdirAll(filepath.Dir(src), 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } - if err := os.WriteFile(src, []byte("content\n"), 0000); err != nil { + if err := os.WriteFile(src, []byte("ORIGINAL\n"), 0644); err != nil { t.Fatalf("setup: write file failed: %v", err) } - _, err := CopyFile(src, dst) - if err == nil { - t.Fatal("CopyFile() should return error when src cannot be opened") - } - - // dst should have been cleaned up - if _, statErr := os.Stat(dst); !os.IsNotExist(statErr) { - t.Error("dst should not exist after failed copy") - } - }) - - t.Run("skips symlinks", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - - // Create a real file and a symlink to it - realFile := filepath.Join(tmpDir, "real.txt") - if err := os.WriteFile(realFile, []byte("content\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) + if _, err := LinkFile(src, dst); err != nil { + t.Fatalf("LinkFile() error = %v", err) } - src := filepath.Join(tmpDir, "link.txt") - if err := os.Symlink(realFile, src); err != nil { - t.Fatalf("setup: symlink failed: %v", err) + // Edit via symlink + if err := os.WriteFile(dst, []byte("MODIFIED\n"), 0644); err != nil { + t.Fatalf("failed to write through symlink: %v", err) } - dst := filepath.Join(tmpDir, "dst", "link.txt") - - copied, err := CopyFile(src, dst) + // Source should reflect the change + got, err := os.ReadFile(src) if err != nil { - t.Fatalf("CopyFile() error = %v", err) + t.Fatalf("failed to read source: %v", err) } - if copied { - t.Error("CopyFile() should return false for symlink") - } - - // dst should not exist - if _, err := os.Stat(dst); !os.IsNotExist(err) { - t.Error("dst should not exist when source is a symlink") - } - }) -} - -func resolveTempDir(t *testing.T) string { - t.Helper() - tmpDir := t.TempDir() - resolved, err := filepath.EvalSymlinks(tmpDir) - if err != nil { - t.Fatalf("failed to resolve symlinks for %s: %v", tmpDir, err) - } - return resolved -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - c := exec.Command("git", args...) - if dir != "" { - c.Dir = dir - } - out, err := c.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, out) - } -} - -func testContext() context.Context { - l := log.New(io.Discard, false, true) - return log.WithLogger(context.Background(), l) -} - -// initBareRepoWithWorktree creates a bare-like repo setup with a main worktree -// and returns (gitDir, mainWorktreePath). -func initBareRepoWithWorktree(t *testing.T, baseDir string) (string, string) { - t.Helper() - - repoDir := filepath.Join(baseDir, "repo.git") - if err := os.MkdirAll(repoDir, 0755); err != nil { - t.Fatalf("setup: mkdir failed: %v", err) - } - runGit(t, repoDir, "init", "--bare", "-b", "main") - - // Create an initial commit via a temporary clone - cloneDir := filepath.Join(baseDir, "tmp-clone") - runGit(t, baseDir, "clone", repoDir, cloneDir) - runGit(t, cloneDir, "config", "user.email", "test@test.com") - runGit(t, cloneDir, "config", "user.name", "Test") - - readmePath := filepath.Join(cloneDir, "README.md") - if err := os.WriteFile(readmePath, []byte("init\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) - } - runGit(t, cloneDir, "add", ".") - runGit(t, cloneDir, "commit", "-m", "initial") - runGit(t, cloneDir, "push", "-u", "origin", "HEAD") - - // Add a main worktree from the bare repo - mainWT := filepath.Join(baseDir, "main-wt") - runGit(t, repoDir, "worktree", "add", mainWT, "main") - - return repoDir, mainWT -} - -func TestFindSourceWorktree(t *testing.T) { - t.Parallel() - - t.Run("returns default branch worktree", func(t *testing.T) { - t.Parallel() - baseDir := resolveTempDir(t) - ctx := testContext() - - repoDir, mainWT := initBareRepoWithWorktree(t, baseDir) - - // Create a feature branch and worktree - featureWT := filepath.Join(baseDir, "feature-wt") - runGit(t, mainWT, "checkout", "-b", "feature") - runGit(t, mainWT, "checkout", "main") - runGit(t, repoDir, "worktree", "add", featureWT, "feature") - - got, err := FindSourceWorktree(ctx, repoDir, featureWT) - if err != nil { - t.Fatalf("FindSourceWorktree() error = %v", err) - } - if got != mainWT { - t.Errorf("FindSourceWorktree() = %q, want %q", got, mainWT) - } - }) - - t.Run("falls back to first non-target worktree", func(t *testing.T) { - t.Parallel() - baseDir := resolveTempDir(t) - ctx := testContext() - - repoDir, mainWT := initBareRepoWithWorktree(t, baseDir) - - // Create two feature worktrees - runGit(t, mainWT, "checkout", "-b", "feat-a") - runGit(t, mainWT, "checkout", "main") - runGit(t, mainWT, "checkout", "-b", "feat-b") - runGit(t, mainWT, "checkout", "main") - - featAWT := filepath.Join(baseDir, "feat-a-wt") - runGit(t, repoDir, "worktree", "add", featAWT, "feat-a") - - featBWT := filepath.Join(baseDir, "feat-b-wt") - runGit(t, repoDir, "worktree", "add", featBWT, "feat-b") - - // Target is mainWT — default branch worktree is excluded, so it should - // fall back. But mainWT IS the default branch. Let's target feat-b - // and check we get something other than feat-b. - // Actually, let's test the fallback by targeting mainWT so default branch - // match is excluded, then it falls back to first non-target. - got, err := FindSourceWorktree(ctx, repoDir, mainWT) - if err != nil { - t.Fatalf("FindSourceWorktree() error = %v", err) - } - // Should return one of the feature worktrees (first non-target after - // default branch worktree is excluded because it IS the target) - if got == mainWT { - t.Errorf("FindSourceWorktree() returned target path %q", mainWT) - } - if got != featAWT && got != featBWT { - t.Errorf("FindSourceWorktree() = %q, want one of %q or %q", got, featAWT, featBWT) - } - }) - - t.Run("returns error when only target exists", func(t *testing.T) { - t.Parallel() - baseDir := resolveTempDir(t) - ctx := testContext() - - repoDir, mainWT := initBareRepoWithWorktree(t, baseDir) - - _, err := FindSourceWorktree(ctx, repoDir, mainWT) - if !errors.Is(err, ErrNoSourceWorktree) { - t.Errorf("FindSourceWorktree() error = %v, want %v", err, ErrNoSourceWorktree) - } - }) -} - -func TestFindIgnoredFiles(t *testing.T) { - t.Parallel() - - t.Run("returns ignored files", func(t *testing.T) { - t.Parallel() - baseDir := resolveTempDir(t) - ctx := testContext() - - // Create a git repo - runGit(t, baseDir, "init") - runGit(t, baseDir, "config", "user.email", "test@test.com") - runGit(t, baseDir, "config", "user.name", "Test") - - // Create .gitignore - if err := os.WriteFile(filepath.Join(baseDir, ".gitignore"), []byte(".env\n"), 0644); err != nil { - t.Fatalf("setup: write .gitignore failed: %v", err) - } - runGit(t, baseDir, "add", ".gitignore") - runGit(t, baseDir, "commit", "-m", "add gitignore") - - // Create an ignored file - if err := os.WriteFile(filepath.Join(baseDir, ".env"), []byte("SECRET=123\n"), 0644); err != nil { - t.Fatalf("setup: write .env failed: %v", err) - } - - got, err := FindIgnoredFiles(ctx, baseDir) - if err != nil { - t.Fatalf("FindIgnoredFiles() error = %v", err) - } - if len(got) != 1 || got[0] != ".env" { - t.Errorf("FindIgnoredFiles() = %v, want [.env]", got) - } - }) - - t.Run("returns nil for no ignored files", func(t *testing.T) { - t.Parallel() - baseDir := resolveTempDir(t) - ctx := testContext() - - runGit(t, baseDir, "init") - runGit(t, baseDir, "config", "user.email", "test@test.com") - runGit(t, baseDir, "config", "user.name", "Test") - - // Create a tracked file so we have at least one commit - if err := os.WriteFile(filepath.Join(baseDir, "README.md"), []byte("hi\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) - } - runGit(t, baseDir, "add", ".") - runGit(t, baseDir, "commit", "-m", "init") - - got, err := FindIgnoredFiles(ctx, baseDir) - if err != nil { - t.Fatalf("FindIgnoredFiles() error = %v", err) - } - if got != nil { - t.Errorf("FindIgnoredFiles() = %v, want nil", got) + if string(got) != "MODIFIED\n" { + t.Errorf("source not updated: got %q, want %q", got, "MODIFIED\n") } }) } @@ -507,13 +189,13 @@ func TestFindIgnoredFiles(t *testing.T) { func TestPreserveFiles(t *testing.T) { t.Parallel() - t.Run("copies matching files", func(t *testing.T) { + t.Run("symlinks listed paths", func(t *testing.T) { t.Parallel() - baseDir := resolveTempDir(t) + tmpDir := resolveTempDir(t) ctx := testContext() - sourceDir := filepath.Join(baseDir, "source") - targetDir := filepath.Join(baseDir, "target") + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") if err := os.MkdirAll(sourceDir, 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } @@ -521,52 +203,47 @@ func TestPreserveFiles(t *testing.T) { t.Fatalf("setup: mkdir failed: %v", err) } - // Init git repo in source - runGit(t, sourceDir, "init") - runGit(t, sourceDir, "config", "user.email", "test@test.com") - runGit(t, sourceDir, "config", "user.name", "Test") - - // Add .gitignore - if err := os.WriteFile(filepath.Join(sourceDir, ".gitignore"), []byte(".env\n"), 0644); err != nil { - t.Fatalf("setup: write .gitignore failed: %v", err) - } - runGit(t, sourceDir, "add", ".gitignore") - runGit(t, sourceDir, "commit", "-m", "init") - - // Create ignored file + // Create source files if err := os.WriteFile(filepath.Join(sourceDir, ".env"), []byte("SECRET=abc\n"), 0644); err != nil { t.Fatalf("setup: write .env failed: %v", err) } + if err := os.WriteFile(filepath.Join(sourceDir, ".envrc"), []byte("dotenv\n"), 0644); err != nil { + t.Fatalf("setup: write .envrc failed: %v", err) + } cfg := config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{".env", ".envrc"}, } - copied, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { t.Fatalf("PreserveFiles() error = %v", err) } - if len(copied) != 1 || copied[0] != ".env" { - t.Errorf("PreserveFiles() copied = %v, want [.env]", copied) + if len(linked) != 2 { + t.Errorf("PreserveFiles() linked = %v, want 2 files", linked) } - // Verify file was actually copied - got, err := os.ReadFile(filepath.Join(targetDir, ".env")) - if err != nil { - t.Fatalf("failed to read copied file: %v", err) - } - if string(got) != "SECRET=abc\n" { - t.Errorf("copied file content = %q, want %q", got, "SECRET=abc\n") + // Verify symlinks were created + for _, path := range []string{".env", ".envrc"} { + dst := filepath.Join(targetDir, path) + info, err := os.Lstat(dst) + if err != nil { + t.Errorf("symlink %s should exist: %v", path, err) + continue + } + if info.Mode()&os.ModeSymlink == 0 { + t.Errorf("%s should be a symlink", path) + } } }) - t.Run("skips non-matching files", func(t *testing.T) { + t.Run("skips missing source files silently", func(t *testing.T) { t.Parallel() - baseDir := resolveTempDir(t) + tmpDir := resolveTempDir(t) ctx := testContext() - sourceDir := filepath.Join(baseDir, "source") - targetDir := filepath.Join(baseDir, "target") + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") if err := os.MkdirAll(sourceDir, 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } @@ -574,46 +251,26 @@ func TestPreserveFiles(t *testing.T) { t.Fatalf("setup: mkdir failed: %v", err) } - runGit(t, sourceDir, "init") - runGit(t, sourceDir, "config", "user.email", "test@test.com") - runGit(t, sourceDir, "config", "user.name", "Test") - - if err := os.WriteFile(filepath.Join(sourceDir, ".gitignore"), []byte("*.log\n"), 0644); err != nil { - t.Fatalf("setup: write .gitignore failed: %v", err) - } - runGit(t, sourceDir, "add", ".gitignore") - runGit(t, sourceDir, "commit", "-m", "init") - - // Create ignored file that does NOT match preserve patterns - if err := os.WriteFile(filepath.Join(sourceDir, "random.log"), []byte("log data\n"), 0644); err != nil { - t.Fatalf("setup: write random.log failed: %v", err) - } - cfg := config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{".env", ".envrc"}, // neither exists } - copied, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { t.Fatalf("PreserveFiles() error = %v", err) } - if len(copied) != 0 { - t.Errorf("PreserveFiles() copied = %v, want empty", copied) - } - - // Verify file was NOT copied - if _, err := os.Stat(filepath.Join(targetDir, "random.log")); !os.IsNotExist(err) { - t.Error("non-matching file should not be copied to target") + if len(linked) != 0 { + t.Errorf("PreserveFiles() linked = %v, want empty", linked) } }) - t.Run("skips excluded directories", func(t *testing.T) { + t.Run("skips existing destination with warning", func(t *testing.T) { t.Parallel() - baseDir := resolveTempDir(t) + tmpDir := resolveTempDir(t) ctx := testContext() - sourceDir := filepath.Join(baseDir, "source") - targetDir := filepath.Join(baseDir, "target") + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") if err := os.MkdirAll(sourceDir, 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } @@ -621,78 +278,88 @@ func TestPreserveFiles(t *testing.T) { t.Fatalf("setup: mkdir failed: %v", err) } - runGit(t, sourceDir, "init") - runGit(t, sourceDir, "config", "user.email", "test@test.com") - runGit(t, sourceDir, "config", "user.name", "Test") - - if err := os.WriteFile(filepath.Join(sourceDir, ".gitignore"), []byte("node_modules/\n.env\n"), 0644); err != nil { - t.Fatalf("setup: write .gitignore failed: %v", err) - } - runGit(t, sourceDir, "add", ".gitignore") - runGit(t, sourceDir, "commit", "-m", "init") - - // Create ignored file inside excluded directory - nmDir := filepath.Join(sourceDir, "node_modules") - if err := os.MkdirAll(nmDir, 0755); err != nil { - t.Fatalf("setup: mkdir failed: %v", err) + if err := os.WriteFile(filepath.Join(sourceDir, ".env"), []byte("SOURCE\n"), 0644); err != nil { + t.Fatalf("setup: write source failed: %v", err) } - if err := os.WriteFile(filepath.Join(nmDir, ".env"), []byte("NM_SECRET=x\n"), 0644); err != nil { - t.Fatalf("setup: write .env failed: %v", err) + if err := os.WriteFile(filepath.Join(targetDir, ".env"), []byte("EXISTING\n"), 0644); err != nil { + t.Fatalf("setup: write target failed: %v", err) } cfg := config.PreserveConfig{ - Patterns: []string{".env"}, - Exclude: []string{"node_modules"}, + Paths: []string{".env"}, } - copied, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { t.Fatalf("PreserveFiles() error = %v", err) } - if len(copied) != 0 { - t.Errorf("PreserveFiles() copied = %v, want empty (excluded dir)", copied) + if len(linked) != 0 { + t.Errorf("PreserveFiles() linked = %v, want empty", linked) } - // Verify file was NOT copied - if _, err := os.Stat(filepath.Join(targetDir, "node_modules", ".env")); !os.IsNotExist(err) { - t.Error("excluded directory file should not be copied to target") + // Existing content should be untouched + got, err := os.ReadFile(filepath.Join(targetDir, ".env")) + if err != nil { + t.Fatalf("failed to read target: %v", err) + } + if string(got) != "EXISTING\n" { + t.Errorf("existing file was modified: got %q, want %q", got, "EXISTING\n") } }) - t.Run("handles empty ignored files", func(t *testing.T) { + t.Run("symlinks nested paths", func(t *testing.T) { t.Parallel() - baseDir := resolveTempDir(t) + tmpDir := resolveTempDir(t) ctx := testContext() - sourceDir := filepath.Join(baseDir, "source") - targetDir := filepath.Join(baseDir, "target") - if err := os.MkdirAll(sourceDir, 0755); err != nil { + sourceDir := filepath.Join(tmpDir, "repo") + targetDir := filepath.Join(tmpDir, "worktree") + + // Create nested source + nestedDir := filepath.Join(sourceDir, "config") + if err := os.MkdirAll(nestedDir, 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } if err := os.MkdirAll(targetDir, 0755); err != nil { t.Fatalf("setup: mkdir failed: %v", err) } - - runGit(t, sourceDir, "init") - runGit(t, sourceDir, "config", "user.email", "test@test.com") - runGit(t, sourceDir, "config", "user.name", "Test") - - if err := os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("hi\n"), 0644); err != nil { - t.Fatalf("setup: write file failed: %v", err) + if err := os.WriteFile(filepath.Join(nestedDir, ".env"), []byte("NESTED=true\n"), 0644); err != nil { + t.Fatalf("setup: write failed: %v", err) } - runGit(t, sourceDir, "add", ".") - runGit(t, sourceDir, "commit", "-m", "init") cfg := config.PreserveConfig{ - Patterns: []string{".env"}, + Paths: []string{"config/.env"}, } - copied, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) + linked, err := PreserveFiles(ctx, cfg, sourceDir, targetDir) if err != nil { t.Fatalf("PreserveFiles() error = %v", err) } - if len(copied) != 0 { - t.Errorf("PreserveFiles() copied = %v, want empty", copied) + if len(linked) != 1 || linked[0] != "config/.env" { + t.Errorf("PreserveFiles() linked = %v, want [config/.env]", linked) + } + + got, err := os.ReadFile(filepath.Join(targetDir, "config", ".env")) + if err != nil { + t.Fatalf("failed to read through symlink: %v", err) + } + if string(got) != "NESTED=true\n" { + t.Errorf("got %q, want %q", got, "NESTED=true\n") } }) } + +func resolveTempDir(t *testing.T) string { + t.Helper() + tmpDir := t.TempDir() + resolved, err := filepath.EvalSymlinks(tmpDir) + if err != nil { + t.Fatalf("failed to resolve symlinks for %s: %v", tmpDir, err) + } + return resolved +} + +func testContext() context.Context { + l := log.New(io.Discard, false, true) + return log.WithLogger(context.Background(), l) +}