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

Move and rename files via billy.Filesystem.Rename. Mirrors GNU
coreutils mv semantics as implemented by just-bash: -n takes
precedence over -f, multi-source moves require a directory target,
verbose output uses the "renamed 'src' -> 'dst'" form, and missing
sources emit "cannot stat" while runtime errors emit "cannot move".
Force is accepted but otherwise unused since we never prompt.

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

Xe Iaso 732f66dc ecab3935

+452
+152
command/internal/mv/mv.go
··· 1 + package mv 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("mv: nil ExecContext") 21 + } 22 + if ec.FS == nil { 23 + return errors.New("mv: 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("mv") 37 + set.SetParameters("SOURCE... DEST") 38 + 39 + usage := func() { 40 + fmt.Fprint(stderr, "Usage: mv [OPTION]... SOURCE... DEST\n") 41 + fmt.Fprint(stderr, "Move (rename) files.\n\n") 42 + fmt.Fprint(stderr, " -f, --force do not prompt before overwriting\n") 43 + fmt.Fprint(stderr, " -n, --no-clobber do not overwrite an existing file\n") 44 + fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 45 + fmt.Fprint(stderr, " --help display this help and exit\n") 46 + } 47 + set.SetUsage(usage) 48 + 49 + force := set.BoolLong("force", 'f', "do not prompt before overwriting") 50 + noClobber := set.BoolLong("no-clobber", 'n', "do not overwrite an existing file") 51 + verbose := set.BoolLong("verbose", 'v', "explain what is being done") 52 + help := set.BoolLong("help", 0, "display this help and exit") 53 + 54 + if err := set.Getopt(append([]string{"mv"}, args...), nil); err != nil { 55 + fmt.Fprintf(stderr, "mv: %s\n", err) 56 + usage() 57 + return interp.ExitStatus(1) 58 + } 59 + if *help { 60 + usage() 61 + return nil 62 + } 63 + 64 + // -n takes precedence over -f (per GNU coreutils behavior). 65 + // force is accepted but otherwise unused since we never prompt. 66 + if *noClobber { 67 + *force = false 68 + } 69 + _ = force 70 + 71 + paths := set.Args() 72 + if len(paths) < 2 { 73 + fmt.Fprint(stderr, "mv: missing destination file operand\n") 74 + return interp.ExitStatus(1) 75 + } 76 + 77 + dest := paths[len(paths)-1] 78 + sources := paths[:len(paths)-1] 79 + destPath := resolvePath(ec, dest) 80 + 81 + destIsDir := false 82 + if info, err := ec.FS.Stat(destPath); err == nil { 83 + destIsDir = info.IsDir() 84 + } 85 + 86 + if len(sources) > 1 && !destIsDir { 87 + fmt.Fprintf(stderr, "mv: target '%s' is not a directory\n", dest) 88 + return interp.ExitStatus(1) 89 + } 90 + 91 + exitCode := 0 92 + for _, src := range sources { 93 + srcPath := resolvePath(ec, src) 94 + if _, err := ec.FS.Stat(srcPath); err != nil { 95 + fmt.Fprintf(stderr, "mv: cannot stat '%s': No such file or directory\n", src) 96 + exitCode = 1 97 + continue 98 + } 99 + 100 + targetPath := destPath 101 + targetDisplay := dest 102 + if destIsDir { 103 + b := path.Base(src) 104 + targetPath = path.Join(destPath, b) 105 + if dest == "/" { 106 + targetDisplay = "/" + b 107 + } else { 108 + targetDisplay = strings.TrimSuffix(dest, "/") + "/" + b 109 + } 110 + } 111 + 112 + if *noClobber { 113 + if _, err := ec.FS.Stat(targetPath); err == nil { 114 + continue 115 + } 116 + } 117 + 118 + if err := ec.FS.Rename(srcPath, targetPath); err != nil { 119 + fmt.Fprintf(stderr, "mv: cannot move '%s': %v\n", src, err) 120 + exitCode = 1 121 + continue 122 + } 123 + 124 + if *verbose { 125 + fmt.Fprintf(stdout, "renamed '%s' -> '%s'\n", src, targetDisplay) 126 + } 127 + } 128 + 129 + if exitCode != 0 { 130 + return interp.ExitStatus(uint8(exitCode)) 131 + } 132 + return nil 133 + } 134 + 135 + func resolvePath(ec *command.ExecContext, p string) string { 136 + dir := ec.Dir 137 + if dir == "" { 138 + dir = "." 139 + } 140 + if path.IsAbs(p) { 141 + p = strings.TrimPrefix(p, "/") 142 + if p == "" { 143 + return "." 144 + } 145 + return path.Clean(p) 146 + } 147 + joined := path.Join(dir, p) 148 + if joined == "" { 149 + return "." 150 + } 151 + return joined 152 + }
+298
command/internal/mv/mv_test.go
··· 1 + package mv 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "os" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/go-git/go-billy/v5" 12 + "github.com/go-git/go-billy/v5/memfs" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + func newFS(t *testing.T) billy.Filesystem { 17 + t.Helper() 18 + fs := memfs.New() 19 + write := func(name string, data []byte) { 20 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + f.Write(data) 25 + f.Close() 26 + } 27 + if err := fs.MkdirAll("dir", 0o755); err != nil { 28 + t.Fatal(err) 29 + } 30 + if err := fs.MkdirAll("existing", 0o755); err != nil { 31 + t.Fatal(err) 32 + } 33 + write("hello.txt", []byte("hello\n")) 34 + write("two.txt", []byte("world\n")) 35 + write("existing/keep.txt", []byte("keep\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 + Stdin: strings.NewReader(""), 44 + Stdout: &stdout, 45 + Stderr: &stderr, 46 + Dir: ".", 47 + FS: fs, 48 + } 49 + err := Impl{}.Exec(context.Background(), ec, args) 50 + return stdout.String(), stderr.String(), err 51 + } 52 + 53 + func readFile(t *testing.T, fs billy.Filesystem, name string) string { 54 + t.Helper() 55 + f, err := fs.Open(name) 56 + if err != nil { 57 + t.Fatalf("open %s: %v", name, err) 58 + } 59 + defer f.Close() 60 + data, err := io.ReadAll(f) 61 + if err != nil { 62 + t.Fatalf("read %s: %v", name, err) 63 + } 64 + return string(data) 65 + } 66 + 67 + func exists(t *testing.T, fs billy.Filesystem, name string) bool { 68 + t.Helper() 69 + _, err := fs.Stat(name) 70 + return err == nil 71 + } 72 + 73 + func TestMv(t *testing.T) { 74 + tests := []struct { 75 + name string 76 + args []string 77 + wantStdout string 78 + wantErrSub string 79 + wantErr bool 80 + check func(t *testing.T, fs billy.Filesystem) 81 + }{ 82 + { 83 + name: "rename single file", 84 + args: []string{"hello.txt", "renamed.txt"}, 85 + check: func(t *testing.T, fs billy.Filesystem) { 86 + if exists(t, fs, "hello.txt") { 87 + t.Errorf("hello.txt should be gone after rename") 88 + } 89 + if got := readFile(t, fs, "renamed.txt"); got != "hello\n" { 90 + t.Errorf("renamed.txt = %q, want %q", got, "hello\n") 91 + } 92 + }, 93 + }, 94 + { 95 + name: "move single file into directory", 96 + args: []string{"hello.txt", "dir"}, 97 + check: func(t *testing.T, fs billy.Filesystem) { 98 + if exists(t, fs, "hello.txt") { 99 + t.Errorf("hello.txt should be gone after move") 100 + } 101 + if got := readFile(t, fs, "dir/hello.txt"); got != "hello\n" { 102 + t.Errorf("dir/hello.txt = %q, want %q", got, "hello\n") 103 + } 104 + }, 105 + }, 106 + { 107 + name: "multiple sources into directory", 108 + args: []string{"hello.txt", "two.txt", "dir"}, 109 + check: func(t *testing.T, fs billy.Filesystem) { 110 + if exists(t, fs, "hello.txt") { 111 + t.Errorf("hello.txt should be gone after move") 112 + } 113 + if exists(t, fs, "two.txt") { 114 + t.Errorf("two.txt should be gone after move") 115 + } 116 + if got := readFile(t, fs, "dir/hello.txt"); got != "hello\n" { 117 + t.Errorf("dir/hello.txt = %q, want %q", got, "hello\n") 118 + } 119 + if got := readFile(t, fs, "dir/two.txt"); got != "world\n" { 120 + t.Errorf("dir/two.txt = %q, want %q", got, "world\n") 121 + } 122 + }, 123 + }, 124 + { 125 + name: "missing destination operand", 126 + args: []string{"hello.txt"}, 127 + wantErrSub: "missing destination file operand", 128 + wantErr: true, 129 + }, 130 + { 131 + name: "no args", 132 + args: nil, 133 + wantErrSub: "missing destination file operand", 134 + wantErr: true, 135 + }, 136 + { 137 + name: "multiple sources but dest is not directory", 138 + args: []string{"hello.txt", "two.txt", "newfile"}, 139 + wantErrSub: "target 'newfile' is not a directory", 140 + wantErr: true, 141 + }, 142 + { 143 + name: "missing source", 144 + args: []string{"nope.txt", "out.txt"}, 145 + wantErrSub: "cannot stat 'nope.txt': No such file or directory", 146 + wantErr: true, 147 + }, 148 + { 149 + name: "no-clobber skips existing target", 150 + args: []string{"-n", "hello.txt", "existing/keep.txt"}, 151 + check: func(t *testing.T, fs billy.Filesystem) { 152 + if !exists(t, fs, "hello.txt") { 153 + t.Errorf("hello.txt should still exist (move was skipped)") 154 + } 155 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 156 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 157 + } 158 + }, 159 + }, 160 + { 161 + name: "no-clobber long flag still moves missing target", 162 + args: []string{"--no-clobber", "hello.txt", "fresh.txt"}, 163 + check: func(t *testing.T, fs billy.Filesystem) { 164 + if exists(t, fs, "hello.txt") { 165 + t.Errorf("hello.txt should be gone after move") 166 + } 167 + if got := readFile(t, fs, "fresh.txt"); got != "hello\n" { 168 + t.Errorf("fresh.txt = %q, want %q", got, "hello\n") 169 + } 170 + }, 171 + }, 172 + { 173 + name: "no-clobber overrides force", 174 + args: []string{"-f", "-n", "hello.txt", "existing/keep.txt"}, 175 + check: func(t *testing.T, fs billy.Filesystem) { 176 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 177 + t.Errorf("existing/keep.txt = %q, want %q (untouched, -n wins over -f)", got, "keep\n") 178 + } 179 + if !exists(t, fs, "hello.txt") { 180 + t.Errorf("hello.txt should still exist (move was skipped)") 181 + } 182 + }, 183 + }, 184 + { 185 + name: "force flag accepted", 186 + args: []string{"-f", "hello.txt", "forced.txt"}, 187 + check: func(t *testing.T, fs billy.Filesystem) { 188 + if got := readFile(t, fs, "forced.txt"); got != "hello\n" { 189 + t.Errorf("forced.txt = %q, want %q", got, "hello\n") 190 + } 191 + }, 192 + }, 193 + { 194 + name: "verbose short flag prints rename line", 195 + args: []string{"-v", "hello.txt", "v1.txt"}, 196 + wantStdout: "renamed 'hello.txt' -> 'v1.txt'\n", 197 + check: func(t *testing.T, fs billy.Filesystem) { 198 + if got := readFile(t, fs, "v1.txt"); got != "hello\n" { 199 + t.Errorf("v1.txt = %q, want %q", got, "hello\n") 200 + } 201 + }, 202 + }, 203 + { 204 + name: "verbose long flag with directory destination", 205 + args: []string{"--verbose", "hello.txt", "dir"}, 206 + wantStdout: "renamed 'hello.txt' -> 'dir/hello.txt'\n", 207 + }, 208 + { 209 + name: "verbose multiple sources into directory", 210 + args: []string{"-v", "hello.txt", "two.txt", "dir"}, 211 + wantStdout: "renamed 'hello.txt' -> 'dir/hello.txt'\nrenamed 'two.txt' -> 'dir/two.txt'\n", 212 + }, 213 + { 214 + name: "overwrite existing file by default", 215 + args: []string{"hello.txt", "existing/keep.txt"}, 216 + check: func(t *testing.T, fs billy.Filesystem) { 217 + if exists(t, fs, "hello.txt") { 218 + t.Errorf("hello.txt should be gone after move") 219 + } 220 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 221 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 222 + } 223 + }, 224 + }, 225 + { 226 + name: "double dash terminator", 227 + args: []string{"--", "hello.txt", "term.txt"}, 228 + check: func(t *testing.T, fs billy.Filesystem) { 229 + if got := readFile(t, fs, "term.txt"); got != "hello\n" { 230 + t.Errorf("term.txt = %q, want %q", got, "hello\n") 231 + } 232 + }, 233 + }, 234 + { 235 + name: "unknown flag", 236 + args: []string{"--no-such-flag", "hello.txt", "out.txt"}, 237 + wantErr: true, 238 + }, 239 + } 240 + 241 + for _, tt := range tests { 242 + t.Run(tt.name, func(t *testing.T) { 243 + fs := newFS(t) 244 + stdout, stderr, err := run(t, tt.args, fs) 245 + if tt.wantErr { 246 + if err == nil { 247 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 248 + } 249 + } else if err != nil { 250 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 251 + } 252 + if tt.wantStdout != "" && stdout != tt.wantStdout { 253 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 254 + } 255 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 256 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 257 + } 258 + if tt.check != nil { 259 + tt.check(t, fs) 260 + } 261 + }) 262 + } 263 + } 264 + 265 + func TestHelp(t *testing.T) { 266 + stdout, stderr, err := run(t, []string{"--help"}, newFS(t)) 267 + if err != nil { 268 + t.Fatalf("unexpected error: %v", err) 269 + } 270 + if stdout != "" { 271 + t.Errorf("expected empty stdout, got %q", stdout) 272 + } 273 + if !strings.Contains(stderr, "Usage: mv [OPTION]... SOURCE... DEST") { 274 + t.Errorf("usage line missing from stderr: %q", stderr) 275 + } 276 + if !strings.Contains(stderr, "-f, --force") { 277 + t.Errorf("force flag missing from help: %q", stderr) 278 + } 279 + if !strings.Contains(stderr, "-n, --no-clobber") { 280 + t.Errorf("no-clobber flag missing from help: %q", stderr) 281 + } 282 + if !strings.Contains(stderr, "-v, --verbose") { 283 + t.Errorf("verbose flag missing from help: %q", stderr) 284 + } 285 + } 286 + 287 + func TestNilContext(t *testing.T) { 288 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 289 + t.Fatal("expected error for nil ExecContext") 290 + } 291 + } 292 + 293 + func TestNilFilesystem(t *testing.T) { 294 + ec := &command.ExecContext{Dir: "."} 295 + if err := (Impl{}).Exec(context.Background(), ec, []string{"a", "b"}); err == nil { 296 + t.Fatal("expected error when filesystem is nil") 297 + } 298 + }
+2
command/registry/coreutils/coreutils.go
··· 23 23 "tangled.org/xeiaso.net/kefka/command/internal/join" 24 24 "tangled.org/xeiaso.net/kefka/command/internal/ls" 25 25 "tangled.org/xeiaso.net/kefka/command/internal/mkdir" 26 + "tangled.org/xeiaso.net/kefka/command/internal/mv" 26 27 "tangled.org/xeiaso.net/kefka/command/internal/nl" 27 28 "tangled.org/xeiaso.net/kefka/command/internal/truecmd" 28 29 "tangled.org/xeiaso.net/kefka/command/internal/unexpand" ··· 53 54 reg.Register("join", join.Impl{}) 54 55 reg.Register("ls", ls.Impl{}) 55 56 reg.Register("mkdir", mkdir.Impl{}) 57 + reg.Register("mv", mv.Impl{}) 56 58 reg.Register("nl", nl.Impl{}) 57 59 reg.Register("true", truecmd.Impl{}) 58 60 reg.Register("unexpand", unexpand.Impl{})