0%

Git便签

How I wish I could finally master git and not bother others with my can-not-solved questions!🙇‍♀️


命令简介

本地操作

git config

1
2
3
4
5
6
7
8
# 配置基础选项
$ git config --global user.name doris user.email xxx@xx.com

# 配置别名
$ git config --local alias.br branch
$ git config --local alias.ci commit
$ git config --global alias.unstage 'reset HEAD --' # git unstage fileA
$ git config --global alias.last 'log -1 HEAD' # git last

git 查找配置的顺序如下,每层配置都会覆盖掉上一层配置。

  • 系统配置,存储于 /etc/gitconfig,可作用于系统中每一位用户,选项为 --system
  • 全局配置,存储于用户的 ~/.gitconfig~/.config/git/config,选项为 --global
  • 本地配置,存储与仓库的 .git/config,选项为 --local

我自己的话,通常还会想看看我都配置了哪些 config,可以用 list 选项:

1
2
3
4
5
6
7
8
9
10
11
# 显示全部
$ git config --list

# 显示全部,并带上来源文件(源自global file dir 或 local file dir)
$ git config --list --show-origin

# 显示 global 的
$ git config --list --global

# 显示 local
$ git config --list --local

介绍几个常用的配置:commit.template,默认的提交信息模板;user.signingkey,gpg 签署密钥;core.excludesfile,所有项目都忽略的文件(eg. macOS 的 .DS_Store);core.autocrlf,是否在提交的时候把 CRLF 换成 LF。

git init

1
2
$ cd projectDirectory
$ git init

创建了一个 .git 目录,里面存了一个版本库。

git add

1
$ git add fileName

将文件作为提交内容放到暂存区(staging area),同时清除工作区相关内容。

git rm

将文件从仓库删去。现在仓库中无该文件,工作目录上也无该文件,该文件被删除的动作被自动存入暂存区。

1
2
$ git rm fileName
$ git rm dir/\*.log # 可以使用匹配,这里删除了 dir 目录下所有 .log 文件

将文件从暂存区删去,同时将其从工作目录删去,需要使用 -f 选项,这是一种防止误删的安全特性。现在暂存区无该文件,工作目录也无该文件。

1
$ git rm -f fileName

将文件从暂存区删去,同时仍将其保留在工作目录中,需要使用 --cached 选项。现在暂存区无该文件,工作目录有该文件。

1
$ git rm --cached fileName

git mv

重命名文件。

1
$ git mv fileFrom fileTo

git 使用哈希值而非文件名来跟踪文件快照,因此当我们给文件重命名时,它内部是没什么体现的。不过聪明的 git 可以推断出改名行为,因此当我们使用 git mv 命令后使用 git status 查看,它会显示 renamed fileFrom -> fileTo

实际上运行 git mv 相当于运行了下面三条命令。

1
2
3
$ mv fileFrom fileTo
$ git rm fileFrom
$ git add fileTo

git commit

1
$ git commit --message "commit message"

将暂存区的修改内容传送到版本库中,并赋予一个提交哈希值,同时清空暂存区。每个提交包括哈希值、作者、日期、提交信息、和上一个提交相比发生的变化、项目所有的文件。

1
$ git commit --amend

将暂存区的文件提交,有两种使用场景:一是当提交后忘了暂存某些需要的修改,可以在提交过后再进行 git addgit commit --amend 来修复这一失误,替换掉旧有的最后一次提交;二是当上次提交之后我们没有做任何修改,这时使用 git commit --amend 则是用来修改提交信息的。前者的使用需要小心,因为它会改变提交的 SHA-1 校验和,类似于一个小的变基——如果已经推送了最后一次提交就不要修正它。

* 补充说明
git 会保存文件的访问权限,但不会保存修改时间。因为在检出的时候,文件的修改时间会被设置为当前时间。为什么不保存呢?因为很多构建工具会根据「最后一次修改时间」和「最后一次构建的时间」来决定是否重新构建。当检出时,会回退到之前的版本,这时我们会希望能够重新构建,如果按照真实的修改时间来,则无法触发构建,因此我们在检出时将修改时间设置为当前时间,这样就能正确而顺畅地构建了。

git status

获取变化。

1
2
3
4
5
6
7
$ git status

# 有几个小标题

# changes to be committed:被修改且已放入暂存区的文件
# changed but not updated:被修改但没放入暂存区的文件
# untracked files:所有新增文件

显示自上次提交开始,哪些文件已被修改,哪些修改将被纳入下一次提交。

也可以控制显示的内容格式。新添加的未跟踪文件前面有 ?? 标记,新添加到暂存区中的文件前面有 A 标记,修改过的文件前面有 M 标记。

1
2
3
4
5
6
$ git status --short # 输出更加紧凑

M a.txt # 表示文件已修改但尚未暂存
MM b.txt # 表示文件已修改,暂存后又作了修改(即工作区既有已暂存部分,也有未暂存部分)
A c.txt # 表示文件已修改且已暂存
?? d.txt # 表示文件为新添加的

git diff

获取差异。

1
2
$ git diff # 暂存区和工作区之间的区别,也就是修改后还没暂存的内容
$ git diff --staged # 暂存区与版本库之间的区别,也就是已暂存的将添加到下次提交的内容

