link: plan a git-seed-v1 seed of a GitHub working copy

Merged
TE
4d ago
+1625-0

Piece 2 of the Cloud project seed: the Mac side that decides whether a folder can be seeded from its GitHub remote, and builds the one git bundle of what the remote lacks.

Leo:

Your project folder is copied up.
we'll want to deal with the edge cases for this too

Design: leo-workspace design/cloud.md, "Getting the project onto the machine". Today cmux link start-chat copy uploads every file plus all of .git. With this, a git folder with a GitHub remote sends only HEAD's unpushed commits, the staged tree, the working tree and the stash. The machine side (git-seed-v1) is built in parallel, and wiring start-chat to it is a later piece. Nothing calls this yet except the new CLI entry.

What it does

link::seed::plan::plan(folder, out_bundle) -> Result<Planned, Fallback> and plan_with_base(folder, out_bundle, base):

  • Bundle refs: refs/cmux-seed/head, refs/cmux-seed/index (staged tree, parent HEAD), refs/cmux-seed/work (working tree with untracked files that are not ignored, less secrets; parent the index commit), and refs/cmux-seed/stash when there is a stash. The one prerequisite is ^BASE. The index and work commits use a fixed author, committer and date, so the same tree gives the same sha.
  • Nothing in the working copy changes: the commits are built through a copy of the index. Every object goes to a scratch object store that borrows the repository's store through alternates, and the bundle is made from a scratch bare repository. No ref, object, index or file in the person's repository is written. A test compares git status --ignored, every ref and the stash list before and after.
  • Remote: the HEAD branch's upstream remote, else origin, else the only remote. GitHub URLs (https://github.com/O/N(.git), git@github.com:O/N(.git), ssh://git@github.com/O/N(.git)) are normalized to https://github.com/O/N.git. A login in the URL is dropped, and it is redacted from messages.
  • Base: the merge-base with the upstream, else with the remote's default branch (refs/remotes/R/HEAD, main, master). bases lists every candidate, ending with the default branch's tip, so the caller can re-plan through plan_with_base when the machine answers seed.base_missing.
  • Secrets: untracked .env*, SSH private keys (id_rsa*, id_ed25519*, ...; .pub goes), *.pem, *.p12, *.pfx, *.key, .netrc, and .npmrc/.pypirc files that hold a token stay on the Mac. They come back in skipped with a reason in plain words. Untracked nested git repositories (not submodules) are skipped and named too. Tracked files are never dropped.
  • Edge cases: big untracked files go in the bundle. Detached HEAD gives head_ref: null. Linked worktrees, alternates and shallow clones work while the base is local. Sparse checkouts send their patterns, and untracked files outside the patterns are still carried (add --sparse). lfs is true when a .gitattributes names filter=lfs. Submodules are listed with their checked-out commits.
  • Every git call runs with core.fsmonitor=false, safe.bareRepository=explicit, core.hooksPath=/dev/null, no auto gc, no signing, GIT_CONFIG_NOSYSTEM=1, GIT_OPTIONAL_LOCKS=0, GIT_NO_LAZY_FETCH=1 and stdin null. Caller GIT_DIR/GIT_INDEX_FILE/object-store variables are dropped.

Delta seed (for a mid-turn live handoff: seed, move begin, a second seed of only what changed, finish): plan_since(folder, out_bundle, &PreviousSeed{head, index, work}) builds the same refs, with prerequisites ^previous.work ^previous.head. Their trees (and the previous index's) are excluded too: the new commits are siblings of the previous ones, so without that git bundle resent every unchanged file. A 2 MB untracked file went again in full until this was added. It sets params.base and params.since to previous.work (since is null on a full seed). When the new work sha equals the previous one, nothing changed: the work commit's parents chain to the index commit and HEAD, and the fixed identity and date make the sha exact. It then answers unchanged: true with no bundle written. Previous commits this repository no longer has (after a gc) give no_previous. So that a later delta can build on them, the seed's objects go into the repository's object store unreferenced, as git stash create does. No ref, index or file changes.

Fallback codes (the folder takes the streamed copy): not_git, not_top, no_commit, no_remote, ambiguous_remote, not_github, no_base, unmerged, submodule_local_work, no_previous, git_failed.

CLI: cmux link seed plan --local FOLDER --bundle OUT [--base SHA] prints one JSON line: {"ok":true,"params":{...},"skipped":[...],"submodules":[...],"bases":[...],"bundle_bytes":N,"unchanged":false} and exits 0, or prints {"ok":false,"code":...,"why":...} and exits 1.

One contract note for the machine side: git leaves a seed ref out of the bundle when its commit is already behind a prerequisite. That covers refs/cmux-seed/head when HEAD is the base (nothing unpushed), and in a delta seed an unchanged HEAD or index. The params always name every commit, so the machine reads shas from the params, not refs from the bundle.

Follow-ups

  • A submodule with unpushed commits or dirty changes falls back (submodule_local_work) for now. The design seeds it with one bundle per submodule. Nested submodules are not walked.
  • LFS: list LFS objects only this Mac has, so they can be uploaded.
  • The CLI has no --since flag yet; the handoff lane calls plan_since in-process.
  • An in-progress merge with conflicts falls back (unmerged).

Tests

These use real fixture repositories in temp dirs, with a bare file:// repository standing in for GitHub through a url.*.insteadOf rewrite. In the key test, a clone of the remote at the base plays the machine: it verifies the bundle, fetches refs/cmux-seed/*, checks out WORK and compares every file with the original working copy, less ignored files and secrets. The INDEX diff must equal git diff --cached. A delta test runs a full seed, an unchanged re-plan, then an edit plus a new untracked file. The delta bundle is under 1/20 of the full one and applies on top of the first. Other tests cover the same tree giving the same sha, detached HEAD, stash, linked worktree, sparse, LFS, shallow, a missing base, default-tip candidates, submodule local work, unmerged, and each fallback code. All 21 pass on the fleet through cmux-ci test -- cargo test -p cmux-tui link::seed. cargo clippy -p cmux-tui --all-targets -- -D warnings is clean.

Size: about 1,600 lines, of which about 560 are tests. It is one module (plan, its git wrapper, the URL parser and the secret rules), and its pieces only make sense together, so it is not split.

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

Adds the Mac-side plan for git-seed-v1 (design/cloud.md) that decides whether a GitHub working copy can be seeded and builds a single bundle of what the remote lacks — unpushed commits, staged tree, working tree (minus ignored files and likely secrets), and the stash — without modifying the repository. Exposed via cmux link seed plan, not yet wired into start-chat.

What the plan does

  • Builds refs/cmux-seed/{head,index,work,stash} with ^BASE as prerequisite, using a scratch object store with alternates and a copied index; the person's index, refs, and files never change.
  • Picks the base from the upstream merge-base, then the remote default branch candidates; plan_with_base retries after a seed.base_missing reply.
  • plan_since produces delta seeds that send only changes since an earlier seed, with ^previous.work ^previous.head prerequisites and the previous trees excluded.
  • Leaves out untracked .env*, SSH keys, *.pem/*.p12/*.pfx/*.key, .netrc, registry files with tokens, and nested git repositories; reports them in skipped.
  • Falls back to the streamed copy for cases like not_git, no_remote, not_github, no_base, unmerged, submodule_local_work, with plain-words reasons.

Contract note

  • Git omits a seed ref when its commit is already behind a prerequisite; the params always name every commit, so the machine reads shas from params, not from the bundle.
  • 21 integration tests cover the plan, delta seeds, fallbacks, sparse/LFS/shallow, and the unchanged-repo guarantee.