Chapter 20 — Git, GitHub, and the Project Workflow: A Time Machine for Your Code

Series I1 — Tidy and Maintainable Code

NoteIn this chapter, you will:
  • Understand git: saving version history with commits, seeing differences, and undoing changes
  • Work in branches, join them with merge, and resolve conflicts
  • Keep secrets out of the repo with .gitignore
  • Meet GitHub: repos, issues, pull requests, and reviews, plus basic GitHub Actions
  • Close Series I1: the inventory project becomes classes, tested, tidy, and kept in git

All scripts for this chapter are in the kode/ch20/ folder. On the web version, you can also download them all at once (zip).

Have you ever saved report_final.docx, then report_final2.docx, then report_final_CORRECT.docx? That is the manual way to track versions, and it gets messy fast. Git is a tool that does it neatly: it records every change as history, so you can see who changed what and when, and go back to an old state if something goes wrong. Picture a garden ledger: every correction is written down with its date, never erased.

Safety rules of this chapter. Every git command in this book runs in a temporary repo in the _tmp/ folder, with settings kept apart from your git configuration. No GitHub repo is created, nothing is pushed, and no account is used. The GitHub part is only explained.

Git branches: a side track, then joined back to the main track.

Basic git concepts

Term Meaning
repo (repository) a project folder whose history git tracks (it holds a hidden .git folder)
commit one “photo” of the project’s state, with a message about what changed
staging (git add) choosing which changes go into the next commit
branch a separate line of history; the main branch is usually called main
merge joining another branch into the branch that is active
remote a copy of the repo somewhere else, for instance GitHub

The flow: you change files, choose them with git add, then git commit to save them into history. The command git status tells you where you stand. (Git records the name and email of the commit’s author. On your computer, set them once with git config --global user.name "Your Name" and git config --global user.email "you@example.com". This book’s scripts do not touch those settings: their test identity is set through environment variables.)

01_init_commit.py
# Chapter 20 - git: a new repo, the first file, the first commit (in a temporary repo)
from helpers import git, new_repo, write

repo = new_repo("basic_repo")
write(repo, "harvest.csv", "block,yield_kg\nA,245.5\nB,310.0\n")

print("Initial status:", git("status", "--short", repo=repo)[0])
git("add", "harvest.csv", repo=repo)
print("After add     :", git("status", "--short", repo=repo)[0])
git("commit", "--quiet", "-m", "Record harvest of blocks A and B", repo=repo)
print("After commit  :", repr(git("status", "--short", repo=repo)[0]))

print("--- git log ---")
print(git("log", "--format=%an: %s", repo=repo)[0])
Initial status: ?? harvest.csv
After add     : A  harvest.csv
After commit  : ''
--- git log ---
Test: Record harvest of blocks A and B

Trace the three status lines: ?? means the file is not yet tracked, A means it is added (waiting for a commit), and empty text after the commit means no pending changes. The command git log --format=... shows the history: one commit with its message.

Seeing differences and undoing

02_change_and_diff.py
# Chapter 20 - Changing a file, seeing the difference (diff), and undoing changes
from helpers import git, new_repo, write

repo = new_repo("diff_repo")
write(repo, "harvest.csv", "block,yield_kg\nA,245.5\nB,310.0\n")
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Record harvest of blocks A and B", repo=repo)

write(repo, "harvest.csv", "block,yield_kg\nA,245.5\nB,312.0\nC,198.5\n")
print("Status:", git("status", "--short", repo=repo)[0])

text, _ = git("diff", repo=repo)
print("--- git diff ---")
print("\n".join(line for line in text.splitlines() if not line.startswith("index ")))

git("commit", "--quiet", "-a", "-m", "Correct block B and add block C", repo=repo)
print("--- history ---")
print(git("log", "--format=%s", repo=repo)[0])

