{
  "version": 1,
  "id": "github-workflow",
  "title": "GitHub Workflow and Pull Requests",
  "summary": "Fork, branch, review and merge: the pull-request workflow and branch protection.",
  "estimatedMinutes": 25,
  "prerequisites": [],
  "source": "courses-notebook ENV202_git_mastery 05_github_workflow",
  "lesson": "## 5.1  Fork & Pull Request Model\n\n```bash\n# 1. Fork on GitHub, then clone\ngit clone git@github.com:you/repo.git\ngit remote add upstream git@github.com:original/repo.git\n\n# 2. Create feature branch\ngit switch -c feature/add-neovim-course\n\n# 3. Make commits\ngit add -p                    # interactive staging (recommended)\ngit commit -m 'feat: add neovim course ENV102'\n\n# 4. Keep in sync\ngit fetch upstream\ngit rebase upstream/main\n\n# 5. Push and open PR\ngit push origin feature/add-neovim-course\ngh pr create --title 'Add ENV102 Neovim course' \\\n             --body 'Closes #123'\n```\n\n## 5.2  GitHub CLI (gh)\n\n```bash\ngh repo clone user/repo\ngh pr create\ngh pr list\ngh pr checkout 42\ngh pr merge --squash\ngh issue create\ngh issue list\ngh workflow run\ngh release create v1.0.0\n```\n\n## 5.3  Commit Message Convention\n\n```\n<type>: <subject>\n\n<body>\n\n<footer>\n```\n\nTypes: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`\n\nExamples:\n```\nfeat: add ENV102 Neovim mastery course\nfix: correct broken TOC references in semester 5\ndocs: update CALIBRE_MAPPING with new books\nchore: run generate_all_notebooks for expansion\n```\n\n```python\nimport subprocess\n\ndef git(cmd):\n    r = subprocess.run(f'git {cmd}', shell=True, capture_output=True, text=True)\n    return r.stdout.strip()\n\nprint('=== Repository Info ===')\nprint('Remotes:')\nprint(git('remote -v'))\nprint()\nprint('Recent commits (conventional format):')\nprint(git('log --oneline -8'))\n```\n\n## GitHub CLI and PR Workflow\n\n```bash\n# GitHub CLI (gh)\ngh auth login              # authenticate\ngh repo create my-project --public  # create repo\ngh pr create --fill        # PR from current branch (fill from commits)\ngh pr list                 # list open PRs\ngh pr checkout 123         # check out PR locally\ngh pr review 123 --approve\ngh pr merge 123 --squash   # squash and merge\n\n# CI/CD with GitHub Actions\n# .github/workflows/test.yml\n# name: Test\n# on: [push, pull_request]\n# jobs:\n#   test:\n#     runs-on: ubuntu-latest\n#     steps:\n#       - uses: actions/checkout@v4\n#       - uses: astral-sh/setup-uv@v2\n#       - run: uv sync && uv run pytest\n```\n\n```python\nactions_workflow = '''\n# .github/workflows/ci.yml\nname: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.11\", \"3.12\"]\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v2\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install dependencies\n        run: uv sync --dev\n\n      - name: Lint\n        run: uv run ruff check . && uv run ruff format --check .\n\n      - name: Type check\n        run: uv run mypy .\n\n      - name: Test\n        run: uv run pytest --cov=src --cov-report=xml\n\n      - uses: codecov/codecov-action@v3\n'''\nprint(actions_workflow)\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- **Secrets in CI**: never hardcode secrets in workflow YAML \u2014 use `secrets.MY_SECRET` and add in repo Settings\n- **Action pinning**: `uses: actions/checkout@v4` is floating \u2014 pin to SHA for security: `uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683`\n\n## Exercises\n1. Set up a CI pipeline that runs tests on PR and deploys on main merge\n2. Add a release workflow: tag \u2192 build \u2192 publish to PyPI using trusted publishing\n3. Configure branch protection: require 2 reviews, passing CI, no direct push to main\n\n## Key Takeaways\n\n- **`gh pr create --fill`**: GitHub CLI creates a PR from the current branch, filling title and body from commit messages \u2014 eliminates switching to the browser.\n- **`git add -p` (interactive staging)**: stages individual hunks from changed files \u2014 the best practice for creating atomic, focused commits from multi-purpose edits.\n- **GitHub Actions `on: pull_request`**: triggers CI on every PR \u2014 the standard way to enforce tests, lint, and type checking before merge.\n- **`actions/checkout@SHA` (pinned)**: pin action versions to a full SHA not a tag \u2014 tags are mutable and floating tags are a supply-chain attack vector.\n- **`secrets.MY_SECRET`**: access repository secrets in workflow YAML \u2014 never hardcode tokens or credentials in workflow files committed to the repo.\n\n## \ud83e\udde0 Quick Quiz\n\n**Q1.** GitHub Flow is a branching strategy where:\n- A) Releases use long-lived release branches\n- B) **`main` is always deployable; features are done in short-lived branches merged via PRs** \u2713\n- C) Developers commit directly to `main`\n- D) Hotfixes use a dedicated `hotfix/` branch hierarchy\n\n**Q2.** A GitHub Actions workflow is triggered by:\n- A) Only `git push`\n- B) **Events: push, pull_request, schedule, workflow_dispatch, and many others** \u2713\n- C) Only manual triggers\n- D) Merges to `main` only\n\n**Q3.** Branch protection rules enforce:\n- A) Code style conventions\n- B) **Required reviews, status checks, and restrictions before merging** \u2713\n- C) Commit message formats\n- D) Maximum PR size\n\n<details><summary>Answers</summary>1-B, 2-B, 3-B</details>\n",
  "quiz": []
}
