☰
本地沙盒演练 git fetch 与 git pull:引用模型与冲突处理
2026/10/26 3:27:53 网站建设 项目流程

1. 把"团队"搬到自己电脑上的理由

单人仓库里敲了几年git commit,到了真实的多分支协作里还是会卡在git fetch和git pull上面——这个事儿我在带新人的时候见过太多次了。表现往往是:本地代码看着挺新,git status一片绿,结果推送被拒;或者git pull一把梭之后冒出一堆冲突标记,人被吓住,直接git reset --hard把半天的活干没了;再或者 rebase 完之后发现同一个提交出现两次,历史被搞乱。问题的根子不在命令本身,而在于大多数人对git fetch和git pull的理解停留在"拉代码"这三个字的层面,从没真正看清它们背后对远程仓库和本地引用的那套操作。

这篇东西想干的事很具体:在你自己的机器上搭出一个能复现团队协作场景的沙盒,用两个工作目录扮演两个同事,把git fetch、git pull、分叉、合并、变基这一整套流程走一遍,走完之后你能自己回答几个问题——fetch 到底把数据放在哪里、pull 等价于哪两条命令、origin/main这个引用是活的还是死的、冲突时 Git 到底卡在了哪一步。它适合刚进团队、被git pull冲突搞出心理阴影的同学,也适合带人多年但一直靠经验踩坑、没系统梳理过引用模型的老手。整个演练不依赖任何真实服务器,全在本地目录里完成,删了就删了,随便折腾。

我自己的转弯点是一次线上问题排查:某个同事说"我明明 fetch 过了,怎么代码还是旧的",查了半天发现他 fetch 完就以为工作区会自动更新。那次之后我就把演练环境固化成了几个脚本,每带一个人就跑一遍,效果比讲十遍原理都好。

2. 演练方案怎么选:几种模拟思路的对比

2.1 为什么不用分支来假装两个人