write(repo, "harvest.csv", "typing mistake\n")
git("restore", "harvest.csv", repo=repo)                # throws away uncommitted changes
print("--- after git restore ---")
print((repo / "harvest.csv").read_text(encoding="utf-8"), end="")
Status:  M harvest.csv
--- git diff ---
diff --git a/harvest.csv b/harvest.csv
--- a/harvest.csv
+++ b/harvest.csv
@@ -1,3 +1,4 @@
 block,yield_kg
 A,245.5
-B,310.0
+B,312.0
+C,198.5
--- history ---
Correct block B and add block C
Record harvest of blocks A and B
--- after git restore ---
block,yield_kg
A,245.5
B,312.0
C,198.5
  • M (a space then M) means the file has been modified but not yet added.
  • git diff shows the difference: lines starting with - were removed, + added. Here B,310.0 became B,312.0 and the block C line was added. (The technical line starting with index is left out of the display.)
  • git commit -a saves all changed files that are already tracked, without a separate git add.
  • git restore file throws away uncommitted changes. The end result: the file returns to the last commit’s contents, so the “typing mistake” is gone. Careful: changes thrown away this way cannot be recovered.

Branch and merge

A branch lets you work on a new feature on a side track without disturbing main. If it works, the track is joined. If it fails, just throw it away.

03_branch_merge.py
# Chapter 20 - Branches and merge: work on a side track, then join
from helpers import git, new_repo, tidy, write

repo = new_repo("branch_repo")
write(repo, "units.txt", "forest plot\ngarden\n")
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Initial list of units", repo=repo)

git("switch", "--quiet", "-c", "pond-feature", repo=repo)
print("Active branch:", git("branch", "--show-current", repo=repo)[0])
write(repo, "pond.txt", "pond A: 800 fish\n")
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Add pond data", repo=repo)

git("switch", "--quiet", "main", repo=repo)
print("On main, does pond.txt exist?", (repo / "pond.txt").exists())

write(repo, "units.txt", "forest plot\ngarden\npen\n")
git("commit", "--quiet", "-a", "-m", "Add pen", repo=repo)

text, _ = git("merge", "--no-edit", "pond-feature", repo=repo)
print("--- git merge ---")
print(text.splitlines()[0])
print("On main, does pond.txt exist?", (repo / "pond.txt").exists())
print("--- git log --graph ---")
print(tidy(git("log", "--graph", "--format=%s", repo=repo)[0]))
Active branch: pond-feature
On main, does pond.txt exist? False
--- git merge ---
Merge made by the 'ort' strategy.
On main, does pond.txt exist? True
--- git log --graph ---
*   Merge branch 'pond-feature'
|\
| * Add pond data
* | Add pen
|/
* Initial list of units

Note the interesting part: before the merge, pond.txt does not exist on main (it lives on the pond-feature branch). After the merge, it does. The git log --graph diagram shows the shape: two tracks (the * on the left and the * on the right) that split from the commit “Initial list of units” and meet at the merge commit. The change on main (“Add pen”) and on the branch (“Add pond data”) touch different files, so git joins them by itself.

Conflict: when two people change the same line

What if both tracks change the same line? Git does not dare to choose; it stops and asks a human to decide. This is called a conflict, and it is not a sign you did wrong but an ordinary thing in teamwork.

04_conflict.py
# Chapter 20 - Merge conflict: two branches change the same line
from helpers import git, new_repo, tidy, write

repo = new_repo("conflict_repo")
write(repo, "pond.txt", "pond A\naverage weight: 500 g\n")
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Initial pond data", repo=repo)

git("switch", "--quiet", "-c", "weighing-fix", repo=repo)
write(repo, "pond.txt", "pond A\naverage weight: 540 g\n")
git("commit", "--quiet", "-a", "-m", "Weight from re-weighing: 540 g", repo=repo)

git("switch", "--quiet", "main", repo=repo)
write(repo, "pond.txt", "pond A\naverage weight: 520 g\n")
git("commit", "--quiet", "-a", "-m", "Weight from old notes: 520 g", repo=repo)

