Choosing a Git Workflow
A Git workflow is the set of conventions a team agrees on for how branches get created, named, reviewed, and merged back into the project’s history. Git itself has no opinion here — it only gives you primitives (branches, commits, merges, tags) and lets you combine them however you like. Choosing the right workflow matters because it directly shapes how fast a team ships, how much merge-conflict pain they feel, and how easy it is to trace what went into a release and why.
Overview: What a Workflow Actually Is
Under the hood, every commit you make is an object identified by a SHA-1 (or SHA-256, on newer repos) content hash. A commit object stores a pointer to a tree (a snapshot of the project’s directory structure), pointers to its parent commit(s), and metadata (author, timestamp, message). A tree in turn points to blobs (raw file contents) and other trees for subdirectories. A branch, such as main or feature/login-page, is nothing more than a small file under .git/refs/heads/ holding the SHA of the commit it currently points to. HEAD normally points at a branch (not directly at a commit), and moving HEAD to a different branch just changes which ref file it points to and rewrites the working tree and index to match that commit’s tree.
Because branches are this cheap — a few bytes on disk, created instantly — Git supports wildly different team conventions on top of the same primitives. A “workflow” is really just an answer to a handful of questions: How long does a branch live before merging? Does main always reflect production, or does a separate branch track releases? Who is allowed to push directly to shared branches, and who must go through a pull request? How do merges happen — fast-forward, merge commit, squash, or rebase? Different answers produce very different commit graphs, and different amounts of process overhead.
The Main Git Workflows
Centralized Workflow
Everyone clones one shared repository and commits (mostly) straight to main, pulling before they push to stay in sync. It’s the closest Git gets to how older centralized systems like Subversion worked. It’s simple and requires no branching discipline, but it doesn’t scale — the more people pushing to main at once, the more frequent the conflicts and the higher the risk of breaking the build for everyone.
Feature Branch Workflow
Every unit of work — a feature, a bug fix — gets its own branch off main, and is merged back only through a pull request once it’s reviewed. main stays deployable because nothing lands without review. This is the baseline almost every other workflow on this page builds on top of.
Gitflow
A heavier model with two long-lived branches: main (always reflects what’s in production) and develop (the integration branch for the next release). Feature branches fork from and merge back into develop; when it’s time to ship, a release/* branch is cut from develop for final stabilization, then merged into both main and develop and tagged. hotfix/* branches fork from main for urgent production fixes. Gitflow is well suited to software with scheduled, versioned releases (desktop apps, libraries, embedded firmware) but is usually overkill for a web app deployed continuously.
GitHub Flow
A much lighter model built around a single long-lived branch, main, which is always deployable. Every change is a short-lived branch off main, opened as a pull request, reviewed, and merged — often triggering an automatic deploy. There’s no develop branch and no scheduled release branch. It fits teams that deploy continuously (multiple times a day) far better than Gitflow does.
Forking Workflow
Common for open-source projects where most contributors don’t have push access to the source repository. Each contributor forks the repo into their own GitHub account, clones their fork, and opens pull requests from their fork’s branches back to the upstream repository. The upstream maintainers keep full control over what merges. This is layered on top of Feature Branch or GitHub Flow — it changes where branches live, not how merging is reviewed.
Trunk-Based Development
An even more aggressive version of GitHub Flow: branches live for at most a day or two (often just hours), are kept tiny, and merge to main constantly. Incomplete features are hidden behind feature flags rather than kept on a long-lived branch. It demands strong CI and a culture of small commits, but minimizes merge conflicts and integration pain since nothing drifts far from main for long.
| Workflow | Branch lifetime | Best for |
|---|---|---|
| Centralized | None (commit to main) |
Solo projects, tiny teams, prototypes |
| Feature Branch | Days to a couple weeks | Most small-to-mid teams |
| Gitflow | Weeks (release branches) | Versioned releases, multiple supported versions |
| GitHub Flow | A few days | Continuous deployment web apps/services |
| Forking | Varies | Open-source projects with many outside contributors |
| Trunk-Based | Hours to 1–2 days | Teams with strong CI, feature flags, frequent deploys |
Syntax
Regardless of which workflow a team picks, the same handful of command patterns show up everywhere:
git switch -c <branch-name> # create and switch to a new branch
git push -u origin <branch-name> # publish it and track it on the remote
git fetch <remote> # download new refs/commits without merging
git switch main && git pull # update main from the remote
git merge --no-ff <branch-name> # merge, always creating a merge commit
git switch -c <name>— creates a new branch pointing at the current commit and movesHEADto it in one step (replacesgit checkout -b).-u/--set-upstreamongit push— links the local branch to a remote-tracking branch so future plaingit push/git pullknow where to go.--no-ffongit merge— forces a merge commit even when a fast-forward would be possible, which keeps a visible record that a branch existed (common in Gitflow).gh pr create/gh pr merge— the GitHub CLI’s shortcuts for opening and merging pull requests without leaving the terminal.
Examples
Example 1: GitHub Flow — a short-lived feature branch
git switch main
git pull
git switch -c feature/checkout-page
git add checkout.html checkout.css
git commit -m "feat: add checkout page skeleton"
git push -u origin feature/checkout-page
gh pr create --base main --title "Add checkout page" --body "Initial checkout page layout, no payment logic yet."
Output:
Enumerating objects: 6, done.
Writing objects: 100% (6/6), 1.02 KiB | 1.02 MiB/s, done.
remote: Create a pull request for 'feature/checkout-page' on GitHub by visiting:
remote: https://github.com/yourname/shop/pull/new/feature/checkout-page
Branch 'feature/checkout-page' set up to track remote branch 'feature/checkout-page' from 'origin'.
https://github.com/yourname/shop/pull/42
This is the whole GitHub Flow cycle: branch off an up-to-date main, commit, push, open a pull request. Once a teammate approves it, merging the PR (usually via “Squash and merge” on GitHub) puts the change on main, which is deployable at every point.
Example 2: Gitflow — cutting a release branch
git switch develop
git switch -c release/1.4.0
git commit -am "chore: bump version to 1.4.0"
git switch main
git merge --no-ff release/1.4.0
git tag -a v1.4.0 -m "Release 1.4.0"
git switch develop
git merge --no-ff release/1.4.0
git branch -d release/1.4.0
git push origin main develop --tags
Output:
Merge made by the 'ort' strategy.
version.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
Deleted branch release/1.4.0 (was 9a3f21c).
To github.com:yourorg/app.git
7c1e0aa..9a3f21c main -> main
4b2d1aa..9a3f21c develop -> develop
* [new tag] v1.4.0 -> v1.4.0
The release branch exists only to stabilize (final bug fixes, version bumps) before it lands in both main and develop and gets tagged. --no-ff guarantees a merge commit even though this merge could otherwise fast-forward, which preserves a visible marker of the release in the graph.
Example 3: Forking workflow with the GitHub CLI
gh repo fork octocat/hello-world --clone
cd hello-world
git remote add upstream https://github.com/octocat/hello-world.git
git fetch upstream
git switch -c fix/typo-readme upstream/main
git commit -am "fix: correct typo in README"
git push -u origin fix/typo-readme
gh pr create --repo octocat/hello-world --base main --title "Fix typo in README"
Output:
✓ Created fork yourname/hello-world
Cloning into 'hello-world'...
✓ Added remote upstream
Branch 'fix/typo-readme' set up to track remote branch 'main' from 'upstream'.
https://github.com/octocat/hello-world/pull/108
Notice two remotes now exist: origin (your fork, where you push) and upstream (the source repo, which you only fetch from). The pull request targets upstream‘s main branch, and only the maintainers of that repository can merge it — you never need push access to contribute.
How It Works Step by Step
Whichever workflow a team uses, the same mechanics happen underneath every branch and merge:
- Branching writes one new ref file (e.g.
.git/refs/heads/feature/checkout-page) containing the SHA of the current commit. Nothing is copied — the new branch and the branch it came from point at the identical commit until you commit something new. - Committing on the new branch creates a new commit object whose parent is the previous commit, then moves the branch ref forward to the new commit’s SHA.
HEAD, which points at the branch (not the commit directly), follows along automatically. - A fast-forward merge (no divergent commits on the target branch) simply moves the target branch’s ref up to match the source branch’s tip — no new commit is created.
- A three-way merge (the target branch has commits the source branch doesn’t) creates a new commit object with two parents: the tip of each branch. This is what
--no-ffforces even when a fast-forward was possible. - A squash merge (GitHub’s “Squash and merge”) takes the combined diff of every commit on the source branch and applies it as a single new commit on the target branch. That new commit has only one parent — the target branch’s previous tip — so the individual commits from the feature branch never appear as ancestors of
main.
Common Mistakes
Mistake 1: Committing straight to main in a team workflow
git switch main
git commit -am "fix: quick patch"
git push
This bypasses code review entirely and risks breaking main for everyone the moment it’s pushed. Once a team has more than one or two contributors, every workflow above except plain Centralized expects changes to land through a branch and a pull request.
Fix: branch first, even for small fixes.
git switch -c fix/quick-patch
git commit -am "fix: quick patch"
git push -u origin fix/quick-patch
gh pr create --title "Quick patch"
Mistake 2: Force-pushing a shared branch
git push --force origin main
A bare --force silently overwrites whatever is on the remote, even commits a teammate pushed seconds ago that you haven’t fetched yet — their work is simply gone from the branch history.
Fix: use --force-with-lease, which refuses to push if the remote branch has moved since you last fetched it, and never force-push a branch other people are actively working from.
git push --force-with-lease origin feature/checkout-page
Mistake 3: Using Gitflow for a continuously-deployed web app
Maintaining separate main and develop branches, plus release branches, adds real overhead: every change has to be merged in two places, and it’s easy for the branches to drift. Teams that ship many times a day get little benefit from a release-stabilization branch they cut through in minutes anyway.
Fix: use GitHub Flow or trunk-based development instead — a single deployable main with short-lived feature branches matches a fast release cadence far better.
Best Practices
- Pick a workflow that matches your release cadence, not the fanciest-sounding one — Gitflow for versioned software, GitHub Flow or trunk-based for continuous deployment.
- Keep branches as short-lived as your workflow allows; the longer a branch lives without merging, the more it diverges and the worse the eventual conflicts.
- Never rebase or force-push a branch other people have already pulled — only rewrite history on branches you alone own.
- Protect
main(anddevelop, if you use one) with GitHub branch protection rules requiring pull request review and passing CI checks before merge. - Write commit messages in a consistent style (e.g. Conventional Commits:
feat:,fix:,chore:) so the history and changelog stay readable regardless of which workflow you use. - Delete branches after merging (
gh pr merge --delete-branchor the GitHub UI’s “Delete branch” button) to keep the branch list from becoming clutter. - Document the chosen workflow in the repository’s
CONTRIBUTING.mdso new contributors don’t have to guess.
Practice Exercises
Exercise 1
You’re on a two-person side project that ships whenever a change is ready, with no scheduled releases. Decide which workflow fits best and write out the exact command sequence to create a branch called feature/dark-mode, commit a change, and open a pull request against main.
Exercise 2
Your team maintains a desktop application with versioned releases (1.0, 1.1, 2.0) that must each remain independently patchable for bugs found after release. Sketch which long-lived branches you’d keep and which commands you’d run to cut a hotfix/1.1.2 branch from the main branch’s v1.1.0 tag.
Exercise 3
You want to fix a typo in the documentation of an open-source project you don’t have push access to. List the commands, in order, to fork it, branch, commit your fix, push to your fork, and open a pull request back to the original repository.
Summary
- A Git workflow is a team convention layered on top of Git’s core primitives — branches, commits, merges, tags — not a separate feature of Git itself.
- Centralized and Feature Branch are the simplest models; Gitflow adds structure for versioned releases; GitHub Flow and trunk-based development optimize for continuous deployment; Forking suits open-source contributors without push access.
- A branch is just a movable pointer to a commit; merging either fast-forwards that pointer, creates a two-parent merge commit, or (when squashing) creates a single new commit representing the combined diff.
- Match the workflow to your release cadence and team size — heavier process like Gitflow pays off with scheduled releases, but is friction without benefit for teams deploying continuously.
- Regardless of workflow, protect shared branches, prefer
--force-with-leaseover bare--force, and never rebase a branch others have already pulled.
