Git for Backend Developers: Commits, Branching, Rebasing and Recovering from Mistakes

Write commits a reviewer can follow, choose between merge and rebase, resolve conflicts with confidence, and recover from almost any mistake.

Git for Backend Developers cover: a terminal showing git reset --hard HEAD~2, then git reflog listing the lost commit and git reset --hard HEAD@{1} bringing both commits back, with git bisect finding a bad commit among 30 in 5 runs

Introduction

Most developers learn enough Git to commit, push and pull, then hit a wall when a rebase goes wrong or a commit seems to vanish. On a backend team that costs real time. Messy history slows review, a bad merge breaks a deploy, and a leaked secret must be rotated everywhere.

This guide covers the Git you use in daily backend work. That means commits and branches a reviewer can follow, merge vs rebase, conflicts and undoing mistakes. It also covers hotfixes and releases, finding the commit that broke something, and keeping secrets out. It ends with a Git cheat sheet you can keep open while you work.

Every command and output below was run on Git 2.43 in a scratch repository. Commit hashes will differ on your machine.

How Git Works in Five Minutes

  • A commit is a snapshot of the whole project, plus a message, an author and a pointer to its parent. Diffs are computed when you ask for them.
  • A commit's ID is a hash of its content and parent. Change anything, even the message, and you get a new commit with a new ID. That is why rebasing and amending "rewrite history".
  • A branch is a movable pointer to one commit, so creating one costs almost nothing. HEAD points to the branch you are on.
  • Work moves through three areas: the working directory (files on disk), the staging area (what the next commit will contain) and the repository (committed history).
working directory  ──git add──▶  staging area  ──git commit──▶  repository
        ◀──git restore──                ◀──git restore --staged──

With this model, git reset is "move a branch pointer" and git rebase is "copy commits onto a new parent". A "lost" commit is usually still in the repository, with nothing pointing to it.

A Sensible Setup

git config --global init.defaultBranch main
git config --global pull.rebase true            # rebase local commits on pull instead of merging
git config --global fetch.prune true            # remove branches deleted on the remote
git config --global push.autoSetupRemote true   # first push of a new branch sets its upstream
git config --global rebase.autoSquash true      # apply fixup! commits automatically
git config --global rebase.updateRefs true      # keep stacked branches in sync during a rebase
git config --global merge.conflictStyle zdiff3  # show the original text in conflicts
git config --global rerere.enabled true         # remember how you resolved a conflict
git config --global alias.lg "log --oneline --graph --decorate --all"

Keep editor and OS clutter (.idea/, .vscode/, .DS_Store) in a global ignore file, so each project's .gitignore only lists what the project creates: __pycache__/, .venv/, .env.

Writing Good Commits

A commit is a unit that a reviewer reads, a deploy can roll back, and git bisect can test. So:

  • One logical change per commit. A migration, the model change it needs and its tests belong together; an unrelated formatting fix does not.
  • Every commit builds and passes tests, so any point in history can be checked out and trusted.
  • The message explains why. The diff already shows what.

Split mixed changes with git add -p

Here cart.py holds a real fix and a debug print you do not want to commit. git add -p shows each hunk and asks y (stage) or n (skip):

$ git add -p cart.py          # y for the fix, n for the debug print
$ git diff --cached --stat     # staged: the fix
 cart.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git diff --stat              # not staged: the debug lines
 cart.py | 2 ++
 1 file changed, 2 insertions(+)

One messy afternoon becomes clean commits, and debug code never reaches review.

A message format that works

Add retry with backoff to payment client

The provider returns 503 during its nightly maintenance window,
which failed checkouts. Retry idempotent calls up to three times
with exponential backoff; non-idempotent calls still fail fast.

Refs: PAY-214
  • Summary in the imperative mood ("Add", "Fix"), around 50 characters, 72 at most.
  • Blank line, then a body explaining the problem and the reasoning, wrapped at about 72 characters.
  • A ticket reference if your team uses one. Conventional Commits prefixes (feat:, fix:) help if you generate changelogs.

