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

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

Xe Iaso a0052915 4cda6738

+439
+154
command/internal/rm/rm.go
··· 1 + package rm 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path" 10 + "strings" 11 + 12 + "github.com/go-git/go-billy/v5/util" 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 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 21 + if ec == nil { 22 + return errors.New("rm: nil ExecContext") 23 + } 24 + if ec.FS == nil { 25 + return errors.New("rm: ExecContext has no filesystem") 26 + } 27 + 28 + stdout := ec.Stdout 29 + if stdout == nil { 30 + stdout = io.Discard 31 + } 32 + stderr := ec.Stderr 33 + if stderr == nil { 34 + stderr = io.Discard 35 + } 36 + 37 + set := getopt.New() 38 + set.SetProgram("rm") 39 + set.SetParameters("FILE...") 40 + 41 + usage := func() { 42 + fmt.Fprint(stderr, "Usage: rm [OPTION]... FILE...\n") 43 + fmt.Fprint(stderr, "Remove (unlink) the FILE(s).\n\n") 44 + fmt.Fprint(stderr, " -f, --force ignore nonexistent files and arguments, never prompt\n") 45 + fmt.Fprint(stderr, " -r, -R, --recursive remove directories and their contents recursively\n") 46 + fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 47 + fmt.Fprint(stderr, " --help display this help and exit\n") 48 + } 49 + set.SetUsage(usage) 50 + 51 + recursive := set.BoolLong("recursive", 'r', "remove directories and their contents recursively") 52 + recursiveUpper := set.Bool('R', "remove directories and their contents recursively") 53 + force := set.BoolLong("force", 'f', "ignore nonexistent files and arguments, never prompt") 54 + verbose := set.BoolLong("verbose", 'v', "explain what is being done") 55 + help := set.BoolLong("help", 0, "display this help and exit") 56 + 57 + if err := set.Getopt(append([]string{"rm"}, args...), nil); err != nil { 58 + fmt.Fprintf(stderr, "rm: %s\n", err) 59 + usage() 60 + return interp.ExitStatus(1) 61 + } 62 + if *help { 63 + usage() 64 + return nil 65 + } 66 + 67 + recurse := *recursive || *recursiveUpper 68 + paths := set.Args() 69 + 70 + if len(paths) == 0 { 71 + if *force { 72 + return nil 73 + } 74 + fmt.Fprint(stderr, "rm: missing operand\n") 75 + return interp.ExitStatus(1) 76 + } 77 + 78 + exitCode := 0 79 + for _, p := range paths { 80 + full := resolvePath(ec, p) 81 + info, err := ec.FS.Stat(full) 82 + if err != nil { 83 + if !*force { 84 + if errors.Is(err, os.ErrNotExist) { 85 + fmt.Fprintf(stderr, "rm: cannot remove '%s': No such file or directory\n", p) 86 + } else { 87 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", p, err) 88 + } 89 + exitCode = 1 90 + } 91 + continue 92 + } 93 + 94 + if info.IsDir() && !recurse { 95 + fmt.Fprintf(stderr, "rm: cannot remove '%s': Is a directory\n", p) 96 + exitCode = 1 97 + continue 98 + } 99 + 100 + var rmErr error 101 + if recurse { 102 + rmErr = util.RemoveAll(ec.FS, full) 103 + } else { 104 + rmErr = ec.FS.Remove(full) 105 + } 106 + if rmErr != nil { 107 + if !*force { 108 + switch { 109 + case errors.Is(rmErr, os.ErrNotExist): 110 + fmt.Fprintf(stderr, "rm: cannot remove '%s': No such file or directory\n", p) 111 + case isNotEmpty(rmErr): 112 + fmt.Fprintf(stderr, "rm: cannot remove '%s': Directory not empty\n", p) 113 + default: 114 + fmt.Fprintf(stderr, "rm: cannot remove '%s': %s\n", p, rmErr) 115 + } 116 + exitCode = 1 117 + } 118 + continue 119 + } 120 + 121 + if *verbose { 122 + fmt.Fprintf(stdout, "removed '%s'\n", p) 123 + } 124 + } 125 + 126 + if exitCode != 0 { 127 + return interp.ExitStatus(uint8(exitCode)) 128 + } 129 + return nil 130 + } 131 + 132 + func isNotEmpty(err error) bool { 133 + msg := err.Error() 134 + return strings.Contains(msg, "not empty") || strings.Contains(msg, "ENOTEMPTY") 135 + } 136 + 137 + func resolvePath(ec *command.ExecContext, p string) string { 138 + dir := ec.Dir 139 + if dir == "" { 140 + dir = "." 141 + } 142 + if path.IsAbs(p) { 143 + p = strings.TrimPrefix(p, "/") 144 + if p == "" { 145 + return "." 146 + } 147 + return path.Clean(p) 148 + } 149 + joined := path.Join(dir, p) 150 + if joined == "" { 151 + return "." 152 + } 153 + return joined 154 + }
+285
command/internal/rm/rm_test.go
··· 1 + package rm 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 + 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 + if err := fs.MkdirAll("emptydir", 0o755); err != nil { 27 + t.Fatal(err) 28 + } 29 + if err := fs.MkdirAll("populated/sub", 0o755); err != nil { 30 + t.Fatal(err) 31 + } 32 + write("hello.txt", []byte("hello\n")) 33 + write("other.txt", []byte("other\n")) 34 + write("populated/inside.txt", []byte("inside\n")) 35 + write("populated/sub/deep.txt", []byte("deep\n")) 36 + return fs 37 + } 38 + 39 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 40 + t.Helper() 41 + var stdout, stderr bytes.Buffer 42 + ec := &command.ExecContext{ 43 + Stdout: &stdout, 44 + Stderr: &stderr, 45 + Dir: ".", 46 + FS: fs, 47 + } 48 + err := Impl{}.Exec(context.Background(), ec, args) 49 + return stdout.String(), stderr.String(), err 50 + } 51 + 52 + func exists(t *testing.T, fs billy.Filesystem, name string) bool { 53 + t.Helper() 54 + _, err := fs.Stat(name) 55 + return err == nil 56 + } 57 + 58 + func TestRm(t *testing.T) { 59 + tests := []struct { 60 + name string 61 + args []string 62 + wantStdout string 63 + wantErrSub string 64 + wantErr bool 65 + check func(t *testing.T, fs billy.Filesystem) 66 + }{ 67 + { 68 + name: "remove single file", 69 + args: []string{"hello.txt"}, 70 + check: func(t *testing.T, fs billy.Filesystem) { 71 + if exists(t, fs, "hello.txt") { 72 + t.Errorf("hello.txt was not removed") 73 + } 74 + }, 75 + }, 76 + { 77 + name: "remove multiple files", 78 + args: []string{"hello.txt", "other.txt"}, 79 + check: func(t *testing.T, fs billy.Filesystem) { 80 + if exists(t, fs, "hello.txt") { 81 + t.Errorf("hello.txt was not removed") 82 + } 83 + if exists(t, fs, "other.txt") { 84 + t.Errorf("other.txt was not removed") 85 + } 86 + }, 87 + }, 88 + { 89 + name: "missing operand", 90 + args: []string{}, 91 + wantErrSub: "missing operand", 92 + wantErr: true, 93 + }, 94 + { 95 + name: "force suppresses missing operand", 96 + args: []string{"-f"}, 97 + }, 98 + { 99 + name: "missing file errors without force", 100 + args: []string{"nope.txt"}, 101 + wantErrSub: "No such file or directory", 102 + wantErr: true, 103 + }, 104 + { 105 + name: "missing file silent with force", 106 + args: []string{"-f", "nope.txt"}, 107 + }, 108 + { 109 + name: "directory without recursive errors", 110 + args: []string{"emptydir"}, 111 + wantErrSub: "Is a directory", 112 + wantErr: true, 113 + check: func(t *testing.T, fs billy.Filesystem) { 114 + if !exists(t, fs, "emptydir") { 115 + t.Errorf("emptydir was removed despite missing -r") 116 + } 117 + }, 118 + }, 119 + { 120 + name: "recursive removes empty directory", 121 + args: []string{"-r", "emptydir"}, 122 + check: func(t *testing.T, fs billy.Filesystem) { 123 + if exists(t, fs, "emptydir") { 124 + t.Errorf("emptydir was not removed") 125 + } 126 + }, 127 + }, 128 + { 129 + name: "recursive removes populated directory", 130 + args: []string{"-r", "populated"}, 131 + check: func(t *testing.T, fs billy.Filesystem) { 132 + for _, p := range []string{"populated", "populated/inside.txt", "populated/sub", "populated/sub/deep.txt"} { 133 + if exists(t, fs, p) { 134 + t.Errorf("%s was not removed", p) 135 + } 136 + } 137 + }, 138 + }, 139 + { 140 + name: "uppercase R is recursive", 141 + args: []string{"-R", "populated"}, 142 + check: func(t *testing.T, fs billy.Filesystem) { 143 + if exists(t, fs, "populated") { 144 + t.Errorf("populated was not removed via -R") 145 + } 146 + }, 147 + }, 148 + { 149 + name: "long recursive flag", 150 + args: []string{"--recursive", "populated"}, 151 + check: func(t *testing.T, fs billy.Filesystem) { 152 + if exists(t, fs, "populated") { 153 + t.Errorf("populated was not removed via --recursive") 154 + } 155 + }, 156 + }, 157 + { 158 + name: "verbose prints removed line", 159 + args: []string{"-v", "hello.txt"}, 160 + wantStdout: "removed 'hello.txt'\n", 161 + check: func(t *testing.T, fs billy.Filesystem) { 162 + if exists(t, fs, "hello.txt") { 163 + t.Errorf("hello.txt was not removed") 164 + } 165 + }, 166 + }, 167 + { 168 + name: "long verbose flag", 169 + args: []string{"--verbose", "hello.txt"}, 170 + wantStdout: "removed 'hello.txt'\n", 171 + }, 172 + { 173 + name: "verbose with multiple files", 174 + args: []string{"-v", "hello.txt", "other.txt"}, 175 + wantStdout: "removed 'hello.txt'\nremoved 'other.txt'\n", 176 + }, 177 + { 178 + name: "verbose recursive on directory", 179 + args: []string{"-rv", "populated"}, 180 + wantStdout: "removed 'populated'\n", 181 + }, 182 + { 183 + name: "partial success continues but reports error", 184 + args: []string{"hello.txt", "nope.txt", "other.txt"}, 185 + wantErrSub: "No such file or directory", 186 + wantErr: true, 187 + check: func(t *testing.T, fs billy.Filesystem) { 188 + if exists(t, fs, "hello.txt") { 189 + t.Errorf("hello.txt was not removed despite later failure") 190 + } 191 + if exists(t, fs, "other.txt") { 192 + t.Errorf("other.txt was not removed despite earlier failure") 193 + } 194 + }, 195 + }, 196 + { 197 + name: "force ignores missing in middle of list", 198 + args: []string{"-f", "hello.txt", "nope.txt", "other.txt"}, 199 + check: func(t *testing.T, fs billy.Filesystem) { 200 + if exists(t, fs, "hello.txt") { 201 + t.Errorf("hello.txt was not removed") 202 + } 203 + if exists(t, fs, "other.txt") { 204 + t.Errorf("other.txt was not removed") 205 + } 206 + }, 207 + }, 208 + { 209 + name: "unknown flag", 210 + args: []string{"--no-such-flag", "hello.txt"}, 211 + wantErr: true, 212 + }, 213 + { 214 + name: "absolute path resolves under fs root", 215 + args: []string{"/hello.txt"}, 216 + check: func(t *testing.T, fs billy.Filesystem) { 217 + if exists(t, fs, "hello.txt") { 218 + t.Errorf("hello.txt was not removed via absolute path") 219 + } 220 + }, 221 + }, 222 + } 223 + 224 + for _, tt := range tests { 225 + t.Run(tt.name, func(t *testing.T) { 226 + fs := newFS(t) 227 + stdout, stderr, err := run(t, tt.args, fs) 228 + if tt.wantErr { 229 + if err == nil { 230 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 231 + } 232 + } else if err != nil { 233 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 234 + } 235 + if tt.wantStdout != "" && stdout != tt.wantStdout { 236 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 237 + } 238 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 239 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 240 + } 241 + if tt.check != nil { 242 + tt.check(t, fs) 243 + } 244 + }) 245 + } 246 + } 247 + 248 + func TestHelp(t *testing.T) { 249 + fs := newFS(t) 250 + stdout, stderr, err := run(t, []string{"--help"}, fs) 251 + if err != nil { 252 + t.Fatalf("unexpected error: %v", err) 253 + } 254 + if stdout != "" { 255 + t.Errorf("expected empty stdout, got %q", stdout) 256 + } 257 + if !strings.Contains(stderr, "Usage: rm [OPTION]... FILE...") { 258 + t.Errorf("usage line missing from stderr: %q", stderr) 259 + } 260 + if !strings.Contains(stderr, "--recursive") { 261 + t.Errorf("--recursive flag missing from help output: %q", stderr) 262 + } 263 + if !strings.Contains(stderr, "--force") { 264 + t.Errorf("--force flag missing from help output: %q", stderr) 265 + } 266 + if !strings.Contains(stderr, "--verbose") { 267 + t.Errorf("--verbose flag missing from help output: %q", stderr) 268 + } 269 + if exists(t, fs, "hello.txt") == false { 270 + t.Errorf("--help should not have removed any files") 271 + } 272 + } 273 + 274 + func TestNoFilesystem(t *testing.T) { 275 + var stdout, stderr bytes.Buffer 276 + ec := &command.ExecContext{ 277 + Stdout: &stdout, 278 + Stderr: &stderr, 279 + Dir: ".", 280 + } 281 + err := Impl{}.Exec(context.Background(), ec, []string{"foo"}) 282 + if err == nil { 283 + t.Fatal("expected error when ExecContext.FS is nil") 284 + } 285 + }