gitvow 0.2.2__tar.gz → 0.3.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 (31) hide show
  1. {gitvow-0.2.2/src/gitvow.egg-info → gitvow-0.3.0}/PKG-INFO +1 -1
  2. {gitvow-0.2.2 → gitvow-0.3.0}/pyproject.toml +1 -1
  3. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/cli.py +35 -0
  4. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/hooks/__init__.py +5 -3
  5. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/install.py +17 -5
  6. gitvow-0.3.0/src/gitvow/report.py +140 -0
  7. {gitvow-0.2.2 → gitvow-0.3.0/src/gitvow.egg-info}/PKG-INFO +1 -1
  8. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow.egg-info/SOURCES.txt +3 -1
  9. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_hooks.py +17 -0
  10. gitvow-0.3.0/tests/test_report.py +84 -0
  11. {gitvow-0.2.2 → gitvow-0.3.0}/LICENSE +0 -0
  12. {gitvow-0.2.2 → gitvow-0.3.0}/README.md +0 -0
  13. {gitvow-0.2.2 → gitvow-0.3.0}/setup.cfg +0 -0
  14. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/__init__.py +0 -0
  15. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/__main__.py +0 -0
  16. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/collect.py +0 -0
  17. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/default_policy.json +0 -0
  18. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/policy.py +0 -0
  19. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/redact.py +0 -0
  20. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/selftest.py +0 -0
  21. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/state.py +0 -0
  22. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow/transcript.py +0 -0
  23. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow.egg-info/dependency_links.txt +0 -0
  24. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow.egg-info/entry_points.txt +0 -0
  25. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow.egg-info/requires.txt +0 -0
  26. {gitvow-0.2.2 → gitvow-0.3.0}/src/gitvow.egg-info/top_level.txt +0 -0
  27. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_collect.py +0 -0
  28. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_install_cli.py +0 -0
  29. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_packaging.py +0 -0
  30. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_policy.py +0 -0
  31. {gitvow-0.2.2 → gitvow-0.3.0}/tests/test_redact.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.2.2
3
+ Version: 0.3.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.2.2"
7
+ version = "0.3.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"}
@@ -14,6 +14,7 @@ from .hooks import HANDLERS, LEGACY_NOTES_REF, notes_ref
14
14
  from .install import install_repo, install_user, uninstall_repo, uninstall_user
15
15
  from .policy import PolicyError, evaluate, load_policy
16
16
  from .redact import RedactionError, load_rules, redact
17
+ from .report import build, render_markdown
17
18
  from .state import git
18
19
 
19
20
 
@@ -94,6 +95,31 @@ def cmd_redact(a: argparse.Namespace) -> int:
94
95
  return 0
95
96
 
96
97
 
98
+ def cmd_report(a: argparse.Namespace) -> int:
99
+ """Per-commit report for base..head; exit 1 when --require-notes and a trailered commit has no note."""
100
+ try:
101
+ r = build(os.getcwd(), a.base, a.head)
102
+ except ValueError as e:
103
+ print(f"report error: {e}", file=sys.stderr)
104
+ return 1
105
+ print(json.dumps(r, indent=1) if a.json else render_markdown(r), end="" if not a.json else "\n")
106
+ if a.require_notes and r["missing_notes"]:
107
+ print(f"missing session notes for: {', '.join(r['missing_notes'])}", file=sys.stderr)
108
+ return 1
109
+ return 0
110
+
111
+
112
+ def cmd_push_notes(a: argparse.Namespace) -> int:
113
+ import subprocess
114
+
115
+ env = {**os.environ, "GITVOW_PUSHING_NOTES": "1"} # the pre-push hook must not push the same refs again
116
+ r = subprocess.run(
117
+ ["git", "push", a.remote, "refs/notes/gitvow/*:refs/notes/gitvow/*"], env=env, capture_output=True, text=True
118
+ )
119
+ print((r.stderr or r.stdout).strip() or "notes pushed", file=sys.stderr if r.returncode else sys.stdout)
120
+ return 1 if r.returncode else 0
121
+
122
+
97
123
  def cmd_collect(a: argparse.Namespace) -> int:
