gitvow 0.29.0__tar.gz → 0.29.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 (85) hide show
  1. {gitvow-0.29.0/src/gitvow.egg-info → gitvow-0.29.2}/PKG-INFO +1 -1
  2. {gitvow-0.29.0 → gitvow-0.29.2}/pyproject.toml +1 -1
  3. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/adapters.py +42 -3
  4. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/cli.py +4 -1
  5. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/status.py +61 -0
  6. {gitvow-0.29.0 → gitvow-0.29.2/src/gitvow.egg-info}/PKG-INFO +1 -1
  7. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
  8. gitvow-0.29.2/tests/test_native_question.py +65 -0
  9. gitvow-0.29.2/tests/test_status_environment.py +61 -0
  10. {gitvow-0.29.0 → gitvow-0.29.2}/LICENSE +0 -0
  11. {gitvow-0.29.0 → gitvow-0.29.2}/README.md +0 -0
  12. {gitvow-0.29.0 → gitvow-0.29.2}/setup.cfg +0 -0
  13. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/__init__.py +0 -0
  14. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/__main__.py +0 -0
  15. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/carry.py +0 -0
  16. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/claims.py +0 -0
  17. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/collect.py +0 -0
  18. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/decisions.py +0 -0
  19. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/default_policy.json +0 -0
  20. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/digest.py +0 -0
  21. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/export.py +0 -0
  22. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/hooks/__init__.py +0 -0
  23. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/install.py +0 -0
  24. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/notes.py +0 -0
  25. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/pack.py +0 -0
  26. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/paths.py +0 -0
  27. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/policy.py +0 -0
  28. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/pricing.py +0 -0
  29. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/proposals.py +0 -0
  30. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/providers.py +0 -0
  31. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/recall.py +0 -0
  32. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/redact.py +0 -0
  33. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/report.py +0 -0
  34. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/rules.py +0 -0
  35. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/safewrite.py +0 -0
  36. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/scan.py +0 -0
  37. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/selftest.py +0 -0
  38. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/serve.py +0 -0
  39. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/snapshots.py +0 -0
  40. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/state.py +0 -0
  41. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/sync.py +0 -0
  42. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/targets.py +0 -0
  43. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/transcript.py +0 -0
  44. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
  45. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/entry_points.txt +0 -0
  46. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/requires.txt +0 -0
  47. {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/top_level.txt +0 -0
  48. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_adapters.py +0 -0
  49. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_answered_confirm_commit.py +0 -0
  50. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_carry.py +0 -0
  51. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_claims.py +0 -0
  52. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_collect.py +0 -0
  53. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_command_position.py +0 -0
  54. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_commit_window.py +0 -0
  55. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_cost.py +0 -0
  56. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_decisions.py +0 -0
  57. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_digest.py +0 -0
  58. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_duplicate_hooks.py +0 -0
  59. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_e2e_globalopt.py +0 -0
  60. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_export.py +0 -0
  61. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_external_adapter.py +0 -0
  62. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_git_rules.py +0 -0
  63. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_hardening.py +0 -0
  64. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_hooks.py +0 -0
  65. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_install_cli.py +0 -0
  66. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_pack.py +0 -0
  67. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_packaging.py +0 -0
  68. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_policy.py +0 -0
  69. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_policy_refresh.py +0 -0
  70. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_proposals.py +0 -0
  71. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_providers.py +0 -0
  72. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_recall.py +0 -0
  73. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_redact.py +0 -0
  74. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_report.py +0 -0
  75. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_rules.py +0 -0
  76. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_scan.py +0 -0
  77. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_serve.py +0 -0
  78. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_snapshots.py +0 -0
  79. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_status.py +0 -0
  80. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_symlinked_paths.py +0 -0
  81. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_sync.py +0 -0
  82. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_sync_http.py +0 -0
  83. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_targets.py +0 -0
  84. {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_tiers.py +0 -0
  85. {gitvow-0.29.0 → gitvow-0.29.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.29.0
3
+ Version: 0.29.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.29.0"
7
+ version = "0.29.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"}
@@ -10,7 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  from typing import Any
12
12
 
13
- from .decisions import CARD_HEADER
13
+ from .decisions import CARD_HEADER, OPEN_CARD_HEADER
14
14
 
15
15
  AGENTS = ("claude", "codex", "gemini", "cursor", "copilot", "factory")
16
16
 
@@ -266,8 +266,47 @@ def _refuse(msg: str) -> bool:
266
266
  return msg.startswith("BLOCKED") or msg.startswith(CARD_HEADER)
267
267
 
268
268
 
269
- def respond(agent: str, code: int, msg: str) -> tuple[int, str, str]:
270
- """(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM."""
269
+ # Claude Code permission modes in which a native question reaches a person. In every other mode (bypass, auto,
270
+ # dontAsk, or an agent too old to report one) an `ask` may be granted with nobody looking, and the record would then
271
+ # say a person approved; there the verdict stays a hard block delivered to the agent, as before 0.29.2.
272
+ PROMPTING_MODES = frozenset({"default", "acceptEdits", "plan"})
273
+ _CONFIRM_REASON = re.compile(r"CONFIRMATION REQUIRED \((.*?)\)\.")
274
+
275
+
276
+ def native_question(msg: str) -> str:
277
+ """The short reason shown in the agent's own permission dialog, in place of the terminal instructions."""
278
+ m = _CONFIRM_REASON.search(msg)
279
+ if m:
280
+ return (
281
+ f"gitvow: {m.group(1)}. Allow records your approval for this session, on the next commit, under your "
282
+ "git identity. Deny records nothing and stops the command."
283
+ )
284
+ if msg.startswith(OPEN_CARD_HEADER):
285
+ first = msg.splitlines()[0]
286
+ return (
287
+ f"gitvow: {first}. Allow commits anyway and records each finding as Gitvow-Open; Deny stops the commit so "
288
+ "they can be answered first with `gitvow decide`."
289
+ )
290
+ return "gitvow: " + msg.splitlines()[0][:200]
291
+
292
+
293
+ def respond(agent: str, code: int, msg: str, mode: str | None = None) -> tuple[int, str, str]:
294
+ """(exit code, stdout, stderr) in the agent's form. gitvow's code 2 means blocked; the message says DENY or CONFIRM.
295
+
296
+ `mode` is Claude Code's permission mode from the hook payload. When it is one that shows prompts, a confirm
297
+ becomes Claude Code's own question to the person (`permissionDecision: ask`), and their click is recorded by
298
+ PostToolUse as a session-scoped decision, exactly as for Cursor and Copilot. Denials and the strict card are
299
+ never a question.
300
+ """
301
+ if agent == "claude" and code == 2 and mode in PROMPTING_MODES and not _refuse(msg):
302
+ out = {
303
+ "hookSpecificOutput": {
304
+ "hookEventName": "PreToolUse",
305
+ "permissionDecision": "ask",
306
+ "permissionDecisionReason": native_question(msg),
307
+ }
308
+ }
309
+ return 0, json.dumps(out), ""
271
310
  if agent == "cursor":
272
311
  if code == 2:
273
312
  perm = "deny" if _refuse(msg) else "ask"
@@ -33,6 +33,7 @@ def cmd_hook(a: argparse.Namespace) -> int:
33
33
  from .adapters import AdapterError, external_normalize, external_path, external_respond
34
34
 
35
35
  agent = a.agent or "claude"
36
+ mode = payload.get("permission_mode") if isinstance(payload, dict) else None
36
37
  exe = external_path(agent)
37
38
  try:
38
39
  calls = external_normalize(exe, a.event, payload) if exe else normalize(agent, a.event, payload)
@@ -72,7 +73,9 @@ def cmd_hook(a: argparse.Namespace) -> int:
72
73
  return 0
73
74
  try:
74
75
  exit_code, out, err = (
75
- external_respond(exe, worst, "\n".join(messages)) if exe else respond(agent, worst, "\n".join(messages))
76
+ external_respond(exe, worst, "\n".join(messages))
77
+ if exe
78
+ else respond(agent, worst, "\n".join(messages), mode=mode)
76
79
  )
77
80
  except AdapterError as e:
78
81
  print(f"BLOCKED: agent adapter failed ({e}).", file=sys.stderr)
@@ -41,6 +41,7 @@ def _agent_checks(home: str, cwd: str) -> list[Check]:
41
41
  checks: list[Check] = []
42
42
  top = toplevel(cwd)
43
43
  found_any = False
44
+ scopes: dict[str, set[str]] = {}
44
45
  for agent, (user_rel, repo_rel) in AGENT_FILES.items():
45
46
  for scope, path in (("user", os.path.join(home, user_rel)), ("repo", os.path.join(top or cwd, repo_rel))):
46
47
  if scope == "repo" and not top:
@@ -49,6 +50,7 @@ def _agent_checks(home: str, cwd: str) -> list[Check]:
49
50
  if not cmds:
50
51
  continue
51
52
  found_any = True
53
+ scopes.setdefault(agent, set()).add(scope)
52
54
  where = path.replace(home, "~", 1)
53
55
  runnable, resolved, from_path = _executable(cmds[0])
54
56
  if not runnable:
@@ -81,6 +83,15 @@ def _agent_checks(home: str, cwd: str) -> list[Check]:
81
83
  )
82
84
  )
83
85
  checks += _agent_notes(agent, home, top)
86
+ for agent, seen in scopes.items():
87
+ if seen == {"user", "repo"}:
88
+ checks.append(
89
+ (
90
+ "note",
91
+ f"{agent}: hooks installed at user scope and in this repository; the agent merges both, so every event arrives twice",
92
+ "handled once since 0.28.5 (deduplicated by tool_use_id); an older gitvow recorded everything twice",
93
+ )
94
+ )
84
95
  if not found_any:
85
96
  checks.append(
86
97
  ("fail", "no agent has gitvow hooks installed", "run `gitvow install --user` (add --agent for others)")
@@ -177,6 +188,7 @@ def _git_checks(cwd: str, home: str) -> list[Check]:
177
188
  else:
178
189
  checks.append(("ok", f"notes.displayRef / rewriteRef → {NOTES_GLOB}", ""))
179
190
  checks += _since_install_checks(top, hooks_dir)
191
+ checks += _hook_environment_checks(top, home)
180
192
  gd = git_dir(top)
181
193
  if gd and os.path.exists(os.path.join(gd, "gitvow-hooks.log")):
182
194
  checks.append(("ok", "this repository has a hook log, so the hooks have run here", ""))
@@ -185,6 +197,55 @@ def _git_checks(cwd: str, home: str) -> list[Check]:
185
197
  return checks
186
198
 
187
199
 
200
+ MINIMAL_PATH = "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"
201
+
202
+
203
+ def _hook_environment_checks(top: str, home: str) -> list[Check]:
204
+ """Can the git hooks actually do their work here?
205
+
206
+ Every trailer is written by `python3` from inside a shell hook, with errors discarded so a broken hook never
207
+ blocks a commit. That is the right failure mode for the commit and the wrong one for the record: without
208
+ `python3` on the PATH git runs hooks with, commits silently carry nothing. An agent started from Finder or a
209
+ launch agent has a shorter PATH than a terminal, so both are checked. The pending-commit window is read too,
210
+ because a window under a minute makes the trailer a race against the harness.
211
+ """
212
+ out: list[Check] = []
213
+ here = shutil.which("python3")
214
+ minimal = shutil.which("python3", path=MINIMAL_PATH)
215
+ if not here:
216
+ out.append(
217
+ (
218
+ "fail",
219
+ "python3 is not on PATH: the git hooks write trailers and notes with it and fail silently without it",
220
+ "install python3, or put it on the PATH of the shell git runs in",
221
+ )
222
+ )
223
+ elif not minimal:
224
+ out.append(
225
+ (
226
+ "note",
227
+ f"python3 is on this shell's PATH ({here}) but not on a minimal one ({MINIMAL_PATH})",
228
+ "an agent started from Finder or by launchd may run git hooks without it; a symlink in /usr/local/bin fixes that",
229
+ )
230
+ )
231
+ else:
232
+ out.append(("ok", f"python3 reachable from git hooks: {minimal}", ""))
233
+ try:
234
+ pol = load_policy(top, home)
235
+ window = int((pol.get("decisions") or {}).get("commit_window_seconds", 1800))
236
+ if window < 60:
237
+ out.append(
238
+ (
239
+ "note",
240
+ f"decisions.commit_window_seconds is {window}: a commit queued behind a slow command loses its trailer",
241
+ "raise it; the default is 1800",
242
+ )
243
+ )
244
+ except (PolicyError, ValueError, TypeError):
245
+ pass
246
+ return out
247
+
248
+
188
249
  def build(cwd: str, home: str) -> list[Check]:
189
250
  checks: list[Check] = []
190
251
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.29.0
3
+ Version: 0.29.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
@@ -59,6 +59,7 @@ tests/test_git_rules.py
59
59
  tests/test_hardening.py
60
60
  tests/test_hooks.py
61
61
  tests/test_install_cli.py
62
+ tests/test_native_question.py
62
63
  tests/test_pack.py
63
64
  tests/test_packaging.py
64
65
  tests/test_policy.py
@@ -73,6 +74,7 @@ tests/test_scan.py
73
74
  tests/test_serve.py
74
75
  tests/test_snapshots.py
75
76
  tests/test_status.py
77
+ tests/test_status_environment.py
76
78
  tests/test_symlinked_paths.py
77
79
  tests/test_sync.py
78
80
  tests/test_sync_http.py
@@ -0,0 +1,65 @@
1
+ """0.29.2: in Claude Code, a confirm is the agent's own question to the person when the permission mode shows one."""
2
+
3
+ import io
4
+ import json
5
+ import sys
6
+
7
+ from gitvow import cli
8
+ from gitvow.adapters import native_question, respond
9
+ from gitvow.decisions import CARD_HEADER, OPEN_CARD_HEADER
10
+ from gitvow.hooks import post_tool_use, session_start
11
+ from tests.conftest import git
12
+
13
+ CONFIRM = (
14
+ "CONFIRMATION REQUIRED (pushing to a protected branch). Ask the user before doing this. If they agree, "
15
+ "record it and run the command again:\n gitvow decide 1 accept --scope session"
16
+ )
17
+
18
+
19
+ def test_prompting_modes_ask_and_other_modes_block():
20
+ for mode in ("default", "acceptEdits", "plan"):
21
+ code, out, err = respond("claude", 2, CONFIRM, mode=mode)
22
+ assert code == 0 and err == ""
23
+ h = json.loads(out)["hookSpecificOutput"]
24
+ assert h["permissionDecision"] == "ask" and h["hookEventName"] == "PreToolUse"
25
+ assert h["permissionDecisionReason"].startswith("gitvow: pushing to a protected branch.")
26
+ for mode in ("bypassPermissions", "dontAsk", "auto", None, "weird"):
27
+ assert respond("claude", 2, CONFIRM, mode=mode) == (2, "", CONFIRM)
28
+
29
+
30
+ def test_denials_and_the_strict_card_are_never_a_question():
31
+ assert respond("claude", 2, "BLOCKED by policy (force push).", mode="default")[0] == 2
32
+ card = CARD_HEADER + "\n1. edit values/production-in/app.yaml"
33
+ assert respond("claude", 2, card, mode="default")[0] == 2
34
+ open_card = OPEN_CARD_HEADER + ": 1 finding nobody has answered\n1. edit values/production-in/app.yaml"
35
+ code, out, _ = respond("claude", 2, open_card, mode="default")
36
+ assert code == 0 and "Gitvow-Open" in json.loads(out)["hookSpecificOutput"]["permissionDecisionReason"]
37
+
38
+
39
+ def test_native_question_wording():
40
+ q = native_question(CONFIRM)
41
+ assert "gitvow decide" not in q and "Allow records your approval" in q
42
+
43
+
44
+ def test_hook_cli_uses_the_payloads_permission_mode(repo, home, payload, monkeypatch, capsys):
45
+ monkeypatch.setenv("HOME", str(home))
46
+ git(repo, "remote", "add", "origin", "git@example.com:acme/ledger.git")
47
+ session_start(payload("SessionStart"), str(home))
48
+ p = {**payload("PreToolUse", "Bash", {"command": "git push origin main"}), "permission_mode": "default"}
49
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(p)))
50
+ rc = cli.main(["hook", "PreToolUse"])
51
+ out = capsys.readouterr().out
52
+ assert rc == 0 and json.loads(out)["hookSpecificOutput"]["permissionDecision"] == "ask"
53
+ # the person clicked Allow: the command ran, PostToolUse records the approval for the session
54
+ q = payload("PostToolUse", "Bash", {"command": "git push origin main"})
55
+ post_tool_use(q, str(home))
56
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
57
+ dec = [f["decision"] for f in st["findings"] if f.get("decision")]
58
+ assert dec and dec[0]["answer"] == "accepted" and dec[0]["scope"] == "session"
59
+ assert "approved at the agent's prompt" in (dec[0].get("note") or dec[0].get("reason") or "")
60
+ # the same call under bypass is a block, not a question
61
+ p["permission_mode"] = "bypassPermissions"
62
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(p)))
63
+ rc = cli.main(["hook", "PreToolUse"])
64
+ assert rc in (0, 2) # 0 when the session answer now allows it; the point is that no `ask` JSON is emitted
65
+ assert "permissionDecision" not in capsys.readouterr().out
@@ -0,0 +1,61 @@
1
+ """0.29.1: status checks the environment the git hooks run in."""
2
+
3
+ import json
4
+ import shutil
5
+
6
+ from gitvow.install import install_repo, install_user
7
+ from gitvow.status import build
8
+
9
+
10
+ def _first(checks, needle):
11
+ return next((c for c in checks if needle in c[1]), None)
12
+
13
+
14
+ def test_python3_missing_from_path_is_a_failure(repo, home, monkeypatch):
15
+ install_repo(str(repo))
16
+ monkeypatch.setattr(shutil, "which", lambda name, path=None: None if name == "python3" else "/usr/bin/" + name)
17
+ checks = build(str(repo), str(home))
18
+ c = _first(checks, "python3 is not on PATH")
19
+ assert c and c[0] == "fail" and "fail silently" in c[1]
20
+
21
+
22
+ def test_python3_only_on_a_rich_path_is_a_note(repo, home, monkeypatch):
23
+ install_repo(str(repo))
24
+ real_which = shutil.which
25
+
26
+ def which(name, path=None):
27
+ if name == "python3":
28
+ return None if path else "/Users/x/.pyenv/shims/python3"
29
+ return real_which(name, path=path)
30
+
31
+ monkeypatch.setattr(shutil, "which", which)
32
+ checks = build(str(repo), str(home))
33
+ c = _first(checks, "not on a minimal one")
34
+ assert c and c[0] == "note" and "Finder" in c[2]
35
+
36
+
37
+ def test_python3_on_a_minimal_path_is_ok(repo, home):
38
+ install_repo(str(repo))
39
+ checks = build(str(repo), str(home))
40
+ c = _first(checks, "python3 reachable from git hooks")
41
+ assert c and c[0] == "ok"
42
+
43
+
44
+ def test_short_commit_window_is_noted(repo, home):
45
+ install_repo(str(repo))
46
+ p = repo / ".gitvow" / "policy.json"
47
+ d = json.loads(p.read_text())
48
+ d["decisions"]["commit_window_seconds"] = 5
49
+ p.write_text(json.dumps(d))
50
+ checks = build(str(repo), str(home))
51
+ c = _first(checks, "commit_window_seconds is 5")
52
+ assert c and c[0] == "note"
53
+
54
+
55
+ def test_both_scopes_installed_is_noted(repo, home, monkeypatch):
56
+ monkeypatch.chdir(repo)
57
+ install_user(str(home))
58
+ install_repo(str(repo))
59
+ checks = build(str(repo), str(home))
60
+ c = _first(checks, "user scope and in this repository")
61
+ assert c and c[0] == "note" and "0.28.5" in c[2]
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
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