diff --git a/src/internal/gitutil/git.go b/src/internal/gitutil/git.go index ff3b554..390e98a 100644 --- a/src/internal/gitutil/git.go +++ b/src/internal/gitutil/git.go @@ -116,28 +116,30 @@ func ConfigUnset(key string) error { return err } -// TrackedFiles returns the repository-relative paths tracked by Git. -func TrackedFiles() ([]string, error) { - out, err := run("ls-files") +// runPaths runs a git command that lists paths with -z and returns them +// verbatim. Without -z, Git quotes any path containing non-ASCII bytes, quotes, +// backslashes or control characters (core.quotePath), which would no longer +// match the real file name. +func runPaths(args ...string) ([]string, error) { + out, err := runRaw(args...) if err != nil { return nil, err } - if out == "" { + raw := strings.TrimSuffix(string(out), "\x00") + if raw == "" { return nil, nil } - return strings.Split(out, "\n"), nil + return strings.Split(raw, "\x00"), nil +} + +// TrackedFiles returns the repository-relative paths tracked by Git. +func TrackedFiles() ([]string, error) { + return runPaths("ls-files", "-z") } // StagedFiles returns the repository-relative paths staged for commit. func StagedFiles() ([]string, error) { - out, err := run("diff", "--cached", "--diff-filter=ACMR", "--name-only") - if err != nil { - return nil, err - } - if out == "" { - return nil, nil - } - return strings.Split(out, "\n"), nil + return runPaths("diff", "--cached", "--diff-filter=ACMR", "--name-only", "-z") } // ShowStaged returns the staged (index) content of a path. diff --git a/src/internal/gitutil/git_test.go b/src/internal/gitutil/git_test.go index e3a8306..62b4d23 100644 --- a/src/internal/gitutil/git_test.go +++ b/src/internal/gitutil/git_test.go @@ -66,3 +66,41 @@ func TestShowStagedBatch(t *testing.T) { t.Fatal(".env.missing must be absent") } } + +func TestPathListingsKeepNonASCIIPaths(t *testing.T) { + root := setupRepo(t) + // Git's default; set explicitly so a global core.quotePath=false cannot + // mask the quoting that the listings must not depend on. + if out, err := exec.Command("git", "config", "core.quotePath", "true").CombinedOutput(); err != nil { + t.Fatalf("git config: %v\n%s", err, out) + } + path := filepath.Join("services", "café", ".env") + if err := os.MkdirAll(filepath.Join(root, "services", "café"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, path), []byte("SECRET=1\n"), 0o600); err != nil { + t.Fatal(err) + } + + managed, err := ManagedFiles(DefaultExclusions) + if err != nil { + t.Fatal(err) + } + if len(managed) != 1 || managed[0] != path { + t.Fatalf("ManagedFiles() = %q, want [%q]", managed, path) + } + + if out, err := exec.Command("git", "add", "--", path).CombinedOutput(); err != nil { + t.Fatalf("git add: %v\n%s", err, out) + } + staged, err := StagedFiles() + if err != nil { + t.Fatal(err) + } + if len(staged) != 1 || staged[0] != path { + t.Fatalf("StagedFiles() = %q, want [%q]", staged, path) + } + if _, err := ShowStaged(staged[0]); err != nil { + t.Fatalf("ShowStaged(%q): %v", staged[0], err) + } +} diff --git a/src/internal/gitutil/managed.go b/src/internal/gitutil/managed.go index 1514114..ab91fc6 100644 --- a/src/internal/gitutil/managed.go +++ b/src/internal/gitutil/managed.go @@ -26,7 +26,7 @@ func IsManagedName(path string, exclusions []string) bool { // ManagedFiles returns tracked and untracked (non-ignored) managed .env files. func ManagedFiles(exclusions []string) ([]string, error) { - out, err := run("ls-files", "--cached", "--others", "--exclude-standard") + out, err := runPaths("ls-files", "-z", "--cached", "--others", "--exclude-standard") if err != nil { return nil, err } @@ -36,20 +36,17 @@ func ManagedFiles(exclusions []string) ([]string, error) { // IgnoredManagedFiles returns managed .env files that Git is currently ignoring // (they would silently never be committed, which doctor should flag). func IgnoredManagedFiles(exclusions []string) ([]string, error) { - out, err := run("ls-files", "--others", "--ignored", "--exclude-standard") + out, err := runPaths("ls-files", "-z", "--others", "--ignored", "--exclude-standard") if err != nil { return nil, err } return filterManaged(out, exclusions), nil } -func filterManaged(out string, exclusions []string) []string { - if out == "" { - return nil - } +func filterManaged(paths []string, exclusions []string) []string { seen := map[string]bool{} var files []string - for _, p := range strings.Split(out, "\n") { + for _, p := range paths { if p == "" || seen[p] { continue }