还可以获得更加细节的修改内容。

1
$ git diff fileName

还可以获取提交之间的差异清单,可以用哈希值、分支、标签、HEAD 等。

1
2
3
4
$ git diff hashValue HEAD # 两次提交的差异
$ git diff hashValue^! # 用^!表示和上一次提交的差异
$ git diff hashValue hashValue - directory # 只显示某个文件或目录的差异
$ git diff --stat hashValue hashValue # 用 --stat 表示统计每个文件中的修改数量

还有一种常用的三点语法。

背景是这样的:当我们想知道某个分支(eg. dev)和另一个分支(eg. master)的区别,我们一般可以使用 git diff master 来查看 dev 新增的修改。当 master 是 dev 的直接祖先,当然可以得到想要的结果;但一旦两个分支的历史产生了分叉,比如说 master 上有新提交,那么得到的 diff 就是「将 dev 中所有的新东西加入,且将 master 中独有的东西删除」,而不是「dev 新增的修改」。因为实际上和 dev 用 diff 对比的是 master 的最新快照,而不是它的祖先。

解决方案有两种:一是手动找到公共祖先,并对其显示运行 diff;二是使用三点语法,将某分支(eg. dev)最新提交和两个分支公共祖先进行比较。

1
2
3
4
5
6
7
8
9
10
# 方法一
$ git merge-base dev master
36c7dba2c95e6bbb78dfa822519ecfec6e1ca649
$ git diff 36c7db

# 方法一缩略形式
$ git diff $(git merge-base dev master)

# 方法二
$ git diff master...dev

git log

输出历史。

1
$ git log

也可以部分输出、格式化输出、获取统计信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git log -n 3                  # 显示最新三次提交
$ git log --patch -2 # 显示最近两次提交所引入的差异(按补丁的格式输出)
$ git log --oneline # 每个提交只显示一行概述信息
$ git log --format=fuller # 每个提交显示细节信息
$ git log --stat # 显示被修改的文件
$ git log --stat -1 # 显示某个提交具体内容
$ git log --dirstat # 显示包含修改文件的目录
$ git log --shortstat # 显示被修改的文件数
$ git log --graph --decorate # 可视化显示,decorate 用于展示各个分支/标签所指对象
$ git log --since="2008-01-15" # 显示 2008/01/15 之后的提交(--after 也可以)
$ git log --until="2008-01-15" # 显示 2008/01/15 之前的提交(--before 也可以)
$ git log --no-merges # 显示合并提交以外的提交
$ git log -S ZLIB_BUF_MAX # 显示新增和删除该字符串(ZLIB_BUF_MAX)的提交(-S 又称 pickaxe)
$ git log -L :func:main.c # 显示 main.c 中 func 函数的每一次变更

git reflog

当你在工作时, git 会在后台保存一个引用日志(reflog), 引用日志记录了最近几个月 HEAD 和分支引用所指向的历史。

1
2
3
4
5
6
7
8
$ git reflog
734713b HEAD@{0}: commit: fixed refs handling, added gc auto, updated
d921970 HEAD@{1}: merge phedders/rdocs: Merge made by the 'recursive' strategy.
1c002dd HEAD@{2}: commit: added some blame and merge stuff
1c36188 HEAD@{3}: rebase -i (squash): updating HEAD
95df984 HEAD@{4}: commit: # This is a combination of two commits.
1c36188 HEAD@{5}: rebase -i (squash): updating HEAD
7e05da5 HEAD@{6}: rebase -i (pick): updating HEAD

可以使用 @{} 语法来引用提交。

1
2
3
$ git show HEAD@{5} # 查看 HEAD 在 5 次前所指向的提交
$ git show master@{yesterday} # 查看 master 分支在昨天所指向的提交
$ git show HEAD@{2.months.ago} # 查看 HEAD 两个月前所指向的提交(当克隆项目至少两个月)

每当 HEAD 所指向的位置发生了变化,git 就会把这个信息存储到引用日志中。要注意的是,它只是记录了我们在「自己」仓库里做什么的「本地」日志,比如当我们新克隆一个仓库时,引用日志就是空的。

git reset

重置暂存区,第一个参数为重置目标,第二个参数为需要被重置的文件。

1
2
3
$ git reset HEAD . # 将暂存区所有内容重置为版本库中的 HEAD 版本
$ git reset eb43bf . # 将暂存区所有内容重置为版本库中提交 eb43bf 对应的版本
$ git reset HEAD foo.txt src/test/ # 将暂存区指定文件和目录重置为版本库中的 HEAD 版本

适用场景有,当我们修改了两个文件并且想要将它们作为两次独立的提交,却不小心将它俩都 git add 到了暂存区,这时我们就可以使用 git reset 来把其中一个文件从暂存区先撤出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ git add *

$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage) # git status 给我们的提示

renamed: README.md -> README # 改了 README
modified: CONTRIBUTING.md # 改了 CONTRIBUTING

$ git reset HEAD CONTRIBUTING.md # 取消暂存 CONTRIBUTING
Unstaged changes after reset:
M CONTRIBUTING.md

$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

renamed: README.md -> README

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: CONTRIBUTING.md # 现在 CONTRIBUTING 就是修改未暂存的状态了

* 补充一个知识点

在上面的场景中,如果我们后续并不想保留 CONTRIBUTING.md 文件的工作区修改,我们可以使用 git checkout -- fileName 来将其还原成上次提交的样子(git status 也给了我们提示)。

1
2
3
4
5
6
7
$ git checkout -- CONTRIBUTING.md
$ git status
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

renamed: README.md -> README # 现在 CONTRIBUTING 就既没有修改也没有暂存了

* 补充一个详细例子

pro git book 7.7 中的重置揭秘。

git revert

场景:假设现在在一个主题分支上工作,不小心将其合并到 master 中,如下,现在我们想要撤销这次合并。

方法一,修复引用,使用 git reset --hard HEAD~ 重置分支,如下。

方法二,还原提交,也就是现在的主题。

1
2
$ git revert -m 1 HEAD
[master b1d8379] Revert "Merge branch 'topic'"

merge 后的新节点有两个父节点,第一个是 HEAD(C6),第二个是将要合并入分支的最新提交(C4)。这里的 -m 1 指出保留 mainline 1 的父节点,也就是撤销由父节点 2(C4)合并引入的修改,保留从父节点 1(C6) 开始的所有内容。结果如下:

^MC6 有完全一样的内容,从这儿开始就像合并从来没发生过,但是还是有一点不一样的。比如我们想再次将 topic 合并到 master,会出现下面的结果:

1
2
$ git merge topic
Already up-to-date.

看到这里我们会觉得,居然是 already up-to-date?没错,topic 中并没有东西不能从 master 中追踪到达。更加糟糕的是,如果我们在 topic 上增加工作后再合并,git 只会引入被还原的合并之后的修改。如下图,这里只合入了 C7

解决办法是撤销还原原始的合并,因为我们此时想引入被还原出去的修改(C3C4),然后再创建新的合并提交。

1
2
3
$ git revert ^M
[master 09f0126] Revert "Revert "Merge branch 'topic'""
$ git merge topic

结果如下图,这里 M^M 抵消了。^^M 合并了 C3C4 的修改,C8 合并了 C7 的修改,现在的 topic 已经完全被合并了。

git stash

暂时将工作区和暂存区的内容保存到本地储藏栈(stash stack)。

1
$ git stash

适用场景一般有,当我们在工作区处理某个事情到一半,突然发现要快速修复某个问题。此时我们希望立即去着手做相关修改,同时先不提交之前一直在做的事情。

1
2
3
4
5
6
7
8
9
10
11
12
$ git stash list # 检查当前储藏了什么修改内容
stash@{0}: WIP on master: 297432e Mindmap updated
stash@{1}: WIP on master: 213e335 Introduction to workflow

$ git stash pop # 恢复位于栈顶的修改,并将其从栈里删除
$ git stash apply # 恢复位于栈顶的修改,并将其继续保存在栈里

$ git stash apply --index # 恢复位于栈顶的修改,重新暂存之前被暂存的文件(如果没有 --index 则只是将所有改动恢复到工作区,不会将之前暂存的文件重新暂存)

$ git stash apply stash@{1} # 恢复更早之前储藏的修改

$ git stash drop stash@{0} # 从栈中移除该储藏的工作

压栈的时候,有可能部分修改已暂存部分修改未暂存,上面也展示了怎样重新暂存(使用 --index)。只要是已跟踪的文件,它都会保存;如果指定 --include-untracked-u 选项,则会额外地把未跟踪文件也贮藏起来;如果指定 --all-a 选项,则会更额外地把明确忽略的文件也贮藏起来。

有一种情况,当贮藏了一些修改,在工作区对有交集的文件进行了一定的修改,之后重新应用时候可能会有合并冲突。这时有两种解决方法,一是慢慢解决冲突,二是可以使用 git stash branch <branchName> 来以指定的分支名创建一个新分支,检出贮藏工作时所在的提交,重新应用,应用成功后再丢弃贮藏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ git stash branch testchanges
M index.html
M lib/simplegit.rb
Switched to a new branch 'testchanges'
On branch testchanges
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

modified: index.html

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: lib/simplegit.rb

Dropped refs/stash@{0} (29d385a81d163dfd45a452a2ce816487a6b8b014)

git clean

用来清理工作目录。它被设计为从工作目录中移除没有被忽略的未跟踪的文件,如果突然改变主意不一定能找回之前的文件内容,因此使用时候要谨慎。

1
2
# 移除工作目录中所有未追踪的文件和空的子目录
$ git clean -d

为了安全起见,可以使用 --dry-run-n 选项,它会告诉我们如果这么做,将会移除哪些文件,相当于一次演练。

1
2
3
$ git clean -d -n
Would remove test.o
Would remove tmp/

默认情况下,它不会去移除已被 .gitignore 忽略的文件,如果想要移除那些文件,可以使用 -x 选项。

1
2
3
4
5
6
7
8
$ git clean -n -d
Would remove build.TMP
Would remove tmp/

$ git clean -n -d -x
Would remove build.TMP
Would remove test.o
Would remove tmp/

git fsck

