muse branch -c and -m: reflog copy and rename parity with git
muse branch -c / -m — reflog parity with git for copy and rename
Background
muse branch supports the full git flag surface — create, copy (-c/-C), rename (-m/-M), and delete (-d/-D). Branch create and delete are already handled correctly: create writes a branch: Created from <source> reflog entry; delete removes the reflog file. Copy and rename both have gaps relative to git's behaviour.
What git branch -c does that muse does not
git branch -c [<src>] <dest> does two things:
- Creates
<dest>at the same commit as<src>. - Copies the source branch's reflog (
.git/logs/refs/heads/<src>) to the destination (.git/logs/refs/heads/<dest>), so the new branch appears to have inherited the full history of pointer movements from the source.
Muse's implementation (lines 636–680 of muse/cli/commands/branch.py) does step 1 but skips step 2 entirely, and also writes no reflog entry for the newly created branch. A branch created by copy has an empty reflog and no trace in the destination log that it came from anywhere.
What git branch -m does that muse does not
git branch -m [<old>] <new> does two things:
- Moves the branch ref (
refs/heads/<old>→refs/heads/<new>). - Moves the reflog file (
.git/logs/refs/heads/<old>→.git/logs/refs/heads/<new>), preserving the full history under the new name. If the renamed branch wasHEAD, it also writes a new entry to the HEAD log recording the rename.
Muse's implementation (lines 540–635 of muse/cli/commands/branch.py) does step 1 but leaves the old log file at the old path (orphaned), creates no log at the new path, and writes no reflog entry. After muse branch -m old new, muse reflog --branch new returns no entries even though the branch may have a rich history.
Clarification: -c does not check out
git branch -c and muse's -c both create without checking out — this is correct and intentional. Checkout-on-create is the job of muse checkout -b and muse switch -c, both of which already work correctly.
Goal
After this issue is complete:
muse branch -c src dstcopiessrc's reflog todstand writes abranch: Copied from srcentry at the top of the destination log.muse branch -m old newmoves the reflog file fromoldtonewand writes abranch: Renamed from oldentry at the top of the destination log.- When
muse branch -mrenames the currently checked-out branch, abranch: Renamed from old to newentry is also written to the HEAD log. muse branch -C(force copy) andmuse branch -M(force rename) carry the same reflog semantics as their non-force counterparts.- All operations are safe with respect to partial failure: log files are written atomically and the branch ref is never left in an inconsistent state with the log.
Phases
Phase 1 — muse branch -c/-C: copy the reflog
When copying a branch, copy the source reflog and prepend a new entry marking the origin.
Semantics
# Before
.muse/logs/refs/heads/dev ← 12 entries (full dev history)
.muse/logs/refs/heads/feat/x ← does not exist
muse branch -c dev feat/x
# After
.muse/logs/refs/heads/dev ← unchanged (12 entries)
.muse/logs/refs/heads/feat/x ← 13 entries: 12 copied from dev +
new entry "branch: Copied from dev"
prepended at the top (newest-first means
it will appear as @{0} in muse reflog)
The new entry at the top uses old_id = <null> (copy has no predecessor on the destination ref) and new_id = <tip of src>.
Deliverables
BC_01—muse branch -c src dstcopies.muse/logs/refs/heads/srcto.muse/logs/refs/heads/dst; the copy is byte-for-byte identical except for the prepended entryBC_02— The prepended entry has operationbranch: Copied from <src>,new_id = tip of src,old_id = null IDBC_03—muse reflog --branch dst --jsonafter copy returnsentrieswith the prepended entry at index 0 and the source history followingBC_04—muse branch -C src dst(force copy, overwriting an existing branch) replaces the destination's reflog with the copied log; the previous destination log is not preservedBC_05— If the source branch has no reflog (new repo, never committed), copy writes just the single origin entry with no prior historyBC_06— Reflog copy is atomic: written to a tmp file and renamed over the destination — if the process is interrupted after the ref write but before the log write, the branch is still valid (just missing the log)BC_07— Integration test: dev branch with 5 reflog entries →muse branch -c dev backup/dev→muse reflog --branch backup/dev --jsonshows 6 entries (5 copied + 1 origin marker)
Test file: tests/test_cmd_branch_copy_reflog.py
Phase 2 — muse branch -m/-M: move the reflog
When renaming a branch, move its reflog file and write a rename entry.
Semantics
# Before
.muse/logs/refs/heads/feat/long-name ← 8 entries
muse branch -m feat/long-name feat/short
# After
.muse/logs/refs/heads/feat/long-name ← deleted
.muse/logs/refs/heads/feat/short ← 9 entries: 8 moved + new rename entry
If the renamed branch is currently checked-out (HEAD points at it), also write to the HEAD log:
# HEAD log gains:
sha256:<tip> sha256:<tip> gabriel <ts> +0000\tbranch: Renamed from feat/long-name to feat/short
Note: old and new ID are identical (the commit didn't change, only the branch name).
Deliverables
BC_08—muse branch -m old newmoves.muse/logs/refs/heads/oldto.muse/logs/refs/heads/new; old path is removedBC_09— The new log has a prepended entry with operationbranch: Renamed from <old>, same new_id and old_id (both equal to the branch tip)BC_10—muse reflog --branch new --jsonafter rename shows all prior entries (with updated path only — content unchanged) plus the rename entry at index 0BC_11— When renaming the currently checked-out branch, abranch: Renamed from <old> to <new>entry is appended to the HEAD logBC_12—muse branch -M old new(force rename) carries identical reflog semantics; the destination's prior log (if any) is discardedBC_13— Move is atomic: source log is not removed until the destination log write succeeds; if the destination write fails the source log is preservedBC_14— Parent directories for the destination path are created if needed (e.g. renamingdevtoarchive/old/dev)BC_15— Integration test: rename a 5-entry branch →muse reflog --branch new --jsonshows 6 entries → rename the currently checked-out branch → HEAD log gains one entry
Test file: tests/test_cmd_branch_rename_reflog.py
Phase 3 — muse branch operation entry standardisation
Audit all append_reflog calls in branch.py and checkout.py to ensure operation strings are consistent and complete.
Current state (from code inspection)
| Operation | append_reflog called? | Operation string |
|---|---|---|
branch <name> (create) |
✅ | branch: Created from <source> |
branch -c (copy) |
❌ | — |
branch -m (rename) |
❌ | — |
branch -d (delete) |
n/a — log file removed | — |
checkout -b |
✅ (via checkout.py) | branch: created from <source> |
After Phase 3, every branch mutation (copy, rename) writes a reflog entry in addition to Phases 1 and 2. The operation string format across all branch operations is standardised:
| Operation | Standard string |
|---|---|
| Create | branch: created from <source> |
| Copy | branch: copied from <src> |
| Rename | branch: renamed from <old> |
Deliverables
BC_16— Operation strings standardised to lowercase-first, present-tense form acrossbranch.pyandcheckout.pyBC_17— Audit test: for each branch mutation,muse reflog --jsoncontains an entry whoseoperationstarts withbranch:and is in the correct standard formBC_18— No regression: existing tests intests/test_cmd_branch.pyandtests/test_cmd_reflog*.pyremain green
Test file: additions to tests/test_cmd_branch.py and existing reflog test files
Acceptance Criteria
muse branch -c src dst→muse reflog --branch dstshows the source's full prior history plus abranch: copied from srcentry.muse branch -m old new→muse reflog --branch newshows the branch's full prior history plus abranch: renamed from oldentry; the old log path is gone.- Renaming the checked-out branch writes to both the branch log and the HEAD log.
- All 18 test IDs (
BC_01–BC_18) are green. muse code test --jsonfor changed files reports no regressions.
Out of Scope
- Remote branch copy/rename (no reflog is maintained for remote-tracking refs).
muse branch --deletelog archival — the current behaviour (delete the log on branch delete) is intentional.- Reflog expiry for orphaned log files — covered by the companion reflog lifecycle issue.
muse switch -c/muse checkout -b— these already write correct reflog entries.
Resolved — all 3 phases implemented, shipped to
main/devon all remotesCommit:
sha256:f7bf283e6cd64892d61560b1d4508b52612b0d8cd2c7d02a17fcdbbd3ae75b66(branchfeat/48-branch-copy-rename-reflog, fast-forward merged dev→main, pushed to local/staging/production).Phase 1 —
muse branch -c/-C: copy the reflogNew
copy_branch_reflog()inmuse/core/reflog.pymirrorsgit branch -c: copies the source branch's reflog to the destination and appends one newbranch: copied from <src>entry (old_id= null commit ID,new_id= source tip). Force-copy (-C) replaces the destination's reflog wholesale, matching the non-force case. Source with no reflog (never committed) → destination gets just the one origin entry. Write is atomic (tmp sibling file +os.replace).One correction to the ticket's own wording, worth flagging explicitly: the spec describes the new entry as "prepended at the top." Reflog files are stored oldest-first on disk —
read_reflog()reverses them for display, which is what makes an entry read as@{0}. So the correct on-disk operation is to append the new entry after the copied bytes, not prepend it before them. "Prepended at the top" describes the display order, not the file's byte layout. (I caught this myself via a first failing test run — my first implementation attempt literally prepended at the byte level, which inverted the newest/oldest ordering; fixed and documented in both the code and the test file.)Tests:
tests/test_cmd_branch_copy_reflog.py(BC_01–BC_07, 8 tests incl. a BC_05 companion).Phase 2 —
muse branch -m/-M: move the reflogNew
rename_branch_reflog()mirrorsgit branch -m: moves the reflog file and appends abranch: renamed from <old>entry withold_id == new_id == tip(the commit doesn't change, only the name). Destination is written andos.replace'd into place before the source is unlinked, so a failed write preserves the original (verified directly by monkeypatchingos.replaceto raise and confirming the source survives). When the renamed branch is currently checked out, a newappend_head_only_reflog()helper writes abranch: renamed from <old> to <new>entry to the HEAD log without double-writing the branch log (the existingappend_reflog()always writes both logs, which would have produced a near-duplicate entry).Tests:
tests/test_cmd_branch_rename_reflog.py(BC_08–BC_15, 9 tests incl. a BC_11 companion for the non-checked-out case).Phase 3 — operation-string standardization
Audited every
branch:-prefixed reflog write inbranch.py/checkout.py. Standardized to the lowercase-first, present-tense form (branch: created from,branch: copied from,branch: renamed from), fixing the pre-existing inconsistency wherebranch.py's create path wrotebranch: Created from(capitalized) whilecheckout.py's-bpath already wrote lowercasecreated from.Note on a ticket-internal inconsistency: Phase 1's prose example and
BC_02's literal text saybranch: Copied from <src>(capitalized), but the ticket's own Acceptance Criteria section and the Phase 3 standardization table both specify lowercasecopied from/renamed from. I went with the lowercase form throughout, since that's what two of the ticket's own sections (including Acceptance Criteria) actually require, and it's consistent with the explicit goal of Phase 3.Tests: additions to
tests/test_cmd_branch.py(BC_16–BC_18).Real bug found and fixed while building this
append_reflog()'s on-disk line format is whitespace-tokenized by_parse_line(), which requires at least 5 tokens.branch.py's branch-create path had usedauthor=""since inception — an empty author collapses two adjacent spaces in the line into one separator, producing only 4 tokens instead of 5, so_parse_line()silently rejected (dropped) every single "branch: created from X" entry ever written.muse reflog --branch <name>on a freshly-created branch has always returned nothing, regardless of how it was created. Fixed by switching toauthor="user"(matchingcheckout.py's established convention) for all ofbranch.py's reflog writes — confirmed this was the only writer in the codebase ever using an empty author.Verification
branch.pywiring (kept thereflog.pyfunctions, since they're also independently unit-tested), re-ran — 13 of 17 new tests failed as expected (the other 4 are mechanism-agnostic edge cases that correctly pass either way) — then restored and confirmed GREEN.test_cmd_branch_copy_reflog.py,test_cmd_branch_rename_reflog.py,test_cmd_branch.py,test_core_reflog.py,test_cmd_reflog.py,test_cmd_checkout.py,test_cmd_switch.py,test_switch_supercharge.py,test_checkout_integrity.py,test_phase6_checkout_interruption.py,test_cmd_switch_detached_head.py) plus a separate 507-test sweep of every other*reflog*/*branch*test file — all passing.muse-devagainst a disposable/tmprepo: copy preserves full source history with the copy marker at@{0}; rename moves the log, removes the old path, and correctly writes the HEAD-log entry when renaming the checked-out branch.Acceptance criteria — all met
muse branch -c src dst→muse reflog --branch dstshows source's full prior history plus abranch: copied from srcentry.muse branch -m old new→muse reflog --branch newshows full prior history plus abranch: renamed from oldentry; old log path is gone.muse code test-relevant suites (verified via the 453+507 test sweeps above).Out of scope (respected, not touched)
Remote branch copy/rename reflog,
muse branch --deletelog archival, reflog expiry for orphaned logs,muse switch -c/muse checkout -b(already correct).