task-manager is about to have its first stable version. The team wants to be able to say "this is 1.0.0" and, two years from now, when an issue arrives from a client still on that version, for anybody to be able to stand exactly on that code without having to remember a forty-character hash or rummage through git log by date.

That is what tags are for: permanent names pointing at one specific commit. Unlike a branch, which moves on every time you commit, a tag stays put. v1.0.0 means today, tomorrow and a decade from now exactly the same commit.

And there is a second thing to learn here: in lesson 01-04 we saw that Git's database stores four types of object — blob, tree, commit and tag — and about the fourth we said hardly anything. This lesson completes it. Because it turns out there are two very different kinds of tag, and the difference between them is exactly whether that tag object exists or not.

Contents

  1. What a tag is and how it differs from a branch
  2. Lightweight versus annotated tags
  3. Creating tags
  4. Listing, searching and examining tags
  5. Tagging a commit from the past
  6. Semantic versioning
  7. Pushing tags to the remote
  8. Deleting tags and why they do not move
  9. Signed tags
  10. git describe: naming any commit
  11. Working on a tag

  1. What a tag is and how it differs from a branch

Remember lesson 03-01: a branch is a 41-byte file in .git/refs/heads/ containing a hash. A tag is the same thing, but in .git/refs/tags/:

git tag v1.0.0
cat .git/refs/tags/v1.0.0
9f4c2e8b7d1a5f3c6e9b2d8a4f7c1e5b3d9a6f2c

The difference is not in the format but in the behaviour:

Branch Tag
Where it lives refs/heads/ refs/tags/
Does it move on its own? Yes: it advances with every commit No: never
Can HEAD point at it? Yes No: it leaves you in detached HEAD
Meaning "This is where I work" "This is one specific point"
Sent with git push Yes (as per the refspec) No by default
Can have metadata of its own No Yes, if it is annotated

That "does not move on its own" is the whole value of the tool. If you stand on v1.0.0 three years from now, you see exactly what was there when it was released, even if main has taken a thousand commits since.

gitGraph
   commit id: "1a4c8d6"
   commit id: "8b6d3c2"
   commit id: "9f4c2e8" tag: "v1.0.0"
   commit id: "c5d9b1e"
   commit id: "7d3a8f4" tag: "v1.0.1"
   commit id: "e91d4a8"

main carries on moving to the right; v1.0.0 and v1.0.1 stay nailed where they were put.

  1. Lightweight versus annotated tags

Here is the lesson's central concept.

A lightweight tag is exactly what you have just seen: a file with a hash in it. A bare pointer, nothing more. It creates no new object in the database.

An annotated tag creates a complete tag object in Git's database, with a hash of its own, containing: which commit it points at, who created it, when, a message, and optionally a cryptographic signature. And refs/tags/v1.0.0 points at that object, not at the commit.

Let us look at it with the tools of 01-04:

# Lightweight tag
git tag lightweight-1.0
git cat-file -t lightweight-1.0
commit

The reference resolves directly to a commit: there is no intermediate object.

# Annotated tag
git tag -a v1.0.0 -m "First stable version of task-manager"
git cat-file -t v1.0.0
tag
git cat-file -p v1.0.0
object 9f4c2e8b7d1a5f3c6e9b2d8a4f7c1e5b3d9a6f2c
type commit
tag v1.0.0
tagger Ana Ferrer <ana.ferrer@example.com> 1754035200 +0200

First stable version of task-manager

Includes the task list with persistence in localStorage,
the pending counter, the filter and the CSV export.

There it is, the fourth type of object, at last in its natural habitat. Note the structure: object (what it points at), type (what the pointed-at thing is), tag (the name), tagger (who and when) and the message. And like every Git object, it is immutable: its hash is computed over that content.

The full comparison:

Aspect Lightweight Annotated
How it is created git tag <name> git tag -a <name> -m "..."
Object in the database None A tag object
What refs/tags/<n> points at The commit The tag object
Tag's author Not stored Yes (tagger)
Tag's date Not stored Yes
Message No Yes
Can be signed (GPG/SSH) No Yes
Appears in git describe Only with --tags Yes, by default
Sent by --follow-tags No Yes
Recommended use Local, temporary markers Published versions, always