哈希值是根据提交里的其他信息计算出来的,不同提交基本上不会计算出相同的哈希值($2^{160}$种可能性)。可以通过一个命令来查看版本库的完整性,也就是验证一下当前存储的哈希值是否和计算值一致。

1
$ git fsck

当使用 --full 选项时,它会显示所有没有被其它对象所指向的对象。

协同操作

git clone

1
$ git clone repositoryUrl projectDirectory

git pull

1
$ git pull

这个操作把原版本库的修改拉取过来,并与本地的修改进行对比,再合并(merge)两边的修改,创建新的提交。之所以知道原版本库的地址,是因为本地版本库将路径存到了 .git 中。 如果没冲突,就可以正常合并;如果有冲突,就必须手动操作,再确认要提交哪些修改(这个提交指的就是合并时的提交)。

最后的 log 应该如下。

1
2
3
4
5
6
7
8
hashValue Merge branch `master` of repositoryUrl # 最新的合并产生的提交
*
| \
| * hashValue commit message from others # 对方的提交
* | hashValue commit message of myself # 工作区的提交
| /
* hashValue commit message # 双方提交开始不一致的分岔点
* hashValue commit message # 更加之前的提交

如果想从指定的版本库中拉去内容,需要带参数。

1
$ git pull repositoryUrl branchName

git push

1
$ git push

同样也可以指定推送到哪个版本库,需要加参数。

1
2
3
4
5
6
7
8
# 下面的三条命令都是等价的

# 缩写形式
$ git push repositoryUrl branchName

# 展开形式(有两种)
$ git push repositoryUrl refs/heads/branchName:refs/heads/branchName
$ git push repositoryUrl branchName:branchName

push 也有可能被拒绝,当被推送的版本库中有我们本地没有的提交时。这个时候就要用 pullfetch 将这个提交拉到本地,在本地合并好后再 push

git remote

可以给常用的 repository url 起别名,这样之后就不用输入完整 url 了。

1
2
$ git remote add specialRepoName file:///tmp/git-book-clone.git
$ git clone specialRepoName

还可以修改别名。

1
$ git remote rename specialRepoName newRepoName

当然也可以删除别名。

1
$ git remote rm specialRepoName # 或 rm -> remove

可以用 --verbose 选项看版本库中存储的,用于获取或推送提交的路径(感觉有点类似 list)。其中 origin 是当我们使用 git clone 命令时,git 给我们克隆的仓库服务器的默认名字。

1
2
3
$ git remote --verbose
origin ssh://stachi@server.de:git-book.git (fetch)
origin git@github.com:rpreissel/git-workflows.git (push)

还可以附加 show 指令来查看某个远程仓库的详细信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ git remote show origin

* remote origin
URL: https://github.com/my-org/complex-project
Fetch URL: https://github.com/my-org/complex-project
Push URL: https://github.com/my-org/complex-project
# 告诉我们当前处于 master 分支
HEAD branch: master
Remote branches:
master tracked
dev-branch tracked
markdown-strip tracked
issue-43 new (next fetch will store in remotes/origin)
issue-45 new (next fetch will store in remotes/origin)
refs/remotes/origin/issue-11 stale (use 'git remote prune' to remove)
# 告诉我们如果运行 git pull 哪些本地分支可以和它跟踪的远程分支自动合并
Local branches configured for 'git pull':
dev-branch merges with remote dev-branch
master merges with remote master
# 告诉我们在特定分支上运行 git push 会自动推送到哪个远程分支
Local refs configured for 'git push':
dev-branch pushes to dev-branch (up to date)
markdown-strip pushes to markdown-strip (up to date)
master pushes to master (up to date)

裸版本库

想要 push 能够成功,必须保证这个被推送到的仓库里没有开发者在上面开展具体工作(即在当下不容易产生新的提交),最好的办法就是创建一个不带工作区的版本库(.git 文件),也叫裸版本库(bare),它可以充当开发者们传递提交(通过 push)的汇聚点。

1
$ git clone --bare repositoryUrl bareRepositoryUrl

关于祖先引用

祖先引用是另一种指明一个提交的方式,当使用 ^ 时,表示的是 parents;当使用 ~ 时,表示的是 ancestors。Stackoverflow 有个提问的图解可以参考。

1
2
3
4
5
$ git show HEAD^ # HEAD 的父提交

# ^ 加数字指明想要哪一个父提交,只适用于合并提交(因为有多个父提交)
$ git show d921970^ # d921970 的第一父提交,即合并时所在的分支
$ git show d921970^2 # d921970 的第二父提交,即所合并的分支
1
2
3
4
5
$ git show HEAD~ # HEAD 的父提交

# ~ 加数字指明获取几次第一父提交
$ git show HEAD~2 # HEAD 的第一父提交的第一父提交,即祖父提交
$ git show HEAD~~~ # HEAD 的第一父提交的第一父提交的第一父提交

这两个语法可以组合使用,比如 HEAD~3^2 表示 HEAD 的第一父提交的第一父提交的第一父提交的第二父提交。

关于提交区间

提交区间用于指定一个区间的提交,以下图为例介绍几种语法。

双点

使用 ..,左右分别放分支名,当留空了其中的一边时,git 会默认为 HEAD

