Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
85 changes: 85 additions & 0 deletions internal/cli/directory_conflict_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
}
}
73 changes: 73 additions & 0 deletions internal/cli/tracked_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
}
}
}
24 changes: 23 additions & 1 deletion internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading