技术杂谈: 聊一聊 git

199 阅读13分钟

1. 概要

今晚我们来聊一聊开发中常用的 git 命令,和面试常问到的 git 问题。

2. 基本概念

git 分为 工作区暂存区本地仓库远程仓库
其中工作区英文是 workspace, 暂存区是 index/stage,本地仓库是 Repository,远程仓库为 remote。如果你看到了不同的表述,他们其实是一个东西。

graph LR

A>工作区/workspace] --> |git add|B[(暂存区/index/stage)]
B --> |git reset|A
B --> |git commit|C[(本地仓库/repository)]

C --> |git push|D[(远程仓库/remote)]
D --> |git fetch/git clone|C

D --> |git pull|A
D --> |git fetch + git merge|A
D --> |git fetch + git rebase|A

3. 本地仓库

3.1 先来初始化一个本地仓库

我们在任意目录执行 git init,一个 git 本地仓库就创建好了。

# git init
Initialized empty Git repository in path/to/.git/

我们来看看里面都有什么

# tree .git/
.git/
├── branches
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   └── update.sample
├── info
│   └── exclude
├── objects
│   ├── info
│   └── pack
└── refs
    ├── heads
    └── tags

3.1.1 config 文件

# cat config
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true

这个文件存储了本仓库的一些配置。如果配置了远程仓库, git 远程仓库的地址其实也在这里,使用 git remote set-url 和直接修改这里可以得到相同的结果。 另外, 如果你要修改 email 地址之类的参数,可以使用命令行或者直接修改 ~/.gitconfig 文件。

3.1.2 HEAD 文件

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

这就是 git 的头指针了。它还可以用以下命令查看:

git symbolic-ref HEAD

3.1.3 裸仓库

初始化一个裸仓库:

git init --bare test_dir

如果你有 gitlab 运维经验,在他的 git-data/repositories/ 目录下,就是这种裸仓库。

如果我们只是要一个仓库用于交换,我们可以在本地或者服务器创建一个裸仓库,而不是非要搭建一个 gitlab。

然后,我们就可以直接从裸仓库中 clone 代码出来了。

git clone path/to/test_dir
git clone ssh://path/to/test_dir

3.2 本地仓库操作

3.2.1 从工作区添加到暂存区

我们创建一个文件,提交到暂存区

# touch test1.txt
# git status
On branch master

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	test1.txt

nothing added to commit but untracked files present (use "git add" to track)
# git add test1.txt
# git status
On branch master

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
	new file:   test1.txt

3.2.2 从暂存区移除

可能你可能尴尬地发现 add 了一个 .DS_Store, .idea, 或者假设它是个 a.txt 想把它从暂存区移除,我们只要执行 reset 命令即可:

# git reset

git reset a.txt
graph LR
A[a.txt] --> |git add|B[(暂存区)] --> |git reset|A

3.2.3 从暂存区提交到本地仓库

git commit -m "提交信息1"
graph LR
A[(暂存区)] --> |git commit|B[(本地仓库)] --> |git reset|A

3.2.4 查看提交

查看提交的 diff

git show <commit_id>

查看某个 commit_id 修改了哪些文件

# git diff-tree -r <commit_id>
# git diff-tree -r --no-commit-id --name-only <commit_id> # 仅显示文件名

3.2.5 从本地仓库回退到暂存区

# git log
commit 24d473f4e028470af575cd555d7b8514ccbe114b (HEAD -> master)
Author: me <me@me.com>
Date:   Sat Jan 8 23:44:50 2022 +0800

    提交信息2

commit 47e58fe6cc87e8d1f20a2fe348e62d68576e3b1a
Author: me <me@me.com>
Date:   Sat Jan 8 23:44:36 2022 +0800

    提交信息1
    

查看暂存区内容:

# git ls-files --stage
100644 72943a16fb2c8f38f9dde202b7a70ccc19c52f34 0	test1.txt

执行 reset:

# git reset 47e58f
Unstaged changes after reset:
M	test1.txt
# 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:   test1.txt

no changes added to commit (use "git add" and/or "git commit -a")
# git log
commit 47e58fe6cc87e8d1f20a2fe348e62d68576e3b1a (HEAD -> master)
Author: me <me@me.com>
Date:   Sat Jan 8 23:44:36 2022 +0800

    提交信息1
    
# git ls-files --stage
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	test1.txt

到这里必须要解释一下了。

我先提出一个问题,如果一个变更已经提交,它(的文件的引用)是不是同时存在于工作区暂存区本地仓库? 那么,撤销变更,就是分别撤销这三个地方的变更。

reset 命令通过修改历史的方式撤销变更。这几种模式的区别在于影响的范围。常用的有三种模式:

mixed: 重置头指针 和 暂存区(默认为 mixed)
hard: 重置头指针、暂存区 和 工作区
soft: 重置头指针

也就是说,如果你使用了 --hard 参数,那么将同时撤销工作区暂存区本地仓库的修改。
如果使用 --mixed 将会重置头指针和暂存区的修改,也就是说,工作区的修改将会被保留。
如果使用 --soft 将会重置头指针。也就是说,工作区暂存区的修改将会被保留。

我们可以在每次 reset 操作后,使用 ls-files 命令查看暂存区的内容:

# git ls-files --stage
100644 72943a16fb2c8f38f9dde202b7a70ccc19c52f34 0	test1.txt

经过观察得知,reset 之后,暂存区的 test1 文件的引用发生了变化。

贴上帮助文档:

# git reset -h
usage: git reset [--mixed | --soft | --hard | --merge | --keep] [-q] [<commit>]
   or: git reset [-q] [<tree-ish>] [--] <pathspec>...
   or: git reset [-q] [--pathspec-from-file [--pathspec-file-nul]] [<tree-ish>]
   or: git reset --patch [<tree-ish>] [--] [<pathspec>...]

    -q, --quiet           be quiet, only report errors
    --mixed               reset HEAD and index
    --soft                reset only HEAD
    --hard                reset HEAD, index and working tree
    --merge               reset HEAD, index and working tree
    --keep                reset HEAD but keep local changes
    --recurse-submodules[=<reset>]
                          control recursive updating of submodules
    -p, --patch           select hunks interactively
    -N, --intent-to-add   record only the fact that removed paths will be added later
    --pathspec-from-file <file>
                          read pathspec from file
    --pathspec-file-nul   with --pathspec-from-file, pathspec elements are separated with NUL character

3.2.6 另外一种回退的方式

reset 命令,特别是带了 --hard参数的 reset 命令是没有后悔药的。如果你还想保留一些历史,revert 命令可能更适合你。对于已经推送到远程仓库的变更,revert 比 reset 更合适一些。毕竟 reset 后是需要强制提交,这会引起一些不必要的麻烦。

# git revert 97ae4
[master 4644f45] Revert "提交信息2"
 1 file changed, 1 deletion(-)
# git log
commit 4644f459752b2096b35514bb74055a39c401cf11 (HEAD -> master)
Author: me <me@me.com>
Date:   Sun Jan 9 00:06:49 2022 +0800

    Revert "提交信息2"

    This reverts commit 97ae483e3526eb37502580f29750e70afba5313a.

commit 97ae483e3526eb37502580f29750e70afba5313a
Author: me <me@me.com>
Date:   Sun Jan 9 00:06:37 2022 +0800

    提交信息2

commit 47e58fe6cc87e8d1f20a2fe348e62d68576e3b1a
Author: me <me@me.com>
Date:   Sat Jan 8 23:44:36 2022 +0800

    提交信息1

3.2.7 分离的头指针

分离的头指针就是让其(HEAD)指向了某个具体的提交而不是分支名。

这里我检出了 97ae4 这个 commit,由于这个提交不和任何分支或者 tag 重合,所以当前处于分离头指针状态。

# git checkout 97ae4
Note: switching to '97ae483e3526eb37502580f29750e70afba5313a'.

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 97ae483 提交信息2
# git status
HEAD detached at 97ae483
nothing to commit, working tree clean
graph LR
A[提交信息1] ---> B[提交信息2] --> C["Revert 提交信息2"]

D[master] --> C["Revert 提交信息2"]
E[HEAD] --> B[提交信息2]

3.2.8 提交到本地仓库

git commit -m "胡搞一波"

此时,你觉得提交信息略有不妥,可以这样修改上一次的提交信息:

git commit --amend

3.2.9 创建新分支

# git branch newBranch
# git branch -v
* master    4644f45 Revert "提交信息2"
  newBranch 4644f45 Revert "提交信息2"

3.2.10 修改分支名

# git branch -v
* master    4644f45 Revert "提交信息2"
  newBranch 4644f45 Revert "提交信息2"
# git branch -m newBranch newBranch1
# git branch -v
* master     4644f45 Revert "提交信息2"
  newBranch1 4644f45 Revert "提交信息2"

3.2.11 给一个 commit 指定一个分支名

# git branch  newBranch2 4644f45
# git branch -v
* master     4644f45 Revert "提交信息2"
  newBranch1 4644f45 Revert "提交信息2"
  newBranch2 4644f45 Revert "提交信息2"

3.2.12 强制修改分支指向

git branch -f master HEAD~3

3.2.13 强制修改分支指向

git branch -f master HEAD~3

注意,强制修改分支指向不能在已经检出的分支上操作。

3.2.14 检出分支

git checkout -b newBranch # 根据当前分支检出新分支
git checkout master # 检出已有分支

3.2.15 merge 合并分支

git 合并两个分支会产生一个有两个父节点的提交记录,这两个父节点及他们的所有提交都会包含。 先要检出要操作的分支,再将别的分支合并过来。以下操作先检出 newBranch1,然后将 master 合并到 new Branch1。

# git checkout newBranch1
Already on 'newBranch1'
# git merge master
Already up to date.

查看已经合并到当前分支的分支:

git branch --merged
git branch --no-merged

3.2.16 打标签

将标签命名为 v1,并且明确地让它指向提交记录 47e58,如果不指定提交记录,git 会将 tag 指向 HEAD。

# git tag v1 47e58
# git log
commit 4644f459752b2096b35514bb74055a39c401cf11 (HEAD -> newBranch1, newBranch2)
Author: me <me@me.com>
Date:   Sun Jan 9 00:06:49 2022 +0800

    Revert "提交信息2"

    This reverts commit 97ae483e3526eb37502580f29750e70afba5313a.

commit 97ae483e3526eb37502580f29750e70afba5313a (master)
Author: me <me@me.com>
Date:   Sun Jan 9 00:06:37 2022 +0800

    提交信息2

commit 47e58fe6cc87e8d1f20a2fe348e62d68576e3b1a (tag: v1)
Author: me <me@me.com>
Date:   Sat Jan 8 23:44:36 2022 +0800

    提交信息1

3.2.17 查看 git 引用操作记录

# git reflog

3.2.18 cherry-pick

当我们仅需要另外一个分支的某个或者某几个提交合并到当前分支, cherry-pick 就很有用。他可以将提交树上任意位置的提交合并到 HEAD 上。

# git cherry-pick <commit_id_1>  <commit_id_2>

注意,这里我提一个问题, 从 A 分支 cherry-pick 一个 提交 C 到 B分支,B 分支上 提交 Ccommit_id 会变化么?

3.2.19 复制粘贴,rebase

rebase 实际上就是复制一系列的提交记录,把他粘贴到另外的分支。它可以创造线性的提交历史,避免不必要的分叉。

带上 -i 参数(--interactive), 会打开rebase 窗口,通过这个窗口我们可以删除、合并提交,还可以调整提交的顺序。

git rebase -i <commit_id>

这里说下 rebase 的两个使用场景:

  1. 我们把特性分支合并到主分支上的时候,使用 rebase 合并代码以创造线性的提交历史。
  2. 我们在本地开发一个功能,每改一点点,就提交一次。这些提交隶属于一个功能或者一次修复,我们完全可以在本地合并这些提交后再 push。

注意,在已经推送到远程仓库以后,不建议修改公共分支历史提交

3.2.20 相对引用

相对引用有两种方式:

  1. 使用 ^ 向上移动 1 个提交记录
  2. 使用 ~ 向上移动多个提交记录,如 ~2

当当前提交有多个父提交的时候,git 默认选择第一个父提交,可以使用 ^ 符号指定合并提交记录的某个父提交。

相对引用符号可以连续使用。比如说:

git checkout HEAD~^2

它等价于

git checkout HEAD~^2
git checkout HEAD^2

3.2.21 stash

当我们正在兴奋地增添功能时,突然需要检出主分支修复一个 bug,我们又不想提交现有的修改,那就可以把当前所有未提交的修改先暂存在一个本地的栈中:

# git stash
# git stash save "test3" # 可以给stash加一个 message 用于标记

默认情况下,git stash 会缓存下列文件:

  • 添加到暂存区的修改
  • Git跟踪的但并未添加到暂存区的修改

但不会缓存以下文件:

  • 在工作目录中新的文件
  • 被忽略的文件

使用-u(--include-untracked) 缓存工作区中增添的新文件(没有 add 过的)。
使用-a--all)命令 stash 当前目录下的所有修改。

当我们完成其它任务之后,可以将刚才的提交 pop 出来,继续开发。

# git stash pop

相关的一些命令:

查看现有的 stash:

# git stash list

删除:

# git stash drop

清空 stash:

# git stash clear

查看 stash:

# git stash show [message] # 可以跟 `stash` 的名字
git stash show -p # 增加 -p 参数查看 diff

直接用 stash 创建一个分支:

git stash branch stashBranch1

3.2.23 describe

# git describe --tags
v1-2-g4644f45

describe 的输出格式是 <tag>-<numCommits>_g<hash>
v1-2-g4644f45 表示,在 v1 这个 tag 之后有 2 次提交,最近的一次提交是 4644f45, ggit 的意思。

4. 远程仓库

远程分支的名称由两部分组成:

<远程仓库名称>/<远程分支名称>

远程分支只有在远程仓库里相应的分支更新了才会更新。所以,如果检出了远程分支, 会自动进入分离头指针的状态。添加新的提交也不会更新远程分支。

4.1 远程分支操作

4.1.1 获取远程分支中的更新

git fetch

fetch 是一个单纯的下载操作,它并不会修改工作区和暂存区的数据,它仅执行两步操作:

  • 从远程仓库下载本地仓库中缺失的提交记录
  • 更新远程分支指针(如 origin/master)

它可以将远程分支直接更新到本地(未检出的)分支上:

git fetch <远程仓库> <远程分支>:<本地分支>

嗯,知道就好了,也没见谁这么用过。

当然,还可以直接根据远程分支创建本地不存在的分支:

# git fetch <远程仓库> :<本地分支>
# git fetch origin :dev

4.1.2 将远程分支的更新合并到本地

合并本地分支和合并远程分支并没有什么不同,他们操作的方式是一样的

git cherry-pick origin/master
git rebase origin/master
git merge origin/master
git pull
git pull --rebase

这里解释一下, pull 相当于 fetch + merge, 如果希望使用 rebase 的方式可以添加 --rebase 参数。

4.1.3 将本地分支的更新推送到远程

git push
git push <远程仓库> <远程分支>
git push <远程仓库> <本地分支>:<远程分支>

push 操作会把本地更新推送到远程,他的行为(rebase 还是 merge)和 push.default 有关。

推送一个空分支到远程会删除远程分支,这是一个比较危险的操作。

git push origin :master

4.1.4 设置远程追踪分支 remote tracking

当我们检出远程分支的时候,本地分支和远程分支的对应关系是自动关联的。我们也可以收工指定这种对应关系。

我们可以在检出远程分支的时候指定:

# git checkout -b <本地分支名> <远程仓库>/<远程分支名>
# git checkout -b notMaster origin/master

还可以直接指定:

git branch -u origin/master notMaster

如果当前在 notMaster 分支上,他可以省略。

git branch -u origin/master

4.1.5 修改/增加远程仓库地址

git 的远程仓库可以修改,也可以增加。

修改:

git remote set-url <远程仓库名称> <远程仓库地址> 

增加:

git remote add <远程仓库名称> <远程仓库地址> 

5. 面试高频题

以下为面试常会问到的题目

  • merge 和 rebase 的区别
  • reset 和 revert 的区别

请小伙伴们自行查阅。

5. 参考资料

官方文档
learnGitBranching
index-format Git 社区手册关于 index 的章节