1
2
3
4
5
6
7
8
9
10
11
12
13
# 在 experiment 分支中而不在 master 分支中的提交
$ git log master..experiment
D
C

# 在 master 分支中而不在 experiment 分支中的提交
$ git log experiment..master
F
E

# 在当前分支中而不在远程 origin 中的提交
$ git log origin/master..HEAD
$ git log origin/master..

多点

使用 ^--not 来指明不希望提交被包含在某分支。

1
2
3
4
5
6
7
8
# 在 refB 分支中而不在 refA 分支中的提交
$ git log refA..refB
$ git log ^refA refB
$ git log refB --not refA

# 被 refA 或 refB 包含的但是不被 refC 包含的提交
$ git log refA refB ^refC
$ git log refA refB --not refC

三点

使用 ...,可以选择出被两个引用之一包含但又不被二者同时包含的提交。

1
2
3
4
5
6
7
8
9
10
11
12
13
# master 或 experiment 中包含的但不是二者共有的提交
$ git log master...experiment
F
E
D
C

# 使用 --left-right 参数可以显示每个提交处于哪一侧的分支,会更加清楚
$ git log --left-right master...experiment
< F
< E
> D
> C

.gitignore

用来忽略不需要版本控制的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# .gitignore file

# 以 / 开头可用防止递归,以 / 结尾可用指定目录
/a/file.txt # 确切文件
a/file.txt # 不确切文件,可匹配 dir1/a/file.txt 或 dir2/a/file.txt
/a/directory/ # 确切路径
a/directory/ # 不确切路径,可匹配 dir1/a/directory/ 或 dir2/a/directory/

# (*)匹配零个或多个任意字符,(?)匹配一个任意字符,(**)匹配任意中间目录
doc/*.txt # 忽略 doc/notes.txt,但不忽略 doc/server/arch.txt
doc/**/*.pdf # 忽略 doc/ 目录及其所有子目录下的 .pdf 文件

*.bak # 忽略所有以 .bak 为后缀的文件
!demo.bak # 不忽略 demo.bak 文件

*.[oa] # 忽略所有以 .o 或 .a 结尾的文件

也可以通过把 .gitignore 放到某个文件夹里,来控制某个文件夹下要忽略哪些内容。

还要注意的是,它只能忽略当前还不存在于版本控制下的文件。如果某个符合忽略条件的文件已经在版本库中了,那它的改变还是会被记录和存储,无法忽略,除非用 update-index 命令的 --assume-unchanged 选项。

.gitattributes

用来针对特定的路径配置某些设置项,比如可以用来对不同文件定义不同的合并策略、让 git 知道怎么比较非文本文件、让 git 在提交或检出前过滤内容等。下面举几个例子。

  • 识别二进制文件

比如有些文件表面是文本文件,但是实际上应该被作为二进制文件处理。也就是我们不应该用文本的区别来决定怎么合并,它本应被机器处理。

1
2
# 把 .pbxproj 当作二进制文件。
*.pbxproj binary
  • 比较二进制文件

比如对一个 word 文档做了修改,但是 diff 对常规二进制文档来说,只能知道他们不一样,但是完全看不出具体区别。不过所幸 git 有 word 过滤器,把文件设置为它就可以以 word 的姿势识别文件区别。

1
2
# 给 .docx 使用 word 过滤器
*.docx diff=word

上面的 .gitattributes 生效的前提是,要下载一个过滤器(eg. docx2txt),安装其并将其设置为 word 过滤器,它就可以把 word 文本转换成文本文件进行比较了。

1
2
3
4
5
6
7
8
9
10
11
# 1. 按照 INSTALL 将其下载放到可执行路径下

# 2. 在可执行路径下,写个脚本把输出结果包装成 git 支持的格式,脚本叫 docx2txt,内容如下
#!/bin/bash
docx2txt.pl "$1" -

# 3. 给文件添加可执行权限
$ chmod a+x docx2txt

# 4. 配置
$ git config diff.word.textconv docx2txt
1
2
3
4
5
# 之前
$ git diff
diff --git a/chapter1.docx b/chapter1.docx
index 88839c4..4afcb7c 100644
Binary files a/chapter1.docx and b/chapter1.docx diffe
1
2
3
4
5
6
7
8
9
10
# 之后
$ git diff
diff --git a/chapter1.docx b/chapter1.docx
index 0b013ca..ba25db5 100644
--- a/chapter1.docx
+++ b/chapter1.docx
@@ -2,6 +2,7 @@
This chapter will be about getting started with Git...
+Testing: 1, 2, 3. # 识别出了 word 中添加的文字
If you are a graphic or web designer and want to keep every version of an image or layout (which you would most certainly want to), a Version Control System (VCS) is a very wise thing to use....
  • 导出版本库

比如当项目归档时,我们不希望一些文件或目录被包含在导出的压缩包(tarball)中。

1
2
# 归档时不导出 test/
test/ export-ignore
  • 合并策略

比如指定某些文件的合并策略。

1
2
# 让 database.xml 使用 ours 的合并策略
database.xml merge=ours

上面的 .gitattributes 生效的前提是,设置一个叫 ours 的合并策略。

1
$ git config --global merge.ours.driver true

复杂操作

关于 branch

