Git | 多人单分支集成协作时的常见场景与处理方案

47 阅读10分钟

引言

最近我在学习 Git,其中有一章节讲到了多人单分支集成协作时的场景下可能遇到的问题和相应的处理方案,我利用这篇文章记录一下相关知识和实践。读完这篇文章,可以对上述场景下遇到的问题做出正确的处理。

背景

a 和 b 两个人都在 feature 分支上开发

常见场景与处理方案

不同人修改了不同文件如何处理

比如有两个人 a 和 b 都在 feature 分支上开发

具体步骤

a 先提交了一个节点并推送到了远程,a 的示意图如下:

本地:
m1 (feature)
远程:
m1 (feature)

b 拉取代码后 b 的示意图如下:

本地
m1 (feature)
远程:
m1 (feature)

之后 b 提交了一个节点,但还没有推送,b 的示意图如下:

本地
m1 -- m2 (feature)
远程:
m1 (feature)

之后 a 提交了一个节点并推送到了远程,a 的示意图如下:

本地:
m1 -- m3 (feature)
远程:
m1 -- m3 (feature)

那么此时如果 b 直接提交是会报错的,因为不是 fast-forward 的合并方式,b 需要先把远程的 feature 分支拉取下来,然后与自己的分支进行合并,生成一个新的 commit 节点,再向远程推送

b 把远程分支拉取下来后
本地:
m1 -- m2 (feature)
远程:
m1 -- m3 (feature)

让远程的 feature 分支合并到自己本地的 feature 分支上
本地:
m1 -- m2 -- m4 (feature)
           /
m1 -- m3  /
远程:
m1 -- m3 (feature)

推送到远程分支
远程:
m1 -- m3 -- m4 (feature)

总结

肯定是有后提交的,后提交者需要先拉取远程的代码,与自己的代码进行合并生成新的节点,然后再向远程推送,就可以成功推送。

不同人修改了同一文件的不同区域如何处理

具体步骤

场景:比如有两个人 a 和 b 都在 feature 分支上开发

a 修改了 x 文件,提交了一个节点并推送到了远程,此时 a 的示意图如下:

本地
m0 -- m1 (feature)
远程
m0 -- m1 (origin/feature)

b 也修改了 x 文件,但修改了不同的地方,提交了一个节点,并执行了推送到远程,会报错,先看此时 b 的示意图:

本地
m0 -- m2 (feature)
远程:
m0 -- m1 (origin/feature)

对于 b 来说,远程的提交历史并不是本地提交历史的子集,因此不能 fast-forward,b 就需要先把远程的分支同步到本地,然后和本地的 feature 分支合并

  • 先把远程的分支同步到本地
本地
m0 -- m2 (feature)
m0 -- m1 (origin/feature) 远程跟踪分支
远程
m0 -- m1 (origin/feature)
  • 然后在本地把远程分支跟踪分支与本地分支合并,形成新的节点
本地
m0 -- m2 -- m3 (feature)
  \       /
   \     /
      m1 
m0 -- m1 (origin/feature)  远程跟踪分支
远程
m0 -- m1 (origin/feature)
  • 然后再推送到远程,推送之后 b 的示意图如下:
本地
m0 -- m2 -- m3 (feature) (origin/feature) 
  \       /
   \     /
      m1 
远程
m0 -- m2 -- m3 (feature) (origin/feature) 
  \       /
   \     /
      m1 
      

总结

不同人修改了同一文件的不同区域,不会造成冲突,后提交者可以按照拉取-合并-推送的思路来处理。

不同人修改了同文件的同一区域如何处理

具体步骤

比如有两个人 a 和 b 都在 feature 分支上开发

a 先提交了一个节点并推送到了远程,然后 b 修改了同一文件的同一区域,然后会发现无法直接推送到远程,这个在上一小节已经说过了,b 需要先拉取远程的代码到本地,然后把拉取到的代码和本地的代码进行合并,之后再推送到远程。

由于本节的场景是修改的同文件的同一区域,因此进行合并时会产生冲突,需要手动解决该冲突,然后重新 add、commit 和 push。

总结

不同人修改了同文件的同一区域,因此后提交者在“拉取-合并-推送”的合并的过程中会发生冲突,需要手动解决该冲突,然后再 git add,git commit 和 git push。

