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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions cmd/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion docs/icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/json-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`. |
Expand All @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions module/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Flags struct {
ShowLongFormat bool
HideSummary bool
RespectGitIgnore bool
ShowGitStatus bool
IncludePatterns []string
ExcludePatterns []string
SortBy string
Expand Down
153 changes: 153 additions & 0 deletions service/gitstatus.go
Original file line number Diff line number Diff line change
@@ -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)]
}
2 changes: 2 additions & 0 deletions service/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 42 additions & 4 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type directoryOptions struct {
module.Options
root string
gitIgnore *gitIgnoreMatcher
gitStatus *gitStatusMatcher
}

// Dispatcher func - executes function based on flags
Expand All @@ -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)
Expand Down Expand Up @@ -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)

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

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

Expand Down Expand Up @@ -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
}

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