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:
- branch - a named line of commits;
mainis the shared one. - origin - the default name for the server copy of the repo you cloned.
origin/mainis your local record of wheremainwas on the server the last time you talked to it. - HEAD - the commit you are on right now.
- sha - a commit's id (a hash), like
9c41e7a.
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:
- merge - keep both lines and add a merge commit that joins them.
- rebase - take your commits off, lay the server's commits down, then replay yours on top, as if you had started after them. History stays a straight line.
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
- Read a rejected push and fix it with
git pull --rebase, never--force. - Undo a shared commit with
git revertand say whyresetis wrong there. - Create and push an annotated tag, and read a semantic version.