98
124
  w = collect(os.path.expanduser("~"), a.out)
99
125
  print(summarize_text(w))
@@ -141,6 +167,15 @@ def main(argv: list[str] | None = None) -> int:
141
167
  s = sub.add_parser("redact", help="apply built-in redaction plus your rules files to text")
142
168
  s.add_argument("text", nargs="+")
143
169
  s.set_defaults(f=cmd_redact)
170
+ s = sub.add_parser("report", help="per-commit report for a range: trailers, notes, attribution, said vs did")
171
+ s.add_argument("--base", required=True)
172
+ s.add_argument("--head", default="HEAD")
173
+ s.add_argument("--json", action="store_true")
174
+ s.add_argument("--require-notes", action="store_true")
175
+ s.set_defaults(f=cmd_report)
176
+ s = sub.add_parser("push-notes", help="push refs/notes/gitvow/* to a remote (default origin)")
177
+ s.add_argument("remote", nargs="?", default="origin")
178
+ s.set_defaults(f=cmd_push_notes)
144
179
  s = sub.add_parser("collect", help="gather ledger, logs, trailers and notes into one directory")
145
180
  s.add_argument("--out", default=os.path.expanduser("~/Desktop"))
146
181
  s.set_defaults(f=cmd_collect)
@@ -29,15 +29,17 @@ def notes_ref(session_id: str | None) -> str:
29
29
  def session_start(h: dict[str, Any], home: str | None = None) -> tuple[int, str]:
30
30
  cwd = h.get("cwd") or os.getcwd()
31
31
  st = load_state(cwd)
32
+ same_session = st.get("session_id") == h.get("session_id") # a resumed session keeps its counters
32
33
  st.update(
33
34
  {
34
35
  "session_id": h.get("session_id"),
35
36
  "transcript_path": h.get("transcript_path"),
36
- "started": time.strftime("%Y-%m-%dT%H:%M:%S"),
37
- "steps": st.get("steps", 0),
38
- "agent_blobs": st.get("agent_blobs", {}) if st.get("session_id") == h.get("session_id") else {},
37
+ "started": st.get("started") if same_session and st.get("started") else time.strftime("%Y-%m-%dT%H:%M:%S"),
38
+ "steps": st.get("steps", 0) if same_session else 0,
39
+ "agent_blobs": st.get("agent_blobs", {}) if same_session else {},
39
40
  }
40
41
  )
42
+ st.pop("pending_commit", None)
41
43
  save_state(cwd, st)
42
44
  log_event(cwd, "session_start", {"session_id": h.get("session_id")})
43
45
  return 0, ""
