On this page:
5.1 Learning Objectives
5.2 Why Version Control?
5.2.1 Git, and Git vs. Git  Hub
5.2.2 Why learn the theory first?
5.3 The Running Example:   Terp  Tasks Goes Multiplayer
5.4 Git’s Data Model
5.4.1 Snapshots, not diffs
5.4.2 Blobs:   file contents
5.4.3 Trees:   directories
5.4.4 Commits:   snapshots with ancestry
5.4.5 The data model as pseudocode
5.4.6 Objects and content-addressing
5.4.7 References:   human-readable names
5.4.8 A repository, defined
5.5 The Three Spaces:   Working Directory, Staging Area, Repository
5.5.1 File states
5.6 The Core Workflow in Practice
5.6.1 Creating a repository:   git init and git clone
5.6.2 git status:   what is going on?
5.6.3 git add:   stage changes
5.6.4 git commit:   take the snapshot
5.6.5 git log:   view history
5.6.6 git diff:   compare states
5.6.7 .gitignore:   files Git should never track
5.6.8 Worked example:   two features, two commits
5.7 Branching and Merging
5.7.1 Creating and switching branches
5.7.2 Fast-forward merges
5.7.3 Three-way merges
5.7.4 Merge conflicts
5.7.5 Detached HEAD:   visiting the past
5.8 Undoing Things
5.9 Rebasing
5.9.1 Interactive rebase:   editing your drafts
5.9.2 When to rebase, when to merge
5.10 Remotes:   Sharing Work
5.10.1 Three branches named main
5.10.2 Tracking branches and the first push
5.10.3 When push is rejected
5.11 Git  Hub:   Collaboration on Top of Git
5.11.1 The pull request workflow
5.11.2 Forks:   contributing without permission
5.12 CI/  CD:   Automation on Every Change
5.13 Git  Hub Actions
5.13.1 Terp  Tasks’s CI workflow, line by line
5.13.2 Reading a failure
5.13.3 Secrets, and a warning about deployment
5.14 The Complete Project:   terpgrades on Git  Hub
5.14.1 The lab:   run the whole loop
5.15 Practice Exercises
5.16 Summary
5.16.1 Key takeaways
5.16.2 Terminology
5.16.3 Common mistakes
5.16.4 Where this goes next
9.1

5 Git, GitHub, and CI/CD🔗

    5.1 Learning Objectives

    5.2 Why Version Control?

      5.2.1 Git, and Git vs. GitHub

      5.2.2 Why learn the theory first?

    5.3 The Running Example: TerpTasks Goes Multiplayer

    5.4 Git’s Data Model

      5.4.1 Snapshots, not diffs

      5.4.2 Blobs: file contents

      5.4.3 Trees: directories

      5.4.4 Commits: snapshots with ancestry

      5.4.5 The data model as pseudocode

      5.4.6 Objects and content-addressing

      5.4.7 References: human-readable names

      5.4.8 A repository, defined

    5.5 The Three Spaces: Working Directory, Staging Area, Repository

      5.5.1 File states

    5.6 The Core Workflow in Practice

      5.6.1 Creating a repository: git init and git clone

      5.6.2 git status: what is going on?

      5.6.3 git add: stage changes

      5.6.4 git commit: take the snapshot

      5.6.5 git log: view history

      5.6.6 git diff: compare states

      5.6.7 .gitignore: files Git should never track

      5.6.8 Worked example: two features, two commits

    5.7 Branching and Merging

      5.7.1 Creating and switching branches

      5.7.2 Fast-forward merges

      5.7.3 Three-way merges

      5.7.4 Merge conflicts

      5.7.5 Detached HEAD: visiting the past

    5.8 Undoing Things

    5.9 Rebasing

      5.9.1 Interactive rebase: editing your drafts

      5.9.2 When to rebase, when to merge

    5.10 Remotes: Sharing Work

      5.10.1 Three branches named main

      5.10.2 Tracking branches and the first push

      5.10.3 When push is rejected

    5.11 GitHub: Collaboration on Top of Git

      5.11.1 The pull request workflow

      5.11.2 Forks: contributing without permission

    5.12 CI/CD: Automation on Every Change

    5.13 GitHub Actions

      5.13.1 TerpTasks’s CI workflow, line by line

      5.13.2 Reading a failure

      5.13.3 Secrets, and a warning about deployment

    5.14 The Complete Project: terpgrades on GitHub

      5.14.1 The lab: run the whole loop

    5.15 Practice Exercises

    5.16 Summary

      5.16.1 Key takeaways

      5.16.2 Terminology

      5.16.3 Common mistakes

      5.16.4 Where this goes next

5.1 Learning Objectives🔗

