# Muse VCS — Musical Version Control System > **Status:** Canonical Implementation Reference > **E2E demo:** [`muse_e2e_demo.md`](muse_e2e_demo.md) --- ## What Muse Is Muse is a persistent, Git-style version control system for musical compositions. It tracks every committed change as a variation in a DAG (directed acyclic graph), enabling: - **Commit history** — every accepted variation is recorded with parent lineage - **Branching** — multiple variations can diverge from the same parent - **Three-way merge** — auto-merges non-conflicting changes, reports conflicts - **Drift detection** — compares HEAD snapshot against the live DAW state (`git status`) - **Checkout / time travel** — reconstruct any historical state via deterministic tool calls - **Log graph** — serialize the full commit DAG as Swift-ready JSON --- ## Module Map ### CLI Entry Point ``` maestro/muse_cli/ ├── __init__.py — Package marker ├── app.py — Typer application root (console script: `muse`) ├── errors.py — Exit-code enum (0 success / 1 user / 2 repo / 3 internal) + exceptions ├── _repo.py — Repository detection (.muse/ directory walker) └── commands/ ├── __init__.py ├── init.py — muse init ✅ fully implemented ├── status.py — muse status ✅ branch + commit state display ├── commit.py — muse commit ✅ fully implemented (issue #32) ├── log.py — muse log ✅ fully implemented (issue #33) ├── checkout.py — muse checkout ✅ fully implemented (issue #34) ├── snapshot.py — walk_workdir, hash_file, build_snapshot_manifest, compute IDs ├── models.py — MuseCliCommit, MuseCliSnapshot, MuseCliObject (SQLAlchemy) ├── db.py — open_session, upsert_object/snapshot/commit helpers ├── merge.py — muse merge (stub — issue #35) ├── remote.py — muse remote (stub — issue #38) ├── push.py — muse push (stub — issue #38) └── pull.py — muse pull (stub — issue #38) ``` The CLI delegates to existing `maestro/services/muse_*.py` service modules. Stub subcommands print "not yet implemented" and exit 0. --- ## `muse log` Output Formats ### Default (`git log` style) ``` commit a1b2c3d4e5f6... (HEAD -> main) Parent: f9e8d7c6 Date: 2026-02-27 17:30:00 boom bap demo take 1 commit f9e8d7c6... Date: 2026-02-27 17:00:00 initial take ``` Commits are printed newest-first. The first commit (root) has no `Parent:` line. ### `--graph` mode Reuses `maestro.services.muse_log_render.render_ascii_graph` by adapting `MuseCliCommit` rows to the `MuseLogGraph`/`MuseLogNode` dataclasses the renderer expects. ``` * a1b2c3d4 boom bap demo take 1 (HEAD) * f9e8d7c6 initial take ``` Merge commits (two parents) require `muse merge` (issue #35) — `parent2_commit_id` is reserved for that iteration. ### Flags | Flag | Default | Description | |------|---------|-------------| | `--limit N` / `-n N` | 1000 | Cap the walk at N commits | | `--graph` | off | ASCII DAG mode | --- ## Branching Model ### `muse checkout` — branch creation and HEAD pointer management Branches are tracked as files under `.muse/refs/heads/`, each containing the `commit_id` of the branch tip (the same convention as Git's packed-refs but in plain files). `.muse/HEAD` holds the symbolic ref of the currently active branch: ``` refs/heads/main ``` ### Switching branches `muse checkout ` rewrites `.muse/HEAD` to `refs/heads/`. Subsequent `muse commit` and `muse log` calls read this file to know which branch to operate on. ### Creating branches `muse checkout -b ` forks from the current HEAD commit: 1. Reads the current branch tip from `.muse/refs/heads/`. 2. Writes that same `commit_id` to `.muse/refs/heads/`. 3. Rewrites `.muse/HEAD` to `refs/heads/`. The new branch starts with the same history as its parent — divergence happens on the next `muse commit`. ### Dirty working-tree guard Before switching branches, `muse checkout` compares the on-disk `snapshot_id` of `muse-work/` against the last committed snapshot on the **current** branch. If they differ the command exits `1` with a message. Use `--force` / `-f` to bypass the guard. If the current branch has no commits yet (empty branch) the tree is never considered dirty. ### Flags | Flag | Description | |------|-------------| | `-b` / `--create` | Create a new branch at current HEAD and switch to it | | `--force` / `-f` | Ignore uncommitted changes in `muse-work/` | ### DB-level branch table A `muse_cli_branches` Postgres table is deferred to the `muse merge` iteration (issue #35), when multi-branch DAG queries will require stable foreign-key references. Until then, branches live exclusively in `.muse/refs/heads/`. --- ## Commit Data Model `muse commit` persists three content-addressed table types to Postgres: ### `muse_cli_objects` — File blobs (sha256-keyed) | Column | Type | Description | |--------|------|-------------| | `object_id` | `String(64)` PK | `sha256(file_bytes)` hex digest | | `size_bytes` | `Integer` | Raw file size | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | Objects are deduplicated across commits: the same file committed on two branches is stored exactly once. ### `muse_cli_snapshots` — Snapshot manifests | Column | Type | Description | |--------|------|-------------| | `snapshot_id` | `String(64)` PK | `sha256(sorted("path:object_id" pairs))` | | `manifest` | `JSON` | `{rel_path: object_id}` mapping | | `created_at` | `DateTime(tz=True)` | Wall-clock insert time | Two identical working trees always produce the same `snapshot_id`. ### `muse_cli_commits` — Commit history | Column | Type | Description | |--------|------|-------------| | `commit_id` | `String(64)` PK | Deterministic sha256 (see below) | | `repo_id` | `String(36)` | UUID from `.muse/repo.json` | | `branch` | `String(255)` | Branch name at commit time | | `parent_commit_id` | `String(64)` nullable | Previous HEAD commit on branch | | `snapshot_id` | `String(64)` FK | Points to the snapshot row | | `message` | `Text` | User-supplied commit message | | `author` | `String(255)` | Reserved (empty for MVP) | | `committed_at` | `DateTime(tz=True)` | Timestamp used in hash derivation | | `created_at` | `DateTime(tz=True)` | Wall-clock DB insert time | ### ID Derivation (deterministic) ``` object_id = sha256(file_bytes) snapshot_id = sha256("|".join(sorted(f"{path}:{oid}" for path, oid in manifest.items()))) commit_id = sha256( "|".join(sorted(parent_ids)) + "|" + snapshot_id + "|" + message + "|" + committed_at.isoformat() ) ``` Given the same working tree state, message, and timestamp two machines produce identical IDs. `sorted()` ensures insertion-order independence for both snapshot manifests and parent lists. --- ## Local Repository Structure (`.muse/`) `muse init` creates the following layout in the current working directory: ``` .muse/ repo.json Repo identity: repo_id (UUID), schema_version, created_at HEAD Current branch pointer, e.g. "refs/heads/main" config.toml [user], [auth], [remotes] configuration refs/ heads/ main Commit ID of branch HEAD (empty = no commits yet) One file per branch ``` ### File semantics | File | Source of truth for | Notes | |------|-------------------|-------| | `repo.json` | Repo identity | `repo_id` persists across `--force` reinitialise | | `HEAD` | Current branch name | Always `refs/heads/` | | `refs/heads/` | Branch → commit pointer | Empty string = branch has no commits yet | | `config.toml` | User identity, auth token, remotes | Not overwritten on `--force` | ### Repo-root detection Every CLI command locates the active repo by walking up the directory tree until `.muse/` is found: ```python # maestro/muse_cli/_repo.py find_repo_root(start: Path | None = None) -> Path | None ``` - Returns the directory containing `.muse/`, or `None` if not found (never raises). - Set `MUSE_REPO_ROOT=/path/to/repo` to override traversal (useful in tests and scripts). - `require_repo()` wraps `find_repo_root()` for command callbacks: exits 2 with "Not a Muse repository. Run `muse init`." if root is `None`. ### `config.toml` example ```toml [user] name = "Gabriel" email = "g@example.com" [auth] token = "eyJ..." # Muse Hub Bearer token — keep out of version control [remotes] [remotes.origin] url = "https://story.audio/musehub/repos/abcd1234" ``` > **Security note:** `.muse/config.toml` contains the Hub auth token. Add `.muse/config.toml` to `.gitignore` (or `.museignore`) to prevent accidental exposure. ### VCS Services ``` app/services/ ├── muse_repository.py — Persistence adapter (DB reads/writes) ├── muse_replay.py — History reconstruction (lineage walking) ├── muse_drift.py — Drift detection engine (HEAD vs working) ├── muse_checkout.py — Checkout plan builder (pure data → tool calls) ├── muse_checkout_executor.py — Checkout execution (applies plan to StateStore) ├── muse_merge_base.py — Merge base finder (LCA in the DAG) ├── muse_merge.py — Three-way merge engine ├── muse_history_controller.py— Orchestrates checkout + merge flows ├── muse_log_graph.py — DAG serializer (topological sort → JSON) ├── muse_log_render.py — ASCII graph + JSON + summary renderer └── variation/ └── note_matching.py — Note + controller event matching/diffing app/api/routes/ ├── muse.py — Production HTTP routes (5 endpoints) └── variation/ — Existing variation proposal routes app/db/ └── muse_models.py — ORM: Variation, Phrase, NoteChange tables tests/ ├── test_muse_persistence.py — Repository + lineage tests ├── test_muse_drift.py — Drift detection tests ├── test_muse_drift_controllers.py — Controller drift tests ├── test_commit_drift_safety.py — 409 conflict enforcement ├── test_muse_checkout.py — Checkout plan tests ├── test_muse_checkout_execution.py — Checkout execution tests ├── test_muse_merge.py — Merge engine tests ├── test_muse_log_graph.py — Log graph serialization tests └── e2e/ ├── muse_fixtures.py — Deterministic IDs + snapshot builders └── test_muse_e2e_harness.py — Full VCS lifecycle E2E test ``` --- ## Data Model ### Variation (ORM: `app/db/muse_models.py`) | Column | Type | Purpose | |--------|------|---------| | `variation_id` | PK | Unique ID | | `project_id` | FK | Project this belongs to | | `parent_variation_id` | FK (self) | Primary parent (lineage) | | `parent2_variation_id` | FK (self) | Second parent (merge commits only) | | `is_head` | bool | Whether this is the current HEAD | | `commit_state_id` | str | State version at commit time | | `intent` | text | User intent / description | | `status` | str | `ready` / `committed` / `discarded` | ### HeadSnapshot (`app/services/muse_replay.py`) Reconstructed from walking the variation lineage. Contains the cumulative state at any point in history: | Field | Type | Contents | |-------|------|----------| | `notes` | `dict[region_id, list[note_dict]]` | All notes per region | | `cc` | `dict[region_id, list[cc_event]]` | CC events per region | | `pitch_bends` | `dict[region_id, list[pb_event]]` | Pitch bends per region | | `aftertouch` | `dict[region_id, list[at_event]]` | Aftertouch per region | | `track_regions` | `dict[region_id, track_id]` | Region-to-track mapping | --- ## HTTP API All routes require JWT auth (`Authorization: Bearer `). Prefix: `/api/v1/muse/` | Method | Path | Purpose | |--------|------|---------| | `POST` | `/muse/variations` | Save a variation directly into history | | `POST` | `/muse/head` | Set HEAD pointer to a variation | | `GET` | `/muse/log?project_id=X` | Get the full commit DAG as `MuseLogGraph` JSON | | `POST` | `/muse/checkout` | Checkout to a variation (time travel) | | `POST` | `/muse/merge` | Three-way merge of two variations | ### Response codes | Code | Meaning | |------|---------| | 200 | Success | | 404 | Variation not found (checkout) | | 409 | Checkout blocked by drift / merge has conflicts | --- ## VCS Primitives ### Commit (save + set HEAD) ``` save_variation(session, variation, project_id, parent_variation_id, ...) set_head(session, variation_id) ``` ### Lineage ``` get_lineage(session, variation_id) → [root, ..., target] get_head(session, project_id) → HistoryNode | None get_children(session, variation_id) → [HistoryNode, ...] ``` ### Drift Detection ``` compute_drift_report(head_snapshot, working_snapshot, ...) → DriftReport ``` Compares HEAD (from DB) against working state (from StateStore). Severity levels: `CLEAN`, `DIRTY`, `DIVERGED`. ### Replay / Reconstruction ``` reconstruct_head_snapshot(session, project_id) → HeadSnapshot reconstruct_variation_snapshot(session, variation_id) → HeadSnapshot build_replay_plan(session, project_id, target_id) → ReplayPlan ``` ### Checkout ``` build_checkout_plan(target_notes, working_notes, ...) → CheckoutPlan execute_checkout_plan(plan, store, trace) → CheckoutExecutionResult checkout_to_variation(session, project_id, target_id, store, ...) → CheckoutSummary ``` ### Merge ``` find_merge_base(session, a, b) → str | None build_merge_result(base, left, right) → MergeResult merge_variations(session, project_id, left, right, store, ...) → MergeSummary ``` ### Log Graph ``` build_muse_log_graph(session, project_id) → MuseLogGraph ``` Topologically sorted (Kahn's algorithm), deterministic tie-breaking by `(timestamp, variation_id)`. Output is camelCase JSON for the Swift frontend. --- ## Architectural Boundaries 17 AST-enforced rules in `scripts/check_boundaries.py`. Key constraints: | Module | Must NOT import | |--------|----------------| | `muse_repository` | StateStore, executor, VariationService | | `muse_replay` | StateStore, executor, LLM handlers | | `muse_drift` | StateStore, executor, LLM handlers | | `muse_checkout` | StateStore, executor, handlers | | `muse_checkout_executor` | LLM handlers, VariationService | | `muse_merge`, `muse_merge_base` | StateStore, executor, MCP, handlers | | `muse_log_graph` | StateStore, executor, handlers, engines | | `note_matching` | handlers, StateStore | The boundary philosophy: Muse VCS modules are **pure data** — they consume snapshots and produce plans/reports. StateStore mutation only happens in `muse_checkout_executor` (via duck-typed store parameter) and the history controller. --- ## E2E Demo Run the full VCS lifecycle test: ```bash docker compose exec maestro pytest tests/e2e/test_muse_e2e_harness.py -v -s ``` Exercises: commit → branch → merge → conflict detection → checkout traversal. Produces: ASCII graph, JSON dump, summary table. See `muse_e2e_demo.md` for details. ---