Git, GitHub, and the research toolchain
¶

Alfred Galichon (NYU)
¶

'math+econ+code' masterclass series: fundamentals of research in python
¶

With bash code examples
¶

© 2018–2026 by Alfred Galichon. Past and present support from NSF grant DMS-1716489, ERC grant CoG-866274 are acknowledged, as well as inputs from contributors listed here.

If you reuse material from this masterclass, please cite as:
Alfred Galichon, 'math+econ+code' masterclass series. https://www.math-econ-code.org/

Learning objectives¶

  • Explain why version control is part of doing reproducible research, not an optional convenience.

  • Distinguish the working directory, the staging area, and the commit history, and predict what each Git command does to them.

  • Use git init, status, add, commit, diff, log, branch, switch, and merge; write and debug a .gitignore.

  • Recognize the commit history as a directed acyclic graph, reconstruct that graph in Python, and compute a merge base yourself: checking your answer against git merge-base.

  • Connect a local repository to GitHub and describe the pull-request workflow.

  • Organize code, environment specification, data, and outputs as a research compendium that another economist can clone and re-run.

References¶

[PG] Chacon, S. and Straub, B. (2014). Pro Git (2nd ed.). Apress. Free at https://git-scm.com/book/.

[GH] GitHub Docs: Get started with GitHub. https://docs.github.com/en/get-started.

[Wi] Wilson, G. et al. (2017). "Good Enough Practices in Scientific Computing." PLOS Computational Biology 13(6). https://doi.org/10.1371/journal.pcbi.1005510.

[CLRS] Cormen, T., Leiserson, C., Rivest, R., and Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press, Chapter 22: topological order and reachability on a DAG, the algorithms of §7.

How to run this notebook¶

This notebook uses a standard Python kernel, but its executable cells are Bash cells introduced by %%bash. That is deliberate: the cells then contain literal shell commands, which you can copy straight into your own terminal.

Two consequences to keep in mind.

  • You need bash on the kernel's PATH. On macOS and Linux it is already there. On Windows, install Git for Windows, but note that its default installer puts git.exe on PATH and not bash.exe. Either launch Jupyter from Git Bash, or add Git's bin directory to PATH. On Google Colab everything below works unchanged.
  • Each Bash cell starts a fresh shell. Shell state, cd, variables, does not carry from one cell to the next, which is why every cell below re-enters the demonstration directory explicitly.

The demonstration writes to a disposable directory, generated/fd05_git_demo, which the first cell recreates from scratch so that re-running the notebook always starts from a known state. It uses repository-local Git settings throughout, so executing this notebook will not touch your global configuration, your other repositories, or the network.

Run the diagnostic below before going further.

In [1]:
import shutil, subprocess, sys

print(f"{'tool':<10}{'found at':<48}version")
print("-" * 78)
ok = True
for tool in ("git", "bash"):
    path = shutil.which(tool)
    if path is None:
        ok = False
        print(f"{tool:<10}{'NOT FOUND':<48}--")
    else:
        try:
            version = subprocess.run([tool, "--version"], capture_output=True,
                                     text=True).stdout.strip().splitlines()[0]
        except Exception:
            version = "(could not query)"
        print(f"{tool:<10}{path:<48}{version}")

print("-" * 78)
if ok:
    print("ready: the %%bash cells below will run.")
else:
    print("MISSING TOOL. Install Git (https://git-scm.com/downloads); on Windows,")
    print("launch Jupyter from Git Bash so that bash.exe is on PATH. The markdown")
    print("command blocks can still be read and typed into a terminal by hand.")
tool      found at                                        version
------------------------------------------------------------------------------
git       C:\Program Files\Git\clangarm64\bin\git.EXE     git version 2.54.0.windows.1
bash      C:\Program Files\Git\usr\bin\bash.EXE           GNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)
------------------------------------------------------------------------------
ready: the %%bash cells below will run.

1. Motivation for version control¶

Without version control, research projects accumulate files named paper_v2_final_REALLY_final(3).tex. It becomes unclear which code produced a figure, which specification produced a result, or how to undo a mistake made three weeks ago.

Git addresses four related problems:

  1. History. Every committed state is recoverable, and carries a message explaining the change.
  2. Diagnosis. The history identifies when a result changed or a bug appeared, which is usually most of finding out why.
  3. Collaboration. Several people work on branches and combine their changes explicitly, rather than by emailing zip files.
  4. Reproducibility. A coauthor, a referee, or you-in-two-years can recover the exact code behind a published number.

The standard. A modern empirical paper ships as a repository containing the code, the environment specification, and the instructions needed to regenerate its tables and figures. The fd07 reproducibility checklist is built on that premise, and this lecture provides its foundation.

Remark on this lecture's place in the series. fd01–fd03 each carried an economic model and a dual object; this one does not, and it would be artificial to manufacture one: there is no optimization problem in version control. What it does carry is a genuine mathematical object, and a useful one: §7 shows that the commit history is a directed acyclic graph, reconstructs it, and computes on it. Acyclicity is exactly the property that licenses backward induction, so the same structure returns in dp01, where the DAG is the state–time graph and the topological order is the order in which Bellman's equation can be solved.

2. The Git data model¶

Changes move through three places:

Place What it contains Main command
Working directory the live files your editor sees edit files
Staging area (the index) the curated draft of the next snapshot git add
Repository history permanent, named snapshots git commit

A commit is a snapshot together with a pointer to its parent (or parents). The history is therefore a graph, and the whole of §7 follows from taking that seriously. A branch is a movable pointer to a commit, and HEAD identifies the branch currently checked out.

working directory  --git add-->  staging area  --git commit-->  history