Why annotated tags are always used for releasing versions, in four concrete reasons:

  1. They record who and when. A v1.0.0 with no author and no date does not answer "who decided this was 1.0?".
  2. They carry a message. That message is the natural place for the release notes, and it travels with the repository.
  3. They can be signed. A signed tag allows cryptographic verification that the version was released by whoever it claims.
  4. Git treats them as first-class citizens. git describe prefers them, push --follow-tags only sends those, and several tools in the ecosystem expect them.

The practical rule is simple: lightweight tag = disposable personal marker; annotated tag = everything else.

  1. Creating tags

# Lightweight, on the current commit
git tag todays-tests

# Annotated, on the current commit (the normal form)
git tag -a v1.0.0 -m "First stable version of task-manager"

# Annotated with a long message: without -m, the editor opens
git tag -a v1.0.0

When the editor opens, write a subject on the first line, a blank line and the body. It is exactly the convention of commit messages (lesson 08-01):

task-manager 1.0.0

First stable version, ready for the client deployment.

Includes:
- Creating, listing and deleting tasks with persistence in localStorage
- Pending task counter
- Pending filter
- Export of the list to CSV

A warning about names: they are Git references, so the same rules as for branches apply (lesson 03-06). No spaces, no .., no ~, ^, :, ?, *, [, no @{, no ending in . or in .lock. The overwhelmingly dominant convention is v followed by the number: v1.0.0, v2.3.1.

And a precaution that avoids a classic problem: do not use the same name for a branch and a tag. If v1.0.0 exists both as a branch and as a tag, git checkout v1.0.0 is ambiguous, Git warns you and applies a precedence rule nobody remembers. We saw it in passing in 04-05 when discussing full refspecs; the solution is not to create the problem.

  1. Listing, searching and examining tags

git tag
v0.9.0
v1.0.0
v1.0.1
v1.1.0
v1.10.0
v1.2.0

Sorted alphabetically, which is the wrong order for versions: v1.10.0 comes out before v1.2.0. Git knows this and has a solution:

git tag --sort=-v:refname
v1.10.0
v1.2.0
v1.1.0
v1.0.1
v1.0.0
v0.9.0

v:refname sorts by understanding the semantics of version numbers, and the - reverses it to put the newest at the top. It is worth leaving configured:

git config --global tag.sort -v:refname

Filtering by pattern with -l (or --list), which accepts shell wildcards:

git tag -l "v1.0.*"
v1.0.0
v1.0.1
v1.0.2
git tag -l "v1.*" -l "v2.*"     # several patterns at once

Seeing each tag's information in the listing:

git tag -n            # the first line of the message
git tag -n5           # the first five
v0.9.0          Version prior to the first stable one
v1.0.0          task-manager 1.0.0
v1.0.1          Fix for the focus after deleting a task

Examining a tag thoroughly with git show:

git show v1.0.0
tag v1.0.0
Tagger: Ana Ferrer <ana.ferrer@example.com>
Date:   Fri Aug 1 10:00:00 2026 +0200

task-manager 1.0.0

First stable version, ready for the client deployment.

commit 9f4c2e8b7d1a5f3c6e9b2d8a4f7c1e5b3d9a6f2c
Author: Bruno Salas <bruno.salas@example.com>
Date:   Thu Jul 31 18:22:41 2026 +0200

    Add the export button to the action bar

diff --git a/index.html b/index.html
...

With an annotated tag you see two blocks: first the tag (with its tagger, its date and its message) and then the commit it points at with its diff. With a lightweight one you would see only the commit, because there is nothing else to see.

Other useful queries:

# Custom format, as in git branch --format (lesson 03-06)
git tag --format='%(refname:short) %(creatordate:short) %(subject)' --sort=-creatordate
v1.0.1 2026-08-05 Fix for the focus after deleting a task
v1.0.0 2026-08-01 task-manager 1.0.0
v0.9.0 2026-07-15 Version prior to the first stable one
# Which tags contain a commit (that is, which versions that change went into)
git tag --contains 7d3a8f4
v1.0.1
v1.1.0

That last one is among the most useful in the repertoire: it answers "which version was this fixed in?" without opening anything.

# What changed between two versions
git log --oneline v1.0.0..v1.1.0
git diff --stat v1.0.0 v1.1.0

Tags work like any other reference in ranges, diff, log and show. Everything you learned in lesson 02-06 applies here.

  1. Tagging a commit from the past

It is common to realise that something needs tagging after you have carried on working. You simply pass the commit:

git tag -a v0.9.0 8b6d3c2 -m "Version prior to the first stable one"
git show v0.9.0 | head -8
tag v0.9.0
Tagger: Ana Ferrer <ana.ferrer@example.com>
Date:   Fri Aug 1 10:14:33 2026 +0200

Version prior to the first stable one

commit 8b6d3c2...

One important detail is visible there: the tag's date is today's, not the commit's. That is correct: the tag was created today, even though it marks something from weeks ago. If for some reason you need them to match:

GIT_COMMITTER_DATE="2026-07-15T18:00:00" git tag -a v0.9.0 8b6d3c2 -m "..."

And since the commit can be given with any of the expressions you know (lesson 02-06), all of this is valid:

git tag -a v0.9.0 HEAD~5 -m "..."
git tag -a v0.9.0 main~10 -m "..."
git tag -a v0.9.0 feature/csv-export -m "..."

  1. Semantic versioning

Tagging v1.0.0 means nothing if the team does not share an understanding of what that number says. Semantic versioning (SemVer) is the most widespread convention for giving it meaning.

Format: MAJOR.MINOR.PATCH

Component Incremented when… Effect on the others
MAJOR There is a change that is incompatible with the previous version MINOR and PATCH go back to 0
MINOR Functionality is added that is backwards compatible PATCH goes back to 0
PATCH A bug is fixed without changing the expected behaviour

Applied to the real history of task-manager:

Version What changed Why that number
v0.1.0 Basic task list Before 1.0 there is no commitment to stability
v0.9.0 Everything planned, in testing Still a pre-release
v1.0.0 First stable version The compatibility commitment is taken on
v1.0.1 Focus after deleting fixed Just a fix: PATCH
v1.1.0 Pending filter added New functionality, nothing breaks: MINOR
v1.1.1 Counter fixed with the filter active PATCH
v1.2.0 CSV export added MINOR
v2.0.0 The localStorage format changes and 1.x data cannot be read Breaks compatibility: MAJOR

Two rules that are often overlooked:

  • Version 0.x.y is territory with no guarantees. Before 1.0.0, the convention explicitly says that anything may change. It is where to be while the design has not settled.
  • Once released, a version is never touched again. If v1.0.0 had a bug, you release v1.0.1. You never re-tag v1.0.0 (section 8).

SemVer also allows suffixes for pre-releases and build metadata:

git tag -a v2.0.0-alpha.1 -m "First alpha of version 2"
git tag -a v2.0.0-rc.1 -m "Release candidate"
git tag -a v2.0.0 -m "task-manager 2.0.0"

In SemVer's ordering, v2.0.0-alpha.1 < v2.0.0-rc.1 < v2.0.0: pre-release suffixes come before the final version. git tag --sort=-v:refname understands this correctly.

How it is decided what goes into each version, who approves it, how the notes are generated and how the release is automated are matters of team process and continuous integration: you will see them in modules 7 and 10. Here we are concerned with the Git mechanism.

  1. Pushing tags to the remote

In lesson 04-05 we left this open with a sentence that surprises everyone: git push does not send tags. Now we close the subject.

The reason is the default refspec (lesson 04-02): refs/heads/*:refs/heads/*. It only covers branches. refs/tags/* falls outside it.

git tag -a v1.0.0 -m "task-manager 1.0.0"
git push
Everything up-to-date

The tag has been created locally and there it stays. The three ways of sending it:

# 1. One specific tag
git push origin v1.0.0

# 2. ALL local tags
git push origin --tags

# 3. Only the ANNOTATED ones reachable from what is being sent
git push origin --follow-tags
Form What it sends When
git push origin <tag> Only that one When releasing a specific version: the most common case
git push origin --tags All of them, lightweight ones included, whatever they point at Hardly ever: it drags your personal markers along
git push origin --follow-tags Only the annotated ones hanging off the commits being sent Day to day

--tags has a real problem: it also sends todays-tests, before-the-rebase and any lightweight marker you happen to have lying around, and once on the server they belong to everyone. That is why --follow-tags is almost always the right option, and why it is worth leaving configured:

git config --global push.followTags true

From then on, an ordinary git push takes the relevant annotated tags with it and no others.

A nuance about --follow-tags: it only sends annotated tags and only those pointing at commits that are already, or are about to be, on the server. It is exactly what you want, and it is another practical reason for always using annotated tags when releasing versions.

On the receiving side there is nothing special to do: git fetch and git pull bring the tags of the commits they download. If you need to fetch them all explicitly:

git fetch --tags
git fetch --prune --prune-tags     # also deletes locally those no longer on the server

  1. Deleting tags and why they do not move

Deleting locally:

git tag -d todays-tests
Deleted tag 'todays-tests' (was 9f4c2e8)

Deleting on the remote (the same branch-deletion syntax from 04-05):

git push origin --delete v1.0.0-rc.1
To git.example.com:team/task-manager.git
 - [deleted]         v1.0.0-rc.1
# Equivalent old form: send "nothing" to that reference
git push origin :refs/tags/v1.0.0-rc.1

And now the important part: why is moving an already published tag a bad idea?

Technically you can. git tag -f v1.0.0 <another-commit> re-points it, and git push --force origin v1.0.0 re-points it on the server. But:

  1. Nobody finds out. Whoever already had v1.0.0 downloaded does not get it updated by an ordinary git fetch. Git is deliberately conservative with existing tags: if the name already exists locally, it does not touch it. Result: for months, your v1.0.0 and Bruno's point at different commits, and neither of you knows.
  2. It breaks the entire premise. A tag is worth something because it is stable. A tag that can change is useless: you can no longer cite it in an issue, in a deployment or in a document.
  3. Artefacts already released do not change. If v1.0.0 is deployed at a client, moving the tag does not move the client's code. All it achieves is a tag that lies about what was deployed.
  4. It is the same violation of the module's golden rule. Rewriting something others have already downloaded is not a local operation.

Whoever receives a moved tag sees this, if they force it:

git fetch --tags --force
 t [tag update]      v1.0.0     -> v1.0.0

And from there on they may struggle to work out which one was the right one.

What to do instead:

Situation The correct solution
You got the commit wrong and have not published it yet Delete it locally and create it properly
You published it five minutes ago and nobody has fetched Delete it locally and on the remote, warn the team, and create it again
The version has a bug Release v1.0.1. Never re-tag
You got the message wrong If it is recent and you warn people, delete and recreate. If not, leave it: it is not worth it

In short: a tag is a commitment. Once sent to the server, it is treated as immutable.

  1. Signed tags

An annotated tag can carry a cryptographic signature proving who created it:

# Sign with GPG
git tag -s v1.0.0 -m "task-manager 1.0.0"

# Verify
git verify-tag v1.0.0
git tag -v v1.0.0
gpg: Signature made Fri Aug  1 10:00:00 2026 CEST
gpg:                using RSA key 4A7D2F8B...
gpg: Good signature from "Ana Ferrer <ana.ferrer@example.com>" [ultimate]

What it is for in practice: if somebody distributes task-manager by downloading the v1.0.0 tag from the server, the signature lets them check that the version really was released by Ana and not by somebody who got access to the server.

It only works with annotated tags — a lightweight one has nowhere to store the signature — and it requires a key to be configured (user.signingkey, gpg.format, tag.gpgSign). The full configuration, including signing with SSH keys and signing commits, is in lesson 08-05: Security Best Practices. Here it is enough to know that the option exists and that it is one more reason to use annotated tags.

  1. git describe: naming any commit

You have some commit or other, in the middle of development, and you want a readable name for it. git describe builds one from the most recent tag that reaches it:

git describe
v1.1.0-14-g8c3e7f1

It reads like this:

Part Meaning
v1.1.0 The most recent annotated tag reachable from this commit
14 There are 14 commits between that tag and here
g8c3e7f1 The current commit is 8c3e7f1 (the g stands for "git")

If you are exactly on a tag, the name is the tag on its own:

git switch --detach v1.1.0
git describe
v1.1.0

Important options:

Option What it does
--tags Considers lightweight tags too
--always If there is no tag at all, returns the abbreviated hash instead of failing
--dirty Adds -dirty if the working tree has uncommitted changes
--abbrev=<n> Length of the abbreviated hash (--abbrev=0 omits it: just the tag name)
--match "<pattern>" Only tags matching the pattern
--contains The other way round: the first tag that contains this commit

The combination used in practice for versioning builds:

git describe --tags --always --dirty
v1.1.0-14-g8c3e7f1-dirty

That identifier is extraordinarily useful: it is embedded in the application and, when somebody reports a bug from an intermediate version, you know exactly which commit they are talking about and whether it came from a dirty working tree. For example, in task-manager:

// This value is injected by the build script with the output of:
//   git describe --tags --always --dirty
const VERSION = 'v1.1.0-14-g8c3e7f1';

function renderFooter() {
  const footer = document.getElementById('footer');
  footer.textContent = 'task-manager ' + VERSION;
}

And --contains, for the inverse question:

git describe --contains 7d3a8f4
v1.0.1~2

"That commit is two commits before v1.0.1", that is: it went into version 1.0.1.

  1. Working on a tag

Standing on a tag leaves you in detached HEAD (lesson 03-02), because a tag is not a branch and cannot advance:

git switch --detach v1.0.0
Note: switching to 'v1.0.0'.

You are in 'detached HEAD' state...
HEAD is now at 9f4c2e8 Add the export button to the action bar

That is fine for looking: inspecting that version's code, running it, reproducing a bug. But if you are going to work — for example, to fix a bug in 1.0 in order to release a 1.0.1 — you need a branch:

git switch -c maintenance/1.0 v1.0.0
Switched to a new branch 'maintenance/1.0'

Now we are talking: a maintenance branch starting exactly at the released version. You fix it (or bring the fix over from main with git cherry-pick -x, lesson 05-03), commit, tag the new version and push it:

git cherry-pick -x 7d3a8f4
git tag -a v1.0.1 -m "Fix for the focus after deleting a task"
git push origin maintenance/1.0 v1.0.1

That is the complete cycle of a maintenance release, and it uses four things from this module at once.

You can also export a tag's content without cloning anything:

git archive --format=zip --prefix=task-manager-1.0.0/ v1.0.0 -o task-manager-1.0.0.zip

git archive generates a .zip (or .tar.gz) with that tag's tree, without the .git directory. It is the canonical way of producing a release package.

Common Mistakes and Tips

Mistake 1: creating lightweight tags for versions. git tag v1.0.0 without -a records neither who, nor when, nor why, cannot be signed and is not sent by --follow-tags. For versions, always -a.

Mistake 2: believing git push sends tags. It does not. It is the classic surprise: you create v1.0.0, you push, and it is not on the server. git push origin v1.0.0 or push.followTags true.

Mistake 3: using --tags out of habit. It sends all your local tags, test markers included. Once on the server, cleaning them up is awkward and everyone has to be told.

Mistake 4: moving a published tag. Whoever already had it does not get it updated and you end up with two different truths. If there is a bug, you release the next version.

Mistake 5: trusting alphabetical order. v1.10.0 comes before v1.2.0 alphabetically. Configure tag.sort -v:refname.

Mistake 6: using the same name for a branch and a tag. It creates ambiguities in checkout, switch and push that are hard to understand afterwards.

Mistake 7: expecting git describe to see lightweight tags. By default it only looks at annotated ones. If your repository has only lightweight tags, git describe fails with no names found; there you need --tags.

Tip 1: tag from main and only what gets released. One tag per real version. Tagging intermediate commits "just in case" fills the namespace with permanent noise.

Tip 2: write a real message. The tag message is the best place for the release notes: it travels with the repository, does not depend on any external tool and is read with git show.

Tip 3: embed git describe --tags --always --dirty in your builds. It is the cheapest way of ensuring that every bug report includes the exact version.

Tip 4: git tag --contains <sha> to answer "which version did this go into?". It is quicker and more reliable than searching the log by date.

Tip 5: agree SemVer with the team and write it down. The value of MAJOR.MINOR.PATCH lies in everyone understanding the same thing. A version number nobody knows how to interpret is just a number.

Exercises

Exercise 1: lightweight versus annotated, hands on

In a practice repository with at least three commits:

  1. Create a lightweight tag and an annotated one on the same commit.
  2. Demonstrate with git cat-file -t that one resolves to commit and the other to tag.
  3. Show the content of the tag object with git cat-file -p and identify its five parts.
  4. Compare the output of git show on each.

Exercise 2: a version history

Simulate the evolution of task-manager:

  1. Three commits and v1.0.0 (annotated, with a release-notes message).
  2. A fix commit and v1.0.1.
  3. Two new-functionality commits and v1.1.0.
  4. List the tags sorted by version, newest to oldest.
  5. Show what changed between v1.0.0 and v1.1.0.
  6. Work out which version the fix commit went into without looking at the log.

Exercise 3: git describe and a maintenance branch

Starting from exercise 2:

  1. Add four more commits to main and check the output of git describe. Interpret each part.
  2. Modify a file without committing and observe the effect of --dirty.
  3. Create a maintenance branch starting at v1.0.0.
  4. Take the 1.0.1 fix over to that branch with a cherry-pick, and tag the result as v1.0.2.
  5. Check with git describe on each branch that the names are coherent.

Solutions

Solution 1:

mkdir /tmp/practice-tags && cd /tmp/practice-tags
git init -b main
echo "one" > f.txt && git add . && git commit -m "First"
echo "two" >> f.txt && git commit -am "Second"
echo "three" >> f.txt && git commit -am "Third"

# 1. The two tags
git tag lightweight
git tag -a annotated -m "This one is annotated"
# 2. The type of object they resolve to
git cat-file -t lightweight
git cat-file -t annotated
commit
tag
# 3. Inside the tag object
git cat-file -p annotated
object 6d3f2a9c8b1e5f7d4a2c9e6b3f8d1a5c7e4b2f9d
type commit
tag annotated
tagger Carla Vidal <carla.vidal@example.com> 1754035200 +0200

This one is annotated

The five parts: object (the commit pointed at), type (what it is), tag (the name), tagger (authorship and date) and the message.

# 4. git show on each
git show lightweight | head -4
git show annotated | head -8
commit 6d3f2a9...
Author: Carla Vidal <carla.vidal@example.com>
Date:   Sat Aug 1 11:02:14 2026 +0200
tag annotated
Tagger: Carla Vidal <carla.vidal@example.com>
Date:   Sat Aug 1 11:03:40 2026 +0200

This one is annotated

commit 6d3f2a9...

The annotated one shows one extra block: its own.

Solution 2:

mkdir /tmp/practice-versions && cd /tmp/practice-versions
git init -b main

echo "<h1>Task manager</h1>" > index.html && git add . && git commit -m "Add the initial structure"
echo "body { font-family: sans-serif; }" > styles.css && git add . && git commit -m "Add the base styles"
echo "const tasks = [];" > app.js && git add . && git commit -m "Add the task list"

git tag -a v1.0.0 -m "task-manager 1.0.0

First stable version: task list with base styles."
echo "// focus after deleting" >> app.js && git commit -am "Return focus to the field after deleting"
git tag -a v1.0.1 -m "Fix for the focus after deleting a task"

echo "// filter" >> app.js && git commit -am "Add the pending filter"
echo ".filter { margin: 1rem; }" >> styles.css && git commit -am "Add the filter styles"
git tag -a v1.1.0 -m "task-manager 1.1.0

New pending task filter."
# 4. Sorted by version, newest at the top
git tag --sort=-v:refname -n1
v1.1.0          task-manager 1.1.0
v1.0.1          Fix for the focus after deleting a task
v1.0.0          task-manager 1.0.0
# 5. What changed between versions
git log --oneline v1.0.0..v1.1.0
git diff --stat v1.0.0 v1.1.0
2c9f4e7 Add the filter styles
8b1d6a3 Add the pending filter
5f7c2e9 Return focus to the field after deleting
 app.js     | 2 ++
 styles.css | 1 +
 2 files changed, 3 insertions(+)
# 6. Which version the fix went into
git tag --contains 5f7c2e9
v1.0.1
v1.1.0

It went into v1.0.1 (the first in the list) and, naturally, it is still present in v1.1.0.

Solution 3:

# 1. Four more commits and describe
for i in 1 2 3 4; do echo "// change $i" >> app.js; git commit -am "Change $i"; done
git describe
v1.1.0-4-g9e2c6b1

The most recent reachable annotated tag is v1.1.0, 4 commits have gone by since it, and the current commit is 9e2c6b1.

# 2. --dirty
echo "uncommitted" >> app.js
git describe --dirty
v1.1.0-4-g9e2c6b1-dirty
git checkout -- app.js     # make it clean again
# 3 and 4. Maintenance branch and cherry-pick
git switch -c maintenance/1.0 v1.0.0
git log --oneline -1
5c8e1f4 Add the task list
git cherry-pick -x 5f7c2e9
git tag -a v1.0.2 -m "Port the focus fix to the 1.0 branch"
# 5. describe on each branch
git describe
git switch main
git describe
v1.0.2
v1.1.0-4-g9e2c6b1

Each branch is named with respect to its own line of tags, which is exactly what you expect from a versioning scheme with maintenance branches.

Conclusion

Tags are the repository's stable memory. The essentials:

  • A tag is a reference that does not move. It lives in refs/tags/, marks one specific commit for ever and cannot be the destination of HEAD (it leaves you in detached HEAD).
  • There are two kinds. The lightweight one is a bare pointer, with no object of its own. The annotated one creates a tag object in the database — the fourth type from lesson 01-04 — with author, date, message and optional signature. For released versions, always annotated.
  • They are created with git tag -a <name> -m "...", optionally on a commit from the past; they are listed with git tag -l "<pattern>", -n and --sort=-v:refname; they are examined with git show; and git tag --contains <sha> answers "which version did this go into?".
  • Semantic versioning (MAJOR.MINOR.PATCH) gives the number a shared meaning: incompatible / new functionality / fix. Before 1.0.0 there is no commitment; afterwards, a released version is never touched.
  • Tags do not travel on their own: git push origin <tag> for a specific one, --tags for all of them (rarely what you want) and --follow-tags — or push.followTags true — for the reachable annotated ones, which is the right thing day to day.
  • They are deleted with git tag -d locally and git push origin --delete on the remote, but a published tag does not move: whoever already has it does not get it updated, and the result is two different truths coexisting. If there is a bug, you release the next version.
  • git describe --tags --always --dirty names any commit with respect to the last tag and is the canonical way of versioning builds.
  • To work on a released version, create a branch from the tag; the tag on its own is only good for looking.

What comes next

We now know how to mark the past. What is missing is the complementary operation, and the most delicate of them all: undoing it.

Because it is going to happen. Somebody is going to publish a commit on main that breaks something, and it will already be on the server, and three people will have it downloaded. The golden rule forbids rewriting it. So what then?

Git has an answer for that, and it is an elegant one: not deleting the commit, but adding another one that applies the opposite change. That is git revert, the only safe way of undoing something that is already public, and with it we close the module in lesson 05-06: Reverting Commits.

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