@misterhuydo/sentinel 1.5.63 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.cairn/session.json +2 -2
  2. package/package.json +1 -1
  3. package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
  4. package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
  5. package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
  6. package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
  7. package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
  8. package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
  9. package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
  10. package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
  11. package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
  12. package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
  13. package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
  14. package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
  15. package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
  16. package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
  17. package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
  18. package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
  19. package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
  20. package/python/sentinel/__init__.py +1 -1
  21. package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
  22. package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
  23. package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
  24. package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
  25. package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
  26. package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
  27. package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
  28. package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
  29. package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
  30. package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
  31. package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
  32. package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
  33. package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
  34. package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
  35. package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
  36. package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
  37. package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
  38. package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
  39. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  40. package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
  41. package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
  42. package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
  43. package/python/sentinel/cairn_client.py +30 -11
  44. package/python/sentinel/fix_engine.py +182 -43
  45. package/python/sentinel/git_manager.py +335 -0
  46. package/python/sentinel/main.py +189 -5
  47. package/python/sentinel/state_store.py +121 -0
  48. package/python/tests/test_cairn_client.py +72 -0
  49. package/python/tests/test_fix_engine_json.py +95 -0
  50. package/python/tests/test_fix_engine_prompt.py +93 -0
  51. package/python/tests/test_multi_repo_apply.py +254 -0
  52. package/python/tests/test_multi_repo_publish.py +175 -0
  53. package/python/tests/test_patch_parser.py +250 -0
  54. package/python/tests/test_project_lock.py +85 -0
  55. package/python/tests/test_state_store.py +87 -0
@@ -87,6 +87,30 @@ class StateStore:
87
87
  fingerprint TEXT, -- error/issue fingerprint that triggered it
88
88
  committed_at TEXT NOT NULL
89
89
  );