@@ -24,6 +24,17 @@ SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null &
24
24
  [ -x "$GD/hooks/prepare-commit-msg" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/prepare-commit-msg" "$@"
25
25
  exit 0
26
26
  """
27
+ PRE_PUSH_HOOK = """#!/bin/sh
28
+ # gitvow: push session notes (refs/notes/gitvow/*) to the same remote whenever a branch is pushed.
29
+ # The inner push re-enters this hook; GITVOW_PUSHING_NOTES stops the recursion.
30
+ REMOTE="$1"
31
+ if [ -z "$GITVOW_PUSHING_NOTES" ] && [ -n "$REMOTE" ] && git for-each-ref --count=1 refs/notes/gitvow/ | grep -q .; then
32
+ GITVOW_PUSHING_NOTES=1 git push --quiet "$REMOTE" 'refs/notes/gitvow/*:refs/notes/gitvow/*' 2>/dev/null || true
33
+ fi
34
+ GD="$(git rev-parse --git-dir)"; SELF="$(cd "$(dirname "$0")" && pwd)"; REPOHOOKS="$(cd "$GD/hooks" 2>/dev/null && pwd || true)"
35
+ [ -x "$GD/hooks/pre-push" ] && [ "$SELF" != "$REPOHOOKS" ] && exec "$GD/hooks/pre-push" "$@"
36
+ exit 0
37
+ """
27
38
  MARKER = "gitvow hook "
28
39
  NOTES_GLOB = "refs/notes/gitvow/*"
29
40
  NOTES_KEYS = ("notes.displayRef", "notes.rewriteRef")
@@ -106,11 +117,12 @@ def unmerge_settings(path: str) -> None:
106
117
 
107
118
  def _write_git_hook(dirpath: str) -> str:
108
119
  os.makedirs(dirpath, exist_ok=True)
109
- p = os.path.join(dirpath, "prepare-commit-msg")
110
- with open(p, "w") as fh:
111
- fh.write(GIT_HOOK)
112
- os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
113
- return p
120
+ for name, body in (("prepare-commit-msg", GIT_HOOK), ("pre-push", PRE_PUSH_HOOK)):
121
+ p = os.path.join(dirpath, name)
122
+ with open(p, "w") as fh:
123
+ fh.write(body)
124
+ os.chmod(p, 0o755) # noqa: S103 # nosec B103 - git runs hooks as the invoking user; must be executable
125
+ return os.path.join(dirpath, "prepare-commit-msg")
114
126
 
115
127
 
116
128
  def install_user(home: str, cmd_prefix: str | None = None) -> list[str]:
@@ -0,0 +1,140 @@
1
+ """Per-commit report for a range: trailers paired with session notes, attribution, and said-vs-did."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from typing import Any
9
+
10
+ from .hooks import LEGACY_NOTES_REF, notes_ref
11
+ from .state import git
12
+
13
+ TRAILER_RE = re.compile(r"^Gitvow-Session:\s*(\S+)", re.M)
14
+ STEP_RE = re.compile(r"^Gitvow-Step:\s*(\d+)", re.M)
15
+
16
+
17
+ def _note_for(cwd: str, sha: str, session_id: str) -> dict[str, Any] | None:
18
+ for ref in (notes_ref(session_id), LEGACY_NOTES_REF):
19
+ rc, body, _ = git(["notes", f"--ref={ref}", "show", sha], cwd)
20
+ if rc == 0 and body.startswith("gitvow-session"):
21
+ try:
22
+ return json.loads(body.split("\n", 1)[1])
23
+ except (json.JSONDecodeError, IndexError):
24
+ return None
25
+ return None
26
+
27
+
28
+ def _changed_files(cwd: str, sha: str) -> list[str]:
29
+ _, out, _ = git(["show", "--name-only", "--format=", sha], cwd)
30
+ return [ln for ln in out.splitlines() if ln.strip()]
31
+
32
+
33
+ def said_vs_did(plan: str, files: list[str]) -> dict[str, list[str]]:
34
+ """Which changed files does the stated plan mention (by path or basename, case-insensitive)?"""
35
+ low = plan.lower()
36
+ mentioned, unmentioned = [], []
37
+ for f in files:
38
+ base = os.path.basename(f).lower()
39
+ stem = os.path.splitext(base)[0]
40
+ if f.lower() in low or base in low or (len(stem) > 3 and stem in low):
41
+ mentioned.append(f)
42
+ else:
43
+ unmentioned.append(f)
44
+ return {"mentioned": mentioned, "unmentioned": unmentioned}
45
+
46
+
47
+ def build(cwd: str, base: str, head: str = "HEAD") -> dict[str, Any]:
48
+ rc, out, err = git(["log", "--reverse", "--format=%H%x00%s%x00%B%x01", f"{base}..{head}"], cwd)
49
+ if rc != 0:
50
+ raise ValueError(err or f"cannot list {base}..{head}")
51
+ commits: list[dict[str, Any]] = []
52
+ for rec in out.split("\x01"):
53
+ rec = rec.strip("\n")
54
+ if not rec.strip():
55
+ continue
56
+ sha, subject, body = rec.split("\x00", 2)
57
+ m = TRAILER_RE.search(body)
58
+ entry: dict[str, Any] = {"sha": sha, "short": sha[:7], "subject": subject, "files": _changed_files(cwd, sha)}
59
+ if not m:
60
+ entry["kind"] = "human"
61
+ commits.append(entry)
62
+ continue
63
+ sid = m.group(1)
64
+ sm = STEP_RE.search(body)
65
+ entry.update({"kind": "agent", "session_id": sid, "step": int(sm.group(1)) if sm else None})
66
+ note = _note_for(cwd, sha, sid)
67
+ entry["note_found"] = note is not None
68
+ if note:
69
+ plan = note.get("last_stated_plan") or ""
70
+ att = note.get("attribution") or {}
71
+ entry.update(
72
+ {
73
+ "plan": plan,
74
+ "tools_used": note.get("tools_used", []),
75
+ "tool_calls": note.get("tool_calls_so_far"),
76
+ "turns": note.get("assistant_turns_so_far"),
77
+ "attribution": {
78
+ "files_in_commit": att.get("files_in_commit"),
79
+ "touched_by_agent": att.get("touched_by_agent"),
80
+ "agent_share": att.get("agent_share"),
81
+ "lines_changed_by_human_after_agent": att.get("lines_changed_by_human_after_agent"),
82
+ },
83
+ "said_vs_did": said_vs_did(plan, entry["files"]),
84
+ }
85
+ )
86
+ commits.append(entry)
87
+ agent = [c for c in commits if c["kind"] == "agent"]
88
+ return {
89
+ "base": base,
90
+ "head": head,
91
+ "commits": commits,
92
+ "agent_commits": len(agent),
93
+ "human_commits": len(commits) - len(agent),
94
+ "missing_notes": [c["short"] for c in agent if not c["note_found"]],
95
+ }
96
+
97
+
98
+ def render_markdown(r: dict[str, Any]) -> str:
99
+ lines = [
100
+ f"## gitvow: {r['agent_commits']} agent commit{'s' if r['agent_commits'] != 1 else ''}, "
101
+ f"{r['human_commits']} human commit{'s' if r['human_commits'] != 1 else ''}",
102
+ "",
103
+ ]
104
+ if r["missing_notes"]:
105
+ lines += [
106
+ f"> **Missing session notes** for {', '.join(r['missing_notes'])}. "
107
+ "The author's notes were not pushed; run `gitvow push-notes` or `git push origin 'refs/notes/gitvow/*'`.",
108
+ "",
109
+ ]
110
+ for c in r["commits"]:
111
+ if c["kind"] == "human":
112
+ lines += [f"### {c['short']} {c['subject']} — no session trailer (made by a person)", ""]
113
+ continue
114
+ sid = c["session_id"][:8]
115
+ lines.append(
116
+ f"### {c['short']} {c['subject']} — session {sid}, step {c['step'] if c['step'] is not None else '?'}"
117
+ )
118
+ if not c["note_found"]:
119
+ lines += ["**Session note:** missing (not pushed)", ""]
120
+ continue
121
+ plan = c["plan"].strip().replace("\n", " ")
122
+ lines.append(f"**Plan:** {plan[:400] if plan else '(none stated before committing)'}")
123
+ tools = ", ".join(c["tools_used"]) or "none recorded"
124
+ lines.append(f"**Tools:** {tools} · {c['tool_calls']} tool calls · {c['turns']} turns")
125
+ a = c["attribution"]
126
+ share = "n/a" if a["agent_share"] is None else f"{a['agent_share']:.2f}"
127
+ lines.append(
128
+ f"**Attribution:** {a['files_in_commit']} file{'s' if a['files_in_commit'] != 1 else ''}, "
129
+ f"agent share {share}, {a['lines_changed_by_human_after_agent'] or 0} lines changed by a human after the agent"
130
+ )
131
+ svd = c["said_vs_did"]
132
+ parts = []
133
+ if svd["mentioned"]:
134
+ parts.append(", ".join(svd["mentioned"]) + " mentioned in plan ✓")
135
+ if svd["unmentioned"]:
136
+ parts.append(", ".join(svd["unmentioned"]) + " **not mentioned in plan**")
137
+ if not plan:
138
+ parts.append("no plan to compare")
139
+ lines += ["**Said vs did:** " + " · ".join(parts), ""]
140
+ return "\n".join(lines).rstrip() + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitvow
3
- Version: 0.2.2
3
+ Version: 0.3.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
@@ -9,6 +9,7 @@ src/gitvow/default_policy.json
9
9
  src/gitvow/install.py
10
10
  src/gitvow/policy.py
11
11
  src/gitvow/redact.py
12
+ src/gitvow/report.py
12
13
  src/gitvow/selftest.py
13
14
  src/gitvow/state.py
14
15
  src/gitvow/transcript.py
@@ -24,4 +25,5 @@ tests/test_hooks.py
24
25
  tests/test_install_cli.py
25
26
  tests/test_packaging.py
26
27
  tests/test_policy.py
27
- tests/test_redact.py
28
+ tests/test_redact.py
29
+ tests/test_report.py
@@ -216,3 +216,20 @@ def test_invalid_rules_fail_closed_nothing_written(repo, home, payload, transcri
216
216
  log = (repo / ".git" / "gitvow-hooks.log").read_text()
217
217
  assert '"kind": "redaction_unavailable"' in log
218
218
  assert "detail" not in [k for ln in log.splitlines() for k in json.loads(ln) if json.loads(ln)["kind"] == "allowed"]
219
+
220
+
221
+ def test_new_session_resets_steps_and_blobs_but_resume_keeps_them(repo, home, payload):
222
+ session_start(payload("SessionStart"), str(home))
223
+ pre_tool_use(payload("PreToolUse", "Bash", {"command": "git commit -m x"}), str(home))
224
+ (repo / "a.txt").write_text("b\n")
225
+ post_tool_use(payload("PostToolUse", "Edit", {"file_path": "a.txt"}), str(home))
226
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
227
+ assert st["steps"] == 1 and st["agent_blobs"] and "pending_commit" in st
228
+ session_start(payload("SessionStart"), str(home)) # same id: resume
229
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
230
+ assert st["steps"] == 1 and st["agent_blobs"] and "pending_commit" not in st
231
+ p = payload("SessionStart")
232
+ p["session_id"] = "sess-2"
233
+ session_start(p, str(home)) # a new session in the same repo starts from zero
234
+ st = json.loads((repo / ".git" / "gitvow-session.json").read_text())
235
+ assert st["session_id"] == "sess-2" and st["steps"] == 0 and st["agent_blobs"] == {}
@@ -0,0 +1,84 @@
1
+ import json
2
+ import time
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
7
+ from gitvow.report import build, render_markdown, said_vs_did
8
+ from tests.conftest import git
9
+
10
+
11
+ def test_said_vs_did_matches_path_basename_or_stem():
12
+ r = said_vs_did(
13
+ "Fix the cache key in QueryCacheHelper.java and touch calc", ["a/QueryCacheHelper.java", "calc.py", "x/util.go"]
14
+ )
15
+ assert r == {"mentioned": ["a/QueryCacheHelper.java", "calc.py"], "unmentioned": ["x/util.go"]}
16
+ assert said_vs_did("", ["a.py"]) == {"mentioned": [], "unmentioned": ["a.py"]}
17
+
18
+
19
+ def _agent_commit(repo, home, payload, transcript, sid, text):
20
+ p = payload("SessionStart", transcript=transcript)
21
+ p["session_id"] = sid
22
+ session_start(p, str(home))
23
+ (repo / "a.txt").write_text(text)
24
+ post_tool_use({**p, "tool_name": "Edit", "tool_input": {"file_path": "a.txt"}}, str(home))
25
+ pre_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, str(home))
26
+ git(repo, "commit", "-qam", f"agent {sid}")
27
+ post_tool_use({**p, "tool_name": "Bash", "tool_input": {"command": "git commit -m x"}}, str(home))
28
+
29
+
30
+ def test_report_pairs_trailers_with_notes_and_flags_missing(repo, home, payload, transcript, monkeypatch, capsys):
31
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
32
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
33
+ base = git(repo, "rev-parse", "HEAD")
34
+ _agent_commit(repo, home, payload, transcript, "s-aaa", "one\n")
35
+ (repo / "h.txt").write_text("human\n")
36
+ git(repo, "add", "h.txt")
37
+ git(repo, "commit", "-qm", "human commit")
38
+ _agent_commit(repo, home, payload, transcript, "s-bbb", "two\n")
39
+ git(repo, "update-ref", "-d", "refs/notes/gitvow/s-bbb") # simulate notes never pushed
40
+ r = build(str(repo), base, "HEAD")
41
+ assert r["agent_commits"] == 2 and r["human_commits"] == 1 and len(r["missing_notes"]) == 1
42
+ kinds = [c["kind"] for c in r["commits"]]
43
+ assert kinds == ["agent", "human", "agent"]
44
+ first = r["commits"][0]
45
+ assert first["note_found"] and first["attribution"]["agent_share"] == 1.0 and first["step"] == 1
46
+ assert "[github-token]" in first["plan"] # redacted plan travels into the report
47
+ assert first["said_vs_did"]["unmentioned"] == ["a.txt"]
48
+ md = render_markdown(r)
49
+ assert "2 agent commits, 1 human commit" in md and "Missing session notes" in md
50
+ assert "made by a person" in md and "not mentioned in plan" in md and "ghp_" not in md
51
+ monkeypatch.chdir(repo)
52
+ assert cli.main(["report", "--base", base]) == 0
53
+ assert cli.main(["report", "--base", base, "--require-notes"]) == 1
54
+ assert "missing session notes" in capsys.readouterr().err
55
+ assert cli.main(["report", "--base", base, "--json"]) == 0
56
+ assert json.loads(capsys.readouterr().out)["agent_commits"] == 2
57
+ assert cli.main(["report", "--base", "nonexistent-rev"]) == 1
58
+
59
+
60
+ def test_pre_push_hook_pushes_notes_with_branch(repo, home, tmp_path):
61
+ remote = tmp_path / "remote.git"
62
+ git(remote.parent, "init", "-q", "--bare", str(remote))
63
+ git(repo, "remote", "add", "origin", str(remote))
64
+ _write_git_hook(str(repo / ".gitvow" / "git-hooks"))
65
+ git(repo, "config", "core.hooksPath", ".gitvow/git-hooks")
66
+ assert (repo / ".gitvow" / "git-hooks" / "pre-push").exists()
67
+ git(repo, "notes", "--ref=gitvow/s1", "add", "-m", "gitvow-session\n{}", "HEAD")
68
+ branch = git(repo, "branch", "--show-current")
69
+ git(repo, "push", "-q", "origin", branch)
70
+ assert git(remote, "for-each-ref", "--format=%(refname)", "refs/notes/gitvow/") == "refs/notes/gitvow/s1"
71
+ # push-notes by hand, and idempotent second push
72
+ (repo / "a.txt").write_text("n\n")
73
+ git(repo, "commit", "-qam", "n")
74
+ git(repo, "notes", "--ref=gitvow/s2", "add", "-m", "gitvow-session\n{}", "HEAD")
75
+ import os
76
+
77
+ cwd = os.getcwd()
78
+ os.chdir(repo)
79
+ try:
80
+ assert cli.main(["push-notes"]) == 0
81
+ finally:
82
+ os.chdir(cwd)
83
+ assert "refs/notes/gitvow/s2" in git(remote, "for-each-ref", "--format=%(refname)", "refs/notes/gitvow/")
84
+ assert time.time() > 0
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