git 常用命令


git clone

克隆 git 仓库的代码
在这里插入图片描述
复制以上的任一克隆地址,后续使用 git clone 命令即可克隆 git 仓库的代码了

zzz@ubuntu-GCP1820-SS:work$ git clone http:xxxxxxx
Cloning into 'SIC2.0'...
Username for 'http://192.168.1.8': zzz
Password for 'http://zzz@192.168.1.8': 
remote: Enumerating objects: 404, done.
remote: Counting objects: 100% (401/401), done.
remote: Compressing objects: 100% (284/284), done.
remote: Total 404 (delta 148), reused 342 (delta 98), pack-reused 3
Receiving objects: 100% (404/404), 126.38 MiB | 21.47 MiB/s, done.
Resolving deltas: 100% (148/148), done.
Checking connectivity... done.
Checking out files: 100% (196/196), done.
zzz@ubuntu-GCP1820-SS:work$ ls -lhtr
total 7.0M
drwxr-xr-x  2 zzz zzz 4.0K 43 12:09 mnt
drwxr-xr-x  2 zzz zzz 4.0K 43 12:09 media
drwxr-xr-x  5 zzz zzz 4.0K 43 12:09 lib
drwxr-xr-x  2 zzz zzz 4.0K 43 12:09 dev
drwxr-xr-x  2 zzz zzz 4.0K 43 12:09 boot
drwxr-xr-x  3 zzz zzz 4.0K 47 17:38 home
drwxr-xr-x  2 zzz zzz 4.0K 47 17:38 bin
drwxr-xr-x  2 zzz zzz 4.0K 47 17:38 sbin
drwxr-xr-x 23 zzz zzz 4.0K 47 17:38 etc
-rw-r--r--  1 zzz zzz 7.0M 47 17:53 rootfs.tar.gz
drwxrwxr-x  5 zzz zzz 4.0K 412 15:05 xxxx.0
zzz@ubuntu-GCP1820-SS:work$ 

一、git 初始化

1. 设置用户名和邮箱地址

git config --global user.name

git config --global user.email

MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config -h
usage: git config [<options>]

Config file location
    --global              use global config file
    --system              use system config file
    --local               use repository config file
    --worktree            use per-worktree config file
    -f, --file <file>     use given config file
    --blob <blob-id>      read config from given blob object
    ......

MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config --global user.name   "admin"

MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config --global user.email  123456789@qq.com


MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config --global user.name
admin

MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config --global user.email
123456789@qq.com

执行下面的命令,删除Git全局配置文件中关于user.name和user.email的设置:

$ git config --unset --global user.name
$ git config --unset --global user.email

这下关于用户姓名和邮件的设置都被清空了,执行下面的命令将看不到输出。

$ git config user.name
$ git config user.email

2. 设置Git命令别名

如果拥有系统管理员权限(可以执行:command:sudo命令),希望注册的命令别
名能够被所有用户使用,可以执行如下命令:

$ sudo git config --system alias.br branch
$ sudo git config --system alias.ci "commit -s"
$ sudo git config --system alias.co checkout
$ sudo git config --system alias.st "-p status"

也可以运行下面的命令,只在本用户的全局配置中添加Git命令别名:

$ git config --global alias.st status
$ git config --global alias.ci "commit -s"
$ git config --global alias.co checkout
$ git config --global alias.br branch

3. 初始化git版本库

为了方便说明,使用了名为 /e/git/workspace/ 的目录作为个人的工作区根目录
在其下建立一个 demo 目录,作为我们的一个使用例子。

MyPC@CY-20210902DXIS MINGW64 /e/git
$ cd workspace/

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace
$ mkdir demo

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace
$ cd demo/

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo
$ git init
Initialized empty Git repository in E:/git/workspace/demo/.git/

MyPC@CY-20210902DXIS MINGW64 /e/git
$ git config --global user.email
123456789@qq.com

.git 目录就是Git版本库(又叫仓库,repository)

git init 命令在工作区创建了隐藏目录 .git。

这个隐藏的 .git 目录就是Git版本库(又叫仓库,repository)。
.git 版本库目录所在的目录,即 /e/git/workspace/demo 目录称为工作区,目前工作区除了包含一个隐藏的 .git 版本库目录外空无一物。

下面为工作区中加点料:在工作区中创建一个文件:file:welcome.txt,内容就是一
行“Hello.”。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo "hello." > welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ cat welcome.txt
hello.

为了将这个新建立的文件添加到版本库,需要执行下面的命令:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git add  welcome.txt
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git commit -m "initialized"
[master (root-commit) 4a462ab] initialized
 1 file changed, 1 insertion(+)
 create mode 100644 welcome.txt

思考:是谁完成的提交?
在本章的一开始,先为Git设置了user.name和user.email全局环境变量,如果不设置会有什么结果呢?
执行下面的命令,删除Git全局配置文件中关于user.name和user.email的设置:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.name
admin

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.email
123456789@qq.com

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --unset --global user.email

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --unset --global user.name

这下关于用户姓名和邮件的设置都被清空了,执行下面的命令将看不到输出。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.name

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.email

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$

git commit --allow-empty: 允许空白提交

下面再尝试进行一次提交,看看提交的过程会有什么不同,以及提交之后显示的提交者是谁?
在下面的命令中使用了–allow-empty参数,这是因为没有对工作区的文件进行任何修
改,Git默认不会执行提交,使用了–allow-empty参数后,允许执行空白提交。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git commit --allow-empty -m "who does this commit?"
Author identity unknown

*** Please tell me who you are.

Run

  git config --global user.email "you@example.com"
  git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: unable to auto-detect email address (got 'MyPC@CY-20210902DXIS.(none)')

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)

喔,因为没有设置user.name和user.email变量,提交输出乱得一塌糊涂。仔细看看上面执行git commit命令的输出,原来Git提供了详细的帮助指引来告诉如何设置必需的变量,以及如何修改之前提交中出现的错误的提交者信息。

看看此时版本库的提交日志。

git log --pretty=fuller

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git log --pretty=fuller
commit 4a462ab4e614d17958932521a36e36baf12ed8dd (HEAD -> master)
Author:     admin <123456789@qq.com>
AuthorDate: Mon Aug 8 18:11:58 2022 +0800
Commit:     admin <123456789@qq.com>
CommitDate: Mon Aug 8 18:11:58 2022 +0800

    initialized

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)

可以看到,我们最新一次的空白提交并没有成功。

为了保证提交时提交者和作者信息的正确性,重新恢复user.name和user.email的设置。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.name   "admin"

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git config --global user.email  123456789@qq.com

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)

再次提交就能成功了。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git commit --allow-empty -m "who does this commit?"
[master b39c089] who does this commit?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git log --pretty=fuller
commit b39c0891e6606310baffcbd01dc5516c08fbd868 (HEAD -> master)
Author:     admin <123456789@qq.com>
AuthorDate: Mon Aug 8 20:32:12 2022 +0800
Commit:     admin <123456789@qq.com>
CommitDate: Mon Aug 8 20:32:12 2022 +0800

    who does this commit?

commit 4a462ab4e614d17958932521a36e36baf12ed8dd
Author:     admin <123456789@qq.com>
AuthorDate: Mon Aug 8 18:11:58 2022 +0800
Commit:     admin <123456789@qq.com>
CommitDate: Mon Aug 8 18:11:58 2022 +0800

    initialized

二、 git 暂存区

在上面的实践中,DEMO版本库经历了两次提交,可以用:command:git log查看提交日志(附加的–stat参数看到每次提交的文件变更统计)。

git log --stat

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git log --stat
commit 817ed41c6f5dd33ea45fdcb52f48111986c6955f (HEAD -> master)
Author: admin <123456789@qq.com>
Date:   Mon Aug 8 20:32:45 2022 +0800

    who does this commit?

commit 4a462ab4e614d17958932521a36e36baf12ed8dd
Author: admin <123456789@qq.com>
Date:   Mon Aug 8 18:11:58 2022 +0800

    initialized

 welcome.txt | 1 +
 1 file changed, 1 insertion(+)

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)

可以看到第一次提交对文件:file:welcome.txt有一行的变更,而第二次提交因为是使用
了–allow-empty参数进行的一次空提交,所以提交说明中看不到任何对实质内容的修改。

1.修改不能直接提交?

首先更改:file:welcome.txt文件,在这个文件后面追加一行。可以使用下面的命令实现
内容的追加。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  echo "Nice to meet you." >> welcome.txt

这时可以通过执行:command:git diff命令看到修改后的文件和版本库中文件的差异。(实际上这句话有问题,和本地比较的不是版本库中的文件,而是一个中间状态的文件)

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git diff
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
diff --git a/welcome.txt b/welcome.txt
index 25735f5..41ea8a4 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1 +1,2 @@
 hello.
+Nice to meet you.

既然文件修改了,那么就提交吧。提交能够成功么?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  git commit -m "Append a nice line."
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   welcome.txt

no changes added to commit (use "git add" and/or "git commit -a")

提交为什么会失败呢?再回过头来仔细看看刚才:command:git commit命令提交失败后的输出:

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

no changes added to commit (use "git add" and/or "git commit -a")

也就是说要对修改的:file:welcome.txt文件执行:command:git add命令,将修改的文件添加到“提交任务”中,然后才能提交!

好了,现在就将修改的文件“添加”到提交任务中吧:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git add welcome.txt
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory

现在再执行一些Git命令,看看当执行“添加”动作后,Git库发生了什么变化:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   welcome.txt

继续修改一下:file:welcome.txt文件(在文件后面再追加一行)。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo "Bye-Bye." >> welcome.txt

然后执行:command:git status,查看一下状态:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   welcome.txt

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

三个区域:工作区、暂存区、版本库

即现在:file:welcome.txt有三个不同的版本,一个在工作区,一个在等待提交的暂存
,还有一个是版本库中最新版本的:file:welcome.txt。通过不同的参数调 用:command:git diff命令可以看到不同版本库:file:welcome.txt文件的差异。

git diff : 比较 工作区 与 暂存区的内容

  • 不带任何选项和参数调用:command:git diff显示工作区最新改动,即工作区和提交任务(提交暂存区,stage)中相比的差异。
MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git diff
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.

git diff HEAD : 比较 工作区 与 HEAD(当前工作分支)

  • 将工作区和HEAD(当前工作分支)相比,会看到更多的差异。
MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git diff HEAD
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
diff --git a/welcome.txt b/welcome.txt
index 25735f5..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1 +1,3 @@
 hello.
+Nice to meet you.
+Bye-Bye.

git diff --cached : 比较 暂存区 与 版本库

  • 通过参数–cached或者–staged参数调用:command:git diff命令,看到的是提交暂存区(提交任务,stage)和版本库中文件的差异。
MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git diff --cached
diff --git a/welcome.txt b/welcome.txt
index 25735f5..41ea8a4 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1 +1,2 @@
 hello.
+Nice to meet you.

好了现在是时候提交了。现在执行:command:git commit命令进行提交。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git commit -m "which version checked in?"
[master b64069c] which version checked in?
 1 file changed, 1 insertion(+)

通过查看提交日志,看到了新的提交。

git log --pretty=oneline

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git log --pretty=oneline
b64069c7b12861f34031fa0c70964130f6769acb (HEAD -> master) which version checked in?
817ed41c6f5dd33ea45fdcb52f48111986c6955f who does this commit?
4a462ab4e614d17958932521a36e36baf12ed8dd initialized

提交的:file:welcome.txt是哪个版本呢?可以通过执行:command:git diff或 者:command:git diff HEAD命令查看差异。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status
On branch master
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   welcome.txt

no changes added to commit (use "git add" and/or "git commit -a")

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git diff
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.

可以看到,被提交的,是加入到暂存区中的"Nice to meet you."的改动版本。


2. 理解 Git 暂存区(stage,index)

首先执行:command:git checkout命令(后面会介绍此命令),撤销工作区中 welcome.txt 文件尚未提交的修改。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s
 M welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git checkout -- welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status
On branch master
nothing to commit, working tree clean

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)

通过状态输出,可以看到工作区已经没有改动了。查看一下:command:.git/index文件,注意该文件的时间戳为: 17:49:04。

ls --full-time .git/index

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  ls --full-time .git/index
-rw-r--r-- 1 MyPC 197121 145 2022-08-09 17:49:04.672959100 +0800 .git/index

再次执行:command:git status命令,然后显示:file:.git/index文件的时间戳为:17:49:04,和上面的一样。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  ls --full-time .git/index
-rw-r--r-- 1 MyPC 197121 145 2022-08-09 17:49:04.672959100 +0800 .git/index

touch welcome.txt :更改文件的时间戳,但是不改变它的内容

现在更改一下 welcome.txt 的时间戳,但是不改变它的内容。然后再执行:command:git status命令,然后查看:file:.git/index文件时间戳为:17:52:43。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  touch welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  ls --full-time .git/index
-rw-r--r-- 1 MyPC 197121 145 2022-08-09 17:52:43.162231900 +0800 .git/index

看到了么,时间戳改变了!

这个试验说明当执行:command:git status命令(或者:command:git diff命令)扫描工作区改动的时候,先依据:file:.git/index文件中记录的(工作区跟踪文件的)时间戳、长度等信息判断工作区文件是否改变。如果工作区的文件时间戳改变,说明文件的内容可能被改变了,需要打开文件,读取文件内容,和更改前的原始文件相比较,判断文件内容是否被更改。如果文件内容没有改变,则将该文件新的时间戳记录到:file:.git/index文件中。

使用时间戳、文件长度等信息进行比较

因为判断文件是否更改,使用时间戳、文件长度等信息进行比较要比通过文件内容比较要快的多,所以Git这样的实现方式可以让工作区状态扫描更快速的执行,这也是Git高效的因素之一。

文件:file:.git/index实际上就是一个包含文件索引的目录树,像是一个虚拟的工作区。在这个虚拟工作区的目录树中,记录了文件名、文件的状态信息(时间戳、文件长度等)。文件的内容并不存储其中,而是保存在Git对象库:file:.git/objects目录中,文件索引建立了文件和对象库中对象实体之间的对应。下面这个图展示了工作区、版本库中的暂存区和版本库之间的关系。

在这里插入图片描述

在这个图中,可以看到部分Git命令是如何影响工作区和暂存区(stage,亦称index)的。

  • 图中左侧为工作区,右侧为版本库。在版本库中标记为index的区域是暂存区(stage,亦称index),标记为master的是master分支所代表的目录树

  • 图中可以看出此时HEAD实际是指向master分支的一个“游标”。所以图示的命令中出现HEAD的地方可以用master来替换。

  • 图中的objects标识的区域为Git的对象库,实际位于:file:.git/objects目录下,会在后面的章节重点介绍。

  • 当对工作区修改(或新增)的文件执行:command:git add命令时,暂存区的目录树被更新,同时工作区修改(或新增)的文件内容被写入到对象库中的一个新的对象中,而该对象的ID被记录在暂存区的文件索引中。

  • 当执行提交操作(:command:git commit)时,暂存区的目录树写到版本库(对象库)中,master分支会做相应的更新。即master最新指向的目录树就是提交时原暂存区的目录树。

  • 当执行:command:git reset HEAD命令时,暂存区的目录树会被重写,被master分支指向的目录树所替换,但是工作区不受影响。

  • 当执行:command:git rm --cached <file>命令时,会直接从暂存区删除文件,工作区则不做出改变。

  • 当执行:command:git checkout .或者:command:git checkout -- <file>命令时,会用暂存区全部或指定的文件替换工作区的文件。这个操作很危险,会清除工作区中未添加到暂存区的改动。

  • 当执行:command:git checkout HEAD .或者:command:git checkout HEAD<file>命令时,会用HEAD指向的master分支中的全部或者部分文件替换暂存区和以及工作区中的文件。这个命令也是极具危险性的,因为不但会清除工作区中未提交的改动,也会清除暂存区中未提交的改动。


三 、git diff魔法

在本章的实践中展示了具有魔法效果的命令::command:git diff。在不同参数的作用下,:command:git diff的输出并不相同。在理解了Git中的工作区、暂存区、和版本库
最新版本(当前分支)分别是三个不同的目录树后,就非常好理解:command:git diff
法般的行为了。


暂存区目录树的浏览

有什么办法能够像查看工作区一样的,直观的查看暂存区以及HEAD当中的目录树么?

对于HEAD(版本库中当前提交)指向的目录树,可以使用Git底层命令:command:git lstree来查看。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git ls-tree -l HEAD
100644 blob 41ea8a4f322ac2a4daafd21b14eaced6fbac7ee7      25    welcome.txt

其中:

  • 使用-l参数,可以显示文件的大小。上面:file:welcome.txt大小为25字节。
  • 输出的:file:welcome.txt文件条目从左至右,第一个字段是文件的属性(rw-r–r–),第二个字段说明是Git对象库中的一个blob对象(文件),第三个字段则是该文件在对象库中对应的ID——一个40位的SHA1哈希值格式的ID(这个会在后面介绍),第四个字段是文件大小,第五个字段是文件名。

在浏览暂存区中的目录树之前,首先清除工作区当中的改动。通过:command:git clean - fd命令清除当前工作区中没有加入版本库的文件和目录(非跟踪文件和目录),然后执行:command:git checkout .命令,用暂存区内容刷新工作区。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git clean -fd

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git checkout .
Updated 0 paths from the index

然后开始在工作区中做出一些修改(修改:file:welcome.txt,增加一个子目录和文
件),然后添加到暂存区。最后再对工作区做出修改。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo "Bye-Bye." >> welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ mkdir -p a/b/c

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  echo "Hello." > a/b/c/hello.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git add .
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
warning: LF will be replaced by CRLF in a/b/c/hello.txt.
The file will have its original line endings in your working directory

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo "Bye-Bye." >> a/b/c/hello.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s
AM a/b/c/hello.txt
M  welcome.txt

上面的命令运行完毕后,通过精简的状态输出,可以看出工作区、暂存区、和版本库当前分支的最新版本(HEAD)各不相同。先来看看工作区中文件的大小:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ find . -path ./.git -prune -o -type f -printf "%-20p\t%s\n"
./a/b/c/hello.txt       16
./welcome.txt           36

git-ls-files - Show information about files in the index and the working tree

要显示暂存区的目录树,可以使用:command:git ls-files命令。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git ls-files -s
100644 18832d35117ef2f013c4009f5b2128dfaeff354f 0       a/b/c/hello.txt
100644 54f1861a6f02a7065d007a105041f04c1630ff14 0       welcome.txt

注意这个输出和之前使用:command:git ls-tree命令输出不一样,如果想要使用:command:git ls-tree命令,需要先将暂存区的目录树写入Git对象库(用:command:git write-tree命令),然后在针对:command:git write-tree命令写入的 tree 执行:command:git ls-tree命令。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git write-tree
4fbde53cc5da8fc00447d7cfae7b82deb5e98170

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git ls-tree -l 4fbde53
040000 tree 53583ee687fbb2e913d18d508aefd512465b2092       -    a
100644 blob 54f1861a6f02a7065d007a105041f04c1630ff14      34    welcome.txt

好了现在工作区,暂存区和HEAD三个目录树的内容各不相同。下面的表格总结了不同文件在三个目录树中的文件大小。

文件名工作区暂存区HEAD
welcome.txt34 字节34 字节25 字节
a/b/c/hello.txt16 字节7 字节0 字节

通过使用不同的参数调用:command:git diff命令,可以对工作区、暂存区、HEAD中的内容两两比较。下面的这个图,展示了不同的:command:git diff命令的作用范围。
在这里插入图片描述
通过上面的图,就不难理解下面:command:git diff命令不同的输出结果了。

git diff

  • 工作区和暂存区比较。
$ git diff
warning: LF will be replaced by CRLF in a/b/c/hello.txt.
The file will have its original line endings in your working directory
diff --git a/a/b/c/hello.txt b/a/b/c/hello.txt
index 18832d3..e8577ea 100644
--- a/a/b/c/hello.txt
+++ b/a/b/c/hello.txt
@@ -1 +1,2 @@
 Hello.
+Bye-Bye.

git diff --cached

  • 暂存区和HEAD比较。
$ git diff --cached
diff --git a/a/b/c/hello.txt b/a/b/c/hello.txt
new file mode 100644
index 0000000..18832d3
--- /dev/null
+++ b/a/b/c/hello.txt
@@ -0,0 +1 @@
+Hello.
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.

