From 9e366ab894d1e872b19a6ad86f4d3e2904a1d2dd Mon Sep 17 00:00:00 2001 From: Oussama Makhlouk Date: Tue, 9 Jun 2026 00:23:44 +0100 Subject: [PATCH] issue #67: Add --gitignore Support --- README.md | 1 + cmd/flag.go | 1 + cmd/root.go | 2 +- cmd/root_test.go | 31 ++++++- module/types.go | 1 + service/gitignore.go | 192 ++++++++++++++++++++++++++++++++++++++++ service/service.go | 49 +++++++--- service/service_test.go | 126 ++++++++++++++++++++++++++ 8 files changed, 388 insertions(+), 15 deletions(-) create mode 100644 service/gitignore.go diff --git a/README.md b/README.md index 36bbbb4..df5bb51 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ treels [Flags] [Path] - `-h, --help`: Help for treels - `-t, --tree`: Tree view of the directory - `--no-icons`: Disable icons +- `--gitignore`: Respect .gitignore rules - `-r, --readable`: Show human-readable size for each file and directory - `-v, --version`: Show treels version diff --git a/cmd/flag.go b/cmd/flag.go index 4b8960e..1cc760b 100644 --- a/cmd/flag.go +++ b/cmd/flag.go @@ -11,6 +11,7 @@ func FlagDefinition(cmd *cobra.Command, flags *module.Flags) { cmd.PersistentFlags().BoolVarP(&flags.ShowHidden, "all", "a", false, "List all files and directories") cmd.PersistentFlags().BoolVarP(&flags.ShowTreeView, "tree", "t", false, "Tree view of the directory") cmd.PersistentFlags().BoolVar(&flags.HideIcon, "no-icons", false, "Disable icons") + cmd.PersistentFlags().BoolVar(&flags.RespectGitIgnore, "gitignore", false, "Respect .gitignore rules") cmd.PersistentFlags().BoolVarP(&flags.ShowReadableSize, "readable", "r", false, "Show human-readable size for each file and directory") cmd.PersistentFlags().BoolVarP(&flags.ShowVersion, "version", "v", false, "Show treels version") } diff --git a/cmd/root.go b/cmd/root.go index bf38e16..5493f02 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -const version = "v1.3.0" +const version = "v1.3.1" // Execute func - runs the root command. func Execute() { diff --git a/cmd/root_test.go b/cmd/root_test.go index a74ecc7..d279ea4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -79,7 +79,7 @@ func TestRootCmd_VersionFlag(t *testing.T) { t.Fatalf("Execute() error = %v, want nil", err) } - if got := output.String(); got != "treels v1.3.0\n" { + if got := output.String(); got != "treels v1.3.1\n" { t.Fatalf("Execute() output = %q, want version output", got) } }) @@ -126,6 +126,35 @@ func TestRootCmd_ReadableFlag(t *testing.T) { } } +func TestRootCmd_GitIgnoreFlag(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.txt\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("ignored"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + output := captureStdout(t, func() { + cmd := newRootCmd() + cmd.SetArgs([]string{"--gitignore", "--no-icons", dir}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v, want nil", err) + } + }) + + if strings.Contains(output, "ignored.txt") { + t.Fatalf("Execute() output = %q, want ignored file to be omitted", output) + } + if !strings.Contains(output, "main.go") { + t.Fatalf("Execute() output = %q, want visible file", output) + } +} + func captureStdout(t *testing.T, run func()) string { t.Helper() diff --git a/module/types.go b/module/types.go index c98c064..334ced2 100644 --- a/module/types.go +++ b/module/types.go @@ -8,6 +8,7 @@ type Flags struct { HideIcon bool ShowReadableSize bool ShowVersion bool + RespectGitIgnore bool } // Options struct - Contains configuration options for directory listing. diff --git a/service/gitignore.go b/service/gitignore.go new file mode 100644 index 0000000..b2ccaba --- /dev/null +++ b/service/gitignore.go @@ -0,0 +1,192 @@ +package service + +import ( + "bufio" + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +const gitignoreFileName = ".gitignore" + +// gitIgnoreMatcher holds parsed .gitignore rules for a root directory. +type gitIgnoreMatcher struct { + root string + rules []gitIgnoreRule +} + +// gitIgnoreRule represents one parsed .gitignore pattern. +type gitIgnoreRule struct { + pattern string + negated bool + anchored bool + dirOnly bool + hasSlash bool +} + +// newGitIgnoreMatcher creates a matcher from the root directory's .gitignore file. +func newGitIgnoreMatcher(root string) (*gitIgnoreMatcher, error) { + gitignorePath := filepath.Join(root, gitignoreFileName) + file, err := os.Open(gitignorePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", gitignorePath, err) + } + defer func() { + _ = file.Close() + }() + + rules := parseGitIgnoreRules(file) + if len(rules) == 0 { + return nil, nil + } + + return &gitIgnoreMatcher{ + root: root, + rules: rules, + }, nil +} + +// parseGitIgnoreRules parses all supported .gitignore rules from a file. +func parseGitIgnoreRules(file *os.File) []gitIgnoreRule { + var rules []gitIgnoreRule + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if rule, ok := parseGitIgnoreRule(scanner.Text()); ok { + rules = append(rules, rule) + } + } + return rules +} + +// parseGitIgnoreRule parses a single .gitignore line into a rule. +func parseGitIgnoreRule(line string) (gitIgnoreRule, bool) { + line = strings.TrimSpace(line) + if line == "" { + return gitIgnoreRule{}, false + } + + if strings.HasPrefix(line, `\#`) { + line = strings.TrimPrefix(line, `\`) + } else if strings.HasPrefix(line, "#") { + return gitIgnoreRule{}, false + } + + negated := false + if strings.HasPrefix(line, `\!`) { + line = strings.TrimPrefix(line, `\`) + } else if strings.HasPrefix(line, "!") { + negated = true + line = strings.TrimPrefix(line, "!") + } + + line = strings.TrimSpace(line) + if line == "" { + return gitIgnoreRule{}, false + } + + anchored := strings.HasPrefix(line, "/") + line = strings.TrimPrefix(line, "/") + + dirOnly := strings.HasSuffix(line, "/") + line = strings.TrimRight(line, "/") + if line == "" { + return gitIgnoreRule{}, false + } + + return gitIgnoreRule{ + pattern: filepath.ToSlash(line), + negated: negated, + anchored: anchored, + dirOnly: dirOnly, + hasSlash: strings.Contains(line, "/"), + }, true +} + +// ignores reports whether a path should be excluded by the parsed rules. +func (m *gitIgnoreMatcher) ignores(filePath string, isDir bool) bool { + if m == nil { + return false + } + + relPath, err := filepath.Rel(m.root, filePath) + if err != nil { + return false + } + + relPath = filepath.ToSlash(relPath) + if relPath == "." || strings.HasPrefix(relPath, "../") { + return false + } + + ignored := false + for _, rule := range m.rules { + if rule.matches(relPath, isDir) { + ignored = !rule.negated + } + } + return ignored +} + +// matches reports whether a relative path matches this .gitignore rule. +func (r gitIgnoreRule) matches(relPath string, isDir bool) bool { + if r.dirOnly && !isDir { + return false + } + + if r.anchored { + return matchSlashPattern(r.pattern, relPath) + } + + if !r.hasSlash { + for _, part := range strings.Split(relPath, "/") { + if matchSlashPattern(r.pattern, part) { + return true + } + } + return false + } + + return matchSlashPattern(r.pattern, relPath) +} + +// matchSlashPattern matches slash-separated paths with glob support. +func matchSlashPattern(pattern, name string) bool { + if !strings.Contains(pattern, "**") { + matched, err := path.Match(pattern, name) + return err == nil && matched + } + + return matchPatternSegments(strings.Split(pattern, "/"), strings.Split(name, "/")) +} + +// matchPatternSegments matches path segments and supports the ** wildcard. +func matchPatternSegments(patternSegments, nameSegments []string) bool { + if len(patternSegments) == 0 { + return len(nameSegments) == 0 + } + + if patternSegments[0] == "**" { + for i := 0; i <= len(nameSegments); i++ { + if matchPatternSegments(patternSegments[1:], nameSegments[i:]) { + return true + } + } + return false + } + + if len(nameSegments) == 0 { + return false + } + + matched, err := path.Match(patternSegments[0], nameSegments[0]) + if err != nil || !matched { + return false + } + + return matchPatternSegments(patternSegments[1:], nameSegments[1:]) +} diff --git a/service/service.go b/service/service.go index 9dec019..24304be 100644 --- a/service/service.go +++ b/service/service.go @@ -20,6 +20,11 @@ const ( dot = "." ) +type directoryOptions struct { + module.Options + gitIgnore *gitIgnoreMatcher +} + // Dispatcher func - executes function based on flags func Dispatcher(options module.Options) error { return dispatcher(options, os.Stdout) @@ -33,19 +38,28 @@ func dispatcher(options module.Options, output io.Writer) error { return err } + traversalOptions := directoryOptions{Options: options} + if options.Flags.RespectGitIgnore { + gitIgnore, err := newGitIgnoreMatcher(options.Directory) + if err != nil { + return err + } + traversalOptions.gitIgnore = gitIgnore + } + var fileCount, dirCount int if _, err := fmt.Fprintln(output, dot); err != nil { return err } if options.Flags.ShowTreeView { var err error - fileCount, dirCount, err = treeDirectory(options, output, "", true) + fileCount, dirCount, err = treeDirectory(traversalOptions, output, "", true) if err != nil { return err } } else { var err error - fileCount, dirCount, err = listDirectory(options, output) + fileCount, dirCount, err = listDirectory(traversalOptions, output) if err != nil { return err } @@ -54,7 +68,7 @@ func dispatcher(options module.Options, output io.Writer) error { } // listDirectory func - lists the content of the directory. -func listDirectory(options module.Options, output io.Writer) (fileCount, dirCount int, err error) { +func listDirectory(options directoryOptions, output io.Writer) (fileCount, dirCount int, err error) { // Open and read the directory files, d, err := readDirectory(options.Directory) if err != nil { @@ -75,7 +89,7 @@ func listDirectory(options module.Options, output io.Writer) (fileCount, dirCoun var maxLen int for _, file := range files { - if !isHidden(file.Name()) || options.Flags.ShowHidden { + if shouldShowFile(file, options) { if file.IsDir() { dirCount++ } else { @@ -103,7 +117,7 @@ func listDirectory(options module.Options, output io.Writer) (fileCount, dirCoun } // treeDirectory func - displays a tree view of the directory. -func treeDirectory(options module.Options, output io.Writer, indent string, isLastFolder bool) (fileCount, dirCount int, err error) { +func treeDirectory(options directoryOptions, output io.Writer, indent string, isLastFolder bool) (fileCount, dirCount int, err error) { // Open and read the directory files, d, err := readDirectory(options.Directory) if err != nil { @@ -127,9 +141,9 @@ func treeDirectory(options module.Options, output io.Writer, indent string, isLa return fc, dc, nil } -func getLastVisibleIndex(files []os.FileInfo, showHidden bool) int { +func getLastVisibleIndex(files []os.FileInfo, options directoryOptions) int { for i := len(files) - 1; i >= 0; i-- { - if !isHidden(files[i].Name()) || showHidden { + if shouldShowFile(files[i], options) { return i } } @@ -137,10 +151,10 @@ func getLastVisibleIndex(files []os.FileInfo, showHidden bool) int { } // printFilesAndDirectoriesTreeFormat - prints files and directories in tree format -func printFilesAndDirectoriesTreeFormat(files []os.FileInfo, options module.Options, output io.Writer, indent string, isLastFolder bool) (fileCount, dirCount int, err error) { - lastVisibleIndex := getLastVisibleIndex(files, options.Flags.ShowHidden) +func printFilesAndDirectoriesTreeFormat(files []os.FileInfo, options directoryOptions, output io.Writer, indent string, isLastFolder bool) (fileCount, dirCount int, err error) { + lastVisibleIndex := getLastVisibleIndex(files, options) for i, file := range files { - if !shouldShowFile(file, options.Flags.ShowHidden) { + if !shouldShowFile(file, options) { continue } @@ -167,8 +181,17 @@ func printFilesAndDirectoriesTreeFormat(files []os.FileInfo, options module.Opti } // shouldShowFile determines if a file should be displayed based on visibility settings -func shouldShowFile(file os.FileInfo, showHidden bool) bool { - return !isHidden(file.Name()) || showHidden +func shouldShowFile(file os.FileInfo, options directoryOptions) bool { + if isHidden(file.Name()) && !options.Flags.ShowHidden { + return false + } + + if options.gitIgnore == nil { + return true + } + + filePath := filepath.Join(options.Directory, file.Name()) + return !options.gitIgnore.ignores(filePath, file.IsDir()) } // calculateIndent returns the appropriate prefix and child indent strings @@ -201,7 +224,7 @@ func formatFileWithOptions(prefix string, file os.FileInfo, flags module.Flags) } // processDirectory recursively processes a subdirectory -func processDirectory(file os.FileInfo, options module.Options, output io.Writer, childIndent string, isLastFolder bool) (fileCount, dirCount int, err error) { +func processDirectory(file os.FileInfo, options directoryOptions, output io.Writer, childIndent string, isLastFolder bool) (fileCount, dirCount int, err error) { newOpts := options newOpts.Directory = filepath.Join(options.Directory, file.Name()) return treeDirectory(newOpts, output, childIndent, isLastFolder) diff --git a/service/service_test.go b/service/service_test.go index 2995193..bf3c48a 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -104,6 +104,132 @@ func TestDispatcher_TreeDirectory(t *testing.T) { } } +func TestDispatcher_ListDirectoryGitIgnore(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".gitignore"), "*.log\nignored-dir/\n!keep.log\n") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + mustWriteFile(t, filepath.Join(dir, "debug.log"), "debug") + mustWriteFile(t, filepath.Join(dir, "keep.log"), "keep") + mustMkdir(t, filepath.Join(dir, "ignored-dir")) + mustWriteFile(t, filepath.Join(dir, "ignored-dir", "ignored.go"), "package ignored") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + RespectGitIgnore: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + for _, want := range []string{"main.go", "keep.log", "0 directories, 2 files"} { + if !strings.Contains(got, want) { + t.Fatalf("dispatcher() output = %q, want to contain %q", got, want) + } + } + for _, missing := range []string{"debug.log", "ignored-dir"} { + if strings.Contains(got, missing) { + t.Fatalf("dispatcher() output = %q, want not to contain %q", got, missing) + } + } +} + +func TestDispatcher_TreeDirectoryGitIgnore(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".gitignore"), "node_modules/\ndist/*.js\n*.log\n!keep.log\n") + mustMkdir(t, filepath.Join(dir, "node_modules")) + mustWriteFile(t, filepath.Join(dir, "node_modules", "package.js"), "module") + mustMkdir(t, filepath.Join(dir, "dist")) + mustWriteFile(t, filepath.Join(dir, "dist", "app.js"), "app") + mustWriteFile(t, filepath.Join(dir, "dist", "style.css"), "style") + mustMkdir(t, filepath.Join(dir, "src")) + mustWriteFile(t, filepath.Join(dir, "src", "main.go"), "package main") + mustWriteFile(t, filepath.Join(dir, "debug.log"), "debug") + mustWriteFile(t, filepath.Join(dir, "keep.log"), "keep") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + ShowTreeView: true, + RespectGitIgnore: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + for _, want := range []string{"dist", "style.css", "src", "main.go", "keep.log", "2 directories, 3 files"} { + if !strings.Contains(got, want) { + t.Fatalf("dispatcher() output = %q, want to contain %q", got, want) + } + } + for _, missing := range []string{"node_modules", "app.js", "debug.log"} { + if strings.Contains(got, missing) { + t.Fatalf("dispatcher() output = %q, want not to contain %q", got, missing) + } + } +} + +func TestDispatcher_GitIgnoreMissingFile(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + RespectGitIgnore: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + if !strings.Contains(got, "main.go") { + t.Fatalf("dispatcher() output = %q, want to contain main.go", got) + } +} + +func TestDispatcher_GitIgnoreWithHiddenFiles(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, ".gitignore"), ".env\n") + mustWriteFile(t, filepath.Join(dir, ".env"), "secret") + mustWriteFile(t, filepath.Join(dir, ".hidden"), "hidden") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + ShowHidden: true, + RespectGitIgnore: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + for _, want := range []string{".gitignore", ".hidden", "main.go", "0 directories, 3 files"} { + if !strings.Contains(got, want) { + t.Fatalf("dispatcher() output = %q, want to contain %q", got, want) + } + } + if strings.Contains(got, ".env") { + t.Fatalf("dispatcher() output = %q, want not to contain ignored .env", got) + } +} + func TestHumanReadableSize(t *testing.T) { tests := []struct { name string