@misterhuydo/sentinel 1.6.0 → 1.6.2
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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/fix_engine.py +56 -15
- package/python/sentinel/git_manager.py +32 -0
- package/python/sentinel/main.py +131 -1
- package/python/tests/test_fix_engine_cmd.py +53 -0
- package/python/tests/test_pull_all_repos.py +94 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-24T10:50:33.264Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-24T11:12:56.361Z",
|
|
3
|
+
"checkpoint_at": "2026-04-24T11:12:56.362Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
6
|
"J:\\Projects\\Sentinel\\cli\\lib\\test.js",
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.2"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -177,6 +177,14 @@ def _build_prompt(
|
|
|
177
177
|
"2. Use your available tools to explore the codebase and identify the root cause.",
|
|
178
178
|
" You can read across ALL listed repos — use that visibility to follow type",
|
|
179
179
|
" definitions, callers, or shared library code that may be involved.",
|
|
180
|
+
"",
|
|
181
|
+
"CRITICAL — fresh reads only",
|
|
182
|
+
" Before you write ANY diff line, use the Read tool to view the CURRENT content",
|
|
183
|
+
" of every file you intend to modify. Do NOT rely on prior memory of the file",
|
|
184
|
+
" from earlier turns in this conversation: the working tree may have been",
|
|
185
|
+
" updated by a previous Sentinel fix, a human commit, or a `git pull` that ran",
|
|
186
|
+
" moments ago. A patch generated from stale memory will fail dry-run.",
|
|
187
|
+
"",
|
|
180
188
|
f"3. {marker_instruction}",
|
|
181
189
|
"4. Consider all possible fix approaches. For each, weigh:",
|
|
182
190
|
" - Confidence: is this definitely the root cause?",
|
|
@@ -254,12 +262,22 @@ def _is_auth_error(output: str) -> bool:
|
|
|
254
262
|
return any(hint in low for hint in _AUTH_ERROR_HINTS)
|
|
255
263
|
|
|
256
264
|
|
|
257
|
-
def _claude_cmd(
|
|
265
|
+
def _claude_cmd(
|
|
266
|
+
bin_path: str,
|
|
267
|
+
prompt: str,
|
|
268
|
+
session_id: str = "",
|
|
269
|
+
use_bare: bool = True,
|
|
270
|
+
) -> list[str]:
|
|
258
271
|
"""Build the `claude --print` command line.
|
|
259
272
|
|
|
260
273
|
`session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
|
|
261
274
|
existing session — gives prompt-cache reuse and shared context across fixes.
|
|
262
275
|
|
|
276
|
+
`use_bare` controls the `--bare` flag, which forces `ANTHROPIC_API_KEY`-only
|
|
277
|
+
auth. It MUST be True for the API-key attempt (claude needs a key in env)
|
|
278
|
+
and MUST be False for the OAuth attempt (otherwise claude refuses to read
|
|
279
|
+
the cached `claude login` token). The caller picks per attempt.
|
|
280
|
+
|
|
263
281
|
Output is forced to `--output-format json` so the caller can extract the
|
|
264
282
|
session_id, cost, and result text deterministically.
|
|
265
283
|
"""
|
|
@@ -268,10 +286,9 @@ def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
|
|
|
268
286
|
skip = _os.getuid() != 0
|
|
269
287
|
except AttributeError:
|
|
270
288
|
skip = True # Windows — always pass flag
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
cmd = [bin_path, "--bare"]
|
|
289
|
+
cmd = [bin_path]
|
|
290
|
+
if use_bare:
|
|
291
|
+
cmd.append("--bare")
|
|
275
292
|
if skip:
|
|
276
293
|
cmd.append("--dangerously-skip-permissions")
|
|
277
294
|
if session_id:
|
|
@@ -346,6 +363,7 @@ def _run_claude_attempt(
|
|
|
346
363
|
on_progress=None,
|
|
347
364
|
cmd_override: list | None = None,
|
|
348
365
|
session_id: str = "",
|
|
366
|
+
use_bare: bool | None = None,
|
|
349
367
|
) -> tuple[str, bool]:
|
|
350
368
|
"""
|
|
351
369
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
@@ -354,11 +372,16 @@ def _run_claude_attempt(
|
|
|
354
372
|
(deduped — same message not repeated consecutively).
|
|
355
373
|
cmd_override: if provided, use this command list instead of _claude_cmd() default.
|
|
356
374
|
session_id: if provided, passes --resume to Claude to continue an existing session.
|
|
375
|
+
use_bare: if None (default), auto-detect — True iff env carries ANTHROPIC_API_KEY.
|
|
376
|
+
Pass --bare ONLY for the API-key attempt; the OAuth attempt must
|
|
377
|
+
omit it so Claude reads the cached `claude login` token.
|
|
357
378
|
"""
|
|
358
379
|
import threading as _threading
|
|
359
380
|
|
|
381
|
+
if use_bare is None:
|
|
382
|
+
use_bare = bool(env.get("ANTHROPIC_API_KEY"))
|
|
360
383
|
proc = subprocess.Popen(
|
|
361
|
-
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id),
|
|
384
|
+
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id, use_bare=use_bare),
|
|
362
385
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
363
386
|
text=True, env=env, cwd=cwd or None,
|
|
364
387
|
)
|
|
@@ -477,11 +500,28 @@ def generate_fix(
|
|
|
477
500
|
except Exception as _e:
|
|
478
501
|
logger.debug("fix_engine: git log check failed: %s", _e)
|
|
479
502
|
|
|
480
|
-
#
|
|
503
|
+
# Pre-pull every project repo so Claude reads up-to-date content. This
|
|
504
|
+
# closes the window where a previous sentinel commit (or human commit)
|
|
505
|
+
# has landed on remote but the local working tree hasn't been refreshed.
|
|
506
|
+
if all_repos:
|
|
507
|
+
from .git_manager import pull_all_repos
|
|
508
|
+
pull_results = pull_all_repos(all_repos)
|
|
509
|
+
n_failed = sum(1 for ok in pull_results.values() if not ok)
|
|
510
|
+
if n_failed:
|
|
511
|
+
logger.warning(
|
|
512
|
+
"fix_engine: pre-fix pull failed for %d/%d repo(s): %s",
|
|
513
|
+
n_failed, len(pull_results),
|
|
514
|
+
[n for n, ok in pull_results.items() if not ok][:5],
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Pull saved session id — keyed per (project, target_repo) so a prior fix
|
|
518
|
+
# targeting repo A doesn't contaminate Claude's memory for a fix targeting
|
|
519
|
+
# repo B (their files differ; resumed memory leads to stale-context patches).
|
|
520
|
+
session_key = f"{getattr(cfg, 'project_name', '') or '_default'}/{repo.repo_name}"
|
|
481
521
|
session_id = ""
|
|
482
|
-
if store is not None
|
|
522
|
+
if store is not None:
|
|
483
523
|
try:
|
|
484
|
-
saved = store.get_claude_session(
|
|
524
|
+
saved = store.get_claude_session(session_key)
|
|
485
525
|
if saved:
|
|
486
526
|
session_id = saved.get("session_id", "") or ""
|
|
487
527
|
except Exception as _se:
|
|
@@ -491,8 +531,8 @@ def generate_fix(
|
|
|
491
531
|
claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
|
|
492
532
|
claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
|
|
493
533
|
logger.info(
|
|
494
|
-
"Invoking Claude Code for %s (fp=%s) — log: %s — resume=%s",
|
|
495
|
-
event.source, event.fingerprint, claude_log_path,
|
|
534
|
+
"Invoking Claude Code for %s (fp=%s, route=%s) — log: %s — resume=%s",
|
|
535
|
+
event.source, event.fingerprint, repo.repo_name, claude_log_path,
|
|
496
536
|
session_id[:8] if session_id else "(new)",
|
|
497
537
|
)
|
|
498
538
|
|
|
@@ -571,15 +611,16 @@ def generate_fix(
|
|
|
571
611
|
|
|
572
612
|
# Persist the session id (and cost delta) regardless of fix outcome — even
|
|
573
613
|
# NEEDS_HUMAN / SKIP turns count toward the conversation history.
|
|
574
|
-
|
|
614
|
+
# Same composite key as the read above so per-route memory stays separated.
|
|
615
|
+
if store is not None and parsed["session_id"]:
|
|
575
616
|
try:
|
|
576
617
|
store.set_claude_session(
|
|
577
|
-
|
|
618
|
+
session_key, parsed["session_id"],
|
|
578
619
|
cost_delta=parsed["total_cost_usd"],
|
|
579
620
|
)
|
|
580
621
|
logger.info(
|
|
581
|
-
"fix_engine: saved claude session %s for
|
|
582
|
-
parsed["session_id"][:8],
|
|
622
|
+
"fix_engine: saved claude session %s for %s (turn cost $%.4f)",
|
|
623
|
+
parsed["session_id"][:8], session_key, parsed["total_cost_usd"],
|
|
583
624
|
)
|
|
584
625
|
except Exception as _se:
|
|
585
626
|
logger.warning("fix_engine: set_claude_session failed: %s", _se)
|
|
@@ -192,6 +192,38 @@ def maven_compile_check(local_path: str, timeout: int = 300) -> tuple[bool, str]
|
|
|
192
192
|
return r.returncode == 0, output
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
def pull_all_repos(repos: list[RepoConfig]) -> dict[str, bool]:
|
|
196
|
+
"""Discard local edits and `git pull --rebase` every repo in the list.
|
|
197
|
+
|
|
198
|
+
Used right before invoking Claude so it reads up-to-date file content. A
|
|
199
|
+
failure for any single repo is logged as a warning but never raised — the
|
|
200
|
+
return dict tells callers which repos pulled cleanly so they can decide
|
|
201
|
+
what to do (e.g. fix engine continues anyway; the target-repo dry-run in
|
|
202
|
+
apply_and_commit_multi will catch a stale-on-disk patch later).
|
|
203
|
+
"""
|
|
204
|
+
results: dict[str, bool] = {}
|
|
205
|
+
for repo in repos:
|
|
206
|
+
if not repo.local_path:
|
|
207
|
+
results[repo.repo_name] = False
|
|
208
|
+
continue
|
|
209
|
+
env = _git_env(repo)
|
|
210
|
+
try:
|
|
211
|
+
_git(["checkout", "."], cwd=repo.local_path, env=env)
|
|
212
|
+
r = _git(["pull", "--rebase", "origin", repo.branch],
|
|
213
|
+
cwd=repo.local_path, env=env)
|
|
214
|
+
ok = (r.returncode == 0)
|
|
215
|
+
results[repo.repo_name] = ok
|
|
216
|
+
if not ok:
|
|
217
|
+
logger.warning(
|
|
218
|
+
"pull_all_repos: %s pull failed: %s",
|
|
219
|
+
repo.repo_name, r.stderr.strip()[:200],
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.warning("pull_all_repos: %s exception: %s", repo.repo_name, e)
|
|
223
|
+
results[repo.repo_name] = False
|
|
224
|
+
return results
|
|
225
|
+
|
|
226
|
+
|
|
195
227
|
def _check_protected_paths(patch_path: Path) -> bool:
|
|
196
228
|
text = patch_path.read_text(encoding="utf-8", errors="replace")
|
|
197
229
|
for line in text.splitlines():
|
package/python/sentinel/main.py
CHANGED
|
@@ -628,12 +628,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
628
628
|
try:
|
|
629
629
|
patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
|
|
630
630
|
_loop = asyncio.get_event_loop()
|
|
631
|
+
all_repos = list(cfg_loader.repos.values())
|
|
631
632
|
|
|
632
633
|
# Run blocking subprocess work in thread executor so Boss stays responsive.
|
|
633
634
|
# _progress is thread-safe (HTTP POST) so pass it as streaming callback.
|
|
634
635
|
_progress(":brain: Analyzing with Claude Code...")
|
|
635
636
|
status, patch_path, marker = await _loop.run_in_executor(
|
|
636
|
-
None,
|
|
637
|
+
None,
|
|
638
|
+
lambda: generate_fix(event, repo, sentinel, patches_dir, store,
|
|
639
|
+
_progress, all_repos),
|
|
637
640
|
)
|
|
638
641
|
|
|
639
642
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
@@ -652,6 +655,133 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
652
655
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
653
656
|
"status": "blocked", "summary": reason_text[:120], "pr_url": ""}
|
|
654
657
|
|
|
658
|
+
# ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
|
|
659
|
+
_progress(f":mag: Patch generated — applying & testing...")
|
|
660
|
+
multi_results = await _loop.run_in_executor(
|
|
661
|
+
None, apply_and_commit_multi, event, patch_path, all_repos, sentinel,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
if multi_results:
|
|
665
|
+
multi_results = await _loop.run_in_executor(
|
|
666
|
+
None, publish_multi, event, multi_results, sentinel,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
committed = [r for r in multi_results if r["status"] == "committed"]
|
|
670
|
+
failed = [r for r in multi_results if r["status"] != "committed"]
|
|
671
|
+
|
|
672
|
+
for r in multi_results:
|
|
673
|
+
pr_state = "open" if r.get("pr_url") else (
|
|
674
|
+
"merged" if r["status"] == "committed" and auto_commit else ""
|
|
675
|
+
)
|
|
676
|
+
try:
|
|
677
|
+
store.record_fix_repo(
|
|
678
|
+
event.fingerprint, r["repo_name"],
|
|
679
|
+
branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
|
|
680
|
+
pr_url=r.get("pr_url", ""),
|
|
681
|
+
pr_state=pr_state if r["status"] == "committed" else r["status"],
|
|
682
|
+
apply_order=r.get("apply_order", 0),
|
|
683
|
+
)
|
|
684
|
+
except Exception as _se:
|
|
685
|
+
logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
|
|
686
|
+
|
|
687
|
+
primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
|
|
688
|
+
if primary and primary["status"] == "committed":
|
|
689
|
+
store.record_fix(
|
|
690
|
+
event.fingerprint,
|
|
691
|
+
"applied" if auto_commit else "pending",
|
|
692
|
+
patch_path=str(patch_path),
|
|
693
|
+
commit_hash=primary["commit_hash"],
|
|
694
|
+
branch=primary.get("branch", ""),
|
|
695
|
+
pr_url=primary.get("pr_url", ""),
|
|
696
|
+
repo_name=repo.repo_name,
|
|
697
|
+
sentinel_marker=marker,
|
|
698
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
701
|
+
|
|
702
|
+
if not committed:
|
|
703
|
+
reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
|
|
704
|
+
_progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
|
|
705
|
+
notify_fix_blocked(sentinel, event.source, event.message,
|
|
706
|
+
reason=f"Multi-repo apply failed: {reasons[:400]}",
|
|
707
|
+
repo_name=repo.repo_name,
|
|
708
|
+
submitter_user_id=submitter_uid,
|
|
709
|
+
body=getattr(event, "body", ""))
|
|
710
|
+
mark_done(event.issue_file)
|
|
711
|
+
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
712
|
+
"status": "blocked", "summary": "Multi-repo apply failed", "pr_url": ""}
|
|
713
|
+
|
|
714
|
+
if failed:
|
|
715
|
+
_progress(
|
|
716
|
+
f":warning: Multi-repo fix PARTIAL — committed in "
|
|
717
|
+
f"{', '.join(r['repo_name'] for r in committed)}; "
|
|
718
|
+
f"failed in {', '.join(r['repo_name'] for r in failed)}"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
pr_lines = []
|
|
722
|
+
for r in committed:
|
|
723
|
+
if r.get("pr_url"):
|
|
724
|
+
pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
|
|
725
|
+
else:
|
|
726
|
+
pr_lines.append(
|
|
727
|
+
f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
|
|
728
|
+
f"(`{r.get('commit_hash','')[:8]}`)"
|
|
729
|
+
)
|
|
730
|
+
if pr_lines:
|
|
731
|
+
_progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
|
|
732
|
+
|
|
733
|
+
if auto_commit and not auto_release:
|
|
734
|
+
for r in committed:
|
|
735
|
+
if r.get("commit_hash") and r.get("branch"):
|
|
736
|
+
store.add_pending_release(
|
|
737
|
+
r["repo_name"], r["commit_hash"], r["branch"],
|
|
738
|
+
description=event.message[:120],
|
|
739
|
+
fingerprint=event.fingerprint,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if primary and primary["status"] == "committed":
|
|
743
|
+
send_fix_notification(sentinel, {
|
|
744
|
+
"source": event.source,
|
|
745
|
+
"severity": "ERROR",
|
|
746
|
+
"fingerprint": event.fingerprint,
|
|
747
|
+
"first_seen": event.timestamp,
|
|
748
|
+
"message": event.message,
|
|
749
|
+
"stack_trace": event.body,
|
|
750
|
+
"repo_name": repo.repo_name,
|
|
751
|
+
"commit_hash": primary.get("commit_hash", ""),
|
|
752
|
+
"branch": primary.get("branch", ""),
|
|
753
|
+
"pr_url": primary.get("pr_url", ""),
|
|
754
|
+
"auto_commit": auto_commit,
|
|
755
|
+
"files_changed": [],
|
|
756
|
+
})
|
|
757
|
+
notify_fix_applied(
|
|
758
|
+
sentinel, event.source, event.message,
|
|
759
|
+
repo_name=repo.repo_name,
|
|
760
|
+
branch=primary.get("branch", ""),
|
|
761
|
+
pr_url=primary.get("pr_url", ""),
|
|
762
|
+
submitter_user_id=submitter_uid,
|
|
763
|
+
origin_channel=_origin_channel,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
mark_done(event.issue_file)
|
|
767
|
+
|
|
768
|
+
if auto_commit and auto_release and primary and primary["status"] == "committed":
|
|
769
|
+
ok = cicd_trigger(repo, store, event.fingerprint)
|
|
770
|
+
if ok:
|
|
771
|
+
_progress(f":rocket: Release triggered via {repo.cicd_type}")
|
|
772
|
+
if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
|
|
773
|
+
_run_cascade(repo, sentinel, cfg_loader)
|
|
774
|
+
|
|
775
|
+
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
776
|
+
"status": "done" if not failed else "partial",
|
|
777
|
+
"summary": event.message[:120],
|
|
778
|
+
"pr_url": primary.get("pr_url", "") if primary else ""}
|
|
779
|
+
|
|
780
|
+
# Legacy single-repo patch (no `repos/<name>/` prefix) — existing flow below.
|
|
781
|
+
logger.info(
|
|
782
|
+
"_handle_issue: patch %s has no repos/<name>/ prefix — falling back to single-repo apply",
|
|
783
|
+
event.fingerprint[:8],
|
|
784
|
+
)
|
|
655
785
|
_progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
|
|
656
786
|
commit_status, commit_hash = await _loop.run_in_executor(
|
|
657
787
|
None, apply_and_commit, event, patch_path, repo, sentinel
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_fix_engine_cmd.py — Tests for _claude_cmd flag composition.
|
|
3
|
+
|
|
4
|
+
The OAuth path silently fast-failed in production until 1.6.1 because
|
|
5
|
+
_claude_cmd unconditionally passed --bare, which forces API-key-only auth
|
|
6
|
+
and rejects the OAuth attempt's keyless env. These tests pin the new
|
|
7
|
+
behaviour: --bare only when the API key is actually in the env.
|
|
8
|
+
"""
|
|
9
|
+
from sentinel.fix_engine import _claude_cmd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_default_includes_bare_for_backcompat():
|
|
13
|
+
"""Existing callers without an explicit flag still get --bare."""
|
|
14
|
+
cmd = _claude_cmd("claude", "hi")
|
|
15
|
+
assert "--bare" in cmd
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_use_bare_false_drops_bare():
|
|
19
|
+
cmd = _claude_cmd("claude", "hi", use_bare=False)
|
|
20
|
+
assert "--bare" not in cmd
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_use_bare_true_keeps_bare():
|
|
24
|
+
cmd = _claude_cmd("claude", "hi", use_bare=True)
|
|
25
|
+
assert "--bare" in cmd
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_session_id_adds_resume():
|
|
29
|
+
cmd = _claude_cmd("claude", "hi", session_id="abc-123")
|
|
30
|
+
assert "--resume" in cmd
|
|
31
|
+
i = cmd.index("--resume")
|
|
32
|
+
assert cmd[i + 1] == "abc-123"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_no_session_id_skips_resume():
|
|
36
|
+
cmd = _claude_cmd("claude", "hi", session_id="")
|
|
37
|
+
assert "--resume" not in cmd
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_output_format_json_always_present():
|
|
41
|
+
cmd_a = _claude_cmd("claude", "hi", use_bare=True)
|
|
42
|
+
cmd_b = _claude_cmd("claude", "hi", use_bare=False)
|
|
43
|
+
for cmd in (cmd_a, cmd_b):
|
|
44
|
+
assert "--output-format" in cmd
|
|
45
|
+
i = cmd.index("--output-format")
|
|
46
|
+
assert cmd[i + 1] == "json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_print_and_prompt_are_last():
|
|
50
|
+
"""`--print <prompt>` must come last so all flags are interpreted."""
|
|
51
|
+
cmd = _claude_cmd("claude", "the actual prompt", use_bare=False)
|
|
52
|
+
assert cmd[-2] == "--print"
|
|
53
|
+
assert cmd[-1] == "the actual prompt"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_pull_all_repos.py — Tests for the pre-fix git pull helper.
|
|
3
|
+
|
|
4
|
+
pull_all_repos() runs `git checkout . && git pull --rebase` on every project repo
|
|
5
|
+
so Claude reads up-to-date content. Per-repo failures are non-fatal — they get
|
|
6
|
+
logged as warnings and recorded in the result dict, not raised.
|
|
7
|
+
"""
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
from sentinel import git_manager
|
|
13
|
+
from sentinel.config_loader import RepoConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ok():
|
|
17
|
+
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _fail(msg="pull rejected"):
|
|
21
|
+
return SimpleNamespace(returncode=1, stdout="", stderr=msg)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _mk_repo(tmp_path: Path, name: str) -> RepoConfig:
|
|
25
|
+
p = tmp_path / "repos" / name
|
|
26
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return RepoConfig(repo_name=name, local_path=str(p), branch="main")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_empty_repo_list_returns_empty(tmp_path):
|
|
31
|
+
assert git_manager.pull_all_repos([]) == {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_single_successful_repo(tmp_path):
|
|
35
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
36
|
+
with patch.object(git_manager, "_git", return_value=_ok()):
|
|
37
|
+
result = git_manager.pull_all_repos([repo])
|
|
38
|
+
assert result == {"r1": True}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_single_failing_repo_recorded_not_raised(tmp_path):
|
|
42
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
43
|
+
with patch.object(git_manager, "_git", return_value=_fail()):
|
|
44
|
+
result = git_manager.pull_all_repos([repo])
|
|
45
|
+
assert result == {"r1": False}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_mixed_results_per_repo(tmp_path):
|
|
49
|
+
a = _mk_repo(tmp_path, "ok-repo")
|
|
50
|
+
b = _mk_repo(tmp_path, "bad-repo")
|
|
51
|
+
c = _mk_repo(tmp_path, "ok-repo-2")
|
|
52
|
+
|
|
53
|
+
# Each repo gets two _git calls (checkout + pull). We make 'bad-repo' fail on pull.
|
|
54
|
+
call_log = []
|
|
55
|
+
def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
|
|
56
|
+
call_log.append((args[0], cwd))
|
|
57
|
+
if "bad-repo" in cwd and args[0] == "pull":
|
|
58
|
+
return _fail("conflict")
|
|
59
|
+
return _ok()
|
|
60
|
+
|
|
61
|
+
with patch.object(git_manager, "_git", side_effect=fake_git):
|
|
62
|
+
result = git_manager.pull_all_repos([a, b, c])
|
|
63
|
+
|
|
64
|
+
assert result == {"ok-repo": True, "bad-repo": False, "ok-repo-2": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_repo_with_empty_local_path_is_skipped(tmp_path):
|
|
68
|
+
repo = RepoConfig(repo_name="ghost", local_path="", branch="main")
|
|
69
|
+
with patch.object(git_manager, "_git") as g:
|
|
70
|
+
result = git_manager.pull_all_repos([repo])
|
|
71
|
+
assert result == {"ghost": False}
|
|
72
|
+
g.assert_not_called()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_subprocess_exception_caught_and_recorded(tmp_path):
|
|
76
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
77
|
+
with patch.object(git_manager, "_git",
|
|
78
|
+
side_effect=RuntimeError("git binary missing")):
|
|
79
|
+
result = git_manager.pull_all_repos([repo])
|
|
80
|
+
assert result == {"r1": False}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_calls_checkout_then_pull_per_repo(tmp_path):
|
|
84
|
+
"""Order matters: checkout (discard local edits) before pull."""
|
|
85
|
+
repo = _mk_repo(tmp_path, "r1")
|
|
86
|
+
seq = []
|
|
87
|
+
def fake_git(args, cwd, env=None, timeout=git_manager.GIT_TIMEOUT):
|
|
88
|
+
seq.append(args[0])
|
|
89
|
+
return _ok()
|
|
90
|
+
with patch.object(git_manager, "_git", side_effect=fake_git):
|
|
91
|
+
git_manager.pull_all_repos([repo])
|
|
92
|
+
assert seq[0] == "checkout"
|
|
93
|
+
assert "pull" in seq
|
|
94
|
+
assert seq.index("checkout") < seq.index("pull")
|