Linux下git合并冲突与子模块报错的排查方法(CONFLICT冲突标记、reference is not a tree与detached HEAD定位)

多人改同一个文件、子模块拉不下来,这两件事在任何一个稍大一点的仓库里都躲不掉。git给出的报错文字很短,真正麻烦的是它不告诉你下一步该敲什么。下面按实际遇到的报错串分三类,把对应命令和坑都列出来。

先分清报的是哪一类

合并冲突、合并状态残留、子模块缺失,这三种情况在终端上长得完全不一样,先看清楚再动手,能少绕很多路。

CONFLICT (content): Merge conflict in src/main.go
Automatic merge failed; fix conflicts and then commit the result.
error: Your local changes to the following files would be overwritten by merge:
    config/app.yml
Please commit your changes or stash them before you merge.
fatal: reference is not a tree: 8f3c1b2d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b (submodule 'vendor/sdk')

第一种是两边改到了同一段,git自己合不了,把标记写进文件让人来判断。第二种根本还没进入合并,是工作区脏。第三种跟代码内容没关系,是父仓库记的那个子模块提交,本地根本没有。

合并冲突:先摸清有多少文件出事

冲突一旦发生,别急着打开编辑器。先跑这两个命令,心里有个数:

git status --short
git diff --name-only --diff-filter=U

第二条只列处于冲突状态的文件,比在git status的一大堆输出里翻要快。git status里那几个状态值得记一下:both modified是双方都改、both added是双方都新建同名文件、deleted by us是本分支删了对方改了、added by them同理。不同状态的处置顺序不一样,全按both modified处理,容易漏掉被删掉的那个文件。

打开冲突文件看到的是这样的结构:

<<<<<<< HEAD
本地这一段
=======
对方那一段
>>>>>>> feature/order-refactor

默认只给两方,看不出改之前是什么样的。见过不少次改完之后才发现删掉了对方加的一行,就是因为这个视图信息不够。给仓库开一个更有用的视图:

git config --global merge.conflictStyle diff3

diff3会在中间多出一段以|||||||开头的内容,那是共同的祖先版本,两边各自动了什么一目了然。zdiff3是它的压缩版,会把两侧相同的前缀后缀挪到冲突区外面,冲突块更小:

git config --global merge.conflictStyle zdiff3

这里有个版本坑。zdiff3是Git 2.35.0才加进来的,而系统源里的版本普遍偏旧,Ubuntu 22.04源里的Git是2.34.1、Debian 11源里是2.30.2、Ubuntu 20.04源里是2.25.1。在这几台上直接设zdiff3,执行时才会报错:

error: unknown style 'zdiff3' given for 'merge.conflictstyle'

Ubuntu 24.04源里的2.43.0、Debian 12源里的2.39.2则都支持。所以想用zdiff3,要么确认版本,要么退回diff3,diff3几乎所有在用版本都认。之前在一台老机器上按zdiff3设完,当时没报错,因为那条命令本身只是写配置,一直到真正冲突才炸出来。

整块取一边,别手改

确认某个文件整份要对方的,或者整份留本分支的,用这两个:

git checkout --ours -- src/main.go
git checkout --theirs -- src/main.go
git add src/main.go

注意别把这条和git checkout origin/main混了,后者会把文件整个回退到远程分支,本地那个分支上别人已经合并进来的东西也会被抹掉。

在rebase过程中,ours和theirs是反过来的。rebase是把当前分支的提交一个一个往新基底上重放,此时的ours是新基底,theirs才是正在重放的那个提交,也就是自己刚写的改动。之前碰到过一次,改冲突时按merge的习惯用了--ours,结果自己的改动被整体丢弃,还以为是编辑器没保存。遇到分不清的时候,直接看冲突标记里的分支名,比记概念可靠。

合并到一半想重来

git merge --abort

这条需要工作区没有额外改动,否则它会拒绝,说Your local changes would be overwritten。实在要强行回到合并前,先git stash push -u把改动收起来,abort之后再弹出来。

