Cloud copy fallback: name every skip, skip heavy folders and secrets outside git, progress, resume, disk_full, refuse system folders

TE
4d ago
+882-52

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: beside Cargo.toml or pom.xml.
  • build: beside package.json, a Gradle file, setup.py or pyproject.toml.
  • dist: beside package.json, setup.py or pyproject.toml.
  • Pods: beside a Podfile.
  • venv: when it holds a pyvenv.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 Lease on 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, /opt and /Volumes.
  • /home and /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-tree against its head 253391a1 is clean.
  • #30280 (sync_cli.rs, pull_into): git merge-tree against 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 a build/ 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 replaces a_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 Autofix with [code]smith
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, target beside Cargo.toml, build beside package.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) and skipped_counts; each file carries path, kind, and why.
  • Inside git nothing changes — ignored files were already skipped — and a cmux link access share keeps its file set.

Copy resilience

  • sync push and start-chat copy print 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 copy resumes 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 copy refuses the root, system top folders, and the home folder itself with folder_too_broad; a project below one of them is still copied.