diff --git a/README.md b/README.md index 25dc3b9..b7b952b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ brew tap satococoa/tap brew install satococoa/tap/git-worktreeinclude ``` +### Install (Go) + +With Go 1.26 or later installed: + +```sh +go install github.com/satococoa/git-worktreeinclude/cmd/git-worktreeinclude@latest +``` + +Add your Go binary installation directory (`GOBIN`, or `$(go env GOPATH)/bin` when `GOBIN` is unset) to `PATH`. This also enables `git worktreeinclude apply`. + +Builds installed with `go install` currently report `dev` for `--version`; the installed module version can be inspected with `go version -m` on the binary. + ### Build ```sh @@ -42,7 +54,7 @@ git worktreeinclude apply - Paths matching `.worktreeinclude` - Paths Git classifies as ignored -Tracked files are never copied, even if listed in `.worktreeinclude`. +Files tracked in either the source or target worktree are never copied, even if listed in `.worktreeinclude`, missing from the target working tree, or used with `--force`. Example for a typical app repo with local env, editor settings, and tool-specific cache: diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2ad82ab..f3e0d7b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -243,7 +243,7 @@ func formatActionLine(action engine.Action, force bool) string { return fmt.Sprintf("COPY %s", action.Path) case "conflict": if force { - return fmt.Sprintf("COPY %s", action.Path) + return fmt.Sprintf("CONFLICT %s (unresolved by --force)", action.Path) } return fmt.Sprintf("CONFLICT %s (differs; use --force)", action.Path) case "skip": diff --git a/internal/cli/directory_conflict_integration_test.go b/internal/cli/directory_conflict_integration_test.go new file mode 100644 index 0000000..a3e53a9 --- /dev/null +++ b/internal/cli/directory_conflict_integration_test.go @@ -0,0 +1,85 @@ +package cli_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyDirectoryConflictRemainsAnError(t *testing.T) { + for _, force := range []bool{false, true} { + for _, dryRun := range []bool{false, true} { + for _, jsonOutput := range []bool{false, true} { + t.Run(fmt.Sprintf("force=%t/dry=%t/json=%t", force, dryRun, jsonOutput), func(t *testing.T) { + fx := setupFixture(t) + writeFile(t, filepath.Join(fx.root, testIncludeFile), ".env\n.env.local\n") + target := filepath.Join(fx.wt, ".env") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + child := filepath.Join(target, "keep") + writeFile(t, child, "KEEP\n") + args := []string{"apply", "--include", testIncludeFile} + if force { + args = append(args, "--force") + } + if dryRun { + args = append(args, "--dry-run") + } + if jsonOutput { + args = append(args, "--json") + } + stdout, stderr, code := runCmd(t, fx.wt, nil, testBinary, args...) + if code != 3 || strings.TrimSpace(stderr) != "" { + t.Fatalf("code=%d stderr=%q stdout=%q", code, stderr, stdout) + } + data, err := os.ReadFile(child) + if err != nil || string(data) != "KEEP\n" { + t.Fatalf("directory contents changed: %q, %v", data, err) + } + other := filepath.Join(fx.wt, ".env.local") + if dryRun { + if _, err := os.Lstat(other); !os.IsNotExist(err) { + t.Fatalf("dry-run wrote other file: %v", err) + } + } else { + data, err := os.ReadFile(other) + if err != nil || string(data) != "SOURCE_LOCAL\n" { + t.Fatalf("non-conflicting file not copied: %q, %v", data, err) + } + } + if jsonOutput { + res := decodeSingleJSON(t, stdout) + if res.DryRun != dryRun || res.Summary.Conflicts != 1 || res.Summary.Errors != 0 || len(res.Actions) != 2 { + t.Fatalf("incorrect conflict result: %+v", res) + } + if dryRun && (res.Summary.CopyPlanned != 1 || res.Summary.Copied != 0) || !dryRun && res.Summary.Copied != 1 { + t.Fatalf("incorrect copy summary: %+v", res.Summary) + } + found := false + for _, action := range res.Actions { + if action.Path == ".env" { + found = true + if action.Op != "conflict" || action.Status != "diff" { + t.Fatalf("incorrect directory action: %+v", action) + } + } + } + if !found { + t.Fatal("directory conflict action missing") + } + } else { + if !strings.Contains(stdout, "CONFLICT .env (") || strings.Contains(stdout, "COPY .env\n") { + t.Fatalf("directory must be reported as conflict, not copied: %q", stdout) + } + if force && strings.Contains(stdout, "use --force") { + t.Fatalf("force cannot resolve this conflict: %q", stdout) + } + } + }) + } + } + } +} diff --git a/internal/cli/tracked_integration_test.go b/internal/cli/tracked_integration_test.go new file mode 100644 index 0000000..46afdf5 --- /dev/null +++ b/internal/cli/tracked_integration_test.go @@ -0,0 +1,73 @@ +package cli_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyProtectsTrackedFilesInEitherWorktree(t *testing.T) { + for _, tracking := range []struct { + name string + source, target bool + }{ + {"source", true, false}, + {"target", false, true}, + {"both", true, true}, + } { + for _, deleted := range []bool{false, true} { + for _, force := range []bool{false, true} { + for _, dryRun := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/deleted=%t/force=%t/dry=%t", tracking.name, deleted, force, dryRun), func(t *testing.T) { + fx := setupFixture(t) + writeFile(t, filepath.Join(fx.root, testIncludeFile), ".env\n") + target := filepath.Join(fx.wt, ".env") + writeFile(t, target, "TARGET\n") + if tracking.source { + runGit(t, fx.root, "add", "-f", ".env") + } + if tracking.target { + runGit(t, fx.wt, "add", "-f", ".env") + } + if deleted { + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + } + before := runGit(t, fx.wt, "status", "--porcelain=v1", "--untracked-files=all") + args := []string{"apply", "--include", testIncludeFile, "--json"} + if force { + args = append(args, "--force") + } + if dryRun { + args = append(args, "--dry-run") + } + stdout, stderr, code := runCmd(t, fx.wt, nil, testBinary, args...) + if code != 0 || strings.TrimSpace(stderr) != "" { + t.Fatalf("code=%d stderr=%q", code, stderr) + } + res := decodeSingleJSON(t, stdout) + if res.DryRun != dryRun || res.Summary.Matched != 0 || len(res.Actions) != 0 { + t.Fatalf("tracked files must not be planned or copied: %+v", res) + } + if deleted { + if _, err := os.Lstat(target); !os.IsNotExist(err) { + t.Fatalf("deleted file was restored: %v", err) + } + } else { + data, err := os.ReadFile(target) + if err != nil || string(data) != "TARGET\n" { + t.Fatalf("target changed: %q, %v", data, err) + } + } + if after := runGit(t, fx.wt, "status", "--porcelain=v1", "--untracked-files=all"); after != before { + t.Fatalf("target status changed: before=%q after=%q", before, after) + } + }) + } + } + } + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 4a5f895..210dc6d 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -256,7 +256,7 @@ func (e *Engine) executePrepared(prep prepared, dryRun, force bool) (Result, int if result.Summary.Errors > 0 { return result, exitcode.Internal } - if result.Summary.Conflicts > 0 && !force { + if result.Summary.Conflicts > 0 { return result, exitcode.Conflict } return result, exitcode.OK @@ -342,6 +342,28 @@ func (e *Engine) prepare(ctx context.Context, cwd, fromOpt, includeOpt string) ( return prepared{}, err } prep.matched = intersectPaths(ignored, included) + + // The source and target have separate indexes. A source-ignored path may + // still be tracked in the target, even when its working-tree file is absent. + trackedOutput, err := e.git.Run(ctx, targetRoot, "ls-files", "--cached", "-z") + if err != nil { + return prepared{}, &CLIError{Code: exitcode.Env, Msg: "failed to list target tracked files", Err: err} + } + tracked, err := parseNULPaths(trackedOutput) + if err != nil { + return prepared{}, &CLIError{Code: exitcode.Env, Msg: "failed to parse target tracked files", Err: err} + } + trackedSet := make(map[string]struct{}, len(tracked)) + for _, rel := range tracked { + trackedSet[rel] = struct{}{} + } + matched := prep.matched[:0] + for _, rel := range prep.matched { + if _, isTracked := trackedSet[rel]; !isTracked { + matched = append(matched, rel) + } + } + prep.matched = matched return prep, nil }