By the end of this lecture you should be able to:

  1. 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.

  2. 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.

  3. Use the core workflow—git status, git add, git commit, git log, git diffand explain the role of the staging area in each step.

  4. Create, switch, merge, and delete branches; distinguish a fast-forward merge from a three-way merge; and resolve a merge conflict by hand.

  5. Work with remote repositories: clone, fetch, pull, and push, and explain the difference between main, origin/main, and the main branch on the server.

  6. Distinguish Git from GitHub, and use the fork-and-pull-request model to propose, review, and merge changes.

  7. Define continuous integration and continuous delivery, and explain what problems they solve on a team.

  8. 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—when the assistant was confidently wrong—revert. You cannot responsibly ship code you cannot roll back.

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—all history, all branches—on their own machine. You can commit, branch, diff, and inspect history on an airplane with no network; you synchronize with others when you choose to.

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—memorizing commands as magic incantations—works right up until something goes wrong, at which point the standard folk remedy is “delete the repo and clone again.”

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?”—something you can reason about rather than recite.

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:

  1. You put TerpTasks under version control and build a commit history (the core workflow).

  2. You develop a new feature—task search—on a branch, while fixing a bug on main in parallel (branching and merging).

  3. 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).

  4. You adopt the pull-request workflow so every change is reviewed before it lands (GitHub).

  5. 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—history, branches, collaboration—from a few simple parts. There are only three kinds of objects and one kind of pointer. Master these and you have mastered Git; everything else is a command-line flag.

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“commit 7 changed line 12 of main.py”—the way diffs are displayed. It does not.1Under the hood, Git compresses its object store using deltas (“packfiles”), but that is an implementation detail of storage, invisible to the model. Conceptually, and in every command you run, a commit is a full snapshot. When you ask Git for a diff, it computes the difference between two snapshots on the fly.

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—just a bunch of bytes—stripped of all metadata, identified by the SHA-1 hash of those bytes.

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—the snapshots that preceded it. It is a set rather than a single parent because a commit can descend from two lines of development at once, when parallel branches are merged.

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—amend, rebaseis really making a copy and abandoning the original.

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: tree

5.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—a tree pointing at blobs, a commit pointing at its parent—they store the hash, not a copy.

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 land

Two 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—a fat folder of work. It is nothing of the sort. A branch is a 41-byte file holding one commit hash. The commits belong to the graph; the branch is a sticky note on one of them. Hold onto this; it makes merging and rebasing almost obvious.

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—“make main point at commit 5d83f9e and discard my uncommitted edits”—there is a command for it (git switch main; git reset hard 5d83f9e). This is the single most useful habit you can develop with Git.

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 init

Initialized empty Git repository in ~/terptasks/.git/

git init creates the .git subdirectory—the object database and references from the data-model section. Your files are not yet tracked; Git never tracks anything without being asked.

The other way is git clone <url>, which copies an existing repository—all commits, all branches, the entire DAG—from somewhere like GitHub onto your machine. We will use it when Jordan joins. (One-time setup if Git is new on your machine: tell it who you are, so commits carry your name.)

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 status

On 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 status

On 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—this is why we learned the terminology first. Git’s messages even tell you which command to run next; read them.

5.6.3 git add: stage changes🔗

git add app/main.py
git status

On 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—convenient, but make a habit of running git status first so you know what “everything” is.

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—including future you, your manager, and the AI assistants you ask to summarize a repo’s history—will rely on it. Convention: a short imperative summary line (“Add task search endpoint”, not “added stuff”), then, if needed, a blank line and a paragraph explaining why. Only files in the staging area get committed: edit without git add, and the change stays behind.

5.6.5 git log: view history🔗

Add a second commit (say, a README.md), then inspect history:

git log

commit 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

Newest first, like a stack. (HEAD -> main) reads straight off the data model: HEAD points at main, which points at commit 0b142e9. The single most useful variant visualizes the whole DAG.
git log --all --graph --decorate --oneline

You 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_Store

Commit the .gitignore itself—it is shared configuration. This matters more than it looks: committing a .env with an OpenAI or Anthropic API key publishes that key to everyone with repo access, forever, because commits are immutable—merely deleting the file in a later commit does not remove it from history. Bots scrape GitHub for exactly this mistake within minutes. If it happens: revoke the key first, then worry about scrubbing history.

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—or git bisect, or an AI agent summarizing your changes—walks this history, each step makes sense in isolation. That is the craft of using the staging area.

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—same effect; the older checkout command did too many unrelated things and was split into switch and restore.) Trace the pointers. Before, with commits A and B on main:

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—use with caution). Branch freely: branches cost nothing, and the convention of doing each feature, fix, or experiment on its own branch is universal in industry. On a typical team main is supposed to stay releasable at all times; feature branches are where work happens.

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 in

But 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—moving main to C would abandon D. Git instead performs a three-way merge among three snapshots: the common ancestor (B), yours (D), and theirs (C). Changes relative to B that do not overlap are combined automatically, and Git creates a merge commit M with two parents:

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—or one deletes a file the other modified. Git does not guess; it stops and asks you. Let’s manufacture one. On task-search, you renamed the app title to "TerpTasks — Search Edition"; on main, Jordan’s typo fix changed the same line to "TerpTasks v1.0". Merging:

