diff --git a/README.md b/README.md index 00d811a..5f53fb1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ treels --tree --dirs-only # show directory structure only treels --long --readable # detailed listing with readable sizes treels --include "*.go" # show only matching files treels --exclude "*.log" # hide matching files +treels --git-status # show Git state next to entries treels --sort size --reverse # sort entries by largest first treels --dirs-first # group directories before files treels --json # machine-readable output @@ -82,6 +83,7 @@ go build . | Detailed metadata | `-l`, `--long` | | Human-readable sizes | `-r`, `--readable` | | Include/exclude filters | `--include PATTERN`, `--exclude PATTERN` | +| Git status decorations | `--git-status` | | Sorting | `--sort name|size|modified|type`, `--reverse`, `--dirs-first` | | JSON output | `--json` | | Hidden files | `-a`, `--all` | diff --git a/cmd/flag.go b/cmd/flag.go index 58688e4..b704a44 100644 --- a/cmd/flag.go +++ b/cmd/flag.go @@ -16,6 +16,7 @@ func FlagDefinition(cmd *cobra.Command, flags *module.Flags) { cmd.PersistentFlags().BoolVar(&flags.HideIcon, "no-icons", false, "Disable icons") cmd.PersistentFlags().BoolVar(&flags.HideSummary, "no-summary", false, "Hide the final file and directory count") cmd.PersistentFlags().BoolVar(&flags.RespectGitIgnore, "gitignore", false, "Respect .gitignore rules") + cmd.PersistentFlags().BoolVar(&flags.ShowGitStatus, "git-status", false, "Show git status symbols next to entries") cmd.PersistentFlags().StringArrayVar(&flags.IncludePatterns, "include", nil, "Show only entries matching a glob pattern; can be used multiple times") cmd.PersistentFlags().StringArrayVar(&flags.ExcludePatterns, "exclude", nil, "Hide entries matching a glob pattern; can be used multiple times") cmd.PersistentFlags().StringVar(&flags.SortBy, "sort", "name", "Sort entries by name, size, modified, or type") diff --git a/docs/development.md b/docs/development.md index 38cdc46..5f84f86 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,6 +35,7 @@ go run . go run . --tree --depth 2 --no-icons go run . --long --readable go run . --include "*.go" --exclude "*_test.go" +go run . --tree --git-status --no-icons ``` ## Build diff --git a/docs/icons.md b/docs/icons.md index 32e71a8..1323fa4 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -39,7 +39,17 @@ This is also useful for: ## Colors -`treels` applies ANSI colors to icons and directory names in text output. +`treels` applies ANSI colors to icons, directory names, and `--git-status` symbols in text output. + +Git status colors are: + +| Symbol | Color | +| --- | --- | +| `M` | Yellow | +| `A` | Green | +| `D` | Red | +| `?` | Cyan | +| `!` | Grey | JSON output never includes icons or ANSI color codes. diff --git a/docs/json-output.md b/docs/json-output.md index 0f37883..790153c 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -137,6 +137,7 @@ Text formatting flags do not affect JSON output: | `--long` | No effect. | | `--readable` | No effect; sizes remain raw bytes. | | `--no-icons` | No effect. | +| `--git-status` | No effect; Git status decorations are text-only. | | `--no-summary` | No effect; JSON always includes `summary`. | ## Stability notes diff --git a/docs/usage.md b/docs/usage.md index 8e7bd1f..aef083e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -149,6 +149,23 @@ treels --exclude "vendor/**" treels --tree --include "*.go" --exclude "vendor/**" ``` +### Show Git status decorations + +```bash +treels --tree --git-status --no-icons +``` + +Example symbols: + +| Symbol | Meaning | Color | +| --- | --- | --- | +| `M` | Modified | Yellow | +| `A` | Added | Green | +| `D` | Deleted | Red | +| `?` | Untracked | Cyan | +| `!` | Ignored | Grey | +| space | Clean or no Git status | Uncolored | + ### Sort by largest files first ```bash @@ -184,6 +201,7 @@ treels --json | `--gitignore` | Respect `.gitignore` rules from the target directory. | | `--include PATTERN` | Show only entries matching a glob pattern. Can be used multiple times. | | `--exclude PATTERN` | Hide entries matching a glob pattern. Can be used multiple times. | +| `--git-status` | Show Git status symbols next to entries in text output. | | `--json` | Output machine-readable JSON. | | `-l`, `--long` | Show detailed file metadata. | | `--sort name|size|modified|type` | Sort entries by name, size, modification time, or file type. Defaults to `name`. | @@ -206,6 +224,7 @@ treels --json | `--include "*.go" --include "*.md"` | Shows entries matching either include pattern. | | `--include "*.go" --exclude "vendor/**"` | Shows Go files except entries under `vendor`. | | `--tree --include "*.go"` | Keeps parent directories visible when they contain included files. | +| `--tree --git-status` | Shows tree branches plus Git status symbols. | | `--sort size --reverse` | Shows largest entries first. | | `--sort modified --reverse` | Shows newest entries first. | | `--dirs-first --reverse` | Keeps directories grouped first, then reverses the selected sort within each group. | diff --git a/module/types.go b/module/types.go index 58af662..cb4619b 100644 --- a/module/types.go +++ b/module/types.go @@ -13,6 +13,7 @@ type Flags struct { ShowLongFormat bool HideSummary bool RespectGitIgnore bool + ShowGitStatus bool IncludePatterns []string ExcludePatterns []string SortBy string diff --git a/service/gitstatus.go b/service/gitstatus.go new file mode 100644 index 0000000..c412f10 --- /dev/null +++ b/service/gitstatus.go @@ -0,0 +1,153 @@ +package service + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type gitStatusMatcher struct { + root string + statuses map[string]string +} + +type deletedGitFileInfo struct { + name string +} + +func (f deletedGitFileInfo) Name() string { return f.name } +func (f deletedGitFileInfo) Size() int64 { return 0 } +func (f deletedGitFileInfo) Mode() os.FileMode { return 0 } +func (f deletedGitFileInfo) ModTime() time.Time { return time.Time{} } +func (f deletedGitFileInfo) IsDir() bool { return false } +func (f deletedGitFileInfo) Sys() interface{} { return nil } + +var gitStatusCommand = func(root string) ([]byte, error) { + return exec.Command("git", "-C", root, "status", "--porcelain=v1", "--ignored=matching", "--untracked-files=all").Output() +} + +func newGitStatusMatcher(root string) *gitStatusMatcher { + output, err := gitStatusCommand(root) + if err != nil { + return nil + } + + statuses := parseGitStatusOutput(string(output)) + if len(statuses) == 0 { + return nil + } + + return &gitStatusMatcher{ + root: root, + statuses: statuses, + } +} + +func parseGitStatusOutput(output string) map[string]string { + statuses := make(map[string]string) + for _, line := range strings.Split(output, "\n") { + code, path, ok := parseGitStatusLine(line) + if !ok { + continue + } + + if symbol := gitStatusSymbol(code); symbol != "" { + statuses[normalizeGitStatusPath(path)] = symbol + } + } + return statuses +} + +func parseGitStatusLine(line string) (code, path string, ok bool) { + if len(line) < 4 { + return "", "", false + } + + code = line[:2] + path = strings.TrimSpace(line[3:]) + if path == "" { + return "", "", false + } + + if strings.Contains(path, " -> ") { + parts := strings.Split(path, " -> ") + path = parts[len(parts)-1] + } + + return code, path, true +} + +func gitStatusSymbol(code string) string { + if code == "??" { + return "?" + } + if code == "!!" { + return "!" + } + if strings.Contains(code, "D") { + return "D" + } + if strings.Contains(code, "A") { + return "A" + } + if strings.ContainsAny(code, "MRC") { + return "M" + } + return "" +} + +func normalizeGitStatusPath(path string) string { + path = strings.Trim(path, `"`) + path = filepath.ToSlash(path) + return strings.TrimRight(path, "/") +} + +func (m *gitStatusMatcher) appendDeletedFiles(directory string, files []os.FileInfo) []os.FileInfo { + if m == nil { + return files + } + + existingNames := make(map[string]struct{}, len(files)) + for _, file := range files { + existingNames[file.Name()] = struct{}{} + } + + for relPath, status := range m.statuses { + if status != "D" { + continue + } + + fullPath := filepath.Join(m.root, filepath.FromSlash(relPath)) + if filepath.Dir(fullPath) != directory { + continue + } + + name := filepath.Base(fullPath) + if _, exists := existingNames[name]; exists { + continue + } + files = append(files, deletedGitFileInfo{name: name}) + existingNames[name] = struct{}{} + } + + return files +} + +func (m *gitStatusMatcher) statusFor(filePath string) string { + if m == nil { + return "" + } + + relPath, err := filepath.Rel(m.root, filePath) + if err != nil { + return "" + } + relPath = filepath.ToSlash(relPath) + if relPath == "." || strings.HasPrefix(relPath, "../") { + return "" + } + + return m.statuses[normalizeGitStatusPath(relPath)] +} diff --git a/service/json.go b/service/json.go index b63afdc..5366f15 100644 --- a/service/json.go +++ b/service/json.go @@ -73,6 +73,7 @@ func collectJSONFlatEntries(options directoryOptions) (entries []jsonEntry, summ } }() + files = options.gitStatus.appendDeletedFiles(options.Directory, files) sortSlice(files, options.Flags) for _, file := range files { if !shouldShowFile(file, options) { @@ -103,6 +104,7 @@ func collectJSONTreeEntries(options directoryOptions, depth int) (entries []json } }() + files = options.gitStatus.appendDeletedFiles(options.Directory, files) sortSlice(files, options.Flags) visibleFiles, err := visibleTreeFiles(files, options, depth) if err != nil { diff --git a/service/service.go b/service/service.go index 78e540e..948038b 100644 --- a/service/service.go +++ b/service/service.go @@ -24,6 +24,7 @@ type directoryOptions struct { module.Options root string gitIgnore *gitIgnoreMatcher + gitStatus *gitStatusMatcher } // Dispatcher func - executes function based on flags @@ -47,6 +48,9 @@ func dispatcher(options module.Options, output io.Writer) error { } traversalOptions.gitIgnore = gitIgnore } + if options.Flags.ShowGitStatus { + traversalOptions.gitStatus = newGitStatusMatcher(options.Directory) + } if options.Flags.ShowJSON { return printJSONDirectory(traversalOptions, output) @@ -90,6 +94,8 @@ func listDirectory(options directoryOptions, output io.Writer) (fileCount, dirCo } }() + files = options.gitStatus.appendDeletedFiles(options.Directory, files) + // sort files by requested order sortSlice(files, options.Flags) @@ -105,7 +111,7 @@ func listDirectory(options directoryOptions, output io.Writer) (fileCount, dirCo fileCount++ } - formatted := formatFileWithOptions("", file, options.Flags) + formatted := formatFileWithOptions(gitStatusPrefix("", file, options), file, options.Flags) entries = append(entries, formatted) @@ -152,6 +158,8 @@ func treeDirectory(options directoryOptions, output io.Writer, indent string, is } }() + files = options.gitStatus.appendDeletedFiles(options.Directory, files) + // Sort files by requested order sortSlice(files, options.Flags) @@ -183,7 +191,7 @@ func printFilesAndDirectoriesTreeFormat(files []os.FileInfo, options directoryOp isLast := i == lastVisibleIndex prefix, childIndent := calculateIndent(indent, isLast) - if err := printFileWithPrefix(output, prefix, file, options.Flags); err != nil { + if err := printFileWithPrefix(output, prefix, file, options); err != nil { return 0, 0, err } @@ -284,11 +292,41 @@ func calculateIndent(indent string, isLast bool) (prefix, childIndent string) { } // printFileWithPrefix prints the file with the given prefix and icon settings -func printFileWithPrefix(output io.Writer, prefix string, file os.FileInfo, flags module.Flags) error { - _, err := fmt.Fprintln(output, formatFileWithOptions(prefix, file, flags)) +func printFileWithPrefix(output io.Writer, prefix string, file os.FileInfo, options directoryOptions) error { + _, err := fmt.Fprintln(output, formatFileWithOptions(gitStatusPrefix(prefix, file, options), file, options.Flags)) return err } +func gitStatusPrefix(prefix string, file os.FileInfo, options directoryOptions) string { + if !options.Flags.ShowGitStatus { + return prefix + } + + filePath := filepath.Join(options.Directory, file.Name()) + symbol := options.gitStatus.statusFor(filePath) + if symbol == "" { + symbol = " " + } + return prefix + colorGitStatusSymbol(symbol) + " " +} + +func colorGitStatusSymbol(symbol string) string { + switch symbol { + case "M": + return module.Yellow + symbol + module.Reset + case "A": + return module.Green + symbol + module.Reset + case "D": + return module.Red + symbol + module.Reset + case "?": + return module.Cyan + symbol + module.Reset + case "!": + return module.Grey + symbol + module.Reset + default: + return symbol + } +} + func formatFileWithOptions(prefix string, file os.FileInfo, flags module.Flags) string { if flags.ShowLongFormat { return formatLongFileWithOptions(prefix, file, flags) diff --git a/service/service_test.go b/service/service_test.go index 8524821..a9a737b 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -124,6 +124,96 @@ func TestDispatcher_ListDirectory(t *testing.T) { } } +func TestDispatcher_ListDirectoryGitStatus(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "README.md"), "readme") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + mustWriteFile(t, filepath.Join(dir, "new-file.go"), "package main") + mustWriteFile(t, filepath.Join(dir, "ignored.log"), "ignored") + + restore := stubGitStatusCommand(t, " M README.md\n?? new-file.go\n!! ignored.log\n D deleted.go\n") + defer restore() + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + ShowGitStatus: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + for _, want := range []string{"D deleted.go", "M README.md", "? new-file.go", "! ignored.log", " main.go", "0 directories, 5 files"} { + if !strings.Contains(got, want) { + t.Fatalf("dispatcher() output = %q, want to contain %q", got, want) + } + } +} + +func TestDispatcher_TreeDirectoryGitStatus(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "README.md"), "readme") + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + mustWriteFile(t, filepath.Join(dir, "new-file.go"), "package main") + + restore := stubGitStatusCommand(t, " M README.md\n?? new-file.go\n D deleted.go\n") + defer restore() + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + ShowTreeView: true, + ShowGitStatus: true, + }, + }, &output) + if err != nil { + t.Fatalf("dispatcher() error = %v, want nil", err) + } + + got := stripANSI(output.String()) + for _, want := range []string{"├── D deleted.go", "├── main.go", "├── ? new-file.go", "└── M README.md"} { + if !strings.Contains(got, want) { + t.Fatalf("dispatcher() output = %q, want to contain %q", got, want) + } + } +} + +func TestDispatcher_GitStatusCommandErrorIsNoop(t *testing.T) { + dir := t.TempDir() + mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") + + original := gitStatusCommand + gitStatusCommand = func(string) ([]byte, error) { + return nil, os.ErrNotExist + } + defer func() { + gitStatusCommand = original + }() + + var output bytes.Buffer + err := dispatcher(module.Options{ + Directory: dir, + Flags: module.Flags{ + HideIcon: true, + ShowGitStatus: 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 clean git status spacing", got) + } +} + func TestDispatcher_ListDirectoryIncludeExclude(t *testing.T) { dir := t.TempDir() mustWriteFile(t, filepath.Join(dir, "main.go"), "package main") @@ -904,6 +994,203 @@ func TestDispatcher_GitIgnoreWithHiddenFiles(t *testing.T) { } } +func TestGitStatusParsing(t *testing.T) { + statuses := parseGitStatusOutput(strings.Join([]string{ + " M README.md", + "A added.go", + " D deleted.go", + "?? new-file.go", + "!! ignored.log", + "R old.go -> renamed.go", + "C source.go -> copied.go", + "", + "x", + }, "\n")) + + want := map[string]string{ + "README.md": "M", + "added.go": "A", + "deleted.go": "D", + "new-file.go": "?", + "ignored.log": "!", + "renamed.go": "M", + "copied.go": "M", + } + if len(statuses) != len(want) { + t.Fatalf("parseGitStatusOutput() length = %d, want %d; got %+v", len(statuses), len(want), statuses) + } + for path, symbol := range want { + if statuses[path] != symbol { + t.Fatalf("status for %q = %q, want %q; got %+v", path, statuses[path], symbol, statuses) + } + } +} + +func TestGitStatusMatcher(t *testing.T) { + root := t.TempDir() + matcher := &gitStatusMatcher{ + root: root, + statuses: map[string]string{ + "cmd/main.go": "M", + }, + } + + if got := matcher.statusFor(filepath.Join(root, "cmd", "main.go")); got != "M" { + t.Fatalf("statusFor() = %q, want M", got) + } + if got := matcher.statusFor(filepath.Join(root, "README.md")); got != "" { + t.Fatalf("statusFor() = %q, want empty status", got) + } + if got := matcher.statusFor(filepath.Join(t.TempDir(), "outside.go")); got != "" { + t.Fatalf("outside root statusFor() = %q, want empty status", got) + } + errMatcher := &gitStatusMatcher{root: ""} + if got := errMatcher.statusFor(filepath.Join(root, "cmd", "main.go")); got != "" { + t.Fatalf("invalid root statusFor() = %q, want empty status", got) + } + if got := (*gitStatusMatcher)(nil).statusFor(filepath.Join(root, "cmd", "main.go")); got != "" { + t.Fatalf("nil statusFor() = %q, want empty status", got) + } +} + +func TestNewGitStatusMatcher(t *testing.T) { + original := gitStatusCommand + defer func() { + gitStatusCommand = original + }() + + t.Run("returns matcher when command has statuses", func(t *testing.T) { + gitStatusCommand = func(root string) ([]byte, error) { + return []byte(" M README.md\n"), nil + } + + matcher := newGitStatusMatcher(t.TempDir()) + if matcher == nil { + t.Fatal("newGitStatusMatcher() = nil, want matcher") + } + if got := matcher.statuses["README.md"]; got != "M" { + t.Fatalf("newGitStatusMatcher().statuses[README.md] = %q, want M", got) + } + }) + + t.Run("returns nil when command has no statuses", func(t *testing.T) { + gitStatusCommand = func(root string) ([]byte, error) { + return []byte("\n"), nil + } + + if matcher := newGitStatusMatcher(t.TempDir()); matcher != nil { + t.Fatalf("newGitStatusMatcher() = %+v, want nil", matcher) + } + }) +} + +func TestDeletedGitFileInfo(t *testing.T) { + file := deletedGitFileInfo{name: "deleted.go"} + + if file.Name() != "deleted.go" { + t.Fatalf("Name() = %q, want deleted.go", file.Name()) + } + if file.Size() != 0 { + t.Fatalf("Size() = %d, want 0", file.Size()) + } + if file.Mode() != 0 { + t.Fatalf("Mode() = %v, want 0", file.Mode()) + } + if !file.ModTime().IsZero() { + t.Fatalf("ModTime() = %v, want zero time", file.ModTime()) + } + if file.IsDir() { + t.Fatal("IsDir() = true, want false") + } + if file.Sys() != nil { + t.Fatalf("Sys() = %v, want nil", file.Sys()) + } +} + +func TestGitStatusMatcherAppendDeletedFiles(t *testing.T) { + root := t.TempDir() + cmdDir := filepath.Join(root, "cmd") + matcher := &gitStatusMatcher{ + root: root, + statuses: map[string]string{ + "deleted.go": "D", + "existing.go": "D", + "cmd/deleted.go": "D", + "cmd/modified.go": "M", + }, + } + + files := matcher.appendDeletedFiles(root, []os.FileInfo{fakeFileInfo{name: "existing.go"}}) + if got, want := fileInfoNames(files), []string{"existing.go", "deleted.go"}; !equalStringSlices(got, want) { + t.Fatalf("appendDeletedFiles(root) names = %v, want %v", got, want) + } + + files = matcher.appendDeletedFiles(cmdDir, nil) + if got, want := fileInfoNames(files), []string{"deleted.go"}; !equalStringSlices(got, want) { + t.Fatalf("appendDeletedFiles(cmdDir) names = %v, want %v", got, want) + } + + files = (*gitStatusMatcher)(nil).appendDeletedFiles(root, []os.FileInfo{fakeFileInfo{name: "main.go"}}) + if got, want := fileInfoNames(files), []string{"main.go"}; !equalStringSlices(got, want) { + t.Fatalf("nil appendDeletedFiles() names = %v, want %v", got, want) + } +} + +func TestColorGitStatusSymbol(t *testing.T) { + tests := []struct { + symbol string + want string + }{ + {symbol: "M", want: module.Yellow + "M" + module.Reset}, + {symbol: "A", want: module.Green + "A" + module.Reset}, + {symbol: "D", want: module.Red + "D" + module.Reset}, + {symbol: "?", want: module.Cyan + "?" + module.Reset}, + {symbol: "!", want: module.Grey + "!" + module.Reset}, + {symbol: " ", want: " "}, + } + + for _, tt := range tests { + t.Run(tt.symbol, func(t *testing.T) { + if got := colorGitStatusSymbol(tt.symbol); got != tt.want { + t.Fatalf("colorGitStatusSymbol(%q) = %q, want %q", tt.symbol, got, tt.want) + } + }) + } +} + +func TestGitStatusHelpers(t *testing.T) { + tests := []struct { + code string + want string + }{ + {code: "??", want: "?"}, + {code: "!!", want: "!"}, + {code: " D", want: "D"}, + {code: "A ", want: "A"}, + {code: " M", want: "M"}, + {code: "R ", want: "M"}, + {code: " ", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + if got := gitStatusSymbol(tt.code); got != tt.want { + t.Fatalf("gitStatusSymbol(%q) = %q, want %q", tt.code, got, tt.want) + } + }) + } + + if got := normalizeGitStatusPath(`"vendor/"`); got != "vendor" { + t.Fatalf("normalizeGitStatusPath() = %q, want vendor", got) + } + if _, _, ok := parseGitStatusLine("x"); ok { + t.Fatal("parseGitStatusLine(short) ok = true, want false") + } + if _, _, ok := parseGitStatusLine(" M "); ok { + t.Fatal("parseGitStatusLine(empty path) ok = true, want false") + } +} + func TestFilterPatternMatching(t *testing.T) { root := t.TempDir() tests := []struct { @@ -1988,6 +2275,17 @@ func mustChtimes(t *testing.T, path string, timestamp time.Time) { } } +func stubGitStatusCommand(t *testing.T, output string) func() { + t.Helper() + original := gitStatusCommand + gitStatusCommand = func(string) ([]byte, error) { + return []byte(output), nil + } + return func() { + gitStatusCommand = original + } +} + func assertOutputOrder(t *testing.T, output string, orderedNames []string) { t.Helper() previousIndex := -1 @@ -2011,6 +2309,18 @@ func fileInfoNames(files []os.FileInfo) []string { return names } +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func findJSONEntry(entries []jsonEntry, name string) (jsonEntry, bool) { for _, entry := range entries { if entry.Name == name {