90
+
91
+ -- One fingerprint can produce changes in multiple repos (cross-repo fix).
92
+ -- Each row tracks the per-repo branch / commit / PR for one such fingerprint.
93
+ CREATE TABLE IF NOT EXISTS fix_repos (
94
+ fingerprint TEXT NOT NULL,
95
+ repo_name TEXT NOT NULL,
96
+ branch TEXT,
97
+ commit_hash TEXT,
98
+ pr_url TEXT,
99
+ pr_state TEXT, -- open|merged|closed|failed
100
+ apply_order INTEGER DEFAULT 0,
101
+ timestamp TEXT NOT NULL,
102
+ PRIMARY KEY (fingerprint, repo_name)
103
+ );
104
+
105
+ -- Long-lived `claude --print --resume <id>` sessions, one per project.
106
+ -- Lets cross-task prompt cache hit and gives Claude continuous context.
107
+ CREATE TABLE IF NOT EXISTS claude_sessions (
108
+ project_name TEXT PRIMARY KEY,
109
+ session_id TEXT NOT NULL,
110
+ last_used TEXT NOT NULL,
111
+ total_cost_usd REAL NOT NULL DEFAULT 0,
112
+ turn_count INTEGER NOT NULL DEFAULT 0
113
+ );
90
114
  """)
91
115
  self._migrate()
92
116
  logger.debug("StateStore initialised at %s", self.db_path)
@@ -276,6 +300,103 @@ class StateStore:
276
300
  ).fetchone()
277
301
  return row is not None
278
302
 
303
+ # ── Multi-repo fix tracking (one fingerprint → many repos) ────────────────
304
+
305
+ def record_fix_repo(
306
+ self,
307
+ fingerprint: str,
308
+ repo_name: str,
309
+ branch: str = "",
310
+ commit_hash: str = "",
311
+ pr_url: str = "",
312
+ pr_state: str = "",
313
+ apply_order: int = 0,
314
+ ) -> None:
315
+ """Record (or replace) the per-repo branch/commit/PR for a multi-repo fix."""
316
+ with self._conn() as conn:
317
+ conn.execute(
318
+ "INSERT INTO fix_repos (fingerprint, repo_name, branch, commit_hash, "
319
+ "pr_url, pr_state, apply_order, timestamp) VALUES (?,?,?,?,?,?,?,?) "
320
+ "ON CONFLICT(fingerprint, repo_name) DO UPDATE SET "
321
+ "branch=excluded.branch, commit_hash=excluded.commit_hash, "
322
+ "pr_url=excluded.pr_url, pr_state=excluded.pr_state, "
323
+ "apply_order=excluded.apply_order, timestamp=excluded.timestamp",
324
+ (fingerprint, repo_name, branch, commit_hash,
325
+ pr_url, pr_state, apply_order, _now()),
326
+ )
327
+
328
+ def update_fix_repo_state(
329
+ self,
330
+ fingerprint: str,
331
+ repo_name: str,
332
+ pr_state: str = "",
333
+ commit_hash: str = "",
334
+ ) -> None:
335
+ """Patch pr_state and/or commit_hash on an existing fix_repos row."""
336
+ sets, args = [], []
337
+ if pr_state:
338
+ sets.append("pr_state=?"); args.append(pr_state)
339
+ if commit_hash:
340
+ sets.append("commit_hash=?"); args.append(commit_hash)
341
+ if not sets:
342
+ return
343
+ args.extend([fingerprint, repo_name])
344
+ with self._conn() as conn:
345
+ conn.execute(
346
+ f"UPDATE fix_repos SET {', '.join(sets)} "
347
+ "WHERE fingerprint=? AND repo_name=?", args,
348
+ )
349
+
350
+ def get_fix_repos(self, fingerprint: str) -> list[dict]:
351
+ """Return all per-repo rows for a fingerprint, ordered by apply_order then repo_name."""
352
+ with self._conn() as conn:
353
+ rows = conn.execute(
354
+ "SELECT * FROM fix_repos WHERE fingerprint=? "
355
+ "ORDER BY apply_order, repo_name",
356
+ (fingerprint,),
357
+ ).fetchall()
358
+ return [dict(r) for r in rows]
359
+
360
+ # ── Per-project Claude session tracking (for `claude --resume <id>`) ──────
361
+
362
+ def get_claude_session(self, project_name: str) -> dict | None:
363
+ """Return the saved {session_id, last_used, total_cost_usd, turn_count} or None."""
364
+ with self._conn() as conn:
365
+ row = conn.execute(
366
+ "SELECT session_id, last_used, total_cost_usd, turn_count "
367
+ "FROM claude_sessions WHERE project_name=?",
368
+ (project_name,),
369
+ ).fetchone()
370
+ return dict(row) if row else None
371
+
372
+ def set_claude_session(
373
+ self,
374
+ project_name: str,
375
+ session_id: str,
376
+ cost_delta: float = 0.0,
377
+ ) -> None:
378
+ """Upsert the session id; accumulates cost + turn count across calls."""
379
+ with self._conn() as conn:
380
+ conn.execute(
381
+ "INSERT INTO claude_sessions "
382
+ "(project_name, session_id, last_used, total_cost_usd, turn_count) "
383
+ "VALUES (?, ?, ?, ?, 1) "
384
+ "ON CONFLICT(project_name) DO UPDATE SET "
385
+ "session_id=excluded.session_id, "
386
+ "last_used=excluded.last_used, "
387
+ "total_cost_usd=total_cost_usd + excluded.total_cost_usd, "
388
+ "turn_count=turn_count + 1",
389
+ (project_name, session_id, _now(), cost_delta),
390
+ )
391
+
392
+ def clear_claude_session(self, project_name: str) -> None:
393
+ """Drop the saved session id for a project (forces a fresh session next call)."""
394
+ with self._conn() as conn:
395
+ conn.execute(
396
+ "DELETE FROM claude_sessions WHERE project_name=?",
397
+ (project_name,),
398
+ )
399
+
279
400
  def mark_marker_seen(self, marker: str) -> dict | None:
280
401
  """Record that a SENTINEL marker appeared in production logs."""
281
402
  with self._conn() as conn:
@@ -0,0 +1,72 @@
1
+ """
2
+ test_cairn_client.py — Unit tests for cairn federation init.
3
+ """
4
+ from unittest.mock import patch, MagicMock
5
+ import pytest
6
+
7
+ from sentinel.cairn_client import init_project_root, index_repo
8
+ from sentinel.config_loader import RepoConfig
9
+
10
+
11
+ def _fake_completed(rc=0, stdout="", stderr=""):
12
+ m = MagicMock()
13
+ m.returncode = rc
14
+ m.stdout = stdout
15
+ m.stderr = stderr
16
+ return m
17
+
18
+
19
+ # ── init_project_root ─────────────────────────────────────────────────────────
20
+
21
+ def test_init_project_root_skips_when_marker_exists(tmp_path):
22
+ (tmp_path / ".cairn").mkdir()
23
+ (tmp_path / ".cairn" / ".cairn-project").touch()
24
+ with patch("sentinel.cairn_client.subprocess.run") as run:
25
+ ok = init_project_root(str(tmp_path))
26
+ assert ok is True
27
+ run.assert_not_called()
28
+
29
+
30
+ def test_init_project_root_runs_cairn_install_when_missing(tmp_path):
31
+ with patch("sentinel.cairn_client.subprocess.run",
32
+ return_value=_fake_completed(rc=0)) as run:
33
+ ok = init_project_root(str(tmp_path))
34
+ assert ok is True
35
+ run.assert_called_once()
36
+ args, kwargs = run.call_args
37
+ assert args[0] == ["cairn", "install"]
38
+ assert kwargs["cwd"] == str(tmp_path)
39
+
40
+
41
+ def test_init_project_root_returns_false_on_install_failure(tmp_path):
42
+ with patch("sentinel.cairn_client.subprocess.run",
43
+ return_value=_fake_completed(rc=1, stderr="boom")):
44
+ ok = init_project_root(str(tmp_path))
45
+ assert ok is False
46
+
47
+
48
+ def test_init_project_root_returns_false_on_subprocess_exception(tmp_path):
49
+ with patch("sentinel.cairn_client.subprocess.run",
50
+ side_effect=FileNotFoundError("cairn not on PATH")):
51
+ ok = init_project_root(str(tmp_path))
52
+ assert ok is False
53
+
54
+
55
+ def test_init_project_root_creates_nothing_when_dir_missing(tmp_path):
56
+ missing = tmp_path / "does-not-exist"
57
+ with patch("sentinel.cairn_client.subprocess.run") as run:
58
+ ok = init_project_root(str(missing))
59
+ assert ok is False
60
+ run.assert_not_called()
61
+
62
+
63
+ # ── index_repo (existing behaviour — guard against regression) ────────────────
64
+
65
+ def test_index_repo_skips_when_marker_exists(tmp_path):
66
+ (tmp_path / ".cairn").mkdir()
67
+ (tmp_path / ".cairn" / ".cairn-project").touch()
68
+ repo = RepoConfig(repo_name="r", local_path=str(tmp_path), branch="main")
69
+ with patch("sentinel.cairn_client.subprocess.run") as run:
70
+ ok = index_repo(repo)
71
+ assert ok is True
72
+ run.assert_not_called()
@@ -0,0 +1,95 @@
1
+ """
2
+ test_fix_engine_json.py — Unit tests for _parse_claude_json().
3
+
4
+ Parses the single-object JSON emitted by `claude --print --output-format json`.
5
+ Critical: must extract session_id (for resume), cost (for budget tracking),
6
+ and the result text (which contains the patch).
7
+ """
8
+ import json
9
+ import pytest
10
+
11
+ from sentinel.fix_engine import _parse_claude_json
12
+
13
+
14
+ def _wrap(result_text: str, session_id: str = "abc-123",
15
+ cost: float = 0.05, is_error: bool = False) -> str:
16
+ return json.dumps({
17
+ "type": "result",
18
+ "subtype": "success" if not is_error else "error",
19
+ "is_error": is_error,
20
+ "result": result_text,
21
+ "session_id": session_id,
22
+ "total_cost_usd": cost,
23
+ "duration_ms": 1234,
24
+ "stop_reason": "end_turn",
25
+ })
26
+
27
+
28
+ # ── happy path ────────────────────────────────────────────────────────────────
29
+
30
+ def test_extracts_result_session_cost():
31
+ raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
32
+ parsed = _parse_claude_json(raw)
33
+ assert parsed["session_id"] == "sess-1"
34
+ assert parsed["total_cost_usd"] == pytest.approx(0.07)
35
+ assert "patch" in parsed["result"]
36
+ assert parsed["is_error"] is False
37
+
38
+
39
+ def test_handles_zero_cost():
40
+ raw = _wrap("ok", "sess", 0.0)
41
+ parsed = _parse_claude_json(raw)
42
+ assert parsed["total_cost_usd"] == 0.0
43
+
44
+
45
+ def test_carries_is_error_flag():
46
+ raw = _wrap("err msg", "sess", 0.01, is_error=True)
47
+ parsed = _parse_claude_json(raw)
48
+ assert parsed["is_error"] is True
49
+
50
+
51
+ # ── tolerant inputs ───────────────────────────────────────────────────────────
52
+
53
+ def test_strips_leading_trailing_whitespace():
54
+ raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
55
+ parsed = _parse_claude_json(raw)
56
+ assert parsed["session_id"] == "s"
57
+ assert parsed["result"] == "ok"
58
+
59
+
60
+ def test_finds_json_after_stderr_garbage():
61
+ """Claude sometimes prints debug lines before the JSON object."""
62
+ raw = (
63
+ "Some stderr line\n"
64
+ "Another warning\n"
65
+ + _wrap("payload", "s", 0.0)
66
+ )
67
+ parsed = _parse_claude_json(raw)
68
+ assert parsed["session_id"] == "s"
69
+ assert parsed["result"] == "payload"
70
+
71
+
72
+ def test_returns_empty_on_unparseable():
73
+ parsed = _parse_claude_json("not json at all")
74
+ assert parsed["session_id"] == ""
75
+ assert parsed["result"] == ""
76
+ assert parsed["total_cost_usd"] == 0.0
77
+ # When unparseable, surface as an error so caller doesn't silently swallow it
78
+ assert parsed["is_error"] is True
79
+
80
+
81
+ def test_returns_empty_on_empty_string():
82
+ parsed = _parse_claude_json("")
83
+ assert parsed["session_id"] == ""
84
+ assert parsed["result"] == ""
85
+ assert parsed["is_error"] is True
86
+
87
+
88
+ def test_handles_missing_fields_gracefully():
89
+ """If claude omits some fields, extract what's there and default the rest."""
90
+ raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
91
+ parsed = _parse_claude_json(raw)
92
+ assert parsed["session_id"] == "s"
93
+ assert parsed["result"] == "x"
94
+ assert parsed["total_cost_usd"] == 0.0
95
+ assert parsed["is_error"] is False
@@ -0,0 +1,93 @@
1
+ """
2
+ test_fix_engine_prompt.py — Spot-checks for the multi-repo prompt builder.
3
+
4
+ The prompt is mostly free text — these tests assert *presence* of key
5
+ instructions, not exact wording, so they tolerate cosmetic edits.
6
+ """
7
+ from sentinel.fix_engine import _build_prompt
8
+ from sentinel.config_loader import RepoConfig
9
+
10
+
11
+ def _mk_event():
12
+ """Minimal ErrorEvent-like duck for the prompt builder."""
13
+ class E:
14
+ source = "MyService"
15
+ severity = "ERROR"
16
+ message = "NullPointerException in Foo.bar()"
17
+ body = ""
18
+ stack_trace = []
19
+ def full_text(self):
20
+ return f"{self.message}\n at Foo.bar"
21
+ return E()
22
+
23
+
24
+ def _mk_repo(name: str) -> RepoConfig:
25
+ return RepoConfig(
26
+ repo_name=name,
27
+ local_path=f"/proj/repos/{name}",
28
+ branch="main",
29
+ )
30
+
31
+
32
+ def test_prompt_lists_all_repos_in_project():
33
+ repos = [_mk_repo("repo-a"), _mk_repo("repo-b"), _mk_repo("repo-c")]
34
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
35
+ marker="sentinel-deadbeef",
36
+ all_repos=repos, project_root="/proj")
37
+ assert "repo-a" in prompt
38
+ assert "repo-b" in prompt
39
+ assert "repo-c" in prompt
40
+
41
+
42
+ def test_prompt_names_primary_repo_as_origin():
43
+ repos = [_mk_repo("primary"), _mk_repo("other")]
44
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
45
+ marker="m", all_repos=repos, project_root="/p")
46
+ # Primary repo must be clearly identified as where the error originated
47
+ assert "primary" in prompt
48
+ # Sanity: project root is referenced, not the per-repo path
49
+ assert "/p" in prompt
50
+
51
+
52
+ def test_prompt_explains_repos_prefix_path_format():
53
+ repos = [_mk_repo("r1")]
54
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
55
+ marker="m", all_repos=repos, project_root="/p")
56
+ # Must instruct claude to use the repos/<name>/ path prefix
57
+ assert "repos/" in prompt
58
+ # And explain the multi-repo header
59
+ assert "Affected repos" in prompt
60
+
61
+
62
+ def test_prompt_instructs_cairn_checkpoint_call():
63
+ repos = [_mk_repo("r1")]
64
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
65
+ marker="m", all_repos=repos, project_root="/p")
66
+ assert "cairn_checkpoint" in prompt
67
+
68
+
69
+ def test_prompt_keeps_marker_instruction():
70
+ """Sentinel marker injection is still required for verification flow."""
71
+ repos = [_mk_repo("r1")]
72
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
73
+ marker="sentinel-abc12345",
74
+ all_repos=repos, project_root="/p")
75
+ assert "sentinel-abc12345" in prompt
76
+
77
+
78
+ def test_prompt_keeps_escalation_keywords():
79
+ """NEEDS_HUMAN / BOSS_ESCALATE keywords must remain — main.py keys off them."""
80
+ repos = [_mk_repo("r1")]
81
+ prompt = _build_prompt(_mk_event(), repos[0], log_file=None,
82
+ marker="m", all_repos=repos, project_root="/p")
83
+ assert "NEEDS_HUMAN:" in prompt
84
+ assert "BOSS_ESCALATE:" in prompt
85
+
86
+
87
+ def test_prompt_works_with_single_repo_project():
88
+ """Single-repo projects should still get a sensible prompt — no list weirdness."""
89
+ repo = _mk_repo("only-repo")
90
+ prompt = _build_prompt(_mk_event(), repo, log_file=None, marker="m",
91
+ all_repos=[repo], project_root="/p")
92
+ assert "only-repo" in prompt
93
+ assert "repos/" in prompt # prefix instruction still present
@@ -0,0 +1,254 @@
1
+ """
2
+ test_multi_repo_apply.py — Tests for apply_and_commit_multi().
3
+
4
+ The flow under test:
5
+ Phase 1 (atomic) — every affected repo's sub-patch is dry-run.
6
+ If any fails, ALL are marked "aborted" with no mutations.
7
+ Phase 2 (per-repo)— apply + test + commit, serially. Per-repo failures
8
+ don't abort the others (partial-success allowed at this point).
9
+
10
+ Tests mock _git and _run_tests so they're fast and deterministic.
11
+ """
12
+ from pathlib import Path
13
+ from unittest.mock import patch, MagicMock
14
+ from types import SimpleNamespace
15
+
16
+ import pytest
17
+
18
+ from sentinel import git_manager
19
+ from sentinel.config_loader import RepoConfig
20
+
21
+
22
+ def _mk_event(fingerprint="deadbeef00000001", source="src"):
23
+ e = SimpleNamespace()
24
+ e.fingerprint = fingerprint
25
+ e.source = source
26
+ e.message = "boom"
27
+ e.stack_trace = []
28
+ e.timestamp = "2026-04-24T10:00:00Z"
29
+ e.log_file = ""
30
+ e.short_summary = lambda: "boom in foo"
31
+ return e
32
+
33
+
34
+ def _mk_repo(tmp_path: Path, name: str) -> RepoConfig:
35
+ p = tmp_path / "repos" / name
36
+ p.mkdir(parents=True, exist_ok=True)
37
+ return RepoConfig(repo_name=name, local_path=str(p), branch="main")
38
+
39
+
40
+ def _mk_cfg(tmp_path: Path):
41
+ cfg = SimpleNamespace()
42
+ cfg.workspace_dir = str(tmp_path / "workspace")
43
+ Path(cfg.workspace_dir).mkdir(exist_ok=True)
44
+ cfg.project_name = "test-project"
45
+ cfg.github_token = ""
46
+ cfg.slack_bot_token = ""
47
+ cfg.slack_channel = ""
48
+ return cfg
49
+
50
+
51
+ def _ok():
52
+ """Mock _git result indicating success."""
53
+ return SimpleNamespace(returncode=0, stdout="", stderr="")
54
+
55
+
56
+ def _fail(err="bad"):
57
+ return SimpleNamespace(returncode=1, stdout="", stderr=err)
58
+
59
+
60
+ def _git_dispatch(returns_per_cmd: dict):
61
+ """Build a fake _git that returns based on the command verb (args[0]).
62
+
63
+ `returns_per_cmd` maps verb → list of CompletedProcess results (one per call).
64
+ Falls back to _ok() if a verb isn't listed.
65
+ """
66
+ counters = {k: 0 for k in returns_per_cmd}
67
+ def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
68
+ verb = args[0]
69
+ if verb in returns_per_cmd:
70
+ results = returns_per_cmd[verb]
71
+ i = counters[verb]
72
+ counters[verb] = min(i + 1, len(results) - 1) if results else 0
73
+ return results[i] if i < len(results) else results[-1]
74
+ if verb == "rev-parse":
75
+ return SimpleNamespace(returncode=0, stdout="abc1234567890def\n", stderr="")
76
+ return _ok()
77
+ return fake_git
78
+
79
+
80
+ # ── helpers to build patches ──────────────────────────────────────────────────
81
+
82
+ def _patch_one_repo(repo_name="r1") -> str:
83
+ return (
84
+ f"diff --git a/repos/{repo_name}/src/F.java b/repos/{repo_name}/src/F.java\n"
85
+ f"--- a/repos/{repo_name}/src/F.java\n"
86
+ f"+++ b/repos/{repo_name}/src/F.java\n"
87
+ f"@@ -1 +1 @@\n-old\n+new\n"
88
+ )
89
+
90
+
91
+ def _patch_two_repos(lib="lib", consumer="consumer") -> str:
92
+ return (
93
+ f"# Affected repos: {lib}, {consumer}\n"
94
+ f"diff --git a/repos/{lib}/src/A.java b/repos/{lib}/src/A.java\n"
95
+ f"--- a/repos/{lib}/src/A.java\n+++ b/repos/{lib}/src/A.java\n"
96
+ f"@@ -1 +1 @@\n-a\n+b\n"
97
+ f"diff --git a/repos/{consumer}/pom.xml b/repos/{consumer}/pom.xml\n"
98
+ f"--- a/repos/{consumer}/pom.xml\n+++ b/repos/{consumer}/pom.xml\n"
99
+ f"@@ -1 +1 @@\n-1.0\n+1.1\n"
100
+ )
101
+
102
+
103
+ # ── empty / unknown ───────────────────────────────────────────────────────────
104
+
105
+ def test_empty_patch_returns_empty(tmp_path):
106
+ patch_file = tmp_path / "empty.diff"
107
+ patch_file.write_text("")
108
+ cfg = _mk_cfg(tmp_path)
109
+ repos = [_mk_repo(tmp_path, "r1")]
110
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
111
+ assert result == []
112
+
113
+
114
+ def test_patch_with_unknown_repo_returns_empty(tmp_path):
115
+ patch_file = tmp_path / "x.diff"
116
+ patch_file.write_text(_patch_one_repo("ghost-repo"))
117
+ cfg = _mk_cfg(tmp_path)
118
+ repos = [_mk_repo(tmp_path, "real-repo")]
119
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
120
+ # ghost-repo is unknown → nothing applied
121
+ assert result == []
122
+
123
+
124
+ # ── single-repo happy path ────────────────────────────────────────────────────
125
+
126
+ def test_single_repo_happy_path(tmp_path):
127
+ patch_file = tmp_path / "s.diff"
128
+ patch_file.write_text(_patch_one_repo("r1"))
129
+ cfg = _mk_cfg(tmp_path)
130
+ repos = [_mk_repo(tmp_path, "r1")]
131
+
132
+ with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
133
+ patch.object(git_manager, "_run_tests", return_value=True), \
134
+ patch.object(git_manager, "_check_protected_paths", return_value=False), \
135
+ patch.object(git_manager, "_append_changelog"):
136
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
137
+
138
+ assert len(result) == 1
139
+ r = result[0]
140
+ assert r["repo_name"] == "r1"
141
+ assert r["status"] == "committed"
142
+ assert r["commit_hash"].startswith("abc")
143
+ assert r["apply_order"] == 0
144
+
145
+
146
+ # ── two-repo happy path with header order ─────────────────────────────────────
147
+
148
+ def test_two_repos_happy_path_preserves_apply_order(tmp_path):
149
+ patch_file = tmp_path / "m.diff"
150
+ patch_file.write_text(_patch_two_repos("lib", "consumer"))
151
+ cfg = _mk_cfg(tmp_path)
152
+ repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
153
+
154
+ with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
155
+ patch.object(git_manager, "_run_tests", return_value=True), \
156
+ patch.object(git_manager, "_check_protected_paths", return_value=False), \
157
+ patch.object(git_manager, "_append_changelog"):
158
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
159
+
160
+ assert [r["repo_name"] for r in result] == ["lib", "consumer"]
161
+ assert [r["apply_order"] for r in result] == [0, 1]
162
+ assert all(r["status"] == "committed" for r in result)
163
+
164
+
165
+ # ── atomic abort — one dry-run failure aborts ALL ─────────────────────────────
166
+
167
+ def test_dry_run_failure_aborts_all_repos(tmp_path):
168
+ patch_file = tmp_path / "m.diff"
169
+ patch_file.write_text(_patch_two_repos("lib", "consumer"))
170
+ cfg = _mk_cfg(tmp_path)
171
+ repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
172
+
173
+ # dry-run is `git apply --check ...`. We make the FIRST `apply --check` fail
174
+ # by making the apply verb fail on the second call (consumer's dry-run).
175
+ apply_results = [_ok(), _fail("conflict in consumer")]
176
+
177
+ with patch.object(git_manager, "_git",
178
+ side_effect=_git_dispatch({"apply": apply_results})), \
179
+ patch.object(git_manager, "_run_tests", return_value=True), \
180
+ patch.object(git_manager, "_check_protected_paths", return_value=False), \
181
+ patch.object(git_manager, "_append_changelog"):
182
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
183
+
184
+ # Both repos must be marked aborted (atomic invariant)
185
+ assert len(result) == 2
186
+ assert all(r["status"] == "aborted" for r in result)
187
+ # No commits anywhere
188
+ assert all(r["commit_hash"] == "" for r in result)
189
+ # Reason should mention which repo's dry-run failed
190
+ assert any("consumer" in r["reason"] for r in result)
191
+
192
+
193
+ def test_protected_paths_aborts_immediately(tmp_path):
194
+ patch_file = tmp_path / "x.diff"
195
+ patch_file.write_text(_patch_one_repo("r1"))
196
+ cfg = _mk_cfg(tmp_path)
197
+ repos = [_mk_repo(tmp_path, "r1")]
198
+
199
+ with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
200
+ patch.object(git_manager, "_run_tests", return_value=True), \
201
+ patch.object(git_manager, "_check_protected_paths", return_value=True):
202
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
203
+
204
+ assert len(result) == 1
205
+ assert result[0]["status"] == "aborted"
206
+ assert "protected" in result[0]["reason"].lower()
207
+
208
+
209
+ # ── per-repo failure in phase 2 ──────────────────────────────────────────────
210
+
211
+ def test_phase2_test_failure_marks_only_that_repo(tmp_path):
212
+ """If tests fail for one repo in phase 2, the other repos still get committed."""
213
+ patch_file = tmp_path / "m.diff"
214
+ patch_file.write_text(_patch_two_repos("lib", "consumer"))
215
+ cfg = _mk_cfg(tmp_path)
216
+ repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
217
+
218
+ # _run_tests returns True for lib, False for consumer
219
+ test_results = [True, False]
220
+ test_iter = iter(test_results)
221
+
222
+ with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
223
+ patch.object(git_manager, "_run_tests", side_effect=lambda *a, **kw: next(test_iter)), \
224
+ patch.object(git_manager, "_check_protected_paths", return_value=False), \
225
+ patch.object(git_manager, "_append_changelog"):
226
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
227
+
228
+ by_name = {r["repo_name"]: r for r in result}
229
+ assert by_name["lib"]["status"] == "committed"
230
+ assert by_name["consumer"]["status"] == "failed"
231
+ assert "test" in by_name["consumer"]["reason"].lower()
232
+
233
+
234
+ # ── sub-patch files written to disk ──────────────────────────────────────────
235
+
236
+ def test_sub_patch_files_are_written(tmp_path):
237
+ """Each affected repo gets its own per-repo .diff file for traceability."""
238
+ patch_file = tmp_path / "m.diff"
239
+ patch_file.write_text(_patch_two_repos("lib", "consumer"))
240
+ cfg = _mk_cfg(tmp_path)
241
+ repos = [_mk_repo(tmp_path, "lib"), _mk_repo(tmp_path, "consumer")]
242
+
243
+ with patch.object(git_manager, "_git", side_effect=_git_dispatch({})), \
244
+ patch.object(git_manager, "_run_tests", return_value=True), \
245
+ patch.object(git_manager, "_check_protected_paths", return_value=False), \
246
+ patch.object(git_manager, "_append_changelog"):
247
+ result = git_manager.apply_and_commit_multi(_mk_event(), patch_file, repos, cfg)
248
+
249
+ for r in result:
250
+ sub_path = r["sub_patch_path"]
251
+ assert Path(sub_path).exists(), f"sub-patch missing for {r['repo_name']}"
252
+ contents = Path(sub_path).read_text()
253
+ # Path prefix was stripped — repos/<name>/ should NOT appear
254
+ assert f"repos/{r['repo_name']}" not in contents