SkillsHooksPromptsAgentsPersonasModelsPoliciesToolsTemplatesBundlesCategoriesStart here
← Hooks
Hk hookgovernanceadvisorystable

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.