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.
Files changed (53) hide show
  1. {gitvow-0.13.2/src/gitvow.egg-info → gitvow-0.14.0}/PKG-INFO +1 -1
  2. {gitvow-0.13.2 → gitvow-0.14.0}/pyproject.toml +1 -1
  3. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/cli.py +20 -0
  4. gitvow-0.14.0/src/gitvow/scan.py +228 -0
  5. {gitvow-0.13.2 → gitvow-0.14.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  6. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/SOURCES.txt +2 -0
  7. gitvow-0.14.0/tests/test_scan.py +97 -0
  8. {gitvow-0.13.2 → gitvow-0.14.0}/LICENSE +0 -0
  9. {gitvow-0.13.2 → gitvow-0.14.0}/README.md +0 -0
  10. {gitvow-0.13.2 → gitvow-0.14.0}/setup.cfg +0 -0
  11. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/__init__.py +0 -0
  12. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/__main__.py +0 -0
  13. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/adapters.py +0 -0
  14. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/collect.py +0 -0
  15. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/decisions.py +0 -0
  16. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/default_policy.json +0 -0
  17. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/digest.py +0 -0
  18. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/hooks/__init__.py +0 -0
  19. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/install.py +0 -0
  20. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/policy.py +0 -0
  21. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/pricing.py +0 -0
  22. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/providers.py +0 -0
  23. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/recall.py +0 -0
  24. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/redact.py +0 -0
  25. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/report.py +0 -0
  26. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/rules.py +0 -0
  27. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/selftest.py +0 -0
  28. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/snapshots.py +0 -0
  29. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/state.py +0 -0
  30. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/status.py +0 -0
  31. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow/transcript.py +0 -0
  32. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  33. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  34. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/requires.txt +0 -0
  35. {gitvow-0.13.2 → gitvow-0.14.0}/src/gitvow.egg-info/top_level.txt +0 -0
  36. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_adapters.py +0 -0
  37. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_collect.py +0 -0
  38. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_cost.py +0 -0
  39. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_decisions.py +0 -0
  40. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_digest.py +0 -0
  41. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_external_adapter.py +0 -0
  42. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_hooks.py +0 -0
  43. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_install_cli.py +0 -0
  44. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_packaging.py +0 -0
  45. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_policy.py +0 -0
  46. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_providers.py +0 -0
  47. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_recall.py +0 -0
  48. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_redact.py +0 -0
  49. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_report.py +0 -0
  50. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_rules.py +0 -0
  51. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_snapshots.py +0 -0
  52. {gitvow-0.13.2 → gitvow-0.14.0}/tests/test_status.py +0 -0
  53. {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.13.2
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.13.2"
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.13.2
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