{
  "version": 1,
  "id": "git-hooks-and-automation",
  "title": "Git Hooks and Automation",
  "summary": "Client and server hooks for linting, commit checks and automation, and their limits.",
  "estimatedMinutes": 25,
  "prerequisites": [],
  "source": "courses-notebook ENV202_git_mastery 04_git_hooks",
  "quiz": [
    {
      "prompt": "Which git hook runs locally before a commit is finalized and is commonly used to lint and format code?",
      "options": [
        "post-commit",
        "pre-receive",
        "pre-commit",
        "update"
      ],
      "answer": 2
    }
  ],
  "lesson": "## 4.1  What Are Git Hooks?\n\nScripts in `.git/hooks/` that run at specific git events:\n\n| Hook | When | Common Use |\n|------|------|------------|\n| `pre-commit` | Before commit | Lint, format, tests |\n| `commit-msg` | After msg entered | Validate format |\n| `pre-push` | Before push | Run full test suite |\n| `post-merge` | After pull/merge | `uv sync` |\n\n## 4.2  Example pre-commit Hook\n\n```bash\n#!/usr/bin/env bash\n# .git/hooks/pre-commit\nset -e\n\necho \"Running pre-commit checks...\"\n\n# Run ruff linter\nif command -v ruff &>/dev/null; then\n  ruff check . || { echo \"Lint failed\"; exit 1; }\nfi\n\n# Check for debug prints\nif git diff --cached | grep -E '^\\+.*print\\(' | grep -v '#'; then\n  echo \"Warning: print() statements in commit\"\n  # uncomment to make this blocking:\n  # exit 1\nfi\n\necho \"Pre-commit OK\"\n```\n\n## 4.3  pre-commit Framework (Recommended)\n\n```bash\npip install pre-commit\n```\n\n```yaml\n# .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.3.0\n    hooks:\n      - id: ruff\n      - id: ruff-format\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.5.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-json\n      - id: check-yaml\n```\n\n```bash\npre-commit install    # install hooks\npre-commit run --all-files  # run manually\n```\n\n```python\nimport os, pathlib\n\nhook_dir = pathlib.Path('.git/hooks')\nif hook_dir.exists():\n    hooks = list(hook_dir.iterdir())\n    print('=== Git Hooks ===')\n    for h in sorted(hooks):\n        executable = os.access(h, os.X_OK)\n        status = '\u2713 active' if executable and not h.name.endswith('.sample') else '\u25cb sample'\n        print(f'  {status}  {h.name}')\nelse:\n    print('Not in a git repo with hooks directory')\n```\n\n## Git Hooks for Automation\n\n```bash\n# Hooks live in .git/hooks/ (local, not tracked)\n# Use pre-commit framework for shareable hooks\n\n# .pre-commit-config.yaml\nrepos:\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.1.0\n    hooks:\n      - id: ruff\n        args: [--fix]\n      - id: ruff-format\n\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: v4.5.0\n    hooks:\n      - id: trailing-whitespace\n      - id: end-of-file-fixer\n      - id: check-yaml\n      - id: check-merge-conflict\n      - id: detect-private-key    # prevent accidental secret commits\n\n# Install hooks\npre-commit install            # installs .git/hooks/pre-commit\npre-commit run --all-files    # run on all files (once)\n```\n\n```python\nprecommit_script = '''#!/usr/bin/env bash\n# .git/hooks/pre-commit (manual, without pre-commit framework)\nset -euo pipefail\n\necho \"Running pre-commit checks...\"\n\n# 1. Check for debug statements\nif git diff --cached --name-only | xargs grep -l \"import pdb\\|breakpoint()\\|debugger;\" 2>/dev/null; then\n    echo \"ERROR: Debug statements found \u2014 remove before committing\"\n    exit 1\nfi\n\n# 2. Check for large files (> 1MB)\nfor file in $(git diff --cached --name-only); do\n    if [[ -f \"$file\" ]] && [[ $(stat -f%z \"$file\" 2>/dev/null || stat -c%s \"$file\") -gt 1048576 ]]; then\n        echo \"ERROR: Large file detected: $file (> 1MB)\"\n        exit 1\n    fi\ndone\n\n# 3. Run tests for changed Python files\nchanged_py=$(git diff --cached --name-only --diff-filter=ACM | grep '\\\\.py$' || true)\nif [[ -n \"$changed_py\" ]]; then\n    python -m pytest --tb=short -q tests/ || { echo \"Tests failed\"; exit 1; }\nfi\n\necho \"Pre-commit checks passed.\"\n'''\nprint(precommit_script)\n```\n\n```python\n# Verification helpers \u2014 exercise checker\n\ndef assert_equal(got, want, label=\"\"):\n    status = \"\u2713\" if got == want else f\"\u2717 got={got!r} want={want!r}\"\n    print(f\"  {label or 'test'}: {status}\")\n\ndef assert_approx(got, want, tol=1e-6, label=\"\"):\n    diff = abs(got - want)\n    status = \"\u2713\" if diff < tol else f\"\u2717 diff={diff:.2e}\"\n    print(f\"  {label or 'test'}: {status}\")\n\ndef run_tests(fn, cases):\n    \"\"\"cases: list of (args_tuple, expected)\"\"\"\n    passed = sum(1 for args, want in cases\n                 if (result := fn(*args) if isinstance(args, tuple) else fn(args)) == want\n                 or (print(f\"  FAIL {fn.__name__}{args}: got {result!r}, want {want!r}\"), False))\n    print(f\"{fn.__name__}: {passed}/{len(cases)} passed {'\u2713' if passed==len(cases) else '\u2717'}\")\n\n# \u2500\u2500\u2500 Example: test your implementations below \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ndef flatten(nested):\n    \"\"\"Flatten arbitrarily nested lists.\"\"\"\n    result = []\n    for item in nested:\n        if isinstance(item, list):\n            result.extend(flatten(item))\n        else:\n            result.append(item)\n    return result\n\nrun_tests(flatten, [\n    (([1,[2,[3,[4]]],5],),  [1,2,3,4,5]),\n    (([],),                  []),\n    (([[1],[2],[3]],),        [1,2,3]),\n])\n\ndef chunk(lst, size):\n    \"\"\"Split list into chunks of given size.\"\"\"\n    return [lst[i:i+size] for i in range(0, len(lst), size)]\n\nrun_tests(chunk, [\n    (([1,2,3,4,5], 2),  [[1,2],[3,4],[5]]),\n    (([1,2,3], 3),       [[1,2,3]]),\n    (([], 2),            []),\n])\n```\n\n```python\n# Advanced: Git object model \u2014 blob, tree, commit simulation\nimport hashlib, json\nfrom dataclasses import dataclass, field\nfrom typing import Optional\n\ndef sha1(content: str) -> str:\n    return hashlib.sha1(content.encode()).hexdigest()[:8]\n\n@dataclass\nclass GitObject:\n    kind:    str   # blob | tree | commit\n    content: str\n    hash:    str = field(init=False)\n    def __post_init__(self):\n        self.hash = sha1(f\"{self.kind}:{self.content}\")\n\nclass GitRepo:\n    def __init__(self):\n        self.objects: dict[str, GitObject] = {}\n        self.refs:    dict[str, str] = {\"HEAD\": None}\n        self.index:   dict[str, str] = {}   # filename \u2192 blob hash\n\n    def hash_object(self, kind: str, content: str) -> str:\n        obj = GitObject(kind=kind, content=content)\n        self.objects[obj.hash] = obj\n        return obj.hash\n\n    def add(self, filename: str, content: str) -> str:\n        blob_hash = self.hash_object(\"blob\", content)\n        self.index[filename] = blob_hash\n        print(f\"  staged {filename} \u2192 blob {blob_hash}\")\n        return blob_hash\n\n    def commit(self, message: str, author: str = \"dev\") -> str:\n        tree_content = json.dumps(dict(sorted(self.index.items())))\n        tree_hash    = self.hash_object(\"tree\", tree_content)\n        parent       = self.refs.get(\"HEAD\")\n        commit_data  = json.dumps({\"tree\": tree_hash, \"parent\": parent,\n                                   \"author\": author, \"message\": message})\n        commit_hash  = self.hash_object(\"commit\", commit_data)\n        self.refs[\"HEAD\"] = commit_hash\n        print(f\"  commit {commit_hash}  '{message}'  tree={tree_hash}\")\n        return commit_hash\n\n    def log(self):\n        cur = self.refs.get(\"HEAD\")\n        while cur:\n            obj = self.objects.get(cur)\n            if not obj: break\n            data = json.loads(obj.content)\n            print(f'  commit {cur}  \"{data['message']}\"  tree={data['tree']}')\n            cur  = data.get(\"parent\")\n\nrepo = GitRepo()\nprint(\"=== git add ===\")\nrepo.add(\"README.md\",  \"# My Project\")\nrepo.add(\"main.py\",    \"print('hello')\")\nprint(\"=== git commit ===\")\nrepo.commit(\"Initial commit\")\n\nrepo.add(\"main.py\", \"print('hello world')\")   # modify file\nrepo.commit(\"Update main.py\")\n\nprint(\"=== git log ===\")\nrepo.log()\nprint(f\"=== Objects stored: {len(repo.objects)} ===\")\nfor h, obj in list(repo.objects.items())[:6]:\n    print(f\"  {h}  {obj.kind}\")\n```\n\n## Git Quick Reference\n\n```bash\n# Daily workflow\ngit status -s                     # compact status\ngit diff --staged                 # review before commit\ngit commit -m \"type: description\" # conventional commit\ngit push -u origin feature/name   # first push\n\n# Branching\ngit switch -c feature/name        # create + switch\ngit switch main && git pull        # update main\ngit rebase main                    # rebase feature onto main\ngit merge --no-ff feature/name     # merge with history\n\n# Undoing things (safe)\ngit restore <file>                 # discard working tree changes\ngit restore --staged <file>        # unstage\ngit revert HEAD                    # new commit that undoes last\n\n# History inspection\ngit log --oneline --graph --all    # visual branch graph\ngit log -p --follow -- <file>      # file history with diffs\ngit blame -L 10,20 <file>          # who changed lines 10-20\ngit bisect start                   # binary search for bug commit\n\n# Stash\ngit stash push -m \"WIP: feature\"\ngit stash list\ngit stash pop                      # restore latest\n\n# Remote\ngit remote -v\ngit fetch --prune                  # remove stale remote branches\ngit pull --rebase origin main      # rebase instead of merge pull\n```\n\n**Commit types (Conventional Commits):**\n`feat` \u00b7 `fix` \u00b7 `refactor` \u00b7 `docs` \u00b7 `test` \u00b7 `chore` \u00b7 `perf` \u00b7 `ci`\n\n## Failure Modes\n\n- **Hooks not executable**: hooks must be `chmod +x .git/hooks/pre-commit`; the pre-commit framework handles this\n- **Bypassing hooks**: `git commit --no-verify` skips all hooks \u2014 should be exceptional; CI should enforce the same checks independently\n\n## Exercises\n1. Set up `pre-commit` with ruff, mypy, and detect-private-key\n2. Write a commit-msg hook that enforces conventional commit format: `type(scope): description`\n3. Add a post-merge hook that automatically runs `uv sync` when `pyproject.toml` changes\n\n## Key Takeaways\n\n- **`.git/hooks/` scripts**: executable scripts triggered by git events \u2014 `pre-commit`, `commit-msg`, `pre-push`, `post-merge` are the most commonly automated hooks.\n- **`pre-commit` framework**: manages shareable hooks via `.pre-commit-config.yaml` \u2014 installs hooks from public repos (ruff, mypy, detect-private-key) with version pinning.\n- **`pre-commit install`**: writes the hook into `.git/hooks/pre-commit` \u2014 must be run once per clone; add to `make install` so new contributors get it automatically.\n- **`commit-msg` hook**: validates that commit messages match a pattern (e.g. Conventional Commits `type: description`) \u2014 enforces team standards before the commit is created.\n- **`git commit --no-verify`**: bypasses all hooks \u2014 should be exceptional; duplicate the same checks in CI so bypassed hooks don't let bad code reach main.\n\n## \ud83e\udde0 Quick Quiz\n\n**Q1.** `pre-commit` git hooks run:\n- A) After code is pushed to remote\n- B) **Before the commit is created \u2014 can reject the commit if they exit non-zero** \u2713\n- C) Before merging branches\n- D) During rebasing\n\n**Q2.** `commit-msg` hook validates:\n- A) File content\n- B) **The commit message format** \u2713\n- C) Test results\n- D) Branch name\n\n**Q3.** `pre-push` hook is useful for:\n- A) Formatting code before commits\n- B) **Running tests before allowing a push to remote** \u2713\n- C) Signing commits\n- D) Creating release tags\n\n<details><summary>Answers</summary>1-B, 2-B, 3-B</details>\n"
}
