Why a team needs a strategy

Git does not care how you use branches; any pattern works technically. People are the problem: if one engineer keeps a branch open for a month while another merges small changes every hour, the first merge back becomes a week of conflicts. A branching strategy (also called a branching model or workflow) is the team's written agreement on questions like:

  • Which long-lived branches exist, and what does each one mean? ("main is always deployable.")
  • Where does new work start, how long may a branch live, and how is it named?
  • How does work get back: a pull request with review, required checks, which merge method?
  • How is a release made, and how is an urgent fix shipped?

The right answer depends mostly on how you ship. A web application or an internal tool that is deployed many times a day needs a different model than firmware or a product that customers install and keep running for years. The three best-known models below sit at different points on that scale. The continuous integration they all rely on is described in DevOps and CI/CD.

GitHub flow

GitHub flow is the lightweight model GitHub describes for its own platform. It has one long-lived branch, main, which is always deployable. Everything else is a short-lived topic branch:

  1. Create a branch from main with a descriptive name (fix-ntp-servers).
  2. Commit and push the branch regularly.
  3. Open a pull request; colleagues review it and automated checks run.
  4. Merge into main once approved and green, and delete the branch.

Because main is always deployable, many teams deploy it automatically after each merge.

It is easy to learn and maps directly onto pull requests (see Pull Requests and Code Review). Its weak point is the same as its strength: there is only one version, the current one. It does not by itself tell you how to maintain version 2 while version 3 is out.

Trunk-based development

In trunk-based development, everyone integrates into one shared branch, the trunk (usually main), in very small steps: directly or through branches that live hours, at most a day or two. It takes the idea of continuous integration literally: integrate continuously, so conflicts stay tiny and the trunk always reflects the real state of the product.

Work that takes weeks still has to reach the trunk in small pieces before it is finished. Teams use two techniques for that:

  • A feature flag hides unfinished functionality at run time, so incomplete code can be merged and deployed safely while it stays switched off.
  • Branch by abstraction: introduce an interface first, build the new implementation behind it, and switch over when it is ready.

Trunk-based development demands discipline and good automated tests, because a broken trunk blocks everyone. The DevOps Research and Assessment (DORA) research program lists it among the technical practices associated with high-performing delivery teams. GitHub flow with branches that are merged within a day is, in practice, a form of trunk-based development.

Git flow

Git flow is the model Vincent Driessen published in 2010 as "A successful Git branching model". It uses two permanent branches and three kinds of temporary ones:

