Branches are cheap, and that is their virtue. It is also their problem: when creating one costs nothing, people create a lot of them, and after a few months Ana's repository holds branches for features that are already integrated, branches from experiments nobody remembers, branches with names like test or fix2 and one that has not seen a commit in six weeks.

A repository with forty branches of which only three are alive is not a technical problem — Git handles thousands without breaking a sweat — but it is a human one: nobody knows which is which, nobody dares delete anything for fear of losing work, and autocompletion stops being useful.

This lesson is the toolkit for keeping that under control: listing branches with the information you actually need, knowing which ones can be deleted without losing anything, renaming, deleting safely and giving them names that mean something. It is the least spectacular lesson of the module and probably the one you will apply most often.

Contents

  1. Listing branches: git branch and its variants
  2. --merged and --no-merged: what can be deleted without losing anything
  3. Custom listings with --sort and --format
  4. Renaming branches with -m
  5. Deleting branches: -d versus -D
  6. When the safe delete fails
  7. Naming conventions
  8. Which names Git accepts: git check-ref-format
  9. Hygiene: spotting stale branches
  10. Wrapping up the module

  1. Listing branches: git branch and its variants

You already know the base command:

git branch
  cleanup/remove-notes
  docs/update-notes
  experiment/indexeddb-storage
  feature/alphabetical-order
  feature/csv-export
  feature/pending-filter
  feature/task-counter
  fix/empty-list-message
* main
  test

Ten branches. The asterisk marks the current one. Sorted alphabetically, with no other information. It is a poor starting point: you cannot tell which ones are integrated, nor which have been idle for months.

-v: what sits at each tip

git branch -v
  cleanup/remove-notes          7f3c9a2 Remove the internal notes, now obsolete
  docs/update-notes             3f7a9c1 Update the internal notes with the new flow
  experiment/indexeddb-storage  5e8b1d4 Store tasks in IndexedDB
  feature/alphabetical-order    a1e5c93 Show tasks in alphabetical order
  feature/csv-export            e9a2c5f Now it works
  feature/pending-filter        b2e6d3f Restore field focus after adding a task
  feature/task-counter          9d1e4b7 Mark tasks as done on click
  fix/empty-list-message        6d3f8b2 Show a message when the list is empty
* main                          c2a8f1e Merge the empty-list message
  test                          8b6d3c2 Add base styles for the list

Now you can start reasoning. Look at test: it points at 8b6d3c2, an ancient commit from the very beginning of the project. It is a dead branch that was created and never used.

There is a doubled-up variant:

git branch -vv

It adds, in square brackets, each branch's tracking branch: which branch of the remote repository it is paired with and how many commits ahead or behind it is. Since everything in this module happens in a single local repository, there is nothing to show yet; -vv will come into its own in module 4, once work starts travelling between machines.

Other listing options

# Only branches that contain a given commit
git branch --contains a1e5c93

# Only branches that do NOT contain it
git branch --no-contains a1e5c93

# Filter by pattern (wildcards allowed)
git branch --list 'feature/*'

# Include remote branches too (module 4)
git branch -a

# Only the remote ones
git branch -r

--contains is especially useful for answering "which branches is this fix in?":

git branch --contains 6d3f8b2
  fix/empty-list-message
* main

The empty-list message fix is on its original branch and on main. Nowhere else.

  1. --merged and --no-merged: what can be deleted without losing anything

These two options are the heart of branch hygiene.

git branch --merged
  cleanup/remove-notes
  experiment/indexeddb-storage
  feature/alphabetical-order
  feature/pending-filter
  feature/task-counter
  fix/empty-list-message
* main
  test

The exact meaning, and it is worth being precise because it causes confusion: --merged lists the branches whose tip is reachable from the current branch. Put another way: all of their commits are already in main's history.

In practical terms: these branches can be deleted without losing a single commit.

Two cases deserve a comment:

  • test shows up here because its tip is 8b6d3c2, a commit from the beginning of the project that is obviously in main. It contributed nothing, which is why it counts as merged.
  • experiment/indexeddb-storage shows up too, even though its code never reached the project: we closed it in lesson 03-04 with git merge -s ours, which creates the merge commit without bringing in the content. That was precisely the point of that strategy: to stop the branch showing up as outstanding.

And now the other side:

git branch --no-merged
  docs/update-notes
  feature/csv-export

These branches have commits that main does not have. Deleting them would leave those commits with no reference reaching them. Why each one is here:

  • docs/update-notes: we resolved its modify/delete conflict in favour of deleting the file, so its commit never made it into main. Quite right that it appears here.
  • feature/csv-export: we integrated it with squash. Its content is in main, but its six original commits are not. Git is telling the truth: from the graph's point of view, it is not merged.

That last case is the most common trap: a branch integrated with squash always appears under --no-merged. If your team uses squash systematically, --merged stops being a reliable criterion for deleting and you have to keep track some other way.

Comparing against another branch

By default, both options compare against HEAD. You can name a different reference:

# Branches merged into main, even if I am not on main
git branch --merged main

# Branches whose work is already in version 1.2
git branch --merged v1.2.0

# Branches not yet integrated into main
git branch --no-merged main

That is the correct way to use them in scripts, because it does not depend on where you happen to be standing.

The cleanup pattern

This is the idiom you will see everywhere, and it is worth understanding piece by piece:

git branch --merged main | grep -v '^\*' | grep -v ' main$' | xargs -r git branch -d
  • git branch --merged main: the candidates.
  • grep -v '^\*': drops the current branch's line (the one with the asterisk).
  • grep -v ' main$': drops main from the list, just in case it tries to delete itself.
  • xargs -r git branch -d: deletes each one with the safe delete (-r stops anything running if the list comes back empty).

Always review the list before running it, by chopping off the last stage:

git branch --merged main | grep -v '^\*' | grep -v ' main$'

One important piece of advice: use -d and never -D in an automated command like this. -d refuses if something does not add up; -D deletes without asking.

  1. Custom listings with --sort and --format

Alphabetical order is not the most useful one. What you almost always want to know is which branches are alive, and that is a question about dates.

git branch --sort=-committerdate
* main
  fix/empty-list-message
  feature/alphabetical-order
  docs/update-notes
  cleanup/remove-notes
  feature/csv-export
  feature/pending-filter
  feature/task-counter
  experiment/indexeddb-storage
  test

The dash in front of committerdate means descending order: the most recent at the top. The ones at the bottom are the candidates for disappearing.

Common sort fields:

Field Sorts by
committerdate Date of the branch's last commit
authordate Author date of the last commit
refname Name (the default order)
-committerdate Date, most recent to oldest

And --format builds a listing to your own taste, using the same placeholders as git for-each-ref:

git branch --sort=-committerdate \
  --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) | %(committerdate:relative) | %(authorname) | %(contents:subject)'
* main | 2 hours ago | Ana Ferrer | Merge the empty-list message
  fix/empty-list-message | 3 hours ago | Bruno Salas | Show a message when the list is empty
  feature/alphabetical-order | 5 hours ago | Ana Ferrer | Show tasks in alphabetical order
  docs/update-notes | 2 days ago | Bruno Salas | Update the internal notes with the new flow
  cleanup/remove-notes | 2 days ago | Ana Ferrer | Remove the internal notes, now obsolete
  feature/csv-export | 6 days ago | Bruno Salas | Now it works
  feature/pending-filter | 3 weeks ago | Bruno Salas | Restore field focus after adding a task
  feature/task-counter | 3 weeks ago | Ana Ferrer | Mark tasks as done on click
  experiment/indexeddb-storage | 2 months ago | Ana Ferrer | Store tasks in IndexedDB
  test | 4 months ago | Ana Ferrer | Add base styles for the list

