gabriel / muse public
test_branch_intent_created_by.py python
729 lines 30.0 KB
Raw
sha256:f8da4e8a3d3f70a3d5b8e754b5e775f9785f6021ac2411a7ef82e64e10917ee3 feat: persist real branch-creation provenance, separate fro… Sonnet 5 minor ⚠ breaking 1 day ago
1 """TDD tests for two new ``muse branch`` features.
2
3 Feature 1 — Branch intent + resumable
4 --------------------------------------
5 ``muse branch <name> [--intent TEXT] [--resumable]``
6
7 - Stores ``intent`` and ``resumable`` in ``.muse/config.toml`` under
8 ``[branch."<name>"]`` on create.
9 - Surfaces both fields in ``branch --json`` listing output.
10 - ``muse branch --resumable`` filters the listing to resumable branches only.
11
12 Feature 2 — created_by / created_at / last_committer (musehub#216)
13 --------------------------------------------------------------------
14 ``branch --json`` surfaces an immutable creation record -- ``created_by``
15 (``{handle, kind}``) and ``created_at`` -- written once at the moment
16 ``muse switch -c`` / ``muse checkout -b`` / ``muse branch <name>`` creates
17 the branch. This is distinct from ``last_committer`` (``{handle, kind}``),
18 which always reflects whoever authored the branch's current tip commit and
19 changes every time the tip moves. Branches created before this feature
20 shipped have no creation record; for those, ``created_by`` falls back to
21 the legacy tip-authorship value (what this field used to mean) and
22 ``created_at`` is ``null``.
23
24 Test categories
25 ---------------
26 - unit : config.py helpers (write_branch_meta, read_branch_meta)
27 - integration : parser flags, config.toml round-trip, listing JSON schema
28 - e2e : full CLI round-trips via CliRunner
29 - security : intent injection (ANSI, newlines, TOML metacharacters)
30 - data_integrity: resumable flag, intent survive save → list cycle
31 - performance : listing 50 branches with intent under 1 s
32 """
33
34 from __future__ import annotations
35 from collections.abc import Mapping
36
37 import json
38 import os
39 import pathlib
40 import time
41 import tomllib
42
43 import pytest
44
45 from tests.cli_test_helper import CliRunner, InvokeResult
46 from muse.core.refs import get_head_commit_id
47 from muse.core.paths import config_toml_path, heads_dir
48
49 runner = CliRunner()
50
51
52 # ---------------------------------------------------------------------------
53 # Helpers
54 # ---------------------------------------------------------------------------
55
56
57 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
58 saved = os.getcwd()
59 try:
60 os.chdir(repo)
61 return runner.invoke(None, args)
62 finally:
63 os.chdir(saved)
64
65
66 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
67 return _invoke(repo, ["branch", *extra])
68
69
70 def _branch_json(repo: pathlib.Path) -> list:
71 return json.loads(_branch(repo, "--json").output)
72
73
74 def _commit(repo: pathlib.Path, msg: str = "commit") -> InvokeResult:
75 return _invoke(repo, ["commit", "-m", msg])
76
77
78 def _config(repo: pathlib.Path) -> Mapping[str, object]:
79 p = config_toml_path(repo)
80 if not p.exists():
81 return {}
82 with p.open("rb") as f:
83 return tomllib.load(f)
84
85
86 @pytest.fixture()
87 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
88 saved = os.getcwd()
89 try:
90 os.chdir(tmp_path)
91 runner.invoke(None, ["init"])
92 finally:
93 os.chdir(saved)
94 (tmp_path / "a.py").write_text("x = 1\n")
95 _commit(tmp_path, "initial")
96 return tmp_path
97
98
99 @pytest.fixture()
100 def agent_repo(tmp_path: pathlib.Path) -> pathlib.Path:
101 """Repo with an agent-attributed commit on main."""
102 saved = os.getcwd()
103 try:
104 os.chdir(tmp_path)
105 runner.invoke(None, ["init"])
106 finally:
107 os.chdir(saved)
108 (tmp_path / "a.py").write_text("x = 1\n")
109 _invoke(tmp_path, [
110 "commit", "-m", "agent commit",
111 "--agent-id", "claude-code",
112 "--model-id", "claude-sonnet-4-6",
113 ])
114 return tmp_path
115
116
117 # ===========================================================================
118 # Unit: config.py helpers
119 # ===========================================================================
120
121
122 class TestWriteBranchMeta:
123 """write_branch_meta persists intent + resumable to config.toml."""
124
125 def test_writes_intent_to_config(self, repo: pathlib.Path) -> None:
126 from muse.cli.config import write_branch_meta
127 write_branch_meta(repo, "feat/x", intent="refactor auth")
128 data = _config(repo)
129 assert data["branch"]["feat/x"]["intent"] == "refactor auth"
130
131 def test_writes_resumable_true(self, repo: pathlib.Path) -> None:
132 from muse.cli.config import write_branch_meta
133 write_branch_meta(repo, "task/y", resumable=True)
134 data = _config(repo)
135 assert data["branch"]["task/y"]["resumable"] is True
136
137 def test_writes_resumable_false(self, repo: pathlib.Path) -> None:
138 from muse.cli.config import write_branch_meta
139 write_branch_meta(repo, "task/z", resumable=False)
140 data = _config(repo)
141 assert data["branch"]["task/z"]["resumable"] is False
142
143 def test_writes_both_fields(self, repo: pathlib.Path) -> None:
144 from muse.cli.config import write_branch_meta
145 write_branch_meta(repo, "feat/both", intent="doing X", resumable=True)
146 data = _config(repo)
147 sec = data["branch"]["feat/both"]
148 assert sec["intent"] == "doing X"
149 assert sec["resumable"] is True
150
151 def test_does_not_clobber_upstream_fields(self, repo: pathlib.Path) -> None:
152 """Existing remote/merge keys must survive a write_branch_meta call."""
153 p = config_toml_path(repo)
154 p.write_text(
155 '[branch."main"]\nremote = "origin"\nmerge = "refs/heads/main"\n'
156 )
157 from muse.cli.config import write_branch_meta
158 write_branch_meta(repo, "main", intent="track origin")
159 data = _config(repo)
160 sec = data["branch"]["main"]
161 assert sec.get("remote") == "origin"
162 assert sec.get("merge") == "refs/heads/main"
163 assert sec.get("intent") == "track origin"
164
165 def test_updates_existing_entry(self, repo: pathlib.Path) -> None:
166 from muse.cli.config import write_branch_meta
167 write_branch_meta(repo, "feat/up", intent="first intent", resumable=False)
168 write_branch_meta(repo, "feat/up", intent="updated intent", resumable=True)
169 data = _config(repo)
170 sec = data["branch"]["feat/up"]
171 assert sec["intent"] == "updated intent"
172 assert sec["resumable"] is True
173
174 def test_multiple_branches_independent(self, repo: pathlib.Path) -> None:
175 from muse.cli.config import write_branch_meta
176 write_branch_meta(repo, "feat/a", intent="alpha")
177 write_branch_meta(repo, "feat/b", intent="beta", resumable=True)
178 data = _config(repo)
179 assert data["branch"]["feat/a"]["intent"] == "alpha"
180 assert "resumable" not in data["branch"]["feat/a"]
181 assert data["branch"]["feat/b"]["intent"] == "beta"
182 assert data["branch"]["feat/b"]["resumable"] is True
183
184 def test_creates_config_file_if_absent(self, repo: pathlib.Path) -> None:
185 p = config_toml_path(repo)
186 p.unlink(missing_ok=True)
187 from muse.cli.config import write_branch_meta
188 write_branch_meta(repo, "new-branch", intent="fresh")
189 assert p.exists()
190 data = _config(repo)
191 assert data["branch"]["new-branch"]["intent"] == "fresh"
192
193
194 class TestReadBranchMeta:
195 """read_branch_meta returns the stored dict (or empty) for a branch."""
196
197 def test_returns_intent_and_resumable(self, repo: pathlib.Path) -> None:
198 from muse.cli.config import write_branch_meta, read_branch_meta
199 write_branch_meta(repo, "feat/r", intent="do X", resumable=True)
200 meta = read_branch_meta(repo, "feat/r")
201 assert meta.get("intent") == "do X"
202 assert meta.get("resumable") is True
203
204 def test_returns_empty_for_unknown_branch(self, repo: pathlib.Path) -> None:
205 from muse.cli.config import read_branch_meta
206 assert read_branch_meta(repo, "nonexistent") == {}
207
208 def test_returns_empty_when_no_config(self, repo: pathlib.Path) -> None:
209 from muse.cli.config import read_branch_meta
210 (config_toml_path(repo)).unlink(missing_ok=True)
211 assert read_branch_meta(repo, "main") == {}
212
213
214 # ===========================================================================
215 # Unit: parser flags
216 # ===========================================================================
217
218
219 class TestParserFlags:
220 def _parse(self, *args: str) -> "argparse.Namespace":
221 import argparse
222 from muse.cli.commands.branch import register
223 p = argparse.ArgumentParser()
224 sub = p.add_subparsers()
225 register(sub)
226 return p.parse_args(["branch", *args])
227
228 def test_intent_flag(self) -> None:
229 ns = self._parse("new-branch", "--intent", "refactor the thing")
230 assert ns.intent == "refactor the thing"
231
232 def test_intent_default_none(self) -> None:
233 ns = self._parse("new-branch")
234 assert ns.intent is None
235
236 def test_resumable_flag(self) -> None:
237 ns = self._parse("new-branch", "--resumable")
238 assert ns.resumable is True
239
240 def test_resumable_default_false(self) -> None:
241 ns = self._parse("new-branch")
242 assert ns.resumable is False
243
244 def test_resumable_filter_flag(self) -> None:
245 ns = self._parse("--resumable")
246 assert ns.resumable is True
247
248
249 # ===========================================================================
250 # Integration: --intent / --resumable on create
251 # ===========================================================================
252
253
254 class TestCreateWithIntent:
255 def test_create_with_intent_exits_0(self, repo: pathlib.Path) -> None:
256 result = _branch(repo, "feat/x", "--intent", "do the thing")
257 assert result.exit_code == 0
258
259 def test_create_stores_intent_in_config(self, repo: pathlib.Path) -> None:
260 _branch(repo, "feat/config-test", "--intent", "store me")
261 data = _config(repo)
262 assert data["branch"]["feat/config-test"]["intent"] == "store me"
263
264 def test_create_stores_resumable_in_config(self, repo: pathlib.Path) -> None:
265 _branch(repo, "feat/res", "--resumable")
266 data = _config(repo)
267 assert data["branch"]["feat/res"]["resumable"] is True
268
269 def test_create_without_intent_has_no_intent_resumable_entries(
270 self, repo: pathlib.Path
271 ) -> None:
272 """No intent/resumable passed -> those keys are absent, but creation
273 provenance (musehub#216) is still written unconditionally."""
274 _branch(repo, "feat/plain")
275 data = _config(repo)
276 branch_sec = data.get("branch", {})
277 assert "feat/plain" in branch_sec
278 assert "intent" not in branch_sec["feat/plain"]
279 assert "resumable" not in branch_sec["feat/plain"]
280 assert branch_sec["feat/plain"]["created_by_kind"] == "human"
281
282 def test_create_json_includes_intent(self, repo: pathlib.Path) -> None:
283 result = _branch(repo, "feat/j", "--intent", "json intent", "--json")
284 assert result.exit_code == 0
285 data = json.loads(result.output)
286 assert data.get("intent") == "json intent"
287
288 def test_create_json_includes_resumable(self, repo: pathlib.Path) -> None:
289 result = _branch(repo, "feat/jr", "--resumable", "--json")
290 data = json.loads(result.output)
291 assert data.get("resumable") is True
292
293 def test_create_json_resumable_false_when_not_set(self, repo: pathlib.Path) -> None:
294 result = _branch(repo, "feat/nores", "--json")
295 data = json.loads(result.output)
296 assert data.get("resumable") is False
297
298
299 # ===========================================================================
300 # Integration: listing JSON includes intent, resumable, created_by
301 # ===========================================================================
302
303
304 class TestListJsonNewFields:
305 def test_list_json_has_intent_field(self, repo: pathlib.Path) -> None:
306 _branch(repo, "feat/listed", "--intent", "listed intent")
307 result = _branch(repo, "--json")
308 data = json.loads(result.output)
309 entry = next(b for b in data if b["name"] == "feat/listed")
310 assert "intent" in entry
311 assert entry["intent"] == "listed intent"
312
313 def test_list_json_intent_null_for_plain_branch(self, repo: pathlib.Path) -> None:
314 _branch(repo, "feat/no-intent")
315 result = _branch(repo, "--json")
316 data = json.loads(result.output)
317 entry = next(b for b in data if b["name"] == "feat/no-intent")
318 assert entry.get("intent") is None
319
320 def test_list_json_has_resumable_field(self, repo: pathlib.Path) -> None:
321 _branch(repo, "feat/reslist", "--resumable")
322 result = _branch(repo, "--json")
323 data = json.loads(result.output)
324 entry = next(b for b in data if b["name"] == "feat/reslist")
325 assert "resumable" in entry
326 assert entry["resumable"] is True
327
328 def test_list_json_resumable_false_for_plain_branch(self, repo: pathlib.Path) -> None:
329 result = _branch(repo, "--json")
330 data = json.loads(result.output)
331 main = next(b for b in data if b["name"] == "main")
332 assert main.get("resumable") is False
333
334 def test_list_json_has_created_by_field(self, repo: pathlib.Path) -> None:
335 result = _branch(repo, "--json")
336 data = json.loads(result.output)
337 assert "created_by" in data[0]
338
339 def test_list_json_created_by_legacy_fallback_for_agent_commit(
340 self, agent_repo: pathlib.Path
341 ) -> None:
342 """main has no creation record (created by `muse init`, not a
343 branch-creating command) — falls back to legacy tip-authorship,
344 now wrapped in the {handle, kind} shape musehub#216 introduces."""
345 result = _branch(agent_repo, "--json")
346 data = json.loads(result.output)
347 main = next(b for b in data if b["name"] == "main")
348 assert main["created_by"] == {"handle": "claude-code", "kind": "agent"}
349
350 def test_list_json_created_by_null_for_human_commit(
351 self, repo: pathlib.Path
352 ) -> None:
353 result = _branch(repo, "--json")
354 data = json.loads(result.output)
355 main = next(b for b in data if b["name"] == "main")
356 # No creation record (main came from `muse init`) and no agent_id on
357 # the tip commit — legacy fallback has nothing to report.
358 assert main["created_by"] is None
359
360 def test_list_json_created_by_null_for_empty_branch(
361 self, repo: pathlib.Path
362 ) -> None:
363 (heads_dir(repo) / "empty").write_text("")
364 result = _branch(repo, "--json")
365 data = json.loads(result.output)
366 entry = next(b for b in data if b["name"] == "empty")
367 assert entry["created_by"] is None
368
369 def test_list_json_created_by_real_record_for_new_branch(
370 self, repo: pathlib.Path
371 ) -> None:
372 """A branch created via `muse branch <name>` gets a real creation
373 record — independent of who authored the tip commit."""
374 _branch(repo, "feat/created-record")
375 result = _branch(repo, "--json")
376 data = json.loads(result.output)
377 entry = next(b for b in data if b["name"] == "feat/created-record")
378 assert entry["created_by"] == {"handle": "human", "kind": "human"}
379 assert entry["created_at"] is not None
380
381 def test_list_json_created_at_null_for_legacy_branch(
382 self, repo: pathlib.Path
383 ) -> None:
384 main = next(b for b in _branch_json(repo) if b["name"] == "main")
385 assert main["created_at"] is None
386
387 def test_list_json_has_last_committer_field(
388 self, agent_repo: pathlib.Path
389 ) -> None:
390 result = _branch(agent_repo, "--json")
391 data = json.loads(result.output)
392 main = next(b for b in data if b["name"] == "main")
393 assert main["last_committer"] == {"handle": "claude-code", "kind": "agent"}
394
395 def test_created_by_independent_of_last_committer(
396 self, agent_repo: pathlib.Path
397 ) -> None:
398 """The bug this ticket fixes: creating a new branch on top of an
399 agent-authored tip must NOT make created_by report the agent as the
400 branch's creator — only last_committer should reflect the tip."""
401 _branch(agent_repo, "child-branch")
402 data = _branch_json(agent_repo)
403 child = next(b for b in data if b["name"] == "child-branch")
404 assert child["created_by"] == {"handle": "human", "kind": "human"}
405 assert child["last_committer"] == {"handle": "claude-code", "kind": "agent"}
406
407 def test_created_by_handle_from_env_agent_handle(
408 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
409 ) -> None:
410 """MUSE_AGENT_HANDLE env var attributes creation to the actual
411 invoking agent, not whoever authored the repo's existing commits."""
412 monkeypatch.setenv("MUSE_AGENT_HANDLE", "some-other-agent")
413 _branch(repo, "feat/agent-created")
414 data = _branch_json(repo)
415 entry = next(b for b in data if b["name"] == "feat/agent-created")
416 assert entry["created_by"] == {"handle": "some-other-agent", "kind": "agent"}
417
418 def test_created_by_immutable_across_later_commits(
419 self, repo: pathlib.Path
420 ) -> None:
421 """Once written, the creation record must not change even as the
422 branch's tip moves to new commits by a different author."""
423 _branch(repo, "feat/stable-record")
424 before = next(
425 b for b in _branch_json(repo) if b["name"] == "feat/stable-record"
426 )
427 _invoke(repo, ["switch", "feat/stable-record"])
428 (repo / "b.py").write_text("y = 2\n")
429 _invoke(repo, ["code", "add", "b.py"])
430 _invoke(repo, [
431 "commit", "-m", "agent commit on top",
432 "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6",
433 ])
434 after = next(
435 b for b in _branch_json(repo) if b["name"] == "feat/stable-record"
436 )
437 assert after["created_by"] == before["created_by"]
438 assert after["created_at"] == before["created_at"]
439 assert after["last_committer"] == {"handle": "claude-code", "kind": "agent"}
440
441 def test_switch_create_writes_creation_record(
442 self, repo: pathlib.Path
443 ) -> None:
444 """`muse switch -c` is the other branch-creation entrypoint and must
445 write the same creation record as `muse branch <name>`."""
446 _invoke(repo, ["switch", "-c", "task/via-switch"])
447 entry = next(
448 b for b in _branch_json(repo) if b["name"] == "task/via-switch"
449 )
450 assert entry["created_by"] == {"handle": "human", "kind": "human"}
451 assert entry["created_at"] is not None
452
453 def test_schema_complete(self, repo: pathlib.Path) -> None:
454 """All new fields must appear in the listing schema."""
455 result = _branch(repo, "--json")
456 data = json.loads(result.output)
457 required = {"name", "current", "commit_id", "committed_at",
458 "last_message", "upstream", "intent", "resumable",
459 "created_by", "created_at", "last_committer"}
460 missing = required - set(data[0].keys())
461 assert not missing, f"branch --json missing fields: {missing}"
462
463
464 # ===========================================================================
465 # E2E: --resumable listing filter
466 # ===========================================================================
467
468
469 class TestResumableFilter:
470 def test_resumable_filter_shows_only_resumable(self, repo: pathlib.Path) -> None:
471 _branch(repo, "task/resumable-1", "--resumable")
472 _branch(repo, "task/resumable-2", "--resumable")
473 _branch(repo, "task/not-resumable")
474 result = _branch(repo, "--resumable", "--json")
475 assert result.exit_code == 0
476 data = json.loads(result.output)
477 names = [b["name"] for b in data]
478 assert "task/resumable-1" in names
479 assert "task/resumable-2" in names
480 assert "task/not-resumable" not in names
481 assert "main" not in names
482
483 def test_resumable_filter_empty_when_none(self, repo: pathlib.Path) -> None:
484 result = _branch(repo, "--resumable", "--json")
485 assert result.exit_code == 0
486 data = json.loads(result.output)
487 assert data == []
488
489 def test_resumable_filter_text_output(self, repo: pathlib.Path) -> None:
490 _branch(repo, "task/res", "--resumable")
491 result = _branch(repo, "--resumable")
492 assert result.exit_code == 0
493 assert "task/res" in result.output
494
495 def test_resumable_filter_all_resumable_returned(self, repo: pathlib.Path) -> None:
496 for i in range(5):
497 _branch(repo, f"task/r-{i}", "--resumable")
498 result = _branch(repo, "--resumable", "--json")
499 data = json.loads(result.output)
500 assert len(data) == 5
501
502 def test_resumable_combined_with_merged_filter(self, repo: pathlib.Path) -> None:
503 """--resumable and --merged can be combined."""
504 _branch(repo, "task/merged-resumable", "--resumable")
505 result = _branch(repo, "--resumable", "--merged", "--json")
506 assert result.exit_code == 0
507 data = json.loads(result.output)
508 names = [b["name"] for b in data]
509 # task/merged-resumable shares HEAD with main, so it's merged
510 assert "task/merged-resumable" in names
511
512
513 # ===========================================================================
514 # E2E: full round-trips
515 # ===========================================================================
516
517
518 class TestE2eRoundTrips:
519 def test_intent_survives_list_cycle(self, repo: pathlib.Path) -> None:
520 _branch(repo, "feat/rt", "--intent", "round-trip test", "--resumable")
521 result = _branch(repo, "--json")
522 data = json.loads(result.output)
523 entry = next(b for b in data if b["name"] == "feat/rt")
524 assert entry["intent"] == "round-trip test"
525 assert entry["resumable"] is True
526
527 def test_last_committer_reflects_tip_even_on_new_branch(
528 self, agent_repo: pathlib.Path
529 ) -> None:
530 """child-branch points at the same (agent-authored) commit as main,
531 so last_committer reflects that tip authorship -- but (musehub#216)
532 this must NOT be conflated with created_by, which reflects who ran
533 `muse branch child-branch` (a human, in this test)."""
534 _branch(agent_repo, "child-branch")
535 result = _branch(agent_repo, "--json")
536 data = json.loads(result.output)
537 child = next(b for b in data if b["name"] == "child-branch")
538 assert child["last_committer"] == {"handle": "claude-code", "kind": "agent"}
539 assert child["created_by"] == {"handle": "human", "kind": "human"}
540
541 def test_create_intent_resumable_json_schema(self, repo: pathlib.Path) -> None:
542 result = _branch(repo, "feat/full", "--intent", "full schema", "--resumable", "--json")
543 data = json.loads(result.output)
544 assert data["action"] == "created"
545 assert data["intent"] == "full schema"
546 assert data["resumable"] is True
547 assert "branch" in data
548 assert "commit_id" in data
549
550 def test_resumable_filter_with_r_flag(self, repo: pathlib.Path) -> None:
551 """--resumable must not conflict with -r (remote-tracking) flag."""
552 _branch(repo, "task/local-res", "--resumable")
553 # -r with no remotes returns empty; should not crash
554 result = _branch(repo, "-r", "--resumable", "--json")
555 assert result.exit_code == 0
556 assert json.loads(result.output) == []
557
558
559 # ===========================================================================
560 # Security: intent injection
561 # ===========================================================================
562
563
564 class TestIntentSecurity:
565 def _has_ansi(self, s: str) -> bool:
566 return "\x1b[" in s
567
568 def test_ansi_in_intent_stripped_from_output(self, repo: pathlib.Path) -> None:
569 _branch(repo, "sec/ansi", "--intent", "\x1b[31mmalicious\x1b[0m")
570 result = _branch(repo, "--json")
571 data = json.loads(result.output)
572 entry = next(b for b in data if b["name"] == "sec/ansi")
573 assert not self._has_ansi(str(entry.get("intent", "")))
574
575 def test_newline_in_intent_escaped_in_toml(self, repo: pathlib.Path) -> None:
576 """Intent with newline must not break TOML file structure."""
577 _branch(repo, "sec/nl", "--intent", "line1\nline2")
578 # Config file must still be parseable
579 data = _config(repo)
580 assert isinstance(data, dict)
581
582 def test_toml_metachar_in_intent_safe(self, repo: pathlib.Path) -> None:
583 """TOML-special chars in intent must not allow section injection."""
584 _branch(repo, "sec/toml", '--intent', '[malicious]\nkey = "injected"')
585 data = _config(repo)
586 # No top-level 'malicious' section should have been injected
587 assert "malicious" not in data
588
589 def test_intent_truncated_to_reasonable_length(self, repo: pathlib.Path) -> None:
590 """Very long intent must not crash or produce a corrupt config."""
591 long_intent = "x" * 10_000
592 result = _branch(repo, "sec/long", "--intent", long_intent)
593 assert result.exit_code == 0
594 data = _config(repo)
595 stored = data.get("branch", {}).get("sec/long", {}).get("intent", "")
596 assert isinstance(stored, str)
597
598
599 # ===========================================================================
600 # Data integrity
601 # ===========================================================================
602
603
604 class TestDataIntegrity:
605 def test_intent_not_lost_on_second_branch_create(self, repo: pathlib.Path) -> None:
606 """Creating a second branch must not overwrite the first's intent."""
607 _branch(repo, "feat/first", "--intent", "first intent")
608 _branch(repo, "feat/second", "--intent", "second intent")
609 data = _config(repo)
610 assert data["branch"]["feat/first"]["intent"] == "first intent"
611 assert data["branch"]["feat/second"]["intent"] == "second intent"
612
613 def test_resumable_preserved_across_other_branch_operations(
614 self, repo: pathlib.Path
615 ) -> None:
616 _branch(repo, "task/keep", "--resumable")
617 _branch(repo, "task/other", "--intent", "unrelated")
618 data = _config(repo)
619 assert data["branch"]["task/keep"]["resumable"] is True
620
621 def test_config_toml_valid_toml_after_write(self, repo: pathlib.Path) -> None:
622 _branch(repo, "feat/valid", "--intent", 'quotes "and" stuff', "--resumable")
623 # tomllib.load must succeed
624 p = config_toml_path(repo)
625 with p.open("rb") as f:
626 parsed = tomllib.load(f)
627 assert isinstance(parsed, dict)
628
629
630 # ===========================================================================
631 # Performance
632 # ===========================================================================
633
634
635 class TestPerformance:
636 def test_list_50_branches_with_intent_under_1s(self, repo: pathlib.Path) -> None:
637 for i in range(50):
638 _branch(repo, f"perf/task-{i:03d}", "--intent", f"task {i}", "--resumable")
639
640 start = time.monotonic()
641 result = _branch(repo, "--json")
642 elapsed = time.monotonic() - start
643
644 assert result.exit_code == 0
645 data = json.loads(result.output)
646 assert len(data) == 51 # main + 50
647 assert elapsed < 1.0, f"listing 51 branches with intent took {elapsed:.2f}s"
648
649 def test_resumable_filter_50_branches_under_500ms(
650 self, repo: pathlib.Path
651 ) -> None:
652 for i in range(50):
653 _branch(repo, f"filter/task-{i:03d}", "--resumable")
654
655 start = time.monotonic()
656 result = _branch(repo, "--resumable", "--json")
657 elapsed = time.monotonic() - start
658
659 assert result.exit_code == 0
660 data = json.loads(result.output)
661 assert len(data) == 50
662 assert elapsed < 0.5, f"--resumable filter on 50 branches took {elapsed:.2f}s"
663
664
665 # ---------------------------------------------------------------------------
666 # Metadata update on existing branch
667 # ---------------------------------------------------------------------------
668
669
670 class TestBranchMetaUpdate:
671 """--intent / --resumable on an already-existing branch update metadata."""
672
673 def test_set_intent_on_existing_branch(self, repo: pathlib.Path) -> None:
674 _branch(repo, "existing")
675 result = _branch(repo, "existing", "--intent", "added later")
676 assert result.exit_code == 0
677
678 def test_update_action_in_json(self, repo: pathlib.Path) -> None:
679 _branch(repo, "upd")
680 result = _branch(repo, "upd", "--intent", "my intent", "--json")
681 assert result.exit_code == 0
682 data = json.loads(result.output)
683 assert data["action"] == "updated"
684 assert data["branch"] == "upd"
685 assert data["intent"] == "my intent"
686
687 def test_intent_visible_in_listing_after_update(self, repo: pathlib.Path) -> None:
688 _branch(repo, "later")
689 _branch(repo, "later", "--intent", "set after creation")
690 listing = json.loads(_branch(repo, "--json").output)
691 entry = next(e for e in listing if e["name"] == "later")
692 assert entry["intent"] == "set after creation"
693
694 def test_set_resumable_on_existing_branch(self, repo: pathlib.Path) -> None:
695 _branch(repo, "checkpoint")
696 result = _branch(repo, "checkpoint", "--resumable", "--json")
697 assert result.exit_code == 0
698 data = json.loads(result.output)
699 assert data["resumable"] is True
700
701 def test_resumable_visible_in_listing_after_update(self, repo: pathlib.Path) -> None:
702 _branch(repo, "chkpt2")
703 _branch(repo, "chkpt2", "--resumable")
704 listing = json.loads(_branch(repo, "--json").output)
705 entry = next(e for e in listing if e["name"] == "chkpt2")
706 assert entry["resumable"] is True
707
708 def test_update_does_not_overwrite_unspecified_fields(
709 self, repo: pathlib.Path
710 ) -> None:
711 """Setting resumable later must not wipe a previously stored intent."""
712 _branch(repo, "preserve", "--intent", "keep me")
713 _branch(repo, "preserve", "--resumable")
714 listing = json.loads(_branch(repo, "--json").output)
715 entry = next(e for e in listing if e["name"] == "preserve")
716 assert entry["intent"] == "keep me"
717 assert entry["resumable"] is True
718
719 def test_update_with_start_point_still_errors(self, repo: pathlib.Path) -> None:
720 """Passing a start_point to an existing branch is still an error."""
721 _branch(repo, "existing2")
722 result = _branch(repo, "existing2", "main", "--intent", "x")
723 assert result.exit_code != 0
724
725 def test_no_meta_flags_still_errors_on_existing(self, repo: pathlib.Path) -> None:
726 """Plain `muse branch <existing>` (no --intent/--resumable) still errors."""
727 _branch(repo, "plain")
728 result = _branch(repo, "plain")
729 assert result.exit_code != 0
File History 1 commit
sha256:f8da4e8a3d3f70a3d5b8e754b5e775f9785f6021ac2411a7ef82e64e10917ee3 feat: persist real branch-creation provenance, separate fro… Sonnet 5 minor ⚠ 1 day ago