gabriel / muse public

test_branch_intent_created_by.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:e fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived with entity_… · gabriel · Oct 22, 2026
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`), no agent_id on
357 # the tip commit, AND no configured user.handle/--author either —
358 # legacy fallback genuinely has nothing to report.
359 assert main["created_by"] is None
360
361 def test_list_json_created_by_legacy_fallback_to_human_author(
362 self, repo: pathlib.Path
363 ) -> None:
364 """musehub#215: created_by's legacy fallback (for branches with no
365 creation record) must also consult the tip commit's .author field,
366 not only .agent_id — a human-authored commit is not "nothing known"."""
367 (repo / "a.py").write_text("x = 2\n")
368 _invoke(repo, ["code", "add", "a.py"])
369 _invoke(repo, ["commit", "-m", "human commit", "--author", "aaronrene"])
370 result = _branch(repo, "--json")
371 data = json.loads(result.output)
372 main = next(b for b in data if b["name"] == "main")
373 assert main["created_by"] == {"handle": "aaronrene", "kind": "human"}
374
375 def test_list_json_created_by_null_for_empty_branch(
376 self, repo: pathlib.Path
377 ) -> None:
378 (heads_dir(repo) / "empty").write_text("")
379 result = _branch(repo, "--json")
380 data = json.loads(result.output)
381 entry = next(b for b in data if b["name"] == "empty")
382 assert entry["created_by"] is None
383
384 def test_list_json_created_by_real_record_for_new_branch(
385 self, repo: pathlib.Path
386 ) -> None:
387 """A branch created via `muse branch <name>` gets a real creation
388 record — independent of who authored the tip commit."""
389 _branch(repo, "feat/created-record")
390 result = _branch(repo, "--json")
391 data = json.loads(result.output)
392 entry = next(b for b in data if b["name"] == "feat/created-record")
393 assert entry["created_by"] == {"handle": "human", "kind": "human"}
394 assert entry["created_at"] is not None
395
396 def test_list_json_created_at_null_for_legacy_branch(
397 self, repo: pathlib.Path
398 ) -> None:
399 main = next(b for b in _branch_json(repo) if b["name"] == "main")
400 assert main["created_at"] is None
401
402 def test_list_json_has_last_committer_field(
403 self, agent_repo: pathlib.Path
404 ) -> None:
405 result = _branch(agent_repo, "--json")
406 data = json.loads(result.output)
407 main = next(b for b in data if b["name"] == "main")
408 assert main["last_committer"] == {"handle": "claude-code", "kind": "agent"}
409
410 def test_created_by_independent_of_last_committer(
411 self, agent_repo: pathlib.Path
412 ) -> None:
413 """The bug this ticket fixes: creating a new branch on top of an
414 agent-authored tip must NOT make created_by report the agent as the
415 branch's creator — only last_committer should reflect the tip."""
416 _branch(agent_repo, "child-branch")
417 data = _branch_json(agent_repo)
418 child = next(b for b in data if b["name"] == "child-branch")
419 assert child["created_by"] == {"handle": "human", "kind": "human"}
420 assert child["last_committer"] == {"handle": "claude-code", "kind": "agent"}
421
422 def test_created_by_handle_from_env_agent_handle(
423 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
424 ) -> None:
425 """MUSE_AGENT_HANDLE env var attributes creation to the actual
426 invoking agent, not whoever authored the repo's existing commits."""
427 monkeypatch.setenv("MUSE_AGENT_HANDLE", "some-other-agent")
428 _branch(repo, "feat/agent-created")
429 data = _branch_json(repo)
430 entry = next(b for b in data if b["name"] == "feat/agent-created")
431 assert entry["created_by"] == {"handle": "some-other-agent", "kind": "agent"}
432
433 def test_created_by_immutable_across_later_commits(
434 self, repo: pathlib.Path
435 ) -> None:
436 """Once written, the creation record must not change even as the
437 branch's tip moves to new commits by a different author."""
438 _branch(repo, "feat/stable-record")
439 before = next(
440 b for b in _branch_json(repo) if b["name"] == "feat/stable-record"
441 )
442 _invoke(repo, ["switch", "feat/stable-record"])
443 (repo / "b.py").write_text("y = 2\n")
444 _invoke(repo, ["code", "add", "b.py"])
445 _invoke(repo, [
446 "commit", "-m", "agent commit on top",
447 "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6",
448 ])
449 after = next(
450 b for b in _branch_json(repo) if b["name"] == "feat/stable-record"
451 )
452 assert after["created_by"] == before["created_by"]
453 assert after["created_at"] == before["created_at"]
454 assert after["last_committer"] == {"handle": "claude-code", "kind": "agent"}
455
456 def test_switch_create_writes_creation_record(
457 self, repo: pathlib.Path
458 ) -> None:
459 """`muse switch -c` is the other branch-creation entrypoint and must
460 write the same creation record as `muse branch <name>`."""
461 _invoke(repo, ["switch", "-c", "task/via-switch"])
462 entry = next(
463 b for b in _branch_json(repo) if b["name"] == "task/via-switch"
464 )
465 assert entry["created_by"] == {"handle": "human", "kind": "human"}
466 assert entry["created_at"] is not None
467
468 def test_schema_complete(self, repo: pathlib.Path) -> None:
469 """All new fields must appear in the listing schema."""
470 result = _branch(repo, "--json")
471 data = json.loads(result.output)
472 required = {"name", "current", "commit_id", "committed_at",
473 "last_message", "upstream", "intent", "resumable",
474 "created_by", "created_at", "last_committer"}
475 missing = required - set(data[0].keys())
476 assert not missing, f"branch --json missing fields: {missing}"
477
478
479 # ===========================================================================
480 # E2E: --resumable listing filter
481 # ===========================================================================
482
483
484 class TestResumableFilter:
485 def test_resumable_filter_shows_only_resumable(self, repo: pathlib.Path) -> None:
486 _branch(repo, "task/resumable-1", "--resumable")
487 _branch(repo, "task/resumable-2", "--resumable")
488 _branch(repo, "task/not-resumable")
489 result = _branch(repo, "--resumable", "--json")
490 assert result.exit_code == 0
491 data = json.loads(result.output)
492 names = [b["name"] for b in data]
493 assert "task/resumable-1" in names
494 assert "task/resumable-2" in names
495 assert "task/not-resumable" not in names
496 assert "main" not in names
497
498 def test_resumable_filter_empty_when_none(self, repo: pathlib.Path) -> None:
499 result = _branch(repo, "--resumable", "--json")
500 assert result.exit_code == 0
501 data = json.loads(result.output)
502 assert data == []
503
504 def test_resumable_filter_text_output(self, repo: pathlib.Path) -> None:
505 _branch(repo, "task/res", "--resumable")
506 result = _branch(repo, "--resumable")
507 assert result.exit_code == 0
508 assert "task/res" in result.output
509
510 def test_resumable_filter_all_resumable_returned(self, repo: pathlib.Path) -> None:
511 for i in range(5):
512 _branch(repo, f"task/r-{i}", "--resumable")
513 result = _branch(repo, "--resumable", "--json")
514 data = json.loads(result.output)
515 assert len(data) == 5
516
517 def test_resumable_combined_with_merged_filter(self, repo: pathlib.Path) -> None:
518 """--resumable and --merged can be combined."""
519 _branch(repo, "task/merged-resumable", "--resumable")
520 result = _branch(repo, "--resumable", "--merged", "--json")
521 assert result.exit_code == 0
522 data = json.loads(result.output)
523 names = [b["name"] for b in data]
524 # task/merged-resumable shares HEAD with main, so it's merged
525 assert "task/merged-resumable" in names
526
527
528 # ===========================================================================
529 # E2E: full round-trips
530 # ===========================================================================
531
532
533 class TestE2eRoundTrips:
534 def test_intent_survives_list_cycle(self, repo: pathlib.Path) -> None:
535 _branch(repo, "feat/rt", "--intent", "round-trip test", "--resumable")
536 result = _branch(repo, "--json")
537 data = json.loads(result.output)
538 entry = next(b for b in data if b["name"] == "feat/rt")
539 assert entry["intent"] == "round-trip test"
540 assert entry["resumable"] is True
541
542 def test_last_committer_reflects_tip_even_on_new_branch(
543 self, agent_repo: pathlib.Path
544 ) -> None:
545 """child-branch points at the same (agent-authored) commit as main,
546 so last_committer reflects that tip authorship -- but (musehub#216)
547 this must NOT be conflated with created_by, which reflects who ran
548 `muse branch child-branch` (a human, in this test)."""
549 _branch(agent_repo, "child-branch")
550 result = _branch(agent_repo, "--json")
551 data = json.loads(result.output)
552 child = next(b for b in data if b["name"] == "child-branch")
553 assert child["last_committer"] == {"handle": "claude-code", "kind": "agent"}
554 assert child["created_by"] == {"handle": "human", "kind": "human"}
555
556 def test_create_intent_resumable_json_schema(self, repo: pathlib.Path) -> None:
557 result = _branch(repo, "feat/full", "--intent", "full schema", "--resumable", "--json")
558 data = json.loads(result.output)
559 assert data["action"] == "created"
560 assert data["intent"] == "full schema"
561 assert data["resumable"] is True
562 assert "branch" in data
563 assert "commit_id" in data
564
565 def test_resumable_filter_with_r_flag(self, repo: pathlib.Path) -> None:
566 """--resumable must not conflict with -r (remote-tracking) flag."""
567 _branch(repo, "task/local-res", "--resumable")
568 # -r with no remotes returns empty; should not crash
569 result = _branch(repo, "-r", "--resumable", "--json")
570 assert result.exit_code == 0
571 assert json.loads(result.output) == []
572
573
574 # ===========================================================================
575 # Security: intent injection
576 # ===========================================================================
577
578
579 class TestIntentSecurity:
580 def _has_ansi(self, s: str) -> bool:
581 return "\x1b[" in s
582
583 def test_ansi_in_intent_stripped_from_output(self, repo: pathlib.Path) -> None:
584 _branch(repo, "sec/ansi", "--intent", "\x1b[31mmalicious\x1b[0m")
585 result = _branch(repo, "--json")
586 data = json.loads(result.output)
587 entry = next(b for b in data if b["name"] == "sec/ansi")
588 assert not self._has_ansi(str(entry.get("intent", "")))
589
590 def test_newline_in_intent_escaped_in_toml(self, repo: pathlib.Path) -> None:
591 """Intent with newline must not break TOML file structure."""
592 _branch(repo, "sec/nl", "--intent", "line1\nline2")
593 # Config file must still be parseable
594 data = _config(repo)
595 assert isinstance(data, dict)
596
597 def test_toml_metachar_in_intent_safe(self, repo: pathlib.Path) -> None:
598 """TOML-special chars in intent must not allow section injection."""
599 _branch(repo, "sec/toml", '--intent', '[malicious]\nkey = "injected"')
600 data = _config(repo)
601 # No top-level 'malicious' section should have been injected
602 assert "malicious" not in data
603
604 def test_intent_truncated_to_reasonable_length(self, repo: pathlib.Path) -> None:
605 """Very long intent must not crash or produce a corrupt config."""
606 long_intent = "x" * 10_000
607 result = _branch(repo, "sec/long", "--intent", long_intent)
608 assert result.exit_code == 0
609 data = _config(repo)
610 stored = data.get("branch", {}).get("sec/long", {}).get("intent", "")
611 assert isinstance(stored, str)
612
613
614 # ===========================================================================
615 # Data integrity
616 # ===========================================================================
617
618
619 class TestDataIntegrity:
620 def test_intent_not_lost_on_second_branch_create(self, repo: pathlib.Path) -> None:
621 """Creating a second branch must not overwrite the first's intent."""
622 _branch(repo, "feat/first", "--intent", "first intent")
623 _branch(repo, "feat/second", "--intent", "second intent")
624 data = _config(repo)
625 assert data["branch"]["feat/first"]["intent"] == "first intent"
626 assert data["branch"]["feat/second"]["intent"] == "second intent"
627
628 def test_resumable_preserved_across_other_branch_operations(
629 self, repo: pathlib.Path
630 ) -> None:
631 _branch(repo, "task/keep", "--resumable")
632 _branch(repo, "task/other", "--intent", "unrelated")
633 data = _config(repo)
634 assert data["branch"]["task/keep"]["resumable"] is True
635
636 def test_config_toml_valid_toml_after_write(self, repo: pathlib.Path) -> None:
637 _branch(repo, "feat/valid", "--intent", 'quotes "and" stuff', "--resumable")
638 # tomllib.load must succeed
639 p = config_toml_path(repo)
640 with p.open("rb") as f:
641 parsed = tomllib.load(f)
642 assert isinstance(parsed, dict)
643
644
645 # ===========================================================================
646 # Performance
647 # ===========================================================================
648
649
650 class TestPerformance:
651 def test_list_50_branches_with_intent_under_1s(self, repo: pathlib.Path) -> None:
652 for i in range(50):
653 _branch(repo, f"perf/task-{i:03d}", "--intent", f"task {i}", "--resumable")
654
655 start = time.monotonic()
656 result = _branch(repo, "--json")
657 elapsed = time.monotonic() - start
658
659 assert result.exit_code == 0
660 data = json.loads(result.output)
661 assert len(data) == 51 # main + 50
662 assert elapsed < 1.0, f"listing 51 branches with intent took {elapsed:.2f}s"
663
664 def test_resumable_filter_50_branches_under_500ms(
665 self, repo: pathlib.Path
666 ) -> None:
667 for i in range(50):
668 _branch(repo, f"filter/task-{i:03d}", "--resumable")
669
670 start = time.monotonic()
671 result = _branch(repo, "--resumable", "--json")
672 elapsed = time.monotonic() - start
673
674 assert result.exit_code == 0
675 data = json.loads(result.output)
676 assert len(data) == 50
677 assert elapsed < 0.5, f"--resumable filter on 50 branches took {elapsed:.2f}s"
678
679
680 # ---------------------------------------------------------------------------
681 # Metadata update on existing branch
682 # ---------------------------------------------------------------------------
683
684
685 class TestBranchMetaUpdate:
686 """--intent / --resumable on an already-existing branch update metadata."""
687
688 def test_set_intent_on_existing_branch(self, repo: pathlib.Path) -> None:
689 _branch(repo, "existing")
690 result = _branch(repo, "existing", "--intent", "added later")
691 assert result.exit_code == 0
692
693 def test_update_action_in_json(self, repo: pathlib.Path) -> None:
694 _branch(repo, "upd")
695 result = _branch(repo, "upd", "--intent", "my intent", "--json")
696 assert result.exit_code == 0
697 data = json.loads(result.output)
698 assert data["action"] == "updated"
699 assert data["branch"] == "upd"
700 assert data["intent"] == "my intent"
701
702 def test_intent_visible_in_listing_after_update(self, repo: pathlib.Path) -> None:
703 _branch(repo, "later")
704 _branch(repo, "later", "--intent", "set after creation")
705 listing = json.loads(_branch(repo, "--json").output)
706 entry = next(e for e in listing if e["name"] == "later")
707 assert entry["intent"] == "set after creation"
708
709 def test_set_resumable_on_existing_branch(self, repo: pathlib.Path) -> None:
710 _branch(repo, "checkpoint")
711 result = _branch(repo, "checkpoint", "--resumable", "--json")
712 assert result.exit_code == 0
713 data = json.loads(result.output)
714 assert data["resumable"] is True
715
716 def test_resumable_visible_in_listing_after_update(self, repo: pathlib.Path) -> None:
717 _branch(repo, "chkpt2")
718 _branch(repo, "chkpt2", "--resumable")
719 listing = json.loads(_branch(repo, "--json").output)
720 entry = next(e for e in listing if e["name"] == "chkpt2")
721 assert entry["resumable"] is True
722
723 def test_update_does_not_overwrite_unspecified_fields(
724 self, repo: pathlib.Path
725 ) -> None:
726 """Setting resumable later must not wipe a previously stored intent."""
727 _branch(repo, "preserve", "--intent", "keep me")
728 _branch(repo, "preserve", "--resumable")
729 listing = json.loads(_branch(repo, "--json").output)
730 entry = next(e for e in listing if e["name"] == "preserve")
731 assert entry["intent"] == "keep me"
732 assert entry["resumable"] is True
733
734 def test_update_with_start_point_still_errors(self, repo: pathlib.Path) -> None:
735 """Passing a start_point to an existing branch is still an error."""
736 _branch(repo, "existing2")
737 result = _branch(repo, "existing2", "main", "--intent", "x")
738 assert result.exit_code != 0
739
740 def test_no_meta_flags_still_errors_on_existing(self, repo: pathlib.Path) -> None:
741 """Plain `muse branch <existing>` (no --intent/--resumable) still errors."""
742 _branch(repo, "plain")
743 result = _branch(repo, "plain")
744 assert result.exit_code != 0