Staging exists because a commit need not contain every edit currently in the working directory: it should contain one coherent change. Almost everything else in Git follows from this three-place model, and when a command surprises you, asking which of the three it moved things between is usually enough to explain it.

3. One-time setup¶

Run these once per computer, in a terminal, substituting your own identity:

git config --global user.name "Your Name"
git config --global user.email "you@example.edu"
git config --global init.defaultBranch main
git config --global core.editor "code --wait"   # optional: VS Code
git config --list

Your name and email are recorded in every commit you make. These are not run by the notebook: the demonstration below sets the same values locally, inside the disposable repository, so that executing this notebook cannot modify your global configuration.

Which shell?¶

Git itself is identical everywhere. git init, git add, git commit behave the same on macOS, Linux and Windows.

The shell around it is not, and this catches people. To create and inspect files the demonstrations below use ordinary Unix commands, ls, cat, mkdir, rm, printf, and in Windows PowerShell these do not work as written:

  • rm, ls and cat do exist there, but only as aliases for PowerShell cmdlets that reject the Unix flags. So rm -rf demo and ls -a fail: the name resolves and the switch does not, which produces a confusing error rather than a missing-command one.
  • touch has no PowerShell equivalent at all.

On Windows, work in Git Bash. Install Git for Windows and open Git Bash; every command in this lecture then works exactly as written. This is the same advice as the note above about bash on the kernel's PATH, seen from the other side: that one is about the notebook running the cells, this one is about you typing them yourself, which §11's checklist will ask you to do.

4. Live demonstration: creating a repository¶

We start from a clean, disposable directory. git init creates a hidden .git/ directory holding the repository database and its history.

In [2]:
%%bash
set -e
# Recreate an explicitly named, disposable demonstration directory.
mkdir -p generated
rm -rf -- generated/fd05_git_demo
mkdir generated/fd05_git_demo
cd generated/fd05_git_demo

# git's message carries an absolute path; keep the message, drop the prefix.
# Anchor on the prose, not on [^ ]*, which stops at a space in the path.
git init -b main | sed "s|in .*/generated/|in <...>/generated/|"
git config user.name "Demo User"
git config user.email "demo@example.com"
git config core.excludesFile "$(pwd)/.git/info/exclude"   # ignore user-level ignore files
git config core.autocrlf false                            # keep output platform-neutral
git status
Initialized empty Git repository in <...>/generated/fd05_git_demo/.git/
On branch main

No commits yet

nothing to commit (create/copy files and use "git add" to track)

Deleting .git/ would destroy the repository and its history while leaving the ordinary working files untouched: the repository is that directory. Nothing has been committed yet, so Git is watching the directory but tracking no files.

First file, first commit¶

Create a README, inspect the working directory, stage the file, and commit the staged snapshot.

In [3]:
%%bash
set -e
cd generated/fd05_git_demo

echo "# Demo project" > README.md
ls -a
git status
.
..
.git
README.md
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	README.md

nothing added to commit but untracked files present (use "git add" to track)
In [4]:
%%bash
set -e
cd generated/fd05_git_demo

git add README.md
git status
git commit -m "initial commit: README"
git --no-pager log
On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   README.md

[main (root-commit) 999c8b0] initial commit: README
 1 file changed, 1 insertion(+)
 create mode 100644 README.md
commit 999c8b0cbbf9fb0d925053fc4dd5e166f2bb3df5
Author: Demo User <demo@example.com>
Date:   Sun Aug 30 10:06:51 2026 +0200

    initial commit: README

git status is the command to use whenever you are unsure of a repository's state. Before the commit it distinguished the untracked working file from the staged one; the commit now has an identifier, an author, a date, a message, and, from the next commit onward, a parent.

A second change, and git diff¶

We now add one untracked file and modify one tracked file. Plain git diff shows unstaged modifications to tracked files; it does not show the contents of untracked files, which is a common surprise.

In [5]:
%%bash
set -e
cd generated/fd05_git_demo

echo 'print("hello, class")' > hello.py
echo "Run: python hello.py" >> README.md

git status
git --no-pager diff
On branch main
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   README.md

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	hello.py

no changes added to commit (use "git add" and/or "git commit -a")
diff --git a/README.md b/README.md
index 54ffbc6..042f899 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,2 @@
 # Demo project
+Run: python hello.py
In [6]:
%%bash
set -e
cd generated/fd05_git_demo

git add .
git --no-pager diff --staged
git commit -m "add hello.py and usage line"
git --no-pager log --oneline
diff --git a/README.md b/README.md
index 54ffbc6..042f899 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,2 @@
 # Demo project
+Run: python hello.py
diff --git a/hello.py b/hello.py
new file mode 100644
index 0000000..a6d8e57
--- /dev/null
+++ b/hello.py
@@ -0,0 +1 @@
+print("hello, class")
[main 1a606ce] add hello.py and usage line
 2 files changed, 2 insertions(+)
 create mode 100644 hello.py
1a606ce add hello.py and usage line
999c8b0 initial commit: README

Inspect both sides of the staging boundary before committing:

  • git diff shows tracked changes that remain unstaged;
  • git diff --staged shows exactly what the next commit will contain.

Good commit messages are imperative and specific: "add hello.py and usage line", and each commit should be one coherent change. The discipline pays off in git log and, later, in git bisect, which can find the commit that broke a result only if commits are small enough to be informative.

5. .gitignore: what not to commit¶

A .gitignore lists paths Git should leave untracked: caches, notebook checkpoints, materialized environments, regenerable outputs, large data available elsewhere, and secrets.

Never commit secrets. A committed API key or password must be treated as compromised even after it is deleted from the visible files, because it remains in the history, which is, after all, the point of a history. This matters concretely from fd07, where we use an API key.