Branch Lives Purpose
main (master in Driessen's article) Forever Only released versions; every commit on it is a tagged release
develop Forever Integration branch for the next release
feature/* Days to weeks One feature, branched from and merged back into develop
release/* Until the release ships Stabilize a version: only fixes, version numbers and release notes; merged into main (and tagged) and back into develop
hotfix/* Hours to days Urgent fix branched from main, merged into main (new patch tag) and develop

Git flow brings order when releases are rare, planned events. It also costs a lot: two branches to keep in sync, long-lived feature branches, and double merges for every release and hotfix. In 2020 its author added a note to the original article recommending a simpler workflow such as GitHub flow for software that is delivered continuously, such as web applications, and keeping Git flow for explicitly versioned software, especially when several versions must be supported in the field. Network teams meet it in vendor projects and in tooling that ships as installable packages.

Release branches

A release branch is a branch cut from the main line to prepare or maintain one version, for example release/2.3. New features continue on the main line; the release branch only receives fixes. Release branches are not exclusive to Git flow: trunk-based teams often cut one shortly before a release ("release from a branch, develop on trunk"), and products that support several versions keep one per supported version and apply fixes there, usually by fixing on the main line first and cherry-picking the fix to each release branch.

git switch -c release/2.3 main         # cut the release branch
# ...only fixes are committed here, tested, then:
git tag -a v2.3.0 -m "Release 2.3.0"
git switch main
git cherry-pick <fix-commit>             # bring a fix made on the release branch back

Comparing the strategies

GitHub flow compared with Git flow, time running left to right. In GitHub flow two short feature branches leave main and merge back into it. In Git flow, feature branches merge into develop, a release branch goes from develop to main where version 1.1.0 is tagged and back to develop, and a hotfix branch goes from main back to main as version 1.1.1 and into develop. GitHub flow main fix-ntp add-r2 Git flow main hotfix release develop feature v1.0.0 v1.1.0 v1.1.1
Time runs left to right. GitHub flow needs one permanent branch; Git flow needs two, plus release and hotfix branches that each merge in two places.
GitHub flow Trunk-based development Git flow
Permanent branches main main (the trunk) main and develop
Topic branch lifetime Days Hours, at most a day or two Days to weeks
Unfinished work Stays on its branch Merged, hidden behind feature flags Stays on its feature branch
Releases Deploy main, often continuously From trunk, or a short-lived release branch Release branches, tags on main
Best fit Web apps, internal tools, small teams Teams with strong automated testing that deploy frequently Versioned products with several supported versions

Tags

A tag is a reference that gives one commit a permanent, human-readable name, typically a version such as v1.4.2. Unlike a branch, a tag does not move when you commit. Git has two kinds:

  • A lightweight tag is just a name pointing to a commit, like a branch that never moves.
  • An annotated tag is a separate object in the object database, with its own tagger name, date and message, and it can be signed. Use annotated tags for releases; lightweight tags are for private bookmarks.

Creating and inspecting tags

git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag v1.0.0
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "feat: add R1 config"
git tag -a v1.1.0 -m "Release 1.1.0: R1 configuration"
git tag
git cat-file -t v1.0.0
git cat-file -t v1.1.0
git show --stat v1.1.0
v1.0.0
v1.1.0
commit
tag
tag v1.1.0
Tagger: Sam Rivera <sam@example.com>
Date:   Mon Mar 2 09:00:00 2026 +0000

Release 1.1.0: R1 configuration

commit 3d5e2b15f7d11bcb644667ab78711af82b55f7ac
Author: Sam Rivera <sam@example.com>
Date:   Mon Mar 2 09:00:00 2026 +0000

    feat: add R1 config

 router.cfg | 1 +
 1 file changed, 1 insertion(+)

git cat-file -t shows the difference: v1.0.0 points straight at a commit, while v1.1.0 points at a tag object, which in turn points at the commit. git show on an annotated tag prints the tag's own header (who tagged, when and why) before the commit. To tag an older commit, name it: git tag -a v0.9.0 -m "…" e9f93d2. Tags are meant to be permanent; if a tag is wrong, publish a new version instead of moving it.

git describe

git describe names any commit relative to the nearest annotated tag, which is handy for build numbers and "which version is running on this box?":

git init -q net-tools && cd net-tools
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag -a v1.0.0 -m "Release 1.0.0"
printf 'hostname R1\n' > router.cfg
git add router.cfg && git commit -q -m "feat: add R1 config"
printf 'hostname R2\n' > r2.cfg
git add r2.cfg && git commit -q -m "feat: add R2 config"
git describe
git tag lab-tested
git describe
git describe --tags
v1.0.0-2-g0956434
v1.0.0-2-g0956434
lab-tested

v1.0.0-2-g0956434 reads: tag v1.0.0, plus 2 commits, at commit 0956434 (the g stands for Git). By default only annotated tags count, so the lightweight bookmark lab-tested is ignored until you add --tags. That is one more reason to make release tags annotated.

Pushing tags

git push does not send tags. Push them explicitly, one at a time or all annotated tags that belong to the commits you push:

git init -q --bare shared.git
git init -q net-tools && cd net-tools
git remote add origin ../shared.git
echo "# Network tools" > README.md
git add README.md && git commit -q -m "Add README"
git tag -a v1.0.0 -m "Release 1.0.0"
git push -q -u origin main
git ls-remote --tags origin
git push origin v1.0.0
git ls-remote --tags origin
To ../shared.git
 * [new tag]         v1.0.0 -> v1.0.0
39e942a755d237335c828f194b719c0642d5a093	refs/tags/v1.0.0
e9f93d2fb391d65b509ecdd4b1e81340f82f2aa0	refs/tags/v1.0.0^{}

The first git ls-remote --tags printed nothing: pushing main did not take the tag along. After the explicit push, the remote lists the tag object (39e942a…) and, marked with ^{}, the commit it points to (e9f93d2…, the README commit).

Command Pushes
git push origin v1.0.0 One tag
git push --follow-tags The branch, plus annotated tags on the commits being pushed
git push --tags Every tag, including stray lightweight ones (use with care)
git push origin --delete v1.0.0 Deletes the tag on the remote

On GitHub, a release is built on a tag: it adds release notes (which GitHub can generate from the merged pull requests) and downloadable files. In the ITSM sense a release is more than a tag; see Release and Deployment Management for why deploying and releasing are separate decisions.

Semantic versioning

Semantic versioning (SemVer, specification 2.0.0 at semver.org) gives a version number a precise meaning, so users can tell from the number alone whether an upgrade is safe. A version is MAJOR.MINOR.PATCH, and you increase:

Part When Example
MAJOR You make incompatible changes to the public API (application programming interface) 1.9.3 → 2.0.0
MINOR You add functionality in a backward-compatible way 1.9.3 → 1.10.0
PATCH You make backward-compatible bug fixes 1.9.3 → 1.9.4
  • Increasing a part resets the parts to its right to zero, and numbers have no leading zeros. 1.10.0 comes after 1.9.0: each part is compared as a number, not as text.
  • 0.y.z is initial development: anything may change at any time. 1.0.0 defines the public API.
  • A pre-release is marked with a hyphen, as in 2.0.0-alpha.1 or 2.0.0-rc.1, and comes before the normal version: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0.
  • Build metadata follows a plus sign (1.0.0+20260302) and is ignored when comparing versions.
  • A released version must never be changed. Any change, however small, needs a new version number.

The "public API" is whatever others depend on: function signatures for a library, command-line options and output format for a tool, the variable names a playbook expects. The Git tag is usually the version with a v prefix (v2.0.0); the prefix is a common convention, not part of the version itself.

Conventional Commits

Conventional Commits (specification 1.0.0) is a convention for commit messages that makes them readable for machines as well as people. The first line has the form:

<type>[optional scope][!]: <description>

[optional body]

[optional footer(s)]
  • fix: patches a bug and corresponds to a PATCH release.
  • feat: adds a feature and corresponds to a MINOR release.
  • A ! before the colon, or a footer starting with BREAKING CHANGE:, marks a breaking change and corresponds to a MAJOR release. BREAKING CHANGE must be uppercase (BREAKING-CHANGE is accepted as a synonym); everything else is case-insensitive.
  • Other types are allowed and common, for example from the Angular convention: build, chore, ci, docs, style, refactor, perf and test. On their own they do not change the version.
feat(inventory)!: group devices by site

Devices are now listed under their site instead of in one flat list,
so playbooks can target a single location.

BREAKING CHANGE: the top-level "routers" key is replaced by "sites".
Refs: #42

Changelogs and automated releases

Because the type of every change is in its message, tools can go from commits to a release without human bookkeeping: read the commits since the last tag, compute the next SemVer version, write a CHANGELOG.md section with "Features" and "Bug fixes", create the tag and publish the release. Widely used examples are semantic-release, release-please and the conventional-changelog tools; teams usually run them in a CI pipeline (see What Is CI/CD?). A linter such as commitlint can reject messages that do not follow the format.

Computing the next version

The core rule of those release tools fits in a few lines of Python. The highest level wins: one breaking change makes a major release, however many fixes come with it.

import re

# <type>[optional scope][!]: <description>
HEADER = re.compile(r"^(?P<type>\w+)(\([^)]*\))?(?P<bang>!)?: \S")
BREAKING_FOOTERS = ("BREAKING CHANGE:", "BREAKING-CHANGE:")


def bump_level(message):
    """Return "major", "minor", "patch" or None for one commit message."""
    lines = message.splitlines()
    match = HEADER.match(lines[0])
    if match is None:
        return None  # not a conventional commit: no version change
    if match["bang"] or any(line.startswith(BREAKING_FOOTERS) for line in lines[1:]):
        return "major"
    kind = match["type"].lower()
    if kind == "feat":
        return "minor"
    if kind == "fix":
        return "patch"
    return None  # docs, chore, ci, test, refactor, ...


def next_version(current, messages):
    major, minor, patch = (int(part) for part in current.split("."))
    levels = {bump_level(message) for message in messages}
    if "major" in levels:
        return f"{major + 1}.0.0"
    if "minor" in levels:
        return f"{major}.{minor + 1}.0"
    if "patch" in levels:
        return f"{major}.{minor}.{patch + 1}"
    return current


releases = {
    "docs only": ["docs: explain the inventory format", "ci: cache pip downloads"],
    "a bug fix": ["fix(backup): keep 30 days of configs", "docs: update README"],
    "a feature": ["feat(inventory): add site field", "fix: trim spaces in hostnames"],
    "breaking": [
        "feat(cli)!: rename --host to --device",
        "fix: handle empty inventory",
    ],
    "footer": [
        "refactor: split loader\n\nBREAKING CHANGE: load() now returns a dict",
    ],
}

for label, messages in releases.items():
    print(f"{label:10} 1.4.2 -> {next_version('1.4.2', messages)}")
docs only  1.4.2 -> 1.4.2
a bug fix  1.4.2 -> 1.4.3
a feature  1.4.2 -> 1.5.0
breaking   1.4.2 -> 2.0.0
footer     1.4.2 -> 2.0.0

The last case shows that the footer counts as much as the !: a refactor is normally invisible in the version, but this one changes what load() returns, so users must be warned with a major release. Real tools add details this sketch leaves out, such as pre-releases and the convention many projects follow of bumping only MINOR for breaking changes while still in 0.y.z.