# 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 3 — Version Control for Words.** You first met `git branch`, `git switch -c`, `git merge`, and `git branch -d` there — on a markdown doc, where a mistake costs nothing and the merge always fast-forwarded. This module takes those same verbs to *code*, where branches actually diverge and merges can conflict. - **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 You already drove this loop once — `git switch -c`, `git merge`, `git branch -d` on a doc in Module 3, where the merge always fast-forwarded because nothing else had moved. Here the same verbs meet code that diverges and conflicts, so it's worth pinning down what a branch really is before we lean on it. 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 -b main` in Module 2, Git made one branch for you automatically — named `main` (the `-b main` is what guaranteed that name; in this course your repo is always on `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** (you saw the short version in Module 3). `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 | list | done <index> | stats]") ======= print("usage: python cli.py [add <title> | list | done <index> | purge]") >>>>>>> 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* `stats` and `purge`). 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 ~/ai-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. > **Starting state.** By now your `tasks-app` has accumulated commands from earlier modules, so your > `usage:` line is longer than the bare `[add <title> | list | done <index>]` you started with — and > that's fine. This lab works *regardless* of what's on that line, because the collision is just "two > branches each appended a different new command to the same usage line." To make it reproduce even on > a carried-forward app, we deliberately add two commands you **haven't** built yet — `stats` and > `purge`. (Any two brand-new commands would do; the point is the same line, edited two ways.) The > marker examples below show the shape; your real markers will carry your fuller usage string. 1. Make sure you're on a clean `main`. Create the first branch and have the AI add a `stats` command: ```bash git switch main git switch -c feature/stats ``` Ask the AI: *"Add a `stats` command to `cli.py` that prints how many tasks are total, done, and 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 stats 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/purge ``` Ask the AI: *"Add a `purge` command to `cli.py` that removes all completed (done) 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 purge 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/stats` into `feature/purge` (you're on `feature/purge`): ```bash git merge feature/stats ``` 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 (your usage string will be longer — it carries the commands from earlier modules — but the collision is exactly this: both branches appended a different new command to it): ```python <<<<<<< HEAD print("usage: python cli.py [add <title> | list | done <index> | purge]") ======= print("usage: python cli.py [add <title> | list | done <index> | stats]") >>>>>>> feature/stats ``` (The command bodies for `stats` and `purge` 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 > `stats` and `purge` 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> | stats | purge]") ``` **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 stats # both commands actually work python cli.py purge ``` 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. Copy it into your `tasks-app` > first (the course's lab scripts live in the course repo, not in `tasks-app` — see Module 4's > *You'll need*), then run it from inside the repo: > > ```bash > cp /path/to/modules/06-branches-sandboxes-for-experiments/lab/make-conflict.sh . > bash 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.