gitvow 0.13.2__tar.gz → 0.14.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.13.2/src/gitvow.egg-info → gitvow-0.14.0}/PKG-INFO +1 -1
- {gitvow-0.13.2 → gitvow-0.14.0}/pyproject.toml +1 -1
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/cli.py +20 -0
- gitvow-0.14.0/src/gitvow/scan.py +228 -0
- {gitvow-0.13.2 → gitvow-0.14.0/src/gitvow.egg-info}/PKG-INFO +1 -1
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
- gitvow-0.14.0/tests/test_scan.py +97 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/LICENSE +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/README.md +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/setup.cfg +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/__init__.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/__main__.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/adapters.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/collect.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/decisions.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/default_policy.json +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/digest.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/hooks/__init__.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/install.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/policy.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/pricing.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/providers.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/recall.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/redact.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/report.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/rules.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/selftest.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/snapshots.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/state.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/status.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/transcript.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/entry_points.txt +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/requires.txt +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/top_level.txt +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_adapters.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_collect.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_cost.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_decisions.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_digest.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_external_adapter.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_hooks.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_install_cli.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_packaging.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_policy.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_providers.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_recall.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_redact.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_report.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_rules.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_snapshots.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_status.py +0 -0
- {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_transcript_formats.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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.14.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"}
|
|
@@ -178,6 +178,19 @@ def cmd_check(a: argparse.Namespace) -> int:
|
|
|
178
178
|
return 2 if d.blocks else 0
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
def cmd_scan(a: argparse.Namespace) -> int:
|
|
182
|
+
"""Read an existing repository's history: how much an agent wrote, and how much of it records who agreed."""
|
|
183
|
+
from .scan import build, render
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
d = build(os.path.abspath(a.repo), a.since)
|
|
187
|
+
except ValueError as e:
|
|
188
|
+
print(str(e), file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
print(json.dumps(d, indent=1) if a.json else render(d), end="" if not a.json else "\n")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
181
194
|
def cmd_status(a: argparse.Namespace) -> int:
|
|
182
195
|
"""Is the record actually being written here? Reports what is missing and the line that fixes it."""
|
|
183
196
|
from .status import build, render
|
|
@@ -545,6 +558,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
545
558
|
s.add_argument("--target", help="branch the change is going to; scoped decisions not covering it are reopened")
|
|
546
559
|
s.add_argument("--decisions-summary", action="store_true", help="print only the trailer block for a PR description")
|
|
547
560
|
s.set_defaults(f=cmd_report)
|
|
561
|
+
s = sub.add_parser(
|
|
562
|
+
"scan", help="read an existing repository's history: how much an agent wrote, and who agreed to it"
|
|
563
|
+
)
|
|
564
|
+
s.add_argument("repo", nargs="?", default=".")
|
|
565
|
+
s.add_argument("--since", default="90d", help="90d, 6m, 1y or a date; default 90d")
|
|
566
|
+
s.add_argument("--json", action="store_true")
|
|
567
|
+
s.set_defaults(f=cmd_scan)
|
|
548
568
|
s = sub.add_parser("status", help="check the install is live: hooks reachable, policy loads, git hooks in place")
|
|
549
569
|
s.add_argument("--json", action="store_true")
|
|
550
570
|
s.set_defaults(f=cmd_status)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""What an existing repository already shows, before gitvow is installed anywhere.
|
|
2
|
+
|
|
3
|
+
`gitvow scan` reads `git log` and nothing else: how much of the recent history an agent wrote, how much of
|
|
4
|
+
that touched files the policy calls consequential, and how much of it records who agreed. It writes nothing,
|
|
5
|
+
needs no configuration and works on a repository that has never heard of gitvow.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .decisions import parse_trailers
|
|
17
|
+
from .policy import DEFAULT_POLICY_PATH, PolicyError, load_policy
|
|
18
|
+
from .state import git, toplevel
|
|
19
|
+
|
|
20
|
+
# How an agent signs its own work. Trailers first, then the phrases agents put in a message body.
|
|
21
|
+
AGENT_MARKS: tuple[tuple[str, str], ...] = (
|
|
22
|
+
("Claude", r"Co-?authored-?by:[^\n]*(claude|anthropic)"),
|
|
23
|
+
("Claude", r"Generated with \[Claude Code\]"),
|
|
24
|
+
("Cursor", r"Co-?authored-?by:[^\n]*cursor"),
|
|
25
|
+
("Copilot", r"Co-?authored-?by:[^\n]*copilot"),
|
|
26
|
+
("Codex", r"Co-?authored-?by:[^\n]*(codex|chatgpt)"),
|
|
27
|
+
("Gemini", r"Co-?authored-?by:[^\n]*(gemini|google-labs-jules)"),
|
|
28
|
+
("Devin", r"Co-?authored-?by:[^\n]*devin"),
|
|
29
|
+
("Aider", r"Co-?authored-?by:[^\n]*aider"),
|
|
30
|
+
("Factory", r"Co-?authored-?by:[^\n]*(factory|droid)"),
|
|
31
|
+
("Windsurf", r"Co-?authored-?by:[^\n]*(windsurf|codeium)"),
|
|
32
|
+
)
|
|
33
|
+
COMPILED = tuple((name, re.compile(pat, re.I)) for name, pat in AGENT_MARKS)
|
|
34
|
+
SESSION_RE = re.compile(r"^Gitvow-Session:\s*\S+", re.M)
|
|
35
|
+
MAX_COMMITS = 5000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _since_date(since: str) -> str:
|
|
39
|
+
m = re.fullmatch(r"(\d+)([dwmy])", since or "")
|
|
40
|
+
if not m:
|
|
41
|
+
return since
|
|
42
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
43
|
+
days = n * {"d": 1, "w": 7, "m": 30, "y": 365}[unit]
|
|
44
|
+
return time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _policy_source(cwd: str, home: str | None = None) -> tuple[dict[str, Any], str]:
|
|
48
|
+
"""The policy load_policy would use, and where it came from, so the scan can say whose rules it applied."""
|
|
49
|
+
home = home or os.path.expanduser("~")
|
|
50
|
+
for path, label in (
|
|
51
|
+
(os.path.join(cwd, ".gitvow", "policy.json"), "this repository's policy"),
|
|
52
|
+
(os.path.join(home, ".gitvow", "policy.json"), "your policy"),
|
|
53
|
+
):
|
|
54
|
+
if os.path.exists(path):
|
|
55
|
+
try:
|
|
56
|
+
return load_policy(cwd, home), label
|
|
57
|
+
except PolicyError:
|
|
58
|
+
break
|
|
59
|
+
with open(DEFAULT_POLICY_PATH) as fh:
|
|
60
|
+
return json.load(fh), "the default policy"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _patterns(cwd: str) -> tuple[list[tuple[re.Pattern[str], str]], str]:
|
|
64
|
+
"""The policy's own path rules decide what counts as consequential, so the scan reflects your policy."""
|
|
65
|
+
pol, source = _policy_source(cwd)
|
|
66
|
+
if not (pol.get("path_confirm") or []):
|
|
67
|
+
with open(DEFAULT_POLICY_PATH) as fh:
|
|
68
|
+
pol, source = json.load(fh), "the default policy"
|
|
69
|
+
out: list[tuple[re.Pattern[str], str]] = []
|
|
70
|
+
for r in pol.get("path_confirm") or []:
|
|
71
|
+
try:
|
|
72
|
+
out.append((re.compile(r["pattern"]), r.get("reason", "consequential path")))
|
|
73
|
+
except (re.error, KeyError, TypeError):
|
|
74
|
+
continue
|
|
75
|
+
return out, source
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build(cwd: str, since: str = "90d") -> dict[str, Any]:
|
|
79
|
+
if not os.path.isdir(cwd):
|
|
80
|
+
raise ValueError(f"not a git repository: {cwd}")
|
|
81
|
+
try:
|
|
82
|
+
top = toplevel(cwd) or cwd
|
|
83
|
+
except OSError as e:
|
|
84
|
+
raise ValueError(f"not a git repository: {cwd}") from e
|
|
85
|
+
since_date = _since_date(since)
|
|
86
|
+
pats, policy_source = _patterns(top)
|
|
87
|
+
args = [
|
|
88
|
+
"log",
|
|
89
|
+
f"-{MAX_COMMITS}",
|
|
90
|
+
f"--since={since_date}",
|
|
91
|
+
"--no-merges",
|
|
92
|
+
"--name-only",
|
|
93
|
+
"--date=short",
|
|
94
|
+
"--format=%x01%H%x00%ad%x00%s%x00%B%x02",
|
|
95
|
+
]
|
|
96
|
+
try:
|
|
97
|
+
rc, out, err = git(args, top)
|
|
98
|
+
except OSError as e:
|
|
99
|
+
raise ValueError(f"not a git repository: {cwd}") from e
|
|
100
|
+
if rc != 0:
|
|
101
|
+
raise ValueError(err or f"not a git repository: {cwd}")
|
|
102
|
+
commits = 0
|
|
103
|
+
agent_commits = 0
|
|
104
|
+
agents: dict[str, int] = {}
|
|
105
|
+
consequential: list[dict[str, Any]] = []
|
|
106
|
+
decided = opened = 0
|
|
107
|
+
files: dict[str, int] = {}
|
|
108
|
+
for rec in out.split("\x01"):
|
|
109
|
+
if not rec.strip():
|
|
110
|
+
continue
|
|
111
|
+
head, _, tail = rec.partition("\x02")
|
|
112
|
+
parts = head.split("\x00")
|
|
113
|
+
if len(parts) < 4:
|
|
114
|
+
continue
|
|
115
|
+
sha, date, subject, body = parts[0], parts[1], parts[2], parts[3]
|
|
116
|
+
commits += 1
|
|
117
|
+
seen = {name for name, rx in COMPILED if rx.search(body)}
|
|
118
|
+
if SESSION_RE.search(body):
|
|
119
|
+
seen.add("recorded by gitvow")
|
|
120
|
+
if not seen:
|
|
121
|
+
continue
|
|
122
|
+
agent_commits += 1
|
|
123
|
+
for name in seen:
|
|
124
|
+
agents[name] = agents.get(name, 0) + 1
|
|
125
|
+
changed = [ln.strip() for ln in tail.splitlines() if ln.strip()]
|
|
126
|
+
hits = []
|
|
127
|
+
for f in changed:
|
|
128
|
+
for rx, reason in pats:
|
|
129
|
+
if rx.search(f):
|
|
130
|
+
hits.append((f, reason))
|
|
131
|
+
files[f] = files.get(f, 0) + 1
|
|
132
|
+
break
|
|
133
|
+
if not hits:
|
|
134
|
+
continue
|
|
135
|
+
trailers = parse_trailers(body)
|
|
136
|
+
answered = [t for t in trailers if t["answer"] in ("accepted", "declined")]
|
|
137
|
+
still_open = [t for t in trailers if t["answer"] == "open"]
|
|
138
|
+
if answered:
|
|
139
|
+
decided += 1
|
|
140
|
+
if still_open:
|
|
141
|
+
opened += 1
|
|
142
|
+
consequential.append(
|
|
143
|
+
{
|
|
144
|
+
"sha": sha[:7],
|
|
145
|
+
"date": date,
|
|
146
|
+
"subject": subject[:70],
|
|
147
|
+
"files": [f for f, _ in hits][:6],
|
|
148
|
+
"reasons": sorted({r for _, r in hits}),
|
|
149
|
+
"recorded": bool(answered),
|
|
150
|
+
"open": bool(still_open),
|
|
151
|
+
"by": sorted({t["by"] for t in answered if t.get("by")}),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return {
|
|
155
|
+
"repo": os.path.basename(top),
|
|
156
|
+
"since": since_date,
|
|
157
|
+
"until": time.strftime("%Y-%m-%d"),
|
|
158
|
+
"window": since,
|
|
159
|
+
"commits": commits,
|
|
160
|
+
"agent_commits": agent_commits,
|
|
161
|
+
"agent_share": round(agent_commits / commits, 2) if commits else None,
|
|
162
|
+
"agents": dict(sorted(agents.items(), key=lambda kv: -kv[1])),
|
|
163
|
+
"consequential": consequential,
|
|
164
|
+
"recorded": decided,
|
|
165
|
+
"open": opened,
|
|
166
|
+
"unrecorded": len(consequential) - decided,
|
|
167
|
+
"files": [{"path": p, "commits": n} for p, n in sorted(files.items(), key=lambda kv: -kv[1])[:8]],
|
|
168
|
+
"policy": policy_source,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _plural(n: int, one: str, many: str) -> str:
|
|
173
|
+
return one if n == 1 else many
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render(d: dict[str, Any]) -> str:
|
|
177
|
+
lines: list[str] = []
|
|
178
|
+
win = d["window"] if re.fullmatch(r"\d+[dwmy]", d["window"] or "") else f"since {d['since']}"
|
|
179
|
+
human = {"d": "days", "w": "weeks", "m": "months", "y": "years"}
|
|
180
|
+
if re.fullmatch(r"\d+[dwmy]", win):
|
|
181
|
+
win = f"the last {win[:-1]} {human[win[-1]]}"
|
|
182
|
+
lines.append(f"{d['repo']} · {d['commits']} commits in {win}, merges excluded")
|
|
183
|
+
lines.append("")
|
|
184
|
+
if not d["commits"]:
|
|
185
|
+
lines.append(" no commits in this window. Try a longer one: gitvow scan --since 1y")
|
|
186
|
+
return "\n".join(lines) + "\n"
|
|
187
|
+
if not d["agent_commits"]:
|
|
188
|
+
lines.append(" no commits carry an agent's signature in this window.")
|
|
189
|
+
lines.append("")
|
|
190
|
+
lines.append(" Either agents are not writing here yet, or they leave no trailer. gitvow records")
|
|
191
|
+
lines.append(" every session from the moment it is installed, whether the agent signs or not.")
|
|
192
|
+
lines.append("")
|
|
193
|
+
lines.append(" Next: pipx install gitvow && gitvow install --user --check")
|
|
194
|
+
return "\n".join(lines) + "\n"
|
|
195
|
+
pct = round(100 * d["agent_commits"] / d["commits"])
|
|
196
|
+
who = ", ".join(d["agents"]) or "unknown"
|
|
197
|
+
lines.append(f" {pct:>3}% agent-assisted{'':11}{d['agent_commits']} of {d['commits']} · {who}")
|
|
198
|
+
n = len(d["consequential"])
|
|
199
|
+
if n:
|
|
200
|
+
shown = ", ".join(d["consequential"][0]["files"][:2]) if d["consequential"] else ""
|
|
201
|
+
more = f", +{len(d['files']) - 2} more" if len(d["files"]) > 2 else ""
|
|
202
|
+
lines.append(f" {n:>4} touched a gated file{'':6}{shown}{more}")
|
|
203
|
+
lines.append(f" {d['recorded']:>4} recorded who agreed")
|
|
204
|
+
lines.append("")
|
|
205
|
+
if not n:
|
|
206
|
+
lines.append(" None of them touched a file your policy calls consequential, on this history.")
|
|
207
|
+
elif d["recorded"] == 0:
|
|
208
|
+
lines.append(" Nothing is wrong with these commits. Nobody can tell you who agreed to them.")
|
|
209
|
+
elif d["unrecorded"]:
|
|
210
|
+
lines.append(
|
|
211
|
+
f" {d['recorded']} of {n} record who agreed. The other {d['unrecorded']} "
|
|
212
|
+
f"{_plural(d['unrecorded'], 'does', 'do')} not."
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
lines.append(f" All {n} record who agreed, with evidence. This is what it looks like when it works.")
|
|
216
|
+
if d["open"]:
|
|
217
|
+
lines.append(f" {d['open']} {_plural(d['open'], 'carries', 'carry')} an open finding nobody has answered.")
|
|
218
|
+
if n:
|
|
219
|
+
lines.append("")
|
|
220
|
+
lines.append(" Gated files in this window, by the rules in " + d["policy"] + ":")
|
|
221
|
+
for f in d["files"][:5]:
|
|
222
|
+
lines.append(f" {f['path']} ({f['commits']} {_plural(f['commits'], 'commit', 'commits')})")
|
|
223
|
+
lines.append("")
|
|
224
|
+
lines.append(" This reads git history and nothing else. It is a floor, not a total: an agent that")
|
|
225
|
+
lines.append(" leaves no trailer is invisible to it, and gitvow does not rely on trailers once installed.")
|
|
226
|
+
lines.append("")
|
|
227
|
+
lines.append(" Next: pipx install gitvow && gitvow install --user --check")
|
|
228
|
+
return "\n".join(lines) + "\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitvow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -17,6 +17,7 @@ src/gitvow/recall.py
|
|
|
17
17
|
src/gitvow/redact.py
|
|
18
18
|
src/gitvow/report.py
|
|
19
19
|
src/gitvow/rules.py
|
|
20
|
+
src/gitvow/scan.py
|
|
20
21
|
src/gitvow/selftest.py
|
|
21
22
|
src/gitvow/snapshots.py
|
|
22
23
|
src/gitvow/state.py
|
|
@@ -44,6 +45,7 @@ tests/test_recall.py
|
|
|
44
45
|
tests/test_redact.py
|
|
45
46
|
tests/test_report.py
|
|
46
47
|
tests/test_rules.py
|
|
48
|
+
tests/test_scan.py
|
|
47
49
|
tests/test_snapshots.py
|
|
48
50
|
tests/test_status.py
|
|
49
51
|
tests/test_transcript_formats.py
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
from gitvow import cli
|
|
5
|
+
from gitvow.scan import _since_date, build, render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _commit(repo, path, body, content="x\n"):
|
|
9
|
+
(repo / path).parent.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
(repo / path).write_text(content)
|
|
11
|
+
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, capture_output=True)
|
|
12
|
+
subprocess.run(["git", "commit", "-qm", body], cwd=repo, check=True, capture_output=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_since_shorthand():
|
|
16
|
+
assert _since_date("2026-01-01") == "2026-01-01"
|
|
17
|
+
assert len(_since_date("90d")) == 10 and _since_date("90d") < _since_date("1d")
|
|
18
|
+
assert _since_date("6m") < _since_date("30d") and _since_date("1y") < _since_date("6m")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_a_repository_with_nothing_recorded(repo, home):
|
|
22
|
+
_commit(repo, "core/auth_rules.go", "expose export\n\nCo-authored-by: Claude <noreply@anthropic.com>")
|
|
23
|
+
_commit(repo, "src/util.py", "tidy\n\nCo-authored-by: Cursor <cursoragent@cursor.com>")
|
|
24
|
+
_commit(repo, ".github/workflows/ci.yml", "ci: add a job\n\nGenerated with [Claude Code]")
|
|
25
|
+
_commit(repo, "docs/readme.md", "a human wrote this one")
|
|
26
|
+
d = build(str(repo))
|
|
27
|
+
assert d["commits"] == 5 and d["agent_commits"] == 3 # the fixture's own initial commit is the fifth
|
|
28
|
+
assert d["agent_share"] == 0.6
|
|
29
|
+
assert set(d["agents"]) == {"Claude", "Cursor"}
|
|
30
|
+
# only the two that touched a path the policy calls consequential
|
|
31
|
+
assert len(d["consequential"]) == 2
|
|
32
|
+
assert {f for c in d["consequential"] for f in c["files"]} == {"core/auth_rules.go", ".github/workflows/ci.yml"}
|
|
33
|
+
assert d["recorded"] == 0 and d["unrecorded"] == 2 and d["open"] == 0
|
|
34
|
+
text = render(d)
|
|
35
|
+
assert "agent-assisted" in text and "touched a gated file" in text
|
|
36
|
+
assert "Nobody can tell you who agreed to them." in text
|
|
37
|
+
assert "the default policy" in text # no policy installed in this fixture
|
|
38
|
+
assert "a floor, not a total" in text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_repository_where_it_is_recorded(repo, home):
|
|
42
|
+
_commit(
|
|
43
|
+
repo,
|
|
44
|
+
"core/auth_rules.go",
|
|
45
|
+
"expose export\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
|
|
46
|
+
"Gitvow-Session: s1\nGitvow-Accepted: edit core/auth_rules.go by nikhil scope=staging: reviewed",
|
|
47
|
+
)
|
|
48
|
+
_commit(
|
|
49
|
+
repo,
|
|
50
|
+
"core/authz.py",
|
|
51
|
+
"second one\n\nCo-authored-by: Claude <noreply@anthropic.com>\nGitvow-Open: edit core/authz.py",
|
|
52
|
+
)
|
|
53
|
+
d = build(str(repo))
|
|
54
|
+
assert d["agent_commits"] == 2 and len(d["consequential"]) == 2
|
|
55
|
+
assert d["recorded"] == 1 and d["unrecorded"] == 1 and d["open"] == 1
|
|
56
|
+
assert d["consequential"][1]["by"] == ["nikhil"] and d["consequential"][1]["recorded"] is True
|
|
57
|
+
text = render(d)
|
|
58
|
+
assert "1 of 2 record who agreed. The other 1 does not." in text
|
|
59
|
+
assert "1 carries an open finding nobody has answered." in text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_everything_recorded_says_so(repo, home):
|
|
63
|
+
_commit(
|
|
64
|
+
repo,
|
|
65
|
+
"core/auth_rules.go",
|
|
66
|
+
"one\n\nCo-authored-by: Claude <x@y>\nGitvow-Accepted: edit core/auth_rules.go by nikhil",
|
|
67
|
+
)
|
|
68
|
+
text = render(build(str(repo)))
|
|
69
|
+
assert "All 1 record who agreed, with evidence." in text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_no_agent_signatures_is_not_a_failure(repo, home):
|
|
73
|
+
_commit(repo, "src/util.py", "a person wrote this")
|
|
74
|
+
d = build(str(repo))
|
|
75
|
+
assert d["agent_commits"] == 0 and d["agent_share"] == 0.0
|
|
76
|
+
text = render(d)
|
|
77
|
+
assert "no commits carry an agent's signature" in text
|
|
78
|
+
assert "whether the agent signs or not" in text
|
|
79
|
+
assert "pipx install gitvow" in text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_an_empty_window_suggests_a_longer_one(repo, home):
|
|
83
|
+
d = build(str(repo), since="2099-01-01")
|
|
84
|
+
assert d["commits"] == 0 and d["agent_share"] is None
|
|
85
|
+
assert "gitvow scan --since 1y" in render(d)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_cli_scan_text_and_json(repo, home, monkeypatch, capsys):
|
|
89
|
+
_commit(repo, "core/auth_rules.go", "x\n\nCo-authored-by: Claude <x@y>")
|
|
90
|
+
monkeypatch.chdir(repo)
|
|
91
|
+
assert cli.main(["scan"]) == 0
|
|
92
|
+
assert "agent-assisted" in capsys.readouterr().out
|
|
93
|
+
assert cli.main(["scan", "--json", "--since", "1y"]) == 0
|
|
94
|
+
d = json.loads(capsys.readouterr().out)
|
|
95
|
+
assert d["repo"] == "repo" and d["agent_commits"] == 1 and d["window"] == "1y"
|
|
96
|
+
assert cli.main(["scan", "/nonexistent-path-for-scan"]) == 1
|
|
97
|
+
assert "not a git repository" in capsys.readouterr().err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|