【Git 权威指南】“和声”+“协同模型"两章的精炼合并版:远程协议、推送与冲突解决、里程碑共享、分支协同、工作流模型、共用代码(submodule / subtree)。

本文沿用书中 master 作为默认分支名;Git 2.28+ 默认改为 main,行为完全一致。

远程协议与版本库

协议选择

协议用途现状
SSH推 + 拉,需公钥认证协作首选
HTTPS推 + 拉,凭证由 helper 缓存网络受限环境首选;个人项目常用
本地file:// 或裸路径备份 / 镜像
git://只读匿名协议已弃用,GitHub 2022 年关停
FTP / RSYNC已弃用,忘了它们
ssh://[user@]example.com[:port]/path/to/repo.git
[user@]example.com:path/to/repo.git          # SSH 简写
https://example.com/path/to/repo.git

远程版本库管理

git remote -v                                # 列出 fetch/push URL
git remote add <name> <url>                  # 注册
git remote set-url <name> <url>              # 改 URL
git remote set-url --push <name> <url>       # 单独改 push URL(读写分离)
git remote rename <old> <new>
git remote rm <name>
git remote update                            # 拉取所有 remote 的更新
git config remote.<name>.skipDefaultUpdate true   # 排除某个 remote

fetch / pull 的关系

git pull = git fetch + git merge        # 默认
git pull --rebase = git fetch + git rebase

习惯用 rebase 整理历史的人,全局开启:

git config --global pull.rebase true
git config --global branch.autosetuprebase always   # 新追踪分支自动设 rebase

pull.rebase=true 是 2.6+ 后协同的现代默认姿势,避免 git pull 平白塞进一堆 Merge branch 'main' 的噪声。

推送与冲突解决

推送基础

git push                                # 推当前分支到追踪的上游
git push -u origin <branch>             # 首次推并建立 upstream
git push origin dev:master              # 显式 refspec:本地 dev → 远端 master

非快进推送:force 的现代姿势

git push -f                                       # 危险:会盖掉别人推的东西
git push --force-with-lease                       # 推荐:只在远端确实是你看到的版本时才覆盖
git push --force-with-lease --force-if-includes   # 2.30+ 更严格:还要求你 fetch 过最新

--force-with-lease-f 的安全替代品。它检测远端是否被别人推了新东西,是就拒推 —— 多人协作改 rebase 后强推必备。

服务端禁止非快进推送(用于受保护分支):

git --git-dir=/path/to/shared.git config receive.denyNonFastForwards true
git --git-dir=/path/to/shared.git config receive.denyDeletes true

真正的权限控制还是交给 GitLab / GitHub 服务端的 protected branch / push rule,这里只是底层兜底。

冲突解决的实际流程

合并 / 变基遇冲突时,冲突文件长这样:

<<<<<<< HEAD
我这边的版本
=======
对方分支的版本
>>>>>>> feature

工作流程:

git status                       # 列出 "both modified" 的文件
# 编辑器里手动取舍,或调工具
git mergetool                    # 调用 vimdiff / opendiff / vscode 等
git diff --check                 # 检查是否还有遗留 <<<<<<<
git add <files>
git commit                       # merge 用 commit;rebase 用 git rebase --continue
git merge --abort                # 想反悔
git rebase --abort

合并策略关键陷阱:-X ours vs -s ours

写法相近,效果完全不同

命令行为
git merge -X ours <branch>recursive 策略 + ours 选项。只在冲突 hunk 上偏向本方,非冲突的对方改动正常合入
git merge -s ours <branch>ours 策略。对方分支整个内容全部丢弃,只记录一笔合并提交

后者常用于"我宣告这个分支已废弃但要在历史上标一笔”。误用会神不知鬼不觉丢光对方所有改动。

合并方式:fast-forward 还是 no-ff

git merge feature                # 默认:如果可以 ff 就 ff,分支痕迹消失
git merge --no-ff feature        # 强制产生合并提交,保留分支拓扑
git config --global merge.ff false

团队偏好"看得见每个 feature 分支"的,统一关掉 ff;偏好线性历史的,反过来用 rebase + ff。

里程碑共享

tag 不会随分支自动推

git push origin v1.0                     # 推单个 tag
git push origin --tags                   # 推所有本地 tag
git push origin refs/tags/v*             # refspec 通配
git push origin :refs/tags/v1.0          # 删除远端 tag
git fetch --tags                         # 主动拉所有 tag

默认 git fetch 只会带回与已 fetch 的 commit 可达的 tag,孤立 tag 不会自动同步。 远端有同名 tag 而本地没有时会建立;本地已存在则不会覆盖,需要 git fetch --tags -f

命名:语义化版本

Semantic Versioning 2.0 —— MAJOR.MINOR.PATCH

  • MAJOR 不兼容改动
  • MINOR 向下兼容的新功能
  • PATCH 向下兼容的修复

预发布 / 编译信息:1.0.0-beta.1+20251010

分支协同

本地分支基本操作

git branch                              # 列本地
git branch -r                           # 列远程跟踪
git branch -a                           # 全部
git branch -vv                          # 含 upstream 关系

git switch <branch>                     # 切分支(2.23+,比 checkout 语义清晰)
git switch -c <new> [<start>]           # 创建并切换
git branch -d <branch>                  # 安全删(未合并会拒绝)
git branch -D <branch>                  # 强删
git branch -m <old> <new>               # 重命名

远程跟踪分支

origin/main 这种引用住在 refs/remotes/ 命名空间,是 fetch 后服务端最新状态的本地快照,不是真正的分支 —— 不能直接 commit 到它上面。

git switch hello-1.x                    # DWIM:本地无同名分支时,自动基于 origin/hello-1.x 建并跟踪
git switch -c local-feat origin/feat    # 显式基于远程分支建跟踪分支
git branch --set-upstream-to=origin/main main   # 给已存在分支补设 upstream
git fetch --prune                       # 清理远端已删的跟踪分支

DWIM (Do What I Mean)git switch <name> 时若本地无同名分支但远端恰有一个 origin/<name>,Git 会自动创建本地分支并设 upstream。多个 remote 都有时不触发,避免歧义。

rebase 整理本地分支

dev 基于老的 master,希望以最新 master 为基底再推送:

git switch dev
git fetch origin
git rebase origin/master
# 冲突 → 解决 → git add → git rebase --continue
git push --force-with-lease             # 历史被重写,必须 force(用 with-lease 才安全)

共享分支 rebase 后必须 --force-with-lease,且最好提前在团队里 broadcast 一下。 个人独享分支随便 rebase;团队共享主干永远 merge 不 rebase。

git worktree:并行多分支

不再需要"克隆好几份仓库"或者 git stash 来回切:

git worktree add ../proj-hotfix hotfix         # 在隔壁目录开一个挂在 hotfix 分支的工作区
git worktree list
git worktree remove ../proj-hotfix

每个 worktree 共享同一 .git/objects,但有独立工作区和独立 HEAD。Code review、跑长测试、临时改 hotfix 都不打断主工作区。

分支安全配置

配置作用
core.logallrefupdates=true给每条分支记 reflog(带工作区的库默认开;裸库默认关,建议手动打开)
receive.denyNonFastForwards=true服务端拒绝非快进推送
receive.denyDeletes=true服务端拒绝删除分支

工作流模型

经典三种,选用按团队规模和发布节奏:

模型适用关键约束
Trunk-based持续部署、CI 强的小到中团队所有人直推 main(或短命 feature 分支)+ feature flag
GitHub FlowSaaS 持续交付,最常见main 永远可部署,feature 分支走 PR → 合并 → 部署
GitFlow有明确版本发布周期的产品(客户端、SDK)main + develop + release/* + hotfix/*,重

Topgit、卖主分支 (Vendor Branch) 是 2010 年前的产物,现代项目用包管理器(npm / composer / cargo)+ submodule / subtree 覆盖了这些场景,可忽略。

共用代码:submodule vs subtree

两者解决同一问题(在一个仓库里嵌套引用另一个仓库),但取舍完全不同:

维度submodulesubtree
子项目代码物理位置独立 .git,靠 commit 指针引用直接嵌入主仓库
克隆体验需要 --recurse-submodules 或 init克隆主仓即拿全
主仓体积小(只存指针)大(含子项目历史)
协作者上手成本高(需理解 submodule + detached HEAD)低(看起来就是普通目录)
双向同步(向上游回推)直接在子仓库 push 即可麻烦(git subtree push 提取相关 commit)
适合真正独立的依赖,少改共享代码模板、内嵌工具脚本

submodule 标准姿势

git submodule add <url> <path>          # 添加;生成 .gitmodules
git clone --recurse-submodules <url>    # 一次克隆主仓 + 所有子模块(推荐)
git submodule update --init --recursive # 已克隆的仓库补拉子模块
git submodule update --remote           # 把子模块拉到其追踪分支的最新
git submodule status

submodule 头号陷阱:每个 submodule 始终处于 detached HEAD。要在子模块里改东西,先 git -C <path> switch <branch> 切到分支,提交后先推子仓库,再推主仓库 —— 顺序反了别人会拉不到对应 commit。

subtree 标准姿势

# 引入:把 S 项目作为主仓的 lib/s 子目录
git subtree add --prefix=lib/s <s-url> main --squash

# 拉上游更新
git subtree pull --prefix=lib/s <s-url> main --squash

# 把主仓内对 lib/s 的改动回推到 S 项目
git subtree push --prefix=lib/s <s-url> feature-branch

--squash 把上游历史压成一笔 commit,主仓 git log 干净,代价是主仓再也看不到子项目的提交图。统一加或统一不加,混用会撞合并冲突。

从已有项目剥离子模块

# 1. 从 P 项目里抽出 lib/s 的所有 commit,存到临时分支
git subtree split -P lib/s -b extracted

# 2. 创建独立的 S 仓库
mkdir ../s && cd ../s && git init
git pull ../P extracted
git remote add origin <s-url>
git push -u origin main

# 3. P 项目里删掉原目录,改用 subtree 引用
cd ../P
git rm -rf lib/s && git commit -m "Extract lib/s to standalone repo"
git subtree add --prefix=lib/s <s-url> main --squash

一个常见用途:把构建产物推到 GitHub Pages

git subtree push --prefix=dist origin gh-pages

补丁文件交互

少数场景(邮件协作 / 没有共同远端 / 跨网隔离环境)会用补丁文件传递提交:

git format-patch -s HEAD~3..HEAD         # 把最近 3 个提交导成 3 个 *.patch 文件,-s 加 Signed-off-by
git format-patch -s origin/main..HEAD    # 导出 fork 后所有新增提交
git am < user1.mbox                      # 收方导入,保留 author 与 message
git apply foo.patch                      # 仅打补丁,不产生 commit,author 信息丢失
git apply --check foo.patch              # 试跑,不真改

Linux 内核、Git 本身都还在用邮件 + git format-patch / git am 流程;日常 GitHub / GitLab 协作用 PR / MR 即可。

References

– EOF –