Skip to content

Git Workflows Footguns

Mistakes that cause failed deploys, merge nightmares, lost commits, and team dysfunction with Git branching strategies.


1. Long-Lived Feature Branches (The Merge Nightmare)

Your feature branch started as "a quick two-day thing." Three weeks later, 47 files have changed on main and your branch has 200+ lines of merge conflicts. You spend two days resolving conflicts, introduce a regression in the merge, and ship a bug to production.

Why it happens: Developers underestimate feature scope, get pulled into other work, or treat the branch as a personal workspace instead of an integration-bound deliverable.

The math is brutal: Merge conflict probability grows exponentially with branch lifetime and team size. A 2-day branch in a 5-person team has ~10% conflict chance. A 2-week branch has ~80%.

# Detect long-lived branches (danger zone: >3 days old)
git for-each-ref --sort=committerdate refs/remotes/origin/ \
  --format='%(committerdate:relative) %(refname:short)' | \
  grep -v 'main\|develop\|release'

# How far has this branch diverged from main?
git rev-list --left-right --count main...feature/old-branch
# Output: "47  23" means 47 commits on main, 23 on the branch — big divergence

Fix: Set a hard rule: branches >3 days old get flagged. Branches >1 week get escalated. Use feature flags to merge incomplete work safely. Break large features into stacked PRs.

Memory aid: "Three days? Three-alarm fire." If a branch survives three days, treat it as an escalation.


2. Workflow/Cadence Mismatch (GitFlow for a SaaS Team)

You adopt GitFlow because it looks professional and the blog post was convincing. But your team deploys 10 times per day. Now you have a develop branch that nobody understands, release branches that live for 2 hours, and developers spending more time on branch management than writing code.

Real example: A 15-person SaaS startup adopted GitFlow because "that is how serious engineering teams work." They spent 40% of their sprint ceremonies discussing branch management. After switching to GitHub Flow, they recovered 8 engineering hours per week.

The inverse is also true: A firmware team tried trunk-based development because "that is what Google does." Without a release branch model, they had no way to patch v2.1 without shipping all the v3.0 changes. Customers got unstable firmware updates.

Fix: Match the workflow to your actual release cadence and deployment model.

You do this You need this Not this
Deploy 5x/day, SaaS TBD or GitHub Flow GitFlow
Ship quarterly, versioned GitFlow or Release Branching TBD
Deploy weekly, staging gate GitLab Flow GitFlow

Memory aid: "Dress for the job you have, not the job you want." Use the workflow that fits your current cadence, not the one you aspire to.


3. Force-Pushing Shared Branches

Developer A rebases their shared branch and force-pushes. Developer B, who pulled the branch an hour ago, now has a divergent history. Their next git pull creates duplicate commits, phantom merge conflicts, or worse — silently loses their work.

# Developer A does this:
git rebase main
git push --force origin feature/shared-work  # DANGER

# Developer B's next pull:
git pull origin feature/shared-work
# "Divergent branches... merge or rebase?"
# Either option creates a mess

Why it is dangerous: Force-push rewrites commit SHAs. Anyone who has already fetched the old SHAs now has orphaned commits. Git cannot automatically reconcile the two histories.

Fix: 1. Never force-push branches that others have pulled. Full stop. 2. If you must update a shared branch, use --force-with-lease (fails if someone else pushed since your last fetch):

git push --force-with-lease origin feature/shared-work
3. Better yet: do not rebase shared branches. Use merge commits instead. 4. Protect shared branches with push rules that block force-push.

Memory aid: "Force push = friendly fire." You are rewriting history that your teammates depend on.


4. Cherry-Pick Drift Between Release Branches

You fix a bug on release/2.0 but forget to cherry-pick it to release/1.9 (which customers are still on). Or you cherry-pick to release/1.9 but forget to apply it to main, so the fix is missing in the next major release.

# The fix exists here:
git log release/2.0 --oneline | grep "fix: payment rounding"
# a1b2c3d fix: payment rounding error

# But not here:
git log release/1.9 --oneline | grep "fix: payment rounding"
# (nothing)

# And not here:
git log main --oneline | grep "fix: payment rounding"
# (nothing)

