gitvow 0.13.0__tar.gz → 0.13.2__tar.gz

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 (51) hide show
  1. {gitvow-0.13.0/src/gitvow.egg-info → gitvow-0.13.2}/PKG-INFO +1 -1
  2. {gitvow-0.13.0 → gitvow-0.13.2}/pyproject.toml +1 -1
  3. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/cli.py +51 -11
  4. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/install.py +104 -7
  5. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/status.py +39 -38
  6. {gitvow-0.13.0 → gitvow-0.13.2/src/gitvow.egg-info}/PKG-INFO +1 -1
  7. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_adapters.py +0 -1
  8. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_status.py +38 -3
  9. {gitvow-0.13.0 → gitvow-0.13.2}/LICENSE +0 -0
  10. {gitvow-0.13.0 → gitvow-0.13.2}/README.md +0 -0
  11. {gitvow-0.13.0 → gitvow-0.13.2}/setup.cfg +0 -0
  12. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/__init__.py +0 -0
  13. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/__main__.py +0 -0
  14. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/adapters.py +0 -0
  15. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/collect.py +0 -0
  16. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/decisions.py +0 -0
  17. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/default_policy.json +0 -0
  18. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/digest.py +0 -0
  19. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/hooks/__init__.py +0 -0
  20. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/policy.py +0 -0
  21. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/pricing.py +0 -0
  22. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/providers.py +0 -0
  23. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/recall.py +0 -0
  24. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/redact.py +0 -0
  25. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/report.py +0 -0
  26. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/rules.py +0 -0
  27. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/selftest.py +0 -0
  28. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/snapshots.py +0 -0
  29. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/state.py +0 -0
  30. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow/transcript.py +0 -0
  31. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow.egg-info/SOURCES.txt +0 -0
  32. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
  33. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow.egg-info/entry_points.txt +0 -0
  34. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow.egg-info/requires.txt +0 -0
  35. {gitvow-0.13.0 → gitvow-0.13.2}/src/gitvow.egg-info/top_level.txt +0 -0
  36. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_collect.py +0 -0
  37. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_cost.py +0 -0
  38. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_decisions.py +0 -0
  39. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_digest.py +0 -0
  40. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_external_adapter.py +0 -0
  41. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_hooks.py +0 -0
  42. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_install_cli.py +0 -0
  43. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_packaging.py +0 -0
  44. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_policy.py +0 -0
  45. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_providers.py +0 -0
  46. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_recall.py +0 -0
  47. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_redact.py +0 -0
  48. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_report.py +0 -0
  49. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_rules.py +0 -0
  50. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_snapshots.py +0 -0
  51. {gitvow-0.13.0 → gitvow-0.13.2}/tests/test_transcript_formats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitvow"
7
- version = "0.13.0"
7
+ version = "0.13.2"
8
8
  description = "Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -97,18 +97,57 @@ def _check_agent(agent: str) -> int:
97
97
 
98
98
 
99
99
  def cmd_install(a: argparse.Namespace) -> int:
100
- agent = a.agent or "claude"
101
- if _check_agent(agent):
102
- return 1
103
- if a.user:
104
- done = install_user(os.path.expanduser("~"), agent=agent)
100
+ """Without --agent, configure every agent found on this machine: not passing a flag should not
101
+ silently leave an agent ungated."""
102
+ from .install import detect_agents
103
+
104
+ home = os.path.expanduser("~")
105
+ repo = os.path.abspath(a.repo)
106
+ if a.agent:
107
+ agents, detected = [a.agent], False
105
108
  else:
106
- done = install_repo(os.path.abspath(a.repo), agent=agent)
107
- print("\n".join(done))
109
+ agents, detected = detect_agents(home), True
110
+ if not agents:
111
+ agents = ["claude"]
112
+ for agent in agents:
113
+ if _check_agent(agent):
114
+ return 1
115
+ lines: list[str] = []
116
+ if detected:
117
+ lines.append(f"agents found here: {', '.join(agents)} (use --agent to pick one)")
118
+ for agent in agents:
119
+ lines += install_user(home, agent=agent) if a.user else install_repo(repo, agent=agent)
120
+ seen: set[str] = set()
121
+ for ln in lines:
122
+ if ln not in seen:
123
+ seen.add(ln)
124
+ print(ln)
125
+ print("next: run `gitvow status` inside a repository to check the record is actually being written.")
126
+ if a.check:
127
+ from .selftest import run, run_agent
128
+
129
+ print()
130
+ worst = 0
131
+ for agent in agents:
132
+ worst = max(worst, run() if agent == "claude" else run_agent(agent))
133
+ if worst:
134
+ print("the self-check failed; `gitvow status` says what is missing", file=sys.stderr)
108
135
  return 0
109
136
 
110
137
 
111
138
  def cmd_uninstall(a: argparse.Namespace) -> int:
139
+ from .install import installed_agents
140
+
141
+ if not a.agent:
142
+ home = os.path.expanduser("~")
143
+ repo = os.path.abspath(a.repo)
144
+ found = [ag for ag, scope, _ in installed_agents(home, repo) if (scope == "user") == bool(a.user)]
145
+ if len(found) > 1:
146
+ worst = 0
147
+ for ag in dict.fromkeys(found):
148
+ worst = max(worst, cmd_uninstall(argparse.Namespace(**{**vars(a), "agent": ag})))
149
+ return worst
150
+ a.agent = found[0] if found else None
112
151
  agent = a.agent or "claude"
113
152
  if _check_agent(agent):
114
153
  return 1
@@ -464,9 +503,10 @@ def main(argv: list[str] | None = None) -> int:
464
503
  s.add_argument("--user", action="store_true")
465
504
  s.add_argument(
466
505
  "--agent",
467
- default="claude",
468
- help="claude|codex|gemini|cursor|copilot|factory or an external gitvow-agent-<name>",
506
+ help="claude|codex|gemini|cursor|copilot|factory or an external gitvow-agent-<name>; "
507
+ "omit to configure every agent found on this machine",
469
508
  )
509
+ s.add_argument("--check", action="store_true", help="run the self-check afterwards and show it working")
470
510
  s.set_defaults(f=cmd_install)
471
511
  s = sub.add_parser("uninstall", help="remove what install added")
472
512
  s.add_argument("repo", nargs="?", default=".")
@@ -477,8 +517,8 @@ def main(argv: list[str] | None = None) -> int:
477
517
  s.add_argument("--purge-snapshots", action="store_true")
478
518
  s.add_argument(
479
519
  "--agent",
480
- default="claude",
481
- help="claude|codex|gemini|cursor|copilot|factory or an external gitvow-agent-<name>",
520
+ help="claude|codex|gemini|cursor|copilot|factory or an external gitvow-agent-<name>; "
521
+ "omit to remove gitvow from every agent configured here",
482
522
  )
483
523
  s.set_defaults(f=cmd_uninstall)
484
524
  s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
@@ -374,18 +374,115 @@ def agent_next_steps(agent: str, home: str, repo: str | None = None) -> list[str
374
374
  "codex: its sandbox refuses writes inside .git, so the agent cannot commit at all. Add to "
375
375
  f'~/.codex/config.toml: [sandbox_workspace_write] writable_roots = ["{gd}"]'
376
376
  )
377
- out.append("check it with `gitvow status`.")
377
+ return out
378
+
379
+
380
+ GIT_HOOKS_EXPECTED = ("prepare-commit-msg", "pre-push", "pre-commit", "post-commit")
381
+ GIT_HOOK_BODIES = {
382
+ "prepare-commit-msg": GIT_HOOK,
383
+ "pre-push": PRE_PUSH_HOOK,
384
+ "pre-commit": PRE_COMMIT_HOOK,
385
+ "post-commit": POST_COMMIT_HOOK,
386
+ }
387
+
388
+ # How to tell an agent is on this machine: a configuration directory under $HOME, an absolute path, or a
389
+ # command on the PATH. Detection only decides what install offers; it never changes what a hook does.
390
+ AGENT_PROBES: dict[str, tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]] = {
391
+ "claude": ((".claude",), (), ("claude",)),
392
+ "codex": ((".codex",), (), ("codex",)),
393
+ "gemini": ((".gemini",), (), ("gemini",)),
394
+ "cursor": ((".cursor",), ("/Applications/Cursor.app",), ("cursor-agent", "cursor")),
395
+ "copilot": ((".copilot",), (), ("copilot",)),
396
+ "factory": ((".factory",), (), ("droid",)),
397
+ }
398
+
399
+
400
+ def detect_agents(home: str) -> list[str]:
401
+ """Agents that look installed here, built-in first, then any external gitvow-agent-<name> found."""
402
+ found = [
403
+ agent
404
+ for agent, (dirs, paths, bins) in AGENT_PROBES.items()
405
+ if any(os.path.isdir(os.path.join(home, d)) for d in dirs)
406
+ or any(os.path.exists(p) for p in paths)
407
+ or any(shutil.which(b) for b in bins)
408
+ ]
409
+ seen = set(found)
410
+ for d in [os.path.join(home, ".gitvow", "agents"), *os.environ.get("PATH", "").split(os.pathsep)]:
411
+ try:
412
+ names = os.listdir(d)
413
+ except OSError:
414
+ continue
415
+ for n in names:
416
+ if n.startswith("gitvow-agent-") and os.access(os.path.join(d, n), os.X_OK):
417
+ name = n[len("gitvow-agent-") :]
418
+ if name not in seen:
419
+ seen.add(name)
420
+ found.append(name)
421
+ return found
422
+
423
+
424
+ def hook_commands(path: str) -> list[str]:
425
+ """Every gitvow hook command in an agent settings file, whatever the schema around it."""
426
+ if not os.path.exists(path):
427
+ return []
428
+ try:
429
+ with open(path) as fh:
430
+ data = json.load(fh)
431
+ except (OSError, json.JSONDecodeError):
432
+ return []
433
+ out: list[str] = []
434
+
435
+ def walk(node: Any) -> None:
436
+ if isinstance(node, dict):
437
+ cmd = node.get("command")
438
+ if isinstance(cmd, str) and MARKER in cmd:
439
+ out.append(cmd)
440
+ for v in node.values():
441
+ walk(v)
442
+ elif isinstance(node, list):
443
+ for v in node:
444
+ walk(v)
445
+
446
+ walk(data)
447
+ return out
448
+
449
+
450
+ def installed_agents(home: str, repo: str | None = None) -> list[tuple[str, str, str]]:
451
+ """(agent, scope, path) for every agent settings file that already carries gitvow hooks."""
452
+ out: list[tuple[str, str, str]] = []
453
+ for agent, (user_rel, repo_rel) in AGENT_FILES.items():
454
+ for scope, path in (("user", os.path.join(home, user_rel)), ("repo", os.path.join(repo or "", repo_rel))):
455
+ if scope == "repo" and not repo:
456
+ continue
457
+ if hook_commands(path):
458
+ out.append((agent, scope, path))
459
+ return out
460
+
461
+
462
+ def expected_events(agent: str) -> set[str]:
463
+ """The hook events this version of gitvow installs for an agent."""
464
+ if agent not in AGENT_FILES:
465
+ return set()
466
+ return set(_agent_entries(agent, "gitvow"))
467
+
468
+
469
+ def stale_git_hooks(dirpath: str) -> list[str]:
470
+ """Hooks whose contents are not what this version writes: an install left behind by an older gitvow."""
471
+ out = []
472
+ for name, body in GIT_HOOK_BODIES.items():
473
+ p = os.path.join(dirpath, name)
474
+ try:
475
+ with open(p) as fh:
476
+ if fh.read() != body:
477
+ out.append(name)
478
+ except OSError:
479
+ continue # missing hooks are reported separately
378
480
  return out
379
481
 
380
482
 
381
483
  def _write_git_hook(dirpath: str) -> str:
382
484
  os.makedirs(dirpath, exist_ok=True)
383
- for name, body in (
384
- ("prepare-commit-msg", GIT_HOOK),
385
- ("pre-push", PRE_PUSH_HOOK),
386
- ("pre-commit", PRE_COMMIT_HOOK),
387
- ("post-commit", POST_COMMIT_HOOK),
388
- ):
485
+ for name, body in GIT_HOOK_BODIES.items():
389
486
  p = os.path.join(dirpath, name)
390
487
  with open(p, "w") as fh:
391
488
  fh.write(body)
@@ -7,46 +7,25 @@ worst outcome, so this exists to make that state loud.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import json
11
10
  import os
12
11
  import shutil
13
- from typing import Any
14
12
 
15
- from .install import AGENT_FILES, MARKER, NOTES_GLOB, agent_next_steps
13
+ from .install import (
14
+ AGENT_FILES,
15
+ GIT_HOOKS_EXPECTED,
16
+ NOTES_GLOB,
17
+ agent_next_steps,
18
+ expected_events,
19
+ hook_commands,
20
+ stale_git_hooks,
21
+ )
16
22
  from .policy import PolicyError, load_policy
17
23
  from .redact import RedactionError, load_rules
18
24
  from .state import git, git_dir, toplevel
19
25
 
20
- GIT_HOOKS = ("prepare-commit-msg", "pre-commit", "post-commit", "pre-push")
21
26
  Check = tuple[str, str, str] # (state: ok|fail|note, what, fix or detail)
22
27
 
23
28
 
24
- def _hook_commands(path: str) -> list[str]:
25
- """Every gitvow hook command in an agent settings file, whatever the schema around it."""
26
- if not os.path.exists(path):
27
- return []
28
- try:
29
- with open(path) as fh:
30
- data = json.load(fh)
31
- except (OSError, json.JSONDecodeError):
32
- return []
33
- out: list[str] = []
34
-
35
- def walk(node: Any) -> None:
36
- if isinstance(node, dict):
37
- cmd = node.get("command")
38
- if isinstance(cmd, str) and MARKER in cmd:
39
- out.append(cmd)
40
- for v in node.values():
41
- walk(v)
42
- elif isinstance(node, list):
43
- for v in node:
44
- walk(v)
45
-
46
- walk(data)
47
- return out
48
-
49
-
50
29
  def _executable(cmd: str) -> tuple[bool, str, bool]:
51
30
  """(runnable, resolved path, found on PATH rather than recorded absolutely)."""
52
31
  parts = cmd.split()
@@ -65,7 +44,7 @@ def _agent_checks(home: str, cwd: str) -> list[Check]:
65
44
  for scope, path in (("user", os.path.join(home, user_rel)), ("repo", os.path.join(top or cwd, repo_rel))):
66
45
  if scope == "repo" and not top:
67
46
  continue
68
- cmds = _hook_commands(path)
47
+ cmds = hook_commands(path)
69
48
  if not cmds:
70
49
  continue
71
50
  found_any = True
@@ -91,6 +70,15 @@ def _agent_checks(home: str, cwd: str) -> list[Check]:
91
70
  )
92
71
  else:
93
72
  checks.append(("ok", f"{agent} ({scope}): {len(cmds)} hooks in {where} → {resolved}", ""))
73
+ missing = sorted(e for e in expected_events(agent) if not any(f" {e}" in c for c in cmds))
74
+ if missing:
75
+ checks.append(
76
+ (
77
+ "fail",
78
+ f"{agent} ({scope}): installed by an older gitvow, missing {', '.join(missing)}",
79
+ f"re-run `gitvow install {'--user ' if scope == 'user' else ''}--agent {agent}`",
80
+ )
81
+ )
94
82
  checks += _agent_notes(agent, home, top)