In [7]:
%%bash
set -e
cd generated/fd05_git_demo

cat > .gitignore <<'EOF'
# Python and Jupyter caches
__pycache__/
*.py[cod]
.ipynb_checkpoints/

# Materialized environments
.venv/
venv/

# Data and generated outputs
data/
outputs/
*.parquet

# Secrets
.env
*.key
*.pem
EOF

cat .gitignore
# Python and Jupyter caches
__pycache__/
*.py[cod]
.ipynb_checkpoints/

# Materialized environments
.venv/
venv/

# Data and generated outputs
data/
outputs/
*.parquet

# Secrets
.env
*.key
*.pem
In [8]:
%%bash
set -e
cd generated/fd05_git_demo

mkdir -p __pycache__ data
touch __pycache__/hello.pyc data/big.parquet

git status
git check-ignore -v data/big.parquet
git add .gitignore
git commit -m "add .gitignore"
git --no-pager log --oneline
On branch main
Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.gitignore

nothing added to commit but untracked files present (use "git add" to track)
.gitignore:11:data/	data/big.parquet
[main c1984e9] add .gitignore
 1 file changed, 18 insertions(+)
 create mode 100644 .gitignore
c1984e9 add .gitignore
1a606ce add hello.py and usage line
999c8b0 initial commit: README

Only .gitignore appeared in git status; the cache and the data file were ignored. git check-ignore -v PATH reports the file and the exact pattern responsible: the tool to use when a file is ignored and you cannot see why.

A common mistake is to append an informal comment to a pattern. In .gitignore, # starts a comment only at the beginning of a line: writing data/ # raw inputs creates a pattern containing spaces and a hash, which matches nothing. You will debug exactly this in Exercise 2.

6. Branches and merges¶

A branch lets you develop a feature without moving main. The workflow is: create and switch to a branch, commit work on it, switch back to main, and merge.

We will do something slightly more interesting than the textbook version: commit on the branch and on main, so that the two genuinely diverge. That divergence is what makes the merge non-trivial, and it is what §10 computes with.

In [9]:
%%bash
set -e
cd generated/fd05_git_demo

git branch
git switch -c feature/greeting
echo 'print("greetings")' >> hello.py
git add hello.py
git commit -m "extend greeting on feature branch"
git --no-pager log --oneline
* main
Switched to a new branch 'feature/greeting'
[feature/greeting 6d46115] extend greeting on feature branch
 1 file changed, 1 insertion(+)
6d46115 extend greeting on feature branch
c1984e9 add .gitignore
1a606ce add hello.py and usage line
999c8b0 initial commit: README
In [10]:
%%bash
set -e
cd generated/fd05_git_demo

# meanwhile, main moves on too -- the histories now diverge
git switch main
echo "" >> README.md
echo "A demonstration repository for fd05." >> README.md
git add README.md
git commit -m "expand README"

git --no-pager log --oneline --graph --all
Switched to branch 'main'
[main 65d80c3] expand README
 1 file changed, 2 insertions(+)
* 6d46115 extend greeting on feature branch
| * 65d80c3 expand README
|/  
* c1984e9 add .gitignore
* 1a606ce add hello.py and usage line
* 999c8b0 initial commit: README

The graph now forks: main and feature/greeting share the .gitignore commit as their last common ancestor, and each has one commit of its own. Merging must combine both.

In [11]:
%%bash
set -e
cd generated/fd05_git_demo

git merge feature/greeting --no-ff -m "merge feature/greeting"
echo "--- hello.py after the merge ---"
cat hello.py
echo "--- history ---"
git --no-pager log --oneline --graph --all
Merge made by the 'ort' strategy.
 hello.py | 1 +
 1 file changed, 1 insertion(+)
--- hello.py after the merge ---
print("hello, class")
print("greetings")
--- history ---
*   25f72be merge feature/greeting
|\  
| * 6d46115 extend greeting on feature branch
* | 65d80c3 expand README
|/  
* c1984e9 add .gitignore
* 1a606ce add hello.py and usage line
* 999c8b0 initial commit: README

The two branches changed different files, so Git combined them without help. The --no-ff flag forced a genuine merge commit, so the branch remains visible in the graph; that merge commit is the first in this repository with two parents.

If two branches change the same lines, Git stops with a merge conflict, writes conflict markers into the file, and waits. You resolve the marked region in an editor, stage the resolved file, and commit. Conflicts are a normal consequence of parallel work, not a failure: you will create and resolve one in Exercise 3.

Habit. Keep main working. Do exploratory or non-trivial work on a branch, and merge it once you have checked it.

7. The commit history is a directed acyclic graph¶

Everything above treated Git as a set of commands. It is worth one section to treat it as a mathematical object instead, because the object is one this series uses repeatedly.

Let $\mathcal{C}$ be the set of commits. Each commit $c$ carries an ordered list of parents $\pi(c) \subseteq \mathcal{C}$: one parent for an ordinary commit, none for the first, two for the merge we just made. Take the edge set

$$ E \;=\; \{\, (c, p) \;:\; c \in \mathcal{C},\; p \in \pi(c) \,\}, $$

so that an edge points from a commit back to its parent. Then $G = (\mathcal{C}, E)$ is a directed acyclic graph.

Acyclicity is not an accident of the examples: a commit's parents must already exist at the moment it is created, so any edge points from a later object to an earlier one, and a cycle would require a commit to precede itself. Exercise 4 asks you to make that argument precise.

Two consequences, and they are exactly the two facts about DAGs that matter in this masterclass series.