Now that is a useful view. At a glance you can see what is alive, who is driving it and what was last done.

The most practical placeholders:

Placeholder Content
%(HEAD) A * if it is the current branch, a space if not
%(refname:short) The branch name without refs/heads/
%(committerdate:relative) "3 weeks ago"
%(committerdate:short) 2026-07-10
%(authorname) Author of the last commit
%(contents:subject) First line of the message
%(objectname:short) Abbreviated hash
%(color:...) / %(color:reset) Colour

Since that line is impossible to remember, you save it as an alias:

git config --global alias.branches "branch --sort=-committerdate --format='%(HEAD) %(color:yellow)%(refname:short)%(color:reset) | %(committerdate:relative) | %(authorname) | %(contents:subject)'"
git branches

Aliases get their own lesson in module 6; this one is a strong candidate for your collection.

  1. Renaming branches with -m

git branch -m <old-name> <new-name>

A few days ago Bruno opened a branch called fix2 and now nobody knows what is in it. After looking through it, Ana gives it a name that says something:

git branch -m fix2 fix/focus-after-delete

If you want to rename the branch you are on, one argument is enough:

git switch feature/csv-export
git branch -m feature/export-to-csv

There is an uppercase variant, -M, which forces the rename even if a branch with the target name already exists, overwriting it. The usual warning applies: -M can orphan commits without warning you. Use -m unless you know exactly what you are doing.

What it actually does

Nothing surprising, this far into the module:

ls .git/refs/heads/
cat .git/HEAD

Renaming a branch means renaming the 41-byte file and, if it was the current branch, updating .git/HEAD to point at the new name. The commits are untouched: they are immutable and completely unaware.

That is why renaming is a perfectly safe operation locally. Once the branch has been shared with the team the picture changes, because everyone else still sees the old name; that belongs to module 4.

  1. Deleting branches: -d versus -D

git branch -d <branch>     # SAFE delete
git branch -D <branch>     # FORCED delete

The safe one, on an already integrated branch:

git branch -d feature/task-counter
Deleted branch feature/task-counter (was 9d1e4b7).

Notice that Git gives you the hash of where it was. That is not politeness: it is the piece of information that lets you recreate the branch if you got it wrong (git branch <name> 9d1e4b7). Copy it if you have the slightest doubt.

You can delete several at once:

git branch -d feature/pending-filter feature/alphabetical-order cleanup/remove-notes
Deleted branch feature/pending-filter (was b2e6d3f).
Deleted branch feature/alphabetical-order (was a1e5c93).
Deleted branch cleanup/remove-notes (was 7f3c9a2).

What exactly gets deleted

The 41-byte file. Nothing else.

ls .git/refs/heads/feature/

The commits remain intact in .git/objects/. The only thing that has disappeared is the name that pointed at them. If those commits are still reachable from main (which is the case when the branch was merged), absolutely nothing changes in the repository.

This is why deleting a merged branch is a trivial, risk-free operation, and why it deserves no reverence whatsoever.

Two limitations

You cannot delete the branch you are on:

git branch -d main
error: Cannot delete branch 'main' checked out at '/home/ana/projects/task-manager'

And, since Git 2.5, you cannot delete a branch that is checked out in another working copy either (git worktree, lesson 06-06). In both cases the fix is the same: git switch to another branch and try again.

  1. When the safe delete fails

git branch -d feature/export-to-csv
error: The branch 'feature/export-to-csv' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/export-to-csv'.

What that message means exactly: that branch's tip is not reachable from the current branch (nor from its tracking branch, if it had one). In other words, it holds commits that exist nowhere else in the graph.

And what it does NOT mean: it does not mean the work is lost, nor that the content is missing from main. Remember the squash case: the content of feature/export-to-csv is entirely in main, in commit 3b9e7d1, but its six original commits are not. Git reasons about the graph, not about the content.

