OnCallReady

Lesson 25.2 · CI/CD Pipelines & Helm · 21 min read

Git as the delivery ledger: branches, rebase, revert, tags

In plain words

Think of a class diary that the teacher keeps in ink. Every day someone writes what happened. If a mistake is made, you do not tear out the page; you write a new line: "correction: the trip is Friday, not Thursday". Anyone can read back and see both the mistake and the fix. Tearing pages out would make the diary useless as proof.

On a shared branch, git is that diary. git revert writes the correction line; git reset --hard plus a force push tears out pages, which is why protected branches refuse it. When two people write at once, git pull --rebase puts your line after theirs so the story stays a straight line. Tags like v1.5.0 are bookmarks that must never move.

The problem

Prod broke at 14:02. Someone asks: "what changed?" and then "undo it". If every change to what runs went through git, both answers are one command. If people force-pushed, rewrote history or edited servers by hand, nobody knows.

What you need to know already: 1.17 (git config, commit, push, clone), 25.1 (app repo vs config repo).

Why git matters more here than in app work

In delivery, git is not just where code lives. It is the audit log of what ran where (a ledger: a record you only ever add to). "What changed in prod at 14:02?" is answered by git log on the config repo. "Undo that" is a git revert. That only works if you use git in a way that keeps history honest, so this lesson is about the handful of operations that matter when several people and several pipelines push to the same branch.

Quick git words used below:

Branches: short-lived, merged into main

main  ----o----o----o----------o----o----      (always deployable)
               \             /
feat/refunds    o----o----o                     (a day or two, then merged)

Each o is a commit. The side branch splits off main, gets a few commits, and is merged back.

Trunk-based development: everyone integrates into main (the "trunk") often; branches live hours or days, not months. Long-lived environment branches (dev, prod branches that are "promoted" by merging) are a known trap: merges carry unrelated changes along, and the branches drift apart. In a config repo, use directories per environment on one branch (envs/dev, envs/prod), not branches per environment.

Making and publishing a branch: git switch -c NAME creates a branch and moves you onto it (-c = create). git push -u origin NAME sends it to the server; -u (upstream) remembers the link, so later a plain git push/git pull know where to go. The remote: lines are messages from the server:

# in a clone of payments-config - the next mission gives you one
git switch -c feat/dev-replicas
Switched to a new branch 'feat/dev-replicas'
git push -u origin feat/dev-replicas
...
remote: To create a merge request for feat/dev-replicas, visit:
remote:   https://git.lab/pay/payments-config/-/merge_requests/new?merge_request%5Bsource_branch%5D=feat%2Fdev-replicas
...
 * [new branch]      feat/dev-replicas -> feat/dev-replicas
branch 'feat/dev-replicas' set up to track 'origin/feat/dev-replicas'.

Protected branches

A force push (git push --force) tells the server "replace your branch with mine", even if that throws away commits it has. On GitLab (and Azure Repos, GitHub) main is protected: no force pushes, often no direct pushes at all - only merge requests with approvals. A force push to a protected branch is refused by the server:

git push --force
remote: GitLab: You are not allowed to force push code to a protected branch on this project.
To https://git.lab/pay/payments-config.git
 ! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs to 'https://git.lab/pay/payments-config.git'

pre-receive hook = a check the server runs on every incoming push before accepting it; "declined" means the check said no.

That rule is what makes main trustworthy as a ledger: history can only grow.

The rejected push

You committed; someone (or a pipeline) pushed to the same branch in the meantime:

git push
To https://git.lab/pay/payments-config.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'https://git.lab/pay/payments-config.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

A push is only accepted as a fast-forward: the server's branch must be an ancestor of yours, so your push only adds commits on top. (fetch first) means the remote has commits you have never seen. (non-fast-forward) means you have seen them (they are in origin/main) but your branch is behind or has diverged (both sides have commits the other lacks). Either way git refuses to throw their work away. Never "fix" this with --force.

pull: merge or rebase, and the refusal in between

git pull = git fetch (download new commits from the server) + combine them with yours. There are two ways to combine:

Modern git (2.33 and later) refuses to guess when a plain git pull meets diverged branches:

git pull
From https://git.lab/pay/payments-config
   8eab22c..ed1ee5c  main       -> origin/main
hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
...
fatal: Need to specify how to reconcile divergent branches.

The first line shows the fetch worked (8eab22c..ed1ee5c: origin/main moved from the first commit to the second). Then git stops and asks you to choose.

For a config repo, rebase your small local commit on top of theirs. git log --oneline -3 shows the last 3 commits, one line each:

git pull --rebase
Successfully rebased and updated refs/heads/main.
git log --oneline -3
2440d25 (HEAD -> main) dev: 3 replicas
ed1ee5c (origin/main, origin/HEAD) prod: payments-api 1.4.1
8eab22c Revert "dev: 1.5.0"
git push
...
   ed1ee5c..2440d25  main -> main

History stays a straight line: "their change, then mine". A merge instead creates a merge commit ("Merge branch 'main' of ...") for every such race, which is noise in a ledger. Make rebase the default once (--global = for every repo of your user, stored in ~/.gitconfig):

git config --global pull.rebase true

Pipelines that commit to the config repo hit exactly this race when two builds finish at once. The robust pattern in a pipeline is a small loop: git pull --rebase && git push, retried a few times.

Undo on a shared branch: revert, never reset

git revert SHA creates a new commit that does the opposite of commit SHA. git show --stat shows the last commit and which files it touched:

git revert ed1ee5c
[main 61fd441] Revert "prod: payments-api 1.4.1"
 1 file changed, 1 insertion(+), 1 deletion(-)