First, a topological order exists. The vertices can be listed so that every commit appears before all of its parents. That is what git log is doing when it prints history in a sensible order despite the branching. The same fact is why finite-horizon dynamic programming works: in dp01, the state–time graph is a DAG, and the topological order, later dates first, is precisely the order in which the Bellman equation can be solved by backward induction. A DAG is exactly the structure on which a recursion terminates.

Second, reachability defines ancestry. Commit $a$ is an ancestor of $c$ if there is a directed path from $c$ to $a$. When Git merges two branch tips $c_1$ and $c_2$ it must first find their merge base: a common ancestor that is not itself an ancestor of another common ancestor: a lowest common ancestor,

$$ \operatorname{LCA}(c_1,c_2) \;=\; \max_{\preceq}\ \bigl(\operatorname{anc}(c_1) \cap \operatorname{anc}(c_2)\bigr), $$

where $\preceq$ orders commits by ancestry. That is the commit against which both sides' changes are computed, and choosing it wrongly would produce a wrong merge.

We now reconstruct $G$ in Python and compute the merge base ourselves: then check it against git merge-base. Two independent routes to one commit hash.

In [12]:
import subprocess

REPO = "generated/fd05_git_demo"

def git(*args):
    """Run a git command inside the demo repository and return its stdout."""
    result = subprocess.run(["git", *args], cwd=REPO, capture_output=True,
                            text=True, check=True)
    return result.stdout

# Each line is "<commit> <parent1> <parent2> ...".  This is the edge list of G.
parent_c = {}
for line in git("log", "--all", "--format=%H %P").strip().split("\n"):
    fields = line.split()
    parent_c[fields[0]] = fields[1:]

short = lambda h: h[:7]

print(f"{len(parent_c)} commits\n")
print(f"{'commit':<10}{'parents':<22}subject")
print("-" * 64)
for c, parents in parent_c.items():
    subject = git("log", "-1", "--format=%s", c).strip()
    print(f"{short(c):<10}{' '.join(short(p) for p in parents):<22}{subject}")
6 commits

commit    parents               subject
----------------------------------------------------------------
25f72be   65d80c3 6d46115       merge feature/greeting
6d46115   c1984e9               extend greeting on feature branch
65d80c3   c1984e9               expand README
c1984e9   1a606ce               add .gitignore
1a606ce   999c8b0               add hello.py and usage line
999c8b0                         initial commit: README

Verification 1: the graph really is acyclic. We test it constructively, by producing a topological order with Kahn's algorithm: repeatedly emit a commit all of whose parents have already been emitted. If the algorithm exhausts the graph, no cycle exists; if it stalls with commits remaining, one does.

In [13]:
def topological_order(parent_of):
    """List commits parents-first. Returns None if the graph has a cycle."""
    remaining = dict(parent_of)
    emitted, order = set(), []
    while remaining:
        ready = [c for c, ps in remaining.items() if all(p in emitted for p in ps)]
        if not ready:                      # nothing can be emitted: a cycle
            return None
        for c in sorted(ready):
            order.append(c)
            emitted.add(c)
            del remaining[c]
    return order

order = topological_order(parent_c)
print("topological order found:", order is not None)
assert order is not None, "the commit graph contains a cycle -- impossible"

position = {c: i for i, c in enumerate(order)}
violations = [(c, p) for c, ps in parent_c.items() for p in ps
              if position[p] > position[c]]
print(f"edges pointing backwards in the order: {len(violations)}   (must be 0)")
assert not violations
print(f"check passed: {len(order)} commits admit a topological order, so G is a DAG.")
topological order found: True
edges pointing backwards in the order: 0   (must be 0)
check passed: 6 commits admit a topological order, so G is a DAG.

Verification 2: computing the merge base ourselves. The merge commit is the only one with two parents. Its parents are the two branch tips that were merged, and the merge base Git used is their lowest common ancestor. We compute the ancestor sets by breadth-first search, intersect them, and discard any common ancestor that is a strict ancestor of another: what remains is the LCA.

In [14]:
def ancestors(commit, parent_of):
    """All commits reachable from `commit` by following parent edges, itself included."""
    seen, frontier = {commit}, [commit]
    while frontier:
        current = frontier.pop()
        for p in parent_of.get(current, []):
            if p not in seen:
                seen.add(p)
                frontier.append(p)
    return seen

def lowest_common_ancestors(a, b, parent_of):
    """Common ancestors of a and b that are not ancestors of another common one."""
    common = ancestors(a, parent_of) & ancestors(b, parent_of)
    return {c for c in common
            if not any(c in ancestors(other, parent_of) - {other} for other in common)}

merges = [c for c, ps in parent_c.items() if len(ps) == 2]
assert len(merges) == 1, f"expected exactly one merge commit, found {len(merges)}"
tip_1, tip_2 = parent_c[merges[0]]

ours = lowest_common_ancestors(tip_1, tip_2, parent_c)
assert len(ours) == 1, f"expected a unique merge base, found {len(ours)}"
ours = ours.pop()

theirs = git("merge-base", tip_1, tip_2).strip()

print(f"merged tips      : {short(tip_1)} and {short(tip_2)}")
print(f"our LCA          : {short(ours)}  ({git('log', '-1', '--format=%s', ours).strip()})")
print(f"git merge-base   : {short(theirs)}")
print(f"\nagree: {ours == theirs}   (exact hash comparison; tolerance 0)")
assert ours == theirs, "our merge base disagrees with git's"
print("check passed: two independent routes, one merge base.")
merged tips      : 65d80c3 and 6d46115
our LCA          : c1984e9  (add .gitignore)
git merge-base   : c1984e9