注意这里的 git add 的作用是在索引/暂存区删除该文件的冲突标记并决定保留该文件,这一点在后续“不同人修改了同一文件的文件名如何处理”中还会详细介绍。

一个人修改了文件名,另一个人同时修改了该文件的内容如何处理

具体步骤

比如有两个人 a 和 b 都在 feature 分支上开发

  • a 修改了 index.html 的文件名,将其修改为 index.htm,然后进行提交。a 修改文件名使用的命令是:
git mv index.html index.htm
  • 然后 b 修改了 index.html 的内容,也进行了提交
  • a 先推送到远程
  • 然后 b 尝试推送到远程。

首先 b 是没有办法直接推送的,b 需要先拉取远程的代码到本地,然后把拉取到的代码和本地的代码进行合并,之后再推送到远程。

总结

  • 如果拉取下来的代码仅仅是修改了文件名,git 可以智能地在不需要解决冲突的情况下将其与自己的代码进行合并

  • git mv 与 mv 的区别

    •   一个值得注意的地方:a 在对 index.html 重命名时使用了 git mv index.html index.htm,而不是直接使用 mv 命令,git mv 命令和 mv 命令有哪些区别呢?
    •   git mv oldname newname 会显式地告诉 Git 这是对文件的重命名操作,同时 Git 会自动地执行 git add 和 git rm 操作,也就是说会自动的更新暂存区。
    •   但是直接使用 mv 命令,mv 命令是系统级别的操作,Git 不会自动跟踪更名。

不同人修改了同一文件的文件名如何处理

具体步骤

比如有两个人 a 和 b 都在 feature 分支上开发。

  • a 修改了 index.htm 的文件名,将其修改为 index1.htm,然后提交了。
  • b 也修改了 index.htm 的文件名,将其修改为 index2.htm,然后也提交了。
  • a 推送到了远程
  • b 无法直接推送,他先将远程代码拉取到本地,此时 Git 会提示冲突(因为 Git 无法确定到底该保留哪一个文件):
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (1/1), done.
Unpacking objects: 100% (2/2), 251 bytes | 83.00 KiB/s, done.
remote: Total 2 (delta 1), reused 2 (delta 1), pack-reused 0 (from 0)
From github.com:wdmlab/git_learning
   547743d..3d6170b  feature/add-git-commands -> origin/feature/add-git-commands
CONFLICT (rename/rename): index.htm renamed to index2.htm in HEAD and to index1.htm in 3d6170b7aeeed5fa213fd061390debdf9c359b7d.
Automatic merge failed; fix conflicts and then commit the result.

由于我拉取远程代码后工作区有了新的代码,所以我执行 git status 会出现如下内容:

Unmerged paths:
  (use "git add/rm <file>..." as appropriate to mark resolution)
        both deleted:    index.htm   ← 已经被删除
        added by them:   index1.htm  ← 来自远程的文件
        added by us:     index2.htm  ← 你本地的文件

这时候需要手动的通过 git add 和 git rm 命令来确定需要保留和去除哪个文件,比如我想要保留 index1.htm 文件,那么我需要执行

git add index1.htm
git rm index2.htm
git rm index.htm

然后再提交和推送

在我执行 git add 和 git rm 操作时我在最开始仅执行了 git add index1.htm 和 git rm index2.htm,就开始推送了,但是 Git 仍然会提示:

error: failed to push some refs to 'github.com:wdmlab/git_learning.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

当我使用 git status 查看文件的状态的时候发现:

Unmerged paths:
  (use "git rm <file>..." to mark resolution)
        both deleted:    index.htm

也就是说 index.htm 文件还是一个未合并的路径。

我心中的困惑是:index.htm 在我上次推送前已经删除了,怎么在这里我还需要再对 index.html 执行 git rm 操作。

解释如下:

在执行 git pull 命令,Git 会在索引/暂存区中存放一个文件的三个版本

合并冲突发生后(未解决):

索引(Index):
────────────────────────────
文件名       Stage   来源
index.htm    1       base(原始版本)
index.htm    2       local(你)
index.htm    3       remote(对方)
────────────────────────────

此时 Git 无法自动决定 → 显示 “unmerged paths”

