虽然用了很久的 Git 了,但基本上只是 add、commit、push、pull,这次系统性的学习了一遍。

整体是按照 Github 官方教程,由牛客网翻译的视频 的顺序排下来的。

内容上外加网上的各种博文总结和补充了一些。

Setup

$ brew install git

Config

Git 希望知道每次提交的作者信息,来说明是谁进行的本次提交,这会随着版本迭代永久纳入历史记录中。

$ git config --global user.name "my name"
$ git config --global user.email xxx@example.com

user.email 最好与 github 中的邮件地址相同,否则 push 后在 github 上并不会准确显示出是自己。

配置文件:

~/.gitconfig 中同样可以修改和查看上述配置,或修改 Git 全局忽略的位置信息等。

SSH

配合 SSH Key 在 Github 中可以免去输入密码的环节。

生成密钥:

$ ssh-keygen -t rsa -C "youremail@example.com"

~/.ssh/id_rsa.pub 中取得公钥并上传 GitHub。

Init

创建本地仓库:

$ git init

Github 上创建仓库,可遵循 Github 的提示:

  • create a new repository on the command line:
echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin https://github.com/xxx/test.git
git push -u origin master
git remote add origin https://github.com/xxx/test.git
git push -u origin master

status

查看状态:

$ git status

可以查看目前所有文件的状态,改动文件较多时推荐使用更简洁易读的:

$ git status -s
  • ?? 新文件,未跟踪
  • A 新文件,已跟踪
  • M 修改
  • D 删除

接下来是 Git 的提交三部曲:

  • 新建或修改文件
  • 加入到暂存区
  • 提交到仓库

Add

Add:将改动登记到暂存区:

单一文件的新建、修改、删除:

$ git add filename

也可以一次性 add 所有变更:

$ git add .

在简单操作中,add 被理解为把文件添加到暂存区没太大问题,但实际是它暂存的是修改而不是文件。

$ echo aaa > file
$ git add file
$ echo bbb >> file
$ git commit -m "bug fix"

上例中,只有 aaa 这一行的修改被暂存,所以版本库中只有 aaa,现在使用 diff 命令仍然可以看到 bbb 并没有被提交。

关于 git add .

在谷歌前几页几乎所有的博客中都强调了这一点:

git add -A = git add -u + git add . (错误的,见下文)

  • git add . 将文件的修改、新建,登记到暂存区(错误的,见下文)
  • git add -u 将文件的修改、删除,登记到暂存区
  • git add -A 将文件的修改、新建、删除,登记到暂存区

