gitvow 0.3.0__tar.gz → 0.5.0__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 (35) hide show
  1. {gitvow-0.3.0/src/gitvow.egg-info → gitvow-0.5.0}/PKG-INFO +1 -1
  2. {gitvow-0.3.0 → gitvow-0.5.0}/pyproject.toml +1 -1
  3. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/cli.py +102 -1
  4. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/hooks/__init__.py +18 -4
  5. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/install.py +5 -1
  6. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/policy.py +21 -3
  7. gitvow-0.5.0/src/gitvow/providers.py +126 -0
  8. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/selftest.py +29 -0
  9. gitvow-0.5.0/src/gitvow/snapshots.py +183 -0
  10. {gitvow-0.3.0 → gitvow-0.5.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  11. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/SOURCES.txt +5 -1
  12. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_hooks.py +1 -1
  13. gitvow-0.5.0/tests/test_providers.py +160 -0
  14. gitvow-0.5.0/tests/test_snapshots.py +136 -0
  15. {gitvow-0.3.0 → gitvow-0.5.0}/LICENSE +0 -0
  16. {gitvow-0.3.0 → gitvow-0.5.0}/README.md +0 -0
  17. {gitvow-0.3.0 → gitvow-0.5.0}/setup.cfg +0 -0
  18. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/__init__.py +0 -0
  19. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/__main__.py +0 -0
  20. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/collect.py +0 -0
  21. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/default_policy.json +0 -0
  22. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/redact.py +0 -0
  23. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/report.py +0 -0
  24. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/state.py +0 -0
  25. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow/transcript.py +0 -0
  26. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  27. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  28. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/requires.txt +0 -0
  29. {gitvow-0.3.0 → gitvow-0.5.0}/src/gitvow.egg-info/top_level.txt +0 -0
  30. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_collect.py +0 -0
  31. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_install_cli.py +0 -0
  32. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_packaging.py +0 -0
  33. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_policy.py +0 -0
  34. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_redact.py +0 -0
  35. {gitvow-0.3.0 → gitvow-0.5.0}/tests/test_report.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.3.0
3
+ Version: 0.5.0
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.3.0"
7
+ version = "0.5.0"
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"}
@@ -9,10 +9,12 @@ import re
9
9
  import sys
10
10
 
11
11
  from . import __version__
12
+ from . import snapshots as snap
12
13
  from .collect import collect, summarize_text
13
14
  from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
14
15
  from .install import install_repo, install_user, uninstall_repo, uninstall_user
15
16
  from .policy import PolicyError, evaluate, load_policy
17
+ from .providers import QUESTIONS, ask
16
18
  from .redact import RedactionError, load_rules, redact
17
19
  from .report import build, render_markdown
18
20
  from .state import git
@@ -43,7 +45,7 @@ def cmd_uninstall(a: argparse.Namespace) -> int:
43
45
  done = (
44
46
  uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
45
47
  if a.user
46
- else uninstall_repo(os.path.abspath(a.repo), a.purge_notes)
48
+ else uninstall_repo(os.path.abspath(a.repo), a.purge_notes, a.purge_snapshots)
47
49
  )
48
50
  print("\n".join(done))
49
51
  return 0
@@ -66,6 +68,26 @@ def cmd_check(a: argparse.Namespace) -> int:
66
68
  return 2 if d.blocks else 0
67
69
 
68
70
 
71
+ def cmd_ask(a: argparse.Namespace) -> int:
72
+ """Ask every configured provider one question and show the gate's decision."""
73
+ try:
74
+ pol = load_policy(os.getcwd())
75
+ except PolicyError as e:
76
+ print(f"policy error: {e}", file=sys.stderr)
77
+ return 2
78
+ if not pol.get("providers"):
79
+ print("no providers configured in the active policy", file=sys.stderr)
80
+ return 1
81
+ path = a.path or (a.subject if a.question == "gate_bearing" else "")
82
+ answers = ask(pol, "Edit", {"file_path": path}, os.getcwd(), only=[(a.question, a.subject)])
83
+ for ans in answers:
84
+ ev = " — " + "; ".join(ans.evidence) if ans.evidence else ""
85
+ print(f"{ans.provider}: {ans.answer}{ev}")
86
+ blocked = any(x.blocks for x in answers)
87
+ print("decision: " + ("CONFIRM" if blocked else "ALLOW"))
88
+ return 2 if blocked else 0
89
+
90
+
69
91
  def cmd_show(a: argparse.Namespace) -> int:
70
92
  """Print a commit's trailers and session note."""
71
93
  rc, msg, _ = git(["log", "-1", "--format=%H%n%s%n%b", a.commit], os.getcwd())
@@ -109,6 +131,61 @@ def cmd_report(a: argparse.Namespace) -> int:
109
131
  return 0
110
132
 
111
133
 
134
+ def cmd_snapshots(a: argparse.Namespace) -> int:
135
+ cwd = os.getcwd()
136
+ if a.sub == "prune":
137
+ days = None
138
+ if a.older_than:
139
+ m = re.fullmatch(r"(\d+)([dh]?)", a.older_than)
140
+ if not m:
141
+ print("--older-than takes a number of days, e.g. 14d", file=sys.stderr)
142
+ return 1
143
+ days = int(m.group(1)) / (24 if m.group(2) == "h" else 1)
144
+ print(f"pruned {snap.prune(cwd, days, a.session)} snapshot(s)")
145
+ return 0
146
+ rows = snap.list_snapshots(cwd, a.session)
147
+ if not rows:
148
+ print("no snapshots" + (f" for session {a.session}" if a.session else ""))
149
+ return 0
150
+ cur = None
151
+ for r in rows:
152
+ if r["session"] != cur:
153
+ cur = r["session"]
154
+ print(f"session {cur[:8]} {sum(1 for x in rows if x['session'] == cur)} snapshots")
155
+ print(" n taken tool file changed vs HEAD")
156
+ print(
157
+ f" {r['n']:<4d} {r['taken']:<20s} {r['tool']:<11s} {r['file'][:37]:<37s} {r['changed_files']} file{'s' if r['changed_files'] != 1 else ''}"
158
+ )
159
+ return 0
160
+
161
+
162
+ def _resolve_or_fail(a: argparse.Namespace) -> str | None:
163
+ ref = snap.resolve(os.getcwd(), a.session, a.n)
164
+ if not ref:
165
+ print(f"no snapshot {a.n} for a unique session matching {a.session!r}", file=sys.stderr)
166
+ return ref
167
+
168
+
169
+ def cmd_diff(a: argparse.Namespace) -> int:
170
+ ref = _resolve_or_fail(a)
171
+ if not ref:
172
+ return 1
173
+ print(snap.diff(os.getcwd(), ref, a.full))
174
+ return 0
175
+
176
+
177
+ def cmd_restore(a: argparse.Namespace) -> int:
178
+ ref = _resolve_or_fail(a)
179
+ if not ref:
180
+ return 1
181
+ rc, msg = snap.restore(os.getcwd(), ref, a.to)
182
+ print(
183
+ f"restored {ref} into {msg} (detached worktree; remove with: git worktree remove {msg})" if rc == 0 else msg,
184
+ file=sys.stderr if rc else sys.stdout,
185
+ )
186
+ return rc
187
+
188
+
112
189
  def cmd_push_notes(a: argparse.Namespace) -> int:
113
190
  import subprocess
114
191
 
@@ -155,6 +232,7 @@ def main(argv: list[str] | None = None) -> int:
155
232
  s.add_argument("--purge-notes", action="store_true")
156
233
  s.add_argument("--purge-policy", action="store_true")
157
234
  s.add_argument("--purge-ledger", action="store_true")
235
+ s.add_argument("--purge-snapshots", action="store_true")
158
236
  s.set_defaults(f=cmd_uninstall)
159
237
  s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
160
238
  s.add_argument("command", nargs="*")
@@ -164,6 +242,11 @@ def main(argv: list[str] | None = None) -> int:
164
242
  s = sub.add_parser("show", help="print a commit's trailers and session note")
165
243
  s.add_argument("commit", nargs="?", default="HEAD")
166
244
  s.set_defaults(f=cmd_show)
245
+ s = sub.add_parser("ask", help="ask configured providers a question: gitvow ask route_gate /v1/x --path src/api.py")
246
+ s.add_argument("question", choices=QUESTIONS)
247
+ s.add_argument("subject")
248
+ s.add_argument("--path", default="")
249
+ s.set_defaults(f=cmd_ask)
167
250
  s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
168
251
  s.add_argument("text", nargs="+")
169
252
  s.set_defaults(f=cmd_redact)
@@ -173,6 +256,24 @@ def main(argv: list[str] | None = None) -> int:
173
256
  s.add_argument("--json", action="store_true")
174
257
  s.add_argument("--require-notes", action="store_true")
175
258
  s.set_defaults(f=cmd_report)
259
+ s = sub.add_parser(
260
+ "snapshots", help="list working-tree snapshots taken after agent edits; 'snapshots prune' deletes"
261
+ )
262
+ s.add_argument("sub", nargs="?", choices=["prune"])
263
+ s.add_argument("--session")
264
+ s.add_argument("--all", action="store_true")
265
+ s.add_argument("--older-than")
266
+ s.set_defaults(f=cmd_snapshots)
267
+ s = sub.add_parser("diff", help="what the agent had changed at snapshot n: gitvow diff <session> <n>")
268
+ s.add_argument("session")
269
+ s.add_argument("n", type=int)
270
+ s.add_argument("--full", action="store_true")
271
+ s.set_defaults(f=cmd_diff)
272
+ s = sub.add_parser("restore", help="check a snapshot out into a detached scratch worktree")
273
+ s.add_argument("session")
274
+ s.add_argument("n", type=int)
275
+ s.add_argument("--to")
276
+ s.set_defaults(f=cmd_restore)
176
277
  s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
177
278
  s.add_argument("remote", nargs="?", default="origin")
178
279
  s.set_defaults(f=cmd_push_notes)
@@ -8,6 +8,7 @@ import re
8
8
  import time
9
9
  from typing import Any
10
10
 
11
+ from .. import snapshots
11
12
  from ..policy import PolicyError, evaluate, load_policy, message_for
12
13
  from ..redact import RedactionError, load_rules, redact
13
14
  from ..state import git, load_state, log_event, save_state, toplevel
@@ -15,7 +16,7 @@ from ..transcript import summarize
15
16
 
16
17
  NOTES_REF_PREFIX = "gitvow" # refs/notes/gitvow/<session-id>; gitvow 0.1 wrote the single ref refs/notes/sessions
17
18
  LEGACY_NOTES_REF = "sessions"
18
- NOTE_SCHEMA = 2
19
+ NOTE_SCHEMA = 3
19
20
  COMMIT_RE = re.compile(r"\bgit\s+commit\b")
20
21
  EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
21
22
  REDACTION_UNAVAILABLE = "redaction rules invalid; nothing written for this event"
@@ -37,6 +38,8 @@ def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
37
38
  "started": st.get("started") if same_session and st.get("started") else time.strftime("%Y-%m-%dT%H:%M:%S"),
38
39
  "steps": st.get("steps", 0) if same_session else 0,
39
40
  "agent_blobs": st.get("agent_blobs", {}) if same_session else {},
41
+ "snapshots": st.get("snapshots", 0) if same_session else 0,
42
+ "last_snapshot": st.get("last_snapshot") if same_session else None,
40
43
  }