text, code = git("merge", "--no-edit", "weighing-fix", repo=repo)
print("--- git merge ---")
print(text)
print("exit code:", code)
print("--- contents of pond.txt during the conflict ---")
print((repo / "pond.txt").read_text(encoding="utf-8"), end="")
print("--- status ---")
print(git("status", "--short", repo=repo)[0])

# Resolution: a human decides which number is right, then removes the markers
write(repo, "pond.txt", "pond A\naverage weight: 540 g\n")
git("add", "pond.txt", repo=repo)
git("commit", "--quiet", "--no-edit", repo=repo)
print("--- after resolving ---")
print(tidy(git("log", "--graph", "--format=%s", repo=repo)[0]))
--- git merge ---
Auto-merging pond.txt
CONFLICT (content): Merge conflict in pond.txt
Automatic merge failed; fix conflicts and then commit the result.
exit code: 1
--- contents of pond.txt during the conflict ---
pond A
<<<<<<< HEAD
average weight: 520 g
=======
average weight: 540 g
>>>>>>> weighing-fix
--- status ---
UU pond.txt
--- after resolving ---
*   Merge branch 'weighing-fix'
|\
| * Weight from re-weighing: 540 g
* | Weight from old notes: 520 g
|/
* Initial pond data

How to read the conflicted file:

  • <<<<<<< HEAD to ======= is the version on your current branch (main: 520 g from old notes).
  • ======= to >>>>>>> weighing-fix is the version from the branch being merged (540 g from re-weighing).
  • The status UU means both sides changed this file and it is not yet resolved.

To resolve it: edit the file, remove the three marker lines, choose (or combine) the right content, then git add and git commit. The script picks 540 g because that is the re-weighing result. The decision of which number is right can only be made by a human who knows the reality in the field. The final history shows the merge commit that joins the two.

.gitignore and guarding secrets

Not every file belongs in git: the .venv folder (Chapter 17), temporary files, big downloads, and above all secrets such as passwords and API keys. The list git ignores is written in the .gitignore file. Secrets are best kept in an ignored .env file, or in environment variables, and read by the program through os.environ.

05_gitignore_secrets.py
# Chapter 20 - .gitignore and keeping secrets out of the repo (the key value here is only a stand-in)
from helpers import git, new_repo, write

repo = new_repo("secret_repo")
write(repo, ".env", "API_KEY=[redacted]\n")
write(repo, "app.py", "import os\nkey = os.environ['API_KEY']\n")
write(repo, "_tmp/result.txt", "temporary file\n")

print("--- without .gitignore ---")
print(git("status", "--short", repo=repo)[0])

write(repo, ".gitignore", ".env\n_tmp/\n__pycache__/\n.venv/\n")
print("--- with .gitignore ---")
print(git("status", "--short", repo=repo)[0])
print("Why is .env ignored?", git("check-ignore", "-v", ".env", repo=repo)[0])

git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Start of project", repo=repo)
print("--- files tracked by git ---")
print(git("ls-files", repo=repo)[0])

print("=== A wrong step: the secret was already committed ===")
repo2 = new_repo("committed_repo")
write(repo2, ".env", "API_KEY=[redacted]\n")
git("add", ".", repo=repo2)
git("commit", "--quiet", "-m", "oops", repo=repo2)
write(repo2, ".gitignore", ".env\n")
git("add", ".gitignore", repo=repo2)
git("commit", "--quiet", "-m", "add .gitignore", repo=repo2)
print("Tracked after .gitignore was added:", git("ls-files", repo=repo2)[0].split())
print("The first commit still contains .env:", ".env" in git("show", "--stat", "--format=", "HEAD~1", repo=repo2)[0])
git("rm", "--cached", "--quiet", ".env", repo=repo2)
git("commit", "--quiet", "-m", "stop tracking .env", repo=repo2)
print("Tracked after git rm --cached:", git("ls-files", repo=repo2)[0].split())
print("But old history still contains it:", ".env" in git("show", "--stat", "--format=", "HEAD~2", repo=repo2)[0])
--- without .gitignore ---
?? .env
?? _tmp/
?? app.py
--- with .gitignore ---
?? .gitignore
?? app.py
Why is .env ignored? .gitignore:1:.env  .env
--- files tracked by git ---
.gitignore
app.py
=== A wrong step: the secret was already committed ===
Tracked after .gitignore was added: ['.env', '.gitignore']
The first commit still contains .env: True
Tracked after git rm --cached: ['.gitignore']
But old history still contains it: True