Why it happens: Cherry-picking is manual and error-prone. There is no automated system that says "this commit should exist on branches X, Y, and Z." As the number of supported release branches grows, the probability of missing a backport approaches 100%.

Fix: 1. Fix on main first, then backport. Never fix on a release branch without also fixing main. 2. Track backports explicitly — use labels ("needs-backport-1.9", "needs-backport-1.8") and automation. 3. Use git log --cherry-pick to find unported commits:

# Commits on main that are NOT on release/1.9
git log --cherry-pick --right-only main...release/1.9 --oneline
4. Automate with bots: Mergify, GitHub Actions, or custom scripts that auto-create backport PRs.

Memory aid: "Fix forward, port backward, verify everywhere." Three steps, every time.


5. Unprotected Main Branch

Your main branch has no branch protection. Anyone can push directly, skip CI, force-push, or push broken code. One junior developer pushes a syntax error at 5 PM on Friday. The deploy pipeline picks it up automatically. Production goes down.

This is the most common workflow footgun. It is also the easiest to fix.

# Check if main is protected (GitHub)
gh api repos/{owner}/{repo}/branches/main/protection 2>&1
# If you get a 404, main is NOT protected

# Fix it:
gh api repos/{owner}/{repo}/branches/main/protection \
  --method PUT \
  --field required_status_checks='{"strict":true,"contexts":["ci"]}' \
  --field enforce_admins=true \
  --field required_pull_request_reviews='{"required_approving_review_count":1}' \
  --field restrictions=null

Fix: Enable branch protection on main on day one of every project. Non-negotiable. Include: - Required CI status checks - Required PR reviews (even if just one) - Block force push - Apply to admins (no bypass)

Memory aid: "Protect main before writing the first feature." Branch protection is infrastructure, not a nice-to-have.


