Git in Production: A Senior Engineer's Practical Guide
From workflow selection to bug hunting with bisect, rebase discipline, worktrees, and signed commits — things I noticed after years of using Git.
The difference between someone who has used Git for 5 years and someone who has used it for 15 isn’t the number of commands they know. The difference is this: the first person learned how to use Git, the second learned how to decide what to do with Git, and when.
This post isn’t about basic commands. You already know add, commit, push, pull. This is about decisions and lesser-known but life-saving pieces: which workflow to choose, how to keep your history clean, how to find which commit introduced a bug, and how to recover from a force-push gone wrong.
For more foundational branch/merge/PR discipline, check out my earlier post Git flow: branch, merge and pull request discipline. This post builds a layer on top of that.
1. Workflow Selection: GitFlow vs GitHub Flow vs Trunk-Based
If you don’t have an answer to “what’s your Git workflow?”, you’ve already picked one without knowing it — and odds are it’s the wrong one. There are three main approaches:
GitFlow
The classic model published by Vincent Driessen in 2010: main (production), develop (integration), feature/*, release/*, hotfix/* branches. Everything is planned, with a separate release branch for each deployment.
When to use it: Products with versioned, planned release schedules. Mobile apps (App Store release cycles), enterprise software, package projects that ship numbered versions like “v2.3.1”.
When not to use it: Web applications and continuously deployed systems. Applying GitFlow to SaaS usually means release branches are meaningless, and develop is perpetually 2–3 commits behind main.
GitHub Flow
A simple model: main is always deployable, short-lived branches for each feature, merge via PR, deploy immediately after merge. No release branches, no develop branch.
When to use it: Small-to-medium teams (3–15 people), CI/CD in place, deploying once or twice a day. The majority of agencies and startups will make the right call here.
When not to use it: Teams of 50+, where the PR queue to main becomes too long. Or if you’re still doing weekly manual deploys — this workflow’s prerequisite is that deploying takes seconds.
Trunk-Based Development
Every developer commits directly to main (or uses very short-lived branches). Unfinished features are hidden behind feature flags. Branch lifetime is measured in hours at most.
When to use it: Large teams (Google, Facebook, Spotify), a continuous deployment culture, and an established feature flag infrastructure. Requires high discipline.
When not to use it: If you lack feature flag infrastructure or your CI test coverage is low — it becomes far too easy to break main.
Decision Matrix
| Situation | Recommendation |
|---|---|
| Mobile app, App Store distribution | GitFlow |
| SaaS, small-to-medium team, daily deploys | GitHub Flow |
| 3-person freelance agency | GitHub Flow (or simpler: direct pushes without PRs) |
| 50+ developers, 10+ deploys per day | Trunk-based |
| Versioned package / library | GitFlow (release branches are useful) |
| Unplanned, “whatever works” | GitHub Flow (default) |
Anti-pattern: Mixing all three. “We have a develop branch AND use feature flags AND each feature goes out on a release branch” — that’s applying half of three different disciplines and getting the benefits of none.
2. History Hygiene: Rebase and Autosquash
You open a PR, get review feedback, and there are comments asking for small fixes and typo corrections. You make three separate fix commits and update the PR. Now the PR has 1 meaningful commit + 3 “address review” commits. That’s what git log will look like after the merge.
There are two ways to fix this. Squash merge (one click on GitHub) collapses everything into a single commit — but that’s its own kind of extreme: 3 logically distinct changes across 200 lines all crammed into one commit.
The right middle ground is interactive rebase + autosquash.
Fixup Commit Workflow
You’re addressing a review comment. Instead of a new commit:
git add .
git commit --fixup <original-commit-hash>
This creates a commit with the message fixup! <original commit message>. You can stack 2–3 more fixups the same way.
When the PR is ready to merge:
git rebase -i --autosquash main
When the editor opens, each fixup has been automatically placed beneath its target commit and marked as fixup. Save without changing anything — Git folds the fixups into their respective commits. Result: a history just as clean as it was before the fixup commits, but with the review changes absorbed into the relevant commit.
To make this even more automatic:
git config --global rebase.autosquash true
After this setting, git rebase -i applies autosquash every time. One less thing to remember.
The Golden Rule
Never rebase on public branches — main, develop, or any branch others have pulled. Rewriting history invalidates the commit hashes that exist in other people’s clones. The result: merge conflict hell.
Rebase on your own private branch. If the PR hasn’t been merged yet, the branch is still yours — rebase freely. Once it’s merged, leave it alone.
3. Bug Hunting with Bisect
Classic scenario: there’s a regression in production. It worked last week, it doesn’t today. Which of the 80 commits in between introduced it? Doing that binary search by hand is a 4-hour job. With bisect, it’s 6 minutes.
Manual Bisect
git bisect start
git bisect bad HEAD # currently broken
git bisect good v2.3.0 # was working at this version
Git checks out the commit halfway between the two points. You test it:
- Broken:
git bisect bad - Working:
git bisect good
Each answer halves the search space. For 80 commits, it finds the culprit in ~7 steps.
When you’re done:
git bisect reset
Automated Bisect
If the “broken or working?” question can be answered by a command (such as a test):
git bisect start HEAD v2.3.0
git bisect run npm test -- --testPathPattern=image-upload
Git runs the test on every commit on your behalf, treating exit code 0 as “good” and anything else as “bad”. You grab a coffee and watch the offending commit appear on screen out of 80 commits.
The best thing about bisect: Once you find the commit that introduced the bug, you have its author, message, and full changeset right in front of you. Rather than looking at a single line with git blame, bisect gives you the whole story.
4. Reflog: “I Lost My Commits” Moments
You ran reset --hard. You merged into the wrong branch. Someone force-pushed over you. “My commits are gone.” No, they’re not.
Git keeps a record of every move HEAD has made over the last ~90 days in the reflog. No commit is ever truly deleted — it just loses any branch or tag pointing to it. The reflog lets you find it again.
git reflog
Output looks something like this:
abc1234 HEAD@{0}: reset: moving to HEAD~3
def5678 HEAD@{1}: commit: addressed review feedback
ghi9012 HEAD@{2}: commit: refactor user service
jkl3456 HEAD@{3}: pull: Fast-forward
The commit you thought was gone is def5678. To recover it:
git checkout def5678
git switch -c recovered-branch
Or create a branch directly:
git branch recovered-branch def5678
Important: The reflog is local. It’s only the record kept by Git on your own machine. If you lost commits because of someone else’s force-push, you need to look at their reflog.
Second important point: garbage collection will eventually delete things for real. The defaults are: reflog entries for 90 days, “unreachable” commits get GC’d after 30 days. So the reflog is not a reliable safety net for “something I did last year”.
5. Worktree: One Repo, Multiple Directories
You’re working on a feature, your branch has changes in progress. Suddenly an urgent hotfix is needed — you have to switch to main but you don’t want to lose your current work.
Classic solution: git stash, switch branches, hotfix, switch back, stash pop. It works, but:
- Stashes accumulate
- IDE state is lost
- Open files change
- You forget which stash was which
The better way: worktree.
git worktree add ../hotfix-2026-05 main
This command creates a second working directory for the same repo, but in a different folder and on the main branch. Your feature branch stays untouched, you can open two separate folders in your IDE, and when the hotfix is done:
cd ../hotfix-2026-05
# work, commit, push
cd ../project
git worktree remove ../hotfix-2026-05
To list worktrees:
git worktree list
Typical use cases:
- Checking out a PR branch in a separate directory for code review (without touching your current work)
- Developing multiple features in parallel
- A quick environment for an urgent hotfix
- When you need to look at an old release (
git worktree add ../v2.3 v2.3.0)
Once you start using it, you’ll notice you’re opening more worktrees than stashes.
6. Signed Commits: GPG and SSH
Git does not verify the author information written into commits. You can run git config user.email "[email protected]" and commit — Git won’t object. This is one of supply chain attackers’ favorite attack surfaces.
The fix: commit signing. You sign each commit with your own key, GitHub/GitLab verifies it, and displays it with a “Verified” badge.
There are two approaches.
Signing with GPG (classic)
gpg --full-generate-key
gpg --list-secret-keys --keyid-format=long
git config --global user.signingkey <KEY_ID>
git config --global commit.gpgsign true
You upload the GPG public key to GitHub (Settings → SSH and GPG keys). Every commit is automatically signed from then on.
The problem: GPG setup, key management, expiring keys, and the hassle of moving to a new machine. Most developers put it off as “I’ll deal with GPG later” and keep committing without signatures indefinitely.
Signing with SSH (modern)
Git 2.34+ and GitHub from 2022 onward: you can sign commits with your SSH key — the same SSH key you already use for git push.
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
On GitHub: Settings → SSH and GPG keys → “New SSH key” → select key type Signing Key (separate from Authentication key).
That’s it. All the GPG setup complexity is bypassed. For anyone starting fresh after 2024, SSH signing should be the default choice.
To verify:
git log --show-signature -1
You should see Good "git" signature for ... in the output.
7. Dangerous Commands: --force Discipline
Three commands can cause you to lose work:
git push --force-with-lease instead of --force
--force overwrites the upstream without asking. If someone else pushed a commit to the branch while you weren’t looking, their commit disappears.
# Dangerous
git push --force
# Safe
git push --force-with-lease
--force-with-lease checks that the current state of the upstream matches what you last fetched. If someone pushed in the meantime, the push is rejected and you’re told to fetch first.
To automate this with an alias:
git config --global alias.pushf 'push --force-with-lease'
git reset --hard
Resets the working directory, index, and HEAD to the target commit. Unsaved changes are gone. This does get recorded in the reflog — so it is recoverable.
Alternative approaches:
- If you just want to undo the commit:
git reset --soft HEAD~1(leaves changes in staging) - If you want to clean the working directory: first take a backup with
git stash, then reset
git clean -fd
Deletes untracked files from the working directory. Adding -x also removes files covered by .gitignore. Called with the wrong flags, it can wipe node_modules or local config files.
Run git clean -nfd (dry run) first to see what would be deleted, then use -f to perform the actual deletion.
8. Powerful Lesser-Known Commands
git rerere — “Reuse Recorded Resolution”
If you’re resolving the same merge conflict over and over (the classic long-running feature branch + frequent main rebase scenario), rerere remembers how you resolved a conflict once and applies the fix automatically next time:
git config --global rerere.enabled true
Once you turn it on, you forget about it and it works silently in the background. A week later you’ll notice: “I don’t remember resolving this conflict, but Git did?”
git log -S and git log -G — Pickaxe
To find when a piece of code was added or removed, and in which commit:
git log -S"OldFunctionName" # find commits that added or removed this string
git log -G"regex_pattern" # search with a regex
This is the fastest answer to “when was this deprecated function removed?” It scans the entire history and performs retroactive archaeology.
git blame -L
Blame a specific line range rather than the entire file:
git blame -L 45,80 src/Service/PaymentGateway.php
Invaluable on large files — you see the authorship history only for the section you care about.
git switch and git restore
Git has long used git checkout for two completely different jobs: switching branches and restoring files. Git 2.23 (2019) split them apart:
git switch main # switch branch
git switch -c new-feature # create and switch to new branch
git restore file.php # revert changes in working directory
git restore --staged file.php # remove from staging (unstage)
checkout still works, but using switch/restore when writing new code is cleaner. Teaching juniors the new commands prevents the confusion caused by checkout’s dual meaning.
Closing Thoughts
Learning Git isn’t a one-time thing. The first year you learn add, commit, push. The second year: branch, merge, rebase. The fifth year: bisect, reflog, worktree. The fifteenth year: rerere, pickaxe, signed commits. The commands are 20 years old, and yet every year you learn a new one, because every new scenario calls for a new tool.
None of the commands covered in this post are new — bisect has been around since 2005. What’s new is knowing when to use them. What separates a senior developer from others isn’t the number of commands they know; it’s remembering the right command at the right moment.
For Git fundamentals, check out my Medium series from 2022 — everything from setup to branching and cherry-pick, covering beginner-to-intermediate commands. This post builds on top of that, collecting the pieces that years of real production problems taught me.
When you learn a new Git command — and there’s one you’ll learn this week — save it as an alias in ~/.gitconfig. Two months from now you’ll have forgotten the name; the alias won’t.
Quick Command Reference
| Need | Command |
|---|---|
| Commit a review fix | git commit --fixup <hash> |
| Fold fixups in | git rebase -i --autosquash main |
| Which commit introduced the bug? | git bisect start → bad/good |
| Automated bisect | git bisect run <test-command> |
| Recover a lost commit | git reflog |
| Parallel working directory | git worktree add <path> <branch> |
| Sign with SSH | git config gpg.format ssh |
| Safe force push | git push --force-with-lease |
| Remember conflict resolutions | git config rerere.enabled true |
| Find a string’s commit | git log -S"text" |
| Line-range blame | git blame -L 10,50 file |
| Modern branch switching | git switch <branch> |
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.