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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions cmd/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
31 changes: 30 additions & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions module/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type Flags struct {
HideIcon bool
ShowReadableSize bool
ShowVersion bool
RespectGitIgnore bool
}

// Options struct - Contains configuration options for directory listing.
Expand Down
192 changes: 192 additions & 0 deletions service/gitignore.go
Original file line number Diff line number Diff line change
@@ -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:])
}
49 changes: 36 additions & 13 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -127,20 +141,20 @@ 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
}
}
return -1
}

// 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
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading