gitvow 0.2.0__tar.gz → 0.2.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 (29) hide show
  1. {gitvow-0.2.0/src/gitvow.egg-info → gitvow-0.2.2}/PKG-INFO +1 -1
  2. {gitvow-0.2.0 → gitvow-0.2.2}/pyproject.toml +1 -1
  3. gitvow-0.2.2/src/gitvow/__main__.py +7 -0
  4. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/hooks/__init__.py +8 -1
  5. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/install.py +22 -3
  6. {gitvow-0.2.0 → gitvow-0.2.2/src/gitvow.egg-info}/PKG-INFO +1 -1
  7. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow.egg-info/SOURCES.txt +1 -0
  8. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_hooks.py +26 -3
  9. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_install_cli.py +26 -0
  10. {gitvow-0.2.0 → gitvow-0.2.2}/LICENSE +0 -0
  11. {gitvow-0.2.0 → gitvow-0.2.2}/README.md +0 -0
  12. {gitvow-0.2.0 → gitvow-0.2.2}/setup.cfg +0 -0
  13. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/__init__.py +0 -0
  14. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/cli.py +0 -0
  15. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/collect.py +0 -0
  16. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/default_policy.json +0 -0
  17. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/policy.py +0 -0
  18. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/redact.py +0 -0
  19. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/selftest.py +0 -0
  20. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/state.py +0 -0
  21. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow/transcript.py +0 -0
  22. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow.egg-info/dependency_links.txt +0 -0
  23. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow.egg-info/entry_points.txt +0 -0
  24. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow.egg-info/requires.txt +0 -0
  25. {gitvow-0.2.0 → gitvow-0.2.2}/src/gitvow.egg-info/top_level.txt +0 -0
  26. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_collect.py +0 -0
  27. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_packaging.py +0 -0
  28. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_policy.py +0 -0
  29. {gitvow-0.2.0 → gitvow-0.2.2}/tests/test_redact.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.2.0
3
+ Version: 0.2.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.2.0"
7
+ version = "0.2.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"}
@@ -0,0 +1,7 @@
1
+ """Allow `python -m gitvow`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
@@ -75,6 +75,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
75
75
  st["session_id"] = h.get("session_id") or st.get("session_id")
76
76
  st["transcript_path"] = h.get("transcript_path") or st.get("transcript_path")
77
77
  st["steps"] = st.get("steps", 0) + 1
78
+ st["pending_commit"] = time.time() # the git hook adds trailers only while this is fresh
78
79
  save_state(cwd, st)
79
80
  if rules is None:
80
81
  log_event(cwd, "allowed", {"tool": tool})
@@ -162,14 +163,20 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
162
163
  return 0, ""
163
164
  if tool != "Bash" or not COMMIT_RE.search(inp.get("command", "")):
164
165
  return 0, ""
166
+ st = load_state(cwd)
167
+ if "pending_commit" in st:
168
+ del st["pending_commit"]
169
+ save_state(cwd, st)
165
170
  rc, head, _ = git(["rev-parse", "HEAD"], cwd)
166
171
  if rc != 0:
167
172
  return 0, ""
168
173
  rules, warn = _rules_or_none(cwd, home)
169
174
  if rules is None:
170
175
  return 0, warn
171
- st = load_state(cwd)
172
176
  summ = summarize(h.get("transcript_path") or st.get("transcript_path"), rules=rules)
177
+ _, head_msg, _ = git(["log", "-1", "--format=%B", head], cwd)
178
+ if "Gitvow-Session:" not in head_msg:
179
+ return 0, "" # the commit did not go through (or was not the agent's): no note
173
180
  _, files, _ = git(["show", "--stat", "--format=", head], cwd)
174
181
  attribution = _attribution(cwd, head, st.get("agent_blobs", {}), summ["files_written"])
175
182
  session_id = h.get("session_id") or st.get("session_id")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import shutil
8
+ import sys
8
9
  from typing import Any
9
10
 
10
11
  from .state import git
@@ -13,7 +14,9 @@ GIT_HOOK = """#!/bin/sh
13
14
  # gitvow: append session trailers when a session is active in this repo; chain to the repo's own hook if present.
14
15
  GD="$(git rev-parse --git-dir)"; STATE="$GD/gitvow-session.json"
15
16
  if [ -f "$STATE" ]; then
16
- SID=$(python3 -c "import json;print(json.load(open('$STATE')).get('session_id') or '')" 2>/dev/null)
17
+ # Trailers go only on commits the agent itself runs: the PreToolUse gate sets pending_commit when the
18
+ # agent invokes git commit and PostToolUse clears it. A human commit in a terminal gets no trailer.
19
+ SID=$(python3 -c "import json,time;s=json.load(open('$STATE'));p=s.get('pending_commit') or 0;print(s.get('session_id') or '' if time.time()-p<300 else '')" 2>/dev/null)
17
20
  STEP=$(python3 -c "import json;print(json.load(open('$STATE')).get('steps') or 0)" 2>/dev/null)
18
21
  if [ -n "$SID" ] && ! grep -q "^Gitvow-Session:" "$1"; then printf "\\nGitvow-Session: %s\\nGitvow-Step: %s\\n" "$SID" "$STEP" >> "$1"; fi
19
22
  fi
@@ -38,6 +41,21 @@ def _unset_notes_config(cwd: str, scope: list[str]) -> None:
38
41
  git(["config", *scope, "--unset", key, "^" + NOTES_GLOB.replace("*", "\\*").replace(".", "\\.") + "$"], cwd)
39
42
 
40
43
 
44
+ def executable_command() -> str:
45
+ """The command Claude Code should run for this install of gitvow.
46
+
47
+ Hooks run in a shell that may not have a virtualenv or pipx path activated, and a hook that
48
+ cannot be found exits non-zero without blocking, which would silently switch the gate off.
49
+ A per-user install therefore records the absolute path of the console script when one exists,
50
+ falling back to `<python> -m gitvow`; per-repo installs keep the bare name because the settings
51
+ file is shared by people with different paths.
52
+ """
53
+ exe = shutil.which("gitvow", path=os.path.dirname(sys.executable)) or shutil.which("gitvow")
54
+ if exe:
55
+ return exe
56
+ return f"{sys.executable} -m gitvow"
57
+
58
+
41
59
  def _hook_entries(cmd_prefix: str) -> dict[str, list[dict[str, Any]]]:
42
60
  def entry(event: str, matcher: str | None) -> dict[str, Any]:
43
61
  e: dict[str, Any] = {"hooks": [{"type": "command", "command": f"{cmd_prefix} hook {event}"}]}
@@ -95,10 +113,11 @@ def _write_git_hook(dirpath: str) -> str:
95
113
  return p
96
114
 
97
115
 
98
- def install_user(home: str, cmd_prefix: str = "gitvow") -> list[str]:
116
+ def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
99
117
  base = os.path.join(home, ".gitvow")
100
118
  os.makedirs(base, exist_ok=True)
101
- done = []
119
+ cmd_prefix = cmd_prefix or executable_command()
120
+ done = [f"hook command → {cmd_prefix}"]
102
121
  pol = os.path.join(base, "policy.json")
103
122
  if not os.path.exists(pol):
104
123
  from .policy import DEFAULT_POLICY_PATH
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.2.0
3
+ Version: 0.2.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
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/gitvow/__init__.py
5
+ src/gitvow/__main__.py
5
6
  src/gitvow/cli.py
6
7
  src/gitvow/collect.py
7
8
  src/gitvow/default_policy.json
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import time
3
4
 
4
5
  from gitvow.hooks import post_tool_use, pre_tool_use, session_start, stop
5
6
  from gitvow.install import _write_git_hook
@@ -51,12 +52,32 @@ def test_commit_flow_trailer_note_and_attribution(repo, home, payload, transcrip
51
52
  assert att["files"][0]["agent_wrote"] is True and "agent_blob" not in att["files"][0]
52
53
 
53
54
 
54
- def test_human_commit_has_no_trailer(repo, home):
55
+ def test_human_commit_has_no_trailer(repo, home, payload):
55
56
  _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
56
57
  git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
57
58
  (repo / "a.txt").write_text("h\n")
58
- git(repo, "commit", "-qam", "human")
59
+ git(repo, "commit", "-qam", "human, no session at all")
59
60
  assert "Gitvow-Session" not in git(repo, "log", "-1", "--format=%B")
61
+ session_start(payload("SessionStart"), str(home)) # session active, but the agent did not run git commit
62
+ (repo / "a.txt").write_text("h2\n")
63
+ git(repo, "commit", "-qam", "human, alongside a live session")
64
+ assert "Gitvow-Session" not in git(repo, "log", "-1", "--format=%B")
65
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
66
+ st["pending_commit"] = 1 # a stale flag from long ago
67
+ (repo / ".git" / "gitvow-session.json").write_text(json.dumps(st))
68
+ (repo / "a.txt").write_text("h3\n")
69
+ git(repo, "commit", "-qam", "human, stale flag")
70
+ assert "Gitvow-Session" not in git(repo, "log", "-1", "--format=%B")
71
+
72
+
73
+ def test_pending_flag_is_cleared_after_note_and_no_note_without_trailer(repo, home, payload):
74
+ session_start(payload("SessionStart"), str(home))
75
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
76
+ assert "pending_commit" in json.loads((repo / ".git" / "gitvow-session.json").read_text())
77
+ # the agent's commit failed (nothing to commit); PostToolUse still runs
78
+ code, msg = post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}), str(home))
79
+ assert (code, msg) == (0, "") and git(repo, "for-each-ref", "refs/notes/") == ""
80
+ assert "pending_commit" not in json.loads((repo / ".git" / "gitvow-session.json").read_text())
60
81
 
61
82
 
62
83
  def test_git_hook_chains_to_repo_hook(repo, home):
@@ -65,7 +86,9 @@ def test_git_hook_chains_to_repo_hook(repo, home):
65
86
  local = repo / ".git" / "hooks" / "prepare-commit-msg"
66
87
  local.write_text('#!/bin/sh\necho LOCAL >> "$1"\n')
67
88
  os.chmod(local, 0o700)
68
- (repo / ".git" / "gitvow-session.json").write_text(json.dumps({"session_id": "s", "steps": 2}))
89
+ (repo / ".git" / "gitvow-session.json").write_text(
90
+ json.dumps({"session_id": "s", "steps": 2, "pending_commit": time.time()})
91
+ )
69
92
  (repo / "a.txt").write_text("c\n")
70
93
  git(repo, "commit", "-qam", "chain")
71
94
  body = git(repo, "log", "-1", "--format=%B")
@@ -139,3 +139,29 @@ def test_default_policy_confirms_edits_to_redaction_rules(monkeypatch, repo, cap
139
139
  monkeypatch.chdir(repo)
140
140
  assert cli.main(["check", "--path", ".gitvow/redact-rules.json"]) == 2
141
141
  assert cli.main(["check", "--path", ".gitvow/git-hooks/prepare-commit-msg"]) == 2
142
+
143
+
144
+ def test_user_install_records_absolute_hook_command(home):
145
+ from gitvow.install import executable_command
146
+
147
+ install_user(str(home))
148
+ s = json.loads((home / ".claude" / "settings.json").read_text())
149
+ cmd = s["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
150
+ assert cmd == f"{executable_command()} hook PreToolUse"
151
+ assert cmd.startswith("/") or " -m gitvow " in cmd # never a bare name that depends on PATH
152
+ uninstall_user(str(home))
153
+ assert not (home / ".claude" / "settings.json").exists() # marker still recognised with the absolute path
154
+
155
+
156
+ def test_repo_install_keeps_bare_name(repo, home):
157
+ install_repo(str(repo))
158
+ s = json.loads((repo / ".claude" / "settings.json").read_text())
159
+ assert s["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "gitvow hook PreToolUse"
160
+
161
+
162
+ def test_python_m_gitvow_entrypoint():
163
+ import subprocess
164
+ import sys
165
+
166
+ r = subprocess.run([sys.executable, "-m", "gitvow", "--version"], capture_output=True, text=True)
167
+ assert r.returncode == 0 and r.stdout.startswith("gitvow ")
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