2684095e2f
Co-authored-by: claude <claude@jpaul.io> Co-committed-by: claude <claude@jpaul.io>
480 lines
23 KiB
Markdown
480 lines
23 KiB
Markdown
# Module 6 — Branches: Sandboxes for Experiments
|
||
|
||
> **A branch is a disposable copy of your project where the AI can try anything — and `main` never
|
||
> finds out unless you decide it should.** This is what turns "let the agent attempt something bold"
|
||
> from a gamble into a one-line decision: keep it or throw it away.
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
- **Module 2 — Version Control as a Safety Net.** You can `init`, `commit`, read `git diff`/`git
|
||
log`/`git status`, and `git restore` an unwanted change. Branches build directly on commits: a
|
||
branch is just a label on the commit history you already understand.
|
||
- **Module 4 — Getting the AI Out of the Browser.** The AI now edits your real files directly from
|
||
your editor. That's exactly the capability that makes branches matter — you're about to let it edit
|
||
files *fast and confidently*, and you want a wall around the blast radius.
|
||
- **Module 5 — Commit the AI's Config, Not Just the Code.** Your committed instructions file travels
|
||
with the branch automatically, so an agent working on a branch inherits the same setup. (You'll see
|
||
this for free in the lab — nothing to do, just notice it.)
|
||
|
||
Module 2's `git restore` undoes *uncommitted* changes back to your last checkpoint. This module is
|
||
the next size up: isolating *a whole line of committed work* so you can keep or discard it as a unit.
|
||
|
||
---
|
||
|
||
## Learning objectives
|
||
|
||
By the end of this module you can:
|
||
|
||
1. Create a branch, switch between branches, and explain what a branch actually *is* (a movable
|
||
pointer, not a copy of your files).
|
||
2. Let an AI make a bold, multi-commit change on a branch while `main` stays untouched and runnable.
|
||
3. Decide the experiment's fate in one command: **merge** it into `main` to keep it, or **delete the
|
||
branch** to throw it away with zero trace.
|
||
4. Read a merge conflict — the `<<<<<<<`/`=======`/`>>>>>>>` markers — and resolve it deliberately,
|
||
including handing the conflict to the AI to resolve.
|
||
5. Tell the difference between a fast-forward merge and a merge commit, and know which one you just
|
||
got.
|
||
|
||
---
|
||
|
||
## Key concepts
|
||
|
||
### What a branch actually is
|
||
|
||
Strip the mystique and a branch is **a named, movable pointer to a commit.** That's the whole
|
||
definition. Your commit history is a chain of snapshots (Module 2); a branch is a sticky label that
|
||
points at one of them and *moves forward* every time you commit on it.
|
||
|
||
When you ran `git init` in Module 2, Git made one branch for you automatically — usually called
|
||
`main`. Every commit you made moved the `main` label forward. You were "on a branch" the entire time
|
||
without thinking about it.
|
||
|
||
The thing that surprises people coming from an ops background: **creating a branch copies nothing.**
|
||
There's no second folder, no duplicated files, no disk cost worth mentioning. Git just writes a new
|
||
label pointing at the same commit you're standing on. That's why branches are *cheap enough to be
|
||
disposable* — and disposable is exactly the property we want.
|
||
|
||
```bash
|
||
git branch # list branches; the * marks the one you're on
|
||
git switch -c experiment # create a branch called "experiment" and switch to it
|
||
git switch main # switch back to main
|
||
git branch -d experiment # delete a branch you've already merged
|
||
git branch -D experiment # FORCE-delete a branch, merged or not (the "throw it away" button)
|
||
```
|
||
|
||
> **Naming note.** `git switch` (create/move between branches) and `git restore` (the Module 2 undo)
|
||
> were split out of the older, overloaded `git checkout` command. You'll still see `git checkout -b
|
||
> experiment` everywhere online — it does the same thing as `git switch -c experiment`. Both work;
|
||
> this module uses `switch`/`restore` because they say what they mean.
|
||
|
||
### The reframe: a branch is a sandbox you can blow away
|
||
|
||
You already have the instinct for this. A branch is the Git equivalent of a **scratch VM you can
|
||
snapshot and roll back, a staging environment nobody depends on, a feature-flag you can rip out.**
|
||
You spin one up precisely *because* you're about to do something you might regret, and you want a
|
||
clean way to make it never have happened.
|
||
|
||
In Module 2 the safety net was "commit, then `restore` if the AI makes a mess." That's perfect for a
|
||
single bad edit. But some experiments are bigger than one edit — "rewrite the storage layer,"
|
||
"try a totally different CLI structure," "add a feature that touches four files." Those take *several
|
||
commits* to even evaluate, and you don't want that half-finished, possibly-broken work sitting on
|
||
`main`. A branch gives the whole experiment its own track:
|
||
|
||
```
|
||
main: A───B───C (always runnable; this is your "known good")
|
||
\
|
||
experiment: D───E───F (the AI's bold attempt, however messy)
|
||
```
|
||
|
||
While you're on `experiment`, `main` is frozen at C — runnable, shippable, untouched. The AI can
|
||
leave `experiment` in a smoking crater at F and `main` doesn't care. When you're done you make one
|
||
decision:
|
||
|
||
- **Keep it:** merge `experiment` into `main` (C gains D, E, F).
|
||
- **Kill it:** delete `experiment`. D, E, F evaporate. `main` is still exactly C, as if the
|
||
experiment never happened.
|
||
|
||
That "kill it, no trace" path is the one this module exists for. It's the difference between *"I have
|
||
to carefully undo everything the AI did"* and *"I delete the branch."*
|
||
|
||
### Switching branches changes your files
|
||
|
||
Here's the part that feels like magic the first time. When you `git switch` to another branch, **Git
|
||
rewrites the files in your folder to match that branch.** Switch to `experiment` and the AI's
|
||
half-built feature appears in your editor. Switch back to `main` and it vanishes — your files are
|
||
back to commit C. Same folder, different contents, instantly.
|
||
|
||
This is why you can't switch with uncommitted changes lying around that would be clobbered: Git
|
||
stops you, because switching would silently throw work away. The fix is the Module 2 habit — commit
|
||
(or stash) before you switch. On a branch, "commit often" pays off again: each commit is a safe
|
||
point to switch away from.
|
||
|
||
> **One folder, one branch at a time.** Switching swaps the *whole* folder between branches, which
|
||
> means you can only have one branch checked out at once. The moment you want *two* branches live
|
||
> simultaneously — say, two agents working in parallel without overwriting each other's files — you've
|
||
> hit the limit of branches alone. That's exactly what **Module 7 (Worktrees)** solves: multiple
|
||
> working directories from one repo. Branches are the concept; worktrees are how you run several at
|
||
> once. Keep that in your back pocket.
|
||
|
||
### Merging: keeping the experiment
|
||
|
||
Merging takes the commits from one branch and brings them into another. You switch to the branch you
|
||
want to *receive* the work (usually `main`), then merge the other branch in:
|
||
|
||
```bash
|
||
git switch main
|
||
git merge experiment
|
||
```
|
||
|
||
There are two outcomes, and it's worth knowing which you got:
|
||
|
||
- **Fast-forward.** If `main` hasn't moved since you branched (it's still at C), Git doesn't need to
|
||
do anything clever — it just slides the `main` label forward to F. The history stays a straight
|
||
line. This is the common case for a solo experiment.
|
||
- **Merge commit.** If `main` *did* move on (someone — or you — committed to `main` while
|
||
`experiment` was off doing its thing), the two lines of history have diverged. Git stitches them
|
||
together with a new commit that has two parents. You'll be dropped into an editor to confirm the
|
||
merge message; save and close it.
|
||
|
||
You don't choose between these — Git picks based on whether the branches diverged. You just need to
|
||
recognize them in `git log --oneline --graph`, where a fast-forward is a straight line and a merge
|
||
commit is a visible fork-and-join.
|
||
|
||
After a successful merge, the branch has done its job. Delete it:
|
||
|
||
```bash
|
||
git branch -d experiment # -d refuses if it's NOT fully merged — a safety check
|
||
```
|
||
|
||
### Discarding: killing the experiment
|
||
|
||
This is the payoff. The AI tried something bold on the branch, you looked at it, and you don't want
|
||
it. You don't undo anything. You don't `restore` file by file. You switch away and delete the branch:
|
||
|
||
```bash
|
||
git switch main # your files snap back to known-good main
|
||
git branch -D experiment # -D force-deletes even though it was never merged
|
||
```
|
||
|
||
That's it. The experiment is gone. `main` never changed. `git log` on `main` shows no sign it ever
|
||
happened. **The whole bold attempt cost you one branch and one delete.**
|
||
|
||
This is the mental shift the module is selling: when discarding is this cheap, you stop being
|
||
precious about what you let the AI try. Risky refactor? Branch it. Want to compare two approaches?
|
||
A branch each, keep the winner, delete the loser. The branch is the unit of "maybe."
|
||
|
||
### Merge conflicts: when two changes collide
|
||
|
||
Most merges just work — Git is good at combining changes that touch *different* lines. A **conflict**
|
||
happens only when two branches changed **the same lines** in different ways, and Git refuses to
|
||
guess which one you meant. It stops the merge and marks the collision *inside the file* so you can
|
||
decide:
|
||
|
||
```python
|
||
<<<<<<< HEAD
|
||
print("usage: python cli.py [add <title> | list | done <index> | count]")
|
||
=======
|
||
print("usage: python cli.py [add <title> | list | done <index> | clear]")
|
||
>>>>>>> experiment
|
||
```
|
||
|
||
Read it like this:
|
||
|
||
- `<<<<<<< HEAD` to `=======` is **your current branch's version** (the branch you're merging *into*
|
||
— `main`, here).
|
||
- `=======` to `>>>>>>> experiment` is **the incoming branch's version**.
|
||
- Both markers and the divider are real text Git inserted into your file. Resolving means **editing
|
||
the file so it contains the version you want and deleting all three marker lines.**
|
||
|
||
You're not picking a side mechanically — you're deciding what the line *should* say. Often that's one
|
||
side, sometimes it's a blend of both (here: a usage string that lists *both* `count` and `clear`).
|
||
Then you tell Git the conflict is settled:
|
||
|
||
```bash
|
||
# edit the file: remove the markers, leave the correct content
|
||
git add cli.py # marks this file's conflict as resolved
|
||
git commit # completes the merge (opens an editor for the merge message)
|
||
```
|
||
|
||
`git status` during a conflict is your map — it lists every file still "unmerged." When that list is
|
||
empty and you've `git add`-ed them all, you commit and the merge is done. If you panic mid-conflict,
|
||
`git merge --abort` rewinds you to before the merge, no harm done.
|
||
|
||
---
|
||
|
||
## The AI angle
|
||
|
||
Everything above is standard Git. Here's why it matters *more* in an AI-assisted workflow, not less:
|
||
|
||
- **The branch is the blast-radius container for an autonomous attempt.** An agent editing your files
|
||
directly (Module 4) is fast and confident — including when it's confidently wrong across four
|
||
files. On `main`, cleaning that up is a chore. On a branch, you delete the branch. The riskier and
|
||
more autonomous the AI work, the more a branch earns its keep — which is why this concept underpins
|
||
everything in Unit 5, where agents run with far less supervision.
|
||
- **"Throw it away" is the feature, not the failure.** With copy-paste, a rejected AI attempt still
|
||
cost you the manual work of pasting it in and the manual work of ripping it back out. With a
|
||
branch, a rejected attempt costs *nothing* — `git branch -D` and it's as if it never happened. That
|
||
flips the economics: you can let the AI try things you'd never risk if undoing were expensive.
|
||
- **Compare, don't commit-and-hope.** Ask the AI for approach A on one branch and approach B on
|
||
another. Run both. Keep the winner, delete the loser. You're using branches as cheap A/B
|
||
experiments on implementation — something that's painful without them and trivial with them.
|
||
- **Conflicts are a great place to put the AI to work.** A merge conflict is a small, perfectly
|
||
bounded reasoning task: here are two versions of the same lines and the surrounding code — produce
|
||
the correct combined version. The AI can see both sides and the intent. You still decide whether
|
||
its resolution is right (it can absolutely merge two changes into something that satisfies neither),
|
||
but "explain this conflict and propose a resolution" is one of the highest-hit-rate uses of an
|
||
editor-integrated agent. You'll do exactly this in the lab.
|
||
|
||
---
|
||
|
||
## Hands-on lab
|
||
|
||
**Lab language:** shell (Git commands), driving the `tasks-app` from Modules 1–2 with your
|
||
editor-integrated AI from Module 4.
|
||
|
||
You'll do three things: let the AI try a bold change on a branch, decide its fate, and then
|
||
deliberately create and resolve a merge conflict — using the AI to help resolve it.
|
||
|
||
**You'll need:**
|
||
|
||
- The `tasks-app` Git repo from Module 2 (committed, clean working tree — run `git status` and make
|
||
sure it says "nothing to commit").
|
||
- Your editor-integrated AI from Module 4.
|
||
- Git (you've had it since Module 2).
|
||
|
||
> Throughout, "ask your AI" now means your **editor-integrated** agent (Module 4) editing the files
|
||
> directly — no more copy-paste. After it edits, you still read `git diff` before committing. That
|
||
> habit doesn't go away; the branch just decides how *much* damage a bad diff can do.
|
||
|
||
### Part A — Branch it and let the AI go bold
|
||
|
||
1. Confirm you're on `main` and clean, then create an experiment branch and switch to it:
|
||
|
||
```bash
|
||
cd ~/workflow-course/tasks-app
|
||
git switch main
|
||
git status # must be clean
|
||
git switch -c experiment/priorities
|
||
git branch # the * is now on experiment/priorities
|
||
```
|
||
|
||
2. Give the AI a deliberately *bold* task — the kind you'd hesitate to run straight on `main`:
|
||
|
||
> *"Add task priorities (low/medium/high) to this app. Store a priority on each task, let me set
|
||
> it when adding (`add "thing" --priority high`), show it in `list`, and sort `list` so high
|
||
> priority comes first. Change whatever files you need to."*
|
||
|
||
Let it edit `tasks.py` and `cli.py` freely. This is a multi-file change — exactly the kind that's
|
||
nerve-wracking on `main` and relaxed on a branch.
|
||
|
||
3. Review and commit the experiment **on the branch**:
|
||
|
||
```bash
|
||
git diff # read what it actually changed
|
||
python cli.py add "ship module 6" --priority high
|
||
python cli.py add "water plants" --priority low
|
||
python cli.py list # see if priorities work and sort
|
||
git add .
|
||
git commit -m "Add task priorities (experiment)"
|
||
```
|
||
|
||
4. Now prove the isolation. Switch back to `main` and watch the feature **disappear**:
|
||
|
||
```bash
|
||
git switch main
|
||
python cli.py list # no priorities — main is exactly as you left it
|
||
```
|
||
|
||
Your bold change exists only on the branch. `main` never saw it. Sit with that for a second —
|
||
that's the whole point.
|
||
|
||
### Part B — Decide its fate
|
||
|
||
Pick the path that matches reality. Do at least one; ideally do **Path 2 (discard)** on this
|
||
experiment so you feel how clean it is, then re-run Part A and do **Path 1 (keep)** so you've done both.
|
||
|
||
**Path 1 — Keep it (merge):**
|
||
|
||
```bash
|
||
git switch main
|
||
git merge experiment/priorities # likely a fast-forward: main slides up to the branch
|
||
git log --oneline --graph # see the history; straight line = fast-forward
|
||
python cli.py list # the feature is now on main
|
||
git branch -d experiment/priorities # branch did its job; -d is the safe delete
|
||
```
|
||
|
||
**Path 2 — Throw it away (discard):**
|
||
|
||
```bash
|
||
git switch main # files snap back to known-good main
|
||
git branch -D experiment/priorities # force-delete the unmerged branch
|
||
git log --oneline # no trace of the experiment on main
|
||
python cli.py list # main is untouched, exactly as before
|
||
```
|
||
|
||
Notice what you did *not* do in Path 2: no file-by-file `restore`, no manual undo, no hunting through
|
||
diffs. You deleted a label and the entire experiment was gone. That's the economics shift — bold AI
|
||
attempts become free to reject.
|
||
|
||
### Part C — Create a merge conflict and resolve it with the AI
|
||
|
||
Now the skill everyone fears and nobody should. You'll engineer a guaranteed conflict by having
|
||
**two branches change the same line in different ways**, then resolve it.
|
||
|
||
1. Make sure you're on a clean `main`. Create the first branch and have the AI add a `count` command:
|
||
|
||
```bash
|
||
git switch main
|
||
git switch -c feature/count
|
||
```
|
||
|
||
Ask the AI: *"Add a `count` command to `cli.py` that prints how many tasks are pending, and update
|
||
the usage string to include it."* Then:
|
||
|
||
```bash
|
||
git diff # confirm it edited the usage line + added the command
|
||
git add . && git commit -m "Add count command"
|
||
```
|
||
|
||
2. Switch back to `main` and create a *different* branch that touches **the same usage line**:
|
||
|
||
```bash
|
||
git switch main
|
||
git switch -c feature/clear
|
||
```
|
||
|
||
Ask the AI: *"Add a `clear` command to `cli.py` that deletes all tasks, and update the usage
|
||
string to include it."* Then:
|
||
|
||
```bash
|
||
git diff # it also edited the usage line — this is the collision to come
|
||
git add . && git commit -m "Add clear command"
|
||
```
|
||
|
||
Both branches changed the same `usage:` line, each adding a *different* command to it. Git will
|
||
not be able to auto-merge that line.
|
||
|
||
3. Merge them and watch it conflict. Merge `feature/count` into `feature/clear` (you're on
|
||
`feature/clear`):
|
||
|
||
```bash
|
||
git merge feature/count
|
||
```
|
||
|
||
Git stops with a conflict and tells you which file is unmerged. Confirm:
|
||
|
||
```bash
|
||
git status # cli.py listed under "Unmerged paths"
|
||
```
|
||
|
||
4. Open `cli.py` and find the conflict markers around the usage line:
|
||
|
||
```python
|
||
<<<<<<< HEAD
|
||
print("usage: python cli.py [add <title> | list | done <index> | clear]")
|
||
=======
|
||
print("usage: python cli.py [add <title> | list | done <index> | count]")
|
||
>>>>>>> feature/count
|
||
```
|
||
|
||
(The command bodies for `count` and `clear` touch different lines, so Git merged *those* cleanly
|
||
on its own — the only collision is the usage string both branches edited.)
|
||
|
||
5. **Resolve it with the AI.** With your editor-integrated agent, this is its sweet spot. Ask:
|
||
|
||
> *"`cli.py` has a merge conflict on the usage line. I want the final version to list BOTH the
|
||
> `count` and `clear` commands. Resolve the conflict and remove the markers."*
|
||
|
||
It should produce a single, marker-free line listing both commands, e.g.:
|
||
|
||
```python
|
||
print("usage: python cli.py [add <title> | list | done <index> | count | clear]")
|
||
```
|
||
|
||
**Verify its work — this is the part the AI can get subtly wrong.** A conflict resolver can
|
||
confidently drop one side, leave a stray marker, or "blend" the lines into something that runs but
|
||
means the wrong thing. Read the result and run it:
|
||
|
||
```bash
|
||
git diff # check ONLY what you intended changed; no markers remain
|
||
python cli.py # run with no args — see the merged usage string
|
||
python cli.py count # both commands actually work
|
||
python cli.py clear
|
||
```
|
||
|
||
6. Tell Git the conflict is settled and complete the merge:
|
||
|
||
```bash
|
||
git add cli.py
|
||
git commit # opens an editor for the merge message; save and close
|
||
git log --oneline --graph # see the fork-and-join: this is a merge commit
|
||
```
|
||
|
||
You just resolved a real merge conflict. The marker syntax is identical no matter the file or the
|
||
project — once you can read those three lines, conflicts stop being scary and become a five-minute
|
||
chore.
|
||
|
||
> **Guaranteed-conflict generator.** AI edits are nondeterministic, so if the agent didn't touch the
|
||
> same line on both branches and you *didn't* get a conflict in step 3, run the helper script to
|
||
> manufacture one deterministically, then practice steps 4–6 on it:
|
||
>
|
||
> ```bash
|
||
> bash modules/06-branches-sandboxes-for-experiments/lab/make-conflict.sh
|
||
> ```
|
||
>
|
||
> It creates two branches that both edit the same line of `README.md`, leaving you mid-conflict with
|
||
> on-screen instructions. The resolution mechanic is identical to the code case above.
|
||
|
||
---
|
||
|
||
## Where it breaks
|
||
|
||
The honest limits, so you don't over-trust the sandbox:
|
||
|
||
- **A branch isolates *files in the repo*, nothing else.** Switching branches rewrites your tracked
|
||
files — it does **not** roll back a database the app wrote to, files Git is ignoring, running
|
||
processes, or anything outside version control. If your AI experiment ran a migration or wrote to
|
||
`tasks.json` (which the Module 2 `.gitignore` excludes), deleting the branch won't undo *that*. The
|
||
sandbox is the repo, not the world. (Real environment isolation is a later problem — containers,
|
||
Module 16.)
|
||
- **Branches are local until you push them.** Everything in this module lives on your laptop. A
|
||
branch isn't shared, backed up, or visible to anyone else until there's a remote — that's
|
||
**Module 8**. Right now `git branch -D` deletes work that exists nowhere else, permanently. Treat
|
||
an unpushed branch as exactly as fragile as the rest of your local-only repo.
|
||
- **The AI can resolve a conflict into something plausible and wrong.** It sees both sides and the
|
||
intent, which makes it good at this — but "good" isn't "trusted." A resolution that runs cleanly can
|
||
still mean the wrong thing (silently keeping the worse of two changes, or merging two behaviors
|
||
into one that satisfies neither). The `git diff` + run-it check in the lab isn't optional ceremony;
|
||
it's the actual safeguard. Reviewing AI output is its own discipline — Module 10.
|
||
- **Long-lived branches drift and conflict harder.** The longer a branch lives away from `main`, the
|
||
more `main` moves underneath it and the gnarlier the eventual merge. The defense is the same as
|
||
"commit often": branch small, merge soon, delete promptly. A branch that's been open for three
|
||
weeks is a future conflict, not a sandbox.
|
||
- **Force-delete (`-D`) and `merge --abort` are sharp.** `-D` discards unmerged commits with no
|
||
confirmation; `--abort` throws away an in-progress resolution. Both are exactly what you want at
|
||
the right moment and a foot-gun at the wrong one. Know which one you're reaching for.
|
||
|
||
---
|
||
|
||
## Check for understanding
|
||
|
||
**You're done when:**
|
||
|
||
- You created a branch, let the AI make a multi-file change on it, and confirmed `main` was untouched
|
||
by switching back and seeing the change vanish.
|
||
- You have **discarded** an experiment with `git branch -D` and confirmed `main` shows no trace, and
|
||
you have **merged** one in and seen it land on `main`.
|
||
- You can explain, in one sentence, why creating a branch costs essentially nothing (it's a movable
|
||
pointer, not a copy).
|
||
- You deliberately created a merge conflict, read the `<<<<<<<`/`=======`/`>>>>>>>` markers, resolved
|
||
it (with the AI's help) to a marker-free file that runs, and completed the merge with `git add` +
|
||
`git commit`.
|
||
- You can name the limit: a branch isolates tracked files, not your database, ignored files, or the
|
||
outside world.
|
||
|
||
When "let the agent try something wild" feels like a one-line decision instead of a risk assessment,
|
||
you've got it. Module 7 takes the next step: running several of these branches *live at the same
|
||
time* in separate working directories, so multiple agents can work in parallel without colliding.
|