Part one: without .gitignore, git offers .env and _tmp/ for adding (??). With .gitignore, both vanish from the list. git check-ignore -v explains which rule applies: line 1 of .gitignore. Only .gitignore and app.py are tracked.

Part two is the most dangerous trap. If a secret was already committed, adding it to .gitignore is not enough: the file stays tracked (['.env', '.gitignore']). git rm --cached stops the tracking, but the old history still holds its contents (True). Anyone holding a copy of the repo can still read it.

WarningWARNING

A secret that has ever entered a commit must be treated as leaked, especially if the repo was ever sent to GitHub. The safest step (the author’s advice): revoke and replace the key at the service provider, then clean the history. Prevention is far easier than repair: make .gitignore before the first commit. GitHub’s documentation also describes push protection, a feature that blocks a push when a secret is detected; its availability depends on account and repo settings (read 2026-10-10), so do not make it your only fence.

GitHub: git for working together

GitHub is a website service that keeps git repos on the internet (git and GitHub are two different things: git is the tool, GitHub is one place to keep it). This part is explanation only; nothing is run, because creating a repo and sending code changes data on the internet and needs an account.

  • A repo on GitHub is connected to the local repo as a remote (usually called origin). git push sends commits there, git pull fetches other people’s changes.
  • An issue is a note about a task, idea, or bug in a repo. According to the GitHub documentation (read 2026-10-10), issues can carry labels and assignees, and closing keywords in a pull request can close the issue automatically when it is merged.
  • A pull request (PR) is a proposal to merge changes into the project. The Files changed tab shows the difference being reviewed, the Conversation tab holds discussion and reviews, and the merge status shows what still blocks it, such as missing approvals.
  • Review: a colleague reads your changes in the PR and comments, then you fix them before merging.
07_github_commands.py
# Chapter 20 - Commands to connect a local repo to GitHub (an example only; replace YOUR-NAME with your account name)
import subprocess

commands = [
    ["git", "remote", "add", "origin", "https://github.com/YOUR-NAME/inventory.git"],
    ["git", "push", "-u", "origin", "main"],              # send the main branch to GitHub
    ["git", "switch", "-c", "pond-feature"],              # work on a new branch
    ["git", "push", "-u", "origin", "pond-feature"],      # send the feature branch
    ["gh", "pr", "create", "--fill"],                     # open a pull request (GitHub CLI tool)
]
for c in commands:
    print("$", " ".join(c))
    subprocess.run(c, check=True)

This code was not run when the book was built because creating a repo and pushing code to GitHub needs a GitHub account and changes data on the internet, so no output is shown. The command gh pr create belongs to the GitHub CLI; its existence was checked through gh pr create --help, but it was not run.

The daily workflow

Even a small team can use this habit:

  1. Fetch the latest version (git pull).
  2. Make a branch for one job (git switch -c feature-name).
  3. Change the code, then run ruff check --fix ., ruff format ., and python -m pytest (Chapters 18 and 19). pre-commit can do it automatically at commit time.
  4. Commit with a message that explains what and why, not just “fix”.
  5. Push the branch, open a pull request, ask for review.
  6. Once approved and all checks are green, merge into main.

The habit “one branch, one purpose, small commits” keeps conflicts rare and history easy to read.

GitHub Actions: automatic checks on a server