41
44
  )
42
45
  st.pop("pending_commit", None)
@@ -64,7 +67,7 @@ def pre_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
64
67
  2,
65
68
  f"BLOCKED: tool policy could not be loaded ({e}); refusing all tool calls until the policy is restored.",
66
69
  )
67
- d = evaluate(pol, tool, inp)
70
+ d = evaluate(pol, tool, inp, cwd)
68
71
  rules, warn = _rules_or_none(cwd, home)
69
72
  if d.blocks:
70
73
  payload: dict[str, Any] = {"tool": tool, "reason": d.reason, "session_id": h.get("session_id")}
@@ -160,8 +163,18 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
160
163
  tool = h.get("tool_name")
161
164
  inp = h.get("tool_input") or {}
162
165
  if tool in EDIT_TOOLS:
163
- if inp.get("file_path"):
164
- _record_agent_blob(cwd, str(inp["file_path"]))
166
+ fp = str(inp.get("file_path") or inp.get("notebook_path") or "")
167
+ if fp:
168
+ _record_agent_blob(cwd, fp)
169
+ try:
170
+ pol = load_policy(cwd, home)
171
+ except PolicyError:
172
+ pol = {}
173
+ cfg = snapshots.settings(pol)
174
+ if cfg.get("enabled", True):
175
+ ref = snapshots.take(cwd, h.get("session_id") or load_state(cwd).get("session_id"), tool, fp, cfg)
176
+ if ref:
177
+ log_event(cwd, "snapshot", {"ref": ref, "tool": tool})
165
178
  return 0, ""
166
179
  if tool != "Bash" or not COMMIT_RE.search(inp.get("command", "")):
167
180
  return 0, ""
@@ -194,6 +207,7 @@ def post_tool_use(h: dict[str, Any], home: str | None = None) -> tuple[int, str]
194
207
  "files_in_commit": [ln.strip() for ln in files.splitlines()[:-1]][:50] if files else [],
195
208
  "files_written_by_agent_this_session": [f["path"] for f in attribution["files"] if f["agent_wrote"]][:50],
196
209
  "attribution": attribution,
210
+ "snapshot": st.get("last_snapshot"),
197
211
  "transcript": "kept local; see ledger",
198
212
  "redaction": "secrets/PII patterns, high-entropy tokens and custom rules replaced at write time",
199
213
  }
@@ -189,7 +189,7 @@ def install_repo(repo: str, cmd_prefix: str = "gitvow") -> list[str]:
189
189
  ]
190
190
 
191
191
 
192
- def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
192
+ def uninstall_repo(repo: str, purge_notes: bool = False, purge_snapshots: bool = False) -> list[str]:
193
193
  done = []
194
194
  unmerge_settings(os.path.join(repo, ".claude", "settings.json"))
195
195
  rc, cur, _ = git(["config", "--get", "core.hooksPath"], repo)
@@ -210,5 +210,9 @@ def uninstall_repo(repo: str, purge_notes: bool = False) -> list[str]:
210
210
  for ref in refs.split():
211
211
  git(["update-ref", "-d", ref], repo)
212
212
  done.append(f"{len(refs.split())} local session note ref(s) deleted (remote copies untouched)")
213
+ if purge_snapshots:
214
+ from . import snapshots as _snap
215
+
216
+ done.append(f"{_snap.purge_all(repo)} snapshot ref(s) deleted")
213
217
  done.append(".gitvow removed; commit trailers already in history remain")
214
218
  return done
@@ -10,6 +10,8 @@ import subprocess
10
10
  from dataclasses import dataclass
11
11
  from typing import Any
12
12
 
13
+ from . import providers as _providers
14
+
13
15
  DEFAULT_POLICY_PATH = os.path.join(os.path.dirname(__file__), "default_policy.json")
14
16
 
15
17
 
@@ -63,9 +65,21 @@ def _validate(pol: dict[str, Any], path: str) -> None:
63
65
  re.compile(pat)
64
66
  except re.error as e:
65
67
  raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
66
-
67
-
68
- def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Decision:
68
+ snap = pol.get("snapshots", {})
69
+ if not isinstance(snap, dict) or not isinstance(snap.get("exclude", []), list):
70
+ raise PolicyError(f"{path}: snapshots must be an object with an optional 'exclude' list")
71
+ provs = pol.get("providers", [])
72
+ if not isinstance(provs, list):
73
+ raise PolicyError(f"{path}: providers must be a list")
74
+ for prov in provs:
75
+ if not isinstance(prov, dict) or not prov.get("name") or not prov.get("command"):
76
+ raise PolicyError(f"{path}: each provider needs 'name' and 'command'")
77
+ bad = [q for q in prov.get("questions", []) if q not in _providers.QUESTIONS]
78
+ if bad:
79
+ raise PolicyError(f"{path}: provider {prov['name']} lists unknown questions {bad}")
80
+
81
+
82
+ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any], cwd: str | None = None) -> Decision:
69
83
  text = tool_input.get("command", "") if tool == "Bash" else ""
70
84
  path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
71
85
  if tool == "Bash":
@@ -85,6 +99,10 @@ def evaluate(pol: dict[str, Any], tool: str, tool_input: dict[str, Any]) -> Deci
85
99
  allow = pol.get("mcp_allow", [])
86
100
  if allow and not any(re.fullmatch(p, tool) for p in allow):
87
101
  return Decision("confirm", "MCP tool not on allow list", tool)
102
+ if pol.get("providers") and tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
103
+ hits = [a for a in _providers.ask(pol, tool, tool_input, cwd) if a.blocks]
104
+ if hits:
105
+ return Decision("confirm", "; ".join(h.text() for h in hits[:3]), path)
88
106
  clf = pol.get("llm_classifier") or {}
89
107
  if clf.get("enabled") and clf.get("command"):
90
108
  return _classify(clf["command"], tool, tool_input, text or path or tool)
@@ -0,0 +1,126 @@
1
+ """Providers: external programs the gate asks factual questions (gate_bearing, route_gate, route_callers)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import subprocess
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ PROTOCOL = 1
14
+ QUESTIONS = ("gate_bearing", "route_gate", "route_callers")
15
+ EDIT_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
16
+ ROUTE_RE = re.compile(r"""["'`](/[A-Za-z0-9_\-./{}:*$]*)["'`]""")
17
+
18
+
19
+ @dataclass
20
+ class Answer:
21
+ provider: str
22
+ question: str
23
+ subject: str
24
+ answer: str # yes | no | unknown | failed
25
+ evidence: list[str] = field(default_factory=list)
26
+
27
+ @property
28
+ def blocks(self) -> bool:
29
+ return self.answer in ("yes", "failed")
30
+
31
+ def text(self) -> str:
32
+ why = "; ".join(self.evidence[:4]) if self.evidence else ""
33
+ if self.answer == "failed":
34
+ return f"provider {self.provider} unavailable for {self.question} {self.subject}"
35
+ return f"provider {self.provider}: {self.question} {self.subject}" + (f": {why}" if why else "")
36
+
37
+
38
+ def route_literals(text: str) -> list[str]:
39
+ """Quoted strings that look like routes: begin with '/', no spaces, at least one more character."""
40
+ out: list[str] = []
41
+ for m in ROUTE_RE.finditer(text or ""):
42
+ r = m.group(1)
43
+ if len(r) > 1 and not r.startswith("//") and "." not in os.path.basename(r) and r not in out:
44
+ out.append(r)
45
+ return out
46
+
47
+
48
+ def questions_for(tool: str, tool_input: dict[str, Any]) -> list[tuple[str, str]]:
49
+ """(question, subject) pairs the gate should ask for this tool call."""
50
+ if tool not in EDIT_TOOLS:
51
+ return []
52
+ path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
53
+ qs: list[tuple[str, str]] = []
54
+ if path:
55
+ qs.append(("gate_bearing", path))
56
+ old, new = "", ""
57
+ if tool == "Edit":
58
+ old, new = str(tool_input.get("old_string") or ""), str(tool_input.get("new_string") or "")
59
+ elif tool == "MultiEdit":
60
+ for e in tool_input.get("edits") or []:
61
+ old += "\n" + str(e.get("old_string") or "")
62
+ new += "\n" + str(e.get("new_string") or "")
63
+ elif tool == "Write":
64
+ new = str(tool_input.get("content") or "")
65
+ before, after = set(route_literals(old)), set(route_literals(new))
66
+ qs += [("route_gate", r) for r in sorted(after - before)]
67
+ qs += [("route_callers", r) for r in sorted(before - after)]
68
+ return qs
69
+
70
+
71
+ def _run(prov: dict[str, Any], question: str, subject: str, repo: str, path: str, tool: str) -> Answer:
72
+ name = str(prov.get("name") or "provider")
73
+ req = {
74
+ "protocol": PROTOCOL,
75
+ "question": question,
76
+ "subject": subject,
77
+ "repo": repo,
78
+ "path": path,
79
+ "tool_name": tool,
80
+ }
81
+ try:
82
+ argv = shlex.split(str(prov.get("command") or ""))
83
+ if not argv:
84
+ return Answer(name, question, subject, "failed", ["empty command"])
85
+ r = subprocess.run(
86
+ argv,
87
+ input=json.dumps(req),
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=float(prov.get("timeout") or 10),
91
+ check=False,
92
+ cwd=repo or None,
93
+ )
94
+ if r.returncode != 0:
95
+ return Answer(name, question, subject, "failed", [f"exit {r.returncode}"])
96
+ data = json.loads(r.stdout)
97
+ ans = str(data.get("answer", "unknown")).lower()
98
+ if ans not in ("yes", "no", "unknown"):
99
+ return Answer(name, question, subject, "failed", ["bad answer"])
100
+ ev = [str(e)[:200] for e in (data.get("evidence") or [])[:8]]
101
+ return Answer(name, question, subject, ans, ev)
102
+ except (OSError, subprocess.TimeoutExpired, ValueError, AttributeError) as e:
103
+ return Answer(name, question, subject, "failed", [type(e).__name__])
104
+
105
+
106
+ def ask(
107
+ pol: dict[str, Any],
108
+ tool: str,
109
+ tool_input: dict[str, Any],
110
+ cwd: str | None = None,
111
+ only: list[tuple[str, str]] | None = None,
112
+ ) -> list[Answer]:
113
+ provs = pol.get("providers") or []
114
+ if not provs:
115
+ return []
116
+ repo = cwd or os.getcwd()
117
+ path = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "")
118
+ if path and os.path.isabs(path):
119
+ rel = os.path.relpath(path, repo)
120
+ path = rel if not rel.startswith("..") else path
121
+ answers: list[Answer] = []
122
+ for q, subj in only if only is not None else questions_for(tool, {**tool_input, "file_path": path}):
123
+ for prov in provs:
124
+ if q in (prov.get("questions") or QUESTIONS):
125
+ answers.append(_run(prov, q, subj, repo, path, tool))
126
+ return answers
@@ -6,6 +6,7 @@ import json
6
6
  import os
7
7
  import shutil
8
8
  import subprocess
9
+ import sys
9
10
  import tempfile
10
11
 
11
12
  from .hooks import post_tool_use, pre_tool_use, session_start, stop
@@ -66,9 +67,37 @@ def run() -> int:
66
67
  "deny: destructive MCP tool",
67
68
  )
68
69
  )
70
+ prov = (
71
+ f'{sys.executable} -c "import sys,json;q=json.load(sys.stdin);'
72
+ "print(json.dumps({'answer':'yes' if q['question']=='route_gate' else 'no',"
73
+ "'evidence':['route not covered by authorization (selftest provider)']}))\""
74
+ )
75
+ os.makedirs(os.path.join(repo, ".gitvow"), exist_ok=True)
76
+ with open(os.path.join(repo, ".gitvow", "policy.json"), "w") as fh:
77
+ json.dump({"providers": [{"name": "selftest", "command": prov}]}, fh)
78
+ code, msg = pre_tool_use(
79
+ {
80
+ **base,
81
+ "tool_name": "Edit",
82
+ "tool_input": {"file_path": "api.py", "old_string": "", "new_string": '"/v1/new"'},
83
+ },
84
+ home,
85
+ )
86
+ results.append((code == 2 and "route not covered" in msg, "confirm: provider says new route is unauthorised"))
87
+ os.remove(os.path.join(repo, ".gitvow", "policy.json"))
69
88
  with open(os.path.join(repo, "a.txt"), "a") as fh:
70
89
  fh.write("b\n")