agree: True   (exact hash comparison; tolerance 0)
check passed: two independent routes, one merge base.

The tolerance is exactly zero, as in fd02 §12: we are comparing forty-character hashes reached by two different routes, so any difference at all would be a logic error rather than rounding.

Note what the check actually establishes. Git's merge base is computed inside a C implementation we did not read; ours is fifteen lines of Python written from the definition. Agreement is evidence that the definition in the markdown above is the one Git implements, which is the point of the exercise, because a merge is only as trustworthy as the base it was computed against.

One honest caveat, and it is the reason lowest_common_ancestors returns a set. Two commits can have several lowest common ancestors when the history contains criss-cross merges, in which case git merge-base reports just one and real merging uses a more elaborate strategy that recursively merges the candidate bases. Our history is simple enough that the LCA is unique, and the assertion checks that rather than assuming it. Claiming uniqueness in general would be exactly the sort of unproved "always" the house style forbids.

8. Remotes and GitHub¶

The demonstration so far is entirely local. A remote is another copy of the repository, usually hosted on GitHub. To publish an existing repository, first create an empty GitHub repository, with no auto-generated README or license, which would give it an unrelated history, then:

cd generated/fd05_git_demo
git remote add origin https://github.com/USER/REPO.git
git remote -v
git push -u origin main

The -u flag records the upstream relationship, so subsequent git push and git pull need no arguments.

Command Purpose
git remote -v list remote names and URLs
git fetch origin download remote history without integrating it
git pull fetch and integrate remote work
git push upload local commits
git clone URL create a local copy of a remote repository

These are not executed by the notebook, and deliberately: they require credentials and network access, and running them would publish a throwaway repository under your account.

Two ways to authenticate. HTTPS, the URL above, prompts for a personal access token, not your account password. SSH uses a key and stops prompting altogether; the remote is then written git@github.com:USER/REPO.git, and ssh -T git@github.com tests the key without pushing anything. Either is fine; follow the current GitHub documentation, which changes more often than Git itself.

And one habit that prevents most of what people call "git problems": pull before you push. Nearly all of them are a stale local history.

9. Collaboration: the pull-request workflow¶

  1. Start from an updated main and branch: git switch -c feature/my-change.
  2. Make small, coherent commits.
  3. Publish the branch: git push -u origin feature/my-change.
  4. Open a pull request on GitHub proposing to merge the branch into main.
  5. A collaborator reviews the diff; further commits pushed to the branch update the same pull request.
  6. Merge the pull request, and delete the branch.
  7. Update your local copy: git switch main && git pull.

The pull request records more than the changed lines. Its title, description, discussion, and review preserve the reasoning behind a change, which is usually the part you most want and least remember six months later. In a research group it is also where a referee-proof audit trail comes from: the question "why is this coefficient computed this way?" has an answer with a date and an author attached.

10. Environments and the research compendium¶

Code alone is not enough for reproducibility. Commit the environment specification: environment.yml or requirements.txt, as built in fd03 §8, but never the installed .venv/ or conda directory, which is large, platform-specific, and regenerable.

A typical research repository:

my-project/
├── .gitignore
├── README.md           # purpose, and how to reproduce the results
├── environment.yml     # dependency specification
├── src/                # reusable code -- the mec_numerical of fd03
├── notebooks/          # exploration and exposition
├── data/               # inputs; often ignored, with retrieval instructions
├── outputs/            # regenerable tables and figures; often ignored
└── tests/              # automated checks -- fd04

Commit the recipe, documentation, environment files, scripts, not caches, installed environments, or artifacts that a single command regenerates. Record software versions and random seeds wherever they affect results; fd08 seeds every generator explicitly for exactly this reason, and fd07 turns the practice into a checklist.

11. A project-start checklist¶

mkdir my-project && cd my-project
git init -b main
# Write README.md and .gitignore BEFORE any data enters the directory.
git add README.md .gitignore
git commit -m "initial commit: project skeleton"
git remote add origin https://github.com/USER/my-project.git
git push -u origin main

Five durable habits: commit early and often; write informative messages; create .gitignore before the first commit; branch for anything non-trivial; and push regularly, so that collaborators, and backups, see the work.

12. Summary¶

  • Git's three places, working directory, staging area, history, explain almost every command. When one surprises you, ask which of the three it moved things between.

  • A commit is a snapshot plus its parents, so the history is a graph, and §7 showed it is a directed acyclic one. We verified acyclicity constructively with a topological sort, and computed the merge base of the two merged tips from the definition, matching git merge-base hash for hash.

  • Acyclicity is what licenses recursion, and that is why the structure recurs: the state–time graph of a finite-horizon dynamic program is a DAG, and its topological order is the backward induction of dp01. The graph you built here with fifteen lines of fd02 dictionaries and sets is the same kind of object you will solve Bellman equations on.

  • This lecture has no optimization problem and hence no dual variable, and none was invented. What it contributes to the series is the discipline underneath every later result: an analysis is not finished when it runs on your laptop, but when someone else can clone it and get your number.

13. Exercises¶

Exercises 1–4 can be worked inside the disposable repository built above, or in one of your own; Exercise 5 requires a GitHub account and a terminal. Worked solutions are in §15.

Exercise 1: Recover a deleted file. In the demonstration repository, create notes.md with a line of text and commit it. Then delete the file and commit the deletion. Now recover it.

Find the commit in which the file was deleted with git log --diff-filter=D -- notes.md, and restore the file from its parent commit. Verify that the restored contents match what you originally wrote, and explain in one sentence why the file was recoverable even though git status reported a clean working tree after the deletion.

In [15]:
%%bash
set -e
cd generated/fd05_git_demo

# your answer here

