{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/dirty-tree-guard",
  "version": "1.0.0",
  "name": "Dirty Tree Guard",
  "description": "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.",
  "event": "Stop",
  "events": [
    "Stop",
    "SubagentStop"
  ],
  "language": "python",
  "trigger": {
    "type": "always"
  },
  "blocking": false,
  "side_effects": [
    "emits GIT-HYGIENE-VIOLATION sentinel lines to stdout and stderr"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "git",
    "governance",
    "agentic-lifecycle",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_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'.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/dirty-tree-guard.py — detect uncommitted changes and unpushed commits at session end.\n\nFires on SubagentStop and Stop events. Checks the git repository whose\nworking directory is reachable from the hook event payload's 'cwd' field\n(falling back to process cwd). Client-agnostic: works with Claude Code,\nCodex, and Copilot. Emits GIT-HYGIENE-VIOLATION sentinel lines to stdout\nand stderr — non-blocking.\n\nSelf-check:\n  --self-check  exits 0 if git is reachable, 1 otherwise.\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\ndef _git_root(cwd: Path) -> Path | None:\n    \"\"\"Return the git root containing cwd, or None if not in a repo.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"git\", \"rev-parse\", \"--show-toplevel\"],\n            capture_output=True, text=True, check=False, cwd=str(cwd),\n        )\n        if result.returncode == 0:\n            return Path(result.stdout.strip())\n    except FileNotFoundError:\n        pass\n    return None\n\n\ndef _has_uncommitted(repo_root: Path) -> tuple[bool, str]:\n    \"\"\"Return (dirty, detail). detail is a short human-readable summary.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"git\", \"status\", \"--porcelain\"],\n            capture_output=True, text=True, check=False, cwd=str(repo_root),\n        )\n        if result.returncode != 0:\n            return False, \"\"\n        lines = [l for l in result.stdout.splitlines() if l.strip()]\n        if lines:\n            staged = [l for l in lines if l[0] != \" \" and l[0] != \"?\"]\n            unstaged = [l for l in lines if l[1] != \" \" and l[0] == \" \"]\n            untracked = [l for l in lines if l.startswith(\"??\")]\n            parts = []\n            if staged:\n                parts.append(f\"{len(staged)} staged\")\n            if unstaged:\n                parts.append(f\"{len(unstaged)} unstaged\")\n            if untracked:\n                parts.append(f\"{len(untracked)} untracked\")\n            return True, \", \".join(parts) if parts else f\"{len(lines)} changed\"\n        return False, \"\"\n    except Exception:  # noqa: BLE001\n        return False, \"\"\n\n\ndef _current_branch(repo_root: Path) -> str:\n    try:\n        result = subprocess.run(\n            [\"git\", \"branch\", \"--show-current\"],\n            capture_output=True, text=True, check=False, cwd=str(repo_root),\n        )\n        if result.returncode == 0:\n            return result.stdout.strip()\n    except FileNotFoundError:\n        pass\n    return \"unknown\"\n\n\ndef _unpushed_commits(repo_root: Path) -> int:\n    \"\"\"Return count of commits on HEAD not yet pushed to upstream.\"\"\"\n    try:\n        result = subprocess.run(\n            [\"git\", \"rev-list\", \"--count\", \"@{u}..HEAD\"],\n            capture_output=True, text=True, check=False, cwd=str(repo_root),\n        )\n        if result.returncode == 0:\n            return int(result.stdout.strip())\n    except (FileNotFoundError, ValueError):\n        pass\n    return 0\n\n\ndef check_repo(cwd: Path) -> list[str]:\n    \"\"\"Return list of violation lines for the repo containing cwd.\"\"\"\n    repo = _git_root(cwd)\n    if repo is None:\n        return []\n\n    violations = []\n    dirty, detail = _has_uncommitted(repo)\n    if dirty:\n        branch = _current_branch(repo)\n        violations.append(\n            f\"GIT-HYGIENE-VIOLATION: uncommitted changes in {repo} on branch '{branch}' ({detail})\"\n        )\n\n    unpushed = _unpushed_commits(repo)\n    if unpushed > 0:\n        branch = _current_branch(repo)\n        violations.append(\n            f\"GIT-HYGIENE-VIOLATION: {unpushed} unpushed commit(s) in {repo} on branch '{branch}'\"\n        )\n\n    return violations\n\n\ndef main() -> None:\n    if \"--self-check\" in sys.argv:\n        try:\n            subprocess.run([\"git\", \"--version\"], capture_output=True, check=True)\n        except (FileNotFoundError, subprocess.CalledProcessError) as e:\n            _lib.log(\"self-check FAIL: git not found:\", e)\n            sys.exit(1)\n        _lib.log(\"self-check OK\")\n        sys.exit(0)\n\n    try:\n        event = json.load(sys.stdin)\n    except (json.JSONDecodeError, EOFError):\n        event = {}\n\n    raw_cwd = (\n        event.get(\"cwd\")\n        or event.get(\"workingDirectory\")\n        or os.getcwd()\n    )\n    cwd = Path(raw_cwd)\n\n    violations = check_repo(cwd)\n\n    if violations:\n        for v in violations:\n            _lib.log(v)\n            print(v, flush=True)\n    else:\n        _lib.log(\"dirty-tree-guard: clean\")\n\n\nif __name__ == \"__main__\":\n    main()\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "governance"
}