A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(command): port tree from just-bash

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 0c4f40f0 410becb6

+488
+239
command/internal/tree/tree.go
··· 1 + package tree 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "sort" 10 + "strings" 11 + 12 + "github.com/pborman/getopt/v2" 13 + "mvdan.cc/sh/v3/interp" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + type Impl struct{} 18 + 19 + type treeOptions struct { 20 + showHidden bool 21 + directoriesOnly bool 22 + fullPath bool 23 + hasMaxDepth bool 24 + maxDepth int 25 + } 26 + 27 + type treeResult struct { 28 + output strings.Builder 29 + stderr strings.Builder 30 + dirCount int 31 + fileCount int 32 + } 33 + 34 + func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 35 + if ec == nil { 36 + return errors.New("tree: nil ExecContext") 37 + } 38 + if ec.FS == nil { 39 + return errors.New("tree: ExecContext has no filesystem") 40 + } 41 + 42 + stdout := ec.Stdout 43 + if stdout == nil { 44 + stdout = io.Discard 45 + } 46 + stderr := ec.Stderr 47 + if stderr == nil { 48 + stderr = io.Discard 49 + } 50 + 51 + set := getopt.New() 52 + set.SetProgram("tree") 53 + set.SetParameters("[DIRECTORY]...") 54 + 55 + usage := func() { 56 + fmt.Fprint(stderr, "Usage: tree [OPTION]... [DIRECTORY]...\n") 57 + fmt.Fprint(stderr, "List contents of directories in a tree-like format.\n\n") 58 + fmt.Fprint(stderr, " -a include hidden files\n") 59 + fmt.Fprint(stderr, " -d list directories only\n") 60 + fmt.Fprint(stderr, " -L LEVEL limit depth of directory tree\n") 61 + fmt.Fprint(stderr, " -f print full path prefix for each file\n") 62 + fmt.Fprint(stderr, " --help display this help and exit\n") 63 + } 64 + set.SetUsage(usage) 65 + 66 + showHidden := set.Bool('a', "include hidden files") 67 + directoriesOnly := set.Bool('d', "list directories only") 68 + fullPath := set.Bool('f', "print full path prefix for each file") 69 + maxDepthFlag := set.Int('L', 0, "limit depth of directory tree") 70 + help := set.BoolLong("help", 0, "display this help and exit") 71 + 72 + if err := set.Getopt(append([]string{"tree"}, args...), nil); err != nil { 73 + fmt.Fprintf(stderr, "tree: %s\n", err) 74 + usage() 75 + return interp.ExitStatus(1) 76 + } 77 + if *help { 78 + usage() 79 + return nil 80 + } 81 + 82 + opts := treeOptions{ 83 + showHidden: *showHidden, 84 + directoriesOnly: *directoriesOnly, 85 + fullPath: *fullPath, 86 + } 87 + if set.Lookup('L').Seen() { 88 + opts.hasMaxDepth = true 89 + opts.maxDepth = *maxDepthFlag 90 + } 91 + 92 + dirs := set.Args() 93 + if len(dirs) == 0 { 94 + dirs = []string{"."} 95 + } 96 + 97 + var result treeResult 98 + for _, d := range dirs { 99 + walkRoot(ec, &opts, &result, d) 100 + } 101 + 102 + dirWord := "directories" 103 + if result.dirCount == 1 { 104 + dirWord = "directory" 105 + } 106 + result.output.WriteString("\n") 107 + fmt.Fprintf(&result.output, "%d %s", result.dirCount, dirWord) 108 + if !opts.directoriesOnly { 109 + fileWord := "files" 110 + if result.fileCount == 1 { 111 + fileWord = "file" 112 + } 113 + fmt.Fprintf(&result.output, ", %d %s", result.fileCount, fileWord) 114 + } 115 + result.output.WriteString("\n") 116 + 117 + io.WriteString(stdout, result.output.String()) 118 + io.WriteString(stderr, result.stderr.String()) 119 + 120 + if result.stderr.Len() > 0 { 121 + return interp.ExitStatus(1) 122 + } 123 + return nil 124 + } 125 + 126 + func walkRoot(ec *command.ExecContext, opts *treeOptions, result *treeResult, displayPath string) { 127 + fsPath := resolvePath(ec, displayPath) 128 + info, err := ec.FS.Stat(fsPath) 129 + if err != nil { 130 + fmt.Fprintf(&result.stderr, "tree: %s: No such file or directory\n", displayPath) 131 + return 132 + } 133 + 134 + result.output.WriteString(displayPath) 135 + result.output.WriteString("\n") 136 + 137 + if !info.IsDir() { 138 + result.fileCount++ 139 + return 140 + } 141 + 142 + walk(ec, opts, result, displayPath, fsPath, "", 0) 143 + } 144 + 145 + func walk(ec *command.ExecContext, opts *treeOptions, result *treeResult, 146 + displayPath, fsPath, prefix string, depth int, 147 + ) { 148 + if opts.hasMaxDepth && depth >= opts.maxDepth { 149 + return 150 + } 151 + 152 + entries, err := ec.FS.ReadDir(fsPath) 153 + if err != nil { 154 + return 155 + } 156 + 157 + type entryInfo struct { 158 + name string 159 + isDir bool 160 + } 161 + infos := make([]entryInfo, 0, len(entries)) 162 + for _, e := range entries { 163 + name := e.Name() 164 + if !opts.showHidden && strings.HasPrefix(name, ".") { 165 + continue 166 + } 167 + isDir := e.IsDir() 168 + if opts.directoriesOnly && !isDir { 169 + continue 170 + } 171 + infos = append(infos, entryInfo{name: name, isDir: isDir}) 172 + } 173 + 174 + sort.Slice(infos, func(i, j int) bool { 175 + return infos[i].name < infos[j].name 176 + }) 177 + 178 + for i, entry := range infos { 179 + isLast := i == len(infos)-1 180 + var connector, childSuffix string 181 + if isLast { 182 + connector = "`-- " 183 + childSuffix = " " 184 + } else { 185 + connector = "|-- " 186 + childSuffix = "| " 187 + } 188 + 189 + entryFS := path.Join(fsPath, entry.name) 190 + entryDisplay := joinDisplay(displayPath, entry.name) 191 + 192 + shown := entry.name 193 + if opts.fullPath { 194 + shown = entryDisplay 195 + } 196 + 197 + result.output.WriteString(prefix) 198 + result.output.WriteString(connector) 199 + result.output.WriteString(shown) 200 + result.output.WriteString("\n") 201 + 202 + if entry.isDir { 203 + result.dirCount++ 204 + walk(ec, opts, result, entryDisplay, entryFS, prefix+childSuffix, depth+1) 205 + } else { 206 + result.fileCount++ 207 + } 208 + } 209 + } 210 + 211 + func joinDisplay(parent, name string) string { 212 + switch parent { 213 + case "", ".": 214 + return "./" + name 215 + case "/": 216 + return "/" + name 217 + default: 218 + return parent + "/" + name 219 + } 220 + } 221 + 222 + func resolvePath(ec *command.ExecContext, p string) string { 223 + dir := ec.Dir 224 + if dir == "" { 225 + dir = "." 226 + } 227 + if path.IsAbs(p) { 228 + p = strings.TrimPrefix(p, "/") 229 + if p == "" { 230 + return "." 231 + } 232 + return path.Clean(p) 233 + } 234 + joined := path.Join(dir, p) 235 + if joined == "" { 236 + return "." 237 + } 238 + return joined 239 + }
+249
command/internal/tree/tree_test.go
··· 1 + package tree 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/go-git/go-billy/v5" 11 + "github.com/go-git/go-billy/v5/memfs" 12 + "tangled.org/xeiaso.net/kefka/command" 13 + ) 14 + 15 + func newTestFS(t *testing.T) billy.Filesystem { 16 + t.Helper() 17 + fs := memfs.New() 18 + write := func(name string, data []byte) { 19 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + f.Write(data) 24 + f.Close() 25 + } 26 + write("alpha.txt", []byte("a")) 27 + write("beta.txt", []byte("b")) 28 + write(".hidden", []byte("h")) 29 + write("sub/inner.txt", []byte("i")) 30 + write("sub/deeper/leaf.txt", []byte("L")) 31 + return fs 32 + } 33 + 34 + func TestExec(t *testing.T) { 35 + tests := []struct { 36 + name string 37 + dir string 38 + args []string 39 + wantStdout string 40 + wantStderr string 41 + wantErr bool 42 + }{ 43 + { 44 + name: "default tree from cwd", 45 + args: []string{}, 46 + wantStdout: ".\n" + 47 + "|-- alpha.txt\n" + 48 + "|-- beta.txt\n" + 49 + "`-- sub\n" + 50 + " |-- deeper\n" + 51 + " | `-- leaf.txt\n" + 52 + " `-- inner.txt\n" + 53 + "\n2 directories, 4 files\n", 54 + }, 55 + { 56 + name: "show hidden with -a", 57 + args: []string{"-a"}, 58 + wantStdout: ".\n" + 59 + "|-- .hidden\n" + 60 + "|-- alpha.txt\n" + 61 + "|-- beta.txt\n" + 62 + "`-- sub\n" + 63 + " |-- deeper\n" + 64 + " | `-- leaf.txt\n" + 65 + " `-- inner.txt\n" + 66 + "\n2 directories, 5 files\n", 67 + }, 68 + { 69 + name: "directories only with -d", 70 + args: []string{"-d"}, 71 + wantStdout: ".\n" + 72 + "`-- sub\n" + 73 + " `-- deeper\n" + 74 + "\n2 directories\n", 75 + }, 76 + { 77 + name: "limit depth with -L 1", 78 + args: []string{"-L", "1"}, 79 + wantStdout: ".\n" + 80 + "|-- alpha.txt\n" + 81 + "|-- beta.txt\n" + 82 + "`-- sub\n" + 83 + "\n1 directory, 2 files\n", 84 + }, 85 + { 86 + name: "limit depth with -L 2", 87 + args: []string{"-L", "2"}, 88 + wantStdout: ".\n" + 89 + "|-- alpha.txt\n" + 90 + "|-- beta.txt\n" + 91 + "`-- sub\n" + 92 + " |-- deeper\n" + 93 + " `-- inner.txt\n" + 94 + "\n2 directories, 3 files\n", 95 + }, 96 + { 97 + name: "full path with -f", 98 + args: []string{"-f"}, 99 + wantStdout: ".\n" + 100 + "|-- ./alpha.txt\n" + 101 + "|-- ./beta.txt\n" + 102 + "`-- ./sub\n" + 103 + " |-- ./sub/deeper\n" + 104 + " | `-- ./sub/deeper/leaf.txt\n" + 105 + " `-- ./sub/inner.txt\n" + 106 + "\n2 directories, 4 files\n", 107 + }, 108 + { 109 + name: "explicit subdirectory", 110 + args: []string{"sub"}, 111 + wantStdout: "sub\n" + 112 + "|-- deeper\n" + 113 + "| `-- leaf.txt\n" + 114 + "`-- inner.txt\n" + 115 + "\n1 directory, 2 files\n", 116 + }, 117 + { 118 + name: "single file argument", 119 + args: []string{"alpha.txt"}, 120 + wantStdout: "alpha.txt\n" + 121 + "\n0 directories, 1 file\n", 122 + }, 123 + { 124 + name: "missing path reports stderr and error", 125 + args: []string{"nope"}, 126 + wantStdout: "\n0 directories, 0 files\n", 127 + wantStderr: "tree: nope: No such file or directory\n", 128 + wantErr: true, 129 + }, 130 + { 131 + name: "multiple roots aggregate counts", 132 + args: []string{"sub", "sub/deeper"}, 133 + wantStdout: "sub\n" + 134 + "|-- deeper\n" + 135 + "| `-- leaf.txt\n" + 136 + "`-- inner.txt\n" + 137 + "sub/deeper\n" + 138 + "`-- leaf.txt\n" + 139 + "\n1 directory, 3 files\n", 140 + }, 141 + { 142 + name: "ec.Dir scopes the listing", 143 + dir: "sub", 144 + args: []string{}, 145 + wantStdout: ".\n" + 146 + "|-- deeper\n" + 147 + "| `-- leaf.txt\n" + 148 + "`-- inner.txt\n" + 149 + "\n1 directory, 2 files\n", 150 + }, 151 + { 152 + name: "unknown flag returns error", 153 + args: []string{"--no-such-flag"}, 154 + wantStderr: "tree: unknown option: --no-such-flag\n" + 155 + "Usage: tree [OPTION]... [DIRECTORY]...\n" + 156 + "List contents of directories in a tree-like format.\n\n" + 157 + " -a include hidden files\n" + 158 + " -d list directories only\n" + 159 + " -L LEVEL limit depth of directory tree\n" + 160 + " -f print full path prefix for each file\n" + 161 + " --help display this help and exit\n", 162 + wantErr: true, 163 + }, 164 + } 165 + 166 + for _, tc := range tests { 167 + t.Run(tc.name, func(t *testing.T) { 168 + var stdout, stderr bytes.Buffer 169 + dir := tc.dir 170 + if dir == "" { 171 + dir = "." 172 + } 173 + ec := &command.ExecContext{ 174 + Stdout: &stdout, 175 + Stderr: &stderr, 176 + Dir: dir, 177 + FS: newTestFS(t), 178 + } 179 + err := Impl{}.Exec(context.Background(), ec, tc.args) 180 + if tc.wantErr && err == nil { 181 + t.Fatalf("expected error, got nil") 182 + } 183 + if !tc.wantErr && err != nil { 184 + t.Fatalf("unexpected error: %v", err) 185 + } 186 + if got := stdout.String(); got != tc.wantStdout { 187 + t.Errorf("stdout mismatch\nwant:\n%q\ngot:\n%q", tc.wantStdout, got) 188 + } 189 + if got := stderr.String(); got != tc.wantStderr { 190 + t.Errorf("stderr mismatch\nwant:\n%q\ngot:\n%q", tc.wantStderr, got) 191 + } 192 + }) 193 + } 194 + } 195 + 196 + func TestExec_Help(t *testing.T) { 197 + var stdout, stderr bytes.Buffer 198 + ec := &command.ExecContext{ 199 + Stdout: &stdout, 200 + Stderr: &stderr, 201 + Dir: ".", 202 + FS: memfs.New(), 203 + } 204 + if err := (Impl{}).Exec(context.Background(), ec, []string{"--help"}); err != nil { 205 + t.Fatalf("unexpected error: %v", err) 206 + } 207 + if stdout.Len() != 0 { 208 + t.Errorf("expected no stdout output, got %q", stdout.String()) 209 + } 210 + if !strings.HasPrefix(stderr.String(), "Usage: tree") { 211 + t.Errorf("expected usage on stderr, got %q", stderr.String()) 212 + } 213 + } 214 + 215 + func TestExec_NilContext(t *testing.T) { 216 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 217 + t.Fatal("expected error for nil ExecContext") 218 + } 219 + } 220 + 221 + func TestExec_NoFS(t *testing.T) { 222 + ec := &command.ExecContext{ 223 + Stdout: &bytes.Buffer{}, 224 + Stderr: &bytes.Buffer{}, 225 + } 226 + if err := (Impl{}).Exec(context.Background(), ec, nil); err == nil { 227 + t.Fatal("expected error for missing filesystem") 228 + } 229 + } 230 + 231 + func TestResolvePath(t *testing.T) { 232 + tests := []struct { 233 + dir, in, want string 234 + }{ 235 + {"", "foo", "foo"}, 236 + {".", "foo", "foo"}, 237 + {".", ".", "."}, 238 + {"sub", "foo", "sub/foo"}, 239 + {"sub", ".", "sub"}, 240 + {".", "/abs/path", "abs/path"}, 241 + {".", "/", "."}, 242 + } 243 + for _, tc := range tests { 244 + ec := &command.ExecContext{Dir: tc.dir} 245 + if got := resolvePath(ec, tc.in); got != tc.want { 246 + t.Errorf("resolvePath(dir=%q, in=%q) = %q, want %q", tc.dir, tc.in, got, tc.want) 247 + } 248 + } 249 + }