Git is something we use every day, yet the reasoning behind workflow decisions often stays vague. “Rebase keeps history cleaner than merge” is a phrase everyone has heard, but what that means precisely — and when not to use rebase — tends to remain fuzzy without a deliberate write-up.

GitHub’s PR and Code Review are split off into a later article.

Commit

Everything in Git centers on the commit. A commit is one node in a graph, and it points to a parent commit to form history. A branch is just a label drawn on top of those nodes, and a merge is the point where two flows meet.

flowchart LR
    A((A)) --> B((B)) --> C((C))
    C --> D((D))
    C --> E((E))
    D --> F((F))
    E --> F

The shape of this graph determines the cost of future debugging. When git bisect chases a regression, when git blame traces intent, clean commit boundaries cut down the search space significantly.

Commit Hygiene

A good commit carries a single intent. Mixing a refactor with an unrelated feature in one commit means later, when only one of the two needs to be reverted, the work has to be split apart by hand.

The message captures that intent. The subject line stays under about 50 characters, and the body — separated by a blank line — explains the why when needed. The what is already visible in the diff, so what makes the message valuable is the reasoning behind the change.

Conventions like Conventional Commits classify messages with prefixes such as feat:, fix:, and chore:. They pair well with toolchains that auto-generate changelogs or decide semantic versions.

Branch

A branch is a label that points to a commit. When a new commit is made, the current branch’s pointer moves one step to the new commit. git checkout simply moves HEAD to a different branch — nothing more. There’s no separate directory created on disk and no heavy work involved.

That simplicity is what makes branching strategies practical.

Branching Strategies

Three patterns are commonly compared.

StrategyFlowWhen it fits
Trunk-basedShort feature branches (hours to days), frequent merges into mainMature CI/CD, fast deploys
GitHub FlowFeature branch → PR → merge to main → deploySaaS, continuous deployment
GitFlowmain / develop / feature / release / hotfix layered separationLong release cycles, strict version management

Trunk-based stays simple and integrates often, so merge conflicts shrink. GitFlow gives strict release control at the cost of more branches and operational overhead. GitHub Flow lands in between and fits most SaaS workflows well.

The core question is: how often can we integrate into main? The more frequent the integration, the simpler the graph and the smaller each conflict.

Merge vs Rebase

When combining two flows, Git offers two options.

flowchart TB
    subgraph Before ["Diverged"]
        m1((m1)) --> m2((m2)) --> m3((m3))
        m1 --> f1((f1)) --> f2((f2))
    end
    subgraph Merge ["After merge"]
        mm1((m1)) --> mm2((m2)) --> mm3((m3)) --> mc((merge))
        mm1 --> mf1((f1)) --> mf2((f2)) --> mc
    end
    subgraph Rebase ["After rebase"]
        rm1((m1)) --> rm2((m2)) --> rm3((m3)) --> rf1((f1')) --> rf2((f2'))
    end

Merge combines two histories into a new commit (a merge commit). The graph keeps the divergence and convergence visible — when, by whom, and where things were combined.

Rebase replays one side’s commits on top of the other end. The graph becomes linear, but every replayed commit gets a new hash (f1 becomes f1'). They are, fundamentally, different commits.

The rule is clear: never rebase shared commits. If a teammate already has those commits in their branch, rebasing followed by a force push wrecks their history. On a local branch or a feature branch you have not shared yet, rebasing to clean up history is safe.

The policy for main itself is a team convention. Teams that prefer linear history use rebase or squash; teams that want the merge flow preserved use merge commits. There is no universally right answer — what matters more is consistency once a choice is made.

Common Pitfalls

git push --force overwriting a teammate’s commits

Force-pushing a shared branch can erase commits a teammate added after the rewritten point. --force-with-lease rejects the push when the remote has moved beyond what you saw locally — a built-in safety net.

Conflicts on every commit during rebase

Rebase replays commits one at a time, so conflicts surface per commit. If resolving them once is preferable, falling back to a merge or finishing the rebase and then squashing with git rebase -i are the practical options.

Lost work after git reset --hard

reset --hard resets both the working tree and the index. Committed work survives in the reflog — git reflog finds the SHA, and git reset --hard <sha> restores it. Uncommitted changes are harder to recover, so a habit of making a temporary commit before risky operations is a useful safety net.

Merge commits forming a tangled web

When small feature branches merge often into main, the graph stays clean. When long-lived branches keep merging into each other, the graph turns into a tangle. Squash merges or rebase merges can clean it up, but the deeper fix is to reduce the number of long-lived branches in the first place.

Wrap-up

Git workflow decisions reduce to choices about graph shape.

  • Commit: one intent per commit, the why in the message
  • Branch: just a pointer; integration frequency drives the strategy
  • Merge vs Rebase: don’t rebase shared commits; beyond that, follow team convention

In the end, commit hygiene, branch strategy, and merge·rebase choices add up to the shape of the graph. The simpler that shape stays, the lower the cost of later debugging and collaboration.

The next article picks up from here: how this graph is collaborated on through GitHub PRs — the Code Review cycle and merge strategies.