git show --stat
commit 61fd441...
    Revert "prod: payments-api 1.4.1"

    This reverts commit ed1ee5c....

 envs/prod/values.yaml | 2 +-

revert adds a new commit that undoes an old one. History keeps both: what happened and that it was undone. git reset --hard HEAD~1 instead moves your branch pointer back one commit (HEAD~1 = "the commit before HEAD") and throws the commit away - fine on your own branch before pushing, but on a shared branch it needs a force push (refused on protected branches, and it rewrites what everyone else has).

Tags and versions

A tag (git sense) is a fixed name stuck on one commit, like v1.5.0. An annotated tag (-a) also stores who made it, when, and a message (-m); a plain ("lightweight") tag is just the name.

git tag -a v1.5.0 -m "payments-api 1.5.0"   # annotated: who, when, why
git push origin v1.5.0                      # tags are NOT pushed by a plain git push
git describe --tags                         # v1.5.0-3-g9c41e7a = 3 commits after v1.5.0
git tag -l 'v1.*'                           # list tags matching a pattern

git describe --tags names the current commit relative to the nearest tag: v1.5.0-3-g9c41e7a = tag v1.5.0, plus 3 commits, now at commit 9c41e7a (the g just means "git").

Semantic versioning: MAJOR.MINOR.PATCH. MAJOR goes up for a breaking change, MINOR for a new feature that keeps old uses working, PATCH for a bug fix. A release pipeline often derives the version from the tag (git describe --tags --abbrev=0 prints just the tag name) so the tag, the image tag and the Helm chart's appVersion (lesson 25.19) agree.

A pushed tag, like a published image tag, should never be moved. The server refuses to overwrite it without --force:

 ! [rejected]        v1.5.0 -> v1.5.0 (already exists)
hint: Updates were rejected because the tag already exists in the remote.

The five commands you will type most

git status -s                       # what changed, compactly (-s = short)
git diff                            # before you commit, always
git log --oneline -5 --decorate     # where am I relative to origin? (--decorate shows branch names)
git pull --rebase                   # integrate before you push
git revert <sha>                    # undo something that is already shared

What you can now do

Why it helps

In delivery, git on the config repo is the audit log of what ran where. At 3am "what changed in prod?" is git log --oneline envs/prod, and "undo it" is git revert <sha> followed by a push, which the pipeline then applies. If you panic and try reset --hard plus --force, the protected branch refuses it, and you lose minutes.

You will also hit the push race daily once pipelines commit to the config repo: two builds finish at once, one gets ! [rejected] (fetch first). Knowing that the fix is git pull --rebase && git push in a small retry loop, and never --force, is the difference between a flaky pipeline and a reliable one. Interviewers ask "revert vs reset" and "merge vs rebase" constantly.

FAQ

What is the difference between git revert and git reset?

git revert <sha> creates a new commit that undoes the changes of an old one; history keeps both. It is safe on shared branches because it only adds. git reset --hard <ref> moves your branch pointer back, discarding commits; sharing that requires a force push, which rewrites history for everyone and is refused on protected branches. Use reset only on your own unpushed work, revert on anything shared.

Should I use merge or rebase when git pull says the branches diverged?

For a small local commit on a busy shared branch, rebase: git pull --rebase replays your commit on top of theirs, keeping history linear with no "Merge branch 'main'" noise. For long-running feature branches merged via merge requests, the server usually does the merge or squash. Never rebase commits others have already pulled. Set git config --global pull.rebase true once so plain git pull rebases.

What is the difference between "(fetch first)" and "(non-fast-forward)" in a rejected push?

Both mean the remote has commits your branch does not contain. (fetch first) means you have never fetched them: they are not even in origin/main locally. (non-fast-forward) means you fetched them but your branch is behind or diverged. The fix is the same: integrate with git pull --rebase (or fetch plus rebase), check the result, and push again. Neither is a reason to use --force.

Why do my tags not appear on the server after git push?

A plain git push pushes the current branch, not tags. Push a tag explicitly with git push origin v1.5.0, or all tags with git push --tags (careful, that includes stray local ones). git push --follow-tags pushes annotated tags that point at the commits being pushed. Prefer annotated tags (git tag -a) for releases: they record who tagged, when and why.

Why are environment branches like dev and prod considered a trap?

Promotion by merging dev into prod carries every other change on dev along, including half-finished ones, and cherry-picks to fix that make the branches drift until nobody knows what differs. Directories per environment on one branch (envs/dev, envs/prod) make the difference between environments a plain diff, and promotion a small, explicit edit to one file. Trunk-based development keeps main always deployable.

In an interview Mid

Production is broken after a deploy that was a commit to the config repo. How do you roll back?

With git, because the config repo is the ledger of what runs where:

  1. git log --oneline - find the commit ("prod: payments-api 1.4.1"), git show --stat to confirm what it touched.
  2. git revert <sha> - a new commit that undoes it. History keeps both: what happened and that it was undone.
  3. git push - the pipeline deploys the reverted config like any other change, through the same checks.

Never git reset --hard plus git push --force on a shared branch: it rewrites what everyone else has, and on a protected main the server refuses it (pre-receive hook declined). That refusal is what keeps the ledger trustworthy.

If the push is rejected (fetch first / non-fast-forward) because someone pushed meanwhile: git pull --rebase, then push - never --force. Pipelines that commit to the repo do the same in a small retry loop. Directories per environment on one branch (envs/dev, envs/prod) keep this simple; branches per environment drift.

Also asked: What is the difference between git merge and git rebase? · Two pipelines commit to the same config repo and one push is rejected. How do you make this reliable? · What is semantic versioning, and when does each number go up?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.