❯ 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:

  1. Open each conflicting file (git status lists them).

  2. Decide what the merged version should say—keep one side, or combine them. Here, presumably title="TerpTasks v1.0 — Search Edition".

  3. Delete the <<<<<<</=======/>>>>>>> markers. They are not syntax; they are shrapnel, and Python will not parse them.

  4. git add app/main.py to mark it resolved.

  5. 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—they are Git refusing to silently destroy one person’s work. The real lesson is social: conflicts grow with how long branches live. Merge often, keep branches short.

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—run it, test it, see what the code looked like before search existed. It is read-mostly territory: commits made here hang off no branch, and will eventually be garbage-collected once you leave. If experiments made in a detached HEAD turn out to be worth keeping, put a branch on them—git switch -c experimentand they are safe. git switch main reattaches you to normal life.

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—and on whether the commits have been shared.

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 5678defbecause commits are immutable, “amend” really means “new commit, abandon the old one.” This is rewriting history, which leads to the golden rule: never rewrite commits you have already pushed to a shared repository. Others may have built work on the original; your rewrite strands them.

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 main

Git 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 commitsnew parents mean new hashes; the originals are abandoned. The payoff is a perfectly linear history, and a subsequent git merge feature from main is a trivial fast-forward. Conflicts can occur exactly as with merge; fix the file, git add it, then git rebase continue (or abort to undo the whole thing).

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—typically on GitHub. You could type the URL every time; instead Git lets you nickname it, and the conventional name for “the main remote” is origin.

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 main

git remote -v

origin  git@github.com:testudo/terptasks.git (fetch)

origin  git@github.com:testudo/terptasks.git (push)

Jordan now clones it—and remember, a clone is the whole repository, full history, not a snapshot:

git clone git@github.com:testudo/terptasks.git

Cloning 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:

  1. main your local branch, which you commit to.

  2. 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.

  3. 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-search

The -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; accepted

If both of you touched the same lines, the pull produces an ordinary merge conflict, resolved exactly as before. Nothing new to learn—that is the payoff of understanding merging once.

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:

  1. Branch: git switch -c due-date-reminders.

  2. Commit your work in small, well-messaged commits.

  3. Push the branch: git push -u origin due-date-reminders.

  4. 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.)

  5. Jordan reviews the diff, comments on lines (“this breaks when the list is empty”), requests changes.

  6. You push fixup commits to the same branch; the PR updates automatically.

  7. Jordan approves; the PR is merged into main on GitHub; both of you git pull.

A pull request is not a Git object—it is GitHub bookkeeping wrapped around an ordinary merge. But the social structure it creates (every change gets a second pair of eyes before landing) is the single biggest quality lever on a team. It will matter even more as AI assistants write a growing share of code: the PR review is where a human verifies machine-written changes before they become part of the record.

5.11.2 Forks: contributing without permission🔗

For repositories you cannot push to—the course website, an open source library—GitHub’s model is the fork: click Fork, and GitHub creates you/project, your own copy, where you can push. You then open a pull request across repositories: from you/project’s branch into upstream/project’s main.

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.git

origin    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 GitHub

Fetch from upstream, push to origin, propose via PR: that triangle is how strangers safely collaborate on the world’s largest codebases—and how you will submit some of your coursework.

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—and continuous deployment goes one further, releasing automatically with no human gate.

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—we saw this with merge conflicts, and it is equally true of bugs. Integrate every few days and conflicts are trivial, failures are small, and the guilty commit is obvious. The CI robot is what makes frequent integration safe: it is the tireless reviewer who never says “looks fine, didn’t run it.”

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—the most common way to run CI for code that lives on GitHub. The model has five terms; learn them and every workflow file becomes readable:

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 -v

Walk 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—the same pytest output you would see locally:

FAILED tests/test_api.py::test_create_task - assert 422 == 201

The discipline: a red check means stop and fixpush another commit to the PR branch and the checks rerun automatically. Resist the urge to assume “the CI is flaky.” First reproduce locally (pytest -v); the runner is a clean machine, so when CI fails and your laptop passes, the difference is usually a dependency you forgot to declare or a file you forgot to commit—real bugs in your project’s reproducibility.

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—an API key for an integration test, a deploy token. Never commit these. Store them in Settings → Secrets, and reference them as ${{ secrets.MY_KEY }}; GitHub injects them at runtime and redacts them from logs. A taste of the delivery side, which we will complete in the Docker lecture:

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—a GPA calculator—with tests and a ready-made CI workflow. It is deliberately tiny so that Git, GitHub, and Actions stay in the foreground. The code lives in the course repository under notes/git/code/terpgrades, ready to copy:

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:

  1. 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.)

  2. Run the tests locally: pip install pytest ruff then pytest -v and ruff check .green before automation.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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🔗

  1. 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/.

  2. 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.

  3. 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.

  4. 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.

  5. What is the difference between git fetch and git pull? Which of main, origin/main, and the server’s main can each command move?

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. Set up the terpgrades project through step 4 of the lab (PR with a new deans_list function and test, green CI).

  11. 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.

  12. 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?

  13. 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—true continuous delivery. And throughout the course, this workflow is the harness in which you will use AI coding assistants: generate on a branch, review in a PR, verify with CI. The tools that make human collaboration safe are exactly the ones that make human–AI collaboration safe.

1Under the hood, Git compresses its object store using deltas (“packfiles”), but that is an implementation detail of storage, invisible to the model. Conceptually, and in every command you run, a commit is a full snapshot.