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 du from just-bash

Estimate file space usage. Mirrors just-bash semantics including
1K block sizes by default with ceiling division, dot-relative
display paths, and the depth-first emission order where files
inside a directory print before their sibling subdirectories'
recursive output. The --max-depth flag gates both this directory's
total line and whether subdirectory output is included upstream,
matching the JS branching exactly.

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

Xe Iaso e512808a 3984d634

+461
+229
command/internal/du/du.go
··· 1 + package du 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "sort" 10 + "strconv" 11 + "strings" 12 + 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + type duOptions struct { 21 + allFiles bool 22 + humanReadable bool 23 + summarize bool 24 + grandTotal bool 25 + maxDepth int 26 + maxDepthSet bool 27 + } 28 + 29 + const maxRecursionDepth = 1000 30 + 31 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 32 + if ec == nil { 33 + return errors.New("du: nil ExecContext") 34 + } 35 + if ec.FS == nil { 36 + return errors.New("du: ExecContext has no filesystem") 37 + } 38 + 39 + stdout := ec.Stdout 40 + if stdout == nil { 41 + stdout = io.Discard 42 + } 43 + stderr := ec.Stderr 44 + if stderr == nil { 45 + stderr = io.Discard 46 + } 47 + 48 + set := getopt.New() 49 + set.SetProgram("du") 50 + set.SetParameters("[FILE]...") 51 + 52 + usage := func() { 53 + fmt.Fprint(stderr, "Usage: du [OPTION]... [FILE]...\n") 54 + fmt.Fprint(stderr, "Estimate file space usage.\n\n") 55 + fmt.Fprint(stderr, " -a write counts for all files, not just directories\n") 56 + fmt.Fprint(stderr, " -h print sizes in human readable format\n") 57 + fmt.Fprint(stderr, " -s display only a total for each argument\n") 58 + fmt.Fprint(stderr, " -c produce a grand total\n") 59 + fmt.Fprint(stderr, " --max-depth=N print total for directory only if N or fewer levels deep\n") 60 + fmt.Fprint(stderr, " --help display this help and exit\n") 61 + } 62 + set.SetUsage(usage) 63 + 64 + allFiles := set.Bool('a', "write counts for all files, not just directories") 65 + humanReadable := set.Bool('h', "print sizes in human readable format") 66 + summarize := set.Bool('s', "display only a total for each argument") 67 + grandTotal := set.Bool('c', "produce a grand total") 68 + maxDepth := set.IntLong("max-depth", 0, 0, "print total for directory only if N or fewer levels deep") 69 + help := set.BoolLong("help", 0, "display this help and exit") 70 + 71 + if err := set.Getopt(append([]string{"du"}, args...), nil); err != nil { 72 + fmt.Fprintf(stderr, "du: %s\n", err) 73 + usage() 74 + return interp.ExitStatus(1) 75 + } 76 + if *help { 77 + usage() 78 + return nil 79 + } 80 + 81 + opts := duOptions{ 82 + allFiles: *allFiles, 83 + humanReadable: *humanReadable, 84 + summarize: *summarize, 85 + grandTotal: *grandTotal, 86 + } 87 + if set.Lookup("max-depth").Seen() { 88 + opts.maxDepth = *maxDepth 89 + opts.maxDepthSet = true 90 + } 91 + 92 + targets := set.Args() 93 + if len(targets) == 0 { 94 + targets = []string{"."} 95 + } 96 + 97 + var stdoutBuf, stderrBuf strings.Builder 98 + var grand int64 99 + 100 + for _, target := range targets { 101 + full := resolvePath(ec, target) 102 + if _, err := ec.FS.Stat(full); err != nil { 103 + fmt.Fprintf(&stderrBuf, "du: cannot access '%s': No such file or directory\n", target) 104 + continue 105 + } 106 + out, total, errOut := calculateSize(ec, full, target, opts, 0) 107 + stdoutBuf.WriteString(out) 108 + stderrBuf.WriteString(errOut) 109 + grand += total 110 + } 111 + 112 + if opts.grandTotal && len(targets) > 0 { 113 + fmt.Fprintf(&stdoutBuf, "%s\ttotal\n", formatSize(grand, opts.humanReadable)) 114 + } 115 + 116 + io.WriteString(stdout, stdoutBuf.String()) 117 + io.WriteString(stderr, stderrBuf.String()) 118 + 119 + if stderrBuf.Len() > 0 { 120 + return interp.ExitStatus(1) 121 + } 122 + return nil 123 + } 124 + 125 + func calculateSize(ec *command.ExecContext, fullPath, displayPath string, opts duOptions, depth int) (string, int64, string) { 126 + if depth > maxRecursionDepth { 127 + return "", 0, "" 128 + } 129 + 130 + info, err := ec.FS.Stat(fullPath) 131 + if err != nil { 132 + return "", 0, fmt.Sprintf("du: cannot read directory '%s': Permission denied\n", displayPath) 133 + } 134 + 135 + if !info.IsDir() { 136 + size := info.Size() 137 + if opts.allFiles || depth == 0 { 138 + return formatSize(size, opts.humanReadable) + "\t" + displayPath + "\n", size, "" 139 + } 140 + return "", size, "" 141 + } 142 + 143 + entries, err := ec.FS.ReadDir(fullPath) 144 + if err != nil { 145 + return "", 0, fmt.Sprintf("du: cannot read directory '%s': Permission denied\n", displayPath) 146 + } 147 + 148 + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) 149 + 150 + var out, errOut strings.Builder 151 + var dirSize int64 152 + 153 + for _, e := range entries { 154 + if e.IsDir() { 155 + continue 156 + } 157 + size := e.Size() 158 + dirSize += size 159 + if opts.allFiles && !opts.summarize { 160 + fmt.Fprintf(&out, "%s\t%s\n", formatSize(size, opts.humanReadable), joinDisplay(displayPath, e.Name())) 161 + } 162 + } 163 + 164 + for _, e := range entries { 165 + if !e.IsDir() { 166 + continue 167 + } 168 + entryPath := path.Join(fullPath, e.Name()) 169 + entryDisplay := joinDisplay(displayPath, e.Name()) 170 + subOut, subTotal, subErr := calculateSize(ec, entryPath, entryDisplay, opts, depth+1) 171 + dirSize += subTotal 172 + errOut.WriteString(subErr) 173 + if !opts.summarize && (!opts.maxDepthSet || depth+1 <= opts.maxDepth) { 174 + out.WriteString(subOut) 175 + } 176 + } 177 + 178 + if opts.summarize || !opts.maxDepthSet || depth <= opts.maxDepth { 179 + fmt.Fprintf(&out, "%s\t%s\n", formatSize(dirSize, opts.humanReadable), displayPath) 180 + } 181 + 182 + return out.String(), dirSize, errOut.String() 183 + } 184 + 185 + func joinDisplay(displayPath, name string) string { 186 + if displayPath == "." { 187 + return name 188 + } 189 + return displayPath + "/" + name 190 + } 191 + 192 + func formatSize(bytes int64, humanReadable bool) string { 193 + if !humanReadable { 194 + v := (bytes + 1023) / 1024 195 + if v <= 0 { 196 + v = 1 197 + } 198 + return strconv.FormatInt(v, 10) 199 + } 200 + if bytes < 1024 { 201 + return strconv.FormatInt(bytes, 10) 202 + } 203 + if bytes < 1024*1024 { 204 + return fmt.Sprintf("%.1fK", float64(bytes)/1024) 205 + } 206 + if bytes < 1024*1024*1024 { 207 + return fmt.Sprintf("%.1fM", float64(bytes)/(1024*1024)) 208 + } 209 + return fmt.Sprintf("%.1fG", float64(bytes)/(1024*1024*1024)) 210 + } 211 + 212 + func resolvePath(ec *command.ExecContext, p string) string { 213 + dir := ec.Dir 214 + if dir == "" { 215 + dir = "." 216 + } 217 + if path.IsAbs(p) { 218 + p = strings.TrimPrefix(p, "/") 219 + if p == "" { 220 + return "." 221 + } 222 + return path.Clean(p) 223 + } 224 + joined := path.Join(dir, p) 225 + if joined == "" { 226 + return "." 227 + } 228 + return joined 229 + }
+230
command/internal/du/du_test.go
··· 1 + package du 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 + if _, err := f.Write(data); err != nil { 24 + t.Fatal(err) 25 + } 26 + f.Close() 27 + } 28 + write("file1.txt", bytes.Repeat([]byte("a"), 500)) 29 + write("file2.txt", bytes.Repeat([]byte("b"), 2000)) 30 + write("sub/inner.txt", bytes.Repeat([]byte("c"), 1024)) 31 + write("sub/deeper/leaf.txt", bytes.Repeat([]byte("d"), 3000)) 32 + return fs 33 + } 34 + 35 + func run(t *testing.T, args []string) (string, string, error) { 36 + t.Helper() 37 + var stdout, stderr bytes.Buffer 38 + ec := &command.ExecContext{ 39 + Stdout: &stdout, 40 + Stderr: &stderr, 41 + Dir: ".", 42 + FS: newTestFS(t), 43 + } 44 + err := Impl{}.Exec(context.Background(), ec, args) 45 + return stdout.String(), stderr.String(), err 46 + } 47 + 48 + func TestDu(t *testing.T) { 49 + tests := []struct { 50 + name string 51 + args []string 52 + wantStdout string 53 + wantStderr string 54 + wantErr bool 55 + skipStderrExact bool 56 + }{ 57 + { 58 + name: "default lists directory totals only", 59 + args: []string{}, 60 + wantStdout: "3\tsub/deeper\n4\tsub\n7\t.\n", 61 + }, 62 + { 63 + name: "all files prints every entry", 64 + args: []string{"-a"}, 65 + wantStdout: "1\tfile1.txt\n2\tfile2.txt\n1\tsub/inner.txt\n3\tsub/deeper/leaf.txt\n3\tsub/deeper\n4\tsub\n7\t.\n", 66 + }, 67 + { 68 + name: "summarize prints only top-level total", 69 + args: []string{"-s"}, 70 + wantStdout: "7\t.\n", 71 + }, 72 + { 73 + name: "grand total appends total line", 74 + args: []string{"-c"}, 75 + wantStdout: "3\tsub/deeper\n4\tsub\n7\t.\n7\ttotal\n", 76 + }, 77 + { 78 + name: "human readable formats sizes", 79 + args: []string{"-h"}, 80 + wantStdout: "2.9K\tsub/deeper\n3.9K\tsub\n6.4K\t.\n", 81 + }, 82 + { 83 + name: "max-depth=1 hides nested directory output", 84 + args: []string{"--max-depth=1"}, 85 + wantStdout: "4\tsub\n7\t.\n", 86 + }, 87 + { 88 + name: "max-depth=0 only top level", 89 + args: []string{"--max-depth=0"}, 90 + wantStdout: "7\t.\n", 91 + }, 92 + { 93 + name: "single file argument always prints", 94 + args: []string{"file1.txt"}, 95 + wantStdout: "1\tfile1.txt\n", 96 + }, 97 + { 98 + name: "single subdir argument with relative display path", 99 + args: []string{"sub"}, 100 + wantStdout: "3\tsub/deeper\n4\tsub\n", 101 + }, 102 + { 103 + name: "multiple targets keep command-line order", 104 + args: []string{"file1.txt", "sub"}, 105 + wantStdout: "1\tfile1.txt\n3\tsub/deeper\n4\tsub\n", 106 + }, 107 + { 108 + name: "summarize with grand total over multiple targets", 109 + args: []string{"-sc", "file1.txt", "sub"}, 110 + wantStdout: "1\tfile1.txt\n4\tsub\n5\ttotal\n", 111 + }, 112 + { 113 + name: "missing target reports stderr and errors", 114 + args: []string{"nope"}, 115 + wantStderr: "du: cannot access 'nope': No such file or directory\n", 116 + wantErr: true, 117 + }, 118 + { 119 + name: "good and missing targets emit both", 120 + args: []string{"file1.txt", "nope"}, 121 + wantStdout: "1\tfile1.txt\n", 122 + wantStderr: "du: cannot access 'nope': No such file or directory\n", 123 + wantErr: true, 124 + }, 125 + { 126 + name: "unknown flag returns error", 127 + args: []string{"--no-such-flag"}, 128 + wantErr: true, 129 + skipStderrExact: true, 130 + }, 131 + } 132 + 133 + for _, tt := range tests { 134 + t.Run(tt.name, func(t *testing.T) { 135 + stdout, stderr, err := run(t, tt.args) 136 + if tt.wantErr && err == nil { 137 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 138 + } 139 + if !tt.wantErr && err != nil { 140 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 141 + } 142 + if stdout != tt.wantStdout { 143 + t.Errorf("stdout mismatch\nwant:\n%q\ngot:\n%q", tt.wantStdout, stdout) 144 + } 145 + if !tt.skipStderrExact && stderr != tt.wantStderr { 146 + t.Errorf("stderr mismatch\nwant:\n%q\ngot:\n%q", tt.wantStderr, stderr) 147 + } 148 + }) 149 + } 150 + } 151 + 152 + func TestHelp(t *testing.T) { 153 + stdout, stderr, err := run(t, []string{"--help"}) 154 + if err != nil { 155 + t.Fatalf("unexpected error: %v", err) 156 + } 157 + if stdout != "" { 158 + t.Errorf("expected empty stdout, got %q", stdout) 159 + } 160 + if !strings.Contains(stderr, "Usage: du [OPTION]... [FILE]...") { 161 + t.Errorf("usage line missing from stderr: %q", stderr) 162 + } 163 + if !strings.Contains(stderr, "--max-depth") { 164 + t.Errorf("--max-depth missing from help output: %q", stderr) 165 + } 166 + } 167 + 168 + func TestExec_NilContext(t *testing.T) { 169 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 170 + t.Fatal("expected error for nil ExecContext") 171 + } 172 + } 173 + 174 + func TestExec_NoFS(t *testing.T) { 175 + ec := &command.ExecContext{ 176 + Stdout: &bytes.Buffer{}, 177 + Stderr: &bytes.Buffer{}, 178 + } 179 + if err := (Impl{}).Exec(context.Background(), ec, nil); err == nil { 180 + t.Fatal("expected error for missing filesystem") 181 + } 182 + } 183 + 184 + func TestFormatSize(t *testing.T) { 185 + tests := []struct { 186 + name string 187 + bytes int64 188 + human bool 189 + want string 190 + }{ 191 + {"zero blocks rounds up to 1", 0, false, "1"}, 192 + {"sub-block rounds up to 1", 500, false, "1"}, 193 + {"exact one block", 1024, false, "1"}, 194 + {"just over one block", 1025, false, "2"}, 195 + {"two blocks", 2048, false, "2"}, 196 + {"human under 1K shows bytes", 500, true, "500"}, 197 + {"human exact 1K", 1024, true, "1.0K"}, 198 + {"human 2K", 2048, true, "2.0K"}, 199 + {"human 1.5K", 1536, true, "1.5K"}, 200 + {"human 1M", 1024 * 1024, true, "1.0M"}, 201 + {"human 1G", 1024 * 1024 * 1024, true, "1.0G"}, 202 + } 203 + for _, tt := range tests { 204 + t.Run(tt.name, func(t *testing.T) { 205 + got := formatSize(tt.bytes, tt.human) 206 + if got != tt.want { 207 + t.Errorf("formatSize(%d, %v) = %q, want %q", tt.bytes, tt.human, got, tt.want) 208 + } 209 + }) 210 + } 211 + } 212 + 213 + func TestResolvePath(t *testing.T) { 214 + tests := []struct { 215 + dir, in, want string 216 + }{ 217 + {"", "foo", "foo"}, 218 + {".", "foo", "foo"}, 219 + {".", ".", "."}, 220 + {"sub", "foo", "sub/foo"}, 221 + {".", "/abs/path", "abs/path"}, 222 + {".", "/", "."}, 223 + } 224 + for _, tt := range tests { 225 + ec := &command.ExecContext{Dir: tt.dir} 226 + if got := resolvePath(ec, tt.in); got != tt.want { 227 + t.Errorf("resolvePath(dir=%q, in=%q) = %q, want %q", tt.dir, tt.in, got, tt.want) 228 + } 229 + } 230 + }
+2
command/registry/coreutils/coreutils.go
··· 11 11 "tangled.org/xeiaso.net/kefka/command/internal/date" 12 12 "tangled.org/xeiaso.net/kefka/command/internal/diff" 13 13 "tangled.org/xeiaso.net/kefka/command/internal/dirname" 14 + "tangled.org/xeiaso.net/kefka/command/internal/du" 14 15 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 15 16 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 16 17 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 29 30 reg.Register("date", date.Impl{}) 30 31 reg.Register("diff", diff.Impl{}) 31 32 reg.Register("dirname", dirname.Impl{}) 33 + reg.Register("du", du.Impl{}) 32 34 reg.Register("false", falsecmd.Impl{}) 33 35 reg.Register("hostname", hostname.Impl{}) 34 36 reg.Register("ls", ls.Impl{})