git diff HEAD

  • 工作区和HEAD比较。
$ git diff HEAD
warning: LF will be replaced by CRLF in a/b/c/hello.txt.
The file will have its original line endings in your working directory
diff --git a/a/b/c/hello.txt b/a/b/c/hello.txt
new file mode 100644
index 0000000..e8577ea
--- /dev/null
+++ b/a/b/c/hello.txt
@@ -0,0 +1,2 @@
+Hello.
+Bye-Bye.
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.

不要使用:command:git commit -a

实际上Git的提交命令(:command:git commit)可以带上-a参数,对本地所有变更的文件执行提交操作,包括本地修改的文件,删除的文件,但不包括未被版本库跟踪的文件。 这个命令的确可以简化一些操作,减少用:command:git add命令标识变更文件的步骤,但是如果习惯了使用这个“偷懒”的提交命令,就会丢掉Git暂存区带给用户最大的好处:对提交内容进行控制的能力。

有的用户甚至通过别名设置功能,创建指向命令:command:git commit -a的别名ci,这更是不可取的行为,应严格禁止。


搁置问题,暂存状态

查看一下当前工作区的状态。

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   a/b/c/hello.txt
        modified:   welcome.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   a/b/c/hello.txt

在状态输出中Git体贴的告诉了用户如何将加入暂存区的文件从暂存区撤出以便让暂存区和HEAD一致(这样提交就不会发生),还告诉用户对于暂存区更新后在工作区所做的再一次的修改有两个选择:或者再次添加到暂存区,或者取消工作区新做出的改动。但是涉及到的命令现在理解还有些难度,一个是:command:git reset,一个是:command:git checkout。需要先解决什么是HEAD,什么是master分支以及Git对象存储的实现机制等问题,才可以更好的操作暂存区。

为此,保存当前的工作进度,在研究了HEAD和master分支的机制之后,继续对暂存区的探索。命令:command:git stash就是用于保存当前工作进度的。

$ git stash
warning: LF will be replaced by CRLF in a/b/c/hello.txt.
The file will have its original line endings in your working directory
Saved working directory and index state WIP on master: b64069c which version checked in?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status
On branch master
nothing to commit, working tree clean

运行完:command:git stash之后,再查看工作区状态,会看见工作区尚未提交的改动(包括暂存区的改动)全都不见了。


四、git 对象

暂存区(stage,亦称index)

Git的一个最重要的概念:暂存区(stage,亦称index)。暂存区是一个介于工作区和版本库的中间状态,当执行提交时实际上是将暂存区的内容提交到版本库中,而且Git很多命令都会涉及到暂存区的概念,例如::command:git diff命令。


Git对象库探秘

通过查看日志的详尽输出,会惊讶的看到非常多的“魔幻数字”,这些“魔幻数字”实际上是SHA1哈希值。

$ git log -1 --pretty=raw
commit b64069c7b12861f34031fa0c70964130f6769acb
tree e5c730d67c1fa9b37927a50341e22314e7c240fb
parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
author admin <123456789@qq.com> 1659963983 +0800
committer admin <123456789@qq.com> 1659963983 +0800

    which version checked in?

一个提交中居然包含了三个SHA1哈希值表示的对象ID。

  • commit b64069c7b12861f34031fa0c70964130f6769acb
    这是本次提交的唯一标识。
  • tree e5c730d67c1fa9b37927a50341e22314e7c240fb
    这是本次提交所对应的目录树。
  • parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
    这是本地提交的父提交(上一次提交)。

研究Git对象ID的一个重量级武器就是:command:git cat-file命令。用下面的命令可以查看一下这三个ID的类型。

git-cat-file - Provide content or type and size information for repository objects

$ git cat-file -t b64069c
commit

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -t e5c730d67
tree

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -t 817ed41c
commit

下面再用:command:git cat-file命令查看一下这几个对象的内容。

  • commit 对象 b64069c7b12861f34031fa0c70964130f6769acb
$  git cat-file -p b64069c
tree e5c730d67c1fa9b37927a50341e22314e7c240fb
parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
author admin <123456789@qq.com> 1659963983 +0800
committer admin <123456789@qq.com> 1659963983 +0800

which version checked in?
  • tree 对象 e5c730d67c1fa9b37927a50341e22314e7c240fb
$ git cat-file -p e5c730d67
100644 blob 41ea8a4f322ac2a4daafd21b14eaced6fbac7ee7    welcome.txt
  • commit 对象 817ed41c6f5dd33ea45fdcb52f48111986c6955f
$ git cat-file -p 817ed41c
tree d53e0ada312de31f102c8adea3a60caf7df72ff0
parent 4a462ab4e614d17958932521a36e36baf12ed8dd
author admin <123456789@qq.com> 1659961965 +0800
committer admin <123456789@qq.com> 1659961965 +0800

who does this commit?

在上面目录树(tree)对象中看到了一个新的类型的对象:blob对象。这个对象保存着文件:file:welcome.txt的内容。用:command:git cat-file研究一下。

  • 该对象的类型为blob。
$ git cat-file -t 41ea8a4f322ac2a4daafd21b14eaced6fbac7ee7
blob
  • 该对象的内容就是:file:welcome.txt文件的内容。
$ git cat-file -p 41ea8a4f322ac2a4daafd21b14eaced6fbac7ee7
hello.
Nice to meet you.

这些对象保存在哪里?当然是Git库中的:file:objects目录下了(ID的前两位作为目录名,后38位作为文件名)。用下面的命令可以看到这些对象在对象库中的实际位置。

$ for id in b64069c  e5c730d67 817ed41c 41ea8a4f3; do \
>  ls .git/objects/${id:0:2}/${id:2}*; done
.git/objects/b6/4069c7b12861f34031fa0c70964130f6769acb
.git/objects/e5/c730d67c1fa9b37927a50341e22314e7c240fb
.git/objects/81/7ed41c6f5dd33ea45fdcb52f48111986c6955f
.git/objects/41/ea8a4f322ac2a4daafd21b14eaced6fbac7ee7

下面的图示更加清楚的显示了Git对象库中各个对象之间的关系。

在这里插入图片描述
在这里插入图片描述

从上面的图示中很明显的看出提交(Commit)对象之间相互关联,通过相互之间的关联则很容易的识别出一条跟踪链。这条跟踪链可以在运行:command:git log命令时,通过使用–graph参数看到。下面的命令还使用了–pretty=raw参数以便显示每个提交对象的parent属性。

git log --pretty=raw --graph b64069

$ git log --pretty=raw --graph b64069c7b1
* commit b64069c7b12861f34031fa0c70964130f6769acb
| tree e5c730d67c1fa9b37927a50341e22314e7c240fb
| parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
| author admin <123456789@qq.com> 1659963983 +0800
| committer admin <123456789@qq.com> 1659963983 +0800
|
|     which version checked in?
|
* commit 817ed41c6f5dd33ea45fdcb52f48111986c6955f
| tree d53e0ada312de31f102c8adea3a60caf7df72ff0
| parent 4a462ab4e614d17958932521a36e36baf12ed8dd
| author admin <123456789@qq.com> 1659961965 +0800
| committer admin <123456789@qq.com> 1659961965 +0800
|
|     who does this commit?
|
* commit 4a462ab4e614d17958932521a36e36baf12ed8dd
  tree d53e0ada312de31f102c8adea3a60caf7df72ff0
  author Luo kaijie <1697065378@qq.com> 1659953518 +0800
  committer Luo kaijie <1697065378@qq.com> 1659953518 +0800

      initialized

最后一个提交没有parent属性,所以跟踪链到此终结,这实际上就是提交的起点。

现在来看看HEAD和master的奥秘吧

因为在上一章的最后执行了:command:git stash将工作区和暂存区的改动全部封存起来,所以执行下面的命令会看到工作区和暂存区中没有改动。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s -b
## master

说明:上面在显示工作区状态时,除了使用了-s参数以显示精简输出外,还使用了-b参数以便能够同时显示出当前工作分支的名称。这个-b参数是在Git 1.7.2以后加入的新的参数。

下面的:command:git branch是分支管理的主要命令,也可以显示当前的工作分支。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git branch
* master

在master分支名称前面出现一个星号表明这个分支是当前工作分支。至于为什么没有其他分支以及什么叫做分支,会在本书后面的章节揭晓。

现在连续执行下面的三个命令会看到相同的输出:

$ git log -1 HEAD
commit b64069c7b12861f34031fa0c70964130f6769acb (HEAD -> master)
Author: admin <123456789@qq.com>
Date:   Mon Aug 8 21:06:23 2022 +0800

    which version checked in?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git log -1 master
commit b64069c7b12861f34031fa0c70964130f6769acb (HEAD -> master)
Author: admin <123456789@qq.com>
Date:   Mon Aug 8 21:06:23 2022 +0800

    which version checked in?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  git log -1 refs/heads/master
commit b64069c7b12861f34031fa0c70964130f6769acb (HEAD -> master)
Author: admin <123456789@qq.com>
Date:   Mon Aug 8 21:06:23 2022 +0800

    which version checked in?

也就是说在当前版本库中,HEAD、master 和 refs/heads/master具有相同的指向。现在到版本库(:file:.git目录)中一探它们的究竟。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  find .git -name HEAD -o -name master
.git/HEAD
.git/logs/HEAD
.git/logs/refs/heads/master
.git/refs/heads/master

找到了四个文件,其中在:file:.git/logs 目录下的文件稍后再予以关注,现在把目光锁定在:file:.git/HEAD和:file:.git/refs/heads/master上。显示一下:file:.git/HEAD的内容:

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ cat .git/HEAD
ref: refs/heads/master

把 HEAD 的内容翻译过来就是:“指向一个引用:refs/heads/master”。这个引用在哪里?当然是文件:file:.git/refs/heads/master了。看看文件:file:.git/refs/heads/master的内容。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  cat .git/refs/heads/master
b64069c7b12861f34031fa0c70964130f6769acb

显示的 b64069c7b1…所指为何物?用:command:git cat-file命令进行查看。

  • 显示SHA1哈希值指代的数据类型。
MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  git cat-file -t b64069c7b1
commit
  • 显示该提交的内容。
$  git cat-file -p b64069c7b1
tree e5c730d67c1fa9b37927a50341e22314e7c240fb
parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
author admin <123456789@qq.com> 1659963983 +0800
committer admin <123456789@qq.com> 1659963983 +0800

which version checked in?

原来分支 master 指向的是一个提交ID(最新提交)。这样的分支实现是多么的巧妙啊:既然可以从任何提交开始建立一条历史跟踪链,那么用一个文件指向这个链条的最新提交,那么这个文件就可以用于追踪整个提交历史了。这个文件就是:file:.git/refs/heads/master文件。

下面看一个更接近于真实的版本库结构图:

在这里插入图片描述
目录:file:.git/refs是保存引用的命名空间,其中:file:.git/refs/heads目录下的引用又称为分支。对于分支既可以使用正规的长格式的表示法,如:file:refs/heads/master,也可以去掉前面的两级目录用master来表示。Git 有一个底层命令:command:git rev-parse可以用于显示引用对应的提交ID。

$ git rev-parse master
b64069c7b12861f34031fa0c70964130f6769acb

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git rev-parse refs/heads/master
b64069c7b12861f34031fa0c70964130f6769acb

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git rev-parse HEAD
b64069c7b12861f34031fa0c70964130f6769acb

可以看出它们都指向同一个对象。

HEAD/master 概念理解