Check before you force. This is the reflex to acquire:

# Which commits would be left without a reference?
git log --oneline main..feature/export-to-csv
e9a2c5f Now it works
7e2a9c4 Remove the console.log
1d6b4f8 wip 2
5a8e2b9 Fix the separator
9f3a2c1 wip
4a1c7e3 First attempt at exporting
# Is its content really in main?
git diff main feature/export-to-csv
(no output)

No differences: the content is identical. It went in via squash and nothing is lost. Now, with full knowledge of the facts:

git branch -D feature/export-to-csv
Deleted branch feature/export-to-csv (was e9a2c5f).

-D is recoverable (for a while)

A -D delete is not the end of the world, and it is worth knowing that so you can stop being afraid of it. The commits are still in the object database, and Git keeps a log called the reflog with every movement of every reference. As long as the hash can be recovered — which is why git branch -D prints it as it deletes — recreating the branch is all it takes:

git branch feature/export-to-csv e9a2c5f

Commits with no reference at all are eventually removed by garbage collection (git gc), but not straight away: the default window is 30 days for unreachable objects and 90 days for reflog entries. There is plenty of margin.

The whole rescue procedure — finding the hash with git reflog and git fsck --lost-found when you no longer have it written down — is covered in lesson 09-04. Take away the reassuring idea: -D is almost never irreversible.

-d -D
Checks whether it is merged Yes No
Fails if there are unintegrated commits Yes No
Prints the hash on deletion Yes Yes
Recoverable afterwards Yes (nothing is lost) Yes, via the reflog, for weeks
When to use it Always, by default Once you have checked and know what you are doing

  1. Naming conventions

Git imposes no structure on branch names beyond a handful of forbidden characters. What teams do is adopt slash-separated prefixes that group branches by purpose, exactly as we have been doing throughout the module.

Prefix What for Example
feature/ (or feat/) New functionality feature/csv-export
fix/ (or bugfix/) Fixing a defect fix/empty-list-message
hotfix/ Urgent fix on the version in production hotfix/save-error
experiment/ (or spike/) A trial that may end up dropped experiment/indexeddb-storage
docs/ (or doc/) Documentation only docs/installation-guide
refactor/ Reorganisation with no functional change refactor/split-tasks-module
chore/ (or cleanup/) Maintenance, dependencies, configuration cleanup/remove-notes
release/ Preparing a version release/1.3.0

Many teams add the issue tracker's ticket identifier, which lets you go from the branch to the specification and back:

feature/GT-142-csv-export
fix/GT-158-empty-list-message

And some include the name of whoever is driving it, which helps in large teams:

ana/feature/alphabetical-order
bruno/fix/field-focus

The practical advantage of the slash

It is not just cosmetic. The slash lets you filter:

git branch --list 'feature/*'
git branch --list 'fix/*'

And it makes autocompletion usable: you type git switch fea<TAB> and the shell offers you only that group.

It is also worth knowing why the slash has a limit: since names become paths inside .git/refs/heads/, a branch called feature and another called feature/order cannot both exist. The first would be a file and the second would require feature to be a directory. Git rejects it:

git branch feature
fatal: cannot lock ref 'refs/heads/feature': 'refs/heads/feature/alphabetical-order' exists; cannot create 'refs/heads/feature'

A cryptic error with a very simple explanation, and now you know it.

Style rules that help

  • Lower case and hyphens: feature/csv-export, not Feature/CSVExport. It avoids surprises between case-sensitive and case-insensitive file systems (remember that Ana is on Ubuntu, Bruno on macOS and Carla on Windows).
  • Plain ASCII, no accents. fix/cafe-list, not fix/café-list. Git accepts them, but they end up in URLs, in file names and in third-party tool output, where they cause trouble.
  • Descriptive but short: fix/focus-after-adding, not fix/fix-the-focus-problem-the-client-reported-on-tuesday.
  • No test, temp, new, fix2. In two weeks' time you will not know what they were, and that is why nobody will dare delete them.