对于过去大多数版本控制系统,它们在创建分支时,都是将所有的项目文件都复制一遍,并保存到一个特定的目录。而 git 的分支实质上仅是包含所指对象校验和(长度为 40 的 SHA-1 值字符串),因此它的创建和销毁都非常高效,比如创建一个分支只需要往一个文件中写入 41 个字节(40 个字符和 1 个换行符)。

查看分支

1
2
3
4
$ git branch
aBranch
* master # 当前活跃分支
bBranch

可以加 -v 来查看每个分支的最后一次提交。

1
2
3
4
$ git branch -v
aBranch 93b412c fix: xxx
* master 7a98805 Merge branch 'aBranch'
bBranch 782fd34 refactor: xxx

可以使用 --merged--no-merged 来过滤列表中已经合并/尚未合并到当前分支的分支。

1
2
3
4
5
6
$ git branch --merged # 这里除了带 * 的,其它通常都可以直接删掉,因为已经合入了
aBranch
* master

$ git branch --no-merged # 这里的如果想要删掉,则会有警告提示,因为含还未合并的内容
bBranch

可以加 -r 来查看远程跟踪分支。远程跟踪分支是 fetch 时 git 设置的书签,该书签指向抓取目标分支在其它版本库中的位置。默认情况下,git clone 命令会自动设置本地 master 分支跟踪克隆的远程仓库的 master 分支。

1
2
3
4
5
6
$ git branch -r
clone/feature-a # 远程跟踪分支
clone/master # 远程跟踪分支
origin/HEAD -> origin/master
origin/feature-a
origin/master

可以加 -vv 来查看设置的所有远程跟踪分支、分支的领先落后关系等。不过要注意,这些信息来自上次从服务器上抓取的数据,它并没有连接服务器,而是读取了本地缓存的服务器数据,因此如果想要统计最新的领先落后数据,则需要先用 git fetch --all 再抓取一遍。

1
2
3
4
5
6
7
8
9
10
$ git branch -vv

# 本地有 2 个提交暂未推送到服务器
iss53 7e424c3 [origin/iss53: ahead 2] forgot the brackets
# master 跟踪 origin/master 并且是最新的
master 1ae2a45 [origin/master] deploying index fix
# 服务器上有 1 个提交暂未合并过来,同时本地也有 3 个提交暂未推送到服务器
* serverfix f8674d9 [teamone/server-fix-good: ahead 3, behind 1] this should do it
# 并没有跟踪任何远程分支
testing 5ea463a trying something new

* 如何知道当前在哪个分支上呢?

git 通过一个名为 HEAD 的特殊指针来判断,它指向的是当前所在的本地分支。当使用 git checkout 进行分支切换的时候,移动的就是 HEAD 指针。当在目前分支有了新的提交后,该分支向前移动,HEAD 指针也会随之自动向前移动。

切换分支

1
$ git checkout aBranch

* 当尝试切换的分支不存在且刚好只有一个名字与之匹配的远程分支,那么 git 就会同时创建一个跟踪分支。

创建分支

1
2
3
4
$ git branch newBranch # 为当前提交创建分支
$ git branch newBranch hashValue # 为任意一批提交创建新分支
$ git branch newBranch tagName # 从某个标签检出一个新分支
$ git branch newBranch aBranch # 从某个分支检出一个新分支

创建并切换分支

1
$ git checkout -b newBranch baseName # 这里的 baseName 就是新分支基于的地方

加入想要在本地创建一个从跟踪远端分支的新分支,还有一种快捷方式,下面的两个命令等价。

1
2
$ git checkout -b dev origin/dev
$ git checkout --track origin/dev

删除分支

1
2
3
4
5
6
# 删除本地
$ git branch -d aBranch # 删除一个被终止的分支
$ git branch -D aBranch # 删除一个打开的分支(也就是企图删除目前所处分支)
# 删除远程
$ git push repositoryUrl :aBranch # 冒号左边为空,表示将该分支设置为不指向任何地方
$ git push repositoryUrl --delete aBranch # 显式删除远程分支

恢复分支

1
2
3
4
5
6
7
8
9
10
11
# 已知哈希值时
$ git branch aBranch hashValue

# 未知哈希值时
$ git reflog
d765a1e HEAD@{0}: checkout: moving from bBranch to master
88117f6 HEAD@{1}: merge bBranch: Fast-forward
9332b08 HEAD@{2}: checkout: moving from aBranch to bBranch
441cdef HEAD@{3}: commit: Expanded important stuff
29146e6 HEAD@{4}: clone: from https://xxx.xxx.xxx/xxx.git
$ git branch aBranch HEAD@{1}

当删除一个分支时,git 只是删除了指向相关提交的指针,但该提交对象依然在版本库中,因此只要知道这个提交的哈希值,就可以把已删除的分支恢复过来。reflog 用于查看 git 中存储的,每次提交中对分支指针做出的修改。

修改跟踪分支

1
$ git branch -u origin/aBranch

为本地分支修改正在跟踪的上游分支。

* 设置好跟踪分治后可以通过 @{upstream}@{u} 来引用上游分支,比如想要 git merge origin/master 时,可以使用命令 git merge @{u}

清理提交对象

1
$ git gc

