Guide: Tooling

Git for Teams

Branching, merge requests and history hygiene: a compact reference for working with Git in a team without losing work or rewriting what others depend on.

The mental model

Outputs below were captured from real runs in throwaway repositories with git version 2.43.0. Hashes and dates differ on your machine, and some paths and progress lines are trimmed. Messages and defaults change between Git versions, so check the documentation for yours. git switch needs Git 2.23 or newer.

A commit is a snapshot plus metadata and pointers to its parent commits (none for the first commit, two or more for a merge). A branch is a lightweight movable pointer to one commit. HEAD points to the branch you are on. Creating a branch copies nothing, and committing moves the current branch pointer forward.

$ git switch -c feature/login
$ echo login > login.txt && git add login.txt && git commit -m "Add login"
$ git log --oneline --decorate --graph --all
* 738e8b1 (HEAD -> feature/login) Add login
* f1f86f4 (main) Update app
* 8756b12 Add app
$ cat .git/HEAD .git/refs/heads/main .git/refs/heads/feature/login
ref: refs/heads/feature/login
f1f86f4059931ae3fa135305fac26c79ce7cadd8
738e8b110ae5020936ab66e559e8cbe55060a4f0

In a default repository, HEAD is a file that names a branch and a branch is a file that holds a commit id (Git can also store branches in a packed file, so do not rely on this layout in scripts). That is why branches are cheap, and why rewriting history means moving pointers to new commits, not editing old ones.

A simple branching approach

A simple approach that suits many teams: keep one default branch (often main) that always builds, make every change on a short-lived branch created from it, open a merge request, review, merge, delete the branch. Pro Git calls this a topic branch: a short-lived branch for a single feature or related work.

$ git switch main
$ git pull
$ git switch -c fix/login-timeout
# work, commit, then publish the branch
$ git push -u origin fix/login-timeout

Protect the default branch on your platform so changes arrive only through reviewed merge requests (GitLab documents several methods to protect branches).

No flow fits every team. Two alternatives, described from general practice and not from the documentation linked here: trunk-based development integrates very small changes into the default branch often, which needs a way to hide unfinished work, such as feature flags. “Trunk-based” is a common practice name, not an official Git term. Long-running branches are described in Pro Git: several branches stay open at different levels of stability (for example a stable branch and a develop branch), and you merge regularly between them.

The merge request habit

GitLab says merge requests provide a central location for your team to review code, have discussions and track code changes. Other platforms call them pull requests. The habits below are common team conventions, not requirements of GitLab.

  • Keep it small: one purpose per request, so it can be reviewed well.
  • Describe it: what changed, why, how you tested it, how to roll it back.
  • Get a review, and let CI gate the merge if your platform can enforce it.
  • Delete the branch after the merge.

Commit messages

The git commit documentation says that, though not required, it is a good idea to start with a single short line (no more than 50 characters), then a blank line, then a fuller description. The Git project’s own guidance asks for the imperative mood (“make X do Y”) and a body that explains the problem and why the change solves it. This is a widely used convention, not a rule of Git. Follow your team’s rules if they differ.

commit message
Reject expired session tokens

The auth middleware compared the expiry in local time, so tokens
issued in another timezone stayed valid for up to 12 hours.
Compare in UTC instead.

Refs: #142

Merge, squash, rebase and syncing

Three common ways to bring a branch into the default branch, shown on three copies of one repository (the branch has two commits, and main gained one meanwhile).

# 1. merge commit
$ git merge --no-edit feature/login
$ git log --oneline --graph
*   ff8d8d2 Merge branch 'feature/login'
|\  
| * 701d9f6 Fix login typo
| * e8ba31d Add login form
* | 0d0101e Add b
|/  
* af8e5ff Add a
# 2. squash
$ git merge --squash feature/login
Squash commit -- not updating HEAD
$ git commit -m "Add login"
$ git log --oneline --graph
* e776f7a Add login
* 0d0101e Add b
* af8e5ff Add a
# 3. rebase, then fast-forward
$ git switch feature/login
$ git rebase main
Successfully rebased and updated refs/heads/feature/login.
$ git switch main
$ git merge --ff-only feature/login
$ git log --oneline --graph
* 197267d Fix login typo
* 73d2398 Add login form
* 0d0101e Add b
* af8e5ff Add a
  • Merge commit: keeps every commit and records the merge. Faithful, but the graph gets busy.
  • Squash: one commit for the whole branch. Tidy, but the individual commits are not on the default branch, so keep branches small.
  • Rebase, then fast-forward: linear history. The rebased commits get new ids (e8ba31d became 73d2398).

In GitLab these are project settings: merge commit, merge commit with semi-linear history, or fast-forward merge, plus optional squashing.

The golden rule

Do not rebase commits that exist outside your repository and that people may have based work on. Rebasing abandons existing commits and creates similar but different ones, so anyone who built on the old ones has to repair their history. That is the Pro Git rule. GitLab’s documentation adds: never modify the commit history of your default or shared branch.

Syncing safely

git fetch downloads new commits and updates remote-tracking branches such as origin/main; it does not integrate them into your current branch (in the run below, local main stays where it was). git pull is a fetch plus an integration step. The documentation says --rebase “rewrites history, which does not bode well when you published that history already”, so use it only for local commits you have not pushed. What plain git pull does on diverged branches depends on the Git version and your configuration, so choose explicitly (git pull --rebase or the pull.rebase setting).

