Version Control

Team Git Workflows in Practice: Branching Models and Conflict Resolution

Branch model choice, the boundary between rebase and merge, and why conflicts keep recurring all point to the same underlying problem: a team without an explicit commit contract. This guide compares the applicable boundaries of trunk-based development and gitflow, gives concrete rules for "rebase locally, merge on shared branches", explains the destructive edge of history rewriting plus recovery options, analyzes common causes of conflict — parallel edits to the same file, formatters, case-insensitive filesystems — and lays out a message and formatting convention that keeps history readable.

By LaoHand Team·9 min read·Updated 2026-10-02

Choosing a branching model: release cadence first, team size second

gitflow expresses code maturity through five branch types: main for stable releases, develop for integration, and feature, release, and hotfix branches on top. Its value is that there is zero ambiguity about which branch may reach production, which suits software that genuinely maintains multiple long-lived versions — supporting both 2.0 and 3.0, shipping separate patch releases, and operating under strict release windows and quality gates.

The cost is branch lifetime. A feature branch that lives two weeks from creation to merge accumulates divergence from main, and the area of potential conflict grows with it; the merge then requires manual verification to be confident the work still behaves correctly on the trunk. For a product shipping many times a day, that coordination cost often exceeds the discipline gitflow buys.

Trunk-based development rests on short-lived branches plus frequent integration: a developer branch typically lives one or two days, unfinished features stay invisible behind feature flags, and the gate for merging to trunk is passing automated tests plus code review. The payoff is a tiny conflict surface and problems surfacing early. The cost is that trunk may hold incomplete-but-hidden code, which demands feature flags and real test coverage.

The pragmatic middle ground is GitHub Flow: a single trunk, short-lived feature branches, and preview environments instead of release branches. For the majority of web projects it is the best default. Three questions actually drive the choice: whether you must maintain several shipped versions simultaneously, whether the team has automated tests and feature flags to make trunk-based safe, and how heavy the approval process for production is.

# gitflow 典型分支
main            # 只接受 hotfix 与 release 的合并,只指向已发布版本
develop         # 集成分支,日常合并目标
feature/xxx     # 特性分支,从 develop 切出,合回 develop
release/1.4     # 只接受 bugfix,从 develop 切出,合回 develop 与 main
hotfix/1.3.2    # 从 main 切出,同时合回 main 与 develop

# GitHub Flow 典型分支(默认推荐)
main            # 始终可发布
feature/checkout-retry   # 活 1-2 天,通过 PR 合回 main

# trunk-based:主干 + 特性开关
main
feature/new-search        # 短命分支,代码合并但功能由 flag 控制可见性

# 本地查看分支拓扑
# git log --graph --oneline --all --decorate -30

The rebase/merge boundary: rewrite locally, never on shared refs

The difference between the two is a single semantic, but the consequences diverge sharply. merge preserves both lines of history and creates a commit with two parents; rebase detaches your commits and replays them on a new base point, producing a straight line. The tidiness gain is real: if five interim commits on a feature branch are not worth keeping, squashing them with git rebase -i into one means the trunk records exactly one commit for one feature.

The rule fits in one sentence: rebase only commits that were never pushed to anyone else and on which nobody built work; once a commit is on a shared branch, merge and never rebase. Rebase rewrites commit hashes, so anyone who already fetched that branch now has a diverged local history, and syncing produces duplicate commits and recurring conflicts.

The routine looks like this: on your feature branch, run git fetch origin regularly, then git rebase origin/main to absorb the latest trunk, resolve conflicts, and update your remote branch with git push --force-with-lease. The lease variant is far safer than a bare force: it only overwrites when the remote branch is still at the state you last fetched, so a colleague pushing in the meantime causes a refusal rather than silent destruction.

If a rebase has already been pushed and fetched by others, back up the ref before fixing anything: git refloc contains the pre-rebase position, so git reset --hard to that sha restores the old history, after which you re-synchronise with a merge. The team convention should state that force pushing to shared branches is forbidden, and it should be enforced by server-side protected-branch settings rather than by verbal agreement.

# 交互式 rebase:压成一个提交、改写信息、删除无用提交
# git rebase -i origin/main
#   pick   3a1f2c8 feat: 增加购物车合并逻辑
#   squash 7b9e0d4 fix: 修正空购物车时的边界判断
#   fixup  c4d5e6a chore: 调整变量命名

# 规整本地历史
# git commit --fixup=<目标提交>   生成 fixup! 提交,后续 autosquash
# git rebase -i --autosquash origin/main

# 更新自己已推送的远端分支(安全覆盖)
# git push --force-with-lease

# rebase 出错后回到 rebase 之前的状态
# git rebase --abort          尚未完成时放弃
# git reflog                 找到 rebase 前的 sha
# git reset --hard <sha>

# 查冲突残留(三阶段条目应全部清空才算解决)
# git diff --check
# git ls-files -u

Why conflicts actually happen: mostly not concurrent edits

The first cause is genuine parallel development: two people changing the same region of the same files. The fix is not a better tool but better module boundaries — if two people keep colliding in one file, that file probably owns too many responsibilities or sits in the wrong place.

The second cause is formatters. Two people with different Prettier configurations or formatter versions touching the same files produce large volumes of purely cosmetic conflict. This class must be handled with .gitattributes: mark machine-generated files such as lockfiles and snapshots with merge attributes such as linguist-generated or -diff, so Git uses a merge driver instead of a text three-way merge.

The third cause is renaming, especially changes in letter case. macOS and Windows filesystems are case-insensitive by default, so renaming UserService.ts to userservice.ts shows up as a delete plus an add, and Git readily interprets that as "someone deleted the file". Rename with git mv as its own commit first, then make content changes in a later commit. A single naming convention across the team, such as kebab-case, eliminates this whole category.

The fourth cause is misuse of .gitignore: adding a rule after the file is already tracked means later edits still show up as conflicts. For already-tracked files use git update-index --skip-worktree, or remove them from the index with git rm --cached and then ignore them. Line-ending differences between Windows CRLF and Unix LF also produce whole-file conflicts, handled by core.autocrlf plus text=auto eol=lf in .gitattributes.

# .gitattributes:把自动生成文件交给合并驱动,而不是文本三方合并
# package-lock.json linguist-generated=true -diff
# *.snap            linguist-generated=true -diff
# *.min.js          linguist-generated=true -diff
# 文本文件统一换行符
* text=auto eol=lf
*.bat text eol=crlf
*.ps1 text eol=crlf

# 重命名分两步:先 git mv 提交,再改内容
# git mv src/user/UserService.ts src/user/user-service.ts
# git commit -m "refactor(user): 重命名 UserService 为 kebab-case"

# 已跟踪文件需要真正停止跟踪时
# git update-index --skip-worktree .env.local
# git rm --cached -r logs/ && echo "logs/" >> .gitignore

# 检测大小写敏感差异(在默认不区分大小写的系统上排查重命名问题时)
# git ls-files | grep -i "userservice"

Commit messages and a readable history

The only consumers of a commit message are you and your colleagues six months from now. An effective message has three parts: what was done, as an imperative verb opening such as fix, add, or refactor; why, which is often more important than what, especially for changes that look superfluous but are genuinely required; and references, such as a ticket or PR link. Messages like "fix bug", "update", or "asdf" carry no information, turn git log into noise, and make git bisect impossible.

The widely used convention is Conventional Commits: a type from a small fixed set, an optional scope, and a short imperative subject with no trailing period. It pays off twice: git log supports --grep filtering by type, and release changelogs can be generated mechanically.

The test for commit granularity is whether the commit can be reverted independently without breaking the system. A commit that mixes a refactor with a feature change denies reviewers the ability to approve half of it and prevents reverting the feature while keeping the refactor. Splitting with git rebase -i is the standard remedy. Before committing, glance at git diff --stat: if a single commit touches forty or fifty files, it probably should be split.

The other lever for readable history is keeping noise out of it. Feature flag toggles, temporary debug prints, and unrelated formatter churn belong in their own commits, ideally ones that can be reverted later. Generated lockfiles and machine output should stay out of diff review via .gitattributes, and local tooling artifacts should remain in the working tree rather than being committed. The health check is simple: if you cannot read git log --oneline -20 and say what those twenty commits did, the history needs work.

# 提交前自检:看改动范围与潜在空白/冲突问题
# git diff --stat
# git diff --check

# 拆分提交:把当前改动分成「暂存」与「未暂存」两组分别提交
# git add -p src/parser.ts
# git commit -m "fix(parser): 修正空输入时的越界读取"
# git commit -m "test(parser): 补充空输入的回归用例"

# 历史检索
# git log --oneline --grep="fix(parser)" --since="2 weeks ago"
# git log --oneline --no-merges -20          跳过合并提交看真实变更
# git log --stat -1                        看单个提交动了哪些文件

# 定位问题提交
# git bisect start
# git bisect bad
# git bisect good <已知正常的提交>
# git bisect reset

Turning conventions into enforced process

Any convention written in a wiki but not enforced by tooling will be bypassed under delivery pressure. What actually works is pushing those rules down into hooks and server configuration: pre-commit formatting, branch name validation, commit message format checks, and protected branches that reject force pushes. None of this needs extra tooling — plain Git hooks are sufficient.

A practical step is to require fast-forward merges. Setting git config --add merge.ff only makes git pull fast-forward only, so a branch that needs a merge surfaces immediately instead of quietly producing a string of useless merge commits in your local history. Combined with an explicit --no-rebase or rebase pull policy, everyone keeps a tidy local graph.

Large files deserve their own governance. Handle binaries through .gitattributes or git lfs. If a large file was already committed, rewriting history with git filter-repo is the only remedy — and that must be a coordinated team operation with everyone re-cloning or resetting, never something one person does silently on their own machine.

Finally, bring large-repository maintenance into the conventions. Beyond periodic git gc, the important thing is to keep build output, dependency directories, and caches out of the repository: they bloat history and are a standing source of conflict. A check for common artifacts in the pre-commit hook is far more reliable than hoping a reviewer notices them in a diff.

# .git/hooks/pre-commit(示例:拦产物 + 校验提交信息)
# #!/bin/sh
# staged=$(git diff --cached --name-only)
# if echo "$staged" | grep -Eq "(^|/)(node_modules|dist|\.next|\.nuxt|coverage)/|\.log$"; then
#   echo "拒绝提交构建产物或依赖目录"
#   exit 1
# fi
# msg=$(git log -1 --pretty=%s)
# case "$msg" in
#   feat\(*\):*|fix\(*\):*|docs\(*\):*|chore\(*\):*|refactor\(*\):*) ;;
#   *) echo "提交信息需符合 <type>(<scope>): <subject>,当前为: $msg"; exit 1 ;;
# esac

# 团队级配置(可用 git config --global 或 includeIf 按目录区分)
# git config --global merge.ff only
# git config --global pull.rebase true
# git config --global core.autocrlf input
# git config --global init.defaultBranch main

# 大文件治理
# git lfs install
# git lfs track "*.psd"
# 已误提交的大文件:需全队协调,用 git filter-repo 改写历史

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.