Skip to content

shell(cp): accept -f/--force, -r/--recursive, --verbose and --no-clobber - #47933

Open
robobun wants to merge 4 commits into
mainfrom
robobun/3c92a8ab/shell-cp-force-flag
Open

robobun wants to merge 4 commits into
mainfrom
robobun/3c92a8ab/shell-cp-force-flag

Conversation

@robobun

@robobun robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #14595

Problem

  • Bun Shell's cp -f a b prints cp: unsupported option, please open a GitHub issue -- -f. cp -r d e prints cp: illegal option -- r.
  • Cause: impl FlagParser for Opts (src/runtime/shell/builtin/cp.rs:872) rejects a leading f and has no r.

Fix

Background

Downsides

  • cp -f a b onto a b that cannot be opened still fails, where GNU cp replaces b. cp -rp stays an error, because -p is not implemented.
  • -r and -f inherit every result of -R and plain cp. cp -r dist/. out/ fills out/dist/ (shell(cp): copy the contents of a dir/. or dir/.. source into the target #38067). cp -vrT src dir copies to dir/src.
  • Cost: 24 to 47 more instructions per cp with an option, 5 without. No new allocation, syscall or binary byte.
Notes

Where this comes from

What changes

Linux x64, builtin forced on, debug builds of main b9f2ec3 and of this PR. Every row exits 1 on main.

Command main this PR
cp -f a b, -ff, -fv, -v -f unsupported option ... -- -f copies
cp -fR d e, cp -R -f d e, cp -frv d e the same copies the tree
cp -fn a b, cp -n -f a b, cp -f -n a b the same keeps b, exit 0
cp -r d e, -rR, -rf, -rv illegal option -- r (and the rest of the group) copies the tree
cp -vr d e, cp -nr d e, cp -nrv d e d is a directory (not copied) copies the tree
cp --force a b, --verbose a b, --recursive d e illegal option -- -force (and so on) as -f, -v, -R
cp --no-clobber a b illegal option -- -no-clobber keeps b, exit 0
cp -p a b, -pR, -R -p unsupported option ... -- -P -- -p
cp -rp d e, cp -rfp d e, cp -fp a b illegal option -- rp, -- rfp, unsupported option ... -- -f unsupported option ... -- -p
cp -a d e, -ar, -ra illegal option -- a unsupported option ... -- -a
cp -fi a b, cp -rL d e -- -f, illegal option -- rL unsupported option ... -- -i, -- -L
cp -rT d e, cp -ru a b illegal option -- rT, -- ru illegal option -- T, -- u
cp -f, cp -f a unsupported option ... -- -f the usage text

Commands that main accepts keep their result: -Rf, -vf, -Rv, -vR, -Rp, -RH, -RL, -RP, -Ri, -Rz, -Ra, -vH, -nz, -nf, -Rnf, -Rfn, -vnf, -n -Rf, -Rf -n. The tests pin them.

-n and -f

  • GNU cp 9.7, run here: -n -f, -f -n, -nf and -fn all keep the destination. BSD cp takes the last of the two. Bun's mv also takes the last one, as GNU mv and BSD mv do.
  • -n wins here. 1.4.4 already keeps the destination for cp -nf a b, because it ignores the f. The BSD rule would turn that command into an overwrite.

What -f does not do

  • POSIX and GNU -f remove a destination that cannot be opened and create it again. The builtin does not.
  • Linux, as a user that is not root: cp -f a b onto a mode 444 b prints cp: Permission denied: <path> and exits 1. cp a b and cp -Rf a b do the same on main. GNU cp 9.7 replaces b.
  • A copy keeps the mode of the source. So cp -f of a read-only source works one time and fails on the next run. GNU cp -f works each time.
  • macOS: a source above 128 KiB is cloned after the destination is unlinked, with or without -f. There the destination is replaced.
  • Windows: CopyFileW refuses a read-only destination with ACCESS_DENIED. That prints as cp: Operation not permitted.
  • The removal belongs in cp_open_dest_with_mkdir and in the CopyFileW arm of copy_single_file_sync (src/runtime/node/node_fs.rs). fs.cp shares both. No open PR has it: draft node:fs: replace an existing destination inside the native single-file cp #38226 gives fs.cp a replace policy and keeps the shell in place.
  • The test -f copies onto a read-only file as plain cp does pins these results on each platform. uutils' test_cp_arg_force expects the replacement.

Letters that cp does not implement

  • Main ignores every letter after the first of a group. A reply on #46270 gives the reason: released versions exit 0 for cp -RL, -Rp, -Rf and -Ri, and an error would break scripts that work.
  • No released version accepts a group that starts with r or f. So the rule does not extend to them. cp -fi a b and cp -rT d e stay errors. So does a file name such as -fonts.css that cp *.css dist gets from the glob.
  • p gets no exception. cp -rp, cp -rfp and cp -fp stay errors, and they now name -p, as cp -pr and cp -r -p do. cp -Rp still works, because released versions accept it.
    • An earlier commit of this PR ignored a p after any letter, so that -rp worked as -Rp does. A review found that it exits 0 without the times that -p asks for. Such a choice is for a maintainer, so it is not in this PR.
    • For that choice: GitHub code search for "cp -rp" filename:package.json gives 258 files. 195 spell it cp -rp, 22 spell it cp -Rp, 24 are scp -rp. A test in that commit showed what a copy with an ignored p keeps: Windows keeps the time of a file (CopyFileW), Linux and macOS do not.
    • One arm, b'p' if i > 0 => {}, would accept -rp. After a release, the rule of Hardening follow-up: fix regressions from #46021 and more crashes #46270 keeps it at exit 0.
  • -a now prints unsupported option, as -p does. The same search gives 2,436 files for "cp -a" and 2,008 for "cp -f". -a is not made an alias of -R, because it also asks for -p.
  • Checked on every group of one to three letters from R r v n f p a H i L P T t u x z (4,368 groups, with a file pair and with a directory pair):
    • Main parses 819 groups, this PR 881. No group that main parses is rejected.
    • The 62 new groups start with r or f and hold only R r v n f.
    • For a group that main parses, the result with files never changes. The result with a directory changes for 60 groups. Each has an r after the first letter. It now copies the tree, as its -R twin does.
  • After a leading R, v or n, a letter is still ignored, as on 1.4.4. Two effects stay:
    • cp -vrT src dir now copies to dir/src. On main it exits 1, because the r was ignored too.
    • A file name that starts with -R, -v or -n is still read as options. With this PR, so is a file name that starts with -r or -f and holds only R r v n f. So is a file named --force, --recursive, --verbose or --no-clobber. Where the glob gives -f first, cp *f t with the files -f and kf copies only kf and exits 0. Main refuses that command, and GNU cp 9.7 does as this PR does. ./* prevents that. shell: accept -- end-of-options delimiter in builtins #33995 adds --.

Results that -r and -f inherit

-R and plain cp give these results on main. The new spellings reach them. Run on Linux against GNU cp 9.7. Each row has an open PR. All of these PRs conflict with main now.

Command builtin GNU cp 9.7 PR
cp -r dist/. out/, out exists out/dist/index.html out/index.html #38067
cp -r dist/. . exit 0, nothing copied ./index.html #38067
cp -r d o2, d/lf links to f o2/lf links to the absolute path of d/f o2/lf links to f #37942
cp -f link out out is a link to the absolute target out is a regular file #37954
cp -f link out, out exists out keeps its content, exit 0 out is replaced #38100
cp -r d missing/parent/e, cp -f a missing/parent/b creates the parents exit 1 #37976

Not in this PR

Measurements

Release builds (LTO) of main b9f2ec3 and of this PR, linux-x64. Tools: size, nm, gdb 16.3 (stepi, catch syscall). strace, perf, valgrind and bloaty are not installed here.

  • Size: .text 110,623,657 and .rodata 26,772,460 bytes on both builds. Cp::start goes from 2,164 to 1,813 bytes. parse_short is now a function of its own (259 bytes), because parse_long also calls it. No other symbol changes size.
  • Instructions of the main thread, from the entry of Cp::start until it returns. Second cp of one script, 3 runs each, spread 0:
Command main this PR
cp a b 855 860
cp -R d e 893 918
cp -n a b 892 918
cp -v a b 893 917
cp -Rf d e 911 941
cp -Rp d e 911 950
cp -Rvn d e 918 965
cp -f a b 916
cp -r d e 918
cp --force a b 925
cp --recursive d e 935
cp --verbose a b 930
cp --no-clobber a b 937
  • Allocator calls in that window: 7 in every row on both builds.
  • File and descriptor syscalls of all threads, from the second Cp::start to exit, 3 runs each, identical:
New spelling Existing spelling Syscalls
cp -r d e, cp --recursive d e cp -R d e 23 each
cp --verbose a b cp -v a b 19 each
cp --no-clobber a b, b exists cp -n a b 9 each
cp -f a b, cp --force a b cp a b 18 each, with and without b
  • The existing spellings give the same counts on main.

Tests

  • New: one table of 71 commands that runs in one child process, the two repros of cp -r illegal option in bun shell on Windows #14595, and a read-only destination with two source sizes.
  • Debug and ASAN build of main b9f2ec3: 4 fail, 11 pass. With this PR: 15 pass.
  • Of the 71 table rows, 26 give the same result on main. They pin released behaviour. The other 45 exit 1 on main: 25 now exit 0, and 20 exit 1 with another message.
  • Mutations, each built and run:
    • the guard for every leader: 11 rows fail
    • no a arm: 3 rows fail
    • no v in the leaders: 1 row fails
    • f that resets -n: 6 rows and the read-only test fail
  • All 44 files of test/js/bun/shell and test/cli/run/run-shell.test.ts: 1,055 pass, 2 fail. The same 2 fail on main on this machine: shell load > immediate exit, and bun exec > it works2 when the whole directory runs in one process.
  • Windows and macOS do not run on my machine, so their expectations come from the code. The file passes on those lanes: 43 tests on Windows x64 and aarch64, 14 on macOS x64 and aarch64. Linux: 15.
  • The read-only test takes the unprivileged spawn options one time for its six commands. Two holders change the mode of the temporary root under each other.
  • On macOS the verbose lines of a directory copy depend on clonefile. The table accepts no line (the clone worked) or both lines (it did not).

no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/commands/cp.test.ts

The cp builtin rejected `-f` as the first flag of a group, had no arm for
`-r`, and took no long option.

`-f` is now accepted and sets nothing: the copy already overwrites unless
`-n` is given, and `-n` wins over `-f` in any order, as in GNU cp. `-r`
means `-R` in any position. `--recursive`, `--verbose`, `--no-clobber` and
`--force` run the arm of their letter.

A flag that cp does not implement is still ignored after a leading `R`,
`v` or `n`, as in the released versions. After `r` or `f` it is an error,
except `p`, which is ignored after any flag. `-p` and `-a` name themselves
in the unsupported-option error.

Fixes #14595
@github-actions github-actions Bot added the claude label Nov 4, 2026
@robobun

robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status of #47933

Reproduced on main b9f2ec3 (Linux x64, debug build, the builtin forced on with BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS=1):

  • bun exec 'cp -f a b' prints cp: unsupported option, please open a GitHub issue -- -f and exits 1.
  • bun exec 'cp -r d e' prints cp: illegal option -- r and exits 1.

With this PR both commands copy. Main b1007f1 has the same cp parser, so it still fails there.

This PR replaces #35616, which can be closed. The check against #35616:

The diff is green. At 05fbdc8, test/js/bun/shell/commands/cp.test.ts passes on every lane of build 137686: Linux (15 tests), Windows x64 and aarch64 (43), macOS x64 and aarch64 (14). The build is red because of 27 files in test/aot/ and test/bundler/compile-aot-size.test.ts. They also fail on main, and this diff does not touch them.

@robobun

robobun commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:09 AM PT - Nov 5th, 2026

❌ @robobun, your commit 05fbdc8 has 38 failures in Build #137686 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47933

That installs a local version of the PR into your bun-47933 executable, so you can run:

bun-47933 --bun

…only test

Two concurrent tests each took and released `unprivilegedSpawnOptions`.
That helper changes the modes of the directories above the temporary
directory, so one test could remove the access that the child of the
other test needed. One test now runs the six commands under one call.

A second test pins the time of a file after `cp -fp`: the `p` is ignored,
and only the copy of Windows keeps the time.
@robobun
robobun marked this pull request as ready for review November 4, 2026 19:21
@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent review.

Walkthrough

The cp builtin now accepts additional short and long option forms, including lowercase -r. Tests cover option parsing, recursive copying, and copies to read-only destinations.

Changes

cp Option Parsing and Copy Behavior

Layer / File(s) Summary
cp option parsing
src/runtime/shell/builtin/cp.rs
The option documentation lists long forms. Parsing adds long-option mappings, accepts lowercase -r, treats -f as a no-op, and changes handling of grouped flags and unsupported -p.
Option parsing and recursive-copy tests
test/js/bun/shell/commands/cp.test.ts
Child-process tests cover option forms and combinations, errors, output, copied files, and recursive copying from TypeScript and shell scripts.
Read-only destination tests
test/js/bun/shell/commands/cp.test.ts
Tests cover plain cp, -f, and -n -f on read-only destinations at two file sizes. The test helper forwards optional spawn options for unprivileged test cases.

Suggested reviewers: jarred-sumner

Severity of issue fixed: Medium

Merge Risk: Minimal · up to 05fbd

No confirmed merge-blocking risk remains in the reviewed changes.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title names the main change: the Bun Shell cp builtin now accepts the -f/--force, -r/--recursive, --verbose and --no-clobber options. The raw summary and objectives confirm this is t…
Linked Issues check Passed Issue #14595 requires cp -r folder dest to work in Bun Shell on Windows, as it does in bash. At the reviewed head, Opts::parse_short in src/runtime/shell/builtin/cp.rs maps b'r' and b'R' to …
Out of Scope Changes check Passed The code change at the reviewed head is limited to the Opts option parser in src/runtime/shell/builtin/cp.rs. The PR description reports changes to cp.test.ts only. The parser changes go beyond …
Description check Passed The description explains what the PR changes, why the change is needed, known limitations, and how the code was verified. It does not use the exact template headings, but it provides the required info…
Full details: Title check

Explanation

The title names the main change: the Bun Shell cp builtin now accepts the -f/--force, -r/--recursive, --verbose and --no-clobber options. The raw summary and objectives confirm this is the primary change, and the title is concise and specific. It does not mention the -p error message change or the parsing leniency for later letters, but those are secondary details.

Full details: Linked Issues check

Explanation

Issue #14595 requires cp -r folder dest to work in Bun Shell on Windows, as it does in bash. At the reviewed head, Opts::parse_short in src/runtime/shell/builtin/cp.rs maps b'r' and b'R' to the same self.recursive = true state. The diff from the merge base confirms this change. The PR description states that the two #14595 repros are added as tests in test/js/bun/shell/commands/cp.test.ts. This assessment did not read the test file. The coding requirement is met in the parser. The test coverage claim is reported by the PR, not confirmed by a separate read. Windows behavior is not run here. The PR description says CI passed the test file on Windows x64 and aarch64.

Full details: Out of Scope Changes check

Explanation

The code change at the reviewed head is limited to the Opts option parser in src/runtime/shell/builtin/cp.rs. The PR description reports changes to cp.test.ts only. The parser changes go beyond the literal request in #14595, which covers -r only. They add -f as a no-op, the long forms --recursive, --verbose, --no-clobber and --force, -n precedence over -f, and new unsupported option results for -a and -p. These changes stay within the cp option parsing that the PR objectives describe. They do not touch other subsystems. The test helper change that forwards uid and gid supports the read-only destination tests. No unrelated change is shown.

Full details: Description check

Explanation

The description explains what the PR changes, why the change is needed, known limitations, and how the code was verified. It does not use the exact template headings, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 7f66e25 and f3f8d01.

Files selected for processing: 2

  • src/runtime/shell/builtin/cp.rs
  • test/js/bun/shell/commands/cp.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/shell/builtin/cp.rs Resolved
Comment thread test/js/bun/shell/commands/cp.test.ts Resolved
Comment thread src/runtime/shell/builtin/cp.rs Resolved
Comment thread src/runtime/shell/builtin/cp.rs Resolved
The previous commit ignored a `p` that followed another flag, so
`cp -rp` and `cp -fp` exited 0 without the times that `-p` asks for.
They now fail with `unsupported option -- -p`, as `cp -pr` and
`cp -r -p` do. `cp -Rp` keeps working, because released versions
accept it.

The options test accepts both outputs of a verbose directory copy on
macOS: no line when clonefile copies the directory, and two lines when
that call fails.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after f3f8d01 and found no bugs; that push addresses both points from my earlier pass (-p after -r/-f now fails with unsupported option -- -p, and the macOS verbose directory row accepts either clonefile outcome). Because -f is accepted as a no-op rather than the POSIX unlink-and-recreate, and the "ignore later letters only after R/v/n" rule is a compatibility policy, a maintainer's call on those semantics is still worthwhile.

What was reviewed:

  • parse_long delegating to parse_short(short, flag, 0): the four long names can only hit the R/v/n/f arms, so the i > 0 and IllegalOption(&smallflags[i..]) arms are unreachable from that path.
  • The narrowed _ if i > 0 && matches!(smallflags[0], b'R' | b'v' | b'n') rule: every cluster the base accepted still parses; r/f-led clusters admit only R r v n f and otherwise fail loudly, which the table pins.
  • New tests: child pipes drained with Promise.all, temp dir and process released via using/await using, read-only destinations restored in finally, and unprivilegedSpawnOptions exists in test/harness.ts:829 with the Disposable shape the test uses.
Extended reasoning...

The diff touches only the FlagParser impl for Opts in src/runtime/shell/builtin/cp.rs (about 20 lines: long-name mapping, -r as -R, -f as a no-op, -a/-p error arms, and a narrowed ignore-after-first-letter rule) plus about 215 lines of new tests in test/js/bun/shell/commands/cp.test.ts. It touches no security-sensitive surface: no path handling, privilege, or network code changes, only option-letter dispatch. The latest commit resolves both optional findings from my previous review, no CODEOWNER covers these paths, and no other reviewer has requested changes. I deferred rather than approved because the change fixes user-facing shell compatibility policy (-f accepted without the POSIX force semantics, -Rp still silently ignoring p) that the PR itself says is a maintainer's choice.

`-Rv`, `-vR`, `-nrv` and `-frv` are the group orders in the tests of
#35616. `-RL` and `-RP` complete the groups that stay accepted after a
leading `R`.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cp -r illegal option in bun shell on Windows

1 participant