95
83
  if not found_any:
96
84
  checks.append(
@@ -121,7 +109,7 @@ def _git_checks(cwd: str, home: str) -> list[Check]:
121
109
  checks.append(("fail", "core.hooksPath is not set, so no commit gets a trailer", "run `gitvow install --user`"))
122
110
  return checks
123
111
  hooks_dir = hp if os.path.isabs(hp) else os.path.join(top, hp)
124
- missing = [h for h in GIT_HOOKS if not os.access(os.path.join(hooks_dir, h), os.X_OK)]
112
+ missing = [h for h in GIT_HOOKS_EXPECTED if not os.access(os.path.join(hooks_dir, h), os.X_OK)]
125
113
  if missing:
126
114
  checks.append(
127
115
  (
@@ -131,7 +119,17 @@ def _git_checks(cwd: str, home: str) -> list[Check]:
131
119
  )
132
120
  )
133
121
  else:
134
- checks.append(("ok", f"git hooks in {hp}: {', '.join(GIT_HOOKS)}", ""))
122
+ stale = stale_git_hooks(hooks_dir)
123
+ if stale:
124
+ checks.append(
125
+ (
126
+ "fail",
127
+ f"git hooks written by an older gitvow: {', '.join(stale)}",
128
+ "re-run `gitvow install` here; behaviour added since that install is not active",
129
+ )
130
+ )
131
+ else:
132
+ checks.append(("ok", f"git hooks in {hp}: {', '.join(GIT_HOOKS_EXPECTED)}", ""))
135
133
  rc, refs, _ = git(["config", "--get-all", "notes.displayRef"], top)
136
134
  if NOTES_GLOB not in (refs or ""):
137
135
  checks.append(
@@ -179,9 +177,12 @@ def render(checks: list[Check]) -> str:
179
177
  if fix:
180
178
  lines.append(" " + fix)
181
179
  fails = sum(1 for s, _, _ in checks if s == "fail")
182
- notes = sum(1 for s, _, _ in checks if s == "note")
183
- lines += [
184
- "",
185
- f"{fails} failing, {notes} to check by hand." if fails else f"nothing failing, {notes} to check by hand.",
186
- ]
180
+ notes = sum(1 for s, _, fix in checks if s == "note" and fix) # only notes that ask something of you
181
+ tail = f" {notes} step{'s' if notes != 1 else ''} left to you above." if notes else ""
182
+ if fails:
183
+ lines += ["", f"{fails} failing: the record is not being written until those are fixed.{tail}"]
184
+ elif any(s == "note" and "no hook log" in w for s, w, _ in checks):
185
+ lines += ["", f"Ready: run a session and commit, or `gitvow selftest` to see it work now.{tail}"]
186
+ else:
187
+ lines += ["", f"The record is live here.{tail}"]
187
188
  return "\n".join(lines) + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.13.0
3
+ Version: 0.13.2
4
4
  Summary: Provenance and policy gate for AI-agent coding sessions: session trailers on commits, redacted session notes in git, a tool-call gate, and a local ledger. No runtime dependencies.
5
5
  Author-email: Nikhil Bora <nikhil@wirevow.com>
6
6
  License: Apache-2.0
@@ -227,7 +227,6 @@ def test_install_per_agent_idempotent_and_reversible(home, repo):
227
227
  # hooks are on by default; what install must say is that they are skipped until trusted
228
228
  assert any("/hooks" in x and "trust" in x for x in out)
229
229
  assert any("writable_roots" in x for x in out)
230
- assert any("gitvow status" in x for x in out)
231
230
  if agent == "cursor":
232
231
  assert s["version"] == 1 and s["hooks"][key][0]["failClosed"] is True
233
232
  if agent == "gemini":
@@ -19,11 +19,11 @@ def test_a_clean_install_reports_nothing_failing(repo, home, monkeypatch):
19
19
  assert not [c for c in checks if c[0] == "fail"], checks
20
20
  assert _first(checks, "policy loads")[0] == "ok"
21
21
  assert _first(checks, "redaction rules load")[0] == "ok"
22
- assert "prepare-commit-msg, pre-commit, post-commit, pre-push" in _first(checks, "git hooks in")[1]
22
+ assert "prepare-commit-msg, pre-push, pre-commit, post-commit" in _first(checks, "git hooks in")[1]
23
23
  # no session has run in this fixture repo yet, and status says so rather than implying it works
24
24
  assert _first(checks, "no hook log")[0] == "note"
25
25
  text = render(checks)
26
- assert text.startswith("gitvow ") and "nothing failing" in text
26
+ assert text.startswith("gitvow ") and "Ready: run a session and commit" in text
27
27
 
28
28
 
29
29
  def test_an_unreachable_hook_command_fails_loudly(repo, home, monkeypatch):
@@ -88,6 +88,41 @@ def test_cli_status_exit_code_and_json(repo, home, monkeypatch, capsys):
88
88
  def test_install_prints_the_codex_steps(repo, home):
89
89
  out = install_user(str(home), agent="codex")
90
90
  assert any("/hooks" in x and "trust" in x for x in out)
91
- assert any("gitvow status" in x for x in out)
92
91
  assert not [x for x in install_user(str(home)) if "/hooks" in x] # claude has no extra step
93
92
  assert Path(home, ".claude", "settings.json").exists()
93
+
94
+
95
+ def test_install_without_an_agent_configures_everything_it_finds(repo, home, monkeypatch, capsys):
96
+ """Forgetting --agent must not silently leave an agent ungated."""
97
+ from gitvow.install import detect_agents
98
+
99
+ monkeypatch.chdir(repo)
100
+ for d in (".codex", ".cursor"):
101
+ os.makedirs(home / d, exist_ok=True)
102
+ assert set(detect_agents(str(home))) >= {"codex", "cursor"}
103
+ assert cli.main(["install", str(repo)]) == 0
104
+ out = capsys.readouterr().out
105
+ assert "agents found here:" in out and "codex" in out and "cursor" in out
106
+ assert (repo / ".codex" / "hooks.json").exists() and (repo / ".cursor" / "hooks.json").exists()
107
+ assert "next: run `gitvow status`" in out
108
+ # and uninstall with no --agent clears every one of them, leaving any hooks of your own in place
109
+ from gitvow.install import hook_commands
110
+
111
+ assert cli.main(["uninstall", str(repo)]) == 0
112
+ for rel in (".codex/hooks.json", ".cursor/hooks.json", ".claude/settings.json"):
113
+ assert hook_commands(str(repo / rel)) == [], rel
114
+
115
+
116
+ def test_an_old_install_is_reported_as_stale(repo, home, monkeypatch):
117
+ monkeypatch.chdir(repo)
118
+ install_repo(str(repo))
119
+ hooks = repo / ".gitvow" / "git-hooks"
120
+ (hooks / "pre-commit").write_text("#!/bin/sh\n# an older gitvow wrote this\nexit 0\n")
121
+ c = _first(build(str(repo), str(home)), "written by an older gitvow")
122
+ assert c and c[0] == "fail" and "pre-commit" in c[1] and "re-run" in c[2]
123
+ # an agent settings file from before an event existed
124
+ settings = json.loads((repo / ".claude" / "settings.json").read_text())
125
+ settings["hooks"].pop("Stop", None)
126
+ (repo / ".claude" / "settings.json").write_text(json.dumps(settings))
127
+ c = _first(build(str(repo), str(home)), "installed by an older gitvow")
128
+ assert c and c[0] == "fail" and "Stop" in c[1]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes