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.
- {gitvow-0.4.0/src/gitvow.egg-info → gitvow-0.5.0}/PKG-INFO +1 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/pyproject.toml +1 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/cli.py +76 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/hooks/__init__.py +17 -3
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/install.py +5 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/policy.py +3 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/selftest.py +10 -0
- gitvow-0.5.0/src/gitvow/snapshots.py +183 -0
- {gitvow-0.4.0 → gitvow-0.5.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/SOURCES.txt +3 -1
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_hooks.py +1 -1
- gitvow-0.5.0/tests/test_snapshots.py +136 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/LICENSE +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/README.md +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/setup.cfg +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/report.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/state.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_collect.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_packaging.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_policy.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_providers.py +0 -0
- {gitvow-0.4.0 → gitvow-0.5.0}/tests/test_redact.py +0 -0
- {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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
164
|
-
|
|
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.
|
|
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"] ==
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|