The named workflows

There are whole methodologies that define which branches exist, what they are called, where they come from and where they get integrated. The best known:

  • Git Flow: develop, release/*, hotfix/* and feature/* branches over a main that only ever holds released versions.
  • GitHub Flow: a single long-lived branch (main) and short-lived working branches integrated through change proposals.
  • Trunk Based Development: everybody integrates into the trunk daily, with branches measured in hours rather than days.

Each has its own assumptions about team size, release frequency and level of automation. We will study them in module 7, where we will already have remotes and change proposals to understand them properly. For now, the point that matters: the specific convention matters far less than the whole team following the same one.

  1. Which names Git accepts: git check-ref-format

The rules are formally defined and you can check against them:

git check-ref-format --branch 'feature/csv-export'
feature/csv-export

If the name is valid, it prints it and returns 0. If not:

git check-ref-format --branch 'feature/csv export'
fatal: 'feature/csv export' is not a valid branch name

The main rules, with the reason behind each:

Forbidden Invalid example Why
Spaces and control characters my branch They break command-line use
Two consecutive dots .. branch..old .. is the range operator
The characters ~ ^ : ? * [ \ branch^2, branch:x They are reference syntax or wildcards
Starting or ending in /, or // /branch, branch//x They are not valid paths
Ending in . or in .lock branch., branch.lock .lock is Git's locking mechanism
The component @{ branch@{1} It is the reflog syntax
A lone @ @ It is an alias for HEAD
Starting a component with . .hidden It would be a hidden file

A curious detail, consistent with everything you have learnt: most of these rules exist because a branch name is simultaneously a file path and an expression Git has to be able to parse. Forbidding ^ is not a whim: if a branch called my^branch existed, git log my^branch would be ambiguous.

Checking it in a script:

NAME="feature/new-thing"
if git check-ref-format --branch "$NAME" > /dev/null 2>&1; then
  git switch -c "$NAME"
else
  echo "Invalid branch name: $NAME"
fi

This kind of validation is exactly what gets automated with a hook, to stop names that do not follow the team's convention getting in. Hooks are the subject of lesson 06-01.

  1. Hygiene: spotting stale branches

Let us pull it all together into a maintenance routine. Doing it once a month is enough.

Step 1: see the picture sorted by age.

git branch --sort=committerdate --format='%(committerdate:short) %(refname:short) %(authorname)'
2026-03-12 test Ana Ferrer
2026-05-28 experiment/indexeddb-storage Ana Ferrer
2026-07-10 feature/task-counter Ana Ferrer
2026-07-12 feature/pending-filter Bruno Salas
...

Without the dash the order is ascending: oldest at the top, which is what you want when you are hunting for deletion candidates.

Step 2: separate the integrated ones from the rest.

echo "=== Merged into main (safe to delete) ==="
git branch --merged main | grep -v ' main$'

echo "=== Not merged (check first) ==="
git branch --no-merged main

Step 3: go through the unmerged ones one by one. For each of them:

BRANCH=experiment/indexeddb-storage

# Which commits does it have that main does not?
git log --oneline main..$BRANCH

# How long since the last one?
git log -1 --format='%ar by %an' $BRANCH

# Is its content in main some other way (squash)?
git diff main..$BRANCH --stat

With those three pieces of information the decision is easy: if the content is already in main, use -D without fear; if it holds real, recent work, leave it; if it holds real work from months ago, talk to whoever opened it before touching anything.

Step 4: delete the integrated ones.

git branch --merged main | grep -v '^\*' | grep -v ' main$' | xargs -r git branch -d

Signs of a stale branch

Sign What it usually means
Merged into main It did its job; delete
No commits for over a month Abandoned work; ask
Generic name (test, temp, new) Nobody remembers what it was for
Its content is in main but it is not merged It went in via squash; delete with -D
Hundreds of commits behind main Merging it will be hell; rethink

And a deeper thought

The best branch management is not needing any: if branches live two or three days and get deleted on integration, the repository never accumulates rubbish. The monthly clean-up is a patch for a problem you avoid by working with short branches.

It is the same conclusion we reached about conflicts in the previous lesson, and that is no coincidence: short-lived branches solve divergence, conflicts and clutter all at once. It is the idea that underpins trunk-based development and a good part of module 7.

  1. Wrapping up the module

Let us recap what we have built across these six lessons.

We started from a promise made in module 2 — "a branch is a file with a hash inside" — and kept it right down to the byte: 41 of them, exactly. We saw that HEAD is another file that points at a branch, not at a commit, and that this indirection is what lets committing move the right pointer. We understood why Git's branches are free compared with Subversion's, and what it means for two branches to diverge from a common ancestor.

Then we created them and moved between them with git branch, git switch and git switch -c, understood why Git split checkout into two commands, what happens to uncommitted changes when you switch branch, and what detached HEAD is.

We put them back together with git merge, distinguishing the fast-forward — a pointer moving along — from the three-way merge that creates a commit with two parents, and we learnt to force or forbid each behaviour with --no-ff and --ff-only. We studied the strategies (ort, resolve, octopus, ours, subtree), the -X options, the critical difference between -s ours and -X ours, and the squash merge.

We resolved a real conflict: the markers, the zdiff3 style that shows the ancestor, the three index stages, git add as the way of saying "resolved" and git merge --abort as the escape key.

And we have finished by tidying up: listing, filtering, renaming, deleting with judgement and naming well.

The problem still open

And yet everything we have done in this module has happened inside a single laptop. Every branch lives in Ana's .git/. When we said "Bruno opened a branch", we were really reasoning as if his work were already there.

In the real world it is not. Ana works on Ubuntu, Bruno on macOS, and their repositories are two completely independent object databases that know nothing about each other. Bruno's commits are on his MacBook and Ana's on her Ubuntu machine, and no git merge in the world can merge something that is not in your own .git/objects/.

Some very concrete questions are still unanswered:

  • How do objects get from one repository to another?
  • Where does the "official" copy of the project live?
  • What exactly is that origin/main that flashed past in lesson 02-06, and why does its name contain a slash?
  • How does Bruno authenticate in order to send his work?
  • What happens if two people push changes to the same branch at once?
  • And above all: Carla still has not joined. She is waiting on her Windows 11 machine for someone to tell her where to download the project from.

Module 4: Working with Remote Repositories closes that circle. We will see what a remote is and why it is nothing more than a short name for a URL, how to register one with git remote add, how authentication works with SSH and with tokens, the crucial difference between git fetch and git pull, how to send work with git push, and what the tracking branches are that make git status tell you "your branch is 3 commits ahead of origin/main".

And the good news is that everything you have learnt in this module stays true, unchanged. Merging someone else's work is exactly the same git merge you have used here. Conflicts are resolved the same way. The strategies are the same. All that gets added is transport: how objects manage to travel from one .git/ to another. Once they have arrived, everything works the way you already know.

Common Mistakes and Tips

Mistake 1: reading --no-merged as "there is lost work here". It only means that the branch's tip is not reachable from where you are standing. A branch integrated with squash will always appear there even though its content is entirely in main. Check with git diff main..<branch> before drawing conclusions.

Mistake 2: reaching for -D out of habit because -d "keeps nagging". That nagging is the safety net. Every time -d fails it is telling you something worth thirty seconds of checking. Save -D for after you have looked.

Mistake 3: not noting down the hash the delete prints. Deleted branch X (was 9d1e4b7) is your return ticket. If you realise a minute later that you got it wrong, git branch X 9d1e4b7 fixes it. Without the hash, you have to go to the reflog.

Mistake 4: creating a branch named after an existing branch directory. If you have feature/alphabetical-order, you cannot create feature. The error Git gives is cryptic, but the cause is that names are file paths.

Mistake 5: hoarding branches "just in case". The cost is not the space (they are 41 bytes) but the confusion: nobody knows which are alive, autocompletion becomes useless and the listings stop being readable. If the branch is merged, delete it; the commits are not going anywhere.

Tip 1: save the pretty listing as an alias. git branches with a relative date, an author and the last message is the view you will genuinely use. The full line is in section 3.

Tip 2: do the monthly clean-up, and do it in two steps. List first, review with your own eyes, then delete. Never in a single chained command without looking.

Tip 3: name branches with the person who will see them in a month in mind. Include the type prefix and, if the team uses an issue tracker, its identifier. It is the difference between a branch list that makes sense and one nobody dares touch.

Tip 4: delete the branch as soon as you integrate it. Making that the last step of the merge ritual — merge, check, delete — means the monthly clean-up never becomes necessary at all.

Exercises

Exercise 1: auditing a repository

In a repository with several branches (create them if you need to), answer with commands:

  1. Which branches can be deleted right now without losing any commit?
  2. Which branches hold unintegrated work, and how many commits is that in each case?
  3. Which branch has been idle the longest?
  4. Which branches contain a given commit?

Exercise 2: the squash case

Reproduce the situation that confuses --merged: integrate a branch with --squash and show with commands that:

  1. Its content is in main.
  2. Git considers it not merged.
  3. -d refuses to delete it and -D does not.
  4. After deleting it with -D, it can be recreated exactly where it was.

Exercise 3: validating branch names

Write a small script that takes a branch name and decides whether it is valid according to Git and according to a team convention requiring one of these prefixes: feature/, fix/, hotfix/ or docs/. Try it with at least four names, valid and invalid.

Solutions

Solution 1:

# 1. Branches deletable without losing anything
git branch --merged main | grep -v ' main$'
  feature/task-counter
  fix/empty-list-message
  test

Their tips are reachable from main: deleting them leaves no commit without a reference.

# 2. Branches with unintegrated work, with the count
for r in $(git branch --no-merged main --format='%(refname:short)'); do
  n=$(git rev-list --count main..$r)
  echo "$r: $n unintegrated commits"
done
docs/update-notes: 1 unintegrated commits
feature/export-to-csv: 6 unintegrated commits

git rev-list --count main..<branch> counts the commits in the range, with the same semantics you learnt in lesson 02-06.

# 3. The most idle branch: ascending order, first line
git branch --sort=committerdate --format='%(committerdate:short) %(refname:short)' | head -1
2026-03-12 test
# 4. Which branches contain a commit
git branch --contains 6d3f8b2
  fix/empty-list-message
* main

Solution 2:

mkdir /tmp/practice-management && cd /tmp/practice-management
git init -b main
echo "base" > f.txt && git add . && git commit -m "Base"

git switch -c feature/something
echo "step 1" >> f.txt && git commit -am "Step 1"
echo "step 2" >> f.txt && git commit -am "Step 2"
echo "step 3" >> f.txt && git commit -am "Step 3"

git switch main
git merge --squash feature/something
git commit -m "Add the complete feature"
# 1. The content IS in main
git diff main feature/something
(no output)

Zero differences: the files are identical.

# 2. But Git does not consider it merged
git branch --merged main
* main
git branch --no-merged main
  feature/something
git log --oneline main..feature/something
5f8b2e1 Step 3
2c9d4e6 Step 2
9f4c2a8 Step 1

Three commits that main does not have, even though their effect is there, condensed into a single different commit.

# 3. The safe delete fails
git branch -d feature/something
error: The branch 'feature/something' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature/something'.
git branch -D feature/something
Deleted branch feature/something (was 5f8b2e1).
# 4. Exact recreation with the hash Git gave us
git branch feature/something 5f8b2e1
git log --oneline -1 feature/something
5f8b2e1 Step 3

Identical to how it was. The commits never went anywhere: only the name pointing at them was deleted.

Solution 3:

#!/bin/bash
# validate-branch.sh — checks a name is valid for Git and for the team

NAME="$1"

if [ -z "$NAME" ]; then
  echo "Usage: $0 <branch-name>"
  exit 2
fi

# 1. Does Git accept it?
if ! git check-ref-format --branch "$NAME" > /dev/null 2>&1; then
  echo "REJECTED: '$NAME' is not a valid branch name for Git."
  exit 1
fi

# 2. Does it follow the team convention?
case "$NAME" in
  feature/*|fix/*|hotfix/*|docs/*)
    ;;
  *)
    echo "REJECTED: '$NAME' must start with feature/, fix/, hotfix/ or docs/."
    exit 1
    ;;
esac

# 3. Extra style rule: no capitals
if echo "$NAME" | grep -q '[A-Z]'; then
  echo "REJECTED: '$NAME' must not contain capital letters."
  exit 1
fi

echo "ACCEPTED: $NAME"
exit 0

Tests:

chmod +x validate-branch.sh

./validate-branch.sh 'feature/csv-export'
./validate-branch.sh 'fix/empty-list-message'
./validate-branch.sh 'fix2'
./validate-branch.sh 'feature/Export CSV'
./validate-branch.sh 'fix/branch..old'
ACCEPTED: feature/csv-export
ACCEPTED: fix/empty-list-message
REJECTED: 'fix2' must start with feature/, fix/, hotfix/ or docs/.
REJECTED: 'feature/Export CSV' is not a valid branch name for Git.
REJECTED: 'fix/branch..old' is not a valid branch name for Git.

Look at the fourth case: the space gets it rejected by git check-ref-format already, before we ever reach the capitals check. And the fifth is rejected for the .., which Git reserves for commit ranges.

This script is exactly what turns into a pre-commit or pre-push hook so that the convention enforces itself, without depending on anybody's discipline. We will see that in lesson 06-01.

Conclusion

This lesson closes module 3. What you have learnt here:

  • git branch -v shows each branch's tip and last message; -vv will add the tracking information once we have remotes (module 4).
  • --merged and --no-merged answer the key maintenance question: which branches can be deleted without losing any commit. Watch out for the ones integrated via squash, which always show up as unmerged even though their content is in main.
  • --sort=-committerdate with --format turns a useless alphabetical listing into a real view of what is alive, who is driving it and since when. Save it as an alias.
  • git branch -m renames: it renames the 41-byte file and, if it was the current branch, updates HEAD. Completely safe locally.
  • -d versus -D: the first checks that nothing gets lost and refuses if that is not the case; the second forces it through. When -d fails, it means the tip is not reachable from where you are, not necessarily that work will be lost. Check with git log main..<branch> and git diff main <branch> before forcing. -D is recoverable for weeks thanks to the reflog (module 9).
  • Deleting a branch deletes 41 bytes. The commits stay intact in the object database.
  • Naming conventions (feature/, fix/, hotfix/…) group, allow filtering and make autocompletion useful. Git's formal rules can be checked with git check-ref-format --branch.
  • Hygiene is a four-step routine: list by age, separate merged from unmerged, review the latter and delete the former. And the best hygiene of all is short-lived branches that never pile up.

What comes next

You have branches under control now: what they are, how to create them, how to merge them, how to resolve the clashes and how to keep the repository tidy. But all of it inside a single .git/.

In module 4: Working with Remote Repositories the project finally leaves Ana's laptop. We will see what a remote is, how to register one, how access is authenticated, the difference between git fetch and git pull, how to send work with git push and what tracking branches are. And Carla will finally join the team from her Windows 11 machine, cloning the project just as Bruno did in lesson 02-02, but this time into a repository that already has history, branches and a way of working.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved