# push: refspec syntax `local:remote` for cross-branch pushes ## Background `muse push ` always pushes the named local branch to the identically-named remote branch. There is no way to push a local branch to a differently-named remote branch without first switching to it — a friction point when doing release promotion (`dev` → `main`) or cross-branch CI uploads from a detached state. Git solved this with refspec syntax: `git push origin local:remote`. The colon separates the source (local) ref from the destination (remote) ref. Muse should support the same pattern. ## Goal ```bash # Push local main to remote main without leaving dev muse push staging main:main # Push local dev up to remote main (release promotion without switching) muse push staging dev:main # Existing single-name syntax unchanged muse push staging dev # → local dev → remote dev (unchanged) muse push local dev # → same ``` The `+` force prefix in refspec (git: `+local:remote`) is also supported as an alternative to `--force`: ```bash muse push staging +dev:main # → force-push local dev to remote main ``` ## How git does it Git parses a positional `` argument as `[+]:`: - `src` — the local ref to send (branch name, or full `refs/heads/…`) - `dst` — the remote ref to update - `+` prefix — sets force flag for this refspec only Git's push plumbing separates "what to send" (commit walk from `src`) from "what to update on the remote" (the `dst` ref). Muse's push protocol already has the same separation: the DAG walk uses the local branch tip, and the unpack-mpack call passes `branch=` to tell the server which ref to advance. The only missing piece is the CLI: today the `BRANCH` positional is used for both roles. Adding a `local:remote` parse step decouples them. ## Implementation ### Refspec parsing A small utility `parse_push_refspec(raw: str) -> tuple[str, str, bool]` that returns `(local_branch, remote_branch, force)`. Called before any push logic runs. No changes to the push protocol or server. | Input | local | remote | force | |-------|-------|--------|-------| | `"dev"` | `"dev"` | `"dev"` | `False` | | `"dev:main"` | `"dev"` | `"main"` | `False` | | `"main:main"` | `"main"` | `"main"` | `False` | | `"+dev:main"` | `"dev"` | `"main"` | `True` | | `"+dev"` | `"dev"` | `"dev"` | `True` | | `"dev:"` | error | — | — | | `":main"` | error | — | — | Empty src or dst is an error — `muse push --delete ` is the correct way to delete a remote branch. ### Push command wiring `push.py` calls `parse_push_refspec` on the `BRANCH` positional, derives `local_branch` and `remote_branch`, then: - Uses `local_branch` as the ref for the DAG walk (commit deduplication) - Passes `remote_branch` to the unpack-mpack call as the target branch name - If the refspec `+` prefix is set, OR `--force` is passed, force-push ### JSON output The `branch` field in push JSON output reflects the **remote** branch that was updated (what matters to the caller), not the local branch. This is consistent with current behavior where `local == remote`. ## Phases ### Phase 1 — Refspec parser + unit tests New function `parse_push_refspec` (in `push.py` or a shared `refspec.py`). Pure function; no I/O. #### Deliverables - [ ] `RS_01` — `parse_push_refspec("dev")` returns `("dev", "dev", False)` - [ ] `RS_02` — `parse_push_refspec("dev:main")` returns `("dev", "main", False)` - [ ] `RS_03` — `parse_push_refspec("+dev:main")` returns `("dev", "main", True)` - [ ] `RS_04` — `parse_push_refspec("+dev")` returns `("dev", "dev", True)` - [ ] `RS_05` — `parse_push_refspec("dev:")` raises `ValueError` with clear message; same for `":main"` and `"dev:main:extra"` --- ### Phase 2 — Wire into push command + integration tests Update `muse/cli/commands/push.py` to call `parse_push_refspec` and route the two branch names correctly through the push engine. #### Deliverables - [ ] `RS_06` — `muse push local dev:dev --json` succeeds; JSON `branch == "dev"`; remote `dev` advances (behaviorally identical to existing `muse push local dev`) - [ ] `RS_07` — On a repo with commits on `dev` not on `main`: `muse push local dev:main --json` advances remote `main` to local `dev`'s tip; remote `dev` is unchanged - [ ] `RS_08` — `muse push local +dev:main --json` force-pushes; equivalent to `muse push local dev:main --force` - [ ] `RS_09` — `muse push local dev:nonexistent --json` creates `nonexistent` on the remote (same as pushing a new branch by name — no special handling needed; server already supports this) - [ ] `RS_10` — `muse push local dev: --json` exits 1 with "invalid refspec" error before any network call --- ### Phase 3 — Docs Update `docs/agent-guide.md` push section and the Gitism glossary to document the new syntax. Run `muse agent-config sync` afterwards. #### Deliverables - [ ] `RS_11` — refspec syntax documented in push section of agent-guide.md with the four canonical forms (same-name, cross-name, force prefix, existing `--force` flag still works) - [ ] `RS_12` — `muse agent-config sync` reports no drift ## Acceptance criteria 1. `muse push staging main:main` from `dev` branch advances remote `main` to local `main`'s tip without requiring a branch switch. 2. `muse push staging dev:main` advances remote `main` to local `dev`'s tip. 3. `muse push staging +dev:main` force-pushes; rejects fast-forward check. 4. Existing `muse push staging dev` behaviour is unchanged. 5. All RS_01–RS_12 tests green. 6. JSON `branch` field in push output always reflects the remote branch updated. ## Out of scope - Delete via refspec (`:remote`) — use `muse push --delete ` - Full git refspec syntax (`refs/heads/…`) — branch names only - Multi-refspec in one command (`muse push local dev:main feat:feat`) — single refspec only for now