Dirty Tree Guard
Checks the session working directory for uncommitted tracked changes and unpushed commits at Stop and SubagentStop. Emits GIT-HYGIENE-VIOLATION sentinel lines to stdout and stderr — non-blocking.
id hook/dirty-tree-guardv1.0.0by convergent-systems-key
- Event
Stop- Trigger
always- Language
python- Side effects
- emits GIT-HYGIENE-VIOLATION sentinel lines to stdout and stderr
- Platforms
linuxmacoswindows- Notes
- Requires git. Reads cwd from the hook event payload (field 'cwd' or 'workingDirectory'), falling back to process cwd. Client-agnostic — works with Claude Code, Codex, Copilot. Non-blocking — uses the sentinel pattern. Cross-platform via 'ai hooks run'.
- Depends on
- hook/lib
Script · dirty-tree-guard.py
#!/usr/bin/env python3
"""hooks/dirty-tree-guard.py — detect uncommitted changes and unpushed commits at session end.
Fires on SubagentStop and Stop events. Checks the git repository whose
working directory is reachable from the hook event payload's 'cwd' field
(falling back to process cwd). Client-agnostic: works with Claude Code,
Codex, and Copilot. Emits GIT-HYGIENE-VIOLATION sentinel lines to stdout
and stderr — non-blocking.
Self-check:
--self-check exits 0 if git is reachable, 1 otherwise.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib # noqa: E402
def _git_root(cwd: Path) -> Path | None:
"""Return the git root containing cwd, or None if not in a repo."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=False, cwd=str(cwd),
)
if result.returncode == 0:
return Path(result.stdout.strip())
except FileNotFoundError:
pass
return None
def _has_uncommitted(repo_root: Path) -> tuple[bool, str]:
"""Return (dirty, detail). detail is a short human-readable summary."""
try:
result = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, check=False, cwd=str(repo_root),
)
if result.returncode != 0:
return False, ""
lines = [l for l in result.stdout.splitlines() if l.strip()]
if lines:
staged = [l for l in lines if l[0] != " " and l[0] != "?"]
unstaged = [l for l in lines if l[1] != " " and l[0] == " "]
untracked = [l for l in lines if l.startswith("??")]
parts = []
if staged:
parts.append(f"{len(staged)} staged")
if unstaged:
parts.append(f"{len(unstaged)} unstaged")
if untracked:
parts.append(f"{len(untracked)} untracked")
return True, ", ".join(parts) if parts else f"{len(lines)} changed"
return False, ""
except Exception: # noqa: BLE001
return False, ""
def _current_branch(repo_root: Path) -> str:
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=False, cwd=str(repo_root),
)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
return "unknown"
def _unpushed_commits(repo_root: Path) -> int:
"""Return count of commits on HEAD not yet pushed to upstream."""
try:
result = subprocess.run(
["git", "rev-list", "--count", "@{u}..HEAD"],
capture_output=True, text=True, check=False, cwd=str(repo_root),
)
if result.returncode == 0:
return int(result.stdout.strip())
except (FileNotFoundError, ValueError):
pass
return 0
def check_repo(cwd: Path) -> list[str]:
"""Return list of violation lines for the repo containing cwd."""
repo = _git_root(cwd)
if repo is None:
return []
violations = []
dirty, detail = _has_uncommitted(repo)
if dirty:
branch = _current_branch(repo)
violations.append(
f"GIT-HYGIENE-VIOLATION: uncommitted changes in {repo} on branch '{branch}' ({detail})"
)
unpushed = _unpushed_commits(repo)
if unpushed > 0:
branch = _current_branch(repo)
violations.append(
f"GIT-HYGIENE-VIOLATION: {unpushed} unpushed commit(s) in {repo} on branch '{branch}'"
)
return violations
def main() -> None:
if "--self-check" in sys.argv:
try:
subprocess.run(["git", "--version"], capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
_lib.log("self-check FAIL: git not found:", e)
sys.exit(1)
_lib.log("self-check OK")
sys.exit(0)
try:
event = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
event = {}
raw_cwd = (
event.get("cwd")
or event.get("workingDirectory")
or os.getcwd()
)
cwd = Path(raw_cwd)
violations = check_repo(cwd)
if violations:
for v in violations:
_lib.log(v)
print(v, flush=True)
else:
_lib.log("dirty-tree-guard: clean")
if __name__ == "__main__":
main()
gitgovernanceagentic-lifecycleclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.