Skip to content
Muhammet Şafak
tr
Tools & Technologies 13 min read

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

SituationRecommendation
Mobile app, App Store distributionGitFlow
SaaS, small-to-medium team, daily deploysGitHub Flow
3-person freelance agencyGitHub Flow (or simpler: direct pushes without PRs)
50+ developers, 10+ deploys per dayTrunk-based
Versioned package / libraryGitFlow (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

NeedCommand
Commit a review fixgit commit --fixup <hash>
Fold fixups ingit rebase -i --autosquash main
Which commit introduced the bug?git bisect startbad/good
Automated bisectgit bisect run <test-command>
Recover a lost commitgit reflog
Parallel working directorygit worktree add <path> <branch>
Sign with SSHgit config gpg.format ssh
Safe force pushgit push --force-with-lease
Remember conflict resolutionsgit config rerere.enabled true
Find a string’s commitgit log -S"text"
Line-range blamegit blame -L 10,50 file
Modern branch switchinggit switch <branch>
Tags: #Git
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind