Lesson 02 / 15
Branch Creation and Switching
Opening a branch and switching to it are separate operations: the switch only rewrites files that differ between the two branches, and of the twelve remaining-change cases, 3 move along, 5 block the switch, 4 are left behind; none is silently lost.
Contents
The previous lesson measured branching’s cost: opening three branches writes 0 new
commits and 123 bytes. The measurement deliberately left one thing out. Once a
branch name is written, there is a new reference under refs/heads, but not a single
file in the working directory has changed. HEAD still points to the old branch. The
name has been written; it has not been switched to.
This lesson’s question is the switch itself. Switching is not just writing a reference; it has to bring the working directory and staging area to what the target branch sees. Two questions arise from this: how many files does the switch touch, and if there is an uncommitted change in hand at that moment, what happens to it? The second question has no single answer: the outcome depends on the change’s type and whether the file differs between the two branches.
Two Separate Commands
Creating a branch and switching to it are two separate invocations of the tool. The
first only writes the reference; the second updates HEAD, the staging area, and the
working directory together.
# taught command and example dump — not executed $ git branch metrics $ git status --short --branch ## main $ git switch metrics Switched to branch 'metrics' $ git status --short --branch ## metrics
After the first call, git status still says main: the branch exists, but has not
been switched to. After the second call, the same line says metrics. No file name
appears in between, because there is no difference between the two branches yet; the
measurement will shortly count the case where there is a difference.
There is a shortcut that does both jobs in one call: git switch -c metrics first
writes the reference, then switches to it; the older form is git checkout -b metrics.
git checkout is an overloaded name — the same command both switches branches and
restores a single file, and the distinction can only be read from the shape of the
call. git switch and git restore split these two jobs into two names. This course
uses the separate names throughout.
The Switch’s Three Steps
Switching to a branch is three separate write operations, and the three behave as a single operation: if one fails, none of them are applied.
The first is rewriting HEAD. The previous lesson established that HEAD points not
to a commit but to a branch name; switching changes this symbolic reference’s content,
and its cost is a single line. The second is rebuilding the staging area to match the
target branch’s tree. The third is the working directory: files that are different
on the target branch are replaced with the target’s version.
The scope of the third step is critical. The switch does not rewrite all of the branch’s files, only the ones that are different between the two branches. The difference is of two kinds: content difference (the file exists on both branches, but their versions differ) and existence difference (the file exists on one branch, not on the other). In the second case, the switch either creates a file or deletes one. This is why the switch’s cost depends not on the branch’s size but on the difference — and the top table counts exactly this.
The Change Left in Hand: Three Rules
If there is an uncommitted change in the working tree at the moment of switching, the tool’s behavior is determined by three rules.
First rule. If the file is identical between the two branches, the switch does not touch it. Every uncommitted change on it — whether in the working directory or already staged — appears unchanged on the new branch. This is usually the wanted behavior: a small fix started on one branch comes along with you if that file has not changed on the other branch.
Second rule. If the file is different between the two branches, the switch has
to rewrite it. If there is an uncommitted change on it, the tool stops the switch
instead of overwriting that change. Nothing is written, HEAD stays in place, and the
error message says which file is blocking it.
Third rule. An untracked file sits outside history and is in no branch’s tree. If the target branch has no file by the same name, it stays put through the switch. If a tracked file by the same name exists, the switch would write over it, and it is stopped again. The subtle point here is that the file blocks the switch even though it sits outside history, because the block’s cause is not the file’s record but the clash of its name.
# taught command and example dump — not executed $ git switch report error: Your local changes to the following files would be overwritten by checkout: report.py Please commit your changes or stash them before you switch branches. Aborting $ git switch report error: The following untracked working tree files would be overwritten by checkout: config.py Please move or remove them before you switch branches. Aborting
The two messages come from two separate rules, and both report the same outcome: the switch did not happen. The second message’s suggested fix is also different — an untracked file cannot be committed or stashed, so it asks for it to be moved or removed.
Three Ways Out
A blocked switch has four options in front of it, and three of them preserve the change.
The first is committing the change. The change is written to the old branch’s tip, becomes reachable from a reference, and the switch is freed. The change does not come along with the switch; it stays on the old branch and is found there when you return to it.
The second is taking it to the stash. git stash push takes the uncommitted change
out of the working tree and writes it to a separate reference under refs/stash; the
tree is cleaned, and the switch is freed. git stash list lists the stack, git stash pop reapplies the top one. A stash is not tied to a branch — opening it on a different
one can produce a conflict if the file differs there. This behavior belongs to the
Merging topic and is covered there.
The third is forcing the change to move: the git switch -m option tries to merge
the uncommitted change with the target branch’s version. When it succeeds, the change
appears on the new branch; when it does not, it leaves a conflict in the working tree
that has to be resolved. This lesson does not resolve conflicts and mentions the option
only by name.
The fourth is discarding the change. The git switch command has an option for
this, and it deletes the uncommitted change irrecoverably; the change was never written
to a commit or a reference, so there is nowhere to recover it from. This lesson does
not write that option in executable form. The safe habit: stash before discarding — a
stash can be undone, a deletion cannot.
The measurement’s assumptions:
- BR7 — The setup is the shared definition’s twelve commits and is not changed.
The base tree has three files:
metrics.py,report.py,identity.py.config.pyis not in the base; on the third step,metricsandreportadd it, so it is not found in theidentitybranch’s tree. - BR8 — A branch’s working tree is the file versions seen from that branch’s tip: the version left by the branch’s last commit, or the base version if untouched.
- BR9 — The switch only rewrites the file that differs between the two branches. The difference covers both content difference and existence difference.
- BR10 — Three rules apply: a change on an identical file moves along; an uncommitted change on a different file blocks the switch; an untracked file moves along if the target has no file by the same name, and blocks the switch if it does.
- BR11 — Twelve cases are tried in a single switch, from the
identitybranch to thereportbranch. Five kinds of change are counted: modified in the working directory, staged, untracked, committed, stashed. - BR12 — A committed or stashed change is of no concern to the switch: both are already written to a reference. The switch does not move them, it leaves them where they are; the measurement counts this outcome as left behind.
- BR13 — A silent loss is a change disappearing without the user being told, and it is counted separately. A blocked switch is not a loss; nothing was written.
- BR14 — The measurement is not a filesystem measurement. Two things are counted: the file the switch has to rewrite, and where the state left in hand at the moment of switching ends up.
Measurement
"""Creating a branch and switching to it are separate operations. Part 1 - the file difference between two branches: which files the switch rewrites. Part 2 - twelve cases: where the change left in hand ends up after the switch. """ SEED = 20260813 BRANCHES = ("metrics", "report", "identity") FILES = {"metrics": "metrics.py", "report": "report.py", "identity": "identity.py"} BUGGY = ("report", 2) SHARED_FILE = "config.py" BASE = ("metrics.py", "report.py", "identity.py") # config.py is not in the base def rng(seed): state = seed % 2147483646 + 1 def draw(n): nonlocal state state = (state * 48271) % 2147483647 return state % n return draw def development(): draw, record, time = rng(SEED), [], 0 for step in range(1, 5): for branch in BRANCHES: time += 1 + draw(3) file = (SHARED_FILE if step == 3 and branch in ("metrics", "report") else FILES[branch]) record.append({"branch": branch, "step": step, "file": file, "buggy": (branch, step) == BUGGY, "time": time}) return record def tree(record, branch): """Working tree at a branch's tip: file -> version.""" s = {d: "base" for d in BASE} for k in record: if k["branch"] == branch: s[k["file"]] = f"{branch}{k['step']}" return s def diff(record, source, target): """Files the switch has to rewrite.""" a, b = tree(record, source), tree(record, target) return sorted(d for d in set(a) | set(b) if a.get(d) != b.get(d)) SOURCE, TARGET = "identity", "report" CASES = (("metrics.py", "working directory"), ("report.py", "working directory"), ("identity.py", "working directory"), ("metrics.py", "staging area"), ("report.py", "staging area"), ("identity.py", "staging area"), ("note.txt", "untracked"), ("config.py", "untracked"), ("report.py", "committed"), ("identity.py", "committed"), ("report.py", "stashed"), ("identity.py", "stashed")) LOCATION = {"moves": "in the working tree", "blocked": "on the old branch, switch did not happen", "committed": "at the old branch's tip", "stashed": "on the stash stack"} def outcome(file, kind, differing, target_tree): if kind in ("committed", "stashed"): return "left behind" if kind == "untracked": return "blocked" if file in target_tree else "moves" return "blocked" if file in differing else "moves" record = development() print(f"setup: {len(record)} commits, {len(BRANCHES)} branches, base tree " f"{len(BASE)} files, files branches touch " f"{len({k['file'] for k in record})}") print() print(f"{'switch':<20s}{'files rewritten':>19s} which ones") for a, b in [("base", d) for d in BRANCHES] + [("identity", "report"), ("metrics", "report")]: f = diff(record, a, b) print(f" {a + ' -> ' + b:<18s}{len(f):19d} {', '.join(f)}") differing, target_tree = set(diff(record, SOURCE, TARGET)), tree(record, TARGET) print() print(f"switch {SOURCE} -> {TARGET}: twelve remaining cases") print(f"{' what is in hand':<34s}{'on target':<11s}{'outcome':<14s}where the change goes") counts = {} for file, kind in CASES: if kind in ("committed", "stashed"): on_target = "-" elif file not in target_tree: on_target = "absent" elif kind == "untracked": on_target = "present" else: on_target = "different" if file in differing else "identical" s = outcome(file, kind, differing, target_tree) counts[s] = counts.get(s, 0) + 1 print(f" {file + ' (' + kind + ')':<32s}{on_target:<11s}{s:<14s}" f"{LOCATION[kind] if s == 'left behind' else LOCATION[s]}") print() for s in ("moves", "blocked", "left behind"): print(f"{s:<14s}{counts.get(s, 0):3d} / {len(CASES)}") print(f"{'silent loss':<14s}{0:3d} / {len(CASES)}")
setup: 12 commits, 3 branches, base tree 3 files, files branches touch 4 switch files rewritten which ones base -> metrics 2 config.py, metrics.py base -> report 2 config.py, report.py base -> identity 1 identity.py identity -> report 3 config.py, identity.py, report.py metrics -> report 3 config.py, metrics.py, report.py switch identity -> report: twelve remaining cases what is in hand on target outcome where the change goes metrics.py (working directory) identical moves in the working tree report.py (working directory) different blocked on the old branch, switch did not happen identity.py (working directory) different blocked on the old branch, switch did not happen metrics.py (staging area) identical moves in the working tree report.py (staging area) different blocked on the old branch, switch did not happen identity.py (staging area) different blocked on the old branch, switch did not happen note.txt (untracked) absent moves in the working tree config.py (untracked) present blocked on the old branch, switch did not happen report.py (committed) - left behind at the old branch's tip identity.py (committed) - left behind at the old branch's tip report.py (stashed) - left behind on the stash stack identity.py (stashed) - left behind on the stash stack moves 3 / 12 blocked 5 / 12 left behind 4 / 12 silent loss 0 / 12
Reading the Numbers
The top table gives the switch’s cost. Switching from the base to the identity branch
writes 1 file; switching to the metrics and report branches writes 2.
Switches between two branches write 3 files, because each branch’s own file and the
shared config.py file both differ. The tree’s fourth file stays put on every row.
This number completes the previous lesson’s measure. Opening a branch wrote 41 bytes and was independent of the tree’s size. Switching to a branch touches the tree, but not the whole tree: in a four-thousand-file tree, if two branches diverge on three files, the switch writes 3 files. In the copy model, the same job would require copying all four thousand files. This is the pointer model’s counterpart at switch time — the cost is not the size of the tree, but the size of the difference.
The bottom table is the lesson’s main measure. 3 of the twelve cases move along, 5 block the switch, 4 are left behind. Silent loss is 0.
What the three that move along have in common is that the switch never touches that
file at all. metrics.py is at the base version on both branches, so it brings along
the change whether it is in the working directory or the staging area. note.txt is in
no branch’s tree and the switch does not care about it. In both cases, moving along is
not a capability, it is the result of not colliding.
The blocked five all have a single cause: the file is different between the two
branches, and the switch is going to rewrite it. The working directory and staging area
make no difference here — all four come from the same two files, report.py and
identity.py. The fifth, config.py, reaches the same outcome by a different path: it
is untracked and appears in no commit, but the target branch has a tracked file by the
same name. Sitting outside history does not mean not blocking the switch.
Zero silent loss is a design decision. The tool never deletes an uncommitted change without reporting it; it blocks instead. The 5/12 stopping rate is not a defect, it is the cost of that decision: when a switch gets blocked in your workflow, nothing has been lost, there is only something not yet recorded.
The four left behind are the user’s decision, not the tool’s, and there is a difference
between the two. A committed change sits at the old branch’s tip; it is reachable from
a branch reference and shows up when the branch name is listed. A stashed change sits
under refs/stash; it is reachable but shows up on no branch, and once forgotten it is
harder to find than searching branches. The case where a commit is reachable from no
branch reference at all is this topic’s fourth lesson’s subject.
In a twelve-case set, the smallest measurable difference is 1/12. The 2/12 gap between moved and blocked is twice this band and defensible; finer distinctions cannot be defended with this setup.
Summary
- Creating a branch only writes a reference; switching to it updates
HEAD, the staging area, and the working directory together.git switch -cdoes both jobs in one call. - The switch does not rewrite all of a branch’s files, only the ones that are different between the two branches; in the setup, this count is 1 to 2 files from the base to a branch, and 3 between branches.
- An uncommitted change on an identical file moves along; a change on a different file stops the switch; an untracked file only stops the switch if the target has a file by the same name.
- 3 of the twelve cases move along, 5 are blocked, 4 are left behind; silent loss is 0 — the tool stops the switch instead of deleting an uncommitted change.
- A blocked switch has three safe ways out: committing, stashing, and forcing the move with a merge; discarding is irreversible, and the stash is its safe counterpart.
Next Step
Which file a switch touches is read off the difference between two branches, computed
without looking at their names at all. The name, though, serves a different question.
In the measurement, the names identity and report never changed the switch’s
outcome; but with thirty branches in the same repository, which one belongs to which
piece of work, which one can be closed, which one is long-lived — all of this is read
from names alone. The next lesson measures a naming convention: feature grouping, the
first of history’s questions, answers yes once a branch record is kept, but if the
names are disorderly, the group itself still cannot be read — the measurement shows the
two conditions do not substitute for each other.
To keep your progress and take notes, Log in
My notes
Log in to take notes.