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.
- {gitvow-0.29.0/src/gitvow.egg-info → gitvow-0.29.2}/PKG-INFO +1 -1
- {gitvow-0.29.0 → gitvow-0.29.2}/pyproject.toml +1 -1
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/adapters.py +42 -3
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/cli.py +4 -1
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/status.py +61 -0
- {gitvow-0.29.0 → gitvow-0.29.2/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.29.2/tests/test_native_question.py +65 -0
- gitvow-0.29.2/tests/test_status_environment.py +61 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/LICENSE +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/README.md +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/setup.cfg +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/__init__.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/__main__.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/carry.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/claims.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/collect.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/decisions.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/digest.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/export.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/install.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/notes.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/pack.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/paths.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/policy.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/pricing.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/proposals.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/providers.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/recall.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/redact.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/report.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/rules.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/safewrite.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/scan.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/selftest.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/serve.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/state.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/sync.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/targets.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow/transcript.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_adapters.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_answered_confirm_commit.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_carry.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_claims.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_collect.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_command_position.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_commit_window.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_cost.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_decisions.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_digest.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_duplicate_hooks.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_e2e_globalopt.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_export.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_external_adapter.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_git_rules.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_hardening.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_hooks.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_install_cli.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_pack.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_packaging.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_policy.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_policy_refresh.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_proposals.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_providers.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_recall.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_redact.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_report.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_rules.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_scan.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_serve.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_snapshots.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_status.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_symlinked_paths.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_sync.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_sync_http.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_targets.py +0 -0
- {gitvow-0.29.0 → gitvow-0.29.2}/tests/test_tiers.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
270
|
-
|
|
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))
|
|
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.
|
|
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
|
|
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
|