移除不属于当前分支的提交对象。

重置分支指针

1
$ git reset --hard hashValue

分支指针用于指向活动分支,每次提交时它都会移动到最新提交上。上例将指针重置到了提交 hashValue 所在的活动分支上,--hard 用于确保工作区和暂存区指针都在该提交上。

关于 merge

merge 的本质是将两条线上的不同修改给都包括进来,产生一个新的提交。图示如下,这里的 F 就是新的提交。

至于合并的方法,就是找到最后一个共同的祖辈提交,和两条线上的两个版本作对比,以确定最终的合并结果。图示如下,思路还是很显而易见的。git 给找共同的提交父辈提供了几种算法:递归算法、三路算法、octopus 算法。

当然会存在同时修改了同一处的问题,也就是冲突。

冲突前后

遇到冲突会有如下显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git merge aBranch
Auto-merging foo.txt
CONFLICT (content): Merge conflict in foo.txt
Automatic merge failed; fix confilct and then commit the result.

$ git status
On branch master
You have unmerged paths/
(fix conflicts and run "git commit")

Changes to be committed: # 自动合并的文件,已被加到暂存区
modified: blah.txt

Unmerged paths:
(use "git add <file>..." to mark resolution) # 有冲突的文件,要手动合并,不在暂存区
both modified: foo.txt

这里有几个要注意的:

  • git 通常会在合并之后自动创建提交,不过这种情况下不行,要手动解决冲突再手动提交
  • .git/MERGE_HEAD 中保存了另一分支的提交哈希值(是另一分支提交而不是最后合并的提交!)
  • 工作区内容为合并结果

字符串文本的冲突部分会有冲突标志,显示如下。解决这种冲突可以直接手动编辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 默认两路显示
In the early morning dew
<<<<<<< HEAD
to the valley # 当前分支 HEAD 中的内容
=======
to the town # 另外一个分支 MERGE_HEAD 中的内容
>>>>>>> aBranch
We are going to Fallera!

# 可配置成三路显示: `git config merge.conflictstyle diff3`
In the early morning dew
<<<<<<< HEAD
to the valley # 当前分支 HEAD 中的内容
||||||| merged common ancestors
to the mountain # 共同祖辈中的内容
=======
to the town # 另外一个分支 MERGE_HEAD 中的内容
>>>>>>> aBranch
We are going to Fallera!

二进制文本的冲突则没有冲突标志,而是需要我们区查看原始版本的并拷贝一下。解决这种冲突就没那么方便了,具体如下。

1
2
3
4
5
6
$ git show :1:picture.png > ancestor.png # 祖辈版本
$ git show :2:picture.png > ours.png # 当前分支版本
$ git show :3:picture.png > theris.png # 其它分支版本

$ git checkout --theirs tests/ # 采用自己的版本的文件
$ git checkout --ours tests/ # 采用别人的版本的文件

解决完冲突就可以快乐地 git add 注册修改并进行 git merge/rebase --continue 来继续(或者不好的做法是 git commit 提交修改)。

合并完冲突后可以通过 git diff 看自己和对方引入的修改。

1
2
3
$ git diff --ours   # 查看这次合并中我们引入的改动
$ git diff --theirs # 查看这次合并中对方引入的改动
$ git diff --base # 查看这次合并中双方引入的改动

如果在合并或解决冲突时出错了,可以取消合并,这样工作区就不会有我们合并的踪迹,git 中也不会在下轮提交中出席那合并提交。

1
2
3
$ git reset --merge
#
$ git merge --abort

快速合并

当 git 在合并时候发现俩分支中只有一个在持续工作,那么这个合并就只用移动指针,而不需要产生合并提交了,这就是快速合并,也是默认的行为。图示如下。

它的优点是简化了历史记录,使其线性发展;缺点是无法呈现合并历史,因此有些人可能会想要非 fast forward 的,操作如下。

压缩合并

1
2
$ git checkout featureBv2 origin/master
$ git merge --squash featureB

--squash 选项接受被合并分支上的所有工作,并将其压缩至一个变更集,使仓库变成一个真正的合并发生的状态,而不会真的生成一个合并提交。这意味着你的未来的提交将会只有一个父提交,并允许你引入另一个分支的所有改动, 然后在记录一个新提交前做更多的改动。参考 pro git book 这里的 Figure 72 处。

查看历史

log 命令中使用 .. 可以了解分支的不同之处。比如 a..b 表示来自于分支 b 但不属于分支 a 的提交。

1
2
$ git log MERGE_HEAD..HEAD # 我们在分支上做的事情
$ git log HEAD..MERGE_HEAD # 别人在分支上做的事情

使用 merge-base 命令配合 diff 命令也可以了解分支的不同。

1
2
3
4
$ git merge-base HEAD MERGE_HEAD    # 获取共同祖先提交
ed3b18xxxxxxxx
$ git diff --stat ed3b18 HEAD # 我们在分支上做的事情
$ git diff --stat ed3b18 MERGE_HEAD # 别人在分支上做的事情

log 命令中使用 --merge 可以限制只输出合并提交。

1
$ git log --merge

关于 rebase

rebase 的本质是将想要移动的提交序列在目标分支上,按照同样的顺序重现一遍,图示如下。

