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(mv): add -i interactive, -v verbose, same-file detection

Implements GNU coreutils compatibility for mv:

- -i/--interactive y/n prompt before overwrite
- -v/--verbose
- Same-file detection emits diagnostic and exits non-zero
- Last-flag-wins precedence for -f / -i / -n via getopt
callbacks
- Trailing-slash-on-non-dir refusal

EXDEV cross-fs fallback documented as deferred since the
command operates against a single billy.Filesystem per
Exec call and EXDEV is unreachable.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 64a3dfa5 b34577b4

+318 -20
+79 -17
command/internal/mv/mv.go
··· 1 1 package mv 2 2 3 3 import ( 4 + "bufio" 4 5 "context" 5 6 "errors" 6 7 "fmt" ··· 14 15 ) 15 16 16 17 type Impl struct{} 18 + 19 + type promptMode int 20 + 21 + const ( 22 + promptDefault promptMode = iota 23 + promptForce 24 + promptInteractive 25 + promptNoClobber 26 + ) 17 27 18 28 func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 19 29 if ec == nil { ··· 39 49 usage := func() { 40 50 fmt.Fprint(stderr, "Usage: mv [OPTION]... SOURCE... DEST\n") 41 51 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") 52 + fmt.Fprint(stderr, " -f, --force do not prompt before overwriting\n") 53 + fmt.Fprint(stderr, " -i, --interactive prompt before overwrite\n") 54 + fmt.Fprint(stderr, " -n, --no-clobber do not overwrite an existing file\n") 55 + fmt.Fprint(stderr, " -v, --verbose explain what is being done\n") 56 + fmt.Fprint(stderr, " --help display this help and exit\n") 46 57 } 47 58 set.SetUsage(usage) 48 59 49 - force := set.BoolLong("force", 'f', "do not prompt before overwriting") 50 - noClobber := set.BoolLong("no-clobber", 'n', "do not overwrite an existing file") 60 + _ = set.BoolLong("force", 'f', "do not prompt before overwriting") 61 + _ = set.BoolLong("interactive", 'i', "prompt before overwrite") 62 + _ = set.BoolLong("no-clobber", 'n', "do not overwrite an existing file") 51 63 verbose := set.BoolLong("verbose", 'v', "explain what is being done") 52 64 help := set.BoolLong("help", 0, "display this help and exit") 53 65 54 - if err := set.Getopt(append([]string{"mv"}, args...), nil); err != nil { 66 + mode := promptDefault 67 + cb := func(opt getopt.Option) bool { 68 + switch opt.LongName() { 69 + case "force": 70 + mode = promptForce 71 + case "interactive": 72 + mode = promptInteractive 73 + case "no-clobber": 74 + mode = promptNoClobber 75 + } 76 + return true 77 + } 78 + 79 + if err := set.Getopt(append([]string{"mv"}, args...), cb); err != nil { 55 80 fmt.Fprintf(stderr, "mv: %s\n", err) 56 81 usage() 57 82 return interp.ExitStatus(1) ··· 60 85 usage() 61 86 return nil 62 87 } 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 88 71 89 paths := set.Args() 72 90 if len(paths) < 2 { ··· 88 106 return interp.ExitStatus(1) 89 107 } 90 108 109 + stdinReader := bufio.NewReader(stdinOrEmpty(ec.Stdin)) 110 + 111 + // POSIX (mv.md DESCRIPTION): if source is a non-directory and the target 112 + // ends with a trailing slash, this is an error and no sources are 113 + // processed. This only applies to the single-source rename form, since 114 + // the multi-source form already requires the dest to be an existing 115 + // directory. 116 + destHasTrailingSlash := strings.HasSuffix(dest, "/") && dest != "/" 117 + if destHasTrailingSlash && !destIsDir && len(sources) == 1 { 118 + srcPath := resolvePath(ec, sources[0]) 119 + if info, err := ec.FS.Stat(srcPath); err == nil && !info.IsDir() { 120 + fmt.Fprintf(stderr, "mv: cannot move '%s' to '%s': Not a directory\n", sources[0], dest) 121 + return interp.ExitStatus(1) 122 + } 123 + } 124 + 91 125 exitCode := 0 92 126 for _, src := range sources { 93 127 srcPath := resolvePath(ec, src) ··· 109 143 } 110 144 } 111 145 112 - if *noClobber { 113 - if _, err := ec.FS.Stat(targetPath); err == nil { 146 + if srcPath == targetPath { 147 + fmt.Fprintf(stderr, "mv: '%s' and '%s' are the same file\n", src, targetDisplay) 148 + exitCode = 1 149 + continue 150 + } 151 + 152 + if _, err := ec.FS.Stat(targetPath); err == nil { 153 + switch mode { 154 + case promptNoClobber: 114 155 continue 156 + case promptInteractive: 157 + fmt.Fprintf(stderr, "mv: overwrite '%s'? ", targetDisplay) 158 + line, _ := stdinReader.ReadString('\n') 159 + line = strings.TrimRight(line, "\r\n") 160 + if line == "" || (line[0] != 'y' && line[0] != 'Y') { 161 + continue 162 + } 115 163 } 116 164 } 117 165 166 + // Cross-filesystem fallback (POSIX mv.md steps 4-7: copy hierarchy, 167 + // preserve metadata, unlink source) is intentionally unimplemented. 168 + // kefka's command FS is a single billy.Filesystem at any moment, 169 + // so EXDEV is not reachable from within one Exec call. If billy 170 + // returns a "cross device" error from its Rename for any other 171 + // reason, the move will fail rather than silently copy. uid/gid 172 + // preservation similarly is not exposed by billy. 118 173 if err := ec.FS.Rename(srcPath, targetPath); err != nil { 119 174 fmt.Fprintf(stderr, "mv: cannot move '%s': %v\n", src, err) 120 175 exitCode = 1 ··· 132 187 return nil 133 188 } 134 189 190 + func stdinOrEmpty(r io.Reader) io.Reader { 191 + if r == nil { 192 + return strings.NewReader("") 193 + } 194 + return r 195 + } 196 + 135 197 func resolvePath(ec *command.ExecContext, p string) string { 136 198 dir := ec.Dir 137 199 if dir == "" { ··· 149 211 return "." 150 212 } 151 213 return joined 152 - } 214 + }
+239 -3
command/internal/mv/mv_test.go
··· 38 38 39 39 func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 40 40 t.Helper() 41 + return runWithStdin(t, args, fs, "") 42 + } 43 + 44 + func runWithStdin(t *testing.T, args []string, fs billy.Filesystem, stdin string) (string, string, error) { 45 + t.Helper() 41 46 var stdout, stderr bytes.Buffer 42 47 ec := &command.ExecContext{ 43 - Stdin: strings.NewReader(""), 48 + Stdin: strings.NewReader(stdin), 44 49 Stdout: &stdout, 45 50 Stderr: &stderr, 46 51 Dir: ".", ··· 170 175 }, 171 176 }, 172 177 { 173 - name: "no-clobber overrides force", 178 + name: "no-clobber wins when last over force", 174 179 args: []string{"-f", "-n", "hello.txt", "existing/keep.txt"}, 175 180 check: func(t *testing.T, fs billy.Filesystem) { 176 181 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") 182 + t.Errorf("existing/keep.txt = %q, want %q (untouched, -n wins because last)", got, "keep\n") 178 183 } 179 184 if !exists(t, fs, "hello.txt") { 180 185 t.Errorf("hello.txt should still exist (move was skipped)") ··· 182 187 }, 183 188 }, 184 189 { 190 + name: "force wins when last over no-clobber", 191 + args: []string{"-n", "-f", "hello.txt", "existing/keep.txt"}, 192 + check: func(t *testing.T, fs billy.Filesystem) { 193 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 194 + t.Errorf("existing/keep.txt = %q, want %q (overwritten, -f wins because last)", got, "hello\n") 195 + } 196 + if exists(t, fs, "hello.txt") { 197 + t.Errorf("hello.txt should be gone after move") 198 + } 199 + }, 200 + }, 201 + { 185 202 name: "force flag accepted", 186 203 args: []string{"-f", "hello.txt", "forced.txt"}, 187 204 check: func(t *testing.T, fs billy.Filesystem) { ··· 276 293 if !strings.Contains(stderr, "-f, --force") { 277 294 t.Errorf("force flag missing from help: %q", stderr) 278 295 } 296 + if !strings.Contains(stderr, "-i, --interactive") { 297 + t.Errorf("interactive flag missing from help: %q", stderr) 298 + } 279 299 if !strings.Contains(stderr, "-n, --no-clobber") { 280 300 t.Errorf("no-clobber flag missing from help: %q", stderr) 281 301 } 282 302 if !strings.Contains(stderr, "-v, --verbose") { 283 303 t.Errorf("verbose flag missing from help: %q", stderr) 304 + } 305 + } 306 + 307 + func TestInteractiveYes(t *testing.T) { 308 + fs := newFS(t) 309 + stdout, stderr, err := runWithStdin(t, []string{"-i", "hello.txt", "existing/keep.txt"}, fs, "y\n") 310 + if err != nil { 311 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 312 + } 313 + if stdout != "" { 314 + t.Errorf("stdout = %q, want empty", stdout) 315 + } 316 + if !strings.Contains(stderr, "overwrite 'existing/keep.txt'?") { 317 + t.Errorf("stderr should contain prompt, got %q", stderr) 318 + } 319 + if exists(t, fs, "hello.txt") { 320 + t.Errorf("hello.txt should be gone") 321 + } 322 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 323 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 324 + } 325 + } 326 + 327 + func TestInteractiveYesUppercase(t *testing.T) { 328 + fs := newFS(t) 329 + _, _, err := runWithStdin(t, []string{"-i", "hello.txt", "existing/keep.txt"}, fs, "Yes\n") 330 + if err != nil { 331 + t.Fatalf("unexpected error: %v", err) 332 + } 333 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 334 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 335 + } 336 + } 337 + 338 + func TestInteractiveNo(t *testing.T) { 339 + fs := newFS(t) 340 + _, stderr, err := runWithStdin(t, []string{"-i", "hello.txt", "existing/keep.txt"}, fs, "n\n") 341 + if err != nil { 342 + t.Fatalf("decline should not be an error, got: %v", err) 343 + } 344 + if !strings.Contains(stderr, "overwrite 'existing/keep.txt'?") { 345 + t.Errorf("stderr should contain prompt, got %q", stderr) 346 + } 347 + if !exists(t, fs, "hello.txt") { 348 + t.Errorf("hello.txt should still exist (declined)") 349 + } 350 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 351 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 352 + } 353 + } 354 + 355 + func TestInteractiveEmptyLine(t *testing.T) { 356 + fs := newFS(t) 357 + _, _, err := runWithStdin(t, []string{"-i", "hello.txt", "existing/keep.txt"}, fs, "") 358 + if err != nil { 359 + t.Fatalf("empty stdin should not be an error, got: %v", err) 360 + } 361 + if !exists(t, fs, "hello.txt") { 362 + t.Errorf("hello.txt should still exist (declined)") 363 + } 364 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 365 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 366 + } 367 + } 368 + 369 + func TestInteractiveTargetMissing(t *testing.T) { 370 + fs := newFS(t) 371 + _, stderr, err := runWithStdin(t, []string{"-i", "hello.txt", "fresh.txt"}, fs, "") 372 + if err != nil { 373 + t.Fatalf("unexpected error: %v", err) 374 + } 375 + if strings.Contains(stderr, "overwrite") { 376 + t.Errorf("no prompt should appear when destination doesn't exist; stderr=%q", stderr) 377 + } 378 + if got := readFile(t, fs, "fresh.txt"); got != "hello\n" { 379 + t.Errorf("fresh.txt = %q, want %q", got, "hello\n") 380 + } 381 + } 382 + 383 + func TestForceWinsWhenLast(t *testing.T) { 384 + fs := newFS(t) 385 + _, stderr, err := runWithStdin(t, []string{"-i", "-f", "hello.txt", "existing/keep.txt"}, fs, "") 386 + if err != nil { 387 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 388 + } 389 + if strings.Contains(stderr, "overwrite") { 390 + t.Errorf("-f after -i should suppress prompt; stderr=%q", stderr) 391 + } 392 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 393 + t.Errorf("existing/keep.txt = %q, want %q (overwritten)", got, "hello\n") 394 + } 395 + } 396 + 397 + func TestInteractiveWinsWhenLast(t *testing.T) { 398 + fs := newFS(t) 399 + _, stderr, err := runWithStdin(t, []string{"-f", "-i", "hello.txt", "existing/keep.txt"}, fs, "y\n") 400 + if err != nil { 401 + t.Fatalf("unexpected error: %v", err) 402 + } 403 + if !strings.Contains(stderr, "overwrite 'existing/keep.txt'?") { 404 + t.Errorf("-i after -f should still prompt; stderr=%q", stderr) 405 + } 406 + if got := readFile(t, fs, "existing/keep.txt"); got != "hello\n" { 407 + t.Errorf("existing/keep.txt = %q, want %q", got, "hello\n") 408 + } 409 + } 410 + 411 + func TestSameFile(t *testing.T) { 412 + fs := newFS(t) 413 + _, stderr, err := run(t, []string{"hello.txt", "hello.txt"}, fs) 414 + if err == nil { 415 + t.Fatalf("expected error for same-file move") 416 + } 417 + if !strings.Contains(stderr, "are the same file") { 418 + t.Errorf("stderr should mention same file; got %q", stderr) 419 + } 420 + if !exists(t, fs, "hello.txt") { 421 + t.Errorf("hello.txt should still exist after same-file refusal") 422 + } 423 + } 424 + 425 + func TestSameFileAmongMany(t *testing.T) { 426 + fs := newFS(t) 427 + // move hello.txt, two.txt, and existing/keep.txt into existing/ 428 + // existing/keep.txt -> existing/keep.txt is the same file; others should succeed 429 + _, stderr, err := run(t, []string{"hello.txt", "two.txt", "existing/keep.txt", "existing"}, fs) 430 + if err == nil { 431 + t.Fatalf("expected error because one src is the same as a dst file") 432 + } 433 + if !strings.Contains(stderr, "are the same file") { 434 + t.Errorf("stderr should mention same file; got %q", stderr) 435 + } 436 + if exists(t, fs, "hello.txt") { 437 + t.Errorf("hello.txt should have been moved") 438 + } 439 + if exists(t, fs, "two.txt") { 440 + t.Errorf("two.txt should have been moved") 441 + } 442 + if got := readFile(t, fs, "existing/hello.txt"); got != "hello\n" { 443 + t.Errorf("existing/hello.txt = %q, want %q", got, "hello\n") 444 + } 445 + if got := readFile(t, fs, "existing/two.txt"); got != "world\n" { 446 + t.Errorf("existing/two.txt = %q, want %q", got, "world\n") 447 + } 448 + if got := readFile(t, fs, "existing/keep.txt"); got != "keep\n" { 449 + t.Errorf("existing/keep.txt = %q, want %q (untouched)", got, "keep\n") 450 + } 451 + } 452 + 453 + func TestDashDashWithDashFile(t *testing.T) { 454 + fs := newFS(t) 455 + // create a file whose name starts with a dash 456 + f, err := fs.OpenFile("-src", os.O_CREATE|os.O_WRONLY, 0o644) 457 + if err != nil { 458 + t.Fatal(err) 459 + } 460 + f.Write([]byte("dash\n")) 461 + f.Close() 462 + 463 + _, stderr, err := run(t, []string{"--", "-src", "dst.txt"}, fs) 464 + if err != nil { 465 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 466 + } 467 + if exists(t, fs, "-src") { 468 + t.Errorf("-src should be gone after move") 469 + } 470 + if got := readFile(t, fs, "dst.txt"); got != "dash\n" { 471 + t.Errorf("dst.txt = %q, want %q", got, "dash\n") 472 + } 473 + } 474 + 475 + func TestTrailingSlashOnNonDirDest(t *testing.T) { 476 + fs := newFS(t) 477 + // POSIX: single-source mv where dest ends with slash and isn't an 478 + // existing directory must fail without processing the source. 479 + _, stderr, err := run(t, []string{"hello.txt", "newpath/"}, fs) 480 + if err == nil { 481 + t.Fatalf("expected error for trailing-slash on non-dir target") 482 + } 483 + if !strings.Contains(stderr, "Not a directory") { 484 + t.Errorf("stderr should mention Not a directory; got %q", stderr) 485 + } 486 + if !exists(t, fs, "hello.txt") { 487 + t.Errorf("hello.txt should still exist after refusal") 488 + } 489 + if exists(t, fs, "newpath") { 490 + t.Errorf("newpath should not have been created") 491 + } 492 + } 493 + 494 + func TestTrailingSlashOnExistingDir(t *testing.T) { 495 + fs := newFS(t) 496 + // Trailing slash on an existing directory is fine and should move 497 + // the source into that directory. 498 + _, stderr, err := run(t, []string{"hello.txt", "dir/"}, fs) 499 + if err != nil { 500 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 501 + } 502 + if exists(t, fs, "hello.txt") { 503 + t.Errorf("hello.txt should be gone after move") 504 + } 505 + if got := readFile(t, fs, "dir/hello.txt"); got != "hello\n" { 506 + t.Errorf("dir/hello.txt = %q, want %q", got, "hello\n") 507 + } 508 + } 509 + 510 + func TestTrailingSlashVerboseDisplay(t *testing.T) { 511 + fs := newFS(t) 512 + stdout, _, err := run(t, []string{"-v", "hello.txt", "dir/"}, fs) 513 + if err != nil { 514 + t.Fatalf("unexpected error: %v", err) 515 + } 516 + // Verbose output should join cleanly without producing "dir//hello.txt" 517 + want := "renamed 'hello.txt' -> 'dir/hello.txt'\n" 518 + if stdout != want { 519 + t.Errorf("stdout = %q, want %q", stdout, want) 284 520 } 285 521 } 286 522