Branching Strategies

Comparison of common Git branching strategies
StrategyHow it worksFitsTrade-off
Trunk-basedBranches live hours to a couple of days and merge into main; unfinished features hide behind flagsContinuous deployment with strong CINeeds good tests and flag discipline
GitHub FlowBranch from main, pull request, review, merge, deploy mainMost web services and small teamsOne production line, no release branches
GitFlowLong-lived main and develop, plus feature, release and hotfix branchesVersioned releases, several supported versions, QA gatesMore branches and merges, slower feedback

For a backend service that deploys often, GitHub Flow or trunk-based development keeps things simple. GitFlow earns its overhead with versioned releases; its author added a note in 2020 recommending simpler workflows for continuously delivered software.

Whatever the strategy: name branches by type and purpose (feature/PAY-214-payment-retry), keep them short-lived, protect main with required reviews and CI, and delete branches after merging.

Merge vs Rebase

Your feature branch has two commits, and someone pushed "Fix settings" to main. Merge joins the two lines with a new commit and changes nothing existing:

*   Merge branch 'main' into merge-demo
|\
| * Fix settings
* | Test orders endpoint
* | Add orders endpoint
|/
* Add app

Rebase replays your commits on top of the new main. History is a straight line, but your commits are new copies with new IDs:

* Test orders endpoint
* Add orders endpoint
* Fix settings
* Add app
git switch feature/orders
git fetch origin
git rebase origin/main          # or: git merge origin/main

Rebase gives a readable history and simpler bisects. Merge preserves exactly what happened. A common team rule: rebase your own branch to stay current, merge it into main through the pull request.

For the pull request itself, squash and merge turns the branch into one commit, which is clean and easy to revert but loses the individual commits. Merge commit or rebase and merge keep them, which is worth it when each commit is already meaningful.

Clean up history before review

Review comments lead to small fixes for earlier commits. Attach each fix to the commit it belongs to with --fixup, then let an autosquash rebase fold it in:

git add models.py
git commit --fixup=<sha-of-"Add order model">
git rebase -i --autosquash origin/main
Before                         After
fixup! Add order model         Add order view
Add order view                 Add order serializer
Add order serializer           Add order model
Add order model

Interactive rebase can also reorder, reword, split or drop commits. For the latest commit only, git commit --amend adds staged changes or edits the message.

Resolving Conflicts

main lowered a timeout to 10 while a feature branch raised it to 60. With merge.conflictStyle zdiff3, Git also shows the original value between the two sides:

<<<<<<< HEAD
TIMEOUT = 10
||||||| af76355
TIMEOUT = 30
=======
TIMEOUT = 60
>>>>>>> feature/timeout
RETRIES = 3

Knowing it started at 30 tells you both sides changed it deliberately, in opposite directions. That is a conversation with the other developer, not a coin toss. To resolve:

  1. Edit the file to the correct final content and remove the markers.
  2. Run the tests; a clean textual merge can still be a broken program.
  3. git add the file, then git commit (merge) or git rebase --continue (rebase).

git merge --abort or git rebase --abort returns you to where you started. With rerere.enabled, Git replays a resolution it has seen before, which helps when rebasing a long branch repeatedly.

Undoing Mistakes

Which Git command undoes which mistake
SituationCommandEffect
Discard uncommitted changes to a filegit restore app.pyFile matches the last commit; changes are gone
Unstage a file, keep the changesgit restore --staged app.pyRemoved from the next commit only
Bring back a file from an older commitgit restore --source=HEAD~1 app.pyOld version in the working directory, ready to commit
Fix the last commitgit commit --amendReplaces the last commit
Undo local commits, keep changes stagedgit reset --soft HEAD~1Branch moves back; files untouched
Undo local commits, keep changes unstagedgit reset HEAD~1The default --mixed mode
Throw away local commits and changesgit reset --hard HEAD~1Branch and files move back
Undo a commit that is already pushedgit revert <sha>New commit that reverses it

