fix(ls): respect git's `core.excludesfile` config if set (#2314)

Martin and Andrey Nering created

See: https://git-scm.com/docs/gitignore

Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

Change summary

go.mod               |  2 +
go.sum               |  6 ++++
internal/fsext/ls.go | 61 ++++++++++++++++++++++++++++++++++++---------
3 files changed, 57 insertions(+), 12 deletions(-)

Detailed changes

go.mod 🔗

@@ -105,6 +105,7 @@ require (
 	github.com/charmbracelet/x/json v0.2.0 // indirect
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
+	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/dlclark/regexp2 v1.11.5 // indirect
 	github.com/ebitengine/purego v0.10.0 // indirect
@@ -156,6 +157,7 @@ require (
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
 	github.com/pierrec/lz4/v4 v4.1.25 // indirect
+	github.com/pjbgf/sha1cd v0.3.2 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/segmentio/asm v1.2.1 // indirect

go.sum 🔗

@@ -141,6 +141,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7X
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -303,10 +305,14 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
 github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

internal/fsext/ls.go 🔗

@@ -1,6 +1,7 @@
 package fsext
 
 import (
+	"cmp"
 	"errors"
 	"log/slog"
 	"os"
@@ -12,6 +13,7 @@ import (
 	"github.com/charlievieth/fastwalk"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/home"
+	gitconfig "github.com/go-git/go-git/v5/config"
 	"github.com/go-git/go-git/v5/plumbing/format/gitignore"
 )
 
@@ -80,18 +82,52 @@ var commonIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
 	return parsePatterns(patterns, nil)
 })
 
-var homeIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
-	homeDir := home.Dir()
-	var lines []string
-	for _, name := range []string{
-		filepath.Join(homeDir, ".gitignore"),
-		filepath.Join(homeDir, ".config", "git", "ignore"),
-		filepath.Join(homeDir, ".config", "crush", "ignore"),
-	} {
-		if bts, err := os.ReadFile(name); err == nil {
-			lines = append(lines, strings.Split(string(bts), "\n")...)
+// gitGlobalIgnorePatterns returns patterns from git's global excludes file
+// (core.excludesFile), following git's config resolution order.
+var gitGlobalIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
+	cfg, err := gitconfig.LoadConfig(gitconfig.GlobalScope)
+	if err != nil {
+		slog.Debug("Failed to load global git config", "error", err)
+		return nil
+	}
+
+	configPath := cmp.Or(
+		os.Getenv("XDG_CONFIG_HOME"),
+		filepath.Join(home.Dir(), ".config"),
+	)
+	excludesFilePath := cmp.Or(
+		cfg.Raw.Section("core").Options.Get("excludesfile"),
+		filepath.Join(configPath, "git", "ignore"),
+	)
+	excludesFilePath = home.Long(excludesFilePath)
+
+	bts, err := os.ReadFile(excludesFilePath)
+	if err != nil {
+		if !os.IsNotExist(err) {
+			slog.Debug("Failed to read git global excludes file", "path", excludesFilePath, "error", err)
 		}
+		return nil
+	}
+
+	return parsePatterns(strings.Split(string(bts), "\n"), nil)
+})
+
+// crushGlobalIgnorePatterns returns patterns from the user's
+// ~/.config/crush/ignore file.
+var crushGlobalIgnorePatterns = sync.OnceValue(func() []gitignore.Pattern {
+	configPath := cmp.Or(
+		os.Getenv("XDG_CONFIG_HOME"),
+		filepath.Join(home.Dir(), ".config"),
+	)
+	name := filepath.Join(configPath, "crush", "ignore")
+	bts, err := os.ReadFile(name)
+	if err != nil {
+		if !os.IsNotExist(err) {
+			slog.Debug("Failed to read crush global ignore file", "path", name, "error", err)
+		}
+		return nil
 	}
+	lines := strings.Split(string(bts), "\n")
 	return parsePatterns(lines, nil)
 })
 
@@ -169,8 +205,9 @@ func (dl *directoryLister) getCombinedMatcher(dir string) gitignore.Matcher {
 		// Add common patterns first (lowest priority).
 		allPatterns = append(allPatterns, commonIgnorePatterns()...)
 
-		// Add home ignore patterns.
-		allPatterns = append(allPatterns, homeIgnorePatterns()...)
+		// Add global ignore patterns (git core.excludesFile + crush global ignore).
+		allPatterns = append(allPatterns, gitGlobalIgnorePatterns()...)
+		allPatterns = append(allPatterns, crushGlobalIgnorePatterns()...)
 
 		// Collect patterns from root to this directory.
 		relDir, _ := filepath.Rel(dl.rootPath, dir)