6. Merge Commit Archaeology (GitFlow's develop Branch)

After six months of GitFlow, git log develop is an unreadable nightmare. Hundreds of merge commits from feature branches interleaved with merge commits from release branch back-merges. Finding "when did this line change?" requires scrolling through pages of merge noise.

# What git log looks like on a busy GitFlow develop branch:
git log --oneline develop | head -20
# a1b2c3d Merge branch 'feature/billing' into develop
# b2c3d4e Merge branch 'release/2.3' into develop
# c3d4e5f Merge branch 'feature/auth-fix' into develop
# d4e5f6a Merge branch 'feature/billing' into develop  (again?!)
# e5f6a7b Merge branch 'hotfix/2.2.1' into develop
# ... (every line is a merge commit)

Why it is painful: - git blame shows the merge commit, not the original author - git log --follow gets confused by merge histories - New developers cannot understand the project history - git bisect works but has to traverse merge commits

Fix: 1. Use git log --no-merges to see actual changes 2. Use git log --first-parent develop to see only the merge points (treats develop like a timeline) 3. Consider squash-merging feature branches into develop (sacrifices bisect granularity) 4. Or: do not use GitFlow for projects that do not need versioned releases

Memory aid: "Develop is a river, not a log." Do not try to read it commit-by-commit. Read the merge points.


7. Rebase on Shared Branches (Lost Commits)

You rebase a branch that three other developers have been pushing to. Their commits are now orphaned — they exist in reflog but are no longer reachable from the branch. If they do not notice immediately, git gc will eventually delete them.

# Alice and Bob both work on feature/api-v2
# Alice rebases and force-pushes
git rebase main
git push --force origin feature/api-v2

# Bob's commits c1, c2, c3 are gone from the branch
# Bob does: git pull
# Git creates a merge of "his" history and "Alice's rewritten" history
# Result: duplicate commits, confusing diffs, wasted time

The subtle version: Even git pull --rebase on a branch that was force-pushed can silently drop commits if the rebase encounters conflicts that are "resolved" by choosing the incoming version.

Fix: 1. Rule: Only rebase branches you own. If anyone else has pushed to or pulled from a branch, it is shared. Do not rebase it. 2. Use merge commits on shared branches. They are noisier but safe. 3. If you must update a shared branch from main, merge main into it (not rebase onto main):

git checkout feature/api-v2
git merge main  # safe — no history rewrite

Memory aid: "Rebase your own, merge everyone's." If you did not write every commit on the branch, use merge.


8. Squash-Merge Losing Bisect Granularity

Your team uses squash-merge for every PR. The history is clean — one commit per PR. Then a subtle bug appears in production. You run git bisect and narrow it down to a single squash commit that contains 847 lines of changes across 23 files. You now have to manually bisect inside that squash commit, which is not possible with git tooling.

# Bisect finds the bad commit
git bisect good v2.3.0
git bisect bad HEAD
# ... after 7 steps ...
# a1b2c3d is the first bad commit
git show a1b2c3d --stat
# 23 files changed, 847 insertions(+), 203 deletions(-)
# Great. Which of the 847 lines caused the bug?

Why it matters: git bisect is one of the most powerful debugging tools in Git. It can find the exact commit that introduced a bug in O(log n) steps. But if your commits are squash-merged, bisect can only narrow to the PR level, not the individual change level.

Fix: 1. For critical services: Use merge commits (preserve individual commits) instead of squash 2. Compromise: Squash-merge for small PRs (<100 lines), merge commit for large PRs 3. Require clean commit history in PRs — if every commit is meaningful, squashing is less valuable 4. Keep PRs small — if a squash commit is <50 lines, losing bisect granularity barely matters

Memory aid: "Squash = clean log, blind bisect." You trade debugging power for readability.


9. Not Merging Main Back Into Feature Branches (Integration Debt)

You branch off main on Monday. By Thursday, main has moved 30 commits ahead. You never merge main into your branch. On Friday, your PR has massive conflicts because three other features changed the same files, and your branch never saw their changes.

# Check how far behind you are:
git fetch origin
git rev-list --count HEAD..origin/main
# 47 — you are 47 commits behind main

# The fix: merge main into your branch regularly
git checkout feature/my-work
git merge origin/main
# Resolve small conflicts incrementally instead of one big conflict at the end

Fix: Merge main into your feature branch at least daily. Some teams automate this with a bot that creates "update from main" PRs for stale branches.

Memory aid: "Sync early, sync often, sync before it hurts."


10. The "Release Manager" Single Point of Failure

In GitFlow, one person typically manages the release branch: cutting it, cherry-picking fixes, merging to main, back-merging to develop, and tagging. When that person is on vacation, sick, or leaves the company, nobody knows the release process. Releases stall.

Fix: 1. Document the release process as a runbook with exact commands 2. Automate as much as possible (CI-driven release cuts, auto-tagging) 3. Rotate the release manager role so at least 3 people can do it 4. Use release tooling (semantic-release, release-please, git-cliff) that reduces manual steps

Memory aid: "If one person holds the release keys, you are one sick day from a missed release."


11. Environment Branch Confusion (GitLab Flow Pitfall)

Teams using GitLab Flow sometimes merge directly to the production branch to "speed things up." This bypasses staging, skips integration testing, and creates a state where production has code that staging does not. Now your staging environment is no longer a reliable pre-production test.

# BAD: Fixing directly on production
git checkout production
git cherry-pick <fix-sha>  # bypasses staging

# GOOD: Fix on main, promote through the pipeline
git checkout main
git cherry-pick <fix-sha>
# Merge main → staging → production (in order)

Fix: Enforce the promotion model with branch protection: production can only receive merges from staging (or main, depending on your setup). Never commit directly to environment branches.

Memory aid: "Water flows downhill. Never pour upstream."


12. Forgetting to Delete Merged Branches

After six months, your repo has 300 stale branches. git branch -r takes 10 seconds to render. New developers cannot find active work. CI pipeline configs reference dead branches. The branch dropdown in GitHub is a scroll-fest.

# Find merged branches that can be deleted
git branch -r --merged origin/main | grep -v 'main\|develop\|release' | wc -l
# 247 — time to clean up

# Delete merged remote branches
git branch -r --merged origin/main | \
  grep -v 'main\|develop\|release' | \
  sed 's/origin\///' | \
  xargs -I {} git push origin --delete {}

# Enable auto-delete on GitHub (Settings > General > "Automatically delete head branches")

Fix: Enable automatic branch deletion after merge in your Git hosting platform. Run a monthly cleanup script for stragglers.

Memory aid: "Branches are disposable. If it is merged, it is done."