macOS 自带的 git 默认都是 2.20 版本了,在 stackoverflow 中的评论中看到,2.0 版本后 git add . 已经和 git add -A 效果相同。(https://stackoverflow.com/questions/15011311/whats-the-difference-between-git-add-u-and-git-add-a/27854048)

所以在大部分情况,只需要记住 git add . 这一个命令就足够了。


Commit

commit: 将暂存区提交到版本库:

$ git commit -m "message..."

如果不使用 -m 参数填写提交信息,git 会打开一个文本编辑器让你输入


工作区、暂存区、仓库区

diff 在 GUI 中观察更为舒适,但下一章 diff 的三种比较模式可以让新手深入了解三大区的变更状态。

工作区(Workspace)

即你肉眼可见的项目目录。

暂存区(Index/Stage)

一般存放在 “.git目录下” 下的index文件(.git/index)中,所以我们把暂存区有时也叫作索引(index)。它保存了下次将提交的文件列表信息,Git 对暂存区在命名和命令上有多个名字,包括Index,Stage,Cache。

本地仓库(Repository)

版本库即 .git 这个隐藏目录,真正存储各版本代码库的地方。

远程仓库(Remote)

协作开发时或个人备份用,存储在 GitHub 等平台或自建 git 的远端仓库,可用于多方共同开发。

Git

Git

日常操作与几大区的关系:

-> 修改代码、新建文件等操作,工作区发生改动

-> add:将改动内容"登记"到暂存区

-> commit:将暂存区的所有内容提交到本地仓库。

只有 commit 了,我们的代码才真正的进入到 git 仓库了。

-> push: 将本地仓库推送到远程仓库

Diff

三种比较模式

$ git diff

比较对象:工作区 与 暂存区(此时暂存区的内容是上一次提交的内容)

使用情景:日常命令,看看这次改动了什么。


$ git diff --staged
$ git diff --cached # 效果同上

比较对象:暂存区 与 版本库(上一次提交的内容)

使用情景: add 所有改动后,git diff 将不会显示任何东西了,在 commit 之前,仍可以使用 --staged 进行对比。


$ git diff HEAD

比较对象:工作区 与 版本库(上一次提交的内容)

使用情景:比如 add 后,又对这个文件进行了修改,在再次 add 之前,想看看在上一次提交之前都做了哪些改动。


经过自己动手测试,通过 Diff 章节应该能基本掌握工作区、暂存区、版本库之间的关系了。

diff 修饰命令

diff 默认是按行显示区别的,这两个命令会获得一种对长行小改动而言更易读的报告:

$ git diff --color-words
$ git diff --word-diff

不输出代码改动,仅仅告诉你哪些文件遭遇了改动:

$ git diff --stat

Log

了解仓库的提交历史:

$ git log

关于 40 位的 16 进制字符:(Git 保证完整性)

Git 中所有数据在存储前都计算校验和,然后以校验和来引用。 这意味着不可能在 Git 不知情时更改任何文件内容或目录内容。 这个功能建构在 Git 底层,是构成 Git 哲学不可或缺的部分。 若你在传送过程中丢失信息或损坏文件,Git 就能发现。

Git 用以计算校验和的机制叫做 SHA-1 散列(hash,哈希)。 这是一个由 40 个十六进制字符(0-9 和 a-f)组成的字符串,基于 Git 中文件的内容或目录结构计算出来。 SHA-1 哈希看起来是这样:

24b9da6552252987aa493b52f8696cd6d3b00373

Git 中使用这种哈希值的情况很多,你将经常看到这种哈希值。 实际上,Git 数据库中保存的信息都是以文件内容的哈希值来索引,而不是文件名。


$ git log --oneline

每行一个简短的概要:标识符+提交信息


$ git log --patch

在原始 log 显示内容的基础上,增加每次提交的代码改动情况。


$ git log --stat

在原始 log 显示内容的基础上,增加每次提交的代码改动情况。(不输出代码改动,仅仅告诉你哪些文件遭遇了改动,类似 diff 中的 --stat 效果)


$ git log -- directory/filename.ext

后接 -- 再接文件名,可以查看仅针对此文件的 log 记录。


常用组合命令

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

支持分支、标签的查看,快速了解整个项目的发展历程。

Remove

$ git rm

可以不用学习的命令,增删改都可以用 add 命令登记。

$ git rm xxx

该命令完成两件事:

  1. 删除文件
  2. 将删除文件这个操作’‘登记’‘到暂存区

如果你在本次变更中只进行了此次操作,可以直接 commit,不用再 add 登记了。

如果你通过终端执行了 rm 操作 / 在编辑器或 IDE 中执行了删除操作 / 在电脑上直接删除文件:

则只完成了第一步的内容,仍然可以通过 git rm xxx 命令将删除文件这个改动登记到暂存区。



$ git rm --cached

这个命令将删除一个文件,但并不是从系统中真正的删除。只是告诉 git 请不要再跟踪这个文件,这个文件将仍然保留在我的工作区中。

情景说明:当我使用 JetBrains 全家桶时,项目下面会有一个 .idea/ 的目录,里面也许会包含一些个人信息或无用信息,这个目录已经被纳入 git 管理了。

目的:并不在本地删除 .idea/ 目录及其中文件,只是取消被 git 追踪。

需配合 .gitignore 文件使用。

  1. 创建 .gitignore 文件,写入一行内容:.idea/ 即可。
  2. $ git rm -r --cached .idea/ (-r 是递归目录,如果只是单个文件可取消这个参数)
  3. add 与 commit 完成本次修改

以后即使 .idea/ 目录里面发生了文件的增删改,git 也不会理会。

Move

$ git mv file.ext xxx/file.ext

rm 类似,同时完成移动与 add。

基本很少用到,一般都用系统自带的文件管理器或 IDE、编辑器来移动文件,然后 add 即可。

建议使用情景:在同一次提交前,既修改了超过 50% 的代码,又想修改文件名。(参考忒修斯之船)


$ git log --stat -M --follow directory/file.ext

加上 -M —follow 参数,可以 log 出文件的移动。

Ship of Theseus (忒修斯之船)

忒修斯悖论来比喻代码修改:

  • 某些 CVS:当移动文件后,它认为只是一个完全不同的东西,无法追踪文件的移动过程。
  • 某些企业版 CVS:每个文件都有标识,有个数据库来记录,可以轻易的追踪文件的移动。
  • Git:当一个文件至少有 50% 相似度时,这是同一个文件,小于 50% 时,这是一个新文件。

在移动并修改文件后 commit,Git 会反馈出一个百分比:

[master 48f02f2] 本次提交信息
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename xxx => xxx/xxx (100%)

100% 即代表是纯移动,未做修改。

默认为 50%,低于这个阈值时,Git 会直接显示创建与删除:

[master 9a28f41] 本次提交信息
 2 files changed, 3 insertions(+), 1 deletion(-)
 create mode 100644 xxx
 delete mode 100644 xxx/xxx

50% 这个默认数值是可以修改的,视频只是简单打了个示例代码并未详细说明,我觉得这可能是个几乎永远不会用到的功能。

Ignore

创建 .gitignore 文件并写入规则,来使 Git 去忽略哪些你不想纳入版本控制的文件与文件夹。

在被 gitignore 之前已经被 Git 追踪的文件不受 gitignore 规则的影响。

比如 Python 生成的 __pycache__ 文件夹,JB 全家桶的 .idea 文件夹,macOS 的 .DS_Store 文件,都属于无用文件。或者存储了数据库密码的配置文件,是绝对不能纳入版本控制的。

.gitignore 书写规则

# 注释行以 # 开头
# 文件名中有 # 怎么办 -> 加一个反斜杠转义
# 文件名中有 ! 怎么办 -> 加一个反斜杠转义
# 文件名最后有空格怎么办 -> 加一个反斜杠转义

# 忽略文件,直接书写文件名
.DS_Store

# 忽略目录,以 / 结尾
# foo/ 会忽略 foo 目录及此目录下所有内容(但不忽略同名的 foo 文件)
foo/

# 支持通配符 *,忽略所有以 .log 结尾的文件
*.log

# 此文件除外
!a.log
# 如果 a.log 父级目录被忽略,Git 并不会递归这个目录来寻找 a.log
# 即如果 a.log 在 foo/ 中(foo/ 目录已被忽略),写这条规则也没用

# 忽略 bar/ 目录内除了 abc.txt 的文件
bar/*
!abc.txt

# 递归忽略所有 foo 文件和目录
**/foo

# 递归忽略所有 foo 目录及其子目录下的 bar.txt 文件
foo/**/bar.txt

全局忽略

~/.gitconfig 中查看全局忽略文件的配置(默认即可):

...
[core]
	excludesfile = /Users/xxx/.gitignore_global
...

如果没有配置,可以复制粘贴上面的,或者:

$ git config --global core.excludesfile ~/.gitignore_global

编辑 ~/.gitignore_global 文件,规则相同。

在 macOS 中安装完 Git,默认的 .gitignore_global 应该是这样的:

*~
.DS_Store

忽略了所有以 ~ 结尾的文件,忽略了所有 .DS_Store 文件。

全局忽略适用于那些无论使用任何语言或 IDE 都需要忽略的文件,但是此文件只对自己有效,如果想让忽略规则对协作开发的所有成员有效,需要在项目目录正常建立 .gitignore 文件并纳入版本控制。

查看当前已被忽略的所有内容

$ git ls-files --others --ignored --exclude-standard

强制 add 被 gitignore 忽略的文件

$ git add -f filename

检测 gitignore 规则

在日常 git add . 时发现某一文件并不想忽略,但是已经被忽略了,检测命令:

$ git check-ignore -v filename

Branch

查看分支

$ git branch

显示分支一览表,当前所在分支前面有 * 表示。

创建分支

$ git branch 新分支名

删除分支

$ git branch -d 分支名

如果分支没有完全合并,Git 会抛出一个 error,提示你需要将 -d 改成 -D

切换分支

$ git checkout 分支名

Checkout

切换分支

$ git checkout 分支名

创建并切换分支

$ git checkout -b 分支名

相当于同时执行了 git branch 分支名git checkout 分支名

Detached head (头指针分离)

先通过 log 查找到某一时刻的 commit 16 进制标识码。

这个命令会让你的工作区会改变为这次提交时的状态:

$ git checkout 12563de6382baaaaf78c58085b4b9abbe5b26608

Git 会提示:

注意:正在检出 '12563de6382baaaaf78c58085b4b9abbe5b26608'。

您正处于分离头指针状态。您可以查看、做试验性的修改及提交,并且您可以通过另外
的检出分支操作丢弃在这个状态下所做的任何提交。

如果您想要通过创建分支来保留在此状态下所做的提交,您可以通过在检出命令添加
参数 -b 来实现(现在或稍后)。例如:

  git checkout -b <新分支名>

HEAD 目前位于 12563de 本次的commit message

这时 checkout 命令相当于一个时光穿梭机,你可以检出任意一次提交时的状态。

注意这不是版本回退。如果你强行修改 + add + commit,Git 会自动帮你创建一个分支。

退出时光穿梭机:

$ git checkout 分支名

Discarding edits (撤销修改)

见下面章节

Merge

合并分支

$ git merge dev

将 dev 分支合并到当前分支,dev 分支会仍然存在。

Fast-forward

  1. 假设当前只有 master 分支
  2. 创建并切换到 dev 分支
  3. 进行了一次或 N 次的修改,但是 master 分支根本没人动
  4. 切换到 master 分支,合并 dev 分支

即便会导致冲突的地方,也会被 dev 分支的内容覆盖。

这就是 Fast-forward (快进)合并模式,关键点在于 master 分支无人推进。

缺点:历史记录是一条冗长的直线,删除分支后会丢失分支信息。

解决冲突

当 master 和 dev 两条分支都并行推进的时候,在 master 分支合并 dev 分支时,如果同一行都被修改了,就会产生冲突。

自动合并 xxx
冲突(内容):合并冲突于 xxx
自动合并失败,修正冲突然后提交修正的结果。

冲突以 <<<<<<< ======= >>>>>>> 隔开,分别展示两个分支的不同内容。

<<<<<<< HEAD
master!!!
=======
dev!!!
>>>>>>> dev

取其一或将这一整体变成你想要的内容即可。

--abort

$ git merge dev
... (发现冲突)
$ git merge --abort

--abort 选项会尝试恢复到你运行合并前的状态,相当于你没有进行本次合并。

--suqash

$ git merge --squash dev
$ git commit -m "提交信息"

与直接合并结果相同,但是不提交、不移动 HEAD,因此需要一条额外的 commit 命令。

合并后 master 分支很干净,只保留一条记录。

删除 dev 分支后,dev 分支信息消失。

--no-ff

$ git merge --no-ff dev -m "提交信息"

禁用 Fast-forward 模式,这样在删除分支后也会保留分支信息。

我觉得这个命令记录的提交内容比较全面且容易理解,但分支多的时候会让历史记录看起来比较混乱。

删除 dev 分支后,dev 分支信息仍然保留。

Network

Remotes

$ git remote add 远端名称 远端地址
# 例如:
$ git remote add origin https://github.com/aaa/bbb

# 修改 URL
$ git remote set-url origin https://github.com/aaa/ccc

# 删除远端链接
$ git remote rm 远端名称
# 例如:
$ git remote rm origin

# 查看所有远端链接
$ git remote -v

# 查看所有远端分支名
$ git branch -r
# 所有远端的分支前都加上了远端名称,例如 origin/master、origin/dev

终于知道 origin 到底是什么意思了。

其实没什么意思,就是约定俗成的,如果你只有一个远端,比如这份代码只放在 Github 上,或只放在 码云 或 Coding 上,那就把远端起名叫 origin,这样大家看到了都懂。

如果你这份代码在本地修改后,既要 push 到 Github,又要 push 到其它某平台,那两个远端名称就可以自己定制。

Fetch, Pull, Push

$ git fetch origin master # 先拉取更新,fetch 不会更改本地工作区内容
$ git merge origin/master master # 将远程内容与本地内容合并
$ git pull origin master # 拉取并合并
$ git push origin master # 将本地内容推送到远程仓库

只有单个远程主机时,一般都命名为 origin。

在任何分支第一次 push 时,Git 会提示:

fatal: 当前分支 xxx 没有对应的上游分支。
为推送当前分支并建立与远程上游的跟踪,使用

    git push --set-upstream origin xxx

你可以输入这个命令让本地分支与远程分支建立连接。

也可以在每个分支的第一次 push 时使用 -u 参数:

$ git push -u origin xxx

以后就可以使用简化命令 git push 了。

Push 所有分支到远端:

$ git push --all

有多个远程主机时,比如 Hexo、Hugo 博客可能同时会部署到 GitHub 和 Coding,也会起不同的远程主机名,这时 -u 参数可以指定一个默认主机,不带参数时就默认 push 到这个远程主机。

GitHub 相关章节

GUI:主要是 GitHub Desktop 的介绍,我个人日常操作更喜欢用命令行,但是 GUI 在查看历史进度和 Diff 时更方便快捷。

GitHub:GitHub 的相关介绍

Forking:GitHub 的 Fork 功能相关介绍。

Pull Requests:GitHub 的 Pull Requests 功能相关介绍。

Reset

见下面章节

撤销修改与版本回退

必须理解的概念:

三个区:工作区、暂存区、版本库,或三棵树:Working Directory、Index、HEAD。

→ 改动代码,工作区改动

→ add,将改动登记到暂存区

→ commit,将暂存区提交到版本库,HEAD 指向当前分支的最后一次提交

→ …

→ 又一次 commit,将暂存区提交到版本库,HEAD 依然指向当前分支的最后一次提交

HEAD 是当前分支引用的指针,它总是指向该分支上的最后一次提交。 这表示 HEAD 将是下一次提交的父结点。 通常,理解 HEAD 的最简方式,就是将它看做 你的上一次提交 的快照。

撤销修改

当你对修改不太满意,想恢复初始状态:

# 单个文件撤销
$ git checkout -- 文件名
# 所有文件撤销
$ git checkout . 

如果 add 进暂存区了,先撤销暂存区,再撤销工作区:

# 单个文件:
$ git reset HEAD 文件名
$ git checkout -- 文件名
# 多个文件:
$ git reset HEAD
$ git checkout .

如果 commit 进版本库了,那就用 reset 进行版本回退。

Reset

reset 分为三种模式:

  • --soft 保持本地所有改动(工作区、暂存区不变)
  • --mixed 默认,保持工作副本但重置索引(工作区不变)
  • --hard 丢弃所有改动

它们的主要区别在于作用覆盖的区域(工作区、暂存区)不一样,HEAD 的指向是必变的,在敲完命令后可以用 diff 的三种比较模式去验证。

以一个例子来讲,这是目前的状况。

$ git log --oneline
4a9dabc (HEAD -> master) 3
e285589 2
116eee9 1
$ git reset --soft e285589
  • 工作区:无变化
  • 暂存区:无变化
  • 版本库:HEAD 指向此版本
$ git reset e285589
$ git reset --mixed e285589 # 同上
  • 工作区:无变化
  • 暂存区:变成此版本
  • 版本库:HEAD 指向此版本
$ git reset --hard e285589
  • 工作区:变成此版本
  • 暂存区:变成此版本
  • 版本库:HEAD 指向此版本

压缩提交 / 合并提交

例子:

$ git log --oneline
900db85 (HEAD -> master) 修复刚才修复刚才修复错误导致的错误导致的错误
656915e 修复刚才修复错误导致的错误
2d7e668 修复错误
57335f5 5
ed32739 4
6a15af1 3
e285589 2
116eee9 1

我认为最后三次这种 commit message 应该合并成一个。

$ git reset --soft 57335f5

HEAD 指向了"版本5",但是工作区和暂存区都是目前的状态,直接 commit 即可。

$ git commit -m "fixed a big bug"

再 log 查看:

$ git log --oneline
5827469 (HEAD -> master) fixed a big bug
57335f5 5
ed32739 4
6a15af1 3
e285589 2
116eee9 1

除了用 reset,在图形化工具 GitHub Desktop 中,选中多个 commit 后点击“Squash n(选中的个数) Commit”,就可以将选中的 n 个提交合并成一个。

Tag

比起混乱的 commit id,标签可以更方便的标记出重要的版本。

狗书《Flask Web 开发》作者配合使用 Tag 功能做了一个相当方便的时光穿梭机,比如第一章第一部分代码使用 1a 作为标签,第二章第三部分代码使用 2c 作为标签,读者只需执行 git checkout 2c 即可查看此次代码。


查看所有标签:

$ git tag

打标签:

# 给最新的 commit 打标签:
$ git tag 标签内容

# 给指定 commit ID 打标签,如:
$ git tag 标签内容 116eee9

# 创建带有说明的标签,用-a指定标签名,-m指定说明文字,可用`git show 标签内容`查看:
$ git tag -a v0.1 -m "version 0.1 released" 1094adb

删除标签:

$ git tag -d 标签内容

删除远端标签:

如果已经 push 到了 GitHub 上,想删除 GitHub 上的标签:

$ git push --delete <远端名成> <标签名称>

例如:

$ git push --delete origin v1.0

默认 push 时不推送标签,推送标签命令:

# 推送单个标签
$ git push 标签内容

# 推送所有标签
$ git push --tags

Rebase (变基)

https://www.git-tower.com/learn/git/ebook/cn/command-line/advanced-topics/rebase

推荐看一些文章后用 https://learngitbranching.js.org/ 这个教程试一试。

一个简洁的工作流教程:https://www.bilibili.com/video/BV19e4y1q7JJ

补充

命令行显示 utf-8 字符

git status 等命令下使 Git 使用 utf-8 编码显示中文等字符,而不是\123\123\123\123 这样的格式。

参考:https://stackoverflow.com/questions/22827239/how-to-make-git-properly-display-utf-8-encoded-pathnames-in-the-console-window

$ git config core.quotepath off

全局:

$ git config --global core.quotepath off

还原:off 改成 on

bisect

看到一个 git bisect 命令挺好玩的,用类似二分法去快速定位是哪次提交引起了当前的错误:

git bisect 命令教程 - 阮一峰的网络日志

提交时关闭 issue

参考:https://stackoverflow.com/questions/60027222/github-how-can-i-close-the-two-issues-with-commit-message

可以使用 close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved 等单词,如:

$ git commit -m "巴啦巴啦; close #233"

可以同时关闭多个 issue:

$ git commit -m "closes #1, closes #2, closes #3; YOUR COMMIT MESSAGE"