Checks on your own computer can be skipped. GitHub Actions runs automatic checks on GitHub’s servers at every push or pull request. The flow is written in a YAML file in the .github/workflows/ folder (GitHub documentation, read 2026-10-10). Here is an example for our project:

name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out code
        uses: actions/checkout@v6
      - name: Set up Python
        uses: actions/setup-python@v7
        with:
          python-version: "3.12"
      - name: Install package and tools
        run: python -m pip install -e ".[dev]"
      - name: Check code style
        run: |
          ruff check .
          ruff format --check .
      - name: Run tests
        run: python -m pytest -q
  • on: sets the triggers: every push to main and every pull request.
  • jobs: holds the jobs; runs-on: ubuntu-latest picks a GitHub machine; steps: are the ordered steps. uses: applies a ready-made action, run: runs a command.
  • The action version numbers (@v6, @v7) were taken from the documentation on 2026-10-10 and may change; check the documentation when you use them.

This workflow was not run by GitHub when the book was built (there is no GitHub repo), so it is not proven to work. The author only checked its YAML syntax, which is all that can be proven here:

06_check_yaml.py
# Chapter 20 - Checking the syntax of a GitHub Actions workflow file (YAML syntax only; GitHub did not run it)
from pathlib import Path

import yaml

file = Path(__file__).parent / "example" / ".github" / "workflows" / "test.yml"
workflow = yaml.safe_load(file.read_text(encoding="utf-8"))

print("Workflow name:", workflow["name"])
print("Triggers (on):", sorted(workflow[True]))        # YAML reads the word 'on' as True
for job_name, job in workflow["jobs"].items():
    print(f"Job '{job_name}' runs on {job['runs-on']} with {len(job['steps'])} steps:")
    for step in job["steps"]:
        print("  -", step["name"])
Workflow name: Tests
Triggers (on): ['pull_request', 'push']
Job 'test' runs on ubuntu-latest with 5 steps:
  - Check out code
  - Set up Python
  - Install package and tools
  - Check code style
  - Run tests

The script uses the PyYAML library (python -m pip install pyyaml). The result shows the file reads correctly: one job holding five steps. A technical note: YAML reads the word on as True, which is why the key is written workflow[True] in the script.

Running Project I1: everything together

This is the summit of the series. The inventory package (classes from Chapter 16, installed in Chapter 17, tested in Chapter 18, tidied in Chapter 19) now lives in a git repo with a healthy workflow: checks, a branch, a merge.

08_final_project.py
# Chapter 20 - Running project I1: classes + tests + ruff + git in one workflow
import re
import shutil
import sys

from helpers import FOLDER, git, new_repo, run, tidy, write

repo = new_repo("project_i1")
shutil.copytree(FOLDER / "final_project", repo, dirs_exist_ok=True,
                ignore=shutil.ignore_patterns("__pycache__"))
write(repo, ".gitignore", ".venv/\n_tmp/\n__pycache__/\n.pytest_cache/\n.ruff_cache/\n.env\n")


def checks():
    """The three checks pre-commit would also run: ruff check, ruff format, pytest."""
    ruff = [sys.executable, "-m", "ruff"]
    a = run([*ruff, "check", "--no-cache", "."], repo)[0].splitlines()[-1]
    b = run([*ruff, "format", "--no-cache", "--check", "."], repo)[0].splitlines()[-1]
    c = run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider"], repo)[0]
    print("  ruff check :", a)
    print("  ruff format:", b)
    print("  pytest     :", re.sub(r" in \d+\.\d+s", "", c.splitlines()[-1]))


print("1. Checks on main")
checks()
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Package inventory: classes, tests, and configuration", repo=repo)

