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

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

Xe Iaso b05d06a4 a0052915

+434
+153
command/internal/rmdir/rmdir.go
··· 1 + package rmdir 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strings" 10 + 11 + "github.com/pborman/getopt/v2" 12 + "mvdan.cc/sh/v3/interp" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + type Impl struct{} 17 + 18 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 19 + if ec == nil { 20 + return errors.New("rmdir: nil ExecContext") 21 + } 22 + if ec.FS == nil { 23 + return errors.New("rmdir: ExecContext has no filesystem") 24 + } 25 + 26 + stdout := ec.Stdout 27 + if stdout == nil { 28 + stdout = io.Discard 29 + } 30 + stderr := ec.Stderr 31 + if stderr == nil { 32 + stderr = io.Discard 33 + } 34 + 35 + set := getopt.New() 36 + set.SetProgram("rmdir") 37 + set.SetParameters("DIRECTORY...") 38 + 39 + usage := func() { 40 + fmt.Fprint(stderr, "Usage: rmdir [-pv] DIRECTORY...\n") 41 + fmt.Fprint(stderr, "Remove empty directories.\n\n") 42 + fmt.Fprint(stderr, " -p, --parents Remove DIRECTORY and its ancestors\n") 43 + fmt.Fprint(stderr, " -v, --verbose Output a diagnostic for every directory processed\n") 44 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 + } 46 + set.SetUsage(usage) 47 + 48 + parents := set.BoolLong("parents", 'p', "Remove DIRECTORY and its ancestors") 49 + verbose := set.BoolLong("verbose", 'v', "Output a diagnostic for every directory processed") 50 + help := set.BoolLong("help", 0, "display this help and exit") 51 + 52 + if err := set.Getopt(append([]string{"rmdir"}, args...), nil); err != nil { 53 + fmt.Fprintf(stderr, "rmdir: %s\n", err) 54 + usage() 55 + return interp.ExitStatus(1) 56 + } 57 + if *help { 58 + usage() 59 + return nil 60 + } 61 + 62 + dirs := set.Args() 63 + if len(dirs) == 0 { 64 + fmt.Fprint(stderr, "rmdir: missing operand\n") 65 + return interp.ExitStatus(1) 66 + } 67 + 68 + exitCode := 0 69 + for _, dir := range dirs { 70 + full := resolvePath(ec, dir) 71 + if code := removeSingleDir(ec, full, dir, *verbose, stdout, stderr); code != 0 { 72 + exitCode = code 73 + continue 74 + } 75 + 76 + if !*parents { 77 + continue 78 + } 79 + 80 + currentPath := full 81 + currentDir := dir 82 + for { 83 + parentPath := path.Dir(currentPath) 84 + parentDir := path.Dir(currentDir) 85 + if parentPath == currentPath || 86 + parentPath == "." || parentPath == "/" || 87 + parentDir == "." || parentDir == "" { 88 + break 89 + } 90 + if removeSingleDir(ec, parentPath, parentDir, *verbose, stdout, io.Discard) != 0 { 91 + break 92 + } 93 + currentPath = parentPath 94 + currentDir = parentDir 95 + } 96 + } 97 + 98 + if exitCode != 0 { 99 + return interp.ExitStatus(uint8(exitCode)) 100 + } 101 + return nil 102 + } 103 + 104 + func removeSingleDir(ec *command.ExecContext, full, display string, verbose bool, stdout, stderr io.Writer) int { 105 + info, err := ec.FS.Stat(full) 106 + if err != nil { 107 + fmt.Fprintf(stderr, "rmdir: failed to remove '%s': No such file or directory\n", display) 108 + return 1 109 + } 110 + if !info.IsDir() { 111 + fmt.Fprintf(stderr, "rmdir: failed to remove '%s': Not a directory\n", display) 112 + return 1 113 + } 114 + 115 + entries, err := ec.FS.ReadDir(full) 116 + if err != nil { 117 + fmt.Fprintf(stderr, "rmdir: failed to remove '%s': %s\n", display, err) 118 + return 1 119 + } 120 + if len(entries) > 0 { 121 + fmt.Fprintf(stderr, "rmdir: failed to remove '%s': Directory not empty\n", display) 122 + return 1 123 + } 124 + 125 + if err := ec.FS.Remove(full); err != nil { 126 + fmt.Fprintf(stderr, "rmdir: failed to remove '%s': %s\n", display, err) 127 + return 1 128 + } 129 + 130 + if verbose { 131 + fmt.Fprintf(stdout, "rmdir: removing directory, '%s'\n", display) 132 + } 133 + return 0 134 + } 135 + 136 + func resolvePath(ec *command.ExecContext, p string) string { 137 + dir := ec.Dir 138 + if dir == "" { 139 + dir = "." 140 + } 141 + if path.IsAbs(p) { 142 + p = strings.TrimPrefix(p, "/") 143 + if p == "" { 144 + return "." 145 + } 146 + return path.Clean(p) 147 + } 148 + joined := path.Join(dir, p) 149 + if joined == "" { 150 + return "." 151 + } 152 + return joined 153 + }
+281
command/internal/rmdir/rmdir_test.go
··· 1 + package rmdir 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 newFS(t *testing.T) billy.Filesystem { 16 + t.Helper() 17 + fs := memfs.New() 18 + if err := fs.MkdirAll("emptydir", 0o755); err != nil { 19 + t.Fatal(err) 20 + } 21 + if err := fs.MkdirAll("a/b/c", 0o755); err != nil { 22 + t.Fatal(err) 23 + } 24 + if err := fs.MkdirAll("populated", 0o755); err != nil { 25 + t.Fatal(err) 26 + } 27 + if err := fs.MkdirAll("siblings/empty1", 0o755); err != nil { 28 + t.Fatal(err) 29 + } 30 + if err := fs.MkdirAll("siblings/empty2", 0o755); err != nil { 31 + t.Fatal(err) 32 + } 33 + f, err := fs.OpenFile("populated/inside.txt", os.O_CREATE|os.O_WRONLY, 0o644) 34 + if err != nil { 35 + t.Fatal(err) 36 + } 37 + f.Write([]byte("inside\n")) 38 + f.Close() 39 + f2, err := fs.OpenFile("regular.txt", os.O_CREATE|os.O_WRONLY, 0o644) 40 + if err != nil { 41 + t.Fatal(err) 42 + } 43 + f2.Write([]byte("regular\n")) 44 + f2.Close() 45 + return fs 46 + } 47 + 48 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 49 + t.Helper() 50 + var stdout, stderr bytes.Buffer 51 + ec := &command.ExecContext{ 52 + Stdout: &stdout, 53 + Stderr: &stderr, 54 + Dir: ".", 55 + FS: fs, 56 + } 57 + err := Impl{}.Exec(context.Background(), ec, args) 58 + return stdout.String(), stderr.String(), err 59 + } 60 + 61 + func exists(t *testing.T, fs billy.Filesystem, name string) bool { 62 + t.Helper() 63 + _, err := fs.Stat(name) 64 + return err == nil 65 + } 66 + 67 + func TestRmdir(t *testing.T) { 68 + tests := []struct { 69 + name string 70 + args []string 71 + wantStdout string 72 + wantErrSub string 73 + wantErr bool 74 + check func(t *testing.T, fs billy.Filesystem) 75 + }{ 76 + { 77 + name: "remove single empty directory", 78 + args: []string{"emptydir"}, 79 + check: func(t *testing.T, fs billy.Filesystem) { 80 + if exists(t, fs, "emptydir") { 81 + t.Errorf("emptydir was not removed") 82 + } 83 + }, 84 + }, 85 + { 86 + name: "remove multiple empty directories", 87 + args: []string{"siblings/empty1", "siblings/empty2"}, 88 + check: func(t *testing.T, fs billy.Filesystem) { 89 + if exists(t, fs, "siblings/empty1") { 90 + t.Errorf("siblings/empty1 was not removed") 91 + } 92 + if exists(t, fs, "siblings/empty2") { 93 + t.Errorf("siblings/empty2 was not removed") 94 + } 95 + if !exists(t, fs, "siblings") { 96 + t.Errorf("siblings was unexpectedly removed") 97 + } 98 + }, 99 + }, 100 + { 101 + name: "missing operand", 102 + args: []string{}, 103 + wantErrSub: "missing operand", 104 + wantErr: true, 105 + }, 106 + { 107 + name: "missing directory errors", 108 + args: []string{"nope"}, 109 + wantErrSub: "rmdir: failed to remove 'nope': No such file or directory", 110 + wantErr: true, 111 + }, 112 + { 113 + name: "non-empty directory errors", 114 + args: []string{"populated"}, 115 + wantErrSub: "rmdir: failed to remove 'populated': Directory not empty", 116 + wantErr: true, 117 + check: func(t *testing.T, fs billy.Filesystem) { 118 + if !exists(t, fs, "populated") { 119 + t.Errorf("populated was removed despite being non-empty") 120 + } 121 + }, 122 + }, 123 + { 124 + name: "regular file errors as Not a directory", 125 + args: []string{"regular.txt"}, 126 + wantErrSub: "rmdir: failed to remove 'regular.txt': Not a directory", 127 + wantErr: true, 128 + }, 129 + { 130 + name: "parents flag removes ancestors", 131 + args: []string{"-p", "a/b/c"}, 132 + check: func(t *testing.T, fs billy.Filesystem) { 133 + for _, p := range []string{"a/b/c", "a/b", "a"} { 134 + if exists(t, fs, p) { 135 + t.Errorf("%s was not removed", p) 136 + } 137 + } 138 + }, 139 + }, 140 + { 141 + name: "long parents flag", 142 + args: []string{"--parents", "a/b/c"}, 143 + check: func(t *testing.T, fs billy.Filesystem) { 144 + if exists(t, fs, "a") { 145 + t.Errorf("a was not removed via --parents") 146 + } 147 + }, 148 + }, 149 + { 150 + name: "verbose prints diagnostic", 151 + args: []string{"-v", "emptydir"}, 152 + wantStdout: "rmdir: removing directory, 'emptydir'\n", 153 + check: func(t *testing.T, fs billy.Filesystem) { 154 + if exists(t, fs, "emptydir") { 155 + t.Errorf("emptydir was not removed") 156 + } 157 + }, 158 + }, 159 + { 160 + name: "long verbose flag", 161 + args: []string{"--verbose", "emptydir"}, 162 + wantStdout: "rmdir: removing directory, 'emptydir'\n", 163 + }, 164 + { 165 + name: "verbose with parents emits one line per directory", 166 + args: []string{"-pv", "a/b/c"}, 167 + wantStdout: "rmdir: removing directory, 'a/b/c'\nrmdir: removing directory, 'a/b'\nrmdir: removing directory, 'a'\n", 168 + }, 169 + { 170 + name: "parents stops silently when parent non-empty", 171 + args: []string{"-p", "siblings/empty1"}, 172 + check: func(t *testing.T, fs billy.Filesystem) { 173 + if exists(t, fs, "siblings/empty1") { 174 + t.Errorf("siblings/empty1 was not removed") 175 + } 176 + if !exists(t, fs, "siblings") { 177 + t.Errorf("siblings was unexpectedly removed despite empty2 sibling") 178 + } 179 + if !exists(t, fs, "siblings/empty2") { 180 + t.Errorf("siblings/empty2 was unexpectedly removed") 181 + } 182 + }, 183 + }, 184 + { 185 + name: "partial success continues but reports error", 186 + args: []string{"emptydir", "nope", "siblings/empty1"}, 187 + wantErrSub: "No such file or directory", 188 + wantErr: true, 189 + check: func(t *testing.T, fs billy.Filesystem) { 190 + if exists(t, fs, "emptydir") { 191 + t.Errorf("emptydir was not removed despite later failure") 192 + } 193 + if exists(t, fs, "siblings/empty1") { 194 + t.Errorf("siblings/empty1 was not removed despite earlier failure") 195 + } 196 + }, 197 + }, 198 + { 199 + name: "unknown flag", 200 + args: []string{"--no-such-flag", "emptydir"}, 201 + wantErr: true, 202 + }, 203 + { 204 + name: "absolute path resolves under fs root", 205 + args: []string{"/emptydir"}, 206 + check: func(t *testing.T, fs billy.Filesystem) { 207 + if exists(t, fs, "emptydir") { 208 + t.Errorf("emptydir was not removed via absolute path") 209 + } 210 + }, 211 + }, 212 + { 213 + name: "single component with parents does not traverse above cwd", 214 + args: []string{"-p", "emptydir"}, 215 + check: func(t *testing.T, fs billy.Filesystem) { 216 + if exists(t, fs, "emptydir") { 217 + t.Errorf("emptydir was not removed") 218 + } 219 + }, 220 + }, 221 + } 222 + 223 + for _, tt := range tests { 224 + t.Run(tt.name, func(t *testing.T) { 225 + fs := newFS(t) 226 + stdout, stderr, err := run(t, tt.args, fs) 227 + if tt.wantErr { 228 + if err == nil { 229 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 230 + } 231 + } else if err != nil { 232 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 233 + } 234 + if tt.wantStdout != "" && stdout != tt.wantStdout { 235 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 236 + } 237 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 238 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 239 + } 240 + if tt.check != nil { 241 + tt.check(t, fs) 242 + } 243 + }) 244 + } 245 + } 246 + 247 + func TestHelp(t *testing.T) { 248 + fs := newFS(t) 249 + stdout, stderr, err := run(t, []string{"--help"}, fs) 250 + if err != nil { 251 + t.Fatalf("unexpected error: %v", err) 252 + } 253 + if stdout != "" { 254 + t.Errorf("expected empty stdout, got %q", stdout) 255 + } 256 + if !strings.Contains(stderr, "Usage: rmdir [-pv] DIRECTORY...") { 257 + t.Errorf("usage line missing from stderr: %q", stderr) 258 + } 259 + if !strings.Contains(stderr, "--parents") { 260 + t.Errorf("--parents flag missing from help output: %q", stderr) 261 + } 262 + if !strings.Contains(stderr, "--verbose") { 263 + t.Errorf("--verbose flag missing from help output: %q", stderr) 264 + } 265 + if !exists(t, fs, "emptydir") { 266 + t.Errorf("--help should not have removed any directories") 267 + } 268 + } 269 + 270 + func TestNoFilesystem(t *testing.T) { 271 + var stdout, stderr bytes.Buffer 272 + ec := &command.ExecContext{ 273 + Stdout: &stdout, 274 + Stderr: &stderr, 275 + Dir: ".", 276 + } 277 + err := Impl{}.Exec(context.Background(), ec, []string{"foo"}) 278 + if err == nil { 279 + t.Fatal("expected error when ExecContext.FS is nil") 280 + } 281 + }