cgcardona / muse public
commit.py python
145 lines 5.1 KB
d87ef453 Introduce Muse v2 architecture: domain-agnostic VCS with plugin interface Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """muse commit — record the current muse-work/ state as a new version.
2
3 Algorithm
4 ---------
5 1. Resolve repo root (walk up for ``.muse/``).
6 2. Read repo_id from ``.muse/repo.json``, current branch from ``.muse/HEAD``.
7 3. Walk ``muse-work/`` and hash each file → snapshot manifest.
8 4. If HEAD snapshot_id == current snapshot_id → "nothing to commit".
9 5. Compute deterministic commit_id = sha256(parents | snapshot | message | ts).
10 6. Write blob objects to ``.muse/objects/``.
11 7. Write snapshot JSON to ``.muse/snapshots/<snapshot_id>.json``.
12 8. Write commit JSON to ``.muse/commits/<commit_id>.json``.
13 9. Advance ``.muse/refs/heads/<branch>`` to the new commit_id.
14 """
15 from __future__ import annotations
16
17 import datetime
18 import json
19 import logging
20 import pathlib
21 from typing import Optional
22
23 import typer
24
25 from muse.core.errors import ExitCode
26 from muse.core.merge_engine import read_merge_state
27 from muse.core.object_store import write_object_from_path
28 from muse.core.repo import require_repo
29 from muse.core.snapshot import build_snapshot_manifest, compute_commit_id, compute_snapshot_id
30 from muse.core.store import (
31 CommitRecord,
32 SnapshotRecord,
33 get_head_snapshot_id,
34 write_commit,
35 write_snapshot,
36 )
37
38 logger = logging.getLogger(__name__)
39
40 app = typer.Typer()
41
42
43 def _read_repo_id(root: pathlib.Path) -> str:
44 return json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
45
46
47 def _read_branch(root: pathlib.Path) -> tuple[str, pathlib.Path]:
48 """Return (branch_name, ref_file_path)."""
49 head_ref = (root / ".muse" / "HEAD").read_text().strip()
50 branch = head_ref.removeprefix("refs/heads/").strip()
51 ref_path = root / ".muse" / head_ref
52 return branch, ref_path
53
54
55 def _read_parent_id(ref_path: pathlib.Path) -> str | None:
56 if not ref_path.exists():
57 return None
58 raw = ref_path.read_text().strip()
59 return raw or None
60
61
62 @app.callback(invoke_without_command=True)
63 def commit(
64 ctx: typer.Context,
65 message: Optional[str] = typer.Option(None, "-m", "--message", help="Commit message."),
66 allow_empty: bool = typer.Option(False, "--allow-empty", help="Allow committing with no changes."),
67 section: Optional[str] = typer.Option(None, "--section", help="Tag this commit with a musical section (verse, chorus, bridge…)."),
68 track: Optional[str] = typer.Option(None, "--track", help="Tag this commit with an instrument track (drums, bass, keys…)."),
69 emotion: Optional[str] = typer.Option(None, "--emotion", help="Attach an emotion label (joyful, melancholic, tense…)."),
70 author: Optional[str] = typer.Option(None, "--author", help="Override the commit author."),
71 ) -> None:
72 """Record the current muse-work/ state as a new version."""
73 if message is None and not allow_empty:
74 typer.echo("❌ Provide a commit message with -m MESSAGE.")
75 raise typer.Exit(code=ExitCode.USER_ERROR)
76
77 root = require_repo()
78
79 merge_state = read_merge_state(root)
80 if merge_state is not None and merge_state.conflict_paths:
81 typer.echo("❌ You have unresolved merge conflicts. Resolve them before committing.")
82 for p in sorted(merge_state.conflict_paths):
83 typer.echo(f" both modified: {p}")
84 raise typer.Exit(code=ExitCode.USER_ERROR)
85
86 repo_id = _read_repo_id(root)
87 branch, ref_path = _read_branch(root)
88 parent_id = _read_parent_id(ref_path)
89
90 workdir = root / "muse-work"
91 if not workdir.exists():
92 typer.echo("❌ No muse-work/ directory found. Run 'muse init' first.")
93 raise typer.Exit(code=ExitCode.USER_ERROR)
94
95 manifest = build_snapshot_manifest(workdir)
96 if not manifest and not allow_empty:
97 typer.echo("⚠️ muse-work/ is empty — nothing to commit.")
98 raise typer.Exit(code=ExitCode.USER_ERROR)
99
100 snapshot_id = compute_snapshot_id(manifest)
101
102 if not allow_empty:
103 head_snapshot = get_head_snapshot_id(root, repo_id, branch)
104 if head_snapshot == snapshot_id:
105 typer.echo("Nothing to commit, working tree clean")
106 raise typer.Exit(code=ExitCode.SUCCESS)
107
108 committed_at = datetime.datetime.now(datetime.timezone.utc)
109 parent_ids = [parent_id] if parent_id else []
110 commit_id = compute_commit_id(
111 parent_ids=parent_ids,
112 snapshot_id=snapshot_id,
113 message=message or "",
114 committed_at_iso=committed_at.isoformat(),
115 )
116
117 metadata: dict[str, str] = {}
118 if section:
119 metadata["section"] = section
120 if track:
121 metadata["track"] = track
122 if emotion:
123 metadata["emotion"] = emotion
124
125 for rel_path, object_id in manifest.items():
126 write_object_from_path(root, object_id, workdir / rel_path)
127
128 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=manifest))
129
130 write_commit(root, CommitRecord(
131 commit_id=commit_id,
132 repo_id=repo_id,
133 branch=branch,
134 snapshot_id=snapshot_id,
135 message=message or "",
136 committed_at=committed_at,
137 parent_commit_id=parent_id,
138 author=author or "",
139 metadata=metadata,
140 ))
141
142 ref_path.parent.mkdir(parents=True, exist_ok=True)
143 ref_path.write_text(commit_id)
144
145 typer.echo(f"[{branch} {commit_id[:8]}] {message or ''}")