71
90
  post_tool_use({**base, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, home)
91
+ snaps = g("for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").splitlines()
92
+ results.append(
93
+ (len(snaps) == 1 and snaps[0].endswith("/selftest-session/1"), "snapshot taken after the agent edit")
94
+ )
95
+ results.append(
96
+ (
97
+ g("show", f"{snaps[0]}:a.txt") == "a\nb" if snaps else False,
98
+ "snapshot holds the agent's version of the file",
99
+ )
100
+ )
72
101
  with open(os.path.join(repo, "a.txt"), "a") as fh:
73
102
  fh.write("human\n")
74
103
  pre_tool_use({**base, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, home)
@@ -0,0 +1,183 @@
1
+ """Working-tree snapshots after agent edits: commits under refs/gitvow/snapshots/<session>/<n>, parented on HEAD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ import time
12
+ from typing import Any
13
+
14
+ from .state import git, git_dir, load_state, save_state, toplevel
15
+
16
+ REF_PREFIX = "refs/gitvow/snapshots"
17
+ DEFAULTS: dict[str, Any] = {
18
+ "enabled": True,
19
+ "max_per_session": 200,
20
+ "exclude": [".env*", "*.pem", "*.key", "*secret*", "*credential*", ".gitvow/**", ".claude/**"],
21
+ }
22
+
23
+
24
+ def settings(pol: dict[str, Any] | None) -> dict[str, Any]:
25
+ out = dict(DEFAULTS)
26
+ out.update((pol or {}).get("snapshots") or {})
27
+ return out
28
+
29
+
30
+ def _safe(session_id: str | None) -> str:
31
+ return re.sub(r"[^A-Za-z0-9._-]", "_", session_id or "unknown")
32
+
33
+
34
+ def ref_for(session_id: str | None, n: int) -> str:
35
+ return f"{REF_PREFIX}/{_safe(session_id)}/{n}"
36
+
37
+
38
+ def take(cwd: str, session_id: str | None, tool: str, file_path: str, cfg: dict[str, Any]) -> str | None:
39
+ """Write the working tree (minus ignored and excluded paths) as a commit and point a new session ref at it."""
40
+ top = toplevel(cwd)
41
+ gd = git_dir(cwd)
42
+ if not top or not gd:
43
+ return None
44
+ rc, head, _ = git(["rev-parse", "--verify", "HEAD"], top)
45
+ parent = head if rc == 0 else None
46
+ st = load_state(cwd)
47
+ n = int(st.get("snapshots", 0)) + 1
48
+ fd, tmp_index = tempfile.mkstemp(prefix="gitvow-index-", dir=gd)
49
+ os.close(fd)
50
+ os.remove(tmp_index)
51
+ env = {**os.environ, "GIT_INDEX_FILE": tmp_index}
52
+ try:
53
+ if parent:
54
+ subprocess.run(["git", "read-tree", parent], cwd=top, env=env, capture_output=True, check=False)
55
+ pathspec = ["--", "."] + [f":(exclude,glob){p}" for p in cfg.get("exclude", [])]
56
+ r = subprocess.run(
57
+ ["git", "add", "-A", *pathspec], cwd=top, env=env, capture_output=True, text=True, timeout=60, check=False
58
+ )
59
+ if r.returncode != 0:
60
+ return None
61
+ r = subprocess.run(["git", "write-tree"], cwd=top, env=env, capture_output=True, text=True, check=False)
62
+ if r.returncode != 0:
63
+ return None
64
+ tree = r.stdout.strip()
65
+ except (OSError, subprocess.TimeoutExpired):
66
+ return None
67
+ finally:
68
+ with contextlib.suppress(OSError):
69
+ os.remove(tmp_index)
70
+ rel = os.path.relpath(os.path.realpath(file_path), os.path.realpath(top)) if file_path else ""
71
+ msg = json.dumps(
72
+ {
73
+ "gitvow": "snapshot",
74
+ "session": session_id,
75
+ "n": n,
76
+ "tool": tool,
77
+ "file": rel,
78
+ "taken": time.strftime("%Y-%m-%dT%H:%M:%S"),
79
+ }
80
+ )
81
+ args = ["commit-tree", tree, "-m", msg] + (["-p", parent] if parent else [])
82
+ rc, commit, _ = git(args, top)
83
+ if rc != 0:
84
+ return None
85
+ ref = ref_for(session_id, n)
86
+ git(["update-ref", ref, commit], top)
87
+ st["snapshots"] = n
88
+ st["last_snapshot"] = ref
89
+ save_state(cwd, st)
90
+ keep = int(cfg.get("max_per_session", 200))
91
+ if n > keep:
92
+ git(["update-ref", "-d", ref_for(session_id, n - keep)], top)
93
+ return ref
94
+
95
+
96
+ def list_snapshots(cwd: str, session_id: str | None = None) -> list[dict[str, Any]]:
97
+ top = toplevel(cwd) or cwd
98
+ prefix = f"{REF_PREFIX}/{_safe(session_id)}/" if session_id else REF_PREFIX + "/"
99
+ rc, out, _ = git(["for-each-ref", "--format=%(refname)%09%(objectname)%09%(contents:subject)", prefix], top)
100
+ rows: list[dict[str, Any]] = []
101
+ if rc != 0:
102
+ return rows
103
+ for line in out.splitlines():
104
+ parts = line.split("\t", 2)
105
+ if len(parts) < 3:
106
+ continue
107
+ ref, commit, subject = parts
108
+ try:
109
+ meta = json.loads(subject)
110
+ except json.JSONDecodeError:
111
+ meta = {}
112
+ _, stat, _ = git(["diff", "--shortstat", f"{commit}^", commit], top)
113
+ m = re.match(r"\s*(\d+) file", stat or "")
114
+ rows.append(
115
+ {
116
+ "ref": ref,
117
+ "commit": commit,
118
+ "session": meta.get("session") or ref.split("/")[-2],
119
+ "n": int(meta.get("n") or ref.split("/")[-1]),
120
+ "tool": meta.get("tool", ""),
121
+ "file": meta.get("file", ""),
122
+ "taken": meta.get("taken", ""),
123
+ "changed_files": int(m.group(1)) if m else 0,
124
+ }
125
+ )
126
+ rows.sort(key=lambda r: (r["session"], r["n"]))
127
+ return rows
128
+
129
+
130
+ def resolve(cwd: str, session_prefix: str, n: int) -> str | None:
131
+ """Full ref for a session id or unique prefix and a snapshot number."""
132
+ top = toplevel(cwd) or cwd
133
+ rc, out, _ = git(["for-each-ref", "--format=%(refname)", REF_PREFIX + "/"], top)
134
+ if rc != 0:
135
+ return None
136
+ hits = {r.rsplit("/", 1)[0] for r in out.split() if r.split("/")[-2].startswith(_safe(session_prefix))}
137
+ if len(hits) != 1:
138
+ return None
139
+ ref = f"{hits.pop()}/{n}"
140
+ rc, _, _ = git(["rev-parse", "--verify", "--quiet", ref], top)
141
+ return ref if rc == 0 else None
142
+
143
+
144
+ def diff(cwd: str, ref: str, full: bool = False) -> str:
145
+ top = toplevel(cwd) or cwd
146
+ args = ["diff"] + ([] if full else ["--stat"]) + [f"{ref}^", ref]
147
+ rc, out, err = git(args, top)
148
+ return out if rc == 0 else err
149
+
150
+
151
+ def restore(cwd: str, ref: str, to: str | None = None) -> tuple[int, str]:
152
+ top = toplevel(cwd) or cwd
153
+ if not to:
154
+ sess, n = ref.split("/")[-2], ref.split("/")[-1]
155
+ to = os.path.join(tempfile.gettempdir(), "gitvow-restore", f"{sess[:8]}-{n}")
156
+ if os.path.exists(to) and os.listdir(to):
157
+ return 1, f"{to} exists and is not empty; choose another --to"
158
+ os.makedirs(os.path.dirname(to) or ".", exist_ok=True)
159
+ rc, _, err = git(["worktree", "add", "--detach", to, ref], top)
160
+ if rc != 0:
161
+ return 1, err
162
+ return 0, to
163
+
164
+
165
+ def prune(cwd: str, older_than_days: float | None = None, session_id: str | None = None) -> int:
166
+ top = toplevel(cwd) or cwd
167
+ cutoff = time.time() - (older_than_days or 0) * 86400 if older_than_days is not None else None
168
+ removed = 0
169
+ for row in list_snapshots(cwd, session_id):
170
+ if cutoff is not None:
171
+ try:
172
+ taken = time.mktime(time.strptime(row["taken"], "%Y-%m-%dT%H:%M:%S"))
173
+ except (ValueError, TypeError):
174
+ taken = 0
175
+ if taken > cutoff:
176
+ continue
177
+ git(["update-ref", "-d", row["ref"]], top)
178
+ removed += 1
179
+ return removed
180
+
181
+
182
+ def purge_all(cwd: str) -> int:
183
+ return prune(cwd, None, None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.3.0
3
+ Version: 0.5.0
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
@@ -8,9 +8,11 @@ src/gitvow/collect.py
8
8
  src/gitvow/default_policy.json
9
9
  src/gitvow/install.py
10
10
  src/gitvow/policy.py
11
+ src/gitvow/providers.py
11
12
  src/gitvow/redact.py
12
13
  src/gitvow/report.py
13
14
  src/gitvow/selftest.py
15
+ src/gitvow/snapshots.py
14
16
  src/gitvow/state.py
15
17
  src/gitvow/transcript.py
16
18
  src/gitvow.egg-info/PKG-INFO
@@ -25,5 +27,7 @@ tests/test_hooks.py
25
27
  tests/test_install_cli.py
26
28
  tests/test_packaging.py
27
29
  tests/test_policy.py
30
+ tests/test_providers.py
28
31
  tests/test_redact.py
29
- tests/test_report.py
32
+ tests/test_report.py
33
+ tests/test_snapshots.py
@@ -43,7 +43,7 @@ def test_commit_flow_trailer_note_and_attribution(repo, home, payload, transcrip
43
43
  note = git(repo, "notes", "--ref=gitvow/sess-1", "show", "HEAD")
44
44
  assert note.startswith("gitvow-session")
45
45
  data = json.loads(note.split("\n", 1)[1])
46
- assert data["schema"] == 2 and data["step"] == 1 and data["tools_used"] == ["Bash", "Edit"]
46
+ assert data["schema"] == 3 and data["step"] == 1 and data["tools_used"] == ["Bash", "Edit"]
47
47
  assert "[github-token]" in data["last_stated_plan"] and "[email:" in data["last_stated_plan"]
48
48
  assert "ghp_" not in note and "ops@example.com" not in note
49
49
  att = data["attribution"]
@@ -0,0 +1,160 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from gitvow import cli
6
+ from gitvow.hooks import pre_tool_use
7
+ from gitvow.policy import evaluate
8
+ from gitvow.providers import questions_for, route_literals
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ EXAMPLE = ROOT / "examples" / "providers" / "static_facts.py"
12
+
13
+
14
+ def test_route_literals_and_questions():
15
+ txt = 'a = "/v1/orders"; b = \'/v1/orders/{id}\'; c = "/static/app.js"; d = "//comment"; e = "/"; f = "/v1/orders"'
16
+ assert route_literals(txt) == ["/v1/orders", "/v1/orders/{id}"]
17
+ qs = questions_for(
18
+ "Edit",
19
+ {"file_path": "api.py", "old_string": '@app.get("/old")', "new_string": '@app.get("/new")\n@app.get("/old2")'},
20
+ )
21
+ assert qs == [
22
+ ("gate_bearing", "api.py"),
23
+ ("route_gate", "/new"),
24
+ ("route_gate", "/old2"),
25
+ ("route_callers", "/old"),
26
+ ]
27
+ assert questions_for("Write", {"file_path": "x.py", "content": 'r("/w")'}) == [
28
+ ("gate_bearing", "x.py"),
29
+ ("route_gate", "/w"),
30
+ ]
31
+ assert questions_for("Bash", {"command": 'echo "/v1/x"'}) == []
32
+
33
+
34
+ def _facts(tmp_path):
35
+ f = tmp_path / "facts.json"
36
+ f.write_text(
37
+ json.dumps(
38
+ {
39
+ "gate_bearing": ["auth/AuthorizeWhitelistedPaths.java", "deploy/values/production-*.yaml"],
40
+ "whitelist_file": "auth/AuthorizeWhitelistedPaths.java",
41
+ "authorized_routes": ["/v1/orders", "/v1/orders/{id}"],
42
+ "callers": {"/v1/orders": ["client-orch (OrdersClient.java:88)"]},
43
+ }
44
+ )
45
+ )
46
+ return f
47
+
48
+
49
+ def _policy(tmp_path, command):
50
+ return {"providers": [{"name": "facts", "command": command}]}
51
+
52
+
53
+ def test_provider_confirms_with_evidence(tmp_path):
54
+ pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
55
+ d = evaluate(
56
+ pol,
57
+ "Edit",
58
+ {"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/export")'},
59
+ str(tmp_path),
60
+ )
61
+ assert d.outcome == "confirm" and "not covered by any authorization rule" in d.reason
62
+ assert "AuthorizeWhitelistedPaths.java" in d.reason
63
+ d = evaluate(
64
+ pol,
65
+ "Edit",
66
+ {"file_path": "src/api/Orders.java", "old_string": "x", "new_string": '@Path("/v1/orders/{id}")'},
67
+ str(tmp_path),
68
+ )
69
+ assert d.outcome == "allow" # listed as authorized
70
+ d = evaluate(
71
+ pol,
72
+ "Edit",
73
+ {"file_path": "src/api/Orders.java", "old_string": '@Path("/v1/orders")', "new_string": ""},
74
+ str(tmp_path),
75
+ )
76
+ assert d.outcome == "confirm" and "client-orch" in d.reason
77
+ d = evaluate(
78
+ pol,
79
+ "Write",
80
+ {"file_path": str(tmp_path / "deploy/values/production-in.yaml"), "content": "a: 1"},
81
+ str(tmp_path),
82
+ )
83
+ assert d.outcome == "confirm" and "gate-bearing" in d.reason
84
+ assert (
85
+ evaluate(pol, "Edit", {"file_path": "README.md", "old_string": "a", "new_string": "b"}, str(tmp_path)).outcome
86
+ == "allow"
87
+ )
88
+ assert evaluate(pol, "Bash", {"command": 'echo "/v1/new"'}, str(tmp_path)).outcome == "allow"
89
+
90
+
91
+ def test_provider_failure_means_confirm(tmp_path):
92
+ for cmd in (
93
+ "/nonexistent/provider",
94
+ f"{sys.executable} -c \"print('not json')\"",
95
+ f"{sys.executable} -c 'import sys; sys.exit(3)'",
96
+ "",
97
+ ):
98
+ pol = _policy(tmp_path, cmd)
99
+ d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": ""}, str(tmp_path))
100
+ assert d.outcome == "confirm" and "unavailable" in d.reason, cmd
101
+ pol = _policy(tmp_path, f"{sys.executable} -c 'import time; time.sleep(5)'")
102
+ pol["providers"][0]["timeout"] = 0.5
103
+ assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "confirm"
104
+
105
+
106
+ def test_unknown_answer_does_not_block(tmp_path):
107
+ pol = _policy(tmp_path, f'{sys.executable} -c "print(\'{{\\"answer\\": \\"unknown\\"}}\')"')
108
+ assert evaluate(pol, "Edit", {"file_path": "a.py"}, str(tmp_path)).outcome == "allow"
109
+
110
+
111
+ def test_provider_questions_filter_and_validation(tmp_path):
112
+ from gitvow.policy import PolicyError, load_policy
113
+
114
+ (tmp_path / ".gitvow").mkdir()
115
+ (tmp_path / ".gitvow" / "policy.json").write_text(
116
+ json.dumps({"providers": [{"name": "x", "command": "y", "questions": ["nope"]}]})
117
+ )
118
+ try:
119
+ load_policy(str(tmp_path), str(tmp_path / "h"))
120
+ raise AssertionError("expected PolicyError")
121
+ except PolicyError as e:
122
+ assert "unknown questions" in str(e)
123
+ (tmp_path / ".gitvow" / "policy.json").write_text(json.dumps({"providers": [{"name": "x"}]}))
124
+ try:
125
+ load_policy(str(tmp_path), str(tmp_path / "h"))
126
+ raise AssertionError("expected PolicyError")
127
+ except PolicyError:
128
+ pass
129
+ pol = _policy(tmp_path, f"{sys.executable} {EXAMPLE} --facts {_facts(tmp_path)}")
130
+ pol["providers"][0]["questions"] = ["gate_bearing"] # route questions not asked of this provider
131
+ d = evaluate(pol, "Edit", {"file_path": "a.py", "old_string": "", "new_string": '"/v1/zzz"'}, str(tmp_path))
132
+ assert d.outcome == "allow"
133
+
134
+
135
+ def test_hook_and_cli_ask_end_to_end(repo, home, payload, tmp_path, monkeypatch, capsys):
136
+ facts = _facts(tmp_path)
137
+ (repo / ".gitvow").mkdir()
138
+ (repo / ".gitvow" / "policy.json").write_text(
139
+ json.dumps({"providers": [{"name": "facts", "command": f"{sys.executable} {EXAMPLE} --facts {facts}"}]})
140
+ )
141
+ code, msg = pre_tool_use(
142
+ payload(
143
+ "PreToolUse",
144
+ "Edit",
145
+ {"file_path": str(repo / "src/Orders.java"), "old_string": "", "new_string": '@Path("/v1/orders/export")'},
146
+ ),
147
+ str(home),
148
+ )
149
+ assert code == 2 and "CONFIRMATION REQUIRED" in msg and "AuthorizeWhitelistedPaths.java" in msg
150
+ log = (repo / ".git" / "gitvow-hooks.log").read_text()
151
+ assert '"kind": "confirm_required"' in log and "provider facts" in log
152
+ monkeypatch.chdir(repo)
153
+ assert cli.main(["ask", "route_gate", "/v1/orders/export", "--path", "src/Orders.java"]) == 2
154
+ out = capsys.readouterr().out
155
+ assert "facts: yes" in out and "decision: CONFIRM" in out
156
+ assert cli.main(["ask", "route_gate", "/v1/orders"]) == 0
157
+ assert "decision: ALLOW" in capsys.readouterr().out
158
+ assert cli.main(["ask", "gate_bearing", "auth/AuthorizeWhitelistedPaths.java"]) == 2
159
+ (repo / ".gitvow" / "policy.json").write_text("{}")
160
+ assert cli.main(["ask", "gate_bearing", "x"]) == 1
@@ -0,0 +1,136 @@
1
+ import json
2
+ import os
3
+
4
+ from gitvow import cli
5
+ from gitvow.hooks import post_tool_use, pre_tool_use, session_start
6
+ from gitvow.install import _write_git_hook, uninstall_repo
7
+ from tests.conftest import git
8
+
9
+
10
+ def _edit(repo, home, payload, name, content, tool="Edit"):
11
+ (repo / name).parent.mkdir(parents=True, exist_ok=True)
12
+ (repo / name).write_text(content)
13
+ post_tool_use(payload("PostToolUse", tool, {"file_path": str(repo / name)}), str(home))
14
+
15
+
16
+ def test_snapshot_per_edit_with_head_parent_and_exclusions(repo, home, payload):
17
+ session_start(payload("SessionStart"), str(home))
18
+ (repo / ".gitignore").write_text("build/\n")
19
+ (repo / "build").mkdir()
20
+ (repo / "build" / "out.o").write_text("bin")
21
+ (repo / ".env").write_text("SECRET=1\n")
22
+ _edit(repo, home, payload, "calc.py", "def add(a, b):\n return a + b\n")
23
+ _edit(repo, home, payload, "calc.py", "def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
24
+ refs = git(repo, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").split()
25
+ assert refs == ["refs/gitvow/snapshots/sess-1/1", "refs/gitvow/snapshots/sess-1/2"]
26
+ head = git(repo, "rev-parse", "HEAD")
27
+ assert git(repo, "rev-parse", "refs/gitvow/snapshots/sess-1/1^") == head # parented on HEAD at the time
28
+ assert git(repo, "show", "refs/gitvow/snapshots/sess-1/1:calc.py") == "def add(a, b):\n return a + b"
29
+ assert "sub" in git(repo, "show", "refs/gitvow/snapshots/sess-1/2:calc.py")
30
+ tree = git(repo, "ls-tree", "-r", "--name-only", "refs/gitvow/snapshots/sess-1/2")
31
+ assert "calc.py" in tree and ".gitignore" in tree and "build/out.o" not in tree and ".env" not in tree
32
+ (repo / ".gitvow").mkdir(exist_ok=True)
33
+ (repo / ".gitvow" / "policy.json").write_text("{}")
34
+ _edit(repo, home, payload, "calc.py", "v3\n")
35
+ assert ".gitvow/policy.json" not in git(repo, "ls-tree", "-r", "--name-only", "refs/gitvow/snapshots/sess-1/3")
36
+ assert git(repo, "status", "--short").count("\n") >= 0 # the real index was never touched
37
+ assert "calc.py" not in git(repo, "diff", "--cached", "--name-only")
38
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
39
+ assert st["snapshots"] == 2 and st["last_snapshot"] == "refs/gitvow/snapshots/sess-1/2"
40
+ assert '"kind": "snapshot"' in (repo / ".git" / "gitvow-hooks.log").read_text()
41
+
42
+
43
+ def test_note_names_last_snapshot_and_new_session_restarts_numbering(repo, home, payload):
44
+ session_start(payload("SessionStart"), str(home))
45
+ _edit(repo, home, payload, "a.txt", "agent\n")
46
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
47
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
48
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
49
+ git(repo, "commit", "-qam", "agent commit")
50
+ post_tool_use(payload("PostToolUse", "Bash", {"command": "git commit -m x"}), str(home))
51
+ note = json.loads(git(repo, "notes", "--ref=gitvow/sess-1", "show", "HEAD").split("\n", 1)[1])
52
+ assert note["schema"] == 3 and note["snapshot"] == "refs/gitvow/snapshots/sess-1/1"
53
+ p = payload("SessionStart")
54
+ p["session_id"] = "sess-2"
55
+ session_start(p, str(home))
56
+ (repo / "a.txt").write_text("two\n")
57
+ post_tool_use({**p, "tool_name": "Write", "tool_input": {"file_path": str(repo / "a.txt")}}, str(home))
58
+ assert git(repo, "rev-parse", "--verify", "refs/gitvow/snapshots/sess-2/1")
59
+
60
+
61
+ def test_cli_list_diff_restore_prune(repo, home, payload, monkeypatch, capsys, tmp_path):
62
+ session_start(payload("SessionStart"), str(home))
63
+ _edit(repo, home, payload, "calc.py", "v1\n")
64
+ _edit(repo, home, payload, "calc.py", "v2\n", tool="Write")
65
+ (repo / "calc.py").write_text("human v3\n")
66
+ monkeypatch.chdir(repo)
67
+ assert cli.main(["snapshots"]) == 0
68
+ out = capsys.readouterr().out
69
+ assert "session sess-1 2 snapshots" in out and "Write" in out and "calc.py" in out
70
+ assert cli.main(["diff", "sess", "1"]) == 0
71
+ assert "calc.py" in capsys.readouterr().out
72
+ assert cli.main(["diff", "sess", "1", "--full"]) == 0
73
+ assert "+v1" in capsys.readouterr().out
74
+ dest = tmp_path / "restore-here"
75
+ assert cli.main(["restore", "sess", "1", "--to", str(dest)]) == 0
76
+ assert (dest / "calc.py").read_text() == "v1\n"
77
+ assert (repo / "calc.py").read_text() == "human v3\n" # the real tree is untouched
78
+ assert cli.main(["restore", "sess", "1", "--to", str(dest)]) == 1 # refuses a non-empty dir
79
+ assert cli.main(["diff", "nope", "1"]) == 1
80
+ assert cli.main(["diff", "sess", "9"]) == 1
81
+ git(repo, "worktree", "remove", "--force", str(dest))
82
+ assert cli.main(["snapshots", "prune", "--older-than", "14d"]) == 0
83
+ assert "pruned 0" in capsys.readouterr().out
84
+ assert cli.main(["snapshots", "prune", "--session", "sess-1"]) == 0
85
+ assert "pruned 2" in capsys.readouterr().out
86
+ assert cli.main(["snapshots"]) == 0 and "no snapshots" in capsys.readouterr().out
87
+
88
+
89
+ def test_retention_disable_and_purge(repo, home, payload):
90
+ (repo / ".gitvow").mkdir()
91
+ (repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": {"max_per_session": 2}}))
92
+ session_start(payload("SessionStart"), str(home))
93
+ for i in range(4):
94
+ _edit(repo, home, payload, "a.txt", f"v{i}\n")
95
+ refs = git(repo, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/").split()
96
+ assert [r.split("/")[-1] for r in refs] == ["3", "4"]
97
+ (repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": {"enabled": False}}))
98
+ _edit(repo, home, payload, "a.txt", "v9\n")
99
+ assert len(git(repo, "for-each-ref", "refs/gitvow/snapshots/").split("\n")) == 2
100
+ uninstall_repo(str(repo), purge_snapshots=True)
101
+ assert git(repo, "for-each-ref", "refs/gitvow/snapshots/") == ""
102
+
103
+
104
+ def test_pre_push_does_not_push_snapshots(repo, home, payload, tmp_path):
105
+ remote = tmp_path / "remote.git"
106
+ git(remote.parent, "init", "-q", "--bare", str(remote))
107
+ git(repo, "remote", "add", "origin", str(remote))
108
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
109
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
110
+ session_start(payload("SessionStart"), str(home))
111
+ _edit(repo, home, payload, "a.txt", "x\n")
112
+ git(repo, "notes", "--ref=gitvow/sess-1", "add", "-m", "gitvow-session\n{}", "HEAD")
113
+ git(repo, "push", "-q", "origin", git(repo, "branch", "--show-current"))
114
+ remote_refs = git(remote, "for-each-ref", "--format=%(refname)")
115
+ assert "refs/notes/gitvow/sess-1" in remote_refs and "snapshots" not in remote_refs
116
+
117
+
118
+ def test_invalid_snapshots_policy_fails_closed(repo, home, payload):
119
+ (repo / ".gitvow").mkdir()
120
+ (repo / ".gitvow" / "policy.json").write_text(json.dumps({"snapshots": "yes please"}))
121
+ code, msg = pre_tool_use(payload("PreToolUse", "Bash", {"command": "ls"}), str(home))
122
+ assert code == 2 and "snapshots must be an object" in msg
123
+
124
+
125
+ def test_snapshot_in_repo_without_commits(tmp_path, home, payload):
126
+ r = tmp_path / "fresh"
127
+ r.mkdir()
128
+ git(r, "init", "-q")
129
+ (r / "f.txt").write_text("first\n")
130
+ p = payload("SessionStart")
131
+ p["cwd"] = str(r)
132
+ session_start(p, str(home))
133
+ post_tool_use({**p, "tool_name": "Write", "tool_input": {"file_path": str(r / "f.txt")}}, str(home))
134
+ ref = git(r, "for-each-ref", "--format=%(refname)", "refs/gitvow/snapshots/")
135
+ assert ref.endswith("/1") and git(r, "show", f"{ref}:f.txt") == "first"
136
+ assert os.path.exists(r / ".git")
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