Exercise 2: Debug a .gitignore. Add these two lines to the repository's .gitignore:

scratch/   # temporary working files
*.log

Create scratch/tmp.txt and run.log, and check git status. One of the two files is ignored and the other is not, contrary to the author's intention.

Diagnose it with git check-ignore -v on each path, explain what the first pattern actually matches, and fix it. Then answer: git check-ignore exits with status 1 when a path is not ignored: why does that make set -e a hazard in a Bash cell that calls it, and what is the one-token fix?

In [16]:
%%bash
set -e
cd generated/fd05_git_demo

# your answer here

Exercise 3: Create and resolve a merge conflict. From main, create two branches that each modify the same line of hello.py differently, and commit on each. Merge the second into the first.

Show the conflict markers Git writes into the file, resolve them by hand to a version keeping both changes, stage the resolution, and commit. Then verify with git log --oneline --graph --all that the resulting merge commit has two parents, and confirm with the code of §7 that your repository still has a valid topological order.

In [17]:
%%bash
set -e
cd generated/fd05_git_demo

# your answer here

Exercise 4: Acyclicity, proved and used.

(a) Prove that a Git commit history can never contain a cycle. Use the fact that a commit's parent hashes must refer to objects that already exist when it is created, and that a commit's identifier is a hash of its content, including its parent hashes. State precisely which property of the hash function your argument relies on.

(b) Compute. Using the parent_c graph and the ancestors function of §7, write an expression for the set of commits reachable from main but not from feature/greeting: the set that git log feature/greeting..main prints. Verify your answer against that command.

(c) Interpret. In dp01 the vertices of the DAG are date–state pairs and the topological order is time running backwards. What plays the role of a merge commit, a vertex with two parents, in that setting, and what does the merge base correspond to?

In [18]:
# your answer here

Exercise 5: Hello, GitHub. In a terminal, not in this notebook:

  1. create a local repository mec_sandbox with a README and a .gitignore, and commit;
  2. create an empty repository of the same name on GitHub and push main to it;
  3. on a branch feature/license, add an MIT license file, push the branch, and open a pull request;
  4. merge the pull request on GitHub, then bring your local main up to date;
  5. finally, clone a public repository you did not write, the math-econ-code organization has several, and inspect git log --oneline --graph --all.

Submit the pull-request URL. In two or three sentences, describe what its commit messages, branch structure, and merge cadence tell you about how the project you cloned is developed.

In [19]:
# Exercise 5 is done in a terminal, not here.
# Paste your pull-request URL below as a comment for your own records.

14. Further directions¶

fd06 and fd07 turn to data, tables first, then the web sources they come from, and fd07 §10 assembles the reproducibility checklist whose first two items are the environment file of §10 above and this lecture's entire subject. Everything from here belongs under version control from its first line, beginning with the mec_numerical module of fd03 and the test suite fd04 built around it.

Commands beyond today's scope, useful exist: git stash to park work in progress, git bisect to binary-search the history for the commit that broke a result, and interactive rebase to tidy a branch before review. GitHub Actions can run your fd04 test suite automatically on every push, which is the point at which "it works on my machine" stops being something anyone has to take on trust.

Main point: understand the working directory, the staging area, and the history. Every command follows from that model.

Save your work, restart the kernel, and run all cells top-to-bottom before you move on.

15. Solutions to the exercises¶

Solutions 1–4 execute against the demonstration repository built above. Solution 5 cannot be executed here, it needs credentials and a network, so it is given as a command transcript.

Solution to Exercise 1: Recover a deleted file¶

In [20]:
%%bash
set -e
cd generated/fd05_git_demo

printf 'Meeting notes: discount factors are dual variables.\n' > notes.md
git add notes.md
git commit -q -m "add notes.md"

rm notes.md
git add -A
git commit -q -m "delete notes.md"

echo "--- working tree after the deletion ---"
git status --short
echo "(clean: git has recorded the deletion)"
echo
echo "--- the commit that deleted the file ---"
git --no-pager log --oneline --diff-filter=D -- notes.md
--- working tree after the deletion ---
(clean: git has recorded the deletion)

--- the commit that deleted the file ---
6118706 delete notes.md
In [21]:
%%bash
set -e
cd generated/fd05_git_demo

# The deleting commit's PARENT still has the file: restore from <commit>^
deleting=$(git log --format=%H --diff-filter=D -- notes.md | head -1)
git restore --source="${deleting}^" -- notes.md

echo "--- restored contents ---"
cat notes.md
echo
git add notes.md
git commit -q -m "restore notes.md from history"
git --no-pager log --oneline -3
--- restored contents ---
Meeting notes: discount factors are dual variables.

883a5bf restore notes.md from history
6118706 delete notes.md
40a7a0f add notes.md

Why it was recoverable. git status reported a clean tree because the deletion had been committed: the working directory faithfully matched the latest snapshot, which is a snapshot without the file. But a commit does not overwrite its parents: every earlier snapshot, including the one containing notes.md, remains in the object database and is reachable by walking the parent edges of §7. "Clean" means consistent with the current commit, never no other states exist.

This is the practical payoff of the DAG. Deleting a file removes it from the tip of the graph; the vertex holding it is still there, and <commit>^ is simply the parent edge that reaches it.

Two variants useful. If the deletion has not yet been committed, none of this is needed: git restore notes.md takes the file back from the current snapshot. And to read a file as of any commit without restoring it, git show <sha>:<path> prints its exact bytes to standard output, which is the tool for comparing a result against the version that produced last month's figure.

Solution to Exercise 2: Debug a .gitignore¶

In [22]:
%%bash
set -e
cd generated/fd05_git_demo