Rewrite only local history; revert shared history. reset and --amend change commit IDs and break everyone who already pulled them. revert adds a commit, so it is safe on main:

Revert "Round tax to two decimals"
Round tax to two decimals
Add tax calculation

When only one file is wrong, restore it from an earlier commit instead of reverting everything. A timeout was lowered from 30 to 5 and needs to go back:

$ git restore --source=HEAD~1 settings.py
$ cat settings.py
TIMEOUT = 30
$ git status --short
 M settings.py

Recover "lost" commits with the reflog

Git records every position HEAD has been in. Here git reset --hard HEAD~2 appeared to delete two commits:

$ git log --oneline
edb832b Add payment client

$ git reflog -n 4
edb832b HEAD@{0}: reset: moving to HEAD~2
c0a5de0 HEAD@{1}: commit: Add payment webhooks
c4ad21c HEAD@{2}: commit: Add retry to payment client
edb832b HEAD@{3}: commit (initial): Add payment client

$ git reset --hard HEAD@{1}
$ git log --oneline
c0a5de0 Add payment webhooks
c4ad21c Add retry to payment client
edb832b Add payment client

The same works after a botched rebase: find the entry before it started, then reset to it or run git branch rescue HEAD@{5}. Unreachable reflog entries expire after 30 days by default. The reflog cannot recover changes that were never committed or stashed, which is the real danger of reset --hard.

Remove untracked files safely with git clean

git clean deletes untracked files, and they are not in the reflog either. Always dry-run with -n first. Each flag widens what it touches:

$ git clean -n                 # untracked files only
Would remove debug.log

$ git clean -nd                # plus untracked directories
Would remove build/
Would remove debug.log

$ git clean -ndx               # plus ignored files
Would remove .env
Would remove __pycache__/
Would remove build/
Would remove debug.log

Note the last one: -x removes ignored files, including your .env. Use git clean -fd for build output and reserve -x for when you really want a pristine checkout.

Everyday Scenarios

Switch context with stash

You are halfway through a feature when an urgent bug comes in. Stash the work with a name, fix the bug, then bring it back:

$ git stash push -m "wip: partial refunds"
Saved working directory and index state On main: wip: partial refunds
$ git stash list
stash@{0}: On main: wip: partial refunds
$ git stash pop
...
	modified:   refunds.py
...
Dropped refs/stash@{0} (5dabebf48500ca6e97373191c1be145cb89bb1ec)

Name every stash; a list of anonymous "WIP on main" entries is hard to trust a week later. Add -u to include untracked files.

Work on two branches at once with worktree

For anything longer than a quick fix, a worktree checks out a second branch in another folder, sharing the same repository. Your feature branch stays exactly as it was:

$ git worktree add ../hotfix main
Preparing worktree (checking out 'main')
HEAD is now at 9a35071 Add app
$ git worktree list
~/code/repo    9a35071 [feature/refunds]
~/code/hotfix  9a35071 [main]

Run tests in one folder while you keep coding in the other. Remove it with git worktree remove ../hotfix when done.

Backport a hotfix with cherry-pick

A tax bug is fixed on main, but production runs the release/1.4 branch, which must not get unreleased features. Cherry-pick copies just the fix:

$ git switch release/1.4
$ git cherry-pick -x d4c9c79
[release/1.4 b6cf7ad] Fix tax rounding on refunds
 Date: Thu Oct 15 10:00:00 2026 +0530
 1 file changed, 1 insertion(+)
 create mode 100644 tax.py

$ git log -1 --format='%s%n%b'
Fix tax rounding on refunds
(cherry picked from commit d4c9c79e5b9ab616ca87f270a4b43e8a44eef228)

-x records where the commit came from, so anyone reading the release branch can trace the fix back to main.

Tag releases

An annotated tag marks the exact commit that shipped and stores who tagged it and when. git describe then names any later commit relative to it:

