Git原理&常用命令

工作原理
常用配置&操作

工作原理

git 维护三棵树:

  • working directory:工作目录
  • stage(index):缓存
  • history:commit 历史

可以参考这个 图解 Git 命令

获取/创建项目

init

1
git init
1
git init <directory>
1
git init --bare <directory>

初始化一个裸的(没有工作目录的) Git 仓库,中央仓库应该总是用 –bare 标记创建,因为向非裸仓库推送分支有可能会覆盖已有的代码变动。将 bare 看作是将仓库标记为储存设施而不是开发环境,中央仓库是裸仓库,开发者的本地仓库是非裸仓库。

clone

1
2
3
4
5
# 将位于 <repo> 的仓库克隆到本地机器
git clone <repo>

# 将位于 <repo> 的仓库克隆到本地机器上的 <directory> 目录
git clone <repo> <directory>

LF CRLF

clone 下来的东西报 lf crlf 的错误,是由于 git clone 的时候自动进行了转换,可以如下设置关闭

1
git config --global core.autocrlf false

submodule

可以通过 git submodule 配置子模块,git 会在父级目录中用 .gitmodules 文件记录

配置

config

常见配置

1
2
3
4
5
6
7
8
9
10
11
12
git config user.name <name>
git config --global user.name <name>
git config --global user.email <email>

# 为 git 命令创建一个快捷方式(别名)
git config --global alias.<alias-name> <git-command>

# 定义当前机器所有用户使用命令时用到的文本编辑器
git config --system core.editor <editor>

# 用文本编辑器打开全局配置文件,手动编辑
git config --global --edit

git 的配置项存储位置:

  • /.git/config – 特定仓库的设置。
  • ~/.gitconfig – 特定用户的设置。这也是 --global 标记的设置项存放的位置。
  • $(prefix)/etc/gitconfig – 系统层面的设置。

忽略特定文件

1
2
3
4
git update-index --assume-unchanged PATH

# 如果想撤销这个操作
git update-index --no-assume-unchanged PATH

基本快照操作

add

将工作目录中的变化添加到缓冲区,缓存更改

1
2
3
4
5
6
7
8
git add <file>
git add <directory>

# 交互式的缓存
git add -p

# 可以控制那些行被加进去
git add --edit

commit

将缓存的快照提交到项目历史

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 运行文本编辑器,等待输入提交信息
git commit

# 将 <message> 作为提交信息
git commit -m "<message>"

# 自动添加缓冲和提交,除了 new files
git commit -a

# 合并 index 和上一次 commit 提交,且不修改 commit message
# amend 会修改 commit 的 hash 值,无论有没有实际文件变化/message 变化
git commit --amend --no-edit

# 强制设定 commit 日期
git commit --date=<date>

status

1
git status

文件状态:

  • modified, to be commited:已缓存
  • not staged for commit:未缓存
  • untracked:未追踪

rm

1
2
3
4
5
6
7
8
# = rm 且默认(-f)add file
# 从 index 和本地都删除
# 下次 commit 时会修改 git 仓库
git rm <file>

# 从 index 里面删除文件,但本地的文件还保留
# 即“停止跟踪”这些文件
git rm --cached file

mv

1
2
3
4
5
# 重命名一个文件
git mv <source> <destination>

# 移动一个文件
git mv <source> <destination directory>

通过 git mv 去移动相当于自动 add 到 stage 中,不用手动 git add

reset

改变 HEAD 指向的 commit,并有选择地变动 working directory 和 index;也用来在从 commit 历史中复制文件到 index,而不动 working directory

reset 可以理解为重设,而 revert 是撤销:reset 移除掉后面所有的 commit,如果想恢复只能自己重新提交,这个重设是永远的,改了就再也找不回来了,所以要小心,最好只用在本地修改

1
2
3
4
5
6
7
8
9
10
11
12
13
# 当前 HEAD 指向 <commit>
# 有几种常用 mode
# --soft:不改变 index 和 working tree
# --mixed:改变 index,不改变 working tree
# --hard:改变 index 和 working tree
git reset [<mode>] [<commit>]

# 没有给出 commit 版本则默认 HEAD
# mode 不为 soft 的时候都是改变 index 但不改变 working tree
git reset

# 把 tree-ish(commit) 中的 file 复制到当前 index
git reset <tree-ish> -- <files>

restore

可以通过 git reflog 查看日志,找到 reset 前的 commit,然后用 restore 将 working directory 恢复(但是commit 不会恢复,要重新提交)

分支与合并

branch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 列出(本地)仓库中所有分支
git branch

# 查看远程分支
git branch -r

# 创建一个名为 branch 的分支(但不会自动切换到那个分支)
git branch <branch>

# 删除指定分支
# 安全操作,Git 会阻止你删除未合并更改的分支
git branch -d <branch>

# 强制删除指定分支,即使包含未合并更改
git branch -D <branch>

# 将当前分支命名为 branch
git branch -m <branch>

Git 的分支实际上是指向 commit 的指针,代表了一系列 commit 的顶端,而不是 commit 的容器。分支历史通过提交之间的关系来推断

所以 Git 的合并其实是将两个独立的 commit 历史连接起来,而不是基于文件的操作

因为分支本质是个指针,所以创建/删除分支都不会影响到仓库历史

删除分支时 Git 会检查这个分支是否已经被合并,如果还没被合并就删除,那就相当于丢失了这段开发线的入口(指针),所以 Git 会做出错误提示

checkout

从 commit history 或 index 中拷贝文件到 working directory;或切换分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 从指定的 commit 中拷贝文件到 working directory
# 如果不指定 tree-ish 则会从 index 中拷贝缓存的内容到 working directory
git checkout <tree-ish> -- <files>

# HEAD 移动到 branch
# index 和 working directory 会和 HEAD 对应的 commit 节点一致
# 新 commit 节点中所有文件都会被复制到 index 和 working directory 中
# 只存在于老的 commit 节点中的文件会被删除
# 不属于两者的文件会被忽略,不受影响
git checkout <branch>

# 如果不指定文件名/分支名,而是一个标签/远程分支/SHA-1值,就会得到一个匿名分支
# 称作 detached HEAD(分离的 HEAD)
# 分离状态:提交操作可以正常进行,但是不会更新任何已命名的分支(其实是在更新匿名分支)
# 此后一旦切换到别的分支,那么这个提交节点因为不会被引用掉,所以就被丢弃了
# 如果想要保存这个状态,可以通过 checkout -b name 创建一个新的有名字的分支

项目中可能出现的情况

由于常用的npm run dev 一般是增量编译配置,所以有时候在一个分支编译完 checkout 到另一个分支,但 /dist 里的东西由于被 ignore 所以还保留着原分支的一些信息,最好 checkout 之后 rm /dist 再重新编译

merge

1
2
3
4
5
6
# 将指定分支并入当前分支
git merge <branch>

# 将制定分支并入当前分支
# 但总是生成一个合并提交(即使是快速向前合并)
git merge --no-ff <branch>

当前分支会被更新,但目标分支完全不受影响

合并算法:

  • 快速向前合并:当当前分支顶端到目标分支路径是线性时,Git 只需要将当期分支顶端(快速向前地)移动到目标分支顶端,即可整合两个分支的历史,而不需要“真正”合并分支。在效果上合并了历史,因为目标分支上的 commit 现在在当前分支可以访问到
  • 三路合并:使用一个专门的 commit 来合并两个分支的历史(Git 实际上使用三个 commit 来生成它,分别是两个分支顶端和它们共同的祖先)

通常用快速向前合并搭配 rebase 来合并微小的功能或者修复 bug,使用三路合并来整合长期运行的功能

解决冲突

手动解决:merge 发现错误 -> 自己修复 -> git add -> git commit 生成合并提交

stash

1
2
3
4
5
6
7
8
9
10
11
12
13
git stash

# 加 meassage
git stash push -m <message>

# 弹出一个存储的 state
git stash pop

# 类似于 stack peek,查看但不弹出
git stash apply

# 列出现有的 stash
git stash list

把现在的 working directory 和 index 存起来,但是不 commit 到 history 以免污染历史

找回不小心 pop/drop 掉的 stash

出处

1
2
 gitk --all $(git fsck --no-reflog | Select-String "(dangling commit )(.*)
" | %{ $_.Line.Split(' ')[2] })

之后会打开一个 GUI 界面,在里面找到你丢失掉的那个 commit 值之后 git stash apply <commit> 即可

tag

通常在发布版本时打一个 tag,tag 会记录版本的 commi 号,方便后期回溯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 列出已有 tag
git tag

# 新建 tag
git tag <tagname>

# 带备注的 tag
git tag -a <tagname> -m <msg>

# 给指定的某个 commit 加 tag
git tag -a <tagname> <commit>

# 查看 tag 详细信息
git show <tagname>

# 将 tag 同步到远程服务器
git push origin <tagname>

# 切换 tag
# 当 branch 和 tagname 重名时候可以指定 tags/tagname
git checkout <tagname>

# 删除 tag
git tag -d <tagname>

分享更新项目

remote

管理与其他仓库之间的连接,用书签(别名)的方式去引用其他仓库的 url 连接

1
2
3
4
5
6
7
8
9
10
11
12
# 列出远程连接列表
git remote

# 带 url 的列表
git remote -v

# 添加/删除远程连接
git remote add <name> <url>
git remote rm <name>

# 重命名
git remote rename <old-name> <new-name>

git clone 时,自动创建了一个名为 origin 的远程连接,指向被克隆的 repo(基本上都是中央仓库)

http 或 ssh 协议

fetch

将远程仓库的提交拉到本地仓库,拉下来的提交储存为远程分支,而不是我们一直用的普通的本地分支

1
2
3
4
5
# 拉取仓库中的所有分支
git fetch <remote>

# 只拉取指定分支
git fetch <remote> <branch>

查看拉下来的远程分支时,会像 checkout 一样处于分离 HEAD 状态(基本上可以视作只读)

如果接受远程分支包含的更改,可以用 git merge 将它并入本地分支

pull

= fetch + merge

1
2
3
4
5
6
# = fetch + merge origin/.
git pull <remote>

# 与上一个命令相同,但使用 rebase 合并远程分支与本地分支,而不是 merge
# rebase 可以用来保证项目的线性历史,
git pull --rebase <remote>

push

将提交导出到远程分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 将指定的分支推送到 remote 上
# 如果导致目标仓库非快速向前合并时,git 不允许 push
# 非快速向前合并指的就是要真正发生 merge 的情况(即已经分叉)
git push <remote> <branch>

# 即使会导致非快速向前合并也强制推送
git push <remote> --force

# 推送所有本地分支
git push <remote> --all

# 将所有本地标签推送到远程仓库
# 上面的写法都不会自动推送标签
git push <remote> --tags

push 其实相当于在远程仓库内部运行 git merge

只应该推送到那些用 –bare 初始化的仓库(裸仓库,没有工作目录)。因为 push 会弄乱远程分支结构,所以永远不要推送到其他开发者的仓库

开发标准做法

1
2
3
4
git checkout master # 参考 checkout 中的解释,注意并不会造成工作丢失!
git fetch origin master # 确保本地 master 和中央仓库一致
git rebase -i origin/master # 交互式 rebase,清理 commit
git push origin master # 确定本地 master 是最新的,能够快速向前合并

审查比较

diff

行号

git diff 不能直接得到行号信息,而是会给出一个 unified-diff format 格式的东西:

1
@@ -start,count +start,count @@

- 标记的代表原状态的信息,+ 标记的代表现状态的信息

start 代表原状态/现状态的开始行号,count 代表从 start 开始有多少行被修改了

实际数起来是这样的(例子来自 stack overflow 的这个回答

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/osx/.gitconfig b/osx/.gitconfig
index 4fd8f04..fcd220c 100644
--- a/osx/.gitconfig
+++ b/osx/.gitconfig
@@ -11,7 +11,7 @@ <== HERE!
[color "branch"]
upstream = cyan
[color "diff"]
- meta = yellow
+ meta = cyan
plain = white dim
old = red bold
new = green bold

这里的 11 代表下面显示的这些代码从第 11 行开始,包括 7 行,所以原文件中的行号其实是这样的

1
2
3
4
5
6
7
8
11  [color "branch"]
12 upstream = cyan
13 [color "diff"]
14 - meta = yellow
14 + meta = cyan
15 plain = white dim
16 old = red bold
17 new = green bold

所以实际被修改的是第 14 行。

打补丁

rebase

将分支移到一个新的基 commit 的过程

rebase 这个“基”,指的是三路合并时的那个基 commit

从内容的角度看,rebase 只不过是将分支从一个 commit 移到了另一个;但是从内部机制来看, Git 是通过在选定的基上创建新 commit 来完成这件事的,事实上重写了项目历史

1
2
3
4
5
6
7
# 将当前分支 rebase 到 base
# base 可以是任何类型的 commit 引用:ID、分支名、标签、或是 HEAD 的相对引用
git rebase <base>

# 交互式的 rebase
# 在过程中可以修改每个 commit,控制它们怎样转移到新的基上去,也可以对它们进行排序
git rebase -i <base>

rebase 的主要目的是为了保持一个线性的项目历史

将自己的 feature 分支整合进 master 分支,有两个选择:

  • 直接 merge,会产生一个三路合并和一个合并提交
  • 先 rebase 到 master HEAD 再 merge,产生一个快速向前的合并以及完美的线性历史

rebase 是将上游更改合并进本地仓库的通常方法,“我想将我的更改建立在其他人的进展之上”的感觉

本质上是线性化的自动的 cherry-pick

revert

1
2
# 生成一个撤销了 commit 引入的修改的新提交,然后应用到当前分支
git revert <commit>

应该用在想要在项目历史中移除一整个提交(通常是因为这个提交造成 bug)的时候

被撤销的提交依然在项目历史中,git revert 在后面增加了一个提交来撤销修改,而不是删除它

cherry-pick

“复制一个 commit 节点并在当前分支做一次完全一样的新提交

debug

blame

1
git blame -- <file>

显示该文件的修改,以及每个修改的作者

grep

打印出符合指定 pattern 的行数

1
git grep <pattern> -- <files>

bisect

1
git bisect <subcommand> <options>

用二分查找的方式找出 bug 来自于哪个 commit,具体见 官方文档

管理

reflog

查看日志