5 Git, GitHub, and CI/CD
5.5 The Three Spaces: Working Directory, Staging Area, Repository |
5.1 Learning Objectives
By the end of this lecture you should be able to:
Explain what a version control system is and describe Git’s data model—
blobs, trees, commits, and references— well enough to predict what a Git command does to the underlying object graph. Explain why Git history is a directed acyclic graph (DAG) rather than a simple list, and what it means for a commit to be immutable and content-addressed.
Use the core workflow—
git status, git add, git commit, git log, git diff— and explain the role of the staging area in each step. Create, switch, merge, and delete branches; distinguish a fast-forward merge from a three-way merge; and resolve a merge conflict by hand.
Work with remote repositories: clone, fetch, pull, and push, and explain the difference between main, origin/main, and the main branch on the server.
Distinguish Git from GitHub, and use the fork-and-pull-request model to propose, review, and merge changes.
Define continuous integration and continuous delivery, and explain what problems they solve on a team.
Write a GitHub Actions workflow that runs a linter and a test suite on every push and pull request, read its logs when it fails, and use it as a merge gate.
5.2 Why Version Control?
Version control systems (VCSs) are tools that track changes to source code, or to any other collection of files and folders. As the name implies, these tools maintain a history of changes, and they make collaboration possible. A VCS tracks a folder and its contents as a series of snapshots, where each snapshot captures the entire state of every file under a top-level directory. Alongside the snapshots it keeps metadata: who created each snapshot, when, and a message saying why.
Why is that useful? Even working alone, a VCS lets you look at old versions of a project, keep a log of why changes were made, work on parallel lines of development, and recover from your own mistakes. Working with others, it is the tool that shows you what your teammates changed and resolves the collisions when two people edit the same file. A modern VCS lets you answer questions like these, often automatically:
Who wrote this module?
When was this particular line of this particular file last edited? By whom? Why?
Over the last 1000 revisions, when—
and why— did this unit test stop working?
There is a second reason version control matters in this
course specifically. Every assignment you submit, every project you
deploy, and every AI-generated patch you accept will flow through
Git. When an AI assistant writes code for you, version control is
your safety net and your audit trail: a small, well-described commit
is the unit at which you can review, test, and—
5.2.1 Git, and Git vs. GitHub
Git is a distributed version control system,
originally created in 2005 for Linux kernel development.
“Distributed” means every developer has a complete copy of
the repository—
A common point of confusion: Git is not GitHub. Git is the version control software that runs on your machine. GitHub is a website that hosts Git repositories in the cloud and adds collaboration features on top: pull requests, issues, code review, and the Actions automation platform we meet at the end of this lecture. There are alternatives to GitHub (GitLab, Bitbucket) and alternatives to Git (Mercurial, SVN), but Git plus GitHub is the de facto standard, and it is what we use.
5.2.2 Why learn the theory first?
Git has a famously awkward command-line interface. Learning it
top-down—
We will do the opposite. Git’s interface is ugly, but its
underlying design is beautiful, and while an ugly interface
must be memorized, a beautiful design can be understood. We start
with the data model; once you can picture the object graph, every
command becomes “which arrows does this move?”—
5.3 The Running Example: TerpTasks Goes Multiplayer
In the last lecture you built TerpTasks, a task-tracking API for UMD students. So far it has lived in a single directory on your laptop, with no history. This lecture fixes that, in stages that mirror the lecture’s structure:
You put TerpTasks under version control and build a commit history (the core workflow).
You develop a new feature—
task search— on a branch, while fixing a bug on main in parallel (branching and merging). Your project partner Jordan joins. You push the repository to GitHub, Jordan clones it, and you learn what happens when both of you edit the same line (remotes, conflicts).
You adopt the pull-request workflow so every change is reviewed before it lands (GitHub).
You wire up GitHub Actions so the test suite runs automatically on every pull request, and a failing test blocks the merge (CI/CD).
By the end, TerpTasks will look like a real open-source project: public history, reviewed changes, and a green checkmark on every commit.
5.4 Git’s Data Model
Git’s ingenuity is its well-thought-out data model, which enables
all the nice features of version control—
5.4.1 Snapshots, not diffs
Git models history as a sequence of snapshots: complete states of the entire tracked directory tree at a moment in time.
In plain English: every commit conceptually stores your whole project, not a list of edits.
Common misconception. Most newcomers assume Git stores
deltas—
Why it matters. Snapshot thinking explains otherwise-magic behavior: checking out an old commit restores everything instantly (it is just “load that snapshot”), and two identical files cost almost nothing extra to store, as we are about to see.
5.4.2 Blobs: file contents
A blob (binary large object) is
the contents of a file—
In plain English: a blob is a file’s contents with no name attached.
The same content always produces the same hash, so the same blob. If README.md and docs/intro.md have identical contents, Git stores one blob. Blobs are immutable: editing a file does not change a blob, it produces a new blob with a new hash, leaving the original untouched.
5.4.3 Trees: directories
A tree maps names to blobs or to other trees.
In plain English: a tree is a directory listing. It supplies what blobs lack: file names, and structure. A snapshot is simply the top-level tree of the project. For TerpTasks:
<root> (tree) |
| |
+- app (tree) |
| | |
| +- main.py (blob, contents = "from fastapi import ...") |
| +- models.py (blob, contents = "from pydantic import ...") |
| |
+- tests (tree) |
| | |
| +- test_api.py (blob) |
| |
+- README.md (blob, contents = "# TerpTasks ...") |
5.4.4 Commits: snapshots with ancestry
A commit is an object containing (1) a pointer to the tree that is the project snapshot, (2) pointers to zero or more parent commits, (3) author metadata, and (4) a message explaining the change.
In plain English: a commit is a saved snapshot plus a note about where it came from and why it exists.
Because each commit points to its parent(s), the commits form a
history. How should snapshots relate? The simplest model would be
a linear list, one snapshot after another in time order. Git
deliberately does not use that model. In Git, history is a
directed acyclic graph (DAG) of commits. That sounds like a
fancy math term, but all it means is: each commit refers to a
set of parents—
Here is a commit history; each o is a commit, and arrows point to parents (a “comes before” relation):
o <-- o <-- o <-- o |
^ |
\ |
--- o <-- o |
After the third commit, history splits in two: perhaps you were adding task search while Jordan fixed a date bug, each working independently. Later, the branches can be merged, producing a commit with two parents (shown as m):
o <-- o <-- o <-- o <------ m |
^ / |
\ v |
--- o <-- o |
Commits are immutable. This does not mean mistakes cannot be
fixed; it means “editing” history actually creates new
commits, and the pointers (next subsection) are moved to the new
ones. Every command that claims to change a commit—
5.4.5 The data model as pseudocode
The whole model fits on an index card. It may be instructive to see it written down:
# a file is a bunch of bytes
blob = bytes
# a directory maps names to files or other directories
tree = dict[str, tree | blob]
# a commit is a snapshot plus ancestry and metadata
class commit:
parents: list[commit] # empty for the first commit; two+ for merges
author: str
message: str
snapshot: tree5.4.6 Objects and content-addressing
An object is a blob, tree, or commit. Git’s object store is a single key-value table where every object is addressed by the SHA-1 hash of its contents:
objects = dict[str, object] # the entire .git object database
def store(o):
objects[sha1(o)] = o # the hash IS the name
def load(id):
return objects[id]In plain English: an object’s name is a fingerprint of its
contents. When objects reference other objects—
Intuition. This one decision buys an astonishing amount:
Deduplication: identical content has identical hash, so it is stored once, ever.
Integrity: corrupt one byte anywhere and the hash no longer matches; Git notices.
Immutable history: a commit’s hash covers its tree and its parents’ hashes. You cannot alter an old commit without changing its hash, which changes every descendant’s hash. History is tamper-evident, like a chain of wax seals.
This is why git log shows lines like commit 34903ef910501690b5c619da5378c2d4b3fd82dc: that hex string is the commit’s true name. In conversation and in commands you may abbreviate it to a unique prefix (34903ef).
5.4.7 References: human-readable names
Hashes are fine for machines, but humans are bad at remembering 40 hexadecimal characters. Git’s solution is references: mutable, human-readable names that point to commits.
references = dict[str, str] # name -> commit hash
# e.g. references["main"] = "0b142e9...",
# and it MOVES as new commits landTwo kinds matter day to day:
Branches are mutable references that follow a line of development. When you commit while “on” a branch, the branch reference automatically advances to the new commit. Creating a branch costs nothing: Git writes one small file containing one hash.
Tags are references that are not supposed to move, used to permanently mark a commit—
typically a release like v1.0.0.
HEAD is one more special reference: “where am I right now?” It answers the question Git must ask every time you commit: what is the new commit’s parent? Normally HEAD points at a branch name, which points at a commit:
HEAD -> main -> commit 0b142e9 |
Common misconception. Students picture a branch as a
container that “holds” commits—
5.4.8 A repository, defined
We can now say precisely what a Git repository is: the objects plus the references. That’s all. On disk, inside the .git directory, that is all there is: an object database and a pile of named pointers.
All Git commands map to some manipulation of the commit DAG:
adding objects, and adding, moving, or deleting references.
Whenever you type a command, ask yourself what it does to the graph.
Conversely, if you can describe the change you want in graph
terms—
5.5 The Three Spaces: Working Directory, Staging Area, Repository
The data model says what Git stores. One more concept governs how snapshots get made. Git operates across three spaces:
working directory --git add--> staging area --git commit--> repository |
(the files you (the draft of (the object database: |
actually edit) your next commits, trees, blobs, |
snapshot) refs -- the .git dir) |
You might imagine a simpler design: a “take snapshot” command that commits the working directory exactly as it stands. Some VCSs work that way; Git does not, and for good reason. Suppose you have implemented two separate features and want two separate, clean commits. Or suppose your bugfix is mixed in with a dozen debugging print() statements you have no intention of committing. The staging area (also called the index) solves this: it is a draft of your next commit that you assemble explicitly, choosing exactly which changes are included.
5.5.1 File states
Every file in your working directory is in one of these states:
State | Meaning |
untracked | Git has never been told about this file |
unmodified | tracked, identical to the last commit |
modified | tracked, edited, but not yet staged |
staged | edited and marked for inclusion in the next commit |
When you stage a file with git add, Git immediately creates the blob for its current contents and records it in the index. When you commit, the index becomes the new commit’s tree, the new commit’s parent is set to wherever HEAD points, and the current branch advances. Working directory, staging area, repository: edit, stage, commit. That loop is 90% of daily Git.
5.6 The Core Workflow in Practice
Theory established, we now meet the commands. We start TerpTasks’s history from scratch in an empty directory.
5.6.1 Creating a repository: git init and git clone
There are exactly two ways to get a repository: make one, or copy one.
mkdir terptasks && cd terptasks
git initInitialized empty Git repository in ~/terptasks/.git/ |
git init creates the .git subdirectory—
The other way is git clone <url>, which copies an existing
repository—
git config --global user.name "Testudo Terp"
git config --global user.email "testudo@umd.edu"5.6.2 git status: what is going on?
git status is your dashboard; when in doubt, run it. On the fresh repository:
git statusOn branch main |
|
No commits yet |
|
nothing to commit (create/copy files and use "git add" to track) |
It tells us we are on branch main, there is no history yet, and nothing is staged. Create the first file, app/main.py, with a minimal TerpTasks app:
from fastapi import FastAPI
app = FastAPI(title="TerpTasks")
@app.get("/")
def read_root():
return {"message": "Welcome to TerpTasks!"}git statusOn branch main |
|
No commits yet |
|
Untracked files: |
(use "git add <file>..." to include in what will be committed) |
app/ |
|
nothing added to commit but untracked files present (use "git add" to track) |
Git noticed the new file but it is untracked. Notice how the
output is written in the vocabulary of the previous section—
5.6.3 git add: stage changes
git add app/main.py
git statusOn branch main |
|
No commits yet |
|
Changes to be committed: |
(use "git rm --cached <file>..." to unstage) |
new file: app/main.py |
The file moved from “untracked” to “changes to be committed”:
its blob now sits in the staging area, awaiting the next commit.
git add . stages everything under the current directory at
once—
5.6.4 git commit: take the snapshot
git commit -m "Add minimal TerpTasks FastAPI app"[main (root-commit) 34903ef] Add minimal TerpTasks FastAPI app |
1 file changed, 7 insertions(+) |
create mode 100644 app/main.py |
Decode the output: we committed to main; this is the root commit (no parent); its abbreviated hash is 34903ef; one file changed. In graph terms: Git wrote a blob, a tree, and a commit object, then moved the main reference (and with it HEAD) to the new commit.
Write good commit messages. A commit message is
documentation attached to exactly the code it describes, and future
readers—
5.6.5 git log: view history
Add a second commit (say, a README.md), then inspect history:
git logcommit 0b142e96b92f9f07c54ecc3f4c22a068f0eac8ea (HEAD -> main) |
Author: Testudo Terp <testudo@umd.edu> |
Date: Tue Sep 8 22:51:59 2026 -0400 |
|
Add README with setup instructions |
|
commit 34903ef910501690b5c619da5378c2d4b3fd82dc |
Author: Testudo Terp <testudo@umd.edu> |
Date: Tue Sep 8 22:40:00 2026 -0400 |
|
Add minimal TerpTasks FastAPI app |
git log --all --graph --decorate --onelineYou can create a custom shortcut or shorthand for a Git command or a sequence of commands using alias. Run git config –global alias.graph "log –all –graph –decorate –oneline" and from then on git graph shows the compact DAG.
5.6.6 git diff: compare states
git diff computes differences between any two states. Suppose we edit app/main.py’s welcome message. With no arguments, it shows unstaged changes (working directory vs. staging area):
git diff
diff --git a/app/main.py b/app/main.py
index e4445b1..f022404 100644
--- a/app/main.py
+++ b/app/main.py
@@ -5,4 +5,4 @@ app = FastAPI(title="TerpTasks")
@app.get("/")
def read_root():
- return {"message": "Welcome to TerpTasks!"}
+ return {"message": "Welcome to TerpTasks, fellow Terp!"}Lines with - are removed, + are added; the @@ -5,4 +5,4 @@ header locates the change (old file lines 5–8, new file lines 5–8). Three variants cover nearly every need:
Command | Compares |
git diff | working directory vs. staging area (unstaged) |
git diff –staged | staging area vs. last commit (what will be committed) |
git diff A B | any two commits, branches, or tags |
5.6.7 .gitignore: files Git should never track
Some files do not belong in history: virtual environments, __pycache__, .env files holding API keys, editor litter like .DS_Store. List patterns in a file named .gitignore at the repository root, and Git stops suggesting them:
.venv/
__pycache__/
*.pyc
.env
.DS_StoreCommit the .gitignore itself—
5.6.8 Worked example: two features, two commits
Here is the staging area earning its keep. You have made two unrelated changes: a new /health endpoint in app/main.py, and a fix to the install instructions in README.md. One commit saying “misc changes” would bury both. Instead:
git add app/main.py
git commit -m "Add /health endpoint for uptime checks"
git add README.md
git commit -m "Fix pip install command in README"[main 5a1c3d2] Add /health endpoint for uptime checks |
1 file changed, 5 insertions(+) |
[main 8e09f4b] Fix pip install command in README |
1 file changed, 1 insertion(+), 1 deletion(-) |
Two self-contained commits, each reviewable and revertable on its
own. When a reviewer—
5.7 Branching and Merging
Branching is Git’s killer feature: parallel lines of development in one repository. Everything follows from the data model fact you already know: a branch is a movable pointer to a commit, nothing more. Creating one is instantaneous because Git just writes a hash to a file.
5.7.1 Creating and switching branches
TerpTasks needs a search feature, and it will take a few days. You do not want half-finished search code blocking quick fixes on main, so the work goes on a branch:
git switch -c task-search # create AND switch to the new branch(git branch task-search creates without switching;
git switch task-search switches; -c does both. You will
also see git checkout -b in older tutorials—
main -> B |
HEAD -> main |
After git switch -c task-search:
main -> B |
task-search -> B (same commit! branching copies nothing) |
HEAD -> task-search |
After one commit (C) implementing the search endpoint:
main -> B |
task-search -> C (the branch you're on advances) |
HEAD -> task-search |
git branch lists branches, marking the current one with *;
git branch -d name deletes a fully-merged branch (-D
forces, discarding unmerged work—
5.7.2 Fast-forward merges
Meanwhile a typo report arrives. You switch to main, fix it, commit (D). History has diverged:
A <-- B <-- D (main) |
^ |
\ |
-- C (task-search) |
Search is finished; time to merge. Merging asks: combine the work of another branch into the current one.
git switch main # the branch RECEIVING the changes
git merge task-search # the branch being merged inBut first consider the easy case: suppose main had not moved (no D), so history was:
A <-- B <-- C (task-search) |
^ |
(main) |
main is a direct ancestor of task-search: nothing happened in parallel, so there is nothing to reconcile. Git performs a fast-forward: it simply slides the main pointer up to C. No new commit, perfectly linear history:
A <-- B <-- C (main, task-search) |
Pointer mechanics, nothing else. This is why understanding “branch = pointer” pays off.
5.7.3 Three-way merges
Back to the real situation: main has D, task-search has C,
and the branches have genuinely diverged. A fast-forward is
impossible—
A <-- B <-- D <-- M (main) |
^ / |
\ v |
----- C (task-search) |
❯ git merge task-search |
Merge made by the 'ort' strategy. |
app/main.py | 12 ++++++++++++ |
1 file changed, 12 insertions(+) |
The complete history of both lines of development is preserved, including exactly when and where they were integrated. This is the DAG from the theory section, live.
5.7.4 Merge conflicts
Sometimes the two branches change the same lines in different
ways—
❯ git merge task-search |
Auto-merging app/main.py |
CONFLICT (content): Merge conflict in app/main.py |
Automatic merge failed; fix conflicts and then commit the result. |
The merge pauses, and Git marks the disputed region in the file with conflict markers:
<<<<<<< HEAD |
app = FastAPI(title="TerpTasks v1.0") |
======= |
app = FastAPI(title="TerpTasks — Search Edition") |
>>>>>>> task-search |
Between <<<<<<< and ======= is your side (HEAD, i.e. main); between ======= and >>>>>>> is theirs. Resolution is manual and unglamorous:
Open each conflicting file (git status lists them).
Decide what the merged version should say—
keep one side, or combine them. Here, presumably title="TerpTasks v1.0 — Search Edition". Delete the <<<<<<</=======/>>>>>>> markers. They are not syntax; they are shrapnel, and Python will not parse them.
git add app/main.py to mark it resolved.
git commit to complete the merge.
(git merge –abort bails out and restores the pre-merge state;
VS Code’s merge editor renders the markers as clickable
“Accept Current / Incoming / Both” buttons, which is pleasant, but
you now know what it is doing underneath.) Conflicts are not
failures—
5.7.5 Detached HEAD: visiting the past
You can point HEAD directly at a commit instead of a branch:
❯ git checkout 34903ef |
Note: switching to '34903ef'. |
|
You are in 'detached HEAD' state... |
Normal: HEAD -> main -> 0b142e9 |
Detached: HEAD -> 34903ef (no branch involved) |
This is how you look around an old snapshot—
5.8 Undoing Things
Half of Git mastery is recovering calmly. The right tool depends on
where the mistake lives: working directory, staging area, or
history—
Mistake |
| Remedy |
edited a file, want it back as committed |
| git restore <file> |
staged a file too early |
| git restore –staged <file> |
last commit has a typo'd message |
| git commit –amend |
last commit forgot a file |
| git add <file>; git commit –amend –no-edit |
a shared commit was wrong |
| git revert <hash> |
need my changes out of the way for a sec |
| git stash, later git stash pop |
Three of these deserve commentary.
Amending. git commit –amend replaces the most recent commit with a corrected one. Watch the hash:
❯ git commit --amend --no-edit |
[main 5678def] Add /health endpoint for uptime checks |
2 files changed, 6 insertions(+) |
The commit that was 5a1c3d2 is now 5678def—
Reverting. For shared history, git revert <hash> creates a new commit that applies the inverse change. History only moves forward; nothing anyone pulled is invalidated. This is the polite, public way to undo.
Stashing. You are mid-edit on the search feature when an urgent fix request arrives, and you are not ready to commit half-broken code. git stash shelves your uncommitted changes and leaves a clean working directory:
❯ git stash |
Saved working directory and index state WIP on task-search: c61751c add search stub |
❯ git switch main |
# ... fix, commit, switch back ... |
❯ git stash pop |
On branch task-search |
Changes not staged for commit: |
modified: app/main.py |
Dropped refs/stash@{0} (1234abc...) |
git stash list shows the stack of stashes if you have several; pop applies the latest and drops it.
5.9 Rebasing
There is a second way to combine branches. Where merge ties two histories together with a merge commit, rebase rewrites one history to sit on top of the other, as if you had started your branch later than you actually did.
Starting from divergence (feature has D, E; main has C):
A <-- B <-- C (main) |
^ |
\ |
-- D <-- E (feature) |
git switch feature
git rebase mainGit takes your commits D and E, and replays them, one at a time, on top of C:
A <-- B <-- C <-- D' <-- E' (feature) |
^ |
(main) |
Note D’ and E’: same changes, new commits—
5.9.1 Interactive rebase: editing your drafts
git rebase -i HEAD~3 opens your last three commits in an editor as a to-do list:
pick abc123 Add search endpoint |
pick def456 fix typo |
pick ghi789 Add search tests |
|
# pick = keep as is reword = edit the message |
# squash = meld into previous fixup = squash, discard message |
# drop = delete the commit edit = pause to amend |
Change verbs, save, and Git rewrites accordingly. The classic use: you committed “fix typo” four times while getting a feature to work. Nobody needs that history. Squash them:
pick abc123 Add search endpoint |
fixup def456 fix typo |
fixup ghi789 fix typo again |
Before: three commits of noise. After: one clean commit, as if you had written it correctly the first time. Think of commits on a private branch as drafts, and interactive rebase as the editing pass before publication.
5.9.2 When to rebase, when to merge
Rebase your own unpushed branch: to clean up drafts, or to incorporate the latest main under your work.
Merge for anything shared or recorded: integrating a finished feature into main where the merge commit documents the integration point.
The golden rule again, because it is the one that burns people: never rebase commits that others may have built on. Rebase manufactures new hashes; anyone holding the old ones now has orphaned history.
5.10 Remotes: Sharing Work
Everything so far happened on one laptop. Time for Jordan. A
remote is simply a named reference to a copy of this
repository hosted elsewhere—
You create an empty repository on GitHub (via the web UI or gh repo create terptasks), then connect and push:
git remote add origin git@github.com:testudo/terptasks.git
git push -u origin maingit remote -vorigin git@github.com:testudo/terptasks.git (fetch) |
origin git@github.com:testudo/terptasks.git (push) |
Jordan now clones it—
git clone git@github.com:testudo/terptasks.gitCloning automatically names the source origin on Jordan’s side.
5.10.1 Three branches named main
Here is the concept that unlocks all remote confusion. After the clone, the name “main” refers to three different things:
main —
your local branch, which you commit to. origin/main —
a remote-tracking branch on your machine: your local, read-only record of where main was on the server the last time you talked to it. You cannot commit to it; only network commands move it. main on GitHub’s servers —
the actual shared branch.
Three commands move information among them:
Command |
| Effect |
git fetch |
| download new objects and update origin/* -- touches none of YOUR branches |
git pull |
| fetch, then merge origin/main into your main (fetch + merge) |
git push |
| upload your commits; move main on the server (and origin/main) to match |
Watch the pointers drift apart and snap together. You make two local commits; your log shows:
commit 2d7d5d (HEAD -> main) <- you are here |
commit cde831 |
commit 311eb3 (origin/main) <- server was here at last contact |
Your main advanced; origin/main stayed put because the server does not know about your commits yet. After git push:
commit 2d7d5d (HEAD -> main, origin/main) |
commit cde831 |
commit 311eb3 |
5.10.2 Tracking branches and the first push
The first time you push a new branch, Git asks where it should go:
❯ git switch -c task-search |
❯ git push |
fatal: The current branch task-search has no upstream branch. |
To push the current branch and set the remote as upstream, use: |
git push --set-upstream origin task-search |
Do what it says (-u is the short form):
git push -u origin task-searchThe -u records that local task-search tracks origin/task-search; from then on bare git push and git pull know what to do. Housekeeping: git fetch –prune drops remote-tracking branches deleted on the server, and git push origin –delete task-search deletes a branch on the server.
5.10.3 When push is rejected
Jordan pushed first. Your push bounces:
! [rejected] main -> main (fetch first) |
error: failed to push some refs |
hint: Updates were rejected because the remote contains work that you do not |
hint: have locally. |
This is not an error so much as Git protecting Jordan’s commits: the server’s main has commits you lack, and pushing would discard them. The cure follows from the model: get their commits, integrate, push again.
git pull # fetch + merge (a normal three-way merge, conflicts and all)
git push # now your history contains theirs; acceptedIf both of you touched the same lines, the pull produces an
ordinary merge conflict, resolved exactly as before. Nothing new
to learn—
5.11 GitHub: Collaboration on Top of Git
Git answers “how do machines share history.” It says nothing about process: who may change main? Who reviews? Where do bug reports live? GitHub layers that on:
Issues: numbered, searchable bug reports and feature requests, referencable from commits (“Fixes #42”).
Pull requests: a proposal to merge one branch into another, with a diff viewer, line-by-line discussion, approvals, and automated checks attached.
Forks: your own server-side copy of someone else’s repository—
how you contribute to projects where you cannot push. Actions: run automation on repository events (the final third of this lecture).
5.11.1 The pull request workflow
You and Jordan adopt the rule used by essentially every serious team: nobody commits to main directly. All changes arrive via pull request. The loop:
Branch: git switch -c due-date-reminders.
Commit your work in small, well-messaged commits.
Push the branch: git push -u origin due-date-reminders.
Open a pull request on GitHub: base main, compare due-date-reminders, plus a description of what and why. (Or from the terminal: gh pr create.)
Jordan reviews the diff, comments on lines (“this breaks when the list is empty”), requests changes.
You push fixup commits to the same branch; the PR updates automatically.
Jordan approves; the PR is merged into main on GitHub; both of you git pull.
A pull request is not a Git object—
5.11.2 Forks: contributing without permission
For repositories you cannot push to—
The only new Git mechanics is a second remote. Convention: your fork is origin, the original is upstream:
git clone git@github.com:testudo/linux.git
cd linux
git remote add upstream https://github.com/torvalds/linux.gitorigin git@github.com:testudo/linux.git (fetch & push: my fork) |
upstream https://github.com/torvalds/linux.git (fetch: the real one) |
To stay current with the original project:
git fetch upstream
git switch main
git merge upstream/main # bring their new work into your copy
git push # update your fork on GitHubFetch from upstream, push to origin, propose via PR: that
triangle is how strangers safely collaborate on the world’s largest
codebases—
5.12 CI/CD: Automation on Every Change
Consider what still depends on memory and goodwill in the TerpTasks workflow: Jordan should run pytest before approving your PR. You should run the linter. Everyone should check the app still starts. On a deadline, “should” loses. The fix is to make a robot do it, on every change, with the results posted where the merge decision is made.
Continuous integration (CI) is the
practice of integrating changes into the shared branch frequently,
with each change automatically built and tested before it lands.
Continuous delivery (CD) extends the pipeline so that every
change that passes is automatically packaged and ready to
release—
In plain English: CI means a robot runs your tests on every proposed change and blocks the merge when they fail. CD means the robot can also ship it.
Intuition. Integration pain grows with the size of the
change being integrated—
Common misconception. “CI” does not mean “has a YAML file.” A repo where tests run but failures are ignored, or where branches live for six weeks, has CI theater, not CI. The practice is: integrate often, keep the build green, fix red now.
A typical pipeline, in stages:
push / PR ──> lint ──> tests ──> build artifact ──> deploy |
(style, (pytest) (Docker image, (staging, |
types) package) then prod) |
└────────── continuous integration ──────┘└────── delivery ─────┘ |
In this course we automate lint and tests now; the build stage arrives with the Docker lecture, where the artifact becomes a container image.
5.13 GitHub Actions
GitHub Actions is GitHub’s built-in automation platform—
Term |
| Meaning |
workflow |
| an automated process, defined by one YAML file in .github/workflows/ |
event |
| what triggers it: push, pull_request, a schedule, a manual click |
job |
| a group of steps run on one fresh virtual machine; jobs run in parallel |
step |
| one command (run:) or one reusable action (uses:) |
runner |
| the VM that executes a job (e.g. ubuntu-latest), discarded afterward |
5.13.1 TerpTasks’s CI workflow, line by line
Committing this one file as .github/workflows/ci.yml gives TerpTasks a robot teammate:
name: CI
# Events: run on any push to main, and on every pull request.
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest # runner: a fresh Ubuntu VM
steps:
- uses: actions/checkout@v4 # action: clone this repo onto the VM
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff # plain shell commands
- run: ruff check .
test:
runs-on: ubuntu-latest
strategy:
matrix: # run this job once per listed version
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt pytest
- run: pytest -vWalk through the anatomy:
on: declares the events. pull_request is the crucial one: the tests run against the proposed merge, and the verdict appears inside the PR before anyone clicks Merge.
Jobs lint and test run in parallel, each on its own throwaway VM. A fresh VM has nothing on it—
which is why the first steps are always “check out my code” and “install Python.” If your project only runs on your laptop because of some undocumented setup, CI will expose that immediately. This is a feature. uses: steps invoke reusable actions—
packaged steps published on the GitHub Marketplace. actions/checkout@v4 means “the action in repository actions/checkout, at its tag v4”— it is all just Git references, even here. run: steps are shell commands, exactly what you would type locally.
The matrix expands the test job into three parallel copies, one per Python version. Six lines of YAML buy you a compatibility guarantee you would never test by hand.
5.13.2 Reading a failure
You open a PR that accidentally breaks task creation. Within a couple of minutes the PR page shows:
✓ lint passed 41s |
✓ test (3.11) passed 58s |
✗ test (3.12) failed 55s |
✗ test (3.13) failed 57s |
Clicking a failed check shows the full log of every step—
FAILED tests/test_api.py::test_create_task - assert 422 == 201 |
The discipline: a red check means stop and fix—
To make the robot’s verdict binding, enable branch protection on main (Settings → Branches): require the lint and test checks to pass, and require one approving review, before merging. Now “it works on my machine” is no longer an argument anyone needs to have.
5.13.3 Secrets, and a warning about deployment
Pipelines often need credentials—
deploy:
needs: [lint, test] # only after CI passes...
if: github.ref == 'refs/heads/main' # ...and only on main
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}needs: sequences jobs into a pipeline; the if: guard ensures pull requests are tested but only main deploys. That is continuous delivery in fifteen lines.
5.14 The Complete Project: terpgrades on GitHub
To practice the entire lecture end to end, the companion
mini-project terpgrades
(https://github.com/software-dev-genai/exercises/tree/main/04-git/terpgrades) is a small, dependency-light Python
package—
terpgrades/ |
├── .github/ |
│ └── workflows/ |
│ └── ci.yml # lint + test matrix, as in this lecture |
├── .gitignore |
├── README.md # the lab instructions, step by step |
├── pyproject.toml # metadata + pytest/ruff configuration |
├── terpgrades/ |
│ ├── __init__.py |
│ └── gpa.py # letter_grade() and gpa() |
└── tests/ |
└── test_gpa.py |
The heart of the package, terpgrades/gpa.py:
"""GPA utilities for the terpgrades practice project."""
# UMD's 4.0 scale, with plus/minus grades.
QUALITY_POINTS = {
"A+": 4.0, "A": 4.0, "A-": 3.7,
"B+": 3.3, "B": 3.0, "B-": 2.7,
"C+": 2.3, "C": 2.0, "C-": 1.7,
"D+": 1.3, "D": 1.0, "D-": 0.7,
"F": 0.0,
}
def letter_grade(score: float) -> str:
"""Map a 0-100 score to a letter grade (no plus/minus)."""
if not 0 <= score <= 100:
raise ValueError(f"score must be in [0, 100], got {score}")
cutoffs = [(90, "A"), (80, "B"), (70, "C"), (60, "D")]
for cutoff, letter in cutoffs:
if score >= cutoff:
return letter
return "F"
def gpa(courses: list[tuple[str, int]]) -> float:
"""Credit-weighted GPA for (grade, credits) pairs, e.g. [("A", 3), ("B+", 4)]."""
if not courses:
return 0.0
points = 0.0
credits = 0
for grade, n in courses:
if grade not in QUALITY_POINTS:
raise ValueError(f"unknown grade: {grade!r}")
if n <= 0:
raise ValueError(f"credits must be positive, got {n}")
points += QUALITY_POINTS[grade] * n
credits += n
return round(points / credits, 3)And its tests, tests/test_gpa.py:
import pytest
from terpgrades.gpa import gpa, letter_grade
def test_letter_grade_boundaries():
assert letter_grade(90) == "A"
assert letter_grade(89.9) == "B"
assert letter_grade(60) == "D"
assert letter_grade(59) == "F"
def test_letter_grade_rejects_out_of_range():
with pytest.raises(ValueError):
letter_grade(101)
def test_gpa_weights_by_credits():
# 4.0*3 + 3.3*1 = 15.3 over 4 credits
assert gpa([("A", 3), ("B+", 1)]) == 3.825
def test_gpa_empty_is_zero():
assert gpa([]) == 0.0
def test_gpa_rejects_unknown_grade():
with pytest.raises(ValueError):
gpa([("Z", 3)])5.14.1 The lab: run the whole loop
The project README walks through this sequence; it exercises every section of the lecture in order:
Copy the terpgrades directory somewhere outside the course repo. git init, then build history in three commits: the config files, the package, the tests. (Staging-area practice: the files are all already there; stage them in groups.)
Run the tests locally: pip install pytest ruff then pytest -v and ruff check .—
green before automation. Create the repo on GitHub, git remote add origin ..., git push -u origin main. The push containing .github/workflows/ci.yml immediately triggers the first workflow run; watch it in the Actions tab.
Branch (git switch -c add-deans-list), implement a deans_list(gpa: float) -> bool function and a test for it, push, and open a pull request. Watch the checks run on the PR.
Sabotage check: in a second PR, change letter_grade’s A cutoff from 90 to 80 without touching tests. Watch CI fail, read the log to the failing assert, then fix and watch it go green. This failure-first experience is the point of the lab.
Conflict practice (with a partner or your second clone): both edit the same line of README.md on two branches, merge one, then the other; resolve the conflict markers by hand.
Enable branch protection requiring the checks; confirm GitHub refuses the merge button on a red PR.
When you finish, look at your repository’s network graph (Insights → Network) and find every concept from this lecture in it: commits, branches, merges, and the green checks of CI.
5.15 Practice Exercises
In your own words, explain what blobs, trees, commits, and references are, and what kind of object each of these is or points to: main, HEAD, 34903ef, the directory app/.
Draw the three-spaces diagram (working directory, staging area, repository) and place each command on its arrow: git add, git commit, git restore, git restore –staged.
A file shows up in both “Changes to be committed” and “Changes not staged for commit” in git status. Explain how this can happen and what each entry refers to.
Clone the repository for this course website. Using git log with arguments, determine who last modified README.md and why (read the commit message). Then use git log –all –graph –decorate –oneline to view its DAG.
What is the difference between git fetch and git pull? Which of main, origin/main, and the server’s main can each command move?
Simulate a collaborative conflict, solo: create a repo with a recipe.txt, commit it, and create branches salty and sweet. On salty, change “1 cup sugar” to “1 cup salt”; on sweet, change the same line to “2 cups sugar”; commit each. Merge salty into main, then sweet. Explain what happened at each step, resolve the conflict, and inspect the result with git log –graph –oneline.
Make three messy commits on a branch (e.g. “wip”, “fix”, “actually fix”), then use git rebase -i to squash them into one well-messaged commit. Verify with git log that the hash of the surviving commit differs from all three originals, and explain why it must.
Modify a file in a cloned repo, then run git stash. What do git status and git stash list show? Run git stash pop to restore it. Describe a scenario where stash beats committing.
Create an alias so git graph runs git log –all –graph –decorate –oneline, by editing ~/.gitconfig or via git config –global alias.... Then configure a global ignore file (git config –global core.excludesfile ~/.gitignore_global) that ignores .DS_Store and your editor’s scratch files.
Set up the terpgrades project through step 4 of the lab (PR with a new deans_list function and test, green CI).
A teammate ran git rebase main on a branch that was already pushed and shared, then force-pushed. Using the data model—
hashes, parents, references— explain precisely what happened to a third teammate who had the old branch checked out, why their next git pull reports divergence, and how they can recover their work. Accidentally committing a secret: in a throwaway repo, commit a file fake_key.txt, make two more commits, then remove the file from history (not just from the latest commit)—
research git filter-repo or the BFG. Verify with git log –all – fake_key.txt that it is gone. Why was deleting it in a new commit insufficient? Why must the key be revoked anyway once pushed? Extend the terpgrades workflow into a pipeline: add a build job that needs: [lint, test], builds a wheel with python -m build, and uploads it with actions/upload-artifact; gate it to run only on main. Then use git bisect on your own repo history to find which commit broke a test you deliberately sabotaged five commits ago, and explain how bisect’s binary search relates to the commit DAG.
5.16 Summary
5.16.1 Key takeaways
Git stores snapshots, not diffs. A repository is just objects (blobs, trees, commits) plus references; every command is a manipulation of that graph.
Commits are immutable and content-addressed; history forms a DAG. “Editing” history always means new commits and moved pointers—
hence the golden rule about never rewriting shared history. A branch is a movable pointer to a commit; HEAD is “where am I.” Fast-forward merges move a pointer; three-way merges create a commit with two parents; conflicts are Git refusing to guess between overlapping edits.
The staging area is the draft of your next commit—
it exists so commits can be deliberate, small, and self-contained. main, origin/main, and the server’s main are three different pointers; fetch, pull, and push move information among them.
GitHub adds process to Git: pull requests put review between a change and main; forks let strangers propose changes safely.
CI runs lint and tests on every push and PR, turning “should” into “does”; with branch protection, a red check blocks the merge. A workflow = events + jobs + steps on throwaway runners.
5.16.2 Terminology
Term |
| Meaning |
blob / tree / commit |
| file contents / directory listing / snapshot with parents and metadata |
DAG |
| directed acyclic graph; the shape of Git history |
reference |
| mutable human-readable name for a commit (branches, tags) |
HEAD |
| where you are now; usually points at a branch |
staging area (index) |
| the draft of the next commit, built with git add |
fast-forward |
| a merge that just advances a pointer; no new commit |
merge commit |
| a commit with two (or more) parents joining histories |
rebase |
| replay commits onto a new base, creating new commits |
remote / origin |
| a named copy of the repo elsewhere; origin is the default |
origin/main |
| remote-tracking branch: local record of the server's main |
fork |
| your server-side copy of someone else's repository |
pull request |
| a reviewed, checked proposal to merge a branch |
CI / CD |
| auto test on every change / auto package-and-release on pass |
workflow / job / step / runner |
| a YAML automation / parallel unit on one VM / one command or action / the VM itself |
5.16.3 Common mistakes
Editing a file after git add and committing—
the commit contains the staged version, not the file you see. Run git status and git diff –staged before committing. Treating a branch as a heavyweight copy and avoiding branching; or its opposite, a six-week branch that merges with fifty conflicts. Branch freely, merge often.
Deleting conflict markers’ content but leaving the <<<<<<< lines in the file—
and committing it. Rewriting pushed history (–amend, rebase) on a shared branch, stranding everyone who pulled it.
Committing secrets or junk (.env, .venv/, __pycache__) because git add . was run before writing a .gitignore.
git pull on a dirty working directory mid-task, getting a surprise merge; stash or commit first.
Ignoring a red CI check (“it’s probably flaky”) or, worse, deleting the failing test to make it pass.
Commit messages like “fix”, “asdf”, “final final v2”—
history is documentation; write it for the person doing archaeology at 2am, who is statistically you.
5.16.4 Where this goes next
TerpTasks now has history, review, and a robot enforcing its tests.
Two threads continue from here. In the Docker lecture, the
CI pipeline gains a build stage: the artifact that passes tests
becomes a container image that runs identically anywhere—