gitvow 0.4.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.4.0/src/gitvow.egg-info → gitvow-0.5.0}/PKG-INFO +1 -1
  2. {gitvow-0.4.0 → gitvow-0.5.0}/pyproject.toml +1 -1
  3. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/cli.py +76 -1
  4. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/hooks/__init__.py +17 -3
  5. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/install.py +5 -1
  6. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/policy.py +3 -0
  7. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/selftest.py +10 -0
  8. gitvow-0.5.0/src/gitvow/snapshots.py +183 -0
  9. {gitvow-0.4.0 → gitvow-0.5.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  10. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/SOURCES.txt +3 -1
  11. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_hooks.py +1 -1
  12. gitvow-0.5.0/tests/test_snapshots.py +136 -0
  13. {gitvow-0.4.0 → gitvow-0.5.0}/LICENSE +0 -0
  14. {gitvow-0.4.0 → gitvow-0.5.0}/README.md +0 -0
  15. {gitvow-0.4.0 → gitvow-0.5.0}/setup.cfg +0 -0
  16. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/__init__.py +0 -0
  17. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/__main__.py +0 -0
  18. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/collect.py +0 -0
  19. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/default_policy.json +0 -0
  20. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/providers.py +0 -0
  21. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/redact.py +0 -0
  22. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/report.py +0 -0
  23. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/state.py +0 -0
  24. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/transcript.py +0 -0
  25. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  26. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  27. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/requires.txt +0 -0
  28. {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/top_level.txt +0 -0
  29. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_collect.py +0 -0
  30. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_install_cli.py +0 -0
  31. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_packaging.py +0 -0
  32. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_policy.py +0 -0
  33. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_providers.py +0 -0
  34. {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_redact.py +0 -0
  35. {gitvow-0.4.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.4.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.4.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,6 +9,7 @@ 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
@@ -44,7 +45,7 @@ def cmd_uninstall(a: argparse.Namespace) -> int:
44
45
  done = (
45
46
  uninstall_user(os.path.expanduser("~"), a.purge_policy, a.purge_ledger)
46
47
  if a.user
47
- 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)
48
49
  )
49
50
  print("\n".join(done))
50
51
  return 0
@@ -130,6 +131,61 @@ def cmd_report(a: argparse.Namespace) -> int:
130
131
  return 0
131
132
 
132
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
+
133
189
  def cmd_push_notes(a: argparse.Namespace) -> int:
134
190
  import subprocess
135
191
 
@@ -176,6 +232,7 @@ def main(argv: list[str] | None = None) -> int:
176
232
  s.add_argument("--purge-notes", action="store_true")
177
233
  s.add_argument("--purge-policy", action="store_true")
178
234
  s.add_argument("--purge-ledger", action="store_true")
235
+ s.add_argument("--purge-snapshots", action="store_true")
179
236
  s.set_defaults(f=cmd_uninstall)
180
237
  s = sub.add_parser("check", help="dry-run the policy: gitvow check -- git push --force")
181
238
  s.add_argument("command", nargs="*")
@@ -199,6 +256,24 @@ def main(argv: list[str] | None = None) -> int:
199
256
  s.add_argument("--json", action="store_true")
200
257
  s.add_argument("--require-notes", action="store_true")
201
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)
202
277
  s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
203
278
  s.add_argument("remote", nargs="?", default="origin")
204
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)
@@ -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
@@ -65,6 +65,9 @@ def _validate(pol: dict[str, Any], path: str) -> None:
65
65
  re.compile(pat)
66
66
  except re.error as e:
67
67
  raise PolicyError(f"{path}: bad regex in {key}: {pat} ({e})") from e
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")
68
71
  provs = pol.get("providers", [])
69
72
  if not isinstance(provs, list):
70
73
  raise PolicyError(f"{path}: providers must be a list")
@@ -88,6 +88,16 @@ def run() -> int:
88
88
  with open(os.path.join(repo, "a.txt"), "a") as fh:
89
89
  fh.write("b\n")
90
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
+ )
91
101
  with open(os.path.join(repo, "a.txt"), "a") as fh:
92
102
  fh.write("human\n")
93
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.4.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
@@ -12,6 +12,7 @@ src/gitvow/providers.py
12
12
  src/gitvow/redact.py
13
13
  src/gitvow/report.py
14
14
  src/gitvow/selftest.py
15
+ src/gitvow/snapshots.py
15
16
  src/gitvow/state.py
16
17
  src/gitvow/transcript.py
17
18
  src/gitvow.egg-info/PKG-INFO
@@ -28,4 +29,5 @@ tests/test_packaging.py
28
29
  tests/test_policy.py
29
30
  tests/test_providers.py
30
31
  tests/test_redact.py
31
- 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,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
File without changes
File without changes