这里 git 做了几件事:

  • 确认涉及到哪些提交:即在 feature-a 上而不在 master 上的 CD
  • 确认目标位置:即 masterfeature-a 将要执行变基操作的目标提交 B
  • 复制提交:以目标提交为基础重演提交,创建相应的 C'D'
  • 重置分支:将 feature-a 分支移动到上述被复制的提交的顶部,即 D'

原来的 CDgc 之前还在版本库中,只是不可见,但依然可以通过哈希值访问。这里当然也可以用 merge,但是会产生钻石链,不够线性和清晰。

书里说通常不会直接用 git rebase,而用 git pull --rebase来将远程版本库中的修改进行变基处理,不过我还是觉得不用任何 pull 比较好。pro git book 里关于变基的这一章也很有参考意义,讲了在某种情况下使用 rebase 可能产生的错误。

冲突前后

rebase 的冲突和 merge 的冲突有区别:merge 过程中,得到的是两个分支合体后的单一提交;rebase 过程中,我们依次执行重复的几次提交,顺利情况下最后一次提交的结果会和 merge 一样,不顺利情况下则会一直被打断,需要手动解决冲突、将它们加到暂存区、再用 rebase --continue 来继续。

1
2
3
4
5
6
7
8
9
10
# 解决冲突
$ git add foo.txt
$ git add bar.txt
$ git rebase --continue

# 取消这次rebase
$ git rebase --abort

# 跳过引起冲突的提交
$ git rebase --skip

移植分支

图示如下,这里将 feature-a 分支移植到了 release1 分支上。

这里 git 先确认 feature-a 上所有不属于原分支 master 上的所有提交(即这里的 EF),再通过 --onto 将这些提交复制到指定位置上(即这里的 release1 分支)。

关于 fetch

fetch 的本质是从另一个版本库获取本地版本库中不存在的提交。图示如下,这里 ABC 都在本地有,所以不用取,DE 都在本地没有,所以要取。

不指定参数的情况下会获取所有分支的提交,指定参数则可以只获取某个分支的提交。

1
$ git fetch clone feature-b:my-feature-b

这里获取了 clone 版本库的 feature-b 分支内容,如果本地没有 my-feature-b 分支则创建一个,如果本地有则对其进行更新。

* fetch、pull、push 都可以用冒号来重命名/转换分支。

关于 pull

pull = fetch + mergepull --rebase = pull + rebase,图示如下。

关于 tag

标签一般用来标识版本,我感觉是给某分支的某个提交起个别名。

创建标签

有两种标签,轻量标签(lightweight)和附注标签(annotated)。轻量标签只是某个特定提交的引用,本质是将“提交校验和”存储到一个文件中,而没有存储任何其它信息;附注标签则是存储在版本库中的一个完整的标签对象,含打标签者名字、邮件地址、日期时间、标签信息,它还有自己的哈希值,是可以使用 GNU Privacy Guard(GPG)签名并被校验的。

1
2
3
4
5
6
# 轻量标签
$ git tag 1.2.3.5

# 附注标签
# 给 master 当前版本创建一个标签,名为 1.2.3.4,注释为 "Freshly built"
$ git tag -a 1.2.3.4 master -m "Freshly built"

还可以对过去的提交补打标签,只要找到该提交的哈希值即可。

1
$ git tag -a v1.2 9fceb02

删除标签

1
2
3
4
5
6
# 删除本地标签
$ git tag -d v1.2

# 删除远程标签
$ git push origin :refs/tags/v1.2 # 方法一,用冒号前面的空值推送到远程标签名
$ git push origin --delete v1.2 # 方法二,显式删除标签

推送标签

1
2
$ git push origin 1.2.3.4 # 推送一个标签
$ git push --tags # 推送所有标签

推送操作通常不会自动传送标签,上面两个方法可以把本地设置的标签给传送过去,过程就像推送远程分支一样。

列出所有标签

1
2
3
4
5
6
7
8
9
10
11
12
$ git tag          # 列出所有标签
$ git tag -l 1.2.* # 列出匹配上的标签
1.2.0.0 Beginning.
...
1.2.3.3 New build.
1.2.3.4 Freshly built.

$ git log --decorate # 带上 --decorate 打印日志就能带上标签了
cef89bb (HEAD, tag: 1.2.3.4) Again, everything rebuilt.
9d4caed Merge branch 'Other'.
ded1c6c Changed.
cce1a68 (tag: 1.2.3.3) Something changed.

查看某个标签

1
2
3
4
5
$ git show-ref --dereference --tags
f63cd7xxxxxx refs/tags/1.2.3.3
cef89bxxxxxx refs/tags/1.2.3.3^{}
4a0228xxxxxx refs/tags/1.2.3.4
cef89bxxxxxx refs/tags/1.2.3.4^{}

show-ref 指令加上 --tags 用来列出标签对象的提交哈希值,--dereference 则是打印相应提交对象的哈希值,它带有 ^{} 标记。

获取提交所在标签

1
2
3
$ git tag --contains f63cd71
1.2.3.3
1.2.3.4

列出历史记录中包含该提交的所有标签,通常用来判断某一 feat 或 bugfix 是否被包含在某个版本中。