Overview
Every team I've joined has had a different Git workflow, and every one of them had someone quietly rebasing pushed commits while everyone else pretended not to notice. The commands aren't the hard part — the hard part is agreeing on what goes where and when. This is the workflow that's worked for teams I've been on, plus the commands that make it possible.
The branching model that actually holds up
Trunk-based development with short-lived feature branches. That's it. Long-lived branches named after environments or release cycles create merge conflicts as a lifestyle, and I've never seen a team go back to them after switching.
| Branch | Lives for | Merges into |
|---|---|---|
main | Forever | Deploys to production on merge |
feature/* | Hours to days | main via squash merge |
fix/* | Hours | main via squash merge |
hotfix/* | Minutes | main, then cherry-pick to any release branch |
The rule that makes this work: nothing lives longer than two days. If a branch is older than that, it's either too big or abandoned.
Interactive rebase: your best friend
Before pushing, clean up your commits. This is the single highest-leverage Git habit:
git rebase -i main
You get an editor with your commits listed. Change pick to:
reword— change the messagesquash— combine with the previous commit, keeping both messagesfixup— combine with the previous commit, discarding this messagedrop— remove the commit entirelyedit— pause to amend the commit content
The workflow I use: commit freely while working, then rebase -i before pushing to turn "fix typo" and "actually fix typo" and "wip" into three clean commits with real messages. Nobody needs to see the mess.
For a quick fixup without the editor:
git commit --fixup=abc123
git rebase -i --autosquash main
The --fixup flag creates a commit marked to be squashed into abc123, and --autosquash reorders and marks it automatically. This is faster than the manual route once you're used to it.
The commands that stop you from making things worse
git reflog
Every Git mistake is recoverable for about 90 days, and reflog is how. It's a log of every place HEAD has pointed:
git reflog
# abc1234 HEAD@{0}: rebase (finish): returning to refs/heads/main
# def5678 HEAD@{1}: commit: add validation
# 9012abc HEAD@{2}: reset: moving to HEAD~3 ← the commit you "lost"
Find the commit you nuked, then recover it:
git reset --hard 9012abc
Or if you just want a file back:
git checkout 9012abc -- path/to/file
The reflog has saved me from a bad rebase at least four times. It's the first thing to check when you think you've lost work.
git bisect
For finding which commit introduced a bug, when you know the bug exists now but didn't last week:
git bisect start
git bisect bad # current commit is broken
git bisect good v1.2.0 # this tag was fine
# Git checks out a commit in the middle
npm test # is it broken?
git bisect good # or: git bisect bad
# Repeat until Git identifies the first bad commit
git bisect reset
For a hundred commits, that's seven checks instead of a hundred. If you can write a script that exits non-zero on failure, git bisect run ./test.sh automates the whole thing.
git stash with a purpose
# Stash with a description
git stash push -m "half-finished auth refactor"
# Include untracked files
git stash push -u -m "new files too"
# List and inspect
git stash list
git stash show -p stash@{1}
# Apply and drop, or apply and keep
git stash pop
git stash apply stash@{2}
Stashing is meant for "I need to switch context for five minutes." Anything longer than that should be a WIP commit on a branch. Stashes are easy to forget about, and a stash that's been sitting for three weeks is almost certainly stale.
Merge vs rebase: the actual rule
The argument never ends because both sides are right about different things.
| Situation | Use |
|---|---|
Updating your feature branch with main | Rebase (keeps history clean) |
Merging a feature into main | Merge with --squash (one commit per feature) |
| Shared branch that others are working on | Merge, never rebase |
| Cleaning up local commits before pushing | Rebase |
The "never rebase pushed commits" rule exists because rewriting history breaks everyone else's local copy. If nobody else has pulled, it's safe. If they have, you're creating work for them. Use git push --force-with-lease instead of --force if you must rewrite:
git push --force-with-lease
This checks that the remote is still where you think it is before force-pushing. If someone else pushed in the meantime, it refuses rather than overwriting their work. There is no reason to use plain --force on a shared branch.
Aliases that save real time
git config --global alias.st "status -sb"
git config --global alias.last "log -1 HEAD --stat"
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.unstage "reset HEAD --"
git config --global alias.amend "commit --amend --no-edit"
git config --global alias.undo "reset --soft HEAD~1"
git lg is the one I use ten times a day. It shows the branch graph, which makes it obvious when your local main is behind, or when a rebase went sideways.
Pre-commit hooks worth having
A pre-commit hook that runs your linter and formatter is worth the ten minutes it takes to set up. The standard tool is pre-commit, installed via pip:
pip install pre-commit
pre-commit install
Then a .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
The detect-private-key hook has caught a committed AWS key for me before. That alone justifies the setup cost.
What I'd tell a new team
Write down the workflow in the README. "We squash-merge to main, branches live less than two days, never force-push to main" is three lines that prevent a year of confusion. Everything else is detail.
