# 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.

- Published: 2026-05-23
- Category: Tools & Technologies
- Tags: Git
- Reading time: 10 min read
- Source: https://www.muhammetsafak.com.tr/en/blog/git-in-production-a-senior-engineers-practical-guide/
- Language: en-US
- Author: Muhammet Şafak

---
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](/en/blog/git-workflow-branching-merging-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](https://nvie.com/posts/a-successful-git-branching-model/): `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:

```bash
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:

```bash
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:

```bash
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

```bash
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:

```bash
git bisect reset
```

### Automated Bisect

If the "broken or working?" question can be answered by a command (such as a test):

```bash
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.

```bash
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:

```bash
git checkout def5678
git switch -c recovered-branch
```

Or create a branch directly:

```bash
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**.

```bash
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:

```bash
cd ../hotfix-2026-05
# work, commit, push
cd ../project
git worktree remove ../hotfix-2026-05
```

To list worktrees:

```bash
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 "linus@kernel.org"` 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)

```bash
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`.

```bash
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:

```bash
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.

```bash
# 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](https://medium.com/@muhametsafak/gi%CC%87t-versiyon-kontrol-sistemi-1-giri%C5%9F-kurulum-ve-yap%C4%B1land%C4%B1rma-2a5cdab1486b) — 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>` |