最省事的模拟方式是在一个仓库里开两条分支,一条叫"A 的工作",一条叫"B 的工作",然后来回切换。这个办法上手快,但它模拟不出真实协作里最关键的一环:两个独立工作区之间的信息差。真实的团队协作中,你手里那份代码是过去某个时刻的快照,别人的提交在这段时间里已经推到了远端,而你的本地仓库对此一无所知。如果只是切分支,两条分支共享同一个.git目录,refs/remotes/origin/*这套远程跟踪引用就没有独立变化的余地,git fetch前后的对比也就做不出来。

所以我更推荐另一种做法:造一个裸仓库当"远端",再克隆出两个普通仓库当两个同事的工作区。裸仓库(git init --bare)没有工作区,只保存提交对象和引用,正好对应服务端的角色;两个克隆出来的仓库各自有独立的.git目录、独立的HEAD、独立的远程跟踪分支,信息差天然存在。这个结构最大的好处是每一步都可以在两个目录之间对照着看,谁领先、谁落后一目了然。

对比一下几种方案的适用场景:

方案目录结构能否模拟信息差能否复现 fetch 前后变化适合场景
单仓库多分支一个.git弱不能练 merge、rebase 的语法
裸仓库 + 两个克隆三个目录强能完整协作流程演练
本地多 remote 指向一个.git多 remote中能练多远程仓库同步
局域网真实仓库多台机器强能团队新人入职演练

第三种方案也值得一提:在同一个仓库里用git remote add挂多个远端地址,其实可以模拟一个仓库对多个来源的同步,但它终究只有一个工作区,HEAD只有一个,做不出"你先提交我也提交,然后双方打架"的效果。第四种最真实,但需要多台机器和网络配置,成本高,且出了问题不好回滚。所以在本地用裸仓库加两个克隆,是投入产出比最高的那一档。

2.2 演练场地的最小结构

目录结构最终长这样:

~/git-lab/ ├── team.git/ # 裸仓库,扮演服务端 ├── alice/ # 同事 A 的工作区 └── bob/ # 同事 B 的工作区

team.git里没有源码文件,只有objects、refs、HEAD这些裸仓库该有的东西,第一次看到可能会觉得奇怪——这正是服务端该有的样子,它不需要能打开的文件,它只需要能按提交号取出对象。

这里有个小细节要提前处理掉:默认分支名。近几年新装的 Git 大多默认main,老环境还是master,两个目录一旦不一致,后面所有命令都得手动指定分支名,容易乱。我的做法是在建裸仓库的时候就用git init --bare --initial-branch=main team.git显式定死,或者建完之后用git symbolic-ref HEAD refs/heads/main改一次。这一步花不了十秒钟,但能省掉后面一堆"为什么 pull 没有反应"的困惑。

2.3 需要提前建立的引用概念模型

在动手之前,有一组概念必须在脑子里摆正位置,否则后面看到的输出会全是天书。

origin/main(或origin/master)是本地保存的、对远端分支最后一次已知状态的快照,它存在于你自己的.git/refs/remotes/origin/main里,是一个普通文件,里面就一行 40 位的提交哈希。它不是"远端的分支",远端的分支在服务端仓库里,你隔着网络看不见。git fetch做的事情,说白了就是:连上远端,问一句"你那边各个分支现在指向哪个提交",然后把答案写进你本地的这几个文件里。

HEAD是当前所在分支的指针,HEAD指向哪个提交,你的工作区就是从哪个树检出来的。git fetch无论如何都不会动HEAD,也不会动工作区里的文件。

FETCH_HEAD是每次 fetch 留下的一个小账本,记录这次拉取到的分支、提交号和用途标记。它每次 fetch 都会被覆盖,所以它不是长期存储,只是一次操作的临时记录。

把这三者分清楚,"fetch 完为什么文件没变"这个问题自然就不需要问了。很多人第一次遇到时的第一反应是"是不是 fetch 失败了",其实是它本来就不该改文件。

3. git fetch 到底动了哪几个文件

3.1 从 .git 目录里的变化看 fetch

先把沙盒建起来,然后我们做一个非常直观的实验:fetch 前后对比.git目录。

mkdir -p ~/git-lab && cd ~/git-lab git init --bare --initial-branch=main team.git git clone team.git alice git clone team.git bob

克隆空仓库时会提示warning: You appear to have cloned an empty repository.,这是正常的,忽略即可。接着让 A 先干活,把仓库的第一次提交打出去:

cd ~/git-lab/alice git config user.name "A同学" git config user.email "a@example.local" printf '# 项目说明\n\n这是第一版。\n' > README.md git add README.md git commit -m "初始化项目说明" git push -u origin main

-u这个参数很关键,它把本地main的上游设置成了origin/main,之后在这个分支上直接敲git pull或git push就不用再带参数了。很多人后面遇到fatal: The current branch main has no upstream branch,根源就是第一次推送时漏了-u。

现在切到 B 那边,先记录一下当前.git里的引用状态:

cd ~/git-lab/bob find .git/refs -type f

因为克隆的是空仓库,refs/remotes/origin/下面是空的。执行一次 fetch:

git fetch origin find .git/refs -type f cat .git/refs/remotes/origin/main cat .git/FETCH_HEAD

你会看到refs/remotes/origin/main这个文件出现了,内容是一串哈希;FETCH_HEAD里有一行,格式大致是<提交号>\t\tbranch 'main' of ...,注意那个not-for-merge标记的有无——git fetch时默认标记为not-for-merge,表示这个提交只是取回来看看,不要自动合并。

此时再看工作区:

ls git status

README.md没有出现,git status提示当前在main分支上,且"Your branch is behind 'origin/main' by 1 commit"。这个提示就是 fetch 的全部成果:引用更新了,工作区纹丝不动。

3.2 FETCH_HEAD:那个被长期忽略的小账本

FETCH_HEAD这个文件值得单独说两句,因为git pull在默认模式下就是拿它当合并源的。它的每一行大致长这样:

a1b2c3d4... branch 'main' of /home/user/git-lab/team.git

第一列是提交号,第二列是描述,第三列是来源。如果某一行带了not-for-merge,说明这个分支不是当前分支的上游,或者你不打算把它合并进来;不带标记的那一行,就是git pull接下来要动手的对象。

这条机制解释了一个很常见的疑惑:为什么git pull origin main和git pull有时候结果不一样。前者显式指定了远端和分支,FETCH_HEAD里被标成可合并的就是main;后者依赖当前分支的上游配置,如果上游指向的是别的分支,合并的对象自然也不同。我见过有人把本地分支的上游错设成了另一个同事的临时分支,结果git pull每次都拉进来一堆不相干的提交,查了半天才发现是branch.main.merge这个配置项写错了。

排查这类问题,两个命令够用:

git branch -vv # 看每个本地分支的上游是谁 git config --get-regexp '^branch\.' # 直接看配置项

3.3 fetch 之后最该敲的三条命令

fetch 完之后不要急着 merge,先看清楚情况。这三条命令我基本是肌肉记忆:

# 1. 双方分叉全貌,图形式展示 git log --oneline --graph --decorate --all # 2. 对方有、我没有的提交 git log --oneline HEAD..origin/main # 3. 我有、对方没有的提交 git log --oneline origin/main..HEAD

那个双点语法A..B的含义是"从 B 可达但从 A 不可达的提交集合",翻译成人话就是差集。配合使用,一眼就能判断当前是"我落后"、"我领先"还是"双方分叉"。

还有一条内容层面的对比,用的是三点语法:

git diff HEAD...origin/main

三点和两点的区别在于比较基准:两点是直接拿两个端点比,三点是先找到两者的共同祖先(merge base),再用祖先分别和两端比。分叉之后看"对方到底改了什么",三点才是对的。

git fetch的常用参数也顺手记一下,都是省时间的:

参数作用典型场景
--all拉取所有远端配了多个 remote 时一次同步
--prune清理远端已删除的分支引用同事删了分支,本地还留着
--tags同时拉取标签需要按版本号回溯时
--dry-run只演示不实际写入想先看看会拉什么
--depth=1浅克隆深度仓库巨大、只关心最新一次提交

--prune这个参数我建议直接写进全局配置:

git config --global fetch.prune true

否则远端分支删了半年,本地的origin/xxx还挂在那儿,git branch -r列出来一长串,全是幽灵。

4. git pull 拆开看:一条命令,两步动作

4.1 pull 不是原子的

git pull在默认配置下,等价于:

git fetch git merge FETCH_HEAD

如果是--rebase模式,第二步换成git rebase。这个拆解非常有用,因为它意味着:pull 出问题时,你可以只做前半步,看清楚再说。我现在的习惯是能 fetch 就不 pull,尤其是在一个改动频繁的分支上,先 fetch、看 diff、再决定合并方式,比一把梭稳妥得多。

git pull之所以容易出事,是因为它把"取数据"和"改工作区"两个动作绑在了一起,中间不留观察窗口。冲突一旦发生,工作区直接进入未完成状态,跟着一堆<<<<<<<标记,人会本能地慌。拆开之后,第一步永远安全,第二步你可以选时机。

4.2 merge 和 rebase 的实际差别

两种收口方式在结果上的差别,用图说话最清楚。假设分叉前共同祖先是C1,你这边提交了C2,对方提交了C3。

走 merge,结果是:

C1 --- C2 --------- M \ / --- C3 --------

M是一个合并提交,有两个父节点。历史保留了真实的分叉形状,能看出"这两条线曾经并行过"。代价是图形会变成网状的,提交多的时候git log --graph看起来像地铁线路图。

走 rebase,结果是:

C1 --- C3 --- C2'

你的C2被摘下来,重新应用到C3之后,生成一个新的提交C2'——哈希变了,这是一个全新的提交对象,原来的C2变成了悬空对象,等gc回收。好处是历史是一条直线,git log干净得像一个人写的;代价是历史被改写了,如果C2已经推给别人,别人的历史就会和你对不上。

选择原则我一般这么定:

  • 还没推送的本地提交,可以随意 rebase,随便整理。
  • 已经推送、别人可能基于它工作的提交,别 rebase,走 merge。
  • 主干分支(main、develop)用 merge,保留合并节点,方便追溯。
  • 自己拉的功能分支同步主干时用 rebase,保持分支历史线性,减少合并时的噪音。

4.3 先把策略固化成配置

新版 Git 在第一次执行git pull且没有配置策略时,会提示你选一个默认行为,并给出pull.rebase的配置建议。这个提示别跳过,选完就固化下来:

# 全局默认走 merge(保守派) git config --global pull.rebase false # 全局默认走 rebase(喜欢线性历史) git config --global pull.rebase true # 只允许快进,不允许产生合并提交,也不允许变基 git config --global pull.ff only

pull.ff only这个配置在主干分支上特别好用,它的效果是:只要本地和远端分叉了,pull 直接报错停下,逼你先看情况。听起来麻烦,实际能挡掉很多"手一抖 merge 出一堆莫名其妙提交"的事故。

如果不想动全局,也可以在单个仓库里配,或者临时在命令上加:

git pull --no-rebase # 这次走 merge git pull --rebase # 这次走 rebase git pull --ff-only # 这次只允许快进

三种写法,作用域不同,优先级是命令行最高、仓库配置次之、全局最低。

5. 完整演练:两个"人"的一次真实碰撞

5.1 搭场子与制造分叉

接着前面的沙盒继续。此时 B 的仓库还没检出main,先把它拉下来:

cd ~/git-lab/bob git config user.name "B同学" git config user.email "b@example.local" git pull origin main

因为本地还没有main分支,这条命令会把远端的main拉下来并自动建立跟踪关系,README.md也就出现在工作区了。这里可以顺便验证一下FETCH_HEAD:

cat .git/FETCH_HEAD

这一次那一行上面没有not-for-merge标记了,因为要合并进来的正是它。

现在做 B 的改动并推上去:

printf '\n## 使用说明\n\n第一步,先安装依赖。\n' >> README.md git add README.md git commit -m "补充使用说明章节" git push origin main

切回 A,不要 pull,先做自己的改动:

cd ~/git-lab/alice printf '\n## 注意事项\n\n提交前请先跑一遍测试。\n' >> README.md git add README.md git commit -m "增加注意事项章节"

到这里,分叉已经形成了:A 本地领先 1 个提交、落后 1 个提交,B 的提交在远端,A 完全不知道。此时 A 如果直接git push,会得到:

! [rejected] main -> main (fetch first) error: failed to push some refs to ... hint: Updates were rejected because the remote contains work that you do not have locally.

这条拒绝信息很多人一看就慌,其实它非常友好,fetch first已经把下一步该干什么写脸上了。

5.2 fetch 之后先做侦察

按它的建议走第一步:

git fetch origin git log --oneline --graph --decorate --all

输出大致是这样(哈希省略):

* 3f2a1b0 (HEAD -> main) 增加注意事项章节 | * 8c4d5e6 (origin/main) 补充使用说明章节 |/ * 1a0b2c3 (origin/HEAD, origin/main@{1}) 初始化项目说明

这个图信息量很大:HEAD -> main和origin/main分列两条线,共同祖先是初始化那次提交。这时候 A 已经有了完整的判断依据,不需要猜。

再补两条差集命令,确认改动内容:

git log --oneline HEAD..origin/main # 只有一行:8c4d5e6 git log --oneline origin/main..HEAD # 只有一行:3f2a1b0 git diff HEAD...origin/main

第三条会显示 B 具体加了什么行。演练时故意让两边的改动落在文件的不同位置,冲突就不会发生;如果想练冲突处理,就让两边都改同一个位置附近。

5.3 merge 路线:产生一个合并提交

决定走合并:

git merge origin/main

如果两边改的是不同区域,Git 会自动完成合并,弹出一个编辑器让你填合并提交的信息,默认是Merge remote-tracking branch 'origin/main'。保存退出即可。

如果改的是同一块,就会看到冲突:

Auto-merging README.md CONFLICT (content): Merge conflict in README.md Automatic merge failed; fix conflicts and then commit the result.

打开文件,会看到这样的标记:

<<<<<<< HEAD ## 注意事项 提交前请先跑一遍测试。 ======= ## 使用说明 第一步,先安装依赖。 >>>>>>> origin/main

三个标记的含义分别是:<<<<<<< HEAD到=======之间是当前分支(也就是你这边)的内容,=======到>>>>>>>之间是要合并进来的那一方的内容。处理方式就是手动把这段改成你想要的最终形态,把三个标记行全部删掉。这里我要强调一句:标记行必须删干净,包括=======那一行。我见过有人只删了上下两个、把中间那行留在了代码里,后面线上跑出奇怪的分隔线,查了很久。

改完之后:

git add README.md git commit # 不用 -m,编辑器里会带着默认的合并信息

这里有个容易踩的点:冲突状态下git commit会直接用准备好的合并信息提交,如果你加了-m "xxx",会覆盖掉那个包含"合并了哪个分支"的默认信息,虽然不影响功能,但以后回看历史会丢失线索。我倾向于保留默认信息,需要补充就在下面另起一行写。

合并完成后推送:

git push origin main

推上去之后,B 那边 fetch 一下就能看到自己的提交后面挂了个合并节点,两边正式会师。

想中途放弃的话,两条命令记住:

git merge --abort # 回到合并前的状态 git reset --hard HEAD # 更暴力,丢弃所有未提交改动,慎用

--abort是安全的,它只撤销合并动作,不动你合并前已经提交的东西。reset --hard会连工作区一起清空,只在确认没有值钱改动时用。

5.4 rebase 路线:线性历史与它的代价

同样的场景,换一种收口方式。重置一下沙盒,重演到分叉的状态,然后:

git fetch origin git rebase origin/main

含义是:把当前分支上"独有"的提交(origin/main..HEAD那部分)摘下来,先把自己移到origin/main的位置,再把这些提交逐个重新应用到上面。

有冲突时,rebase 会停下来:

CONFLICT (content): Merge conflict in README.md error: could not apply 3f2a1b0... 增加注意事项章节 hint: Resolve all conflicts manually, mark them as resolved with hint: "git add/rm <conflicted_files>", then run "git rebase --continue".

注意这里和 merge 的差别:rebase 是逐个提交重放的,冲突可能不止一次。第一个提交解决完git add之后,如果后面还有提交,会继续下一轮。处理完所有冲突:

git add README.md git rebase --continue

想放弃:

git rebase --abort

还有一个比较少用但很有用的git rebase --skip,作用是直接跳过当前这个提交。它的语义是"这个改动我不要了",误用的后果是代码丢失,我一般只在自己分支上确认某个提交完全作废时才用。

rebase 完成后再看历史:

git log --oneline --graph --decorate --all

会看到一条干净的直线,A 的提交排在了 B 的提交之后,哈希已经变了。此时推送需要:

git push --force-with-lease origin main

为什么不用--force?因为--force是无条件覆盖,万一在你 rebase 期间别人又推了新东西,直接就被你抹掉了。--force-with-lease会先检查远端分支是不是还停留在你上次 fetch 时的那个提交,如果不是就拒绝推送,相当于给你加了一道保险。这个参数值得养成习惯,比记一堆原理管用。

6. 常见问题与排查实录

6.1 高频问题速查表

下面这张表是我这些年被问得最多的问题,按出现频率排的:

现象大概率原因排查命令处理方式
fetch 完文件没变正常行为,fetch 不动工作区git status再执行git merge origin/xxx
pull 提示 "no tracking information"本地分支没设上游git branch -vvgit branch -u origin/main
push 被拒 "fetch first"远端有新提交git log HEAD..origin/main先 fetch,再 merge 或 rebase
合并后看到重复提交之前 rebase 过又 merge 了旧分支git log --graph --all检查是否有两套等价提交
冲突解决完仍提示未解决标记行没删干净或漏了git addgit status检查文件,重新git add
rebase 卡在中途有未解决冲突git status解决后--continue,或--abort
远端分支列表有一堆幽灵远端删了本地没清git branch -rgit fetch --prune
拉到别人的临时分支提交上游配错了git config --get-regexp '^branch\.'修正上游指向

6.2 踩出来的几条经验

第一条:冲突时先看git status,别看文件。git status会明确列出"Unmerged paths",告诉你哪些文件处于冲突状态、是双方都改了还是单方删除。文件里那一堆标记只能告诉你冲突位置,告诉不了你全局还有几个文件没处理。我习惯先把 status 看完,列个清单,再逐个开文件。

第二条:冲突解决后一定跑一遍测试再提交。冲突的本质是"两段改动在语义上可能冲突",Git 只能判断文本层面是否重叠,判断不了逻辑。两边各加了一行代码,文本不冲突,但合起来可能就是个空指针。自动合并成功不代表代码正确,这一步省不得。

第三条:不确定的时候,先开个临时分支兜底。面对一个复杂的分叉,拿不准该 merge 还是 rebase,我的做法是先git branch backup-$(date +%m%d)打个备份分支,然后随便折腾。真搞砸了,git reset --hard backup-0421就回去了。这个习惯救过我至少三次。

第四条:git pull之前先git stash或提交。工作区有未提交改动时 pull,如果两边改的是同一个文件,Git 会拒绝执行并提示 "Your local changes would be overwritten"。这时候别硬来,要么提交,要么git stash,要么git stash push -m "临时保存"带上说明方便以后找。

第五条:rebase 过程中不要git commit --amend。rebase 进行到一半时 amend 会让状态更混乱,正确的动作永远是git add加--continue,或者--abort重来。这个坑我在早期项目里踩过,最后是靠 reflog 一点点捞回来的。说到 reflog,git reflog是最后的保险绳,几乎所有"我以为提交丢了"的情况,都能在 reflog 里找到那个哈希,然后git reset --hard <哈希>复活。

第六条:理解"引用"和"对象"的区别。分支、标签、origin/main这些都是引用,本质是指向提交对象的指针;提交对象本身是不可变的,一旦生成就永远在那儿。所以 rebase 不是"改写历史",而是"新建一批提交,把分支指针挪过去",老提交还在,只是没人指着它了。想通这一点,很多操作就不再可怕——因为它们几乎都是可逆的。

最后说个我自己的用法:日常开发我基本不用裸的git pull,而是固定成三步——git fetch origin、git log --oneline HEAD..origin/main看一眼、然后根据情况git merge origin/main或git rebase origin/main。多敲两行命令,换来的是每一次合并都在自己的掌控里,不用担心某天早上一睁眼,工作区已经被一堆不知道哪来的合并提交填满了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询