Git提供很多方法可以方便的访问Git库中的对象。

  • 采用部分的SHA1哈希值。不必写全40位的哈希值,只采用开头的部分,不和现有其他的冲突即可。

  • 使用 master 代表分支 master 中最新的提交,使用全称refs/heads/master亦可。

  • 使用 HEAD 代表版本库中最近的一次提交。

  • 符号` ^可以用于指代父提交。例如:

  • HEAD^ 代表版本库中上一次提交,即最近一次提交的父提交

  • HEAD^^代表 HEAD^ 的父提交

  • 对于一个提交有多个父提交,可以在符号^后面用数字表示是第几个父提交。例如:

    • a573106^2 含义是提交a573106的多个父提交中的第二个父提交。
    • HEAD^1 相当于 HEAD^ ,含义是HEAD多个父提交中的第一个
    • HEAD^^2 含义是 HEAD^(HEAD父提交)的多个父提交中的第二个
  • 符号 ~<n> 也可以用于指代祖先提交。下面两个表达式效果等同:

a573106~5
a573106^^^^^
  • 提交所对应的树对象,可以用类似如下的语法访问。
a573106^{tree}
  • 某一次提交对应的文件对象,可以用如下的语法访问。
a573106:path/to/file
  • 暂存区中的文件对象,可以用如下的语法访问。
:path/to/file

读者可以使用:command:git rev-parse命令在本地版本库中练习一下:

$  git rev-parse HEAD
b64069c7b12861f34031fa0c70964130f6769acb

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -p b64069
tree e5c730d67c1fa9b37927a50341e22314e7c240fb
parent 817ed41c6f5dd33ea45fdcb52f48111986c6955f
author admin <123456789@qq.com> 1659963983 +0800
committer admin <123456789@qq.com> 1659963983 +0800

which version checked in?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -p b64069^
tree d53e0ada312de31f102c8adea3a60caf7df72ff0
parent 4a462ab4e614d17958932521a36e36baf12ed8dd
author admin <123456789@qq.com> 1659961965 +0800
committer admin <123456789@qq.com> 1659961965 +0800

who does this commit?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -p b64069^{tree}
100644 blob 41ea8a4f322ac2a4daafd21b14eaced6fbac7ee7    welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git cat-file -p b64069^^{tree}
100644 blob 25735f595470e0e6894159694a4238a3ee8a3df0    welcome.txt



五、git 重置

在上一章了解了版本库中对象的存储方式以及分支master的实现。即master分支在版本库的引用目录(.git/refs)中体现为一个引用文件:file:.git/refs/heads/master,其内容就是分支中最新提交的提交ID。

$ cat .git/refs/heads/master
b64069c7b12861f34031fa0c70964130f6769acb

上一章还通过对提交本身数据结构的分析,看到提交可以通过到父提交的关联实现对提交
历史的追溯。注意:下面的:command:git log命令中使用了–oneline参数,类似于
–pretty=oneline,但是可以显示更短小的提交ID。参数–oneline在Git 1.6.3 及以后版本
提供,老版本的Git可以使用参数–pretty=oneline --abbrev-commit替代。

git log --graph --oneline

$  git log --graph --oneline
* b64069c (HEAD -> master) which version checked in?
* 817ed41 who does this commit?
* 4a462ab initialized

那么是不是有新的提交发生的时候,代表 master 分支的引用文件的内容会改变呢?代表 master分支的引用文件的内容可以人为的改变么?本章就来探讨用:command:git reset命令改变分支引用文件内容,即实现分支的重置。

分支游标 master 的探秘

先来看看当有新的提交发生的时候,文件:file:.git/refs/heads/master的内容如何改变。首先在工作区创建一个新文件,姑且叫做:file:new-commit.txt,然后提交到版本库中。

$ touch new-commit.txt
$  git add new-commit.txt
$ git commit -m "does master follow this new commit?"
[master 6750cde] does master follow this new commit?
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 new-commit.txt

此时工作目录下会有两个文件,其中文件:file:new-commit.txt是新增的。

$ ls
new-commit.txt  welcome.txt

来看看master分支指向的提交ID是否改变了。

  • 先看看在版本库引用空间(.git/refs/目录)下的master文件内容的确更改了,指向了新的提交。
$  cat .git/refs/heads/master
6750cde6c905dff70e5dbebc72c68393fd129839
  • 再用:command:git log查看一下提交日志,可以看到刚刚完成的提交。
$  git log --graph --oneline
* 6750cde (HEAD -> master) does master follow this new commit?
* b64069c which version checked in?
* 817ed41 who does this commit?
* 4a462ab initialized

引用 refs/heads/master 就好像是一个游标,在有新的提交发生的时候指向了新的提交。可是如果只可上、不可下,就不能称为“游标”。Git提供了:command:git reset命令,可以将“游标”指向任意一个存在的提交ID。下面的示例就尝试人为的更改游标。(注意下面的命令中使用了–hard参数,会破坏工作区未提交的改动,慎用。)

$  git log --graph --oneline
* 6750cde (HEAD -> master) does master follow this new commit?
* b64069c which version checked in?
* 817ed41 who does this commit?
* 4a462ab initialized

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git reset --hard HEAD^
HEAD is now at b64069c which version checked in?

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  git log --graph --oneline
* b64069c (HEAD -> master) which version checked in?
* 817ed41 who does this commit?
* 4a462ab initialized

还记得上一章介绍的HEAD^代表了HEAD的父提交么?所以这条命令就相当于将master重置到
上一个老的提交上。来看一下master文件的内容是否更改了。

$ cat .git/refs/heads/master
b64069c7b12861f34031fa0c70964130f6769acb

果然master分支的引用文件的指向更改为前一次提交的ID了。而且通过下面的命令可以看出新添加的文件:file:new-commit.txt也丢失了。

$ ls
welcome.txt

重置命令不仅仅可以重置到前一次提交,重置命令可以直接使用提交ID重置到任何一次提交。

  • 通过:command:git log查询到最早的提交ID。
$ git log --graph --oneline
* b64069c (HEAD -> master) which version checked in?
* 817ed41 who does this commit?
* 4a462ab initialized
  • 然后重置到最早的一次提交。
$ git reset  --hard   4a462ab
HEAD is now at 4a462ab initialized
  • 重置后会发现:file:welcome.txt也回退到原始版本库,曾经的修改都丢失了。
$ cat welcome.txt
Hello.

使用重置命令很危险,会彻底的丢弃历史。 那么还能够通过浏览提交历史的办法找到丢弃的提交ID,再使用重置命令恢复历史么?不可能!因为重置让提交历史也改变了。

$ git log
commit 4a462ab4e614d17958932521a36e36baf12ed8dd (HEAD -> master)
Author: Luo kaijie <1697065378@qq.com>
Date:   Mon Aug 8 18:11:58 2022 +0800

    initialized

用reflog挽救错误的重置

如果没有记下重置前master分支指向的提交ID,想要重置回原来的提交真的是一件麻烦的事情(去对象库中一个一个地找)。幸好Git提供了一个挽救机制,通过:file:.git/logs目录下日志文件记录了分支的变更。默认非裸版本库(带有工作区)都提供分支日志功能,这是因为带有工作区的版本库都有如下设置:

$ git config core.logAllRefUpdates
true

查看一下master分支的日志文件:file:.git/logs/refs/heads/master中的内容。下面命令显示了该文件的最后几行。为了排版的需要,还将输出中的40位的SHA1提交ID缩短。

$ tail -5 .git/logs/refs/heads/master
b39c0 817ed41 admin <123456789@qq.com> 1659961965 +0800     commit (amend): who does this commit?
817ed b64069c admin <123456789@qq.com> 1659963983 +0800     commit: which version checked in?
b6406 6750cde admin <123456789@qq.com> 1665481501 +0800     commit: does master follow this new commit?
6750c b64069c admin <123456789@qq.com> 1665484010 +0800     reset: moving to HEAD^
b6406 4a462ab admin <123456789@qq.com> 1665579659 +0800     reset: moving to 4a462ab

可以看出这个文件记录了master分支指向的变迁,最新的改变追加到文件的末尾因此最后出现。最后一行可以看出因为执了:command:git reset --hard命令,指向的提交ID由 b6406 改变为 4a462ab

git reflog show master | head -5

Git提供了一个:command:git reflog命令,对这个文件进行操作。使用show子命令可以显示此文件的内容。

$ git reflog show master | head -5
4a462ab master@{0}: reset: moving to 4a462ab
b64069c master@{1}: reset: moving to HEAD^
6750cde master@{2}: commit: does master follow this new commit?
b64069c master@{3}: commit: which version checked in?
817ed41 master@{4}: commit (amend): who does this commit?

使用:command:git reflog的输出和直接查看日志文件最大的不同在于显示顺序的不同,即最新改变放在了最前面显示,而且只显示每次改变的最终的SHA1哈希值。还有个重要的区别在于使用:command:git reflog的输出中还提供一个方便易记的表达式:</refname/>@{</n/>}。这个表达式的含义是引用</refname/>之前第</n/>次改变时的SHA1哈希值。

那么将引用master切换到两次变更之前的值,可以使用下面的命令。

  • 重置master为两次改变之前的值。
$  git reset --hard master@{2}
HEAD is now at 6750cde does master follow this new commit?
  • 重置后工作区中文件:file:new-commit.txt又回来了。
$ ls
new-commit.txt welcome.txt
  • 提交历史也回来了。
$ git log --oneline
6750cde (HEAD -> master) does master follow this new commit?
b64069c which version checked in?
817ed41 who does this commit?
4a462ab initialized

此时如果再用:command:git reflog查看,会看到恢复master的操作也记录在日志中了。

$ git reflog show master -5
6750cde (HEAD -> master) master@{0}: reset: moving to master@{2}
4a462ab master@{1}: reset: moving to 4a462ab
b64069c master@{2}: reset: moving to HEAD^
6750cde (HEAD -> master) master@{3}: commit: does master follow this new commit?
b64069c master@{4}: commit: which version checked in?

深入了解:command:git reset命令

重置命令(:command:git reset)是Git最常用的命令之一,也是最危险,最容易误用的命令。来看看:command:git reset命令的用法。

用法一: git reset [-q] [<commit>] [--] <pathspec>...
用法二: git reset [--mixed | --soft | --hard | --merge | --keep] [-q] [<commit>]

上面列出了两个用法,其中 </commit/> 都是可选项,可以使用引用或者提交ID,如果省略</commit/> 则相当于使用了HEAD的指向作为提交ID。

上面列出的两种用法的区别在于,第一种用法在命令中包含路径:file:<paths>。为了避免路径和引用(或者提交ID)同名而冲突,可以在:file:<paths>前用两个连续的短线(减号)作为分隔。

第一种用法(包含了路径:file:<paths>的用法)不会重置引用,更不会改变工作区,而是用指定提交状态(</commit/>)下的文件(</paths/>)替换掉暂存区中的文件。例如命令:command:git reset HEAD <paths>相当于取消之前执行的:command:git add <paths>命令时改变的暂存区。

第二种用法(不使用路径:file:<paths>的用法)则会重置引用。根据不同的选项,可以对暂存区或者工作区进行重置。参照下面的版本库模型图,来看一看不同的参数对第二种重置语法的影响。

在这里插入图片描述

命令格式: git reset [--soft | --mixed | --hard ] [<commit>]
  • 使用参数–hard,如::command:git reset --hard <commit>

会执行上图中的1、2、3全部的三个动作。即:
i. 替换引用的指向。引用指向新的提交ID。
ii. 替换暂存区。替换后,暂存区的内容和引用指向的目录树一致。
iii. 替换工作区。替换后,工作区的内容变得和暂存区一致,也和HEAD所指向的目录树内容相同。

  • 使用参数–soft,如::command:git reset --soft <commit>

会执行上图中的操作1。即只更改引用的指向,不改变暂存区和工作区。

  • 使用参数–mixed或者不使用参数(缺省即为–mixed),如::command:git reset <commit>

会执行上图中的操作1和操作2。即更改引用的指向以及重置暂存区,但是不改变工作区。


下面通过一些示例,看一下重置命令的不同用法。

  • 命令::command:git reset

仅用HEAD指向的目录树重置暂存区,工作区不会受到影响,相当于将之前用:command:git add命令更新到暂存区的内容撤出暂存区。引用也未改变,因为引用重置到HEAD相当于没有重置。

  • 命令::command:git reset HEAD

同上。

  • 命令::command:git reset -- filename

仅将文件:file:filename撤出暂存区,暂存区中其他文件不改变。相当于对命令:command:git add filename的反向操作。

  • 命令::command:git reset HEAD filename

同上。

  • 命令::command:git reset --soft HEAD^

工作区和暂存区不改变,但是引用向前回退一次。当对最新提交的提交说明或者提交的更改不满意时,撤销最新的提交以便重新提交。

在之前曾经介绍过一个修补提交命令:command:git commit --amend,用于对最新的提交进行重新提交以修补错误的提交说明或者错误的提交文件。修补提交命令实际上相当于执行了下面两条命令。(注:文件:file:.git/COMMIT_EDITMSG保存了上次的提交日志)

$ git reset --soft HEAD^
$ git commit -e -F .git/COMMIT_EDITMSG
  • 命令::command:git reset HEAD^

工作区不改变,但是暂存区会回退到上一次提交之前,引用也会回退一次。

  • 命令::command:git reset --mixed HEAD^

同上。

  • 命令::command:git reset --hard HEAD^

彻底撤销最近的提交。引用回退到前一次,而且工作区和暂存区都会回退到上一次提交的状态。自上一次以来的提交全部丢失。


六、恢复进度

在之前“Git暂存区”一章的结尾,曾经以终结者(The Terminator)的口吻说:“我会再回来”,会继续对暂存区的探索。经过了前面三章对Git对象、重置命令、检出命令的探索,现在已经拥有了足够多的武器,是时候“回归”了。

本章“回归”之后,再看Git状态输出中关于:command:git reset或者:command:git checkout的指示,有了前面几章的基础已经会觉得很亲切和易如反掌了。本章还会重点介绍“回归”使用的:command:git stash命令。

继续暂存区未完成的实践

经过了前面的实践,现在DEMO版本库应该处于master分支上,看看是不是这样。

$  git status -sb
## master

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$  git log --graph --pretty=oneline --stat
* 6750cde6c905dff70e5dbebc72c68393fd129839 (HEAD -> master) does master follow this new commit?
|  new-commit.txt | 0
|  1 file changed, 0 insertions(+), 0 deletions(-)
* b64069c7b12861f34031fa0c70964130f6769acb which version checked in?
|  welcome.txt | 1 +
|  1 file changed, 1 insertion(+)
* 817ed41c6f5dd33ea45fdcb52f48111986c6955f who does this commit?
* 4a462ab4e614d17958932521a36e36baf12ed8dd initialized
   welcome.txt | 1 +
   1 file changed, 1 insertion(+)

还记得在之前“Git暂存区”一章的结尾,是如何保存进度的么?翻回去看一下,用的是:command:git stash命令。这个命令用于保存当前进度,也是恢复进度要用的命令。

git stash list

查看保存的进度用命令:command:git stash list

$ git stash list
stash@{0}: WIP on master: b64069c which version checked in?

现在就来恢复进度。使用:command:git stash pop从最近保存的进度进行恢复。

$  git stash pop
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   a/b/c/hello.txt

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

Dropped refs/stash@{0} (ddc529a228bf308142a1d5e2ee09e9890f1af91f)

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash list

先不要管:command:git stash pop命令的输出,后面会专题介绍:command:git stash命令。通过查看工作区的状态,可以发现进度已经找回了(状态和进度保存前稍有不同)。

$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   a/b/c/hello.txt

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

此时再看Git状态输出,是否别有一番感觉呢?有了前面三章的基础,现在可以游刃有余的应对各种情况了。

  • 以当前暂存区状态进行提交,即只提交:file:a/b/c/hello.txt,不提交:file:welcome.txt

    • 执行提交:

    在这里插入图片描述

  • 查看提交后的状态:

    在这里插入图片描述

  • 反悔了,回到之前的状态。

    • 用重置命令放弃最新的提交:

    在这里插入图片描述

  • 查看最新的提交日志,可以看到前面的提交被抛弃了。
    在这里插入图片描述

  • 工作区和暂存区的状态也都维持原来的状态。

    在这里插入图片描述

  • 想将:file:welcome.txt提交。

    再简单不过了。

    在这里插入图片描述

  • 想将:file:a/b/c/hello.txt撤出暂存区。

    也是用重置命令。

    在这里插入图片描述

  • 想将剩下的文件(:file:welcome.txt)从暂存区撤出,就是说不想提交任何东西了。

    还是使用重置命令,甚至可以不使用任何参数。

    在这里插入图片描述

  • 想将本地工作区所有的修改清除。即清除:file:welcome.txt的改动,删除添加的目录:file:a即下面的子目录和文件。

    • 清除:file:welcome.txt的改动用检出命令。

      实际对于此例执行:command:git checkout .也可以。

    在这里插入图片描述

    • 工作区显示还有一个多余的目录:file:a

    在这里插入图片描述

    • 删除本地多余的目录和文件,可以使用:command:git clean命令。先来测试运行以便看看哪些文件和目录会被删除,以免造成误删。

    在这里插入图片描述

    • 真正开始强制删除多余的目录和文件。

    在这里插入图片描述

    • 整个世界清净了。

    在这里插入图片描述

git clean -nd

git clean -fd


使用:command:git stash

命令:command:git stash可以用于保存和恢复工作进度,掌握这个命令对于日常的工作会有很大的帮助。关于这个命令的最主要的用法实际上通过前面的演示已经了解了。

  • 命令::command:git stash

    保存当前工作进度。会分别对暂存区和工作区的状态进行保存。

  • 命令::command:git stash list

    显示进度列表。此命令显然暗示了:command:git stash可以多次保存工作进度,并且在恢复的时候进行选择。

  • 命令::command:git stash pop [--index] [<stash>]

    如果不使用任何参数,会恢复最新保存的工作进度,并将恢复的工作进度从存储的工作进度列表中清除。

    如果提供<\stash>参数(来自于:command:git stash list显示的列表),则从该<\stash>中恢复。恢复完毕也将从进度列表中删除<\stash>。

    选项 --index 除了恢复工作区的文件外,还尝试恢复暂存区。 这也就是为什么在本章一开始恢复进度的时候显示的状态和保存进度前略有不同。

实际上还有几个用法也很有用。

  • 命令::command:git stash [save [--patch] [-k|--[no-]keep-index] [-q|-- quiet] [<message>]]

    • 这条命令实际上是第一条:command:git stash命令的完整版。即如果需要在保存工作进度的时候使用指定的说明,必须使用如下格式

    在这里插入图片描述

  • 使用参数–patch 会显示工作区和HEAD的差异,通过对差异文件的编辑决定在进度中最终要保存的工作区的内容,通过编辑差异文件可以在进度中排除无关内容。

  • 使用 -k 或者 --keep-index 参数,在保存进度后不会将暂存区重置。缺省会将暂存区和工作区强制重置

  • 命令::command:git stash apply [--index] [<stash>]

    除了不删除恢复的进度之外,其余和:command:git stash pop命令一样

  • 命令::command:git stash drop [<stash>]

    删除一个存储的进度。缺省删除最新的进度

  • 命令::command:git stash clear

    删除所有存储的进度

  • 命令::command:git stash branch <branchname> <stash>

    基于进度创建分支


探秘:command:git stash

了解一下:command:git stash的机理会有几个好处:当保存了多个进度的时候知道从哪个进度恢复;综合运用前面介绍的Git知识点;了解Git的源码,Git将不再神秘。

在执行:command:git stash命令时,Git实际调用了一个脚本文件实现相关的功能,这个脚本的文件名就是:file:git-stash。看看:file:git-stash安装在哪里了。

$ git --exec-path
D:/Git/mingw64/libexec/git-core

如果检查一下这个目录,会震惊的。

$ ls D:/Git/mingw64/libexec/git-core
git-add.exe*                                    git-remote-fd.exe*
git-gui--askpass*                               libexpat-1.dll*
git-gui--askyesno*                              libgcc_s_seh-1.dll*
git-gui.tcl*                                    libgmp-10.dll*
......

实际上在1.5.4之前的版本,Git会安装这些一百多个以:command:git-<cmd>格式命名的程序到可执行路径中。这样做的唯一好处就是不用借助任何扩展机制就可以实现命令行补齐:即键入git-后,连续两次键入键,就可以把这一百多个命令显示出来。这种方式随着Git子命令的增加越来越显得混乱,因此在1.5.4版本开始,不再提供:command:git-<cmd>格式的命令,而是用唯一的:command:git命令。而之前的名为:command:git-<cmd>的子命令则保存在非可执行目录下,由Git负责加载。

在后面的章节中偶尔会看到形如:command:git-<cmd>字样的名称,以及同时存在的:command:git <cmd>命令。可以这样理解::command:git-<cmd>作为软件本身的名称,而其命令行为:command:git <cmd>

最早很多Git命令都是用Shell或者Perl脚本语言开发的,在Git的发展中一些对运行效率要求高的命令用C语言改写。而:file:git-stash(至少在Git 1.7.3.2版本)还是使用Shell脚本开发的,研究它会比研究用C写的命令要简单的多。

$ file D:/Git/mingw64/libexec/git-core/git-stash
D:/Git/mingw64/libexec/git-core/git-stash: PE32+ executable (console) x86-64, for MS Windows

解析:file:git-stash脚本会比较枯燥,还是通过运行一些示例更好一些。

当前的进度保存列表是空的。

$ git stash list

下面在工作区中做一些改动。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo Bye-Bye. >> welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ echo hello. > hack-1.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git add hack-1.txt
warning: LF will be replaced by CRLF in hack-1.txt.
The file will have its original line endings in your working directory

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s
A  hack-1.txt
 M welcome.txt

可见 暂存区中已经添加了新增的:file:hack-1.txt 修改过的:file:welcome.txt并未添加到暂存区。执行:command:git stash保存一下工作进度。

$ git stash save "hack-1: hacked welcome.txt, newfile hack-1.txt"
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory
Saved working directory and index state On master: hack-1: hacked welcome.txt, newfile hack-1.txt

再来看工作区恢复了修改前的原貌(实际上用了 git reset --hard HEAD 命令),文件:file:welcome.txt的修改不见了,文件:file:hack-1.txt整个都不见了。

$ git status -s

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ ls
new-commit.txt  welcome.txt

再做一个修改,并尝试保存进度。

$ echo fix. > hack-2.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash
No local changes to save

进度保存失败!可见本地没有被版本控制系统跟踪的文件并不能保存进度。 因此本地新文件需要执行添加再执行:command:git stash命令。

$ git add hack-2.txt
warning: LF will be replaced by CRLF in hack-2.txt.
The file will have its original line endings in your working directory

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash
Saved working directory and index state WIP on master: 6750cde does master follow this new commit?

不用看就知道工作区再次恢复原状。如果这时执行:command:git stash list会看到有两次进度保存。

$ git stash list
stash@{0}: WIP on master: 6750cde does master follow this new commit?
stash@{1}: On master: hack-1: hacked welcome.txt, newfile hack-1.txt

从上面的输出可以得出两个结论:

  • 在用:command:git stash命令保存进度时,提供说明更容易找到对应的进度文件。
  • 每个进度的标识都是stash@{}格式,像极了前面介绍的reflog的格式。

实际上,:command:git stash的就是用到了前面介绍的引用和引用变更日志(reflog)来实现的。

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ ls -l .git/refs/stash .git/logs/refs/stash
-rw-r--r-- 1 MyPC 197121 365 Oct 17 17:43 .git/logs/refs/stash
-rw-r--r-- 1 MyPC 197121  41 Oct 17 17:43 .git/refs/stash

那么在“Git重置”一章中学习的reflog可以派上用场了。

$ git reflog show refs/stash
4ea6dd9 (refs/stash) refs/stash@{0}: WIP on master: 6750cde does master follow this new commit?
d45a0a0 refs/stash@{1}: On master: hack-1: hacked welcome.txt, newfile hack-1.txt

对照:command:git reflog的结果和前面:command:git stash list的结果,可以肯定用:command:git stash保存进度,实际上会将进度保存在引用refs/stash所指向的提交中。多次的进度保存,实际上相当于引用refs/stash一次又一次的变化,而refs/stash引用的变化由reflog(即:command:.git/logs/refs/stash)所记录下来。这个实现是多么的简单而巧妙啊。

新的一个疑问又出现了,如何在引用refs/stash中同时保存暂存区的进度和工作区中的进度呢?查看一下引用refs/stash的提交历史能够看出端倪。

$ git log --graph --pretty=raw refs/stash -2
*   commit 4ea6dd9e50c5183a20728fa623716e523ae682a9
|\  tree b7928f946c6f9836c5a08a2500c8be7e48d46579
| | parent 6750cde6c905dff70e5dbebc72c68393fd129839
| | parent 9ec115481be5410dca28974479de74c570c62f30
| | author admin <123456789@qq.com> 1665999828 +0800
| | committer admin <123456789@qq.com> 1665999828 +0800
| |
| |     WIP on master: 6750cde does master follow this new commit?
| |
| * commit 9ec115481be5410dca28974479de74c570c62f30
|/  tree b7928f946c6f9836c5a08a2500c8be7e48d46579
|   parent 6750cde6c905dff70e5dbebc72c68393fd129839
|   author admin <123456789@qq.com> 1665999828 +0800
|   committer admin <123456789@qq.com> 1665999828 +0800
|
|       index on master: 6750cde does master follow this new commit?

在提交关系图可以看到进度保存的最新提交是一个合并提交。最新的提交说明中有WIP字样(是Work In Progess的简称),说明代表了工作区进度。 而最新提交的第二个父提交(上图中显示为第二个提交)有index on master字样,说明这个提交代表着暂存区的进度

但是上图中的两个提交都指向了同一个树——tree b7928f9…,这是因为最后一次做进度保存时工作区相对暂存区没有改变,这让关于工作区和暂存区在引用refs/stash中的存储变得有些扑朔迷离。别忘了第一次进度保存工作区、暂存区和版本库都是不同的,可以用于验证关于refs/stash实现机制的判断

第一次进度保存可以用reflog中的语法,即用refs/stash@{1}来访问,也可以用简称stash@{1}。下面就用第一次的进度保存来研究一下。

$ git log --graph --pretty=raw stash@{1} -3
*   commit d45a0a06fa04dbe34fc9b3be1b612113ea808b57
|\  tree 9754deccecc7c67205e9e18a514781b88f1ad02c
| | parent 6750cde6c905dff70e5dbebc72c68393fd129839
| | parent e07da4dfee837aa4ead52731abc7d59417976f1e
| | author admin <123456789@qq.com> 1665999729 +0800
| | committer admin <123456789@qq.com> 1665999729 +0800
| |
| |     On master: hack-1: hacked welcome.txt, newfile hack-1.txt
| |
| * commit e07da4dfee837aa4ead52731abc7d59417976f1e
|/  tree 0d0296e9fea8a0c3e331a1b41bc1135ec445d96f
|   parent 6750cde6c905dff70e5dbebc72c68393fd129839
|   author admin <123456789@qq.com> 1665999729 +0800
|   committer admin <123456789@qq.com> 1665999729 +0800
|
|       index on master: 6750cde does master follow this new commit?
|
* commit 6750cde6c905dff70e5dbebc72c68393fd129839
| tree 57fe502e36ed712140f9b989b1a05fa8fe05d104
| parent b64069c7b12861f34031fa0c70964130f6769acb
| author admin <123456789@qq.com> 1665481501 +0800
| committer admin <123456789@qq.com> 1665481501 +0800
|
|     does master follow this new commit?

果然上面显示的三个提交对应的三棵树各不相同。查看一下差异。用“原基线”代表进度保存时版本库的状态,即提交 6750cde6c;用“原暂存区”代表进度保存时暂存区的状态,即提交 e07da4df;用“原工作区”代表进度保存时工作区的状态,即提交 d45a0a06

  • 原基线和原暂存区的差异比较。
$ git diff stash@{1}^2^ stash@{1}^2
diff --git a/hack-1.txt b/hack-1.txt
new file mode 100644
index 0000000..25735f5
--- /dev/null
+++ b/hack-1.txt
@@ -0,0 +1 @@
+hello.
  • 原暂存区和原工作区的差异比较。
$ git diff stash@{1}^2 stash@{1}
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.
  • 原基线和原工作区的差异比较。
$ git diff stash@{1}^1 stash@{1}
diff --git a/hack-1.txt b/hack-1.txt
new file mode 100644
index 0000000..25735f5
--- /dev/null
+++ b/hack-1.txt
@@ -0,0 +1 @@
+hello.
diff --git a/welcome.txt b/welcome.txt
index 41ea8a4..54f1861 100644
--- a/welcome.txt
+++ b/welcome.txt
@@ -1,2 +1,3 @@
 hello.
 Nice to meet you.
+Bye-Bye.

从stash@{1}来恢复进度。

$  git stash apply stash@{1}
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   hack-1.txt

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


显示进度列表,然后删除进度列表。

$ git stash list
stash@{0}: WIP on master: 6750cde does master follow this new commit?
stash@{1}: On master: hack-1: hacked welcome.txt, newfile hack-1.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash clear

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash list


删除进度列表之后,会发现stash相关的引用和reflog也都不见了。

$ ls -l .git/refs/stash .git/logs/refs/stash
ls: cannot access '.git/refs/stash': No such file or directory
ls: cannot access '.git/logs/refs/stash': No such file or directory

通过上面的这些分析,有一定Shell编程基础的读者就可以尝试研究git-stash的代码了,可能会有新的发现。


七、基本操作

之前的实践选取的示例都非常简单,基本上都是增加和修改文本文件,而现实情况要复杂的多,需要应对各种情况:文件删除,文件复制,文件移动,目录的组织,二进制文件,误删文件的恢复等等。

本章要用一个更为真实的例子:通过对Hello World程序源代码的版本控制,来介绍工作区中其他的一些常用操作。首先会删除之前历次实践在版本库中留下的“垃圾”数据,然后再在其中创建一些真实的代码,并对其进行版本控制。

先来合个影

马上就要和之前实践遗留的数据告别了,告别之前是不是要留个影呢?在Git里,“留影”用的命令叫做:command:tag,更加专业的术语叫做“里程碑”(打tag,或打标签)。

$ cd /path/to/my/workspace/demo
$ git tag -m "Say bye-bye to all previous practice." old_practice

在本章还不打算详细介绍里程碑的奥秘,只要知道里程碑无非也是一个引用,通过记录提交ID(或者创建Tag对象)来为当前版本库状态进行“留影”。

$ ls .git/refs/tags/old_practice
.git/refs/tags/old_practice

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git rev-parse refs/tags/old_practice
01c472ebf79a52a9e73c470dcd3bed542e6dc2ed

留过影之后,可以执行:command:git describe命令显示当前版本库的最新提交的版本号。显示的时候会选取离该提交最近的里程碑作为“基础版本号”,后面附加标识距离“基础版本”的数字以及该提交的SHA1哈希值缩写。因为最新的提交上恰好被打了一个“里程碑”,所以用“里程碑”的名字显示为版本号。这个技术在后面的示例代码中被使用。

$ git describe
old_practice

删除文件

看看版本库当前的状态,暂存区和工作区都包含修改。

$ git status -s
A  hack-1.txt
 M welcome.txt

在这个暂存区和工作区都包含文件修改的情况下,使用删除命令更具有挑战性。删除命令有多种使用方法,有的方法很巧妙,而有的方法需要更多的输入。为了分别介绍不同的删除方法,还要使用上一章介绍的进度保存(:command:git-stash)命令。

  • 保存进度。
$ git stash list

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git stash
Saved working directory and index state WIP on master: 6750cde does master follow this new commit?
  • 再恢复进度。注意不要使用:command:git stash pop,而是使用:command:git stash apply,因为这个保存的进度要被多次用到。
$ git stash apply
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   hack-1.txt

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

本地删除不是真的删除

当前工作区的文件有:

$ ls
hack-1.txt  new-commit.txt  welcome.txt

直接在工作区删除这些文件,会如何呢?

$  rm *.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ ls

git ls-files : 可以看到在暂存区(版本库)中文件

通过下面的命令,可以看到在暂存区(版本库)中文件仍在,并未删除。

$ git ls-files
hack-1.txt
new-commit.txt
welcome.txt

通过文件的状态来看,文件只是在本地进行了删除,尚未加到暂存区(提交任务)中。也就是说:直接在工作区删除,对暂存区和版本库没有任何影响

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   hack-1.txt

Changes not staged for commit:
  (use "git add/rm <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        deleted:    hack-1.txt
        deleted:    new-commit.txt
        deleted:    welcome.txt

从Git状态输出可以看出,本地删除如果要反映在暂存区中应该用:command:git rm命令,对于不想删除的文件执行:command:git checkout -- <file>可以让文件在工作区重现。


执行:command:git rm命令删除文件

好吧,按照上面状态输出的内容,将所有的文本文件删除。执行下面的命令。

git rm file

$ git rm  hack-1.txt new-commit.txt welcome.txt
rm 'hack-1.txt'
rm 'new-commit.txt'
rm 'welcome.txt'

再看一看状态:

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        deleted:    new-commit.txt
        deleted:    welcome.txt

删除动作加入了暂存区。这时执行提交动作,就真正意义上执行了文件删除。

$ git commit -m "delete trash files. (using: git rm)"
[master 40eef2c] delete trash files. (using: git rm)
 2 files changed, 2 deletions(-)
 delete mode 100644 new-commit.txt
 delete mode 100644 welcome.txt

不过不要担心,文件只是在版本库最新提交中删除了,在历史提交中尚在。可以通过下面命令查看历史版本的文件列表。

git-ls-files - Show information about files in the index and the working tree

git ls-files --with-tree=HEAD^

$ git ls-files --with-tree=HEAD^
new-commit.txt
welcome.txt

git cat-file -p HEAD^:welcome.txt

也可以查看在历史版本中尚在的删除文件的内容。

$  git cat-file -p HEAD^:welcome.txt
hello.
Nice to meet you.

命令:command:git add -u 快速标记删除

-u, --update update tracked files

在前面执行:command:git rm命令时,一 一写下了所有要删除的文件名,好长的命令啊!

能不能简化些?实际上:command:git add可以,即使用-u参数调用:command:git add命令,含义是将本地有改动(包括添加和删除)的文件标记为删除。为了重现刚才的场景,先使用重置命令抛弃最新的提交,再使用进度恢复到之前的状态。

  • 丢弃之前测试删除的试验性提交。
$ git reset --hard HEAD^
HEAD is now at 6750cde does master follow this new commit?
  • 恢复保存的进度。(参数-q使得命令进入安静模式)
$ git stash apply -q

然后删除本地文件,状态依然显示只在本地删除了文件,暂存区文件仍在。

$ rm *txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s
AD hack-1.txt
 D new-commit.txt
 D welcome.txt

执行:command:git add -u命令可以将(被版本库追踪的)本地文件的变更(修改、删除)全部记录到暂存区中。

$ git add -u

查看状态,可以看到工作区删除的文件全部被标记为下次提交时删除。

$ git status -s
 D new-commit.txt
 D welcome.txt

执行提交,删除文件。

$ git commit -m "delete trash files. (using: git add -u)"
[master dac26c2] delete trash files. (using: git add -u)
 2 files changed, 2 deletions(-)
 delete mode 100644 new-commit.txt
 delete mode 100644 welcome.txt

恢复删除的文件

经过了上面的文件删除,工作区已经没有文件了。为了说明文件移动,现在恢复一个删除的文件。前面已经说过执行了文件删除并提交,只是在最新的提交中删除了文件,历史提交中文件仍然保留,可以从历史提交中提取文件。执行下面的命令可以从历史(前一次提交)中恢复:file:welcome.txt文件。

git cat-file -p HEAD~1:welcome.txt > welcome.txt

$ git cat-file -p HEAD~1:welcome.txt > welcome.txt

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ cat welcome.txt
hello.
Nice to meet you.

上面命令中出现的HEAD~1即相当于HEAD^,都指的是HEAD的上一次提交。执行:command:git add -A命令会对工作区中所有改动以及新增文件添加到暂存区,也是一个常用的技巧。执行下面的命令后,将恢复过来的:file:welcome.txt文件添加回暂存区。

$ git add -A
warning: LF will be replaced by CRLF in welcome.txt.
The file will have its original line endings in your working directory

MyPC@CY-20210902DXIS MINGW64 /e/git/workspace/demo (master)
$ git status -s
A  welcome.txt

执行提交操作,文件:file:welcome.txt又回来了。

$ git commit -m "restore file: welcome.txt"
[master dd4aeae] restore file: welcome.txt
 1 file changed, 2 insertions(+)
 create mode 100644 welcome.txt

通过再次添加的方式恢复被删除的文件是最自然的恢复的方法。

其他版本控制系统如CVS也采用同样的方法恢复删除的文件,但是有的版本控制系统如Subversion如果这样操作会有严重的副作用——文件变更历史被人为的割裂而且还会造成服务器存储空间的浪费。Git通过添加方式反删除文件没有副作用,这是因为在Git的版本库中相同内容的文件保存在一个blob对象中,而且即便是内容不同的blob对象在对象库打包整理过程中也会通过差异比较优化存储。


移动文件

通过将:file:welcome.txt改名为:file:README文件来测试一下在Git中如何移动文件。Git提供了:command:git mv命令完成改名操作。

$ git mv welcome.txt README

可以从当前的状态中看到改名的操作。

$ git status
On branch master
Your branch is ahead of 'origin/master' by 2 commits.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        renamed:    welcome.txt -> README

提交改名操作,在提交输出可以看到改名前后两个文件的相似度(百分比)。

$ git commit -m "改名测试"
[master 7693ad4] 改名测试
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename welcome.txt => README (100%)

可以不用:command:git mv命令实现改名。

从提交日志中出现的文件相似度可以看出Git的改名实际上源自于Git对文件追踪的强大支持(文件内容作为blob对象保存在对象库中)。改名操作实际上相当于对旧文件执行删除,对新文件执行添加,即完全可以不使用:command:git mv操作,而是代之以:command:git rm和一个:command:git add操作。为了试验不使用:command:git mv命令是否可行,先撤销之前进行的提交。

  • 撤销之前测试文件移动的提交。
$ git reset --hard HEAD^
HEAD is now at 63992f0 restore file: welcome.txt
  • 撤销之后:file:welcome.txt文件又回来了。
$ git status -s
$ git ls-files
welcome.txt

新的改名操作不使用:command:git mv命令,而是直接在本地改名(文件移动),将:file:welcome.txt 改名为:file:README

$ mv welcome.txt README
$ git status -s
 D welcome.txt
?? README

为了考验一下Git的内容追踪能力,再修改一下改名后的 README 文件,即在文件末尾追加一行。

$ echo "Bye-Bye." >> README

可以使用前面介绍的:command:git add -A命令。相当于对修改文件执行:command:git add,对删除文件执行:command:git rm,而且对本地新增文件也执行:command:git add

$ git add -A

查看状态,也可以看到文件重命名。

$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# renamed: welcome.txt -> README
#

执行提交。

$ git commit -m "README is from welcome.txt."
[master c024f34] README is from welcome.txt.
 1 files changed, 1 insertions(+), 0 deletions(-)
 rename welcome.txt => README (73%)

这次提交中也看到了重命名操作,但是重命名相似度不是 100%,而是 73%。


一个显示版本号的Hello World

在本章的一开始为纪念前面的实践留了一个影,叫做old_practice。现在再次执行:command:git describe看一下现在的版本号。

git describe

$ git describe
old_practice-3-gc024f34

就是说:当前工作区的版本是“留影”后的第三个版本,提交ID是c024f34

下面的命令可以在提交日志中显示提交对应的里程碑(Tag)。其中参数–decorate可以在提交ID的旁边显示该提交关联的引用(里程碑或分支)

git log --oneline --decorate -4

$ git log --oneline --decorate -4
c024f34 (HEAD, master) README is from welcome.txt.
63992f0 restore file: welcome.txt
7161977 delete trash files. (using: git add -u)
2b31c19 (tag: old_practice) Merge commit 'acc2f69'

命令:command:git describe的输出可以作为软件版本号,这个功能非常有用。因为这样可以很容易的实现将发布的软件包版本和版本库中的代码对应在一起,当发现软件包包含Bug时,可以最快、最准确的对应到代码上。

下面的Hello World程序就实现了这个功能。创建目录:file:src,并在:file:src目录下创建下面的三个文件:

  • 文件::file:src/main.c

    没错,下面的几行就是这个程序的主代码,和输出相关代码的就两行,一行显示“Hello, world.”,另外一行显示软件版本。在显示软件版本时用到了宏_VERSION,这个宏的来源参考下一个文件。

    源代码:

#include "version.h"
#include <stdio.h>
int
main()
{
 printf( "Hello, world.\n" );
 printf( "version: %s.\n", _VERSION );
 return 0;
}
  • 文件::file:src/version.h.in

    没错,这个文件名的后缀是:file:.h.in。这个文件其实是用于生成文件:file:version.h的模板文件。在由此模板文件生成的:file:version.h的过程中,宏_VERSION的值 “” 会动态替换。

    源代码:

#ifndef HELLO_WORLD_VERSION_H
#define HELLO_WORLD_VERSION_H
#define _VERSION "<version>"
#endif
  • 文件::file:src/Makefile

    这个文件看起来很复杂,而且要注意所有缩进都是使用一个键完成的缩进,千万不要错误的写成空格,因为这是:file:Makefile。这个文件除了定义如何由代码生成可执行文件:file:hello之外,还定义了如何将模板文件:file:version.h.in转换为:file:version.h。在转换过程中用:command:git describe命令的输出替换模板文件中的字符串。

    源代码:

OBJECTS = main.o
TARGET = hello
all: $(TARGET)
$(TARGET): $(OBJECTS)
	$(CC) -o $@ $^
main.o: | new_header
main.o: version.h
new_header:
	@sed -e "s/<version>/$$(git describe)/g" \
 	< version.h.in > version.h.tmp
	@if diff -q version.h.tmp version.h >/dev/null 2>&1; \
	then \
	rm version.h.tmp; \
	else \
 	echo "version.h.in => version.h" ; \
 	mv version.h.tmp version.h; \
 	fi
clean:
	rm -f $(TARGET) $(OBJECTS) version.h
.PHONY: all clean

上述三个文件创建完毕之后,进入到:file:src目录,试着运行一下。先执行:command:make编译,再运行编译后的序:command:hello

$ cd src
$ make
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o
$ ./hello
Hello, world.
version: old_practice-3-gc024f34.

使用:command:git add -i 选择性添加

刚刚创建的Hello World程序还没有添加到版本库中,在:file:src目录下有下列文件:

$ cd /path/to/my/workspace/demo
$ ls src
hello main.c main.o Makefile version.h version.h.in

这些文件中:file:hello,:file:main.o和:file:version.h都是在编译时生成的程序,不应该加入到版本库中。那么选择性添加文件除了针对文件逐一使用:command:git add命令外,还有什么办法么?通过使用-i参数调用:command:git add就是一个办法,提供了一个交互式的界面。

git add -i

执行:command:git add -i命令,进入一个交互式界面,首先显示的是工作区状态。显然因为版本库进行了清理,所以显得很“干净”。

$ git add -i
 staged unstaged path
*** Commands ***
 1: status 2: update 3: revert 4: add untracked
 5: patch 6: diff 7: quit 8: help
What now>

在交互式界面显示了命令列表,可以使用数字或者加亮显示的命令首字母,选择相应的功能。对于此例需要将新文件加入到版本库,所以选择“4”。

What now> 4
 1: src/Makefile
 2: src/hello
 3: src/main.c
 4: src/main.o
 5: src/version.h
 6: src/version.h.in
Add untracked>>

当选择了“4”之后,就进入了“Add untracked”界面,显示了本地新增(尚不再版本库中)的文件列表,而且提示符也变了,由“What now>”变为“Add untracked>>”。依次输入1、3、6将源代码添加到版本库中。

  • 输入“1”:
Add untracked>> 1
 * 1: src/Makefile
 2: src/hello
 3: src/main.c
 4: src/main.o
 5: src/version.h
 6: src/version.h.in
  • 输入“3”:
Add untracked>> 3
 * 1: src/Makefile
 2: src/hello
 * 3: src/main.c
 4: src/main.o
 5: src/version.h
 6: src/version.h.in
  • 输入“6”:
Add untracked>> 6
* 1: src/Makefile
 2: src/hello
* 3: src/main.c
 4: src/main.o
 5: src/version.h
* 6: src/version.h.in
Add untracked>>

每次输入文件序号,对应的文件前面都添加一个星号,代表将此文件添加到暂存区。在提示符“Add untracked>>”处按回车键,完成文件添加,返回主界面。

Add untracked>>
added 3 paths
*** Commands ***
 1: status 2: update 3: revert 4: add untracked
 5: patch 6: diff 7: quit 8: help
What now>

此时输入“1”查看状态,可以看到三个文件添加到暂存区中。

 staged unstaged path
 1: +20/-0 nothing src/Makefile
 2: +10/-0 nothing src/main.c
 3: +6/-0 nothing src/version.h.in
*** Commands ***
 1: status 2: update 3: revert 4: add untracked
 5: patch 6: diff 7: quit 8: help

输入“7”退出交互界面。

查看文件状态,可以发现三个文件被添加到暂存区中。

$ git status -s
A src/Makefile
A src/main.c
A src/version.h.in
?? src/hello
?? src/main.o
?? src/version.h

完成提交。

$ git commit -m "Hello world initialized."
[master d71ce92] Hello world initialized.
 3 files changed, 36 insertions(+), 0 deletions(-)
 create mode 100644 src/Makefile
 create mode 100644 src/main.c
 create mode 100644 src/version.h.in

Hello world引发的新问题

进入:file:src目录中,对Hello world执行编译。

$ cd /path/to/my/workspace/demo/src
$ make clean && make
rm -f hello main.o version.h
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o

运行编译后的程序,是不是对版本输出不满意呢?

$ ./hello
Hello, world.
version: old_practice-4-gd71ce92.

之所以显示长长的版本号,是因为使用了在本章最开始留的“影”。现在为Hello world留下一个新的“影”(一个新的里程碑)吧。

$ git tag -m "Set tag hello_1.0." hello_1.0

然后清除上次编译结果后,重新编译和运行,可以看到新的输出。

$ make clean && make
rm -f hello main.o version.h
version.h.in => version.h
cc -c -o main.o main.c
cc -o hello main.o
$ ./hello
Hello, world.
version: hello_1.0.

还不错,显示了新的版本号。此时在工作区查看状态,会发现工作区“不干净”。

$ git status
# On branch master
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# hello
# main.o
# version.h

编译的目标文件和以及从模板生成的头文件出现在了Git的状态输出中,这些文件会对以后的工作造成干扰。当写了新的源代码文件需要添加到版本库中时,因为这些干扰文件的存在,不得不一一将这些干扰文件排除在外。更为严重的是,如果不小心执行:command:git add .或者:command:git add -A命令会将编译的目标文件及其他临时文件加入版本库中,浪费存储空间不说甚至还会造成冲突。

Git提供了文件忽略功能,可以解决这个问题。


文件忽略

Git提供了文件忽略功能。当对工作区某个目录或者某些文件设置了忽略后,再执行:command:git status 查看状态时,被忽略的文件即使存在也不会显示为未跟踪状态,甚至根本感觉不到这些文件的存在。现在就针对Hello world程序目录试验一下。

$ cd /path/to/my/workspace/demo/src
$ git status -s
?? hello
?? main.o
?? version.h

可以看到:file:src目录下编译的目标文件等显示为未跟踪,每一行开头的两个问号好像在向我们请求:“快把我们添加到版本库里吧”。

执行下面的命令可以在这个目下创建一个名为:file:.gitignore的文件(注意文件的前面有个点),把这些要忽略的文件写在其中,文件名可以使用通配符。注意:第2行到第5行开头的右尖括号是:command:cat命令的提示符,不是输入。

$ cat > .gitignore << EOF
> hello
> *.o
> *.h
> EOF

看看写好的:file:.gitignore文件。每个要忽略的文件显示在一行。

$ cat .gitignore
hello
*.o
*.h

再来看看当前工作区的状态。

$ git status -s
?? .gitignore

把:file:.gitignore文件添加到版本库中吧。(如果不希望添加到库里,也不希望:file:.gitignore文件带来干扰,可以在忽略文件中忽略自己。)

$ git add .gitignore
$ git commit -m "ignore object files."
[master b3af728] ignore object files.
 1 files changed, 3 insertions(+), 0 deletions(-)
 create mode 100644 src/.gitignore

:file:.gitignore文件可以放在任何目录

文件:file:.gitignore的作用范围是其所处的目录及其子目录,因此如果把刚刚创建的:file:.gitignore移动到上一层目录(仍位于工作区内)也应该有效。

$ git mv .gitignore ..
$ git status
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# renamed: .gitignore -> ../.gitignore
#

果然移动:file:.gitignore文件到上层目录,Hello world程序目录下的目标文件依然被忽略着。

提交。

$ git commit -m "move .gitignore outside also works."
[master 3488f2c] move .gitignore outside also works.
 1 files changed, 0 insertions(+), 0 deletions(-)
 rename src/.gitignore => .gitignore (100%)

忽略文件有错误,后果很严重

实际上面写的忽略文件不是非常好,为了忽略:file:version.h,结果使用了通配符*.h会把源码目录下的有用的头文件也给忽略掉,导致应该添加到版本库的文件忘记添加。

在当前目录下创建一个新的头文件:file:hello.h

$ echo "/* test */" > hello.h

在工作区状态显示中看不到:file:hello.h文件。

$ git status
# On branch master
nothing to commit (working directory clean)

只有使用了–ignored参数,才会在状态显示中看到被忽略的文件。

git status --ignored -s

$ git status --ignored -s
!! hello
!! hello.h
!! main.o
!! version.h

要添加:file:hello.h文件,使用:command:git add -A和:command:git add .都失效。无法用这两个命令将:file:hello.h添加到暂存区中。

$ git add -A
$ git add .
$ git status -s

git add -f hello.h

只有在添加操作的命令行中明确的写入文件名,并且提供-f参数才能真正添加。

$ git add -f hello.h
$ git commit -m "add hello.h"
[master 48456ab] add hello.h
 1 files changed, 1 insertions(+), 0 deletions(-)
 create mode 100644 src/hello.h

忽略只对未跟踪文件有效,对于已加入版本库的文件无效

文件:file:hello.h添加到版本库后,就不再受到:file:.gitignore设置的文件忽略影响了,对:file:hello.h的修改都会立刻被跟踪到。这是因为Git的文件忽略只是对未入库的文件起作用

$ echo "/* end */" >> hello.h
$ git status
# On branch master
# Changed but not updated:
# (use "git add <file>..." to update what will be committed)
# (use "git checkout -- <file>..." to discard changes in working directory)
#
# modified: hello.h
#
no changes added to commit (use "git add" and/or "git commit -a")

偷懒式提交。(使用了-a参数提交,不用预先执行:command:git add命令。)

git commit -a -m “偷懒了,直接用 -a 参数直接提交。”

$ git commit -a -m "偷懒了,直接用 -a 参数直接提交。"
[master 613486c] 偷懒了,直接用 -a 参数直接提交。
 1 files changed, 1 insertions(+), 0 deletions(-)

本地独享式忽略文件

文件:file:.gitignore设置的文件忽略是共享式的。之所以称其为“共享式”,是因为:file:.gitignore被添加到版本库后成为了版本库的一部分,当版本库共享给他人(克隆)或者把版本库推送(PUSH)到集中式的服务器(或他人的版本库),这个忽略文件就会出现在他人的工作区中,文件忽略在他人的工作区中同样生效。

与“共享式”忽略对应的是“独享式”忽略。独享式忽略就是不会因为版本库共享或者版本库之间的推送传递给他人的文件忽略。独享式忽略有两种方式:

  • 一种是针对具体版本库的“独享式”忽略。即在版本库:file:.git目录下的一个文件:file:.git/info/exclude来设置文件忽略。
  • 另外一种是全局的“独享式”忽略。即通过Git的配置变量core.excludesfile指定的一个忽略文件,其设置的忽略对所有文件均有效。

至于哪些情况需要通过向版本库中提交:file:.gitignore文件设置共享式的文件忽略,哪些情况通过:file:.git/info/exclude设置只对本地有效的独享式文件忽略,这取决于要设置的文件忽略是否具有普遍意义。如果文件忽略对于所有使用此版本库工作的人都有益,就通过在版本库相应的目录下创建一个:file:.gitignore文件建立忽略,否则如果是需要忽略工作区中创建的一个试验目录或者试验性的文件,则使用本地忽略。

例如我的本地就设置着一个全局的独享的文件忽略列表(这个文件名可以随意设置):

$ git config --global core.excludesfile /home/jiangxin/_gitignore
$ git config core.excludesfile
/home/jiangxin/_gitignore
$ cat /home/jiangxin/_gitignore
*~ # vim 临时文件
*.pyc # python 的编译文件
.*.mmx # 不是正则表达式哦,因为 FreeMind-MMX 的辅助文件以点开头

Git忽略语法

Git的忽略文件的语法规则再多说几句。

  • 忽略文件中的空行或者以井号(#)开始的行被忽略。
  • 可以使用通配符,参见Linux手册:glob(7)。例如:星号(*)代表任意多字符,问号(?)代表一个字符,方括号([abc])代表可选字符范围等。
  • 如果名称的最前面是一个路径分隔符(/),表明要忽略的文件在此目录下,而非子目录的文件。
  • 如果名称的最后面是一个路径分隔符(/),表明要忽略的是整个目录,同名文件不忽略,否则同名的文件和目录都忽略。
  • 通过在名称的最前面添加一个感叹号(!),代表不忽略。

下面的文件忽略示例,包含了上述要点:

# 这是注释行 —— 被忽略
*.a # 忽略所有以 .a 为扩展名的文件。
!lib.a # 但是 lib.a 文件或者目录不要忽略,即使前面设置了对 *.a 的忽略。
/TODO # 只忽略根目录下的 TODO 文件,子目录的 TODO 文件不忽略。
build/ # 忽略所有 build/ 目录下的文件。
doc/*.txt # 忽略文件如 doc/notes.txt,但是文件如 doc/server/arch.txt 不被忽略。

文件归档

如果使用压缩工具(tar、7zip、winzip、rar等)将工作区文件归档,一不小心会把版本库(:file:.git目录)包含其中,甚至将工作区中的忽略文件、临时文件也包含其中

Git提供了一个归档命令::command:git archive,可以对任意提交对应的目录树建立归档。示例如下:

  • 基于最新提交建立归档文件:file:latest.zip
$ git archive -o latest.zip HEAD
  • 只将 目录:file:src和:file:doc建立到归档:file:partial.tar 中。
$ git archive -o partial.tar HEAD src doc
  • 基于里程碑v1.0建立归档,并且为归档中文件添加目录前缀1.0
$ git archive --format=tar --prefix=1.0/ v1.0 | gzip > foo-1.0.tar.gz

在建立归档时,如果使用树对象ID进行归档,则使用当前时间作为归档中文件的修改时间,而如果使用提交ID或里程碑等,则使用提交建立的时间作为归档中文件的修改时间。

如果使用tar格式建立归档,并且使用提交ID或里程碑ID,还会把提交ID记录在归档文件的文件头中。记录在文件头中的提交ID可以通过:command:git tar-commit-id命令获取。

如果希望在建立归档时忽略某些文件或目录,可以通过为相应文件或目录建立export-ignore属性加以实现。具体参见本书第8篇第41章“41.1 属性”一节。


八、 历史穿梭

经过了之前众多的实践,版本库中已经积累了很多次提交了,从下面的命令可以看出来有13次提交。

git rev-list HEAD

$ git rev-list HEAD
1346266e63d0f30ae25352f80266bc11d6cfeee8
f925ff736d4299c0594031847a617e332311a136
96a6bfc23a64ac77d324826483b0b3d53374992d
2b82d10eafd2c3a2538116c9a9eaae449eec27d6
2a907f07e772546e28997d8a14577af4070de094
3849a39c42a3bdbf4ef1086d7e2ab891f24e4574
51d7cbd4214604321396f564825394997e6610e9
06a2b2009b31decd515bab8fa7eecb20f9799a6a
dac26c20859ee0d3baa8a10fa76a5d621ca8fbc3
6750cde6c905dff70e5dbebc72c68393fd129839
b64069c7b12861f34031fa0c70964130f6769acb
817ed41c6f5dd33ea45fdcb52f48111986c6955f
4a462ab4e614d17958932521a36e36baf12ed8dd

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide/src (master)
$ git rev-list HEAD | wc -l
13

有很多工具可以研究和分析Git的历史提交,在前面的实践中已经用过很多相关的Git命令进行查看历史提交、查看文件的历史版本、进行差异比较等。


命令行工具

使用Git命令行探索版本库历史对于读者来说并不新鲜,因为在前几章的实践中已经用到了相关命令,展示了对历史记录的操作。本节对这些命令的部分要点进行强调和补充。

前面历次实践的提交基本上是线性的提交,研究起来没有挑战性。为了能够更加接近于实际又不失简洁,我构造了一个版本库,放在了Github上。可以通过如下操作在本地克隆这个示例版本库。

$ cd /path/to/my/workspace/
$ git clone git://github.com/ossxp-com/gitdemo-commit-tree.git
Cloning into gitdemo-commit-tree...
remote: Counting objects: 63, done.
remote: Compressing objects: 100% (51/51), done.
remote: Total 63 (delta 8), reused 0 (delta 0)
Receiving objects: 100% (63/63), 65.95 KiB, done.
Resolving deltas: 100% (8/8), done.
$ cd gitdemo-commit-tree

运行gitg命令,显示其提交关系图。
在这里插入图片描述
是不是有点“乱花渐欲迷人眼”的感觉。如果把提交用里程碑标识的圆圈来代表,稍加排1列就会看到下面的更为直白的提交关系图。

在这里插入图片描述

Git的大部分命令可以使用提交版本作为参数(如::command:git diff <commit-id>),有的命令则使用一个版本范围作为参数(如::command:git log <rev1>..<rev2>)。Git的提交有着各式各样的表示法,提交范围也是一样,下面就通过两个命令:command:git rev-parse和:command:git rev-list分别研究一下Git的版本表示法和版本范围表示法。


版本表示法::command:git rev-parse

命令:command:git rev-parse是Git的一个底层命令,其功能非常丰富(或者说杂乱),很多Git脚本或工具都会用到这条命令。

此命令的部分应用在“Git初始化”章节中就已经看到。例如可以显示Git版本库的位置(–git-dir),当前工作区目录的深度(–show-cdup),甚至可以用于被Git无关应用用于解析命令行参数(–parseopt)。

此命令可以显示当前版本库中的引用。

  • 显示分支。
$ git rev-parse --symbolic --branches
master
  • 显示里程碑。
$ git rev-parse --symbolic --tags
A
B
C
D
E
F
G
H
I
J
  • 显示定义的所有引用。

其中:file:refs/remotes/目录下的引用成为远程分支(或远程引用),在后面的章节会予以介绍。

$ git rev-parse --symbolic --glob=refs/*
refs/heads/master
refs/remotes/origin/HEAD
refs/remotes/origin/master
refs/tags/A
refs/tags/B
refs/tags/C
refs/tags/D
refs/tags/E
refs/tags/F
refs/tags/G
refs/tags/H
refs/tags/I
refs/tags/J

命令:command:git rev-parse另外一个重要的功能就是将一个Git对象表达式表示为对应的SHA1哈希值。针对本节开始克隆的版本库gitdemo-commit-tree,做如下操作。

  • 显示HEAD对应的SHA1哈希值。
$ git rev-parse HEAD
6652a0dce6a5067732c00ef0a220810a7230655e
  • 命令:command:git describe的输出也可以显示为SHA1哈希值。
$ git describe
A-1-g6652a0d
$ git rev-parse A-1-g6652a0d
6652a0dce6a5067732c00ef0a220810a7230655e
  • 可以同时显示多个表达式的SHA1哈希值。

下面的操作可以看出master和refs/heads/master都可以用于指代master分支。

$ git rev-parse master refs/heads/master
6652a0dce6a5067732c00ef0a220810a7230655e
6652a0dce6a5067732c00ef0a220810a7230655e
  • 可以用哈希值的前几位指代整个哈希值。
$ git rev-parse 6652 6652a0d
6652a0dce6a5067732c00ef0a220810a7230655e
6652a0dce6a5067732c00ef0a220810a7230655e
  • 里程碑的两种表示法均指向相同的对象。

里程碑对象不一定是提交,有可能是一个Tag对象。Tag对象包含说明或者签名,还包括到对应提交的指向。

$ git rev-parse A refs/tags/A
c9b03a208288aebdbfe8d84aeb984952a16da3f2
c9b03a208288aebdbfe8d84aeb984952a16da3f2
  • 里程碑A指向了一个Tag对象而非提交的时候,用下面的三个表示法都可以指向里程碑对应的提交。

    实际上下面的语法也可以直接作用于轻量级里程碑(直接指向提交的里程碑)或者作用于提交本身。

$ git rev-parse A^{} A^0 A^{commit}
81993234fc12a325d303eccea20f6fd629412712
81993234fc12a325d303eccea20f6fd629412712
81993234fc12a325d303eccea20f6fd629412712
  • A的第一个父提交就是B所指向的提交。

    回忆之前的介绍,^ 操作符代表着父提交。当一个提交有多个父提交时,可以通过在符号 ^ 后面跟上一个数字表示第几个父提交。A ^ 就相当于 A ^ 1 。而B ^ 0 代表了B所指向的一个Commit对象(因为B是Tag对象)。

$ git rev-parse A^ A^1 B^0
776c5c9da9dcbb7e463c061d965ea47e73853b6e
776c5c9da9dcbb7e463c061d965ea47e73853b6e
776c5c9da9dcbb7e463c061d965ea47e73853b6e
  • 更为复杂的表示法。

连续的 ^ 符号依次沿着父提交进行定位至某一祖先提交。^ 后面的数字代表该提交的第几个父提交。

$ git rev-parse A^^3^2 F^2 J^{}
3252fcce40949a4a622a1ac012cb120d6b340ac8
3252fcce40949a4a622a1ac012cb120d6b340ac8
3252fcce40949a4a622a1ac012cb120d6b340ac8
  • 记号 ~< n > 就相当于连续 < n > 个符号^。
$ git rev-parse A~3 A^^^ G^0
e80aa7481beda65ae00e35afc4bc4b171f9b0ebf
e80aa7481beda65ae00e35afc4bc4b171f9b0ebf
e80aa7481beda65ae00e35afc4bc4b171f9b0ebf
  • 显示里程碑A对应的目录树。下面两种写法都可以。
$ git rev-parse A^{tree} A:
95ab9e7db14ca113d5548dc20a4872950e8e08c0
95ab9e7db14ca113d5548dc20a4872950e8e08c0
  • 显示树里面的文件,下面两种表示法均可。
$ git rev-parse A^{tree}:src/Makefile A:src/Makefile
96554c5d4590dbde28183e9a6a3199d526eeb925
96554c5d4590dbde28183e9a6a3199d526eeb925
  • 暂存区里的文件和HEAD中的文件相同。
$ git rev-parse :gitg.png HEAD:gitg.png
fc58966ccc1e5af24c2c9746196550241bc01c50
fc58966ccc1e5af24c2c9746196550241bc01c50
  • 还可以通过在提交日志中查找字串的方式显示提交。
$ git rev-parse :/"Commit A"
81993234fc12a325d303eccea20f6fd629412712
  • 再有就是reflog相关的语法,参见“Git重置”章节中关于reflog的介绍。
$ git rev-parse HEAD@{0} master@{0}
6652a0dce6a5067732c00ef0a220810a7230655e
6652a0dce6a5067732c00ef0a220810a7230655e

版本范围表示法:git rev-list

有的Git命令可以使用一个版本范围作为参数,命令:command:git rev-list可以帮助研究Git的各种版本范围语法。

在这里插入图片描述

  • 一个提交ID实际上就可以代表一个版本列表。含义是:该版本开始的所有历史提交
$ git rev-list --oneline A
8199323 Commit A: merge B with C.
0cd7f2e commit C.
776c5c9 Commit B: merge D with E and F
beb30ca Commit F: merge I with J
212efce Commit D: merge G with H
634836c commit I.
3252fcc commit J.
83be369 commit E.
2ab52ad commit H.
e80aa74 commit G.
  • 两个或多个版本,相当于每个版本单独使用时指代的列表的并集。
$ git rev-list --oneline D F
beb30ca Commit F: merge I with J
212efce Commit D: merge G with H
634836c commit I.
3252fcc commit J.
2ab52ad commit H.
e80aa74 commit G.
  • 在一个版本前面加上符号(^)含义是取反,即排除这个版本及其历史版本。
$ git rev-list --oneline ^G D
212efce Commit D: merge G with H
2ab52ad commit H.
  • 和上面等价的“点点”表示法。使用两个点连接两个版本,如G…D,就相当于^G D。
$ git rev-list --oneline G..D
212efce Commit D: merge G with H
2ab52ad commit H.
  • 版本取反,参数的顺序不重要,但是“点点”表示法前后的版本顺序很重要。

    • 语法:^B C

    在这里插入图片描述

    • 语法:C ^B

    在这里插入图片描述

    • 语法:B…C相当于^B C

    在这里插入图片描述

    • 语法:C…B相当于^C B

    在这里插入图片描述

浏览日志::command:git log

命令:command:git log是老朋友了,在前面的章节中曾经大量的出现,用于显示提交历史。

参数代表版本范围

当不使用任何参数调用,相当于使用了缺省的参数HEAD,即显示当前HEAD能够访问到的所有历史提交。还可以使用上面介绍的版本范围表示法,例如:

$ git log --oneline F^! D
beb30ca Commit F: merge I with J
212efce Commit D: merge G with H
2ab52ad commit H.
e80aa74 commit G.

分支图显示

git log --graph

通过–graph参数调用:command:git log可以显示字符界面的提交关系图,而且不同的分支还可以用不同的颜色来表示。如果希望每次查看日志的时候都看到提交关系图,可以设置一个别名,用别名来调用。

$ git config --global alias.glog "log --graph"

定义别名之后,每次希望自动显示提交关系图,就可以使用别名命令:

$ git glog --oneline
* 6652a0d Add Images for git treeview.
* 8199323 Commit A: merge B with C.
|\
| * 0cd7f2e commit C.
| |
| \
*-. \ 776c5c9 Commit B: merge D with E and F
|\ \ \
| | |/
| | * beb30ca Commit F: merge I with J
| | |\
| | | * 3252fcc commit J.
| | * 634836c commit I.
| * 83be369 commit E.
* 212efce Commit D: merge G with H
|\
| * 2ab52ad commit H.
* e80aa74 commit G.

显示最近的几条日志

git log -3

可以使用参数-< n >(< n >为数字),显示最近的< n >条日志。例如下面的命令显示最近的3条日志。

$ git log -3 --pretty=oneline
6652a0dce6a5067732c00ef0a220810a7230655e Add Images for git treeview.
81993234fc12a325d303eccea20f6fd629412712 Commit A: merge B with C.
0cd7f2ea245d90d414e502467ac749f36aa32cc4 commit C.

显示每次提交的具体改动

git log -p

使用参数-p可以在显示日志的时候同时显示改动。

$ git log -p -1
commit 6652a0dce6a5067732c00ef0a220810a7230655e
Author: Jiang Xin <jiangxin@ossxp.com>
Date: Thu Dec 9 16:07:11 2010 +0800
 Add Images for git treeview.
 Signed-off-by: Jiang Xin <jiangxin@ossxp.com>
diff --git a/gitg.png b/gitg.png
new file mode 100644
index 0000000..fc58966
Binary files /dev/null and b/gitg.png differ
diff --git a/treeview.png b/treeview.png
new file mode 100644
index 0000000..a756d12
Binary files /dev/null and b/treeview.png differ

因为是二进制文件改动,缺省不显示改动的内容。实际上Git的差异文件提供对二进制文件的支持,在后面“Git应用”章节予以专题介绍。

显示每次提交的变更概要

git log --stat

使用-p参数会让日志输出显得非常冗余,当不需要知道具体的改动而只想知道改动在哪些文件上,可以使用–stat参数。输出的变更概要像极了Linux 的:command:diffstat命令的输出。

$ git log --stat --oneline I..C
0cd7f2e commit C.
 README | 1 +
 doc/C.txt | 1 +
 2 files changed, 2 insertions(+), 0 deletions(-)
beb30ca Commit F: merge I with J
3252fcc commit J.
 README | 7 +++++++
 doc/J.txt | 1 +
 src/.gitignore | 3 +++
 src/Makefile | 27 +++++++++++++++++++++++++++
 src/main.c | 10 ++++++++++
 src/version.h.in | 6 ++++++
 6 files changed, 54 insertions(+), 0 deletions(-)

定制输出

Git的差异输出命令提供了很多输出模板提供选择,可以根据需要选择冗余显示或者精简显示。

git log --pretty=raw

  • 参数–pretty=raw显示提交的原始数据。可以显示提交对应的树ID
$ git log --pretty=raw -1
commit 6652a0dce6a5067732c00ef0a220810a7230655e
tree e33be9e8e7ca5f887c7d5601054f2f510e6744b8
parent 81993234fc12a325d303eccea20f6fd629412712
author Jiang Xin <jiangxin@ossxp.com> 1291882031 +0800
committer Jiang Xin <jiangxin@ossxp.com> 1291882892 +0800
 Add Images for git treeview.
 Signed-off-by: Jiang Xin <jiangxin@ossxp.com>

git log --pretty=fuller

  • 参数–pretty=fuller会同时显示作者和提交者,两者可以不同。
$ git log --pretty=fuller -1
commit 6652a0dce6a5067732c00ef0a220810a7230655e
Author: Jiang Xin <jiangxin@ossxp.com>
AuthorDate: Thu Dec 9 16:07:11 2010 +0800
Commit: Jiang Xin <jiangxin@ossxp.com>
CommitDate: Thu Dec 9 16:21:32 2010 +0800
 Add Images for git treeview.
 Signed-off-by: Jiang Xin <jiangxin@ossxp.com>

git log --pretty=oneline

  • 参数–pretty=oneline显然会提供最精简的日志输出。也可以使用–oneline参数,效果近似。
$ git log --pretty=oneline -1
6652a0dce6a5067732c00ef0a220810a7230655e Add Images for git treeview.

如果只想查看、分析某一个提交,也可以使用:command:git show或者:command:git cat-file命令

git show

  • 使用:command:git show显示里程碑D及其提交:
$ git show D --stat
tag D
Tagger: Jiang Xin <jiangxin@ossxp.com>
Date: Thu Dec 9 14:24:52 2010 +0800
create node D
commit 212efce1548795a1edb08e3708a50989fcd73cce
Merge: e80aa74 2ab52ad
Author: Jiang Xin <jiangxin@ossxp.com>
Date: Thu Dec 9 14:06:34 2010 +0800
 Commit D: merge G with H
 Signed-off-by: Jiang Xin <jiangxin@ossxp.com>
 README | 2 ++
 doc/D.txt | 1 +
 doc/H.txt | 1 +
 3 files changed, 4 insertions(+), 0 deletions(-)

git cat-file

  • 使用:command:git cat-file显示里程碑D及其提交。

    参数-p的含义是美观的输出(pretty)。

$ git cat-file -p D^0
tree 1c22e90c6bf150ee1cde6cefb476abbb921f491f
parent e80aa7481beda65ae00e35afc4bc4b171f9b0ebf
parent 2ab52ad2a30570109e71b56fa1780f0442059b3c
author Jiang Xin <jiangxin@ossxp.com> 1291874794 +0800
committer Jiang Xin <jiangxin@ossxp.com> 1291875877 +0800
Commit D: merge G with H
Signed-off-by: Jiang Xin <jiangxin@ossxp.com>

差异比较::command:git diff

Git差异比较功能在前面的实践中也反复的接触过了,尤其是在介绍暂存区的相关章节重点介绍了:command:git diff命令如何对工作区、暂存区、版本库进行比较。

  • 比较里程碑B和里程碑A,用命令::command:git diff B A
  • 比较工作区和里程碑A,用命令::command:git diff A
  • 比较暂存区和里程碑A,用命令::command:git diff --cached A
  • 比较工作区和暂存区,用命令::command:git diff
  • 比较暂存区和HEAD,用命令::command:git diff --cached
  • 比较工作区和HEAD,用命令::command:git diff HEAD

Git中文件在版本间的差异比较

差异比较还可以使用路径参数,只显示不同版本间该路径下文件的差异。语法格式:

$ git diff <commit1> <commit2> -- <paths>

非Git目录/文件的差异比较

命令:command:git diff还可以在Git版本库之外执行,对非Git目录进行比较,就像GNU的:command:diff命令一样。之所以提供这个功能是因为Git差异比较命令更为强大,提供了对GNU差异比较的扩展支持。

$ git diff <path1> <path2>

扩展的差异语法

Git扩展了GNU的差异比较语法,提供了对重命名、二进制文件、文件权限变更的支持。在后面的“Git应用”辟专题介绍二进制文件的差异比较和补丁的应用。

逐词比较,而非缺省的逐行比较

git diff --word-diff

Git的差异比较缺省是逐行比较,分别显示改动前的行和改动后的行,到底改动哪里还需要仔细辨别。Git还提供一种逐词比较的输出,有的人会更喜欢。使用–word-diff参数可以显示逐词比较。

$ git diff --word-diff
diff --git a/src/book/02-use-git/080-git-history-travel.rst b/src/book/02-use-git/080-git-history-travel.rst
index f740203..2dd3e6f 100644
--- a/src/book/02-use-git/080-git-history-travel.rst
+++ b/src/book/02-use-git/080-git-history-travel.rst
@@ -681,7 +681,7 @@ Git的大部分命令可以使用提交版本作为参数(如:git diff),
::
 [-18:23:48 jiangxin@hp:~/gitwork/gitbook/src/book$-]{+$+} git log --stat --oneline I..C
 0cd7f2e commit C.
 README | 1 +
 doc/C.txt | 1 +

上面的逐词差异显示是有颜色显示的:删除内容[-…-]用红色表示,添加的内容{+…+}用绿色表示。


文件追溯::command:git blame

在软件开发过程中当发现Bug并定位到具体的代码时,Git的文件追溯命令可以指出是谁在什么时候,什么版本引入的此Bug。

当针对文件执行:command:git blame命令,就会逐行显示文件,在每一行的行首显示此行最早是在什么版本引入的,由谁引入。

$ cd /path/to/my/workspace/gitdemo-commit-tree
$ git blame README
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 1) DEMO program for git-scm-book.
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 2)
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 3) Changes
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 4) =======
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 5)
81993234 (Jiang Xin 2010-12-09 14:30:15 +0800 6) * create node A.
0cd7f2ea (Jiang Xin 2010-12-09 14:29:09 +0800 7) * create node C.
776c5c9d (Jiang Xin 2010-12-09 14:27:31 +0800 8) * create node B.
beb30ca7 (Jiang Xin 2010-12-09 14:11:01 +0800 9) * create node F.
^3252fcc (Jiang Xin 2010-12-09 14:00:33 +0800 10) * create node J.
^634836c (Jiang Xin 2010-12-09 14:00:33 +0800 11) * create node I.
^83be369 (Jiang Xin 2010-12-09 14:00:33 +0800 12) * create node E.
212efce1 (Jiang Xin 2010-12-09 14:06:34 +0800 13) * create node D.
^2ab52ad (Jiang Xin 2010-12-09 14:00:33 +0800 14) * create node H.
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 15) * create node G.
^e80aa74 (Jiang Xin 2010-12-09 14:00:33 +0800 16) * initialized.

只想查看某几行,使用-L n,m参数,如下:

$ git blame -L 6,+5 README
81993234 (Jiang Xin 2010-12-09 14:30:15 +0800 6) * create node A.
0cd7f2ea (Jiang Xin 2010-12-09 14:29:09 +0800 7) * create node C.
776c5c9d (Jiang Xin 2010-12-09 14:27:31 +0800 8) * create node B.
beb30ca7 (Jiang Xin 2010-12-09 14:11:01 +0800 9) * create node F.
^3252fcc (Jiang Xin 2010-12-09 14:00:33 +0800 10) * create node J.

二分查找::command:git bisect

前面的文件追溯是建立在问题(Bug)已经定位(到代码上)的基础之上,然后才能通过错误的行(代码)找到人(提交者),打板子(教育或惩罚)。那么如何定位问题呢?Git的二分查找命令可以提供帮助。

二分查找并不神秘,也不是万灵药,是建立在测试的基础之上的。实际上每个进行过软件测试的人都曾经使用过:“最新的版本出现Bug了,但是在给某某客户的版本却没有这个问题,所以问题肯定出在两者之间的某次代码提交上”。

Git提供的:command:git bisect命令是基于版本库的,自动化的问题查找和定位工作流程。取代传统软件测试中粗放式的、针对软件发布版本的、无法定位到代码的测试。

执行二分查找,在发现问题后,首先要找到一个正确的版本,如果所发现的问题从软件最早的版本就是错的,那么就没有必要执行二分查找了,还是老老实实的Debug吧。但是如果能够找到一个正确的版本,即在这个正确的版本上问题没有发生,那么就可以开始使用:command:git bisect命令在版本库中进行二分查找了:

  1. 工作区切换到已知的“好版本”和“坏版本”的中间的一个版本。
  2. 执行测试,问题重现,将版本库当前版本库为“坏版本”,如果问题没有重现,将当前版本标记为“好版本”。
  3. 重复1-2,直至最终找到第一个导致问题出现的版本。

下面是示例版本库标记了提交ID后的示意图,在这个示例版本库中试验二分查找流程:首先标记最新提交(HEAD)是“坏的”,G提交是好的,然后通过查找最终定位到坏提交(B)

在这里插入图片描述

在下面的试验中定义坏提交的依据很简单,如果在:file:doc/目录中包含文件:file:B.txt,则此版本是“坏”的。(这个示例太简陋,不要见笑,聪明的读者可以直接通过:file:doc/B.txt文件就可追溯到B提交。)

下面开始通过手动测试(查找:file:doc/B.txt存在与否),借助Git二分查找定位“问
题”版本。

  • 首先确认工作在master分支。
$ cd /path/to/my/workspace/gitdemo-commit-tree/
$ git checkout master
Already on 'master'
  • 开始二分查找。
$ git bisect start
  • 已经当前版本是“坏提交”,因为存在文件:file:doc/B.txt。而G版本是“好提交”,因为不存在文件:file:doc/B.txt
$ git cat-file -t master:doc/B.txt
blob
$ git cat-file -t G:doc/B.txt
fatal: Not a valid object name G:doc/B.txt
  • 将当前版本(HEAD)标记为“坏提交”,将G版本标记为“好提交”。
$ git bisect bad
$ git bisect good G
Bisecting: 5 revisions left to test after this (roughly 2 steps)
[0cd7f2ea245d90d414e502467ac749f36aa32cc4] commit C.
  • 自动定位到C提交。没有文件:file:doc/B.txt,也是一个好提交。
$ git describe
C
$ ls doc/B.txt
ls: 无法访问doc/B.txt: 没有那个文件或目录
  • 标记当前版本(C提交)为“好提交”。
$ git bisect good
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[212efce1548795a1edb08e3708a50989fcd73cce] Commit D: merge G with H
  • 现在定位到D版本,这也是一个“好提交”。
$ git describe
D
$ ls doc/B.txt
ls: 无法访问doc/B.txt: 没有那个文件或目录
  • 标记当前版本(D提交)为“好提交”。
$ git bisect good
Bisecting: 1 revision left to test after this (roughly 1 step)
[776c5c9da9dcbb7e463c061d965ea47e73853b6e] Commit B: merge D with E and F
  • 现在定位到B版本,这是一个“坏提交”。
$ git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[83be36956c007d7bfffe13805dd2081839fd3603] commit E.
  • 现在定位到E版本,这是一个“好提交”。当标记E为好提交之后,输出显示已经成功定位到引入坏提交的最接近的版本。
$ git bisect good
776c5c9da9dcbb7e463c061d965ea47e73853b6e is the first bad commit
  • 最终定位的坏提交用引用:file:refs/bisect/bad标识。可以如下方法切换到该版
    本。
$ git checkout bisect/bad
Previous HEAD position was 83be369... commit E.
HEAD is now at 776c5c9... Commit B: merge D with E and F
  • 当对“Bug”定位和修复后,撤销二分查找在版本库中遗留的临时文件和引用。
    撤销二分查找后,版本库切换回执行二分查找之前所在的分支。
$ git bisect reset
Previous HEAD position was 776c5c9... Commit B: merge D with E and F
Switched to branch 'master'

把“好提交”标记成了“坏提交”该怎么办?

在执行二分查找的过程中,一不小心就有可能犯错,将“好提交”标记为“坏提交”,或
者相反。这将导致前面的查找过程也前功尽弃。Git的二分查找提供一个恢复查找进度的办法。

  • 例如对E提交,本来是一个“好版本”却被错误的标记为“坏版本”。
$ git bisect bad
83be36956c007d7bfffe13805dd2081839fd3603 is the first bad commit
  • 用:command:git bisect log命令查看二分查找的日志记录。
    把二分查找的日志保存在一个文件中。
$ git bisect log > logfile
  • 编辑这个文件,删除记录了错误动作的行。
    以井号(#)开始的行是注释。
$ cat logfile
# bad: [6652a0dce6a5067732c00ef0a220810a7230655e] Add Images for git treeview.
# good: [e80aa7481beda65ae00e35afc4bc4b171f9b0ebf] commit G.
git bisect start 'master' 'G'
# good: [0cd7f2ea245d90d414e502467ac749f36aa32cc4] commit C.
git bisect good 0cd7f2ea245d90d414e502467ac749f36aa32cc4
# good: [212efce1548795a1edb08e3708a50989fcd73cce] Commit D: merge G with H
git bisect good 212efce1548795a1edb08e3708a50989fcd73cce
# bad: [776c5c9da9dcbb7e463c061d965ea47e73853b6e] Commit B: merge D with E and F
git bisect bad 776c5c9da9dcbb7e463c061d965ea47e73853b6e
  • 结束上一次出错的二分查找。
$ git bisect reset
Previous HEAD position was 83be369... commit E.
Switched to branch 'master'
  • 通过日志文件恢复进度。
$ git bisect replay logfile
We are not bisecting.
Bisecting: 5 revisions left to test after this (roughly 2 steps)
[0cd7f2ea245d90d414e502467ac749f36aa32cc4] commit C.
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[83be36956c007d7bfffe13805dd2081839fd3603] commit E.
  • 再一次回到了提交E,这一次不要标记错了。
$ git describe
E
$ git bisect good
776c5c9da9dcbb7e463c061d965ea47e73853b6e is the first bad commit

获取历史版本

提取历史提交中的文件无非就是下面表格中的操作,在之前的实践中多次用到,不再赘述。

在这里插入图片描述


九、改变历史

悔棋

在日常的Git操作中,会经常出现这样的状况,输入:command:git commit命令刚刚敲下回车键就后悔了:可能是提交说明中出现了错别字,或者有文件忘记提交,或者有的修改不应该提交,诸如此类。

像Subversion那样的集中式版本控制系统是“落子无悔”的系统,只能叹一口气责怪自己太不小心了。然后根据实际情况弥补:马上做一次新提交改正前面的错误;或者只能将错就错:错误的提交说明就让它一直错下去吧,因为大部分Subversion管理员不敢或者不会放开修改提交说明的功能导致无法对提交说明进行修改。

Git提供了“悔棋”的操作,甚至因为“单步悔棋”是如此经常的发生,乃至于Git提供了一个简洁的操作——修补式提交,命令是::command:git commit --amend

看看当前版本库最新的两次提交:

$ git log --stat -2
commit d757b1e129caa0c029e0cfbd0bbc869f5b9b83fc (HEAD -> master)
Author: luokaijie <1697065378@qq.com>
Date:   Thu Oct 20 01:07:37 2022 +0800

    测试使用 qgit 提交。

 README      | 2 ++
 src/hello.h | 2 --
 2 files changed, 2 insertions(+), 2 deletions(-)

commit 1346266e63d0f30ae25352f80266bc11d6cfeee8
Author: luokaijie <1697065378@qq.com>
Date:   Tue Oct 18 23:44:36 2022 +0800

    偷懒了,直接用 -a 参数直接提交.

 src/hello.h | 1 +
 1 file changed, 1 insertion(+)

git commit --amend -m

最新一次的提交是的确是在上一章使用qgit进行的提交,但这和提交内容无关,因此需要改掉这个提交的提交说明。使用下面的命令即可做到。

$ git commit --amend -m "Remove hello.h, which is useless."
[master 46ed38b] Remove hello.h, which is useless.
 Date: Thu Oct 20 01:07:37 2022 +0800
 2 files changed, 2 insertions(+), 2 deletions(-)
 delete mode 100644 src/hello.h

上面的命令使用了-m参数是为了演示的方便,实际上完全可以直接输入:command:git commit --amend,在弹出的提交说明编辑界面修改提交说明,然后保存退出完成修补提交。

下面再看看最近两次的提交说明,可以看到最新的提交说明更改了(包括提交的SHA1哈希值),而它的父提交(即前一次提交)没有改变。

$ git log --stat -2
commit 46ed38be7eba813ab1e56ac323da49f4292aa26d (HEAD -> master)
Author: luokaijie <1697065378@qq.com>
Date:   Thu Oct 20 01:07:37 2022 +0800

    Remove hello.h, which is useless.

 README      | 2 ++
 src/hello.h | 2 --
 2 files changed, 2 insertions(+), 2 deletions(-)

commit 1346266e63d0f30ae25352f80266bc11d6cfeee8
Author: luokaijie <1697065378@qq.com>
Date:   Tue Oct 18 23:44:36 2022 +0800

    偷懒了,直接用 -a 参数直接提交.

 src/hello.h | 1 +
 1 file changed, 1 insertion(+)

如果最后一步操作不想删除文件:file:src/hello.h,而只是想修改:file:README,则可以按照下面的方法进行修补操作。

$ git checkout HEAD^ – src/hello.h 还原删除的文件

  • 还原删除的:file:src/hello.h文件。
$ git checkout HEAD^ -- src/hello.h
  • 此时查看状态,会看到:file:src/hello.h被重新添加回暂存区。
$ git status
On branch master
Your branch is ahead of 'origin/master' by 6 commits.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   src/hello.h

  • 执行修补提交,不过提交说明是不是也要更改呢,因为毕竟这次提交不会删除文件了。
$ git commit --amend  -m "commit with --amend test."
[master ee02ad5] commit with --amend test.
 Date: Thu Oct 20 01:07:37 2022 +0800
 1 file changed, 2 insertions(+)
  • 再次查看最近两次提交,会发现最新的提交不再删除文件:file:src/hello.h了。
$ git log --stat -2
commit ee02ad56bf9ad5ccfb4bf2d4434e2798836be695 (HEAD -> master)
Author: luokaijie <1697065378@qq.com>
Date:   Thu Oct 20 01:07:37 2022 +0800

    commit with --amend test.

 README | 2 ++
 1 file changed, 2 insertions(+)

commit 1346266e63d0f30ae25352f80266bc11d6cfeee8
Author: luokaijie <1697065378@qq.com>
Date:   Tue Oct 18 23:44:36 2022 +0800

    偷懒了,直接用 -a 参数直接提交.

 src/hello.h | 1 +
 1 file changed, 1 insertion(+)

多步悔棋

Git能够提供悔棋的奥秘在于Git的重置命令。实际上上面介绍的单步悔棋也可以用重置命令来实现,只不过Git提供了一个更好用的更简洁的修补提交命令而已。多步悔棋顾名思义就是可以取消最新连续的多次提交,多次悔棋并非是所有分布式版本控制系统都具有的功能,像Mercurial/Hg只能对最新提交悔棋一次(除非使用MQ插件)。Git因为有了强大的重置命令,可以悔棋任意多次。

多步悔棋会在什么场合用到呢?软件开发中针对某个特性功能的开发就是一例。某个开发工程师领受某个特性开发的任务,于是在本地版本库进行了一系列开发、测试、修补、再测试的流程,最终特性功能开发完毕后可能在版本库中留下了多次提交。在将本地版本库改动推送(PUSH)到团队协同工作的核心版本库时,这个开发人员就想用多步悔棋的操作,将多个试验性的提及合为一个完整的提交。

以DEMO版本库为例,看看版本库最近的三次提交。

$ git log --stat --pretty=oneline -3
ee02ad56bf9ad5ccfb4bf2d4434e2798836be695 (HEAD -> master) commit with --amend test.
 README | 2 ++
 1 file changed, 2 insertions(+)
1346266e63d0f30ae25352f80266bc11d6cfeee8 偷懒了,直接用 -a 参数直接提交.
 src/hello.h | 1 +
 1 file changed, 1 insertion(+)
f925ff736d4299c0594031847a617e332311a136 add hello.h
 src/hello.h | 1 +
 1 file changed, 1 insertion(+)

想要将最近的两个提交压缩为一个,并把提交说明改为“modify hello.h”,可以使用如
下方法进行操作。

  • 使用–soft参数调用重置命令,回到最近两次提交之前。
$  git reset --soft HEAD^^
  • 版本状态和最新日志。
$ git status
On branch master
Your branch is ahead of 'origin/master' by 4 commits.
  (use "git push" to publish your local commits)

Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   README
        modified:   src/hello.h
$ git log -1
commit f925ff736d4299c0594031847a617e332311a136 (HEAD -> master)
Author: luokaijie <1697065378@qq.com>
Date:   Tue Oct 18 23:43:07 2022 +0800

    add hello.h
  • 执行提交操作,即完成最新两个提交压缩为一个提交的操作。
$ git commit -m "modify hello.h"
[master c17d5af] modify hello.h
 2 files changed, 3 insertions(+)
  • 看看提交日志,“多步悔棋”操作成功。
$ git log --pretty=oneline -2 --stat
c17d5afb6feb444644c7f9cd7eba8fee7927bfd8 (HEAD -> master) modify hello.h
 README      | 2 ++
 src/hello.h | 1 +
 2 files changed, 3 insertions(+)
f925ff736d4299c0594031847a617e332311a136 add hello.h
 src/hello.h | 1 +
 1 file changed, 1 insertion(+)

回到未来

当更改历史提交(SHA1哈希值变更),即使后续提交的内容和属性都一致,但是因为后续提交中有一个属性是父提交的SHA1哈希值,所以一个历史提交的改变会引起连锁变化,导致所有后续提交必然的发生变化, 就会形成两条平行的时间线:一个是变更前的提交时间线,另外一条是更改历史后新的提交时间线。

把此次实践比喻做一次电影(回到未来)拍摄的话,舞台依然是之前的DEMO版本库,而剧本是这样的。

  • 角色:最近的六次提交。分别依据提交顺序,编号为A、B、C、D、E、F。
$ git log --oneline -6
b6f0b0a modify hello.h # F
48456ab add hello.h # E
3488f2c move .gitignore outside also works. # D
b3af728 ignore object files. # C
d71ce92 Hello world initialized. # B
c024f34 README is from welcome.txt. # A
  • 坏蛋:提交D。
    即对:file:.gitignore文件移动的提交不再需要,或者这个提交将和前一次提交
    (C)压缩为一个。

  • 前奏:故事人物依次出场,坏蛋D在图中被特殊标记。
    在这里插入图片描述

  • 第一幕:抛弃提交D,将正确的提交E和F重新“嫁接”到提交C上,最终坏蛋被消灭。
    在这里插入图片描述

  • 第二幕:坏蛋D被C感化,融合为"CD"复合体,E和F重新“嫁接”到"CD"复合体上,最
    终大团圆结局。
    在这里插入图片描述

  • 道具:分别使用三辆不同的时光车来完成“回到未来”。
    分别是:核能跑车,清洁能源飞车,蒸汽为动力的飞行火车。


时间旅行一

《回到未来-第一集》布朗博士设计的第一款时间旅行车是一辆跑车,使用核燃料:钚。与之对应,此次实践使用的工具也没有太出乎想象,用一条新的指令——拣选指令(:command:git cherry-pick)实现提交在新的分支上“重放”。

拣选指令——:command:git cherry-pick,其含义是从众多的提交中挑选出一个提交应用在当前的工作分支中。该命令需要提供一个提交ID作为参数,操作过程相当于将该提交导出为补丁文件,然后在当前HEAD上重放形成无论内容还是提交说明都一致的提交。

首先对版本库要“参演”的角色进行标记,使用尚未正式介绍的命令:command:git tag(无非就是在特定命名空间建立的引用,用于对提交的标识)。

$ git log --oneline
c17d5af (HEAD -> master) modify hello.h
f925ff7 add hello.h
96a6bfc move .gitignore outside also works.
2b82d10 ignore object files.
2a907f0 (tag: hell0_1.0) Hello world initialized.
3849a39 (origin/master, origin/HEAD) commit src directory
51d7cbd README is from welcome.txt.
06a2b20 restore file: welcome.txt
dac26c2 delete trash files. (using: git add -u)
6750cde does master follow this new commit?
b64069c which version checked in?
817ed41 who does this commit?
4a462ab initialized

$ git tag F

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag E HEAD^

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag D HEAD^^

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag C HEAD^^^

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag B HEAD^^^^

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag A HEAD^^^^^

luokaijie@Minami MINGW32 /d/myCode/Git_authori-guide (master)
$ git tag X HEAD^^^^^^

通过日志,可以看到被标记的7个提交。

$ git log --oneline --decorate -7
c17d5af (HEAD -> master, tag: F) modify hello.h
f925ff7 (tag: E) add hello.h
96a6bfc (tag: D) move .gitignore outside also works.
2b82d10 (tag: C) ignore object files.
2a907f0 (tag: hell0_1.0, tag: B) Hello world initialized.
3849a39 (tag: A, origin/master, origin/HEAD) commit src directory
51d7cbd (tag: X) README is from welcome.txt.

现在演出第一幕:干掉坏蛋D

  • 执行:command:git checkout命令,暂时将HEAD头指针切换到C。
    切换过程显示处于非跟踪状态的警告,没有关系,因为剧情需要。
$ git checkout C
Note: switching to 'C'.

You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false

HEAD is now at 2b82d10 ignore object files.
  • 执行拣选操作将E提交在当前HEAD上重放。

    因为E和master^显然指向同一角色,因此可以用下面的语法。

$ git cherry-pick master^
[detached HEAD b601627] add hello.h
 Date: Tue Oct 18 23:43:07 2022 +0800
 1 file changed, 1 insertion(+)
 create mode 100644 src/hello.h
  • 执行拣选操作将F提交在当前HEAD上重放。
    F和master也具有相同指向。
$ git cherry-pick master
[detached HEAD dcbfbae] modify hello.h
 Date: Thu Oct 20 01:23:37 2022 +0800
 2 files changed, 3 insertions(+)
  • 通过日志可以看到坏蛋D已经不在了。
$ git log --oneline --decorate -7
dcbfbae (HEAD) modify hello.h
b601627 add hello.h
2b82d10 (tag: C) ignore object files.
2a907f0 (tag: hell0_1.0, tag: B) Hello world initialized.
3849a39 (tag: A, origin/master, origin/HEAD) commit src directory
51d7cbd (tag: X) README is from welcome.txt.
06a2b20 restore file: welcome.txt

十、Git克隆

到现在为止,读者已经零略到Git的灵活性以及健壮性。Git可以通过重置随意撤销提交,可以通过变基操作更改历史,可以随意重组提交,还可以通过reflog的记录纠正错误的操作。但是再健壮的版本库设计,也抵挡不了存储介质的崩溃。还有一点就是不要忘了Git版本库是躲在工作区根目录下的:file:.git目录中,如果忘了这一点直接删除工作区,就会把版本库也同时删掉,悲剧就此发生。

“不要把鸡蛋装在一个篮子里”,是颠扑不破的安全法则。

在本章会学习到如何使用:command:git clone命令建立版本库克隆,以及如何使用:command:git push和:command:git pull命令实现克隆之间的同步。


鸡蛋不装在一个篮子里

Git的版本库目录和工作区在一起,因此存在一损俱损的问题,即如果删除一个项目的工作区,同时也会把这个项目的版本库删除掉。一个项目仅在一个工作区中维护太危险了,如果有两个工作区就会好很多。

在这里插入图片描述

上图中一个项目使用了两个版本库进行维护,两个版本库之间通过拉回(PULL)和/或推送(PUSH)操作实现同步。

  • 版本库A通过克隆操作创建克隆版本库B。
  • 版本库A可以通过推送(PUSH)操作,将新提交传递给版本库B;
  • 版本库A可以通过拉回(PULL)操作,将版本库B中的新提交拉回到自身(A)。
  • 版本库B可以通过拉回(PULL)操作,将版本库A中的新提交拉回到自身(B)。
  • 版本库B可以通过推送(PUSH)操作,将新提交传递给版本库A;

Git使用:command:git clone命令实现版本库克隆,主要有如下三种用法:

用法1: git clone <repository> <directory>
用法2: git clone --bare <repository> <directory.git>
用法3: git clone --mirror <repository> <directory.git>

这三种用法的区别如下:

  • 用法1将< repository >指向的版本库创建一个克隆到:file:<directory>目录。目录:file:<directory>相当于克隆版本库的工作区,文件都会检出,版本库位于工作区下的:file:.git目录中。
  • 用法2和用法3创建的克隆版本库都不含工作区,直接就是版本库的内容,这样的版本库称为裸版本库。一般约定俗成裸版本库的目录名以:file:.git为后缀,所以上面示例中将克隆出来的裸版本库目录名写做:file:<directory.git>
  • 用法3区别于用法2之处在于,用法3克隆出来的裸版本对上游版本库进行了注册,这样可以在裸版本库中使用:command:git fetch命令和上游版本库进行持续同步。
  • 用法3只在 1.6.0 或更新版本的Git才提供。

Git的PUSH和PULL命令的用法相似,使用下面的语法:

git push [<remote-repos> [<refspec>]]
git pull [<remote-repos> [<refspec>]]

其中方括号的含义是参数可以省略,< remote-repos >是远程版本库的地址或名称,< refspec >是引用表达式,暂时理解为引用即可。在后面的章节再具体介绍PUSH和PULL命令的细节。

下面就通过不同的Git命令组合,掌握版本库克隆和镜像的技巧。


对等工作区

不使用–bare或者–mirror创建出来的克隆包含工作区,这样就会产生两个包含工作区的版本库。这两个版本库是对等的,如下图。
在这里插入图片描述
这两个工作区本质上没有区别,但是往往提交是在一个版本(A)中进行的,另外一个(B)作为备份。对于这种对等工作区模式,版本库的同步只有一种可行的操作模式,就是在备份库(B)执行 git pull 命令从源版本库(A)拉回新的提交实现版本库同步。为什么不能从版本库A向版本库B执行 git push 的推送操作呢?看看下面的操作。

执行克隆命令,将版本库:file:/path/to/my/workspace/demo克隆到:file:/path/to/my/workspace/demo-backup

$ git clone /path/to/my/workspace/demo /path/to/my/workspace/demo-backup
Cloning into /path/to/my/workspace/demo-backup...
done.

进入 demo 版本库,生成一些测试提交(使用–allow-empty参数可以生成空提交)。

$ cd /path/to/my/workspace/demo/
$ git commit --allow-empty -m "sync test 1"
[master 790e72a] sync test 1
$ git commit --allow-empty -m "sync test 2"
[master f86b7bf] sync test 2

能够在 demo 版本库向 demo-backup 版本库执行PUSH操作么?执行一下:command:git push看一看。

$ git push /path/to/my/workspace/demo-backup
Counting objects: 2, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 274 bytes, done.
Total 2 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
remote: error: refusing to update checked out branch: refs/heads/master
remote: error: By default, updating the current branch in a non-bare repository
remote: error: is denied, because it will make the index and work tree inconsis
remote: error: with what you pushed, and will require 'git reset --hard' to match
remote: error: the work tree to HEAD.
remote: error:
remote: error: You can set 'receive.denyCurrentBranch' configuration variable to
remote: error: 'ignore' or 'warn' in the remote repository to allow pushing into
remote: error: its current branch; however, this is not recommended unless you
remote: error: arranged to update its work tree to match what you pushed in some
remote: error: other way.
remote: error:
remote: error: To squelch this message and still keep the default behaviour, set
remote: error: 'receive.denyCurrentBranch' configuration variable to 'refuse'.
To /path/to/my/workspace/demo-backup
 ! [remote rejected] master -> master (branch is currently checked out)
error: failed to push some refs to '/path/to/my/workspace/demo-backup'

翻译成中文:

$ git push /path/to/my/workspace/demo-backup
...
对方说: 错了:
 拒绝更新已检出的分支 refs/heads/master 。
 缺省更新非裸版本库的当前分支是不被允许的,因为这将会导致
 暂存区和工作区与您推送至版本库的新提交不一致。这太古怪了。
 如果您一意孤行,也不是不允许,但是您需要为我设置如下参数:
 receive.denyCurrentBranch = ignore|warn
 到 /path/to/my/workspace/demo-backup
 ! [对方拒绝] master -> master (分支当前已检出)
错误: 部分引用的推送失败了, 至 '/path/to/my/workspace/demo-backup'

从错误输出可以看出,虽然可以改变Git的缺省行为,允许向工作区推送已经检出的分支,但是这么做实在不高明。

为了实现同步,需要进入到备份版本库中,执行:command:git pull命令。

$ git pull
From /path/to/my/workspace/demo
 6e6753a..f86b7bf master -> origin/master
Updating 6e6753a..f86b7bf
Fast-forward

在 demo-backup 版本库中查看提交日志,可以看到在 demo 版本库中的新提交已经被拉回到 demo-backup 版本库中。

$ git log --oneline -2
f86b7bf sync test 2
790e72a sync test 1

为什么执行 git pull 拉回命令没有像执行 git push 命令那样提供那么多的参数呢?

这是因为在执行:command:git clone操作后,克隆出来的demo-backup版本库中对源版本库(上游版本库)进行了注册,所以当在 demo-backup 版本库执行拉回操作,无须设置上游版本库的地址

在 demo-backup 版本库中可以使用下面的命令查看对上游版本库的注册信息:

$ cd /path/to/my/workspace/demo-backup
$ git remote -v
origin /path/to/my/workspace/demo (fetch)
origin /path/to/my/workspace/demo (push)

实际注册上游远程版本库的奥秘都在Git的配置文件中(略去无关的行):

$ cat /path/to/my/workspace/demo-backup/.git/config
...
[remote "origin"]
 fetch = +refs/heads/*:refs/remotes/origin/*
 url = /path/to/my/workspace/demo
[branch "master"]
 remote = origin
 merge = refs/heads/master

关于配置文件[remote]小节和[branch]小节的奥秘在后面的章节予以介绍。


克隆生成裸版本库

上一节在对等工作区模式下,工作区之间执行推送,可能会引发大段的错误输出,如果采用裸版本库则没有相应的问题。这是因为裸版本库没有工作区。没有工作区还有一个好处就是空间占用会更小。

在这里插入图片描述

使用–bare参数克隆demo版本库到:file:/path/to/repos/demo.git,然后就可以从demo 版本库向克隆的裸版本库执行推送操作了。(为了说明方便,使用了:file:/path/to/repos/作为Git裸版本的根路径,在后面的章节中这个目录也作为Git服务器端版本库的根路径。可以在磁盘中以root账户创建该路径并设置正确的权限。)

$ git clone --bare /path/to/my/workspace/demo /path/to/repos/demo.git
Cloning into bare repository /path/to/repos/demo.git...
done.

克隆出来的:file:/path/to/repos/demo.git目录就是版本库目录,不含工作区。

  • 看看:file:/path/to/repos/demo.git目录的内容。
$ ls -F /path/to/repos/demo.git
branches/ config description HEAD hooks/ info/ objects/ packed-ref
  • 还可以看到demo.git版本库core.bare的配置为true。
$ git --git-dir=/path/to/repos/demo.git config core.bare
true

进入demo版本库,生成一些测试提交。

$ cd /path/to/my/workspace/demo/
$ git commit --allow-empty -m "sync test 3"
[master d4b42b7] sync test 3
$ git commit --allow-empty -m "sync test 4"
[master 0285742] sync test 4

在demo版本库向demo-backup版本库执行PUSH操作,还会有错误么?

  • 不带参数执行:command:git push因为未设定上游远程版本库,因此会报错:
$ git push
fatal: No destination configured to push to.
  • 在执行:command:git push时使用:file:/path/to/repos/demo.git作为参数。
    推送成功。
$ git push /path/to/repos/demo.git
Counting objects: 2, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 275 bytes, done.
Total 2 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
To /path/to/repos/demo.git
 f86b7bf..0285742 master -> master

-看看:file:demo.git版本库,是否已经完成了同步?

$ git log --oneline -2
0285742 sync test 4
d4b42b7 sync test 3

这个方式实现版本库本地镜像显然是更好的方法,因为可以直接在工作区修改、提交,然后执行:command:git push命令实现推送。稍有一点遗憾的是推送命令还需要加上裸版本库的路径。这个遗憾在后面介绍远程版本库的章节会给出解决方案。


创建生成裸版本库

裸版本库不但可以通过克隆的方式创建,还可以通过:command:git init命令以初始化的方式创建。之后的同步方式和上一节大同小异。
在这里插入图片描述
命令:command:git init在“Git初始化”一章就已经用到了,是用于初始化一个版本库的。之前执行:command:git init命令初始化的版本库是带工作区的,如何以裸版本库的方式初始化一个版本库呢?奥秘就在于–bare参数

下面的命令会创建一个空的裸版本库于目录:file:/path/to/repos/demo-init.git 中。

$ git init --bare /path/to/repos/demo-init.git
Initialized empty Git repository in /path/to/repos/demo-init.git/

创建的果真是裸版本库么?

  • 看看 :file:/path/to/repos/demo-init.git 下的内容:
$ ls -F /path/to/repos/demo-init.git
branches/ config description HEAD hooks/ info/ objects/ refs/
  • 看看这个版本库的配置core.bare的值:
$ git --git-dir=/path/to/repos/demo-init.git config core.bare
true

可是空版本库没有内容啊,那就执行PUSH操作为其创建内容呗。

$ cd /path/to/my/workspace/demo
$ git push /path/to/repos/demo-init.git
No refs in common and none specified; doing nothing.
Perhaps you should specify a branch such as 'master'.
fatal: The remote end hung up unexpectedly
error: failed to push some refs to '/path/to/repos/demo-init.git'

为什么出错了?翻译一下错误输出。

$ cd /path/to/my/workspace/demo
$ git push /path/to/repos/demo-init.git
没有指定要推送的引用,而且两个版本库也没有共同的引用。
所以什么也没有做。
可能您需要提供要推送的分支名,如 'master'。
严重错误:远程操作意外终止
错误:部分引用推送失败,至 '/path/to/repos/demo-init.git'

关于这个问题详细说明要在后面的章节介绍,这里先说一个省略版:因为:file:/path/to/repos/demo-init.git 版本库刚刚初始化完成,还没有任何提交更不要说分支了。当执行:command:git push命令时,如果没有设定推送的分支,而且当前分支也没有注册到远程某个分支,将检查远程分支是否有和本地相同的分支名(如master),如果有,则推送,否则报错

所以需要把:command:git push命令写的再完整一些。像下面这样操作,就可以完成向空的裸版本库的推送。

$ git push /path/to/repos/demo-init.git master:master
Counting objects: 26, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (20/20), done.
Writing objects: 100% (26/26), 2.49 KiB, done.
Total 26 (delta 8), reused 0 (delta 0)
Unpacking objects: 100% (26/26), done.
To /path/to/repos/demo-init.git
 * [new branch] master -> master

上面的:command:git push命令也可以简写为::command:git push /path/to/repos/demo-init.git master

推送成功了么?看看:file:demo-init.git版本库中的提交。

$ git --git-dir=/path/to/repos/demo-init.git log --oneline -2
0285742 sync test 4
d4b42b7 sync test 3

好了继续在 demo 中执行几次提交。

$ cd /path/to/my/workspace/demo/
$ git commit --allow-empty -m "sync test 5"
[master 424aa67] sync test 5
$ git commit --allow-empty -m "sync test 6"
[master 70a5aa7] sync test 6

然后再向:file:demo-init.git推送。注意这次使用的命令。

$ git push /path/to/repos/demo-init.git
Counting objects: 2, done.
Delta compression using up to 2 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (2/2), 273 bytes, done.
Total 2 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (2/2), done.
To /path/to/repos/demo-init.git
 0285742..70a5aa7 master -> master

为什么这次使用:command:git push命令后面没有跟上分支名呢?这是因为远程版本库(demo-init.git)中已经不再是空版本库了,而且有名为master的分支。

通过下面的命令可以查看远程版本库的分支。

$ git ls-remote /path/to/repos/demo-init.git
70a5aa7a7469076fd435a9e4f89c4657ba603ced HEAD
70a5aa7a7469076fd435a9e4f89c4657ba603ced refs/heads/master

至此相信读者已经能够把鸡蛋放在不同的篮子中了,也对Git更加的喜爱了吧。


十一、Git检出

在上一章学习了重置命令(:command:git reset)。重置命令的一个用途就是修改引用(如master)的游标。实际上在执行重置命令的时候没有使用任何参数对所要重置的分支名进行设置,这是因为重置命令实际上所针对的是头指针HEAD。之所以没有改变HEAD的内容是因为HEAD指向了一个引用refs/heads/master,所以重置命令体现为分支“游标”的变更,HEAD本身一直指向的是refs/heads/master,并没有在重置时改变

如果HEAD的内容不能改变而一直都指向master分支,那么Git如此精妙的分支设计岂不浪费?如果HEAD要改变该如何改变呢?本章将学习检出命令(:command:git checkout),该命令的实质就是修改HEAD本身的指向,该命令不会影响分支“游标”(如master)


HEAD的重置即检出 checkout

HEAD可以理解为“头指针”,是当前工作区的“基础版本”,当执行提交时,HEAD指向的提交将作为新提交的父提交。看看当前HEAD的指向。

cat .git/HEAD

$ cat .git/HEAD
ref: refs/heads/master

可以看出HEAD指向了分支 master。此时执行:command:git branch会看到当前处于master分支。

$ git branch -v
* master 4902dc3 does master follow this new commit?

现在使用:command:git checkout命令检出该ID的父提交,看看会怎样。

$ git checkout 4902dc3^
Note: checking out '4902dc3^'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
 git checkout -b new_branch_name
HEAD is now at e695606... which version checked in?

出现了大段的输出!翻译一下,Git肯定又是在提醒我们了。

$ git checkout 4902dc3^
注意: 正检出 '4902dc3^'.
您现在处于 '分离头指针' 状态。您可以检查、测试和提交,而不影响任何分支。
通过执行另外的一个 checkout 检出指令会丢弃在此状态下的修改和提交。
如果想保留在此状态下的修改和提交,使用 -b 参数调用 checkout 检出指令以
创建新的跟踪分支。如:
 git checkout -b new_branch_name
头指针现在指向 e695606... 提交说明为: which version checked in?

什么叫做“分离头指针”状态?查看一下此时HEAD的内容就明白了。

$ cat .git/HEAD
e695606fc5e31b2ff9038a48a3d363f4c21a3d86

原来 “分离头指针”状态指的就是HEAD头指针指向了一个具体的提交ID,而不是一个引用(分支)

查看最新提交的reflog也可以看到,当针对提交执行:command:git checkout命令时,HEAD头指针被更改了:由指向master分支变成了指向一个提交ID

git reflog -1

$ git reflog -1
e695606 HEAD@{0}: checkout: moving from master to 4902dc3^

注意上面的reflog是HEAD头指针的变迁记录,而非master分支

查看一下HEAD和master对应的提交ID,会发现现在它们指向的不一样。

git rev-parse HEAD master

$ git rev-parse HEAD master
e695606fc5e31b2ff9038a48a3d363f4c21a3d86
4902dc375672fbf52a226e0354100b75d4fe31e3

前一个是HEAD头指针的指向,后一个是master分支的指向。而且还可以看到执行:command:git checkout命令并不像:command:git reset命令,分支(master)的指向并没有改变,仍旧指向原有的提交ID。

现在版本库的HEAD是基于e695606提交的。再做一次提交,HEAD会如何变化呢?

  • 先做一次修改:创建一个新文件:file:detached-commit.txt,添加到暂存区中。
$ touch detached-commit.txt
$ git add detached-commit.txt
  • 看一下状态,会发现其中有:“当前不处于任何分支”的字样,显然这是因为 HEAD 处于“分离头指针”模式
$ git status
# Not currently on any branch.
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# new file: detached-commit.txt
#
  • 执行提交。在提交输出中也会出现[detached HEAD …]的标识,也是对用户的警示。
$ git commit -m "commit in detached HEAD mode."
[detached HEAD acc2f69] commit in detached HEAD mode.
 0 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 detached-commit.txt
  • 此时头指针指向了新的提交。
$ cat .git/HEAD
acc2f69cf6f0ae346732382c819080df75bb2191
  • 再查看一下日志会发现新的提交是建立在之前的提交基础上的。
$ git log --graph --pretty=oneline
* acc2f69cf6f0ae346732382c819080df75bb2191 commit in detached HEAD mode.
* e695606fc5e31b2ff9038a48a3d363f4c21a3d86 which version checked in?
* a0c641e92b10d8bcca1ed1bf84ca80340fdefee6 who does commit?
* 9e8a761ff9dd343a1380032884f488a2422c495a initialized.

记下新的提交ID(acc2f69),然后以master分支名作为参数执行:command:git checkout命令,会切换到master分支上。

  • 切换到master分支。没有之前大段的文字警告。
$ git checkout master
Previous HEAD position was acc2f69... commit in detached HEAD mode.
Switched to branch 'master'
  • 因为HEAD头指针重新指向了分支,而不是处于“断头模式”(分离头指针模式)
$ cat .git/HEAD
ref: refs/heads/master
  • 切换之后,之前本地建立的新文件:file:detached-commit.txt不见了。
$ ls
new-commit.txt welcome.txt
  • 切换之后,刚才的提交日志也不见了。
$ git log --graph --pretty=oneline
* 4902dc375672fbf52a226e0354100b75d4fe31e3 does master follow this new commit?
* e695606fc5e31b2ff9038a48a3d363f4c21a3d86 which version checked in?
* a0c641e92b10d8bcca1ed1bf84ca80340fdefee6 who does commit?
* 9e8a761ff9dd343a1380032884f488a2422c495a initialized.

刚才的提交在版本库的对象库中还存在么?看看刚才记下的提交ID。

git show acc2f69

$ git show acc2f69
commit acc2f69cf6f0ae346732382c819080df75bb2191
Author: Jiang Xin <jiangxin@ossxp.com>
Date: Sun Dec 5 15:43:24 2010 +0800
 commit in detached HEAD mode.
diff --git a/detached-commit.txt b/detached-commit.txt
new file mode 100644
index 0000000..e69de29

可以看出这个提交现在仍在版本库中。由于这个提交没有被任何分支跟踪到,因此并不能保证这个提交会永久存在。实际上当reflog中含有该提交的日志过期后,这个提交随时都会从版本库中彻底清除。


挽救分离头指针

在“分离头指针”模式下进行的测试提交除了使用提交ID(acc2f69)访问之外,不能通过master分支或其他引用访问到。如果这个提交是master分支所需要的,那么该如何处理呢?如果使用上一章介绍的:command:git reset命令,的确可以将master分支重置到该测试提交acc2f69,但是如果那样就会丢掉master分支原先的提交4902dc3。使用合并操作(:command:git merge)可以实现两者的兼顾。

下面的操作会将提交 acc2f69 合并到master分支中来。

  • 确认当前处于master分支。
$ git branch -v
* master 4902dc3 does master follow this new commit?
  • 执行合并操作,将 acc2f69 提交合并到当前分支。
$ git merge acc2f69
Merge made by recursive.
 0 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 detached-commit.txt
  • 工作区中多了一个:file:detached-commit.txt文件。
$ ls
detached-commit.txt new-commit.txt welcome.txt
  • 查看日志,会看到不一样的分支图。即在e695606提交开始出现了开发分支,而分支在最新的2b31c19提交发生了合并。
$ git log --graph --pretty=oneline
* 2b31c199d5b81099d2ecd91619027ab63e8974ef Merge commit 'acc2f69'
|\
| * acc2f69cf6f0ae346732382c819080df75bb2191 commit in detached HEAD mode.
* | 4902dc375672fbf52a226e0354100b75d4fe31e3 does master follow this new commit?
|/
* e695606fc5e31b2ff9038a48a3d363f4c21a3d86 which version checked in?
* a0c641e92b10d8bcca1ed1bf84ca80340fdefee6 who does commit?
* 9e8a761ff9dd343a1380032884f488a2422c495a initialized.
  • 仔细看看最新提交,会看到这个提交有两个父提交。这就是合并的奥秘。
$ git cat-file -p HEAD
tree ab676f92936000457b01507e04f4058e855d4df0
parent 4902dc375672fbf52a226e0354100b75d4fe31e3
parent acc2f69cf6f0ae346732382c819080df75bb2191
author Jiang Xin <jiangxin@ossxp.com> 1291535485 +0800
committer Jiang Xin <jiangxin@ossxp.com> 1291535485 +0800
Merge commit 'acc2f69'

深入了解:command:git checkout命令

检出命令(:command:git checkout是Git最常用的命令之一,同样也很危险,因为这条命令会重写工作区

用法一: git checkout [-q] [<commit>] [--] <paths>...
用法二: git checkout [<branch>]
用法三: git checkout [-m] [[-b|--orphan] <new_branch>] [<start_point>]

上面列出的第一种用法和第二种用法的区别在于,第一种用法在命令中包含路径:file:<paths>。为了避免路径和引用(或者提交ID)同名而冲突,可以在:file:<paths>前用两个连续的短线(减号)作为分隔。

第一种用法的< commit >是可选项,如果省略则相当于从暂存区(index)进行检出。这和上一章的重置命令大不相同:重置的默认值是 HEAD,而检出的默认值是暂存区。因此重置一般用于重置暂存区(除非使用–hard参数,否则不重置工作区),而检出命令主要是覆盖工作区(如果< commit >不省略,也会替换暂存区中相应的文件)。

第一种用法(包含了路径:file:<paths>的用法)不会改变HEAD头指针,主要是用于 指定版本的文件 覆盖 工作区中对应的文件如果省略< commit >,会拿暂存区的文件覆盖工作区的文件,否则用指定提交中的文件覆盖暂存区和工作区中对应的文件

第二种用法(不使用路径:file:<paths>的用法)则会改变HEAD头指针。之所以后面的参数写作< branch >,是因为只有HEAD切换到一个分支才可以对提交进行跟踪,否则仍然会进入“分离头指针”的状态。在“分离头指针”状态下的提交不能被引用关联到而可能会丢失。所以用法二最主要的作用就是切换到分支。如果省略< branch >则相当于对工作区进行状态检查。

第三种用法主要是创建和切换到新的分支(< new_branch >),新的分支从< start_point >指定的提交开始创建。新分支和我们熟悉的master分支没有什么实质的不同,都是在refs/heads命名空间下的引用。关于分支和:command:git checkout命令的这个用法会在后面的章节做具体的介绍。

下面的版本库模型图描述了:command:git checkout实际完成的操作。

在这里插入图片描述
下面通过一些示例,具体的看一下检出命令的不同用法。

  • 命令::command:git checkout branch
    检出branch分支。要完成如图的三个步骤,更新HEAD以指向branch分支,以branch指向的树更新暂存区和工作区。

  • 命令::command:git checkout
    汇总显示工作区、暂存区与HEAD的差异。

  • 命令::command:git checkout HEAD
    同上。

  • 命令::command:git checkout -- filename
    用暂存区中:file:filename文件来覆盖工作区中的:file:filename文件。相当于取消自上次执行:command:git add filename以来(如果执行过)本地的修改。

    这个命令很危险,因为对于本地的修改会悄无声息的覆盖,毫不留情。

  • 命令::command:git checkout branch -- filename
    维持HEAD的指向不变。将branch所指向的提交中的:file:filename替换暂存区和工作区中相应的文件。注意会将暂存区和工作区中的:file:filename文件直接覆盖。

  • 命令::command:git checkout -- . 或写做 git checkout .
    注意::command:git checkout命令后的参数为一个点(“.”)。这条命令最危险!会取消所有本地的修改(相对于暂存区)。相当于将暂存区的所有文件直接覆盖本地文件,不给用户任何确认的机会!


十二、Git库管理

版本库管理?那不是管理员要干的事情么,怎么放在“Git独奏”这一部分了?

没有错,这是因为对于Git,每个用户都是自己版本库的管理员,所以在“Git独奏”的最后一章,来谈一谈Git版本库管理的问题。如果下面的问题您没有遇到或者不感兴趣,读者大可以放心的跳过这一章。

  • 从网上克隆来的版本库,为什么对象库中找不到对象文件?而且引用目录里也看不到所有的引用文件?
  • 不小心添加了一个大文件到Git库中,用重置命令丢弃了包含大文件的提交,可是版本库不见小,大文件仍在对象库中。
  • 本地版本库的对象库里文件越来越多,这可能导致Git性能的降低。

对象和引用哪里去了?

从GitHub上克隆一个示例版本库,这个版本库在“历史穿梭”一章就已经克隆过一次了,现在要重新克隆一份。为了和原来的克隆相区别,克隆到另外的目录。执行下面的命令。

$ cd /path/to/my/workspace/
$ git clone git://github.com/ossxp-com/gitdemo-commit-tree.git i-am-admin
Cloning into i-am-admin...
remote: Counting objects: 65, done.
remote: Compressing objects: 100% (53/53), done.
remote: Total 65 (delta 8), reused 0 (delta 0)
Receiving objects: 100% (65/65), 78.14 KiB | 42 KiB/s, done.
Resolving deltas: 100% (8/8), done.

git show-ref

进入克隆的版本库,使用:command:git show-ref命令看看所含的引用。

$ cd /path/to/my/workspace/i-am-admin
$ git show-ref
6652a0dce6a5067732c00ef0a220810a7230655e refs/heads/master
6652a0dce6a5067732c00ef0a220810a7230655e refs/remotes/origin/HEAD
6652a0dce6a5067732c00ef0a220810a7230655e refs/remotes/origin/master
c9b03a208288aebdbfe8d84aeb984952a16da3f2 refs/tags/A
1a87782f8853c6e11aacba463af04b4fa8565713 refs/tags/B
9f8b51bc7dd98f7501ade526dd78c55ee4abb75f refs/tags/C
887113dc095238a0f4661400d33ea570e5edc37c refs/tags/D
6decd0ad3201ddb3f5b37c201387511059ac120c refs/tags/E
70cab20f099e0af3f870956a3fbbbda50a17864f refs/tags/F
96793e37c7f1c7b2ddf69b4c1e252763c11a711f refs/tags/G
476e74549047e2c5fbd616287a499cc6f07ebde0 refs/tags/H
76945a15543c49735634d58169b349301d65524d refs/tags/I
f199c10c3f1a54fa3f9542902b25b49d58efb35b refs/tags/J

其中以refs/heads/开头的是分支;以refs/remotes/开头的是远程版本库分支在本地的映射,会在后面章节介绍;以refs/tags/开头的是里程碑。按照之前的经验,在:file:.git/refs目录下应该有这些引用所对应的文件才是。看看都在么?

$ find .git/refs/ -type f
.git/refs/remotes/origin/HEAD
.git/refs/heads/master

为什么才有两个文件?实际上当运行下面的命令后,引用目录下的文件会更少:

$ git pack-refs --all
$ find .git/refs/ -type f
.git/refs/remotes/origin/HEAD

那么本应该出现在:file:.git/refs/目录下的引用文件都到哪里去了呢?答案是这些文件被打包了,放到一个文本文件:file:.git/packed-refs中了。查看一下这个文件中的内容。

head -5 .git/packed-refs

$ head -5 .git/packed-refs
# pack-refs with: peeled
6652a0dce6a5067732c00ef0a220810a7230655e refs/heads/master
6652a0dce6a5067732c00ef0a220810a7230655e refs/remotes/origin/master
c9b03a208288aebdbfe8d84aeb984952a16da3f2 refs/tags/A
^81993234fc12a325d303eccea20f6fd629412712

再来看看Git的对象(commit、blob、tree、tag)在对象库中的存储。通过下面的命令,会发现对象库也不是原来熟悉的模样了。

$ find .git/objects/ -type f
.git/objects/pack/pack-969329578b95057b7ea1208379a22c250c3b992a.idx
.git/objects/pack/pack-969329578b95057b7ea1208379a22c250c3b992a.pack

对象库中只有两个文件,本应该一个一个独立保存的对象都不见了。读者应该能够猜到,所有的对象文件都被打包到这两个文件中了,其中以:file:.pack结尾的文件是打包文件,以:file:.idx结尾的是索引文件。打包文件和对应的索引文件只是扩展名不同,都保存于:file:.git/objects/pack/目录下。Git对于以SHA1哈希值作为目录名和文件名保存的对象有一个术语,称为松散对象。松散对象打包后会提高访问效率,而且不同的对象可以通过增量存储节省磁盘空间。

可以通过Git一个底层命令可以查看索引中包含的对象:

$ git show-index < .git/objects/pack/pack-*.idx | head -5
661 0cd7f2ea245d90d414e502467ac749f36aa32cc4 (0793420b)
63020 1026d9416d6fc8d34e1edfb2bc58adb8aa5a6763 (ed77ff72)
3936 15328fc6961390b4b10895f39bb042021edd07ea (13fb79ef)
3768 1a588ca36e25f58fbeae421c36d2c39e38e991ef (86e3b0bd)
2022 1a87782f8853c6e11aacba463af04b4fa8565713 (e269ed74)

为什么克隆远程版本库就可以产生对象库打包以及引用打包的效果呢?这是因为克隆远程版本库时,使用了“智能”的通讯协议,远程Git服务器将对象打包后传输给本地,形成本地版本库的对象库中的一个包含所有对象的包以及索引文件。无疑这样的传输方式——按需传输、打包传输,效率最高。

克隆之后的版本库在日常的提交中,产生的新的对象仍旧以松散对象存在,而不是以打包的形式,日积月累会在本地版本库的对象库中形成大量的松散文件。松散对象只是进行了压缩,而没有(打包文件才有的)增量存储的功能,会浪费磁盘空间,也会降低访问效率。更为严重的是一些非正式的临时对象(暂存区操作中产生的临时对象)也以松散对象的形式保存在对象库中,造成磁盘空间的浪费。下一节就着手处理临时对象的问题。


暂存区操作引入的临时对象

暂存区操作有可能在对象库中产生临时对象,例如文件反复的修改和反复的向暂存区添加,或者添加到暂存区后不提交甚至直接撤销,就会产生垃圾数据占用磁盘空间。为了说明临时对象的问题,需要准备一个大的压缩文件,10MB即可。

在Linux上与内核匹配的:file:initrd文件(内核启动加载的内存盘)就是一个大的压缩文件,可以用于此节的示例。将大的压缩文件放在版本库外的一个位置上,因为这个文件会多次用到。

$ cp /boot/initrd.img-2.6.32-5-amd64 /tmp/bigfile
$ du -sh bigfile
11M bigfile

将这个大的压缩文件复制到工作区中,拷贝两份。

$ cd /path/to/my/workspace/i-am-admin
$ cp /tmp/bigfile bigfile
$ cp /tmp/bigfile bigfile.dup

然后将工作区中两个内容完全一样的大文件加入暂存区。

$ git add bigfile bigfile.dup

查看一下磁盘空间占用:

  • 工作区连同版本库共占用33MB。
$ du -sh .
33M .
  • 其中版本库只占用了11MB。版本库空间占用是工作区的一半。
    如果再有谁说版本库空间占用一定比工作区大,可以用这个例子回击他。
$ du -sh .git/
11M .git/

看看版本库中对象库内的文件,会发现多出了一个松散对象。之所以添加两个文件而只有一个松散对象,是因为Git对于文件的保存是将内容保存为blob对象中,和文件名无关,相同内容的不同文件会共享同一个blob对象

$ find .git/objects/ -type f
.git/objects/2e/bcd92d0dda2bad50c775dc662c6cb700477aff
.git/objects/pack/pack-969329578b95057b7ea1208379a22c250c3b992a.idx
.git/objects/pack/pack-969329578b95057b7ea1208379a22c250c3b992a.pack

如果不想提交,想将文件撤出暂存区,则进行如下操作。

  • 当前暂存区的状态。
$ git status -s
A bigfile
A bigfile.dup
  • 将添加的文件撤出暂存区。
$ git reset HEAD
  • 通过查看状态,看到文件被撤出暂存区了。
$ git status -s
?? bigfile
?? bigfile.dup

文件撤出暂存区后,在对象库中产生的blob松散对象仍然存在,通过查看版本库的磁盘占用就可以看出来。

$ du -sh .git/
11M .git/

git fsck

Git提供了:command:git fsck命令,可以查看到版本库中包含的没有被任何引用关联松散对象。

$ git fsck
dangling blob 2ebcd92d0dda2bad50c775dc662c6cb700477aff

标识为dangling的对象就是没有被任何引用直接或者间接关联到的对象。这个对象就是前面通过暂存区操作引入的大文件的内容。如何将这个文件从版本库中彻底删除呢?Git提供了一个清理的命令:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值