另一种更常见的卡法是合并失败后没处理就去干别的,回来再跑任何命令都被挡:

error: You have not concluded your merge (MERGE_HEAD exists).
fatal: Exiting because of unfinished merge.

根源是.git/MERGE_HEAD还在。这时候要么继续解决冲突,要么abort。git merge --quit是第三条路,它把状态清掉但保留工作区里已经处理过的内容,不适合新手场景,知道有这个就行。

标记没删干净就提交

冲突标记忘删,提交上去代码就编译不过。提交前扫一遍:

git grep -n -e '^<<<<<<< ' -e '^=======$' -e '^>>>>>>> '

更省事的做法是让它进pre-commit,在.git/hooks/pre-commit里放上面那条git grep,有命中就退出非零。这套检查在2026年接手的一个仓库里救过两次,都是合并完赶着下班的情况。

同一段冲突反复出现时,可以让git记住解法:

git config --global rerere.enabled true

开了之后,解决一次冲突,下次遇到同样的冲突git会自动套用上次的结果,标记也不会再写进文件。长期维护分支、频繁把主干往特性分支上合的仓库,这个开关值得一直开着。

子模块:fatal: reference is not a tree

这条报错的意思是父仓库的索引里记了一个子模块的提交号,但本地子模块仓库里没有那个对象。最常见的成因是:对方在子模块里改了代码、在子模块里提交了、在父仓库里把这个新提交号commit进去了,但子模块自身的提交没有push到子模块的远端。父仓库推上去了,父仓库的拉取方自然找不到那个提交。

确认这件事只要两步。看父仓库记的是哪个提交:

git ls-tree HEAD vendor/sdk
git submodule status

git submodule status每行开头那个字符很关键,减号表示没初始化,加号表示当前签出的提交和父仓库记录的不一致,开头是U表示有冲突。整片都是减号,说明clone的时候没带--recurse-submodules,或者从没跑过update。

补齐子模块:

git submodule update --init --recursive

如果子模块的URL后来改过(换过域名、http改ssh),.gitmodules变了而本地.git/config里还是旧地址,update会去旧地址找,这时要先同步再拉:

git submodule sync --recursive
git submodule update --init --recursive --force

批量把子模块切到各自分支的最新提交:

git submodule foreach --recursive 'git fetch origin && git checkout main && git pull --ff-only'

实测在十来个子模块的仓库上,比一个一个进去敲快很多,代价是任何子模块里pull失败,整条命令就停在那儿。想容错就在末尾加|| true,然后单独看git submodule status里还是加号的那几个。

子模块进去是detached HEAD

update之后进入子模块目录,git branch会显示HEAD detached at xxxx。这不是故障,是设计如此:父仓库保存的是具体的提交号,不是一个分支名,签出到提交号就必然是游离状态。要在这个状态下改代码,务必先切到分支:

cd vendor/sdk
git checkout main

改完在子模块里commit和push,回到父仓库再commit一次,把新的提交号记进去。两个commit缺一个都不算完成,只做父仓库那次的话,别人拉下来照样报reference is not a tree。这个顺序搞错过一次,排查了半天,最后是拿git ls-tree对比两边才看出来。

反过来,要彻底删掉一个子模块,得四步都做:

git submodule deinit -f -- vendor/sdk
git rm -f vendor/sdk
rm -rf .git/modules/vendor/sdk
git commit -m "remove submodule vendor/sdk"

少做第三步,日后想用同名路径加回来,会撞上A git directory for 'vendor/sdk' is found locally这样的提示。

另外,老仓库里子目录中的.git可能是个文件而不是目录,指向父仓库.git/modules下的位置。把它收进去用:

git submodule absorbgitdirs

这条自Git 2.12.0起可用,做仓库迁移、打包备份时先跑一下能少掉一堆断链。

把上面这些串起来,合并冲突和子模块报错基本都能在几分钟内定位完,不用每次都靠搜索拼答案。