$ git tag -a v1.4.0 -m "Release 1.4.0"
# ...two more commits later
$ git describe --tags
v1.4.0-2-g9c1e870

That reads "two commits after v1.4.0, at commit 9c1e870", which makes a useful version string for logs and error reports. Tags are not pushed by default; push each one with git push origin v1.4.0.

Investigating History: log -S and bisect

To find when a function appeared or disappeared, search the content of every commit's changes with git log -S:

$ git log -S "calculate_tax" --oneline
669f647 Rename tax helper
c67036e Add tax calculation

Only commits that added or removed that string are listed; the unrelated commit in between is skipped. git blame -w -C app.py answers the line-level question, ignoring whitespace and following code moved between files.

When a test that passed last week fails today, git bisect binary-searches the commits in between. If a command can tell good from bad (exit code 0 is good), Git runs the whole search:

git bisect start HEAD HEAD~30          # bad commit first, then a known good one
git bisect run ./check.sh
git bisect reset                       # return to where you started
#!/bin/sh
# check.sh
python3 -c "from cart import total; assert total([10, 20, 30]) == 60"
a8e3c52d796e10faf8c2a953b3ecbfc958981462 is the first bad commit
commit a8e3c52d796e10faf8c2a953b3ecbfc958981462
Author: Riyas A C <dev@example.com>
Date:   Thu Oct 15 10:00:00 2026 +0530

    Refactor cart total

Git found the bad commit among 30 in 5 test runs; for 1,000 commits it would take about 10. In a real project the check is usually one test, such as git bisect run pytest tests/test_cart.py -x. Small, buildable commits make the answer a 10-line change instead of a 2,000-line one.

Keeping Secrets Out with Hooks

A secret committed once stays in history even after a later commit deletes it, and every clone has it. Read secrets from the environment and keep local values in a .env listed in .gitignore. Commit a .env.example with placeholders, and enable secret scanning and push protection on your Git host.

A Git hook is a script in .git/hooks/; a non-zero exit stops the action. This pre-commit hook blocks staged lines that look like an AWS key, a hard-coded Django SECRET_KEY or a private key:

#!/bin/sh
# .git/hooks/pre-commit: block staged lines that look like hard-coded secrets
pattern='AKIA[0-9A-Z]{16}|SECRET_KEY *= *"|PRIVATE KEY-----'

if git diff --cached -U0 | grep -E "^\+.*($pattern)" >/dev/null; then
    echo "pre-commit: possible secret in staged changes, commit blocked" >&2
    exit 1
fi
$ git commit -m "Add settings"          # SECRET_KEY = "django-insecure-abc123"
pre-commit: possible secret in staged changes, commit blocked

$ git commit -m "Add settings"          # SECRET_KEY = os.environ["SECRET_KEY"]
[main (root-commit) 43ae2e8] Add settings
 1 file changed, 1 insertion(+)

Hooks in .git/hooks/ are not shared through the repository, and --no-verify skips them. For a team, use the pre-commit framework with a committed config and a maintained scanner such as Gitleaks or detect-secrets. Run the same checks in CI.

If a secret reaches a remote, rotate it immediately. Rewriting history is cleanup, not a fix; the value may already be in clones, forks and CI logs. My post on API fundamentals covers where secrets should live instead.

Git Cheat Sheet

The commands from this guide, grouped by task.

Start and configure

CommandWhat it does
git initCreate a repository in the current folder
git clone <url>Copy a remote repository
git config --global user.name "Name"Set your identity (also user.email)
git config --global --listShow your global settings

Stage and commit

CommandWhat it does
git status -sShort status of changed files
git diff / git diff --cachedUnstaged / staged changes
git add <file>Stage a file
git add -pStage hunk by hunk
git commit -m "Message"Commit staged changes
git commit --amendChange the last commit (local only)
git commit --fixup=<sha>Mark a fix for an earlier commit

Branches