printf 'scratch/   # temporary working files\n*.log\n' >> .gitignore
mkdir -p scratch
echo "scratch" > scratch/tmp.txt
echo "log line" > run.log

echo "--- git status ---"
git status --short
echo
echo "--- who ignores what? (|| true: exit status 1 just means 'not ignored') ---"
git check-ignore -v scratch/tmp.txt || echo "scratch/tmp.txt is NOT ignored"
git check-ignore -v run.log || echo "run.log is NOT ignored"
--- git status ---
 M .gitignore
?? scratch/

--- who ignores what? (|| true: exit status 1 just means 'not ignored') ---
scratch/tmp.txt is NOT ignored
.gitignore:20:*.log	run.log
In [23]:
%%bash
set -e
cd generated/fd05_git_demo

# Fix: put the comment on a line of its own.
grep -v '^scratch/   # temporary working files$' .gitignore > .gitignore.tmp
printf '# temporary working files\nscratch/\n' >> .gitignore.tmp
mv .gitignore.tmp .gitignore

echo "--- corrected tail of .gitignore ---"
tail -3 .gitignore
echo
echo "--- now who ignores scratch/tmp.txt? ---"
git check-ignore -v scratch/tmp.txt
echo
git status --short
git add -A
git commit -q -m "fix .gitignore comment placement"
echo "committed."
--- corrected tail of .gitignore ---
*.log
# temporary working files
scratch/

--- now who ignores scratch/tmp.txt? ---
.gitignore:21:scratch/	scratch/tmp.txt

 M .gitignore
committed.

What the broken pattern matched. In .gitignore a # begins a comment only at the start of a line. The line scratch/ # temporary working files is therefore a single pattern whose text is scratch/ # temporary working files, spaces and hash included: a directory name nobody has. It matched nothing, which is why scratch/tmp.txt showed up in git status while run.log, whose pattern was well formed, did not. git check-ignore -v is decisive here because it names the file and line of the rule that fired; silence means no rule fired at all.

Why set -e is a hazard. git check-ignore uses its exit status to answer the question: 0 means "ignored", 1 means "not ignored". Under set -e a non-zero status aborts the cell, so a Bash cell asking about a file that turns out not to be ignored dies at that line, and it dies precisely in the case you were investigating. The one-token fix is to append || true (or, as above, || echo ...), which supplies a successful exit status while keeping the diagnostic output. The general lesson recurs whenever a command reports its answer through its exit code rather than its output: grep behaves the same way.

Solution to Exercise 3: Create and resolve a merge conflict¶

In [24]:
%%bash
set -e
cd generated/fd05_git_demo

git switch -q -c conflict/left main
printf 'print("hello from the left branch")\n' > hello.py
git commit -q -am "left: rewrite greeting"

git switch -q -c conflict/right main
printf 'print("hello from the right branch")\n' > hello.py
git commit -q -am "right: rewrite greeting"

git switch -q conflict/left
echo "--- merging right into left: expect a conflict ---"
git merge conflict/right -m "merge right into left" || true
echo
echo "--- hello.py with conflict markers ---"
cat hello.py
--- merging right into left: expect a conflict ---
Auto-merging hello.py
CONFLICT (content): Merge conflict in hello.py
Automatic merge failed; fix conflicts and then commit the result.

--- hello.py with conflict markers ---
<<<<<<< HEAD
print("hello from the left branch")
=======
print("hello from the right branch")
>>>>>>> conflict/right
In [25]:
%%bash
set -e
cd generated/fd05_git_demo

# Resolve by hand, keeping both changes.
cat > hello.py <<'EOF'
print("hello from the left branch")
print("hello from the right branch")
EOF

git add hello.py
git commit -q -m "merge right into left, keeping both greetings"

echo "--- resolved file ---"
cat hello.py
echo
echo "--- the merge commit and its parents ---"
git --no-pager log --oneline -1
git --no-pager log --pretty="%h has parents: %p" -1
--- resolved file ---
print("hello from the left branch")
print("hello from the right branch")

--- the merge commit and its parents ---
6a82ab3 merge right into left, keeping both greetings
6a82ab3 has parents: 2297762 379bd85
In [26]:
# the graph is still a DAG after the second merge -- re-run the section 7 checks
parent_c = {}
for line in git("log", "--all", "--format=%H %P").strip().split("\n"):
    fields = line.split()
    parent_c[fields[0]] = fields[1:]

merges = [c for c, ps in parent_c.items() if len(ps) == 2]
order = topological_order(parent_c)

print(f"commits: {len(parent_c)}   merge commits: {len(merges)}")
print(f"topological order still exists: {order is not None}")
assert order is not None, "a cycle appeared, which is impossible"
print("check passed: the history remains a DAG.")
commits: 13   merge commits: 2
topological order still exists: True
check passed: the history remains a DAG.

Git wrote the two competing versions into the file separated by <<<<<<<, =======, and >>>>>>> markers, and stopped. It does not guess: when both sides changed the same line, only the author knows which change is right, or whether, as here, the answer is to keep both. Note that git merge exits non-zero on a conflict, so the cell needed || true for the same reason as Exercise 2.

The resolution is an ordinary commit that happens to have two parents. Nothing about the graph structure is special about conflicts: a conflicted merge and a clean one produce exactly the same shape, which is why the topological order still exists afterwards.

Solution to Exercise 4: Acyclicity, proved and used¶

