push: refspec syntax local:remote for cross-branch pushes
Background
muse push <remote> <branch> 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
# 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:
muse push staging +dev:main # → force-push local dev to remote main
How git does it
Git parses a positional <refspec> argument as [+]<src>:<dst>:
src— the local ref to send (branch name, or fullrefs/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=<dst> 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 <remote> <branch> 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_branchas the ref for the DAG walk (commit deduplication) - Passes
remote_branchto the unpack-mpack call as the target branch name - If the refspec
+prefix is set, OR--forceis 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:")raisesValueErrorwith 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 --jsonsucceeds; JSONbranch == "dev"; remotedevadvances (behaviorally identical to existingmuse push local dev) - [ ]
RS_07— On a repo with commits ondevnot onmain:muse push local dev:main --jsonadvances remotemainto localdev's tip; remotedevis unchanged - [ ]
RS_08—muse push local +dev:main --jsonforce-pushes; equivalent tomuse push local dev:main --force - [ ]
RS_09—muse push local dev:nonexistent --jsoncreatesnonexistenton the remote (same as pushing a new branch by name — no special handling needed; server already supports this) - [ ]
RS_10—muse push local dev: --jsonexits 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--forceflag still works) - [ ]
RS_12—muse agent-config syncreports no drift
Acceptance criteria
muse push staging main:mainfromdevbranch advances remotemainto localmain's tip without requiring a branch switch.muse push staging dev:mainadvances remotemainto localdev's tip.muse push staging +dev:mainforce-pushes; rejects fast-forward check.- Existing
muse push staging devbehaviour is unchanged. - All RS_01–RS_12 tests green.
- JSON
branchfield in push output always reflects the remote branch updated.
Out of scope
- Delete via refspec (
:remote) — usemuse push --delete <remote> <branch> - 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