CommandWhat it does
git branchList local branches (-a includes remote)
git switch -c feature/xCreate and switch to a branch
git switch mainSwitch branch
git branch -d feature/xDelete a merged branch (-D forces)
git merge feature/xMerge a branch into the current one
git rebase origin/mainReplay your commits on top of main
git rebase -i --autosquash origin/mainEdit, reorder and squash your commits
git cherry-pick -x <sha>Copy one commit onto the current branch

Sync with a remote

CommandWhat it does
git fetch originDownload remote changes without touching your branch
git pullFetch, then rebase or merge into your branch
git pushUpload your commits
git push --force-with-leaseSafe force-push after a rebase of your own branch
git push origin --delete feature/xDelete a remote branch

Inspect history

CommandWhat it does
git log --oneline --graph --allCompact history with branch structure
git log -p app.pyEvery change to one file
git log -S "text"Commits that added or removed a string
git show <sha>One commit's message and diff
git blame -w -C app.pyWho last changed each line
git bisect run ./check.shFind the commit that broke a check

Undo and clean up

CommandWhat it does
git restore app.pyDiscard uncommitted changes to a file
git restore --staged app.pyUnstage, keep changes
git restore --source=<sha> app.pyBring back a file from an older commit
git reset --soft HEAD~1Undo the last commit, keep changes staged
git reset --hard HEAD~1Undo the last commit and its changes
git revert <sha>Reverse a pushed commit with a new one
git reflogEvery position HEAD has been in
git clean -nPreview which untracked files would be deleted
git clean -fdDelete untracked files and directories
git merge --abort / git rebase --abortCancel a merge or rebase in progress

Stash, worktrees and tags

CommandWhat it does
git stash push -m "msg"Shelve uncommitted changes (-u includes untracked)
git stash list / git stash popList stashes / restore the latest
git worktree add ../dir mainCheck out another branch in a second folder
git tag -a v1.4.0 -m "msg"Create an annotated release tag
git push origin v1.4.0Push a tag
git describe --tagsName the current commit relative to the latest tag

Common Git Mistakes

  • Force-pushing a shared branch, or using --force instead of --force-with-lease on your own.
  • Rebasing commits others have pulled, so the same changes appear twice in their history.
  • Huge commits mixing unrelated changes, which are hard to review, revert or bisect.
  • Long-lived branches that drift for weeks and end in a painful merge.
  • Committing generated files, virtual environments or .env. Set up .gitignore on day one.
  • Resolving conflicts without running tests.
  • git reset --hard or git clean -x with uncommitted work, the losses the reflog cannot undo.

Conclusion

Git rewards a small mental model. Commits are snapshots, branches are pointers, and history is lost only when nothing points to it and the reflog expires. The rules follow from it. Make small commits that explain why. Rebase your own work, merge shared work, and revert rather than rewrite anything pushed. Bisect finds what broke; the reflog finds what seems lost.

Frequently Asked Questions

What is the difference between git merge and git rebase?

Merge joins two branches with a new merge commit and leaves existing commits unchanged. Rebase replays your commits on top of another branch, producing a linear history with new commit IDs. Rebase your own unshared branches; merge shared ones.

How do I undo a pushed commit?

Use git revert <sha>. It creates a new commit that reverses the change, so nobody else's history breaks. Avoid git reset plus a force-push on shared branches.

Can I recover a commit after git reset --hard?

Usually. Run git reflog, find the commit, and reset to it or create a branch there. Unreachable entries expire after 30 days by default. Uncommitted changes discarded by the reset cannot be recovered this way.

What does git clean do?

It deletes untracked files from the working directory. -d includes directories and -x includes ignored files such as .env. Deleted files are not recoverable through Git, so run git clean -n first to preview.

Which branching strategy should a backend team use?

For services that deploy continuously, GitHub Flow or trunk-based development: short-lived branches merged into main after review and CI. Use GitFlow when you ship versioned releases or support several versions at once.

References