(a) Proof. Let $c$ be a commit. Git computes its identifier as $h(c) = H(\text{tree}, \pi(c), \text{author}, \text{message}, \dots)$, where $H$ is a cryptographic hash and $\pi(c)$ is the list of parent identifiers. Two observations:

  1. To compute $h(c)$, the identifiers of every parent must already be known, so each parent object exists strictly before $c$ is created. Order the commits by creation time, $t(\cdot)$; every edge $(c, p) \in E$ then satisfies $t(p) < t(c)$.
  2. A directed cycle $c_1 \to c_2 \to \cdots \to c_k \to c_1$ would give $t(c_1) < t(c_k) < \cdots < t(c_1)$, a contradiction. Hence no cycle exists. $\blacksquare$

Note what the argument does not use. No property of $H$ enters it at all. Step 1 needs only that a parent's identifier is known when the child is written, which is a fact about the order of operations, not about hashing. Acyclicity is therefore not enforced by a check in Git's code, and it is not a cryptographic guarantee either: it follows from the requirement that each parent already exist.

Content addressing does something else, and it is worth separating. It makes the parent identifiers part of the child's own identity, so an edge cannot be rewritten without changing every identifier downstream: that is integrity, and it is what Git's migration from SHA-1 to SHA-256 protects.

The one way to imagine a cycle is to hand-craft a commit object whose stored parent field is its own identifier. That asks for a fixed point of $H$, an input whose digest appears inside itself, which is a preimage-style problem. It is not a collision: a collision exhibits two distinct inputs with the same digest and produces no self-reference, so collision resistance is not the property that rules this out. The distinction matters because the two assumptions are not interchangeable, and SHA-1 is broken for one of them and not the other.

(b) Computation. Reachable from main but not from feature/greeting is a set difference of the ancestor sets of §7.

In [27]:
main_tip = git("rev-parse", "main").strip()
feature_tip = git("rev-parse", "feature/greeting").strip()

ours = ancestors(main_tip, parent_c) - ancestors(feature_tip, parent_c)

theirs = set(git("log", "--format=%H", "feature/greeting..main").strip().split("\n"))

print(f"{'commit':<10}subject")
print("-" * 52)
for c in sorted(ours, key=lambda h: -len(ancestors(h, parent_c))):
    print(f"{short(c):<10}{git('log', '-1', '--format=%s', c).strip()}")

print("-" * 52)
print(f"ours: {len(ours)} commits, git log feature/greeting..main: {len(theirs)} commits")
print(f"identical sets: {ours == theirs}   (exact hash comparison)")
assert ours == theirs, "our reachability computation disagrees with git"
print("check passed.")
commit    subject
----------------------------------------------------
2a3d05d   fix .gitignore comment placement
883a5bf   restore notes.md from history
6118706   delete notes.md
40a7a0f   add notes.md
25f72be   merge feature/greeting
65d80c3   expand README
----------------------------------------------------
ours: 6 commits, git log feature/greeting..main: 6 commits
identical sets: True   (exact hash comparison)
check passed.

(c) Interpretation. In dp01 the vertices are date–state pairs $(t, x)$ and an edge runs from $(t,x)$ to each $(t+1, x')$ the transition allows; the topological order is time, and backward induction traverses it in reverse.

A vertex with two parents, two incoming edges, in the ancestry direction used here, corresponds to a state reachable from more than one predecessor: two different histories arriving at the same state. That is precisely what makes dynamic programming worth doing rather than enumerating paths, because the continuation value at $(t,x)$ can be computed once and reused by every predecessor. The merge base, the deepest vertex from which both branches descend, corresponds to the last state the two histories had in common before diverging, which in a dynamic program is where the decision that separated them was taken.

The analogy has a limit worth stating. Git's graph grows by appending, and its edges record authorship rather than probability; a Markov decision process weights its edges with $P_{x'|xy}$ and asks for an optimal policy, a question with no counterpart here. What transfers is the structural fact: acyclicity is what makes a recursion over the vertices terminate, and both settings depend on it.

Solution to Exercise 5: Hello, GitHub¶

This one cannot be executed in the notebook: steps 2–4 need GitHub credentials and network access, and running them would publish a repository under your account. The transcript below is what to type.

# 1. a local repository
mkdir mec_sandbox && cd mec_sandbox
git init -b main
printf '# mec_sandbox\n\nScratch repository for fd05.\n' > README.md
printf '__pycache__/\n.ipynb_checkpoints/\n.venv/\ndata/\n.env\n' > .gitignore
git add README.md .gitignore
git commit -m "initial commit: project skeleton"

# 2. push to an EMPTY GitHub repository created in the browser first
git remote add origin https://github.com/USER/mec_sandbox.git
git push -u origin main

# 3. a branch, a licence, a pull request
git switch -c feature/license
curl -s https://api.github.com/licenses/mit | python -c \
    "import json,sys; print(json.load(sys.stdin)['body'])" > LICENSE
git add LICENSE
git commit -m "add MIT license"
git push -u origin feature/license
# then open the pull request on github.com, or: gh pr create --fill

# 4. after merging the PR in the browser
git switch main
git pull

# 5. read someone else's history
cd ..
git clone https://github.com/math-econ-code/mec_optim.git
cd mec_optim
git --no-pager log --oneline --graph --all | head -40

Two things to notice. In step 2 the GitHub repository must be created empty: if you let GitHub add a README or a licence, it starts its own initial commit, your local history and the remote history share no ancestor, and the push is refused as unrelated. In the language of §7, there is no merge base: the two graphs are disjoint, so ancestors(local) ∩ ancestors(remote) = ∅ and there is nothing to compute a diff against.

In step 5, what to look for when reading an unfamiliar history: whether commit messages describe why rather than what; whether main is a straight line (a rebase or squash-merge policy) or visibly braided (merge commits preserved, as our --no-ff did); and how much work sits between merges, which tells you how large a unit the project treats as reviewable.