print("2. New branch: a test for RiceField")
git("switch", "--quiet", "-c", "rice-field-test", repo=repo)
write(repo, "tests/test_rice_field.py", '''import pytest

from inventory import Crop, RiceField


def test_rice_field_productivity():
    rice_field = RiceField("Lowland", 0.5)
    rice_field.add(Crop("rice", "1", 110, 1250.0))
    assert rice_field.productivity() == pytest.approx(2500)
''')
checks()
git("add", ".", repo=repo)
git("commit", "--quiet", "-m", "Add rice field productivity test", repo=repo)

print("3. Merge into main")
git("switch", "--quiet", "main", repo=repo)
git("merge", "--quiet", "--no-ff", "-m", "Merge branch rice-field-test", "rice-field-test", repo=repo)
checks()

print("--- history ---")
print(tidy(git("log", "--graph", "--format=%s", repo=repo)[0]))
print("Files tracked by git:", len(git("ls-files", repo=repo)[0].splitlines()))
print("Final status (empty = clean):", repr(git("status", "--short", repo=repo)[0]))
1. Checks on main
  ruff check : All checks passed!
  ruff format: 9 files already formatted
  pytest     : 14 passed
2. New branch: a test for RiceField
  ruff check : All checks passed!
  ruff format: 10 files already formatted
  pytest     : 15 passed
3. Merge into main
  ruff check : All checks passed!
  ruff format: 10 files already formatted
  pytest     : 15 passed
--- history ---
*   Merge branch rice-field-test
|\
| * Add rice field productivity test
|/
* Package inventory: classes, tests, and configuration
Files tracked by git: 12
Final status (empty = clean): ''

Trace it:

  • Step 1: on main, ruff passes, 9 files are tidy, and 14 tests pass. The first commit.
  • Step 2: on the branch rice-field-test, one new test file is added. All checks run before the commit: 10 files tidy and 15 tests pass.
  • Step 3: merge into main with --no-ff (always make a merge commit so the branch track shows in history), then the checks again: still 15 tests pass.
  • Git tracks 12 files: 10 package files, one new test file, and .gitignore. The final status is empty: nothing left behind.

Exercises

  1. Run 01_init_commit.py, then open the folder _tmp/basic_repo in a terminal and type git log and git status. Compare with the book’s output.
  2. Change 03_branch_merge.py so the second branch adds a file pen.txt. Does the merge still run without conflict? Why?
  3. Add .idea/ and *.log to the .gitignore in 05_gitignore_secrets.py, make a file debug.log, and check with git check-ignore -v debug.log.
  4. Challenge: change 04_conflict.py so the conflict is resolved by computing the average of the two numbers (530 g) rather than choosing one. Why can git not make a decision like this by itself?

Chapter Summary

  • git records history: git add chooses, git commit saves, git status and git diff inspect, git restore throws away uncommitted changes.
  • A branch = a side track; merge joins it. A conflict appears when the same line is changed; a human decides, then commits.
  • .gitignore keeps temporary files and secrets away. A secret that was committed must be treated as leaked.
  • GitHub keeps repos on the internet; issues, pull requests, and reviews organise teamwork. This part is only explained, not run.
  • GitHub Actions runs automatic checks from a YAML file in .github/workflows/; the example here only had its syntax checked.
  • The daily flow: branch, change, ruff, tests, commit, pull request, review, merge.

Closing Series I1

Congratulations! You have finished Series I1 and passed the first gate of the Intermediate level. Your ability has shifted from “writing a program that runs” to “building a project that is maintained”:

  • Turning field data into classes: Unit and its children, with inheritance, polymorphism, and dataclass (Chapter 16).
  • Giving every project its own environment with venv, pip, uv, and pyproject.toml (Chapter 17).
  • Guarding code with pytest tests (Chapter 18) and ruff, pre-commit, and logging (Chapter 19).
  • Keeping history and working together with git and the GitHub workflow (Chapter 20).

Your inventory project is now tidy and safe to change. But all its data is still made-up data we typed ourselves. In Series I2, your program starts to fetch and process data from outside: the internet, large files, and databases.

Next, in Chapter 21: working with APIs, how a program asks a service on the internet for data and reads the reply.