$ git push origin main
! [rejected]        main -> main (fetch first)
$ git fetch origin
$ git status -sb
## main...origin/main [ahead 1, behind 1]
$ git pull --rebase origin main
Successfully rebased and updated refs/heads/main.
$ git push origin main

Force-pushing

After you amend or rebase a commit already on your remote branch, a normal push is refused. --force overwrites unconditionally and can make the remote lose commits. --force-with-lease overwrites only if the remote branch is still where your remote-tracking branch says. Below, a teammate pushed to the same branch after you. The bare lease refuses, until something runs git fetch. The documentation warns that the bare form interacts badly with anything that fetches in the background, and in the run below the bare command then overwrote the teammate’s commit. Naming the value you expect avoids that:

$ git commit --amend -m "Add x, take two"
$ git push --force-with-lease origin feature/x
! [rejected]        feature/x -> feature/x (stale info)
$ git fetch
# name the commit you last pushed (6423771 here)
$ git push --force-with-lease=feature/x:6423771 origin feature/x
! [rejected]        feature/x -> feature/x (stale info)
# the bare form now trusts the fetched value
$ git push --force-with-lease origin feature/x
+ 0f1f4f5...7bbe6b2 feature/x -> feature/x (forced update)

Recovering and finding mistakes

Get lost commits back with reflog

The reflog records where HEAD and branch tips used to be in your local repository. It is never pushed, and old entries expire (documented defaults: 90 days for reachable and 30 days for unreachable entries). After a mistaken reset --hard, it is the way back for commits. It cannot restore uncommitted changes that reset --hard overwrote.

$ git reset --hard HEAD~2
HEAD is now at 321f3d6 Add line 1
$ git reflog
321f3d6 HEAD@{0}: reset: moving to HEAD~2
e86b703 HEAD@{1}: commit: Add line 3
ec07cbd HEAD@{2}: commit: Add line 2
321f3d6 HEAD@{3}: commit (initial): Add line 1
$ git reset --hard 'HEAD@{1}'
HEAD is now at e86b703 Add line 3

revert vs reset

git revert records a new commit that undoes an earlier one, so history only grows. Use it for anything already pushed. git reset moves the branch pointer, and with --hard it overwrites your working tree, so use it on local work only. GitLab’s documentation: reset removes the commit entirely, revert removes the changes but leaves the commit, and it is safer because you can revert a revert. To revert a merge commit, name the mainline parent with -m (for example git revert -m 1 <merge-sha>). Note that later merges of that branch will then not bring back the reverted changes, which may not be what you want.

$ git revert --no-edit HEAD
[main 21a9330] Revert "Add line 3"
 1 file changed, 1 deletion(-)

Find the breaking commit with bisect

git bisect binary-searches history between a bad and a good commit. With git bisect run a script decides: exit 0 means good, 1 to 127 except 125 means bad, 125 means skip, other codes abort. Here check.sh (kept outside the repository) exits 1 when config.txt contains rate=0. Seven commits took three test runs:

$ git bisect start HEAD HEAD~7
Bisecting: 3 revisions left to test after this (roughly 2 steps)
$ git bisect run sh ../check.sh
Bisecting: 1 revision left to test after this (roughly 1 step)
Bisecting: 0 revisions left to test after this (roughly 0 steps)
e50a3c29f71c42ef56708070a9b43eaa79b10825 is the first bad commit
    Tune rate
bisect found first bad commit
$ git bisect reset

Ignore files and secrets

Put build output, local settings and credential files in .gitignore before the first commit. Ignore rules do not affect files that are already tracked. To stop tracking one, run git rm --cached and ignore it. That does not erase it from history:

$ echo ".env" > .gitignore && git add .gitignore && git commit -m "Ignore .env"
$ echo "API_KEY=changed" > .env
$ git status -s
 M .env
$ git rm --cached .env
$ git commit -m "Stop tracking .env"
$ git check-ignore -v .env
.gitignore:1:.env	.env
$ git show HEAD~2:.env
API_KEY=not-a-real-key

Here .env was committed first and ignored later, so git status still reported it modified. The last command shows the old value is still in history.

If a secret was committed, rotate it first. GitLab’s guidance: once a secret has been pushed to a remote repository it is no longer secure, so revoke and replace it even if few people can see the repository. Then remove it from the code and, if policy requires, from history. Rewriting history is subject to the golden rule, and clones may still hold the old data. The Git documentation says git filter-branch is not recommended and points to alternatives such as git filter-repo.

Cheat sheet and checklist

GoalCommand
Replay unpushed commits on the remotegit pull --rebase
Overwrite your own remote branchgit push --force-with-lease=topic:<sha>
Undo a pushed commitgit revert <sha>
Find where a pointer used to begit reflog
Find the commit that broke itgit bisect start <bad> <good> then git bisect run <cmd>

Before you push or merge

A suggested checklist. Adapt it to your team.

  • The branch is small and described, and CI is green.
  • Any history I rewrite is unpushed or on a branch only I use, and I never force-push the default branch.
  • I fetched and read what arrived before a force push.
  • No secrets in git diff --staged.

Official documentation

Keep going

benmabrouk.fr: free DevOps and SRE learning resources, written from production experience.

Scroll to Top