185 lines
16 KiB
Markdown
185 lines
16 KiB
Markdown
<!--
|
|
Suggested title: Let the AI Try Something Reckless: On a Branch
|
|
Alt title: Branches: A Sandbox the AI Can Wreck and You Can Throw Away
|
|
Slug: the-workflow-branches-sandboxes
|
|
Meta description: A Git branch is a disposable copy of your project where an AI agent can
|
|
try anything bold, and main never finds out unless you decide it
|
|
should. Here's how to spin one up, keep it, or delete it with zero risk.
|
|
Tags: AI, developer workflow, git, branches, merge conflicts, version control
|
|
-->
|
|
|
|
# Let the AI Try Something Reckless: On a Branch
|
|
|
|
There's a specific flavor of hesitation I want to talk you out of.
|
|
|
|
You've got an idea (*rewrite the storage layer*, *try a completely different CLI structure*, *add a feature that touches four files*) and you suspect the AI could just do it. But you're not sure it'll work, you're not sure you'll like it, and the thing it'd be operating on is your actual, working code. So you don't ask. Or you ask, get a sprawling multi-file change back, and now you're squinting at it going "...how do I undo all of *this* if it's wrong?"
|
|
|
|
That hesitation is the tax you pay for not having a sandbox. This post is about removing it.
|
|
|
|
If you're new here: this is part of [The Workflow](https://git.jpaul.io/justin/ai-workflow-course), a free course about all the engineering scaffolding *around* AI-generated code (the version control, the editor integration, the review reflex) that the model itself doesn't give you. A couple of posts back we [installed the safety net](https://git.jpaul.io/justin/ai-workflow-course): Git, framed as undo for the AI. That safety net was perfect for *one* bad edit: commit, then `git restore` if the AI makes a mess. Today we go one size up: isolating a *whole line of experimental work* so you can keep it or throw it away as a single unit. That's a branch.
|
|
|
|
## What a branch actually is (it's less than you think)
|
|
|
|
Strip the mystique and a branch is **a named, movable pointer to a commit.** That's the entire definition.
|
|
|
|
Your commit history is a chain of snapshots; you built that intuition with `git commit`. A branch is just a sticky label that points at one of those snapshots and slides forward every time you commit. When you ran `git init -b main` to start your repo, Git made one branch for you and named it `main`. Every commit since moved the `main` label forward. You've been "on a branch" this whole time without thinking about it.
|
|
|
|
Here's the part that surprises people with an ops background, because it cut against my instincts too: **creating a branch copies nothing.** No second folder. No duplicated files. No disk cost worth mentioning. Git writes a new label pointing at the same commit you're standing on, and that's it. Which is exactly *why* branches are cheap enough to be disposable, and disposable is the whole property we're after.
|
|
|
|
```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)
|
|
```
|
|
|
|
(Quick aside for anyone who's googled this before: you'll see `git checkout -b experiment` all over the internet. It does the same thing as `git switch -c experiment`. The newer `switch`/`restore` commands got split out of the old overloaded `checkout` so they actually say what they mean. Use whichever you like; I'll use `switch`.)
|
|
|
|
## The reframe: a branch is a scratch VM you can blow away
|
|
|
|
You already have the mental model for this, you just file it under different names. A branch is the Git equivalent of **a scratch VM you snapshot and roll back, a staging box 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.
|
|
|
|
Picture it as two tracks:
|
|
|
|
```
|
|
main: A───B───C (always runnable; 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` as a smoking crater at F and `main` genuinely does not care. When you're done, you make exactly 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 nothing happened.
|
|
|
|
That second path (*kill it, no trace*) is the one this whole concept exists for. It's the difference between "I now have to carefully undo everything the AI did" and "I delete the branch."
|
|
|
|
One more thing that feels like magic the first time: when you `git switch` to another branch, **Git rewrites the files in your folder to match it.** Switch to `experiment` and the AI's half-built feature appears in your editor. Switch back to `main` and it vanishes. Same folder, different contents, instantly. (This is also why Git won't let you switch with uncommitted changes that'd get clobbered; switching would silently throw work away. The fix is the habit you already have: commit before you switch.)
|
|
|
|
[insert a screenshot referencing `git log --oneline --graph` showing main and an experiment branch diverging here]
|
|
|
|
## The lab: let the AI go bold on `tasks-app`
|
|
|
|
Enough theory. The course runs on a tiny example app called `tasks-app` (a little command-line to-do tracker), and this is where branches stop being abstract. Make sure you're on a clean `main` first (`git status` should say "nothing to commit"), then spin up an experiment:
|
|
|
|
```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
|
|
```
|
|
|
|
Now give your editor-integrated 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 completely relaxed on a branch. Review what it did, then commit **on the branch**:
|
|
|
|
```bash
|
|
git diff # read what it actually changed
|
|
python3 cli.py add "ship module 6" --priority high
|
|
python3 cli.py add "water plants" --priority low
|
|
python3 cli.py list # see if priorities work and sort
|
|
git add .
|
|
git commit -m "Add task priorities (experiment)"
|
|
```
|
|
|
|
The payoff: prove the isolation. Switch back to `main` and watch the whole feature **disappear**:
|
|
|
|
```bash
|
|
git switch main
|
|
python3 cli.py list # no priorities: main is exactly as you left it
|
|
```
|
|
|
|
Sit with that for a second. Your bold change exists *only* on the branch. `main` never saw it. That's the entire point of the module in two commands.
|
|
|
|
## Decide its fate: keep it or kill it
|
|
|
|
**Keep it (merge):** switch to the branch you want to *receive* the work, then merge:
|
|
|
|
```bash
|
|
git switch main
|
|
git merge experiment/priorities # likely a fast-forward: main slides up to the branch
|
|
git log --oneline --graph # straight line = fast-forward
|
|
python3 cli.py list # the feature is now on main
|
|
git branch -d experiment/priorities # branch did its job; -d is the safe delete
|
|
```
|
|
|
|
Worth knowing there are two flavors of merge, and Git picks for you. If `main` hasn't moved since you branched, you get a **fast-forward**: Git just slides the `main` label up to F, history stays a straight line. If `main` *did* move on (you committed to it while the experiment was off doing its thing), the two lines diverged and Git stitches them with a **merge commit** that has two parents. You don't choose; you just recognize them in the graph (straight line vs. a visible fork-and-join).
|
|
|
|
**Kill it (discard):** this is the one I really want you to feel. The AI tried something, you looked, you don't want it. You don't undo anything. You don't `restore` file by file. You switch away and delete:
|
|
|
|
```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
|
|
```
|
|
|
|
That's it. Notice what you did *not* do: no file-by-file restore, no manual undo, no hunting through diffs. You deleted a label and the entire experiment was gone. **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 becomes your unit of "maybe."
|
|
|
|
## Merge conflicts: when two changes collide (and the AI resolves them before you see them)
|
|
|
|
Most merges just work; Git is genuinely good at combining changes that touch *different* lines. A **conflict** only happens when two branches changed the *same* lines in different ways, and Git refuses to guess which you meant. It stops and marks the collision right inside the file:
|
|
|
|
```python
|
|
<<<<<<< HEAD
|
|
print("usage: python3 cli.py [add <title> | list | done <index> | purge]")
|
|
=======
|
|
print("usage: python3 cli.py [add <title> | list | done <index> | stats]")
|
|
>>>>>>> feature/stats
|
|
```
|
|
|
|
Read it like this. Everything from `<<<<<<< HEAD` to `=======` is **your current branch's version**. Everything from `=======` to `>>>>>>> feature/stats` is **the incoming version**. The markers are real text Git inserted into your file. Resolving means editing the file so it holds the version you want (often a blend of both, here a usage string listing *both* commands) and deleting all three marker lines.
|
|
|
|
Here's the twist, and it's the reason I'm not going to hand you a "read the markers, edit them out" drill and call it a skill. You can manufacture exactly this collision in `tasks-app`: make one branch where the AI adds a `stats` command (updating the usage string), then a *separate* branch off `main` where it adds a `purge` command (also updating the usage string). Both edit the same line. Then tell a current editor-agent to "merge `feature/stats` into `feature/purge`," and watch what *doesn't* happen: it doesn't stop. It reads both sides, picks the resolution, finishes the merge, and reports a clean result, all in one turn. You never see a marker. From your chair the conflict simply didn't occur.
|
|
|
|
That's the sweet spot for the AI (a small, perfectly bounded reasoning task with both sides and the surrounding code right there) and it's also the trap. So do this once, deliberately, to see the machine: ask it to stop instead of resolving.
|
|
|
|
> *"Merge `feature/stats` into `feature/purge`. If it conflicts, stop and show me the conflict; don't resolve it yet."*
|
|
|
|
Now Git pauses on the unmerged file and you can read the markers above with your own eyes. Then `git merge --abort` to rewind, and let the agent do it for real with no guard rail, the way you actually would:
|
|
|
|
> *"Merge `feature/stats` into `feature/purge`; the usage line collides, and the final version should list BOTH commands."*
|
|
|
|
It resolves silently and the merge lands. And here is the only part that's still your job, conflict or no conflict:
|
|
|
|
```bash
|
|
git diff HEAD~1 # what the merge actually changed; confirm no markers, both commands present
|
|
python3 cli.py # run it: see the merged usage string
|
|
python3 cli.py stats && python3 cli.py purge # both actually work
|
|
```
|
|
|
|
That `git diff` after *every* merge is the whole skill now. Not "edit the markers by hand," which the AI did for you before you could blink, but "know a conflict can happen and check the silent resolution," because a resolution that runs cleanly can still be wrong and it won't leave an error behind to warn you. (And if your AI's edits didn't happen to collide (they're nondeterministic), the course ships a little `make-conflict.sh` helper that manufactures one deterministically so you can still see the markers at least once.)
|
|
|
|
## The AI angle: why this matters *more* now
|
|
|
|
Everything above is standard Git that predates the current AI wave by a decade. So why am I telling IT pros who already know Git to care? Because AI changes the cost-benefit:
|
|
|
|
- **The branch is the blast-radius container for an autonomous attempt.** An agent editing your files directly 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 hands-off the AI work, the more a branch earns its keep.
|
|
- **"Throw it away" is the feature, not the failure.** With copy-paste, a rejected AI attempt still cost you the manual paste-in and the manual rip-out. With a branch it costs *nothing*: `git branch -D` and 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 for approach A on one branch and approach B on another. Run both. Keep the winner. Cheap A/B experiments on *implementation*: painful without branches, trivial with them.
|
|
|
|
## Where this breaks (because I'd rather you trust me)
|
|
|
|
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 your app wrote to, files Git is ignoring, running processes, or anything outside version control. If the AI's experiment ran a migration or wrote to `tasks.json` (which is git-ignored), deleting the branch won't undo *that*. The sandbox is the repo, not the world.
|
|
- **Branches are local until you push them.** Everything here lives on your laptop. A branch isn't shared, backed up, or visible to anyone until there's a remote (that's a later post). Right now `git branch -D` permanently deletes work that exists nowhere else. 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 blending two behaviors into one that satisfies neither. The `git diff` + run-it check isn't ceremony; it's the actual safeguard.
|
|
- **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 three weeks is a future conflict, not a sandbox.
|
|
- **`-D` and `git merge --abort` are sharp tools.** Force-delete 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.
|
|
|
|
## You're done when
|
|
|
|
You've created a branch, let the AI make a multi-file change on it, and confirmed `main` was untouched by switching back and watching the change vanish. You've **discarded** an experiment with `git branch -D` and seen `main` show no trace, and you've **merged** one in and seen it land. You can explain in one sentence why a branch costs essentially nothing (it's a movable pointer, not a copy). And you've seen those `<<<<<<<` / `=======` / `>>>>>>>` markers at least once, then watched the AI merge for real and resolve the conflict silently, and you verified the result with `git diff` even though no marker was ever shown to you.
|
|
|
|
When "let the agent try something wild" feels like a one-line decision instead of a risk assessment, you've got it.
|
|
|
|
Next up: branches let you run *one* experiment at a time, because switching swaps your whole folder. The moment you want *two* agents working in parallel without stepping on each other, you've hit the edge of branches, and that's exactly what worktrees solve. That's the next post.
|
|
|
|
Tried this on a real experiment: kept one, threw one away? Tell me how it went in the comments. I read them, and the rough edges you hit are what make the course better.
|