即使工作区已经没有 index.htm文件了,但是对于索引暂存区的这三条冲突记录,对这三个文件我都需要使用 git add/rm 命令来更新索引。

  • 所以 git add 和 git rm 在普通情况下和解决冲突的场景下的意义是不一样的:
场景git add 的意义git rm 的意义
平常(无冲突)把修改过的文件放入暂存区,准备提交删除文件并在暂存区记录删除
合并冲突时标记冲突已解决并决定保留这个文件标记冲突已解决并决定删除这个文件
  • 用一张简图(包含工作区、索引、HEAD 三层)来直观感受冲突时、git add 之后、git commit 之后,每一层的状态变化

    • Git 的三层结构

    • ┌────────────┐
      │  HEAD       │ ← 已提交的版本(版本库 / commit)
      └────────────┘
      ┌────────────┐
      │  Index      │ ← 暂存区(准备提交的内容)
      └────────────┘
      ┌────────────┐
      │  WorkingDir │ ← 你的实际文件(工作区)
      └────────────┘
      
    • 合并时发生了冲突

    •   场景是远程将 index.htm 改名为 index1.htm,本地将 index.htm 改名为 index2.htm,我执行 git pull,发生冲突

    • ───────────────────────────────
      【HEAD】(上一次提交)
        index.htm
      ───────────────────────────────
      【Index(索引)】
        index.htm (stage 1) ← base(共同祖先)
        index.htm (stage 2) ← 你的版本(index2.htm)
        index.htm (stage 3) ← 远程版本(index1.htm)
      ───────────────────────────────
      【WorkingDir(工作区)】
        index1.htm  ← 远程改名结果
        index2.htm  ← 本地改名结果
        (index.htm 不存在)
      ───────────────────────────────
      Git 状态:
        both deleted: index.htm
        added by them: index1.htm
        added by us: index2.htm
      

      Git 在索引里仍然保留了三个版本的 index.htm,代表冲突尚未解决。

    • 执行 git add/rm 来标记冲突已解决并决定保留或者删掉某个文件

      如果执行:

      git add index1.htm
      git rm index2.htm
      git rm index.htm
      

      状态变化为:

      ───────────────────────────────
      【HEAD】
        index.htm
      ───────────────────────────────
      【Index】
        index1.htm ← 你添加的文件(准备提交)
      ───────────────────────────────
      【WorkingDir】
        index1.htm   ← 保留
        (index2.htm 已被 rm)
        (index.htm 不存在)
      ───────────────────────────────
      Git 状态:
        All conflicts fixed but you are still merging.
      
      • 执行 git commit 生成一个新的合并提交,这时三层完全一致,冲突彻底解决。
      ───────────────────────────────
      【HEAD】
        index1.htm   ← 新的合并结果
      ───────────────────────────────
      【Index】
        index1.htm   ← 与 HEAD 同步(干净状态)
      ───────────────────────────────
      【WorkingDir】
        index1.htm   ← 与 HEAD 同步(干净状态)
      ───────────────────────────────
      Git 状态:
        nothing to commit, working tree clean 
      

  总结

  • git status 命令的作用

    • 在正常无冲突的情况下,git status 显示的结果是工作区相对暂存区的差异和暂存区相对上一次提交之间的差异。

    • 在有冲突的情况下,git status 显示的是每个冲突文件的多个版本情况(base(共同祖先)、local(你自己)、remote(对方))

    •     比如在上述场景中,git status 输出的内容就是:

    • Unmerged paths:
        (use "git add/rm <file>..." as appropriate to mark resolution)
              both deleted:    index.htm   ← 已经被删除
              added by them:   index1.htm  ← 来自远程的文件
              added by us:     index2.htm  ← 你本地的文件
      

      对于 index.htm 文件来说,其版本情况是:

      baseusthem
      index.htm
      index1.htm
      index2.htm
  • 如何解决冲突

  我想要保留 index1.htm,那么我需要执行的是

git add index1.htm
git rm index2.htm
git rm index.htm

然后再提交和推送到远程
  • git add 和 git rm 在普通情况下和解决冲突的场景下的意义:
场景git add 的意义git rm 的意义
平常(无冲突)把修改过的文件放入暂存区,准备提交删除文件并在暂存区记录删除
合并冲突时标记冲突已解决并决定保留这个文件标记冲突已解决并决定删除这个文件

参考