gabriel / muse public
Closed #48
filed by gabriel human · 117 days ago

muse branch -c and -m: reflog copy and rename parity with git

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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:

  1. Creates <dest> at the same commit as <src>.
  2. 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:

  1. Moves the branch ref (refs/heads/<old> → refs/heads/<new>).
  2. 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 was HEAD, 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 dst copies src's reflog to dst and writes a branch: Copied from src entry at the top of the destination log.
  • muse branch -m old new moves the reflog file from old to new and writes a branch: Renamed from old entry at the top of the destination log.
  • When muse branch -m renames the currently checked-out branch, a branch: Renamed from old to new entry is also written to the HEAD log.
  • muse branch -C (force copy) and muse 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 dst copies .muse/logs/refs/heads/src to .muse/logs/refs/heads/dst; the copy is byte-for-byte identical except for the prepended entry
  • BC_02 — The prepended entry has operation branch: Copied from <src>, new_id = tip of src, old_id = null ID
  • BC_03 — muse reflog --branch dst --json after copy returns entries with the prepended entry at index 0 and the source history following
  • BC_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 preserved
  • BC_05 — If the source branch has no reflog (new repo, never committed), copy writes just the single origin entry with no prior history
  • BC_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 --json shows 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 new moves .muse/logs/refs/heads/old to .muse/logs/refs/heads/new; old path is removed
  • BC_09 — The new log has a prepended entry with operation branch: Renamed from <old>, same new_id and old_id (both equal to the branch tip)
  • BC_10 — muse reflog --branch new --json after rename shows all prior entries (with updated path only — content unchanged) plus the rename entry at index 0
  • BC_11 — When renaming the currently checked-out branch, a branch: Renamed from <old> to <new> entry is appended to the HEAD log
  • BC_12 — muse branch -M old new (force rename) carries identical reflog semantics; the destination's prior log (if any) is discarded
  • BC_13 — Move is atomic: source log is not removed until the destination log write succeeds; if the destination write fails the source log is preserved
  • BC_14 — Parent directories for the destination path are created if needed (e.g. renaming dev to archive/old/dev)
  • BC_15 — Integration test: rename a 5-entry branch → muse reflog --branch new --json shows 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 across branch.py and checkout.py
  • BC_17 — Audit test: for each branch mutation, muse reflog --json contains an entry whose operation starts with branch: and is in the correct standard form
  • BC_18 — No regression: existing tests in tests/test_cmd_branch.py and tests/test_cmd_reflog*.py remain 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 dst shows the source's full prior history plus a branch: copied from src entry.
  • muse branch -m old new → muse reflog --branch new shows the branch's full prior history plus a branch: renamed from old entry; 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 --json for changed files reports no regressions.

Out of Scope

  • Remote branch copy/rename (no reflog is maintained for remote-tracking refs).
  • muse branch --delete log 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.
◎ Activity1
●
gabriel opened this issue 117 days ago
○
gabriel 2 days ago

Resolved — all 3 phases implemented, shipped to main/dev on all remotes

Commit: sha256:f7bf283e6cd64892d61560b1d4508b52612b0d8cd2c7d02a17fcdbbd3ae75b66 (branch feat/48-branch-copy-rename-reflog, fast-forward merged dev→main, pushed to local/staging/production).

Phase 1 — muse branch -c/-C: copy the reflog

New copy_branch_reflog() in muse/core/reflog.py mirrors git branch -c: copies the source branch's reflog to the destination and appends one new branch: 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 reflog

New rename_branch_reflog() mirrors git branch -m: moves the reflog file and appends a branch: renamed from <old> entry with old_id == new_id == tip (the commit doesn't change, only the name). Destination is written and os.replace'd into place before the source is unlinked, so a failed write preserves the original (verified directly by monkeypatching os.replace to raise and confirming the source survives). When the renamed branch is currently checked out, a new append_head_only_reflog() helper writes a branch: renamed from <old> to <new> entry to the HEAD log without double-writing the branch log (the existing append_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 in branch.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 where branch.py's create path wrote branch: Created from (capitalized) while checkout.py's -b path already wrote lowercase created from.

Note on a ticket-internal inconsistency: Phase 1's prose example and BC_02's literal text say branch: Copied from <src> (capitalized), but the ticket's own Acceptance Criteria section and the Phase 3 standardization table both specify lowercase copied 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 used author="" 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 to author="user" (matching checkout.py's established convention) for all of branch.py's reflog writes — confirmed this was the only writer in the codebase ever using an empty author.

Verification

  • All new tests confirmed via genuine RED: reverted the branch.py wiring (kept the reflog.py functions, 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.
  • 453 tests across every directly-relevant suite (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.
  • Live-verified via muse-dev against a disposable /tmp repo: 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 dst shows source's full prior history plus a branch: copied from src entry.
  • ✅ muse branch -m old new → muse reflog --branch new shows full prior history plus a branch: renamed from old entry; 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) green.
  • ✅ No regressions in 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 --delete log archival, reflog expiry for orphaned logs, muse switch -c/muse checkout -b (already correct).

✓
closed this issue 2 days ago