Cloud copy fallback: name every skip, skip heavy folders and secrets outside git, progress, resume, disk_full, refuse system folders
Leo, 2026-10-31, on the start screen's line "Your project folder is copied up.":
Your project folder is copied up. / we'll want to deal with the edge cases for this too
This hardens the streamed copy (cmux link sync push, cmux link start-chat copy), per leo-workspace design/cloud.md, "Getting the project onto the machine", rules 4, 5 and 7. The copy stays the fallback for non-git folders, repos without a GitHub remote and machines without git-seed-v1; the git seed path is being built separately (link/seed/).
What changes
Heavy folders outside git (sync_cli/skips.rs heavy). In a non-git folder, a chat's copy skips the dependency and build folders the project rebuilds on the machine. These are always skipped: node_modules, .venv, __pycache__, .next, .nuxt, DerivedData, .gradle, .tox, .mypy_cache, .pytest_cache, .cache, .turbo and .parcel-cache. The ambiguous names are skipped only next to the file that makes them a build folder:
target: besideCargo.tomlorpom.xml.build: besidepackage.json, a Gradle file,setup.pyorpyproject.toml.dist: besidepackage.json,setup.pyorpyproject.toml.Pods: beside aPodfile.venv: when it holds apyvenv.cfg.
Inside git nothing changes, since ignored files are already skipped. A cmux link access share (push_kept) also keeps every file as before (Rules::Share). A top-level .gitignore is not read outside git (optional in the brief, left out to keep this conservative).
Secrets outside git. .env*, id_rsa*, id_ed25519*, id_ecdsa*, id_dsa*, *.pem, *.p12, *.pfx and *.key are skipped by default and named.
Named skips. The summary keeps skipped (the total) and adds skipped_files (the first 200, sorted by path) and skipped_counts:
{"ok":true,"written":N,"unchanged":N,"deleted":N,"skipped":N,"bytes":N,
"skipped_files":[{"path":"node_modules/","kind":"heavy","why":"a heavy folder rebuilt on the machine"}, ...],
"skipped_counts":{"heavy":N,"secret":N,"symlink":N,"special":N,"name":N,"reserved":N,"other":N}}
Each why is one of these:
- "secret: .env files are shared only on purpose"
- "secret: key files are shared only on purpose"
- "a symlink (to a place inside the folder)", "a symlink (to a place outside the folder)" or "a symlink (to nothing)"
- "a socket, FIFO or device"
- "a name Linux can't hold (too long | control character | not UTF-8)"
- "a heavy folder rebuilt on the machine": one entry per folder, path ending in
/ - "the name of the sync record"
other counts skips that are not named: in git, the untracked .env* files and non-UTF-8 paths, and a share's own refusals. Those git lines sit right next to #30000's change to local.rs, so naming them waits until #30000 lands (follow-up). Non-git names that are not UTF-8 used to be dropped without being counted; now they are named. Case collisions are left out because they are not a real failure on Linux.
Progress. push_inner takes an optional progress callback. cmux link sync push and cmux link start-chat copy print progress on stderr as JSON lines: the totals before anything is written, then at most one line every 250 ms, then the end. The stdout answer line keeps its shape.
{"progress":{"files_done":N,"files_total":N,"bytes_done":N,"bytes_total":N}}
files_done and bytes_done count files that were written or found unchanged.
Resume.
- Push. The manifest on the machine is now also saved during the push: after every 500 written files (or a twentieth of the push's files, when that is more) or every 256 MiB. A saved manifest lists what was written so far plus the earlier push's records for files not reached yet. The git state stays the earlier push's until the end, and this Mac's base (what a pull trusts) is still saved only at the end. A re-push skips what already landed, through the per-file hash, permissions and revision check.
start-chat copy. A retry of the same send on the same machine whose copy did not finish (cut off, answer lost, process gone) reuses that copy's folder from the ledger (Ledger::copy_place) instead of a new random one.- Two copies of the same send at once still never share a folder. Each copy holds a
Leaseon its folder while its push runs: an flock on<state>/cloud-start-chat/copies/<hash>.lock, which the system releases when the process ends or is killed. A retry that finds the folder held by a live copy takes a new folder. - After taking the lease, the ledger is read again. A copy that finished in between is answered as finished and is never pushed over.
- No chat opens in a folder before its copy finishes, so resuming never writes over a chat's changes. An abandoned half-copied folder (its send never retried on that machine) stays on the machine, as before, and is documented.
Disk full. The machine's fs.no_space (ENOSPC/EDQUOT in fs_ops/error.rs) answers "error_code":"disk_full","error":"The Cloud machine's disk is full", for both sync push and start-chat copy. The App already shows the error text.
Too-broad folders. start-chat copy refuses with "error_code":"folder_too_broad" these folders, compared exactly, both as spelled and as resolved:
/and the home folder itself./System,/Library,/Applications,/Users,/usr,/bin,/sbin,/private,/etc,/var,/tmp,/optand/Volumes./homeand/root, for Linux.
A project below one of them is still copied. Swift's CloudMoveFolder.isTooBroad is unchanged.
Swift progress: not in this PR. ProcessRunner writes the CLI's stderr to a temp file and reads it only after the process exits. Showing "Copying 1,204 of 8,310 files (312 MB of 2.1 GB)" as the step text needs a streaming reader and step plumbing in CloudStartChat, which is more than a few lines, so it is a follow-up.
Merges with
- #30000 (alternates,
local.rs): kept away from its lines.git merge-treeagainst its head 253391a1 is clean. - #30280 (
sync_cli.rs,pull_into):git merge-treeagainst its head is clean.
Tests
Run on the fleet with cmux-ci test: cargo test -p cmux-tui --test link_sync, the link::start_chat_cli / link::sync_cli / link::access_cli lib tests, and cargo clippy -p cmux-tui --all-targets -- -D warnings.
tests/link_sync.rs:
outside_git_heavy_folders_secrets_and_links_stay_and_are_named: covers heavy folders (and abuild/with no project file beside it, which is kept),.env*, keys, a symlink inside the folder, a symlink outside it, a FIFO, the reasons and the counts.a_push_reports_its_progress_against_the_totals: the first line holds the totals, the last line is at the totals, and the answer keeps its shape.a_retry_of_an_interrupted_push_writes_only_what_had_not_landed: the fake machine refuses writes after 700 of 1200. The retry finds 500 unchanged (the saved manifest) and writes 700.a_full_disk_on_the_machine_answers_disk_full.
start_chat_cli/tests.rs:
a_retry_of_a_copy_that_did_not_finish_resumes_its_folder: this replacesa_copy_whose_answer_was_lost_is_never_written_over, whose assertion flips by design.a_retry_resumes_its_folder_only_when_no_live_copy_holds_it.two_copies_of_one_send_at_once_never_share_a_folder: unchanged, still passes.a_copy_of_the_root_a_system_folder_or_home_is_refused.
Unit tests are in skips.rs.
Changelog
Improved: copying a project to a Cloud machine skips dependency and build folders and secrets outside git, names every skipped file with the reason, reports progress, resumes an interrupted copy, and says plainly when the machine's disk is full.
Risk: normal. The changes are in the copy path only. A cmux link access share keeps its file set.
Agent session: agent session
🤖 Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Hardens the streamed copy (cmux link sync push, cmux link start-chat copy) so skips are named, heavy folders and secrets stay home outside git, interrupted copies resume, and the machine's limits are reported plainly.
Skips outside git
- Dependency and build folders (
node_modules,.venv,targetbesideCargo.toml,buildbesidepackage.json, etc.) and secrets (.env*, SSH keys,*.pem,*.key) are now skipped and named with a plain reason. - The summary adds
skipped_files(first 200, sorted by path) andskipped_counts; each file carriespath,kind, andwhy. - Inside git nothing changes — ignored files were already skipped — and a
cmux link accessshare keeps its file set.
Copy resilience
sync pushandstart-chat copyprint JSON progress on stderr (totals first, then at most every 250 ms, then the end); the stdout answer keeps its shape.- The manifest is saved during the push (every 500 files or 256 MiB), so a re-push skips what already landed.
- A retried
start-chat copyresumes its unfinished copy's folder, held by an flock lease so two live copies never share one. - The machine's full disk answers
"error_code":"disk_full"with "The Cloud machine's disk is full". start-chat copyrefuses the root, system top folders, and the home folder itself withfolder_too_broad; a project below one of them is still copied.