Title: Lecture 7 — Repos, Snapshots, Branching, and Keeping Data Out of History
Course: AIINFRA 100: AI Systems & Development Environments > Section: Week 07 — Version Control with Git

📖 Lecture — Repos, Snapshots, Branching, and Keeping Data Out of History

Welcome to your first real encounter with version control. If you've ever renamed a file final_v2_REALLY_FINAL.py because you were afraid of losing an earlier version, this week is for you. Git replaces that anxiety with a system that remembers everything on purpose, so you never have to. You already have VS Code and Python installed from Week 1, and you wrote Python back in Week 5 — this week you'll learn to track that kind of work properly.

Git is distributed — every clone is a full repository

The most important idea to absorb is that Git is a distributed version control system. When you run git clone on a project, you are not downloading a thin pointer to some central server the way you might "open" a shared Google Doc. You are downloading the entire history of that project — every commit, every message, every snapshot ever made. Your copy is complete and self-sufficient. This matters practically in two ways. First, you can commit while on a plane, on a train, or anywhere with zero internet connection, because your local repository already has everything Git needs to record a snapshot. Second, you can browse the full project history — every past version of every file — without ever talking to "the server." There is no single server Git strictly requires; platforms like GitHub are just a convenient, commonly-agreed-upon place to host a copy everyone syncs with. We'll get into syncing with remotes next week (Week 8). This week is entirely about what happens on your own machine.

The three-area mental model

Nearly every Git command you'll ever type moves something between these three areas:

Area | What it is | How files get there | How files leave
Working directory | The actual files on your disk that you edit in VS Code | You create/edit them normally | Saved edits stay here until staged
Staging area (the "index") | A holding area listing exactly what will go into the next commit | git add <file> | git commit moves it into the repository
Repository (.git history) | The permanent, timestamped record of every past commit | git commit -m "message" | Nothing — it's permanent history

Think of the staging area as a shopping cart. Editing a file puts it on the shelf (working directory). git add puts it in your cart (staging). git commit is the checkout — it's final, it's recorded, and it has a receipt (the commit message and a unique hash). You decide exactly what goes into each commit.

The two commands you should run constantly

Beginners get into trouble not because Git is hard, but because they type commands without first looking at what Git is telling them. Build the habit now of running these two commands before you do almost anything else:

- git status — tells you, in plain English, which files are modified, which are staged, and which are untracked. Read it fully before your next command.
- git log (or git log --oneline for a compact view) — shows you the commit history: what was recorded, in what order, with what message.

If you're ever unsure what Git will do next, run git status first. It is always safe to run and it will save you from most beginner mistakes.

.gitignore: telling Git what to never track

Not everything in a project folder belongs in version control. A .gitignore file lists patterns for files and folders Git should simply pretend don't exist — it will never suggest staging them, and they won't clutter your git status output. This is especially critical for AI/ML projects, where folders quickly fill up with large, regenerable, or sensitive files that have no business being permanently baked into your project's history:

Category | Example patterns | Why exclude it
Datasets | data/, datasets/, *.csv, *.parquet, *.pkl | Often huge, regenerable from a source, and bloats repo size forever
Model weights | *.pt, *.h5, *.joblib, saved_model/ | Large binary artifacts that change with every training run
Notebook checkpoints | .ipynb_checkpoints/ | Auto-generated Jupyter cache, not meaningful history
Python environments | venv/, __pycache__/ | Machine-specific, regenerated from requirements.txt
Secrets | .env | Contains API keys/passwords — must never be committed

A common misconception: many students assume writing a .gitignore rule will retroactively erase a file Git is already tracking. It will not. .gitignore only stops Git from noticing new/untracked files going forward. If a file was already committed before you wrote the rule, you must explicitly run git rm --cached <file> to untrack it (the file stays on disk; it's just removed from Git's tracking). A second misconception: committing is not the same as backing up to the cloud. A git commit is recorded entirely on your own machine, in your local .git folder — it is not automatically sent anywhere. Sending your commits to a remote server (like GitHub) is called pushing, and we won't cover that until Week 8. Until then, your commits are safe local history, but only local.

Keeping truly large files lean: Git LFS awareness

Git was designed to track source code — small text files that change a little at a time. It was not designed for huge binary files like datasets or trained model checkpoints (.pt, .h5, .ckpt). Every time you commit a new version of a large binary, Git stores the entire file again, because binary diffs don't compress the way text diffs do. Commit a 2 GB checkpoint five times and your repo can balloon by 10 GB — even if you later delete the file, that history stays. The first line of defense is the .gitignore habit above: keep regenerable data and weights out of history entirely. When you genuinely need a large binary in the repo, the tool for the job is Git LFS (Large File Storage), which stores the real file content on a separate LFS server while your Git history keeps only a small pointer file in its place. You register patterns with git lfs track "*.pt" (written into a .gitattributes file). Just know that LFS is opt-in per pattern — it only intercepts patterns you've registered, so a forgotten extension still goes into normal history at full size. For now, simply being aware LFS exists is enough; you'll meet it again as projects grow.

Branches are just pointers

Real AI development rarely happens on a single, straight timeline. You'll try different model architectures, clean data in parallel with tuning hyperparameters, and collaborate with teammates — all at once. Git's branching model makes this manageable, and the key idea unlocks everything: a branch is not a copy of your project. A branch is a lightweight, movable pointer to a single commit. When you run git branch feature/data-prep, Git just writes a few bytes pointing at your current commit — it doesn't duplicate any files. That's why creating and switching branches (git switch or git checkout) is nearly instant, even in huge repositories. Because branching is cheap, Git encourages you to branch constantly. Trying a new model architecture? Make a branch. Cleaning up a dataset? Make a branch. Each experiment gets its own isolated line of history, and your main branch stays stable while you explore. A common AI pattern is one branch per model variant (experiment/resnet50, experiment/vit-base) so you can compare approaches without them interfering.

Merging brings a branch back together

When work on a branch is ready to rejoin main, you run git merge. If main hasn't changed since you branched, Git simply fast-forwards. If both lines changed, Git creates a merge commit with two parents — a history that honestly shows two lines of work happened in parallel and were then joined. Occasionally the same lines of the same file were changed differently on both branches; Git can't guess which you want, so it stops with a merge conflict, marking the disputed area with <<<<<<<, =======, and >>>>>>> so you can edit the file to keep the correct final version, then git add and commit. VS Code's built-in merge view makes this a matter of clicking "Accept Current," "Accept Incoming," or "Accept Both."

A light word on rebase

There's a second way to combine branches: rebase, which replays your branch's commits one by one on top of the latest main, producing a straight, linear history with no merge commit — cleaner to read when preparing a branch for review. The trade-off is the golden rule of rebasing: never rebase a branch others have already pulled or built on, because rebasing rewrites history (each replayed commit gets a new hash). Rebase your own private, not-yet-shared branches; merge when branches are shared. You don't need to master rebase this week — just know merge preserves an honest record, rebase produces a tidy line, and you'll use these workflows collaboratively in Week 8.

Why this matters going forward

Small, message-driven commits — where the message explains why a change was made, not just what changed — become your project's audit trail. "Fixed bug" tells a future reader nothing; "Fix off-by-one error in train/test split that leaked one row into both sets" tells them everything. This disciplined, three-area workflow, plus cheap branching, is the foundation everything else builds on. Next week (Week 8) you'll push these branches to GitHub and propose merging them through pull requests.