Git 学习笔记

本文最后更新于:2022年10月29日 晚上

资料主要来自:

  1. ProGit 2nd Edition (2014) 中文版
  2. Git Reference
  3. Learn Git Branching 中文网站

基于目前普遍使用的 Git 最新版本,记录了一些较为有用的 Git 科技。

推荐到 Github 仓库 下载该博客的 PDF 版本查看。

Git 基本信息

Git 特性

  • Git 和其它版本控制系统的主要差别在于对待数据的方法:

    • 其他系统:

      ​ 存储每个文件与初始版本的差异,如图:

    • Git:

      ​ 存储项目随时间改变的快照,把数据看作是对小型文件系统的一组快照。每次 commit 或保存项目状态时,Git 对当时的全部文件制作一个快照并保存这个快照的索引。为了高效,如果文件没有修改,Git 不再重新存储该文件,而是只保留一个链接指向之前存储的文件。Git 对待数据更像是一个快照流

  • Git 保护文件完整性:

    • Git 中所有数据在存储前都计算校验和,不能在不改变校验和的情况下修改文件。
    • 计算校验和的机制叫做 SHA-1 散列(hash,哈希),由 40 个十六进制字符组成。
    • Git 以校验和来引用文件,而不是文件名。
  • Git 一般只增加数据,难以清除数据库中数据,使得 Git 操作一般都是可逆的

「三棵树」 🌳

Git 项目的三个工作区域(文件的集合): + Git 仓库,.git directory,保存项目的元数据和对象数据库的地方。 + 暂存区域,或 Index,或 Staging Area,保存了下次将提交的文件列表信息,一般在 Git 仓库目录中。有时候也被称作「索引」,不过一般还是叫暂存区域。 + 工作目录,Working Directory(Tree),对项目的某个版本独立提取出来的内容,放在磁盘上可以使用或修改

Git 基础操作

  • git config

    • 用于设置控制 Git 外观和行为的配置变量,它们存储在三个不同的位置:

      • /etc/gitconfig 文件:

        使用 --system 选项读写该文件,包含系统上每一个用户及他们仓库的通用配置。

      • ~/.gitconfig~/.config/git/config 文件:

        使用 --global 选项读写该文件,只针对当前用户,一般 Windows 设置此处。

      • 当前使用仓库的 Git 目录中的 config 文件(就是 .git/config):

        针对该仓库。

        每一个级别覆盖上一级别的配置。

    • 用户信息:

      1
      2
      $ git config --global user.name "用户名"
      $ git config --global user.email "邮箱"
    • Git 操作别名 alias

      • 相当于 define,不用输入复杂的操作命令,只需要输入它的别名

      • git log 的显示优化为例子,可以以 Git 图形象展示分支和提交情况,并涂上颜色 🖍

        1
        $ git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --date=relative
      • 对这一操作起别名 showlog,截取 git 之后的部分:

        1
        $ git config --global alias.showlog "log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit --date=relative"
    • 设置编辑器:

      ​ 默认编辑器为 Vim,入门不太友好,如果配置有 VSCode 或其他编辑器,可以修改默认编辑器:

      1
      $ git config --global core.editor "code --wait"
    • git config --list 命令来列出所有 Git 配置

  • 获取帮助文档的三种方式:

    1
    2
    3
    $ git help <verb>
    $ git <verb> --help
    $ man git-<verb>
  • 获取 Git 仓库

    • git init 创建 .git 的子目录,含有初始化的 Git 仓库中所有的必须文件,此时三棵树只有工作目录,其他两棵树都为空,后续可以添加远程仓库。
    • git clone 克隆现有的仓库。
  • 忽略文件

    • 对于不希望 Git 加入版本控制的文件,可以通过 .gitignore 文件,列出要忽略的文件模式,一行一个规则(所有空行或者以 开头的行都会被 Git 忽略)。

    • 经典格式:

      • 按文件名忽略单个文件:

        1
        2
        3
        4
        # 忽略当前文件夹下的 a 文件:
        a
        # 忽略所有子目录下的 a 文件
        */a
      • 按后缀忽略文件:

        1
        2
        3
        4
        5
        6
        7
        8
        # 忽略所有的 .a 文件
        *.a
        # 用 ! 否定忽略 test.a 文件, 即使前面忽略了所有的 .a 文件
        !test.a
        # 忽略 /b/test.a,不能忽略 /b/b/test.a
        b/*.a
        # 忽略 b 目录及其子目录下所有的 .a 文件
        b/**/*.a
      • 忽略文件夹:

        1
        2
        3
        4
        5
        6
        # 忽略根目录下的 a 文件夹,以下等价
        a/
        /a/
        /a/*
        # 忽略 a 文件夹, 不管是根目录下的 /a/, 还是子目录下的 /child/a/
        a/*
      • 否定的格式和忽略格式一样,只需要加 !

      • glob 模式规则(简化版正则表达式规则):

        • 以星号 * 通配任意个字符

        • 以问号 ? 通配单个字符

        • 以方括号 [] 包含单个字符的匹配列表:

          1
          2
          3
          4
          5
          6
          # 忽略 debug0.log、debugA.log 等
          debug[a-zA-Z0-9].log
          # 仅忽略 debug0.log、debug1.log
          debug[01].log
          # 不忽略 debug0.log、debug1.log
          debug[!01].log
    • 要注意的细节:

      • gitignore 是从上到下一行一行匹配,后面的会覆盖前面的。

      • 默认编码是 GBK,所以不能识别中文

      • 对于已经加入版本控制的文件,不能仅通过修改 .gitignore 来取消跟踪,还要使用:

        1
        $ git update-index --assume-unchanged 文件路径
      • 一般对于各种语言和项目有 .gitignore 模板

Git 本地操作

文件状态

  • 工作目录下的每一个文件处于三种状态之一:

    • 已跟踪,指那些被纳入了版本控制的文件,在上一次快照中有它们的记录,在工作一段时间后,它们的状态可能处于未修改 Unmodified,已修改 Modified 或已放入暂存区状态 Staged

      初次克隆某个仓库的时候,工作目录中的所有文件都属于已跟踪文件,并处于未修改状态。

    • 已忽略,在提交前已经被 .gitignore 规则忽略。

    • 未忽略且未跟踪,不是以上两种状态的文件

    • 状态转换:

  • git status 查看文件状态:

    • 会显示了当前所在分支,以及这个分支同远程服务器上对应的分支是否偏离

    • 输出格式:

      1
      2
      3
      4
      5
      6
      7
      8
      Changes to be committed:
      # 已添加到暂存区的状态,包含 modified, new file, renamed, deleted

      Changes not staged for commit:
      # 工作目录的状态,未添加到暂存区,包含 modified, deleted

      Untracked files:
      # 未忽略且未跟踪的新文件
    • 使用 -s 选项让输出更简洁:

      • 例子输出:

        1
        2
        3
        4
        5
        $ git status -s
        M README
        MM Rakefile
        A lib/git.rb
        ?? LICENSE.txt
      • 状态分为两列左列为上述所谓的暂存区状态右列工作目录状态

      • M 表示文件被修改过,左边M 表示该文件被修改了并放入了暂存区右边M 表示该文件被修改了但是还没放入暂存区。同时具有两列状态的还有 D,表示一个已跟踪文件被删除

        上面 Rakefile 文件可能经历了两次修改,第一次修改后,加入到了暂存区中,有了左边的 M,之后再进行第二次修改,未加入到暂存区,此时就会得到右边的 M。若再将该文件放入暂存区,则只会输出左边的 M

      • A 表示文件新添加到暂存区中,之前可能处于为未跟踪状态,只会出现在左边。同样只会出现在左边的还有更名操作产生的状态 R

      • ?? 表示未忽略且未跟踪的新文件。

    • git status 输出 working tree clean 时,或 -s 选项无输出时,表示当前没有修改,三棵树保持一致。许多命令都要求在 working tree clean,以防丢失修改。

Git 个人开发操作

  • 查看文件差异:

    • 直接上图:

    • git diff 本身只显示尚未暂存的改动,而不是自上次提交以来所做的所有改动。所以暂存了所有更新过的文件后,运行 git diff 会什么也不输出。

  • git add 添加进暂存区:

    • 在新版本的 Git 中,git add .git add -A 操作效果一致,都是将未跟踪、已跟踪被修改,已跟踪被删除的文件添加到暂存区。
    • git add -u 不会将未跟踪文件添加到暂存区,uupdate,顾名思义就是只有已被跟踪的文件被更新(删除或被修改)才会添加到暂存区。
  • git rm 删除:

    • 清除不小心添加到暂存区的文件,不想让 Git 去跟踪。如果已经提交了,需要用之前说的方式删除:

      1
      $ git update-index --assume-unchanged 文件路径
    • 不带选项时,是同时删除暂存区和工作目录下的文件,并且该文件的状态变为左边D,即删除这一动作也会被提交。

    • --cache 选项,只删除暂存区的文件,不删除工作目录的文件,比如一些中间过程文件。文件状态会同时出现在暂存区和工作目录:

      • 在暂存区中显示左边D

      • 在工作目录中显示未跟踪??

      • 当使用简洁输出状态的时,会产生以下奇妙输出:

        1
        2
        3
        4
        $ git rm --cache d
        $ git status -s
        D d
        ?? d
    • 可以使用 glob 模式规则选择删除文件

  • git mv 移动或更名文件:

    • 格式:git mv a bb 可以是另一个文件名,也可以是一个具体的文件路径。
    • 工作目录和暂存区的文件都会被移动
    • Git 会意识到这是一次改名,文件的状态会变成 renamed左边R
  • git commit 提交更新:

    • -a 选项,跳过使用暂存区,直接把已修改或者已删除的文件添加提交,但是对于未跟踪的新文件不会添加,一般只用在修改少量文件的提交。
    • --amend 选项,尽量在未推送到远程时使用(除非只有自己用),可以替换掉当前 HEAD 指向的提交(校验和改变),用于补充一些修改,保持 Git 记录工整。如果不使用 -m 编写新的提交信息,则会使用旧的提交信息。

后悔药:撤销操作 💊

  • 强烈建议使用git restore

    • 新版本的撤销命令,分担了原来 git checkoutgit reset 的大量任务,通常格式为:

      1
      $ git restore [-s <tree-ish>] [--staged] [--worktree] 文件路径
    • [] 表示这一选项或参数可有可无。不带任何选项时,则会默认从暂存区还原工作目录下的文件。

    • -s <tree> 选项,从 <tree-ish> 对应的提交还原文件,可以是 HEAD,分支名,或是提交的校验和前几位。

    • --staged 选项,从 HEAD 或者在设置 -s <tree-ish> 后,从 <tree-ish> 还原文件,只会改变暂存区

    • --worktree 选项,一般和 --staged 一起用(因为没有 --staged 时就是会还原工作目录的),表示同时改变暂存区和工作目录

    • 通过灵活使用上述选项,改变文件状态,基本上满足工作需求

  • git reset 重置:

    • 当重置目标是文件时,reset 改变的是暂存区的对应文件,格式为:

      1
      $ git reset [<tree-ish>] [--] 文件路径

      -- 会比较好一点,因为如果不幸有个分支名与路径名相同,那么 reset 是不能判断的(报错 ambiguous argument),所以要用 -- 指定。

      重置文件后,会发现文件的状态变成了 MM,即暂存区的文件与 HEAD 和工作目录的文件都不同。这命令其实等价于:

      1
      $ git restore [-s <tree-ish>] --staged 文件路径
    • 其余情况,会移动 HEAD 指向的分支 到某一提交。格式为:

      1
      $ git reset [<mode>] [<commit>]

      此时有三种模式 mode:

      • --soft:只是将 HEAD 及其指向的分支移动到 commit不会改变暂存区和工作目录。
      • --mixed:不指定 mode 时的默认模式,将会改变暂存区与 commit 一致,其余同 --soft,不改变工作目录。
      • --hard:较为危险的选项,将工作目录的修改全部丢弃,重置到 commit 一致,其余同 --mixed
      • 以上三种模式影响范围递增,形象对比如下:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      原状态
      working index HEAD target
      A B C D

      $ git reset mode target
      mode working index HEAD
      --soft A B D
      --mixed A D D
      --hard D D D

      可以使用 --soft 模式实现所谓「压缩提交」的效果:

      假设当前提交历史为 a → b → c,当不想保留 b 提交时,可以在 c 时使用 --softreset,使 HEAD 和其分支指向 a,同时因为暂存区不变,再提交一次即可,最终历史为:

      1
      2
      a -> b -> c
      └--> d (HEAD)
  • 万能的 git checkout

    • 如果要移动 HEAD,格式为:

      1
      2
      3
      4
      5
      # 切换到指定分支
      $ git checkout [-q] [-f] [-m] [<branch>]

      # 切换到 start_point,并在其上建立新分支
      $ git checkout [[-b|-B] <new_branch>] [<start_point>]
      • 不同于 resetcheckout 仅会移动 HEAD,不会移动分支。这使得 HEAD 可能进入 detach 分离状态,即 HEAD 指向了某个具体的提交记录而不是分支名,可以通过新建分支解决。
      • -B 是强制建立分支,即使同名分支已存在,这会使得原同名分支移动到 start_point
      • 切换分支时尽量保持 working tree clean,即三棵树保持一致,否则可能会丢失修改。
    • 如果要撤销文件修改,格式为:

      1
      $ git checkout [<tree-ish>] [--] 文件路径

      这会同时修改暂存区和工作目录的对应文件,使得与 tree-ish 一致,这会使得修改丢失,属于危险操作

Git 标签 🔖

  • 分支很容易被人为移动,并且当有新的提交时,它也会移动,大部分分支还只是临时的。所以需要一个永远指向某个提交记录的标识,比如软件发布新的大版本,或者是修正一些重要的 Bug 或是增加了某些新特性。这就需要打标签

  • 标签不会随着新的提交而移动,它就像是提交树上的一个锚点,标识了某个特定的位置。

  • 列出已有标签:

    ​ 以字母顺序列出标签

    1
    2
    3
    $ git tag
    v1.0
    v1.1
  • 轻量标签(lightweight):

    • 只是某个特定提交的引用,没有注释,只有一个标签号

    • 格式:

      1
      2
      ## 没有指定 commit 会默认在当前 HEAD 指向提交上打上标签
      $ git tag <tagname> [<commit>]
  • 附注标签(annotated):

    • 是存储在 Git 数据库中的一个完整对象,可以被校验的,包含打标签者的名字、电子邮件地址、日期时间;还有一个标签信息;并且可以使用 GNU Privacy Guard(GPG)签名与验证。
    • 在运行 tag 命令时指定 -a 选项,并使用 -m 选项指定了一条将会存储在标签中的信息
  • 显示标签对应信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    $ git show v1.0
    tag v1.0
    ################################################ 附注标签才有的信息
    Tagger: ailanxier <ailanxier@ailanxier.cc>
    Date: Sat May 3 20:19:12 2014 -0700

    tag information
    ################################################ 附注标签才有的信息
    commit ca82a6dff817ec66f44342007202690a93763949
    Author: ailanxier <ailanxier@ailanxier.com>
    Date: Mon Mar 17 21:52:11 2008 -0700

    commit information
  • 推送标签到远程:

    默认情况下,git push 命令并不会传送标签到远程仓库服务器上。在创建完标签后必须显式地推送标签到共享服务器上,就像共享远程分支一样。

    1
    2
    $ git push origin <tagname>
    $ git push origin --tags

    ​ 通常使用带有 --tags 选项的 git push 命令,会把所有不在远程仓库上的标签全部推送,不会区分轻量标签和附注标签。

  • 删除标签:

    1
    $ git tag -d <tagname>

    ​ 这只会删除本地标签,如果已经推送到远程仓库了,此时删除标签是一件对别人不太友好的事,可以使用如下命令删除远程仓库的标签:

    1
    $ git push origin -d <tagname>

在已经推送标签后,最好不要使用 -f 选项强制替换已有标签,也不要删除标签。

  • 可以根据标签名,使用 git checkout 切换到标签所在的提交,但是此时通常会处于分离 HEAD 状态,即新的修改提交不能被分支记录,要索引到这一提交只能通过校验和。所以如果要进行更改,比如修复旧版本中的错误,那么通常需要创建一个新分支,可以使用切换并新建分支的命令:

    1
    $ git checkout -b      bugFix_v2.0.0     v2.0.0

Git 分支

分支简单操作

  • 新建分支:

    1
    $ git branch <branchname> [<start-point>]

    如果不指定 <start_point> 则在 HEAD 指向的提交对象上创建一个指针,即为分支,同时在 .git\refs\heads 上新建一个名为 branchname 的文件,这个文件只含有 40 位校验和。所以创建分支是很轻量的一个操作。该命令不会切换分支

    HEAD 是一个特殊的指针,告诉 Git 当前在哪个分支上,且只会指向本地分支。当使用 git checkout 到一个远程分支时,会处于 HEAD 分离状态。

  • 删除分支:

    1
    $ git branch [-d | -D] <branchname>

    -d 不能删除未合并的分支,-D 可以强制删除这种分支。

  • 查看分支:

    • 不带选项和参数的 git branch 命令:

      1
      2
      3
      4
      $ git branch
      * dev
      mynew
      new

      显示所有本地分支,* 的分支为 HEAD 指向的分支。

    • -v 选项:

      1
      2
      3
      4
      $ git branch -v
      * dev e31e199 [ahead 9] Merge
      mynew 6a1cba4 test merge branch
      new 6a1cba4 test merge branch

      显示更详细的信息,如分支指向的提交记录校验和,与远程分支的差异,提交记录的描述信息

    • -vv 选项:

      1
      2
      3
      4
      $ git branch -vv
      * dev e31e199 [o/dev: ahead 9] Merge
      mynew 6a1cba4 [o/new] test merge branch
      new 6a1cba4 [o/new] test merge branch

      显示跟踪的远程分支,用的较多。

    • -a 选项:

      1
      2
      3
      4
      5
      6
      $ git branch -a
      * dev
      mynew
      new
      remotes/o/dev
      remotes/o/new

      列出所有分支,包括远程分支。

    • --merged--no-merged 选项:

      1
      $ git branch [--merged | --no-merged] [<branchname>]

      显示已(未)合并到当前分支<branchname>(在有该参数的情况下)的分支。一般已合并的分支,如果其为临时分支,可以删除

  • git branch -f

    这可以当做一个新建分支命令,也可以移动现有分支到指定位置,格式为:

    1
    $ git branch -f <branchname> [<start-point>]

Git merge 🏳️‍🌈

  • 通常使用的格式:

    1
    git merge [--squash] [--no-commit] [<commit>]

    假设分支情况如下:

    1
    2
    3
       A---B---C topic
    /
    D---E---F---G *master

    master 分支合并 topic 分支,即执行 git merge topic 后,分支情况如下:

    1
    2
    3
       A----B----C topic
    / \
    D---E---F---G---H *master

    会得到一个合并的提交,它有两个父提交

  • 解决冲突:

    • 合并时,如果两个分支修改了同一个文件,可能会出现冲突,Git 不能智能合并,只能手动解决冲突,提示如下:

      1
      2
      CONFLICT (content): Merge conflict in d1.txt
      Automatic merge failed; fix conflicts and then commit the result.

      Git 会暂停下来,等待手动解决合并产生的冲突。在合并冲突后的任意时刻使用 git status 命令来查看那些因包含合并冲突而处于未合并(unmerged)状态的文件:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      $ git status
      On branch dev
      You have unmerged paths.
      (fix conflicts and run "git commit")
      (use "git merge --abort" to abort the merge)

      Unmerged paths:
      (use "git add <file>..." to mark resolution)
      both modified: d1.txt

      no changes added to commit (use "git add" and/or "git commit -a")
    • Git 会在有冲突的文件中加入标准的冲突解决标记,他们会包含一些特殊区段,格式如下:

      1
      2
      3
      4
      5
      <<<<<<< HEAD
      now_change
      =======
      new_change
      >>>>>>> new

      这表示 HEAD 所指示的版本(在运行 merge 命令之前已经切换到这个分支)在这个区段的上半部分(====== 之上),而 new 分支所指示的版本在 ====== 之下。 为了解决冲突,你必须选择使用由====== 分割的两部分中的一个,或者也可以自行合并这些内容。

      可以使用 VSCode 打开冲突文件,会有特殊的高亮和提示解决冲突,非常友好。

    • 在解决了所有文件里的冲突之后,对每个文件使用 git add 命令来将其标记为冲突已解决。此时可以使用 git commit 来生成合并提交,完成合并操作。

  • 快进 Fast-forward 现象:

    • 当试图合并两个分支时, 如果顺着一个分支走下去能够到达另一个分支,那么 Git 在合并两者的时候,只会简单的将指针向前推进,因为这种情况下的合并操作没有需要解决的分歧,所以也叫 Fast-forward
    • 例如在 dev 分支成功合并 new 后,若切换回 new 分支再合并 dev,则只会将 new 分支移动到 dev 分支,Git 显示此时发生了 Fast-forward
  • --squash 选项:

    接受被合并的分支上的所有工作,并将其压缩至一个变更集,产生一个不是合并提交的提交记录。它只有一个父提交,但却引入另一个分支的所有改动,可以在记录一个新提交前做更多的改动。

    即好像合并了,但是没完全合并,只是把另一个分支的变动拿过来合并到工作目录中而已,另一个分支不会发生任何变化。

  • --no-commit 选项:

    即将完成合并前,强制停下(否则会直接生成合并提交记录),允许用户进行修改和暂存,最后手动提交来结束合并。可以和 --squash 同时使用。

  • git merge --abort 结束合并命令:

    当合并因为 --no-commit 或者冲突停下时,可以使用此命令结束合并,恢复到 Git 没执行合并命令之前的状态。

Git rebase ♐

  • 常用格式:

    1
    $ git rebase [-i] [--onto <newbase>] [<branch>]
  • 演示过程:

    假设分支情况如下:

    1
    2
    3
          A---B---C *topic
    /
    D---E---F---G master

    topic 分支处执行 git rebase master,或者在其他任何地方执行 git rebase master topic,会得到一样的结果:

    1
    2
    3
                  A'--B'--C' *topic
    /
    D---E---F---G master

    git rebase master topic 其实会先切换到 topic 分支,再执行 git rebase master

  • 上例中 rebase 原理:

    • 先找到这两个分支(即当前分支 topic、变基操作的目标基底分支 master) 的最近共同祖先 E
    • 对比当前分支相对于该祖先的历次提交,提取相应的修改并存为临时文件,将当前分支指向目标基底 G
    • 最后以临时文件的修改按顺序应用于 G 上,就好像他们是在 G 后完成的修改,他们的校验和会发生改变,所以在图中用带 ' 的字母表示。
    • 原来的提交仍然存在,但是只能通过提交的校验和访问,可以使用 git reflog 查看 HEAD 移动的历史记录,找到已经不在历史中的提交校验和。
  • 这样做的好处:

    • 确保在向远程分支推送时能保持提交历史的整洁
    • 例如向某个其他人维护的项目贡献代码时,先在自己的分支里进行开发,当开发完成时你需要先将你的代码变基到远程分支上,然后再向主项目提交修改。
    • 这样的话,该项目的维护者就不再需要进行整合工作,只需要快进合并(Fast-forward)便可。
  • 注意一种特殊情况:

    • 目标基底分支已经包含了当前分支所做的相同修改(整个提交做的修改完全相同,可能是修复了同样的 bug),类似:

      1
      2
      3
            A---B---C *topic
      /
      D---E---A'---F master

      AA' 所做的修改相同

    • 此时运行变基命令,会得到:

      1
      2
      3
                     B'--C' *topic
      /
      D---E---A'---G master

      A 的提交被跳过了,Git 认为 G 已经包含了 A 中的修改,就不会再重新做一次了。

  • --onto 选项:

    • 个人认为比较危险,容易丢失修改和新文件,需要对分支的改变非常熟悉才建议使用。

    • 假设当前分支情况如下:

      1
      2
      3
      4
      5
      o---o---o---o---o  master
      \
      o---o---o---o---o next
      \
      o---o---o topic

      topic 分支依赖实现了某种功能的 next 分支,此时 master 分支的功能逐渐完善,已经可以让 topic 分支基于更稳定的 master 分支了,此时仅想保留 topic 分支中的修改,不需要 next 分支中的修改,则可以使用:

      1
      2
      3
      4
      5
      6
      $ git rebase --onto master next topic
      o---o---o---o---o master
      | \
      | o'--o'--o' topic
      \
      o---o---o---o---o next

      如果 next 分支中有新文件,且 topic 分支中没有对新文件进行修改,则变基后新文件将消失

      同理如果 topic 中没有对 next 修改过的文件进行修改,变基后修改会消失。

    • 该选项可以用来去掉一段连续的提交,但是不太推荐,还是用 -i 选项的 rebase 比较好,也能达到同样的效果,但是更安全。

  • 非常推荐的交互式变基 -i 选项:

    • Git 会打开一个文件(如果是 VSCode 打开会有图形操作界面,如下图)并列出将要被复制到目标分支的备选提交记录,以及它们的哈希值和提交说明,有助于理解这个提交进行了哪些更改。

    • 可以根据意愿调整提交记录:

      • 调整提交记录的顺序(VSCode 通过鼠标拖放来完成)
      • 删除不想要的提交
      • 合并提交,有多种合并方式。
      • 修改提交说明
    • 具体选项如下:

      • p, pick <commit> = use commit 完整保留提交。
      • r, reword <commit> = use commit, but edit the commit messagep,只是会打开编辑器修改提交说明
      • e, edit <commit> = use commit, but stop for amendingp,同时等待手动 git commit --amend,修改到满意了就会运行 git rebase --continue 继续变基下一个提交。
      • s, squash <commit> = use commit, but meld into previous commit 合并提交到自己的夫提交,会打开文本编辑器修改提交说明。这个选项用的比较多。
      • f, fixup <commit> = like "squash" but keep only the previous commit's log messages,但是舍弃这次提交的提交说明,其实和 s 的区别就在于它不会打开编辑器。
      • d, drop remove commit 舍弃提交。
  • 变基是一个提交一个提交进行处理的。如果中间发生冲突也需要一个一个处理,此时 Git 会停下变基,用户可以打开冲突文件进行修改,修改完后用 git add . 添加所有的冲突文件,然后用 git rebase --continue 继续进行变基。

    Git 全程有命令提示,过程很友好。

    如果想终止变基,在 Git 停下阶段使用 git rebase --abort 恢复到变基前状态。

  • 多次 rebase 实例:

    • 来自 Git Referencelevel advanced1 关卡。

      这个网站很好玩,通过关卡学习 Git 分支常用操作,不涉及暂存区,有命令条数的要求(不严格)。

      一些常用命令:

      • levels 重新选择关卡
      • reset 将分支恢复关卡开始前原状
      • show solution 显示答案,建议不管有没有过关都显示一次答案

      它还提供了「沙盒」功能,可以提供用户使用各种具有破坏性的 Git 命令。

    • 原分支情况:

    • 目标分支情况:

    • 执行过程:

      1. git rebase main bugFix,得到 C3'
      2. git rebase bugFix side,得到 C4',C5',C6'
      3. git rebase side another,仅得到 C7',这里其实对应之前说的特殊情况,C4,C5C4',C5' 所作的修改是一样的,所以 rebase 不会将它们再放到这条提交线上。
      4. git rebase another main,利用快进 Fast-forward,使 main 分支更新到最新版本。

Merge vs. Rebase

  • 关于使用 mergerebase 的争论一直存在,甚至有人说无脑用 rebase 就行,笔者认为还是看情况用比较好。

  • merge 保留了所有提交记录,但是分支错杂,记录树非常乱;rebase 可以任意修改提交记录,记录树很整洁。

  • 有一种观点认为,仓库的提交历史即是记录实际发生过什么。它是针对历史的文档,本身就有价值,不能乱改。 从这个角度看来,改变提交历史是一种亵渎,你使用「谎言」掩盖了实际发生过的事情。如果由合并产生的提交历史是一团糟怎么办?既然事实就是如此,那么这些痕迹就应该被保留下来,让后人能够查阅。

  • 另一种观点则正好相反,他们认为提交历史是项目过程中发生的事。 没人会出版一本书的第一版草稿,软件维护手册也是需要反复修订才能方便使用。 持这一观点的人会使用 rebasefilter-branch 等工具来编写故事,怎么方便后来的读者就怎么写。

  • 书上推荐的原则:

    只对尚未推送或分享给别人的本地修改执行变基操作清理历史,从不对已推送至别处的提交执行变基操作。

    这一原则极其重要,否则合作者可能会使用到已被变基放弃的旧提交,产生非常混乱的结果

Git 远程协作

远程仓库

  • 显示远程仓库的 URL:

    ​ 显示需要读写远程仓库的简写与其对应的 URL。

    1
    2
    3
    $ git remote -v
    origin git@github.com:ailanxier/Test.git (fetch)
    origin git@github.com:ailanxier/Test.git (push)
  • 添加远程仓库:

    1
    $ git remote add <shortname> <url>

​ 之后可以用字符串 shortname 来代替整个 URL。

  • 联网查看远程仓库的详细信息 🧙‍♂️:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    $ git remote show origin
    * remote origin
    URL: https://github.com/my-org/project
    Fetch URL: https://github.com/my-org/project
    Push URL: https://github.com/my-org/project
    HEAD branch: master
    Remote branches:
    master tracked
    dev-branch tracked
    issue new (next fetch will store in remotes/origin)
    Local branches configured for 'git pull':
    dev-branch merges with remote dev-branch
    master merges with remote master
    Local refs configured for 'git push':
    dev-branch pushes to dev-branch (up to date)
    master pushes to master (up to date)

    ​ 同样会列出远程仓库的 URL 与跟踪分支的信息,在特定的分支上执行 git push 会自动地推送到哪一个远程分支,哪些远程分支不在你的本地,哪些远程分支已经从服务器上移除了,还有执行 git pull 时哪些分支会自动合并

  • 移除远程仓库:

    1
    $ git remote rm <shortname>
  • 重命名远程仓库:

    ​ 将远程仓库 a 重命名为 b

    1
    $ git remote rename a b

远程分支

  • 远程分支的命名格式<remote name>/<branch name><remote name> 常见如 origin<branch name> 常见如 master

    originmaster无特殊含义,即不是关键词,可以改成任意名字。

    master 是运行 git init 时默认的起始分支名字,原因仅仅是它的广泛使用,origin 是当你运行 git clone 时默认的远程仓库名字。

  • 拉取到本地的远程分支,只能通过 git fetchgit pullgit push 移动,当 HEAD 切换到远程分支时通常会处于 HEAD 分离状态。

  • 本地跟踪分支上游分支):

    • 跟踪分支是与远程分支有直接关系的本地分支。

    • 如果在一个跟踪分支上输入 git fetch,Git 能自动地识别去哪个服务器上抓取、合并到哪个分支。

      在一个跟踪分支输入 git push,如果和远程分支不同名,不能自动识别出要推送到哪个分支。

    • 新建跟踪分支的命令:

      1
      $ git checkout -t <remote>/<branch>

      新建一个名为 branch 的分支,跟踪远程分支 <remote>/<branch>

      如果想自定义分支名字,可以使用:

      1
      $ git checkout -b <branchname> <remote>/<branch>
    • 当前本地分支设置(修改)上游分支:

      1
      $ git branch -u <remote>/<branch>

      上游快捷方式:

      当设置好跟踪分支后,可以通过简写 @{u} 来引用它的上游分支。

      例如在 master 分支时并且它正在跟踪 origin/master 时,可以使用 git merge @{u} 来取代 git merge origin/master

    • 可以使用 git branch -vv 查看本地分支及其跟踪分支,属于离线操作,即没有联网。

Fetch,Pull,Push 👨🏻‍💻

  • git fetch

    • 一般格式:

      1
      $ git fetch [<remote>] [<branchname>]

      从远程仓库 <remote> 上抓取本地没有的数据时,它并不会修改工作目录中的内容,只会修改远程分支。

    • 不指定 remote<branchname>

      默认从 origin 远程仓库获取数据更新本地所有分支。如果一个分支已经设置了跟踪分支,则该分支从那个跟踪分支所在仓库获取数据。

    • 指定 branchname

      仅更新指定远程分支。

    • git fetch --all

      所有远程仓库下载本地没有的数据。

  • git pull

    • 可以理解为 git fetch + git merge 的缩写:

      1
      2
      3
      4
      $ git pull origin foo
      ############################## 等价于下面两条命令
      $ git fetch origin foo
      $ git merge origin/foo
    • 需要注意最终 merge 的位置,无论执行 git pull 前处于哪个分支,这个命令都会合并到这个分支上,这个分支可能并不跟踪远程分支 foo。所以笔者更推荐git fetch 后手动 git merge

    • git pull 例子:

      • 原分支情况:

        1
        2
        3
        4
        5
              A---B---C master on origin
        /
        D---E---F---G *master

        origin/master in your repository
      • 运行 git pull 后,分支情况:

        1
        2
        3
              A---B---C origin/master
        / \
        D---E---F---G---H *master
    • 使用 --rebase 选项:

      不适用 merge 策略,转而执行 git fetch + git rebase

  • git push

    • 一般格式:

      1
      $ git push <remote> <branch>

      推送本地 branch 的变更到远程仓库 remote,同时本地的远程分支会移动到 branch 的位置。

    • 神奇的 <refspec> 格式:

      • 使用 git push origin a 时,Git 自动将 a 分支名字展开为refs/heads/a:refs/heads/a, 那意味着「推送本地的 a 分支来更新远程仓库上的 a 分支」。
      • 这种带冒号的 <src>:<dst> 格式为 <refspec> 格式,<src> 可以是某个具体的提交的校验和,只要是 Git 能识别的都可以起效。
      • 这意味着上述语句等价于 git push origin a:a,所以我们完全可以使用 git push origin b:a,即使分支 borigin/a 毫无关联,但是一般不会这样做。
      • 如果 <dst> 不存在,Git 甚至会在远程仓库新建一个名为 dst 的远程分支。
      • 如果 <src> 为空,即使用 git push origin :a,会删除远程仓库中的 origin/a 分支
      • git pushgit fetch 也可以使用这样的格式,但是觉得用处不大就不提了 👻
    • --all 选项:

      git push 默认是只推送当前分支,该选项会推送所有分支。

Git 杂项

相对引用

  • ^ 后面添加一个数字来指明哪一个父提交:
    • 通常用于合并提交节点,如果只有一个父节点,那么 ^ 就表示它的父节点。
    • 对于合并提交节点:
      • 第一父提交 ^1合并时所在分支
      • 第二父提交 ^2 是所合并的分支
  • 另一种相对引用是使用 ~,同样是指向第一父提交,因此 HEAD~HEAD^ 是等价的。而区别在于你在后面加数字的时候,例如 HEAD~2 代表「第一父提交的第一父提交」。下图可以更清晰地反映区别:
  • ^~ 前可以是提交校验和分支名HEAD 等。

交互式暂存

  • 使用 git add -i,发现 Git 新世界。

  • Git 将会进入一个交互式终端模式,显示类似下面的东西:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    $ git add -i
    staged unstaged path
    1: unchanged +0/-1 TODO
    2: unchanged +1/-1 index.html
    3: unchanged +5/-1 lib/simplegit.rb

    *** Commands ***
    1: [s]tatus 2: [u]pdate 3: [r]evert 4: [a]dd untracked
    5: [p]atch 6: [d]iff 7: [q]uit 8: [h]elp
    What now>

    首先显示 HEAD 和暂存区有差异的文件,将暂存的修改列在左侧 staged未暂存的修改列在右侧 unstaged

    同时会进入循环命令状态,直到输入 7q 退出(8 个命令都可以输入首字母或数字生效)。

  • 1 status

    • 显示 HEAD 和暂存区有差异的文件,和第一次显示时一样,且是实时的,如果你在 git add -i 之后做了修改可以通过 status 得知。
    • stagedunchanged 表示 HEAD 和暂存区的该文件相同,即还未暂存该文件。
    • unstagednothing 表示暂存区和工作目录的该文件相同,即已暂存该文件的全部修改。
    • 注意,这里不会显示未跟踪的新文件
  • 2 update

    • 它会问你想要暂存哪个文件,如果没有要暂存的文件(全部已暂存未修改未跟踪),则会直接退出,回到循环,否则显示:

      1
      2
      3
      4
      5
      6
      What now> u
      staged unstaged path
      1: unchanged +0/-1 TODO
      2: unchanged +1/-1 index.html
      3: unchanged +5/-1 lib/simplegit.rb
      Update>>
    • 如果要暂存 TODOindex.html 文件,可以输入数字或区间(本例中等价于输入 1-2),用空格或逗号隔开:

      1
      2
      3
      4
      5
      6
      Update>> 1,2
      staged unstaged path
      *1: unchanged +0/-1 TODO
      *2: unchanged +1/-1 index.html
      3: unchanged +5/-1 lib/simplegit.rb
      Update>>

      每个文件前面的 * 意味着选中的文件将会被暂存。

    • 如果要取消选中的 TODOindex.html ,使用 -1 -2 即可。

    • 如果在 Update>> 提示符后不输入任何东西并直接按回车,Git 将会暂存选择的文件。

  • 3 revert

    • 显示已经暂存的文件,操作方式和 2 update 相同,选中并 revert 表示将暂存区的该文件恢复到 HEAD 状态,相当于 git restore --staged 文件路径
    • 对于加入暂存区的新文件,该操作会将该文件重新变为 untracked 状态。
  • 4 add untracked

    显示未跟踪的新文件,操作方式同上,操作相当于 git add 未跟踪文件

  • 5 patch

    高端操作,只暂存某个文件的部分修改,看起来挺复杂的,先不记了 🦥

  • 6 diff

    显示已暂存的文件,操作方式同上,操作相当于 git diff --cached,比较的是 HEAD 和暂存区文件的差异。

  • 7 quit:润🏃🏻

当你在修改了大量文件后,希望这些改动能拆分为若干提交而不是混杂在一起成为一个提交时,交互式暂存会非常有用。

Git stash 💾

  • 应用背景:

    当你在项目的一部分上已经工作一段时间后,所有东西都进入了混乱的状态,而这时你想要切换到另一个分支做别的事。问题是,Git 可能不允许在已修改的工作目录执行切换分支命令,你不想仅仅因为这些不成熟的修改创建一次提交。 针对这个问题的答案是使用 git stash 命令。

  • stash 会处理工作目录的脏的状态(即跟踪文件的修改暂存区的改动),然后将未完成的修改保存到一个栈上,而你可以在任何时候重新应用这些改动(甚至在不同的分支上)。

  • 执行 git stashgit stash push(在不带选项和参数的情况下两者等价),将工作目录和暂存区的修改贮藏到栈顶,同时将这两棵树还原到 HEAD 状态。

  • git stash -m "描述",给这个贮藏记录像提交记录一样写描述文字

  • git stash -k,在贮藏工作目录和暂存区的修改的同时,只还原工作目录到 HEAD 状态,保持暂存区修改不变。

  • git stash -u,默认情况下,git stash 只会贮藏已修改和暂存的已跟踪文件。如果指定 -u 选项,Git 也会贮藏任何未跟踪文件。甚至对于已忽略的文件,使用 -a 选项也可以贮藏。

  • git stash list 查看贮藏栈中的情况:

    1
    2
    3
    stash@{0}: on master: using git stash -m message
    stash@{1}: WIP on master: c264051 Revert "added file_size"
    stash@{2}: WIP on master: 21d80a5 added number to log

    栈顶为 stash@{0},它使用了 -m 选项的贮藏命令。

    可以用 stash@{n} 来指代任意贮藏记录,在命令格式中使用 <stash> 表示。

  • git stash show [<stash>]

    显示某条贮藏记录的 diff 差异,不带 <stash> 参数时默认显示栈顶贮藏记录。

  • git stash pop [--index] [<stash>]

    • 删除栈顶或 <stash> 的贮藏记录,同时应用在当前工作目录
    • 如果产生冲突,则需要在解决冲突后,使用 git add .git commit 解决冲突状态。注意这种情况 Git 不会自动删除栈中对应贮藏记录。可以使用 drop 删除(后面有讲)。
    • 使用 --index 选项,会将贮藏记录的修改应用到当前暂存区
  • git stash apply [--index] [<stash>]

    除了不删除贮藏记录外,功能完全相同,用得较多。

  • git stash drop [<stash>]

    删除栈顶或 <stash> 的贮藏记录。

  • git stash clear

    删除所有贮藏记录。

  • git stash branch <branchname> [<stash>]

    • 对于一些会对当前节点发生冲突的贮藏记录,可以通过在贮藏记录原本的提交节点新建一个分支,应用贮藏的所有修改,进行测试处理。
    • 因为是在本来的节点上新建分支,所以是不会发生冲突的(本来就在这个节点基础上做的修改)。
    • 该命令会删除栈顶或 <stash> 的贮藏记录,并同时对暂存区和工作目录产生影响。

Git clean 🧹

  • 常用格式:

    1
    $ git clean [-d] [-x | -X] -i [--] [文件路径]
  • 命令可以用于清理不在版本控制范围内的文件,即未跟踪的文件,但不清理已忽略的文件。

  • 命令默认只清理 .git 所知文件夹下的文件,不清理未跟踪文件夹下的文件。

  • 如果指定了文件路径,那么 -d-x | -X 都不生效。

  • -d 选项:

    递归进入未跟踪的文件夹下清理文件。

  • -x 选项:

    清理范围包括已忽略的文件。

  • -X 选项:

    清理范围只包括已忽略的文件。

  • -i 选项:

    • 推荐使用该命令时都加上 -i 选项,使用交互式清理方式,一目了然要清理哪些文件。

    • 显示的交互界面如下:

      1
      2
      3
      4
      5
      6
      $ git clean -i
      Would remove the following item:
      a b c
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
    • 1 clean

      删除 Would remove the following item: 下的文件,并退出交互模式。

    • 2 filter by pattern

      列出当前删除范围内的文件,输入 *.txt 等文件类型进行过滤,过滤的文件移出删除范围:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      $ git clean -i
      Would remove the following items:
      1/1.md 1/1.txt
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
      What now> 2
      1/1.md 1/1.txt
      Input ignore patterns>> *.md
      1/1.txt
      Input ignore patterns>>
      Would remove the following item:
      1/1.txt
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
      What now>

      输入 *.md,不删除 1.md 文件。

    • 3 select by numbers

      按顺序列出删除范围内的文件,通过数字进行选择哪些文件要删除:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      $ git clean -i
      Would remove the following items:
      1/1.md 1/1.txt
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
      What now> 3
      1: 1/1.md 2: 1/1.txt
      Select items to delete>> 2
      1: 1/1.md * 2: 1/1.txt
      Select items to delete>>
      Would remove the following item:
      1/1.txt
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
      What now>
    • 4 ask each

      逐个询问在删除范围内的文件,是否要删除,全部询问后退出交互模式:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      $ git clean -i
      Would remove the following items:
      1/1.md 1/1.txt
      *** Commands ***
      1: clean 2: filter by pattern 3: select by numbers
      4: ask each 5: quit 6: help
      What now> 4
      Remove 1/1.md [y/N]? n
      Remove 1/1.txt [y/N]? y
      Removing 1/1.txt

Git Cherry-pick 🌸

  • 命令格式很简单:

    1
    2
    $ git cherry-pick [-n] [-m] 可以多个<commit>
    $ git cherry-pick (--continue | --skip | --abort | --quit)
  • 拣选一个或多个提交的修改到 HEAD 下,例如原分支情况如下:

    ​ 执行命令 git cherry-pick C3 C4 C7 后,分支变成:

  • 要求执行命令前保持 working tree clean,可能要像 git merge 一样合并冲突,合并后使用 git addgit cherry-pick --continue 生成新的提交。

  • -n 选项:

    合并后不自动生成提交,并且退出 CHERRY-PICKING 状态,即不能再使用 git cherry-pick [--continue | --skip | --abort | --quit] 等命令,需要手动 git commit 生成提交。

  • -m 选项:

    拣选不能选择合并提交的节点,除非使用 -m <parent-number><parent-number> 指的是哪个父节点,相当于之前说的相对引用 ^<parent-number>

  • --continue 都很熟悉了;--skip 跳过当前某个 cherry-pick 的提交;--abort 终止整个 cherry-pick 过程并还原到初始状态;--quit 退出 CHERRY-PICKING 状态,可以中途退出拣选过程,不完成整个过程。


Git 学习笔记
https://ailanxier.top/git
作者
Zeyu Dong
发布于
2022年1月18日
许可协议