Chapter 3. Version control and change review
Learning objectives
- Explain the relationship between the working tree, staging area, commit history and remote repository.
- Choose comparison points for a diff and identify out-of-scope edits and weakened checks in agent changes.
- Make commits with a single purpose, selecting changes even within one file.
- Choose restore, reset, revert or stash according to whether changes have been shared.
- Recover lost commits with reflog and resolve conflicts while preserving both changes' intentions.
Introduction
A coding agent can modify dozens of files in a few minutes. It is doing work that might take a person a whole day, but that also makes it easy to miss what changed. Its report may contain just one line, "Added a date filter," while in reality the entire date calculation function has been rewritten, a new package has been added and a test has quietly been turned off.
Git makes this difference visible. It shows what changed line by line and lets you record a state you are happy with so that you can return to it at any time. When working with an agent, Git is an essential safety net. It is precisely because there is always a point you can return to that you can entrust an agent with bold experiments.
This chapter starts with the basic structure of Git, then covers, in order, how to read and review diffs, use commits and branches, resolve conflicts and choose how to undo changes for a particular situation. Going deeper looks at recovering lost commits, rewriting shared history and running several agents at the same time.
Key concepts
Git: a time machine with save points
Git is a version control tool that records the history of changes to files. If you record each state you are happy with, as you would save points in a game, you can return to that state at any time. These save points are called commits, and each commit has a message explaining why the change was made. GitHub's documentation calls these records snapshots at particular points in time.
Changes pass through three areas in Git.
| Area | Analogy | Description |
|---|---|---|
| Working tree | Workbench | Where you are editing files now |
| Staging area (index) | Packing box | Holds the changes selected for the next commit |
| Commit history | Storage shelves | Stores finalized save points |
HEAD points to the latest commit on the checked-out branch: the save point where you currently stand. Inspect the current state with git status. In git status --short, M means a tracked file is modified, while ?? means a new file Git does not yet track. Get used to checking that this output is clean before delegating. Codex's documentation also recommends working on a feature branch, keeping git status clean before delegation and committing frequently for easy reversal.
Local history and remote repositories
GitHub's documentation explains that Git is distributed: every developer has a copy of the entire project and its history. The repository on your computer is local; the shared server repository, such as on GitHub, is the remote.
| Command | What it does |
|---|---|
git clone |
Downloads the full remote repository for the first time |
git fetch |
Retrieves new remote history without combining it with your work |
git pull |
Integrates retrieved history into your branch |
git push |
Uploads your local commits to the remote |
This matters because the way to undo a change depends on whether it has been shared. Unpushed commits are yours alone and can be removed or rewritten. Removing pushed commits that others have downloaded makes their history diverge from yours.
diff: which two states are compared?
A diff shows the differences between two states, line by line. The most accurate way to find out what an agent did is to examine the diff rather than its report. However, even with the same git diff command, what you see differs according to which two states you are comparing.
git diff # Working tree versus staging area: unstaged changes
git diff --staged # Staging area versus last commit: changes for the next commit
git diff HEAD # Working tree versus last commit: all changes since that commit
git diff main...feature # Changes on feature since the branches diverged
--staged means the same thing as --cached. For a large change, look at the summary first. --stat shows how many lines changed in each file, while --name-only shows only the names of the files that changed. If an agent has changed indentation or trailing whitespace in bulk and the real changes are buried among those edits, use -w to ignore differences in whitespace. To inspect a small change within a single line, use --word-diff to see the differences word by word.
Newly created files do not appear in git diff, because Git is not tracking them yet. To check whether the agent created new files, look for the ?? marker in git status. Stage them, then use git diff --staged to inspect their contents.
A diff review sequence and checklist
Trying to read every line in equal depth is tiring. Look at the scope first, then read the risky areas more closely.
- Check the scope. Use
git diff --statto scan how many files changed and where they are. If there are many changed files relative to the request, question that first. - Open risky areas first. If authentication, payments, permissions, configuration files, the database structure or the dependency lists (
package.jsonand the lock file) have changed, check that there is a clear reason for each change. - Look for removals. Find deleted tests, removed error handling and missing check steps.
- Inspect individual values. Small changes within a line can substantially alter behavior, such as reversing the sort order by changing
a.date - b.datetob.date - a.date. - Look for things that do not belong. Check for secrets, build outputs, temporary files and logs. Exclude such files from tracking with
.gitignore(Chapter 4). - Question changes you do not understand. Do not accept a change without an answer to "Why was this file changed?"
Changes that weaken checks
When asked to eliminate failures, agents sometimes weaken checks instead of fixing the app. That is like removing a fire alarm's battery because it is sounding. Look for these signs in the diff.
| Sign | What it looks like |
|---|---|
| Deleted tests | Whole test files or blocks disappear |
| Skipping | New it.skip, describe.skip or xit markers |
| Relaxed expectations | Specific comparisons become mere existence checks, or expected values change to actual values |
| Check-disabling comments | New comments disabling type checking or linting |
| Weaker configuration | Strict type settings are disabled or folders excluded from checks |
| Removed automation | Test or lint stages disappear from CI configuration |
Requirements sometimes change and tests must change too. Ask "Which requirement justifies this edit?" If none does, undo it.
Small commits with one purpose
GitHub's guide to its workflow says that each commit should have a description that makes clear what changed, and ideally each commit should contain one independent, complete change. This makes it easier to select and undo just that change later. If "copy changes" and "authentication logic changes" are mixed into one commit, even reverting only the authentication change requires you to pick out the relevant parts manually and verify the result again. In particular, do not mix formatting cleanup, such as indentation and quotation marks, with feature changes. When hundreds of lines of formatting change, the real changes are buried among them.
If an agent has left both necessary and unnecessary edits within a single file, use git add -p to select which ones to stage. Git presents changes in hunks, or contiguous groups of changed lines, and asks about them one at a time.
| Key | Meaning |
|---|---|
y |
Stage this hunk |
n |
Do not stage this hunk |
s |
Split it into smaller hunks |
e |
Edit the hunk directly in an editor |
q |
Quit; do not stage this or remaining hunks |
Commit messages should explain both what and why. Three months later, "Fix morning bookings rejected by format validation" is much more useful than "Fix bug."
Branches and the pull request flow
A branch is a separate line of work diverging from the main line, usually main. It is like doing a test shoot separately from the main production. GitHub flow recommends this sequence.
- Create a branch with a short, descriptive name. Use separate branches for unrelated changes.
- Add descriptive commits on the branch.
- Open a pull request (PR), summarize changes and request review. Respond to review by adding commits to the same branch.
- Once approved, merge and delete the branch. The PR and commit history remain.
Branches are particularly useful for large agent tasks. If the result is unsuitable, discard the branch while leaving main intact. If every PR gets a preview deployment (Chapter 5), inspect the actual screens before merging too.
Resolve conflicts by meaning
If two branches change the same line in the same file in different ways, Git stops because it does not know which side to choose. GitHub's documentation explains that conflicts occur when the same line has been changed differently, or when one side edits a file and the other deletes it. You must resolve every conflict before you can merge. The conflicting file contains markers.
<<<<<<< HEAD
const MAX_GUESTS = 8;
=======
const MAX_GUESTS = 6;
const WAITLIST_ENABLED = true;
>>>>>>> feature/waitlist
The content above ======= comes from the current branch (HEAD), and the content below it comes from the side you are trying to merge. Resolving a conflict means deciding on a result that preserves the intentions of both changes, rather than merely removing the markers. In this example, first find out why each decision was made: "Increase the capacity to 8 people" and "Enable the waiting list feature." Then write code that preserves both decisions. If you accept one side in its entirety, the decision on the other side quietly disappears.
When you ask an agent to resolve a conflict, explain the purpose of each of the two changes and have it rerun the tests after resolving it. If you cannot reach a decision while resolving the conflict, you can return to the state before the merge with git merge --abort. Git's documentation recommends committing or stashing changes before merging, because this command may not be able to restore uncommitted changes completely.
Choosing how to undo a change
Commands for undoing changes differ both in what they undo and in how they treat the history.
| Command | Target | When to use | Caution |
|---|---|---|---|
git restore <file> |
Changes in the working tree that have not been committed | When you want to discard the edits you just made | By default, restores the contents from the staging area. If you discard changes that were not staged, you cannot recover them through Git |
git restore --staged <file> |
The staging area | When you want to undo staging only | The edits in the working tree remain as they are |
git stash push -u |
Uncommitted changes and new untracked files (ignored files are included only with --all) |
When you want to set aside changes temporarily rather than discard them | apply applies the changes while keeping the entry in the stash list; pop removes the entry from the list if applying it succeeds |
git reset --soft <point> |
Only the commit history | When you want to combine several commits that have not been shared into one | The changes from the undone commits remain in the staging area |
git reset --hard <point> |
The history, staging area and entire working tree | When you want to discard unshared local commits in their entirety | Uncommitted changes are overwritten as well |
git revert <commit> |
A commit already recorded in the history | When you want to undo the effect of a shared commit | Records the inverse change as a new commit without deleting the history. The working tree must be clean |
reset also has a --keep mode. It discards commits while preserving uncommitted edits, and stops if a file whose contents differ between the current HEAD and the target point also has uncommitted edits. Git's documentation explicitly says not to use --hard on commits you have already given to other people.
Key point
The decision order is simple: before committing, use restore or stash; after committing but before sharing, reset; after pushing and others receiving the commits, revert.
A merge commit has two or more parents, so reverting it needs -m to specify the mainline parent, usually -m 1. Git's documentation warns that reverting a merge declares its changes unwanted in future too: merging the same branch later does not automatically reintroduce them.
Agent rewind features and Git
Claude Code creates a checkpoint that saves the file state every time you send a request that starts a new turn. When the input box is empty, press Esc twice, or use /rewind, to open a menu where you can return just the code, just the conversation or both to an earlier point. This is convenient, but the official documentation makes its limitations clear.
- Tracks only changes made through the agent's file-editing tools. Cannot restore changes from shell commands such as
rm,mvorcp. - Usually does not include manual edits or changes from other concurrent sessions.
- Files edited by subagents are generally not restored.
- Snapshots are cleaned up after about 30 days by default.
- Does not replace version control. Keep using Git for permanent history and collaboration.
Rewind is a quick aid for undoing mistakes within a session. The real safety net is a commit at every verified point. Codex protects .git as read-only even in modes allowing workspace writes, preventing the agent from damaging history itself.
Going deeper
Recovering lost commits: reflog
Even if an accidental git reset --hard removes commits from git log, you can usually recover them. Git records each branch-tip movement in the reflog, or reference log. Commits you thought were deleted remain in the repository for a while.
git reflog # List the positions HEAD has occupied
# a1c9e02 HEAD@{0}: reset: moving to HEAD~2
# 7f3b8d1 HEAD@{1}: commit: Add recurring payment notice
git branch rescue 7f3b8d1 # Recover the lost commit by attaching a branch
HEAD@{2} means "the place where HEAD was two movements ago." If you are undoing a reset you just performed, you can also use git reset --hard ORIG_HEAD (the position immediately before the reset remains in ORIG_HEAD; use it straight away because other commands, such as merge or rebase, overwrite this value). However, the reflog does not last forever. According to Git's documentation, the default retention period is 90 days for entries reachable from the current branch and 30 days for entries that are not reachable. Also, the reflog is a local record and cannot contain changes that were never committed. If --hard overwrote edits you had never even staged, you cannot recover them through Git. Previously staged contents may remain as Git objects, leaving a chance of finding them with git fsck --lost-found, but it is better not to rely on that.
Rewriting shared history
Before pushing, you can tidy up messy commits. For example, as shown in Git's documentation, running git reset --soft HEAD~5 followed by git commit combines the last five commits into one. The problem arises when you rewrite history that has already been pushed.
Pushing rewritten history requires a force push. git push --force overwrites the remote without considering its current state. If a colleague has pushed a new commit to the same branch while you were rewriting its history, that commit disappears. git push --force-with-lease overwrites the remote branch only if it is still at the point you last fetched; if it has moved in the meantime, the push is rejected. However, Git's documentation warns that an automatic git fetch running in the background can silently update the "last seen state" and defeat this protection. It describes using --force-if-includes alongside it as a way to address this.
GitHub blocks force pushes to protected branches by default. The best approach is to reduce the need to rewrite shared history: keep feature branches short-lived and tidy before pushing.
Another common mistake is committing directly on local main without a feature branch. Before pushing, first preserve the commits by creating a branch at the current position (git branch <new-branch>), reset main to match the remote (git reset --hard origin/main), then switch to the new branch and continue. Order matters: resetting before creating the branch leaves the commits reachable only through reflog.
Running several agents at once
When several agent sessions work in one folder at the same time, they take turns editing the same files, and different tasks can easily become tangled together in one commit. It is like two cooks sharing a single chopping board in one kitchen. The solution is a worktree. Git's documentation explains that you can attach multiple working trees to one repository, allowing different branches to be checked out at the same time.
git worktree add -b feature/coupon ../shop-coupon # New branch in a new folder
git worktree list
git worktree remove ../shop-coupon # Clean up when finished
With claude --worktree <name>, Claude Code creates a worktree for a new branch under .claude/worktrees/<name>/ in the repository, then starts the session inside it. Each session has its own copy of the files, so their edits do not collide. A worktree is a freshly checked-out copy and therefore lacks ignored files such as .env. Claude Code's documentation advises using .worktreeinclude, a configuration file specific to this tool, to copy the necessary files, and adding .claude/worktrees/ to .gitignore.
Concurrent sessions help review too. Claude Code's best practices say fresh context improves review because it is less biased toward freshly written code. One session can implement while another reviews, or a subagent can receive only the plan file and diff to find missing requirements and out-of-scope changes. The documentation also cautions that reviewers asked to find gaps tend to report something even in sound work; accept only findings affecting correctness or requirements.
At work
Wind Path Bikes: nine changed files for a single sort feature
Seoyeon, who operates the shared bicycle rental station "Wind Path Bikes," asked an agent, "Sort the rental list by expected return time." The agent reported completion, and the tests passed as well. Following her usual habit, Seoyeon began by looking at git diff --stat. A task that seemed to need only two files had changed nine files.
A quick review revealed three kinds of changes. The edits to the list screen and sorting function matched the request exactly. Five files contained only formatting cleanup: changes to quotation marks and indentation. In addition, a date library had been added to package.json and the lock file, and it.skip had been added to a return-time calculation test. A test marked with skip is not executed and is only reported as skipped, so skipping that test was part of the reason the tests had passed.
Seoyeon considered three options.
- Accept everything: convenient because tests passed, but it would record a disabled test and unexplained dependency. Rejected.
- Discard everything and delegate again: clean, but it would throw away the correct sorting changes too. Rejected.
- Select changes: commit only what is needed and undo the rest. Selected.
Asked why, the agent said standard date comparison made timezone handling cumbersome, so it added a library whose different results then led it to skip the old test. Seoyeon used -w to ignore formatting and reexamined the sort function. Existing date comparison was sufficient. She requested a fix without the library, restored the skipped test and ran all tests. Since formatting also appeared in the sort function file, she used git add -p: y for the sorting hunk, n for formatting. She restored the other formatting files with git restore.
She left the commit message "Sort rentals by expected return time." If formatting cleanup was needed, she decided to do that separately next time in a commit containing only those changes. If she had accepted the work merely because the tests passed, a disabled check and a dependency nobody knew about would have been mixed into the history.
Hanbit Choir: Friday's commit, Monday's discovery
Taemin, secretary of "Hanbit Choir" and operator of its attendance app, received a Monday morning report that attendance rates looked unusually low. They had been correct Friday afternoon. Since then, eleven commits from Taemin and two volunteers had entered main, all pushed remotely.
There were too many commits to open each one to find the cause. Since there was no test for the attendance rate, Taemin first asked the agent to write a small script that calculated the rate using Friday's data and compared it against an expected value. The script returned exit code 0 if the values matched and 1 if they differed. For a commit that could not be checked because it would not even build, it returned 125 so that commit would be skipped. He then ran Git's binary search.
git bisect start HEAD <Friday-good-commit>
git bisect run node scripts/check-attendance.mjs
git bisect reset
git bisect checks out a commit midway between good and bad, runs the script and halves the remaining range according to the result. Four checks found the culprit: a commit adding absence reasons had changed a condition to count excused absences as absences.
A volunteer proposed resetting all history to Friday and overwriting the remote. Taemin refused. Removing history already received by two others would diverge from their copies and discard ten unrelated commits. He used git revert for the offending commit alone.
A conflict occurred because a later volunteer commit had added lateness handling to the same function. Before deleting markers, Taemin clarified both intentions: undo counting excused absences as absences, preserve lateness handling. He wrote code preserving both, reran the attendance script and all tests, then pushed. He converted the attendance script into a test so CI would catch a recurrence.
Common mistakes
- "Tests passed, so I do not need to review the diff." A passing result is reassuring. However, passing does not mean that the scope of the changes is right. The tests may have passed because some tests were deleted or disabled.
- "Deleting the bad commit with reset and force-pushing keeps things tidy." The history may look clean. However, rewriting shared history makes it diverge from your colleagues' history and can even erase their work. Use revert to undo shared commits.
- "One commit at the end of the day is enough." Fewer commits can make the record look simpler. However, it is difficult to select and undo only part of a large commit, and you must verify the selected result again. Commit each small unit once it has been verified.
- "If it does not appear in
git diff, nothing has changed." Quiet command output can make the state feel clean. However, new files do not appear because they are not tracked yet, and staged changes appear only when you use--staged. Look atgit statusas well. - "Accepting one side in full is a quick way to finish resolving a conflict." The markers disappear quickly. However, the decisions on the other side are silently erased. Check the intentions of both sides, preserve both and then rerun the tests.
- "With the agent's rewind feature, I can commit less often." Rewind is convenient. However, it cannot undo files changed by shell commands or edits made in another session, and it does not replace Git.
Summary at a glance
| Topic | Remember |
|---|---|
| Three areas | Working tree → staging → commit. Inspect with git status; ?? means a new file |
| Sharing | Rewrite before pushing if needed; use revert after sharing |
| diff | Plain git diff: unstaged; --staged: next commit; HEAD: all changes since commit |
| Review order | --stat → risky files → removals → individual values → things that do not belong |
| Weakened checks | Deletions, skip, relaxed expectations, disabling comments, weaker settings, removed CI steps |
| Commits | One purpose; separate formatting and behavior; select with git add -p; explain why |
| Conflicts | Preserve both intentions, not just remove markers. If unsure, git merge --abort |
| Undo | Uncommitted: restore or stash; unshared: reset; shared: revert. Merge commits need -m |
| Recovery | Find the position in reflog and attach a branch. Uncommitted changes are not included |
| Concurrent work | A worktree per session. Review in fresh context |
Check the official docs
- GitHub: About Git — distributed version control, commits, branches and basic flow
- GitHub: GitHub flow — branches, commits, PRs, merging and branch deletion
- Git: git-diff — the different forms of comparison and the
--staged,--stat,-w, and--word-diffoptions - Git: git-reset —
--soft,--mixed,--hard,--keep - Git: git-revert — recording an inverse change as a commit, and using
-mwith merge commits - Git: git-reflog — reference logs and how long they are retained
- Git: git-merge — conflict markers, resolution and
--abort - Claude Code: Checkpointing — checkpoint scope and limitations
Self-check
3-1. Which two states does each of git diff, git diff --staged and git diff HEAD compare? Which command should you use to see both staged and unstaged changes together at once?
3-2. An agent reports that it created a new file named src/lib/receipt.ts, but nothing appears in git diff. Why is that? What should you do to view the contents of this file as a diff?
3-3. While fixing a feature, an agent changes indentation from tabs to spaces in ten files. Thousands of diff lines obscure the real change. Which option shows the meaningful changes? How should the commits be divided?
3-4. You want to revert a merge commit that integrated a feature branch into an already-shared main. What extra argument does git revert need? What should you watch for when later fixing and remerging that branch?
3-5. You stash experimental changes and want to apply them to two other branches. Which is suitable, git stash apply or git stash pop, and why?
3-6. You want to discard two local commits that have not yet been pushed. However, the working tree also contains uncommitted edits to other files, and you want to keep those edits. Which reset mode can you use instead of --hard, and under what circumstances does that command stop?
3-7. You are writing a checking script to pass to git bisect run. Some commits will not even build, so you cannot judge whether they work correctly. What exit code should the script return for those commits?
Hands-on tasks
Exercise
Four ways to undo, plus recovery (45 minutes)
- Create a repository in a new folder with
git init. Edit one file three times, committing each time. - Leave a fourth edit uncommitted, set it aside with
git stash push, restore it withgit stash apply, then discard it withgit restore. - Revert the second commit with
git revertand confirm a new commit ingit log --oneline. - On a new branch, make a commit and discard it with
git reset --hard HEAD~1. - Find the discarded commit in
git reflogand recover it withgit branch rescue <hash>.
Done when: git log --oneline output has been recorded at every step, and you can use the output to demonstrate both the difference that revert adds to the history while reset reduces it and the fact that the recovered commit is on the rescue branch.
Exercise
Select agent changes for commits (45 minutes)
- Delegate a small feature in a practice app, deliberately adding "Tidy the formatting while you are there."
- Before committing, use
git diff --stat,git diff -wandgit statusto classify behavior changes, formatting changes and new files in a table. - Look for weakened checks—deletion, skip or changed expectations—and ask why if present.
- Use
git add -pto commit only behavior first, then formatting in a separate second commit. - Run tests at each commit and confirm they pass.
Done when: the two commits each contain only one purpose (check with git show --stat), the change classification table and the result of checking whether any checks were weakened are saved, and there is output showing that the tests passed at both commits.