Git

全面介绍了Git的原理和使用

Source

  • 廖雪峰的教程

  • git使用技巧和笔记

  • Learn Git Branching

Tools:

  • Git Command Explorer

Pro Git

Git Community Book

Intro

  • Git是Linus用C写的分布式版本管理系统

集中式:

  • CVS[^1]:最早的开源且免费的集中式版本控制系统<由于自身设计问题,会造成提交文件不完整,版本库莫名损坏的情况
  • SVN[^2]:同样开源且免费,修正了CVS的一些稳定性问题,是目前用得最多的集中式版本控制系统
  • ClearCase:IBM的,收费,又大又笨

分布式:

  • Git
  • git add tells git that it should track changes made to a particular file. Useful if you don't necessarily want to track all changes to all files in the index at every commit.
  • git commit is like a save button. Up until that point all changes since the last commit are still just "staged" and not yet permanently written into the local git repo. This command tells git to permanently store changes made to the files you selected using git add as a node in the git tree.
  • git commit -a is a shortcut for "save all changes to all known files in the index". It's the same as if you git add'ed all the files you're already tracking and then ran git commit. No new files will be tracked, so if something / someone writes junk like temporary files they won't get swept up into the repo.

创建版本库

版本库:repository

1
2
$ git init
Initialized empty Git repository in /c/Users/陆昱宽/Desktop/DOC/.git/

自动生成.git目录, 用于跟踪管理版本库; 以及在 project 和 每个 module 中生成一个 .gitgnore 文件

基本操作

git add

我们修改Git.md文件,运行git.status看看结果:

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

$ git statusOn branch master
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: "\346\227\245\350\256\260.md"

Untracked files:
(use "git add <file>..." to include in what will be committed)
Diary.md
"\344\271\220\350\260\261.md"

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

git.status命令可以展示仓库当前的状态,上面的输出告诉我们,Git.md被修改过了,但还没有准备提交的修改.

git diff Git.md能看到具体修改了什么内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git diff
diff --git "a/\346\227\245\350\256\260.md" "b/\346\227\245\350\256\260.md"
deleted file mode 100644
index 4db3376..0000000
--- "a/\346\227\245\350\256\260.md"
+++ /dev/null
@@ -1,179 +0,0 @@
- # Diary
-
-## 12 / 25
-
-  圣诞夜中了两棵树,一棵二叉搜索树,一棵AVL树,另一棵B树没有开工,忙碌的一天。
-
-
-
- ## 12/26
-
-  昨天凡人修仙传看到四点, 今天本打算写完微积分和计基的,但是没忍住看了一天小说

git diff顾名思义就是查看difference,显示的格式正是Unix通用的diff格式

知道了对Git.md作了什么修改后,再把它提交到仓库就放心多了. 提交修改和提交新文件一样是两步,

  1. git add
1
$ git add Git.md

没有任何反应,在执行git commit之前,我们再运行git status 看看当前仓库的状态:

1
2
3
4
5
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: Git.md

git status告诉我们,将要被提交的修改包括Git.md ,下一步就能放心提交了:

1
2
3
$ git commit -m"4"
[master 4553e0a] 4
1 file changed, 77 insertions(+), 1 deletion(-)

提交后,再用git status看看仓库的当前状态:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

Git告诉我们当前没有需要提交的修改,且工作目录是干净(working tree clean)的.

  • windows下commit的message千万不能有中文,否则会乱码

版本回退

先提交文件,message为"origin";再输入"初次修改"并提交,message为"chucixiugai";再输入"再次修改" 并提交,message为"再次修改".

现在,Git.md一共有三个版本(origin之前的不算)被提交到repository里了,

版本1:origin

1

(啥都没写)

版本2:chucixiugai

1
初次修改

版本3:zaicixiugai

1
再次修改

我们不可能记住一个文件每次都改了什么内容,版本控制系统肯定有某个命令可以告诉我们历史记录,Git中用git log命令查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git log
commit 028637e47a974357debe623e8bb4d0ca5053db87 (HEAD -> master)
Author: 陆昱宽 <191820133@ smail.nju.edu.cn>
Date: Sun Feb 28 23:41:08 2021 +0800

zaicixiugai

commit cdd92a630141982791273c123d60b3ce0ed09932
Author: 陆昱宽 <191820133@ smail.nju.edu.cn>
Date: Sun Feb 28 23:40:30 2021 +0800

chucixiugai

commit 914f04abce43f4517121ba10957d89bd9658432e
Author: 陆昱宽 <191820133@ smail.nju.edu.cn>
Date: Sun Feb 28 23:39:31 2021 +0800

origin

git log命令显示最近到最远的全部提交日志,我们看最近三次,最近一次是zaicixiugai,上一次是chuxixiugai,再上一次是origin. 如果嫌输出信息太多不太好看,可以加上--pretty=oneline参数:

1
2
3
4
$ git log --pretty=oneline
028637e47a974357debe623e8bb4d0ca5053db87 (HEAD -> master) zaicixiugai
cdd92a630141982791273c123d60b3ce0ed09932 初次修改
914f04abce43f4517121ba10957d89bd9658432e origin

我们看到许多类似028637ecommit id(版本号),这是一个SHA1计算出的非常大的数字,用十六进制表示. SVN的版本号是1,2,3,4递增,因为SVN是集中式. Git是分布式,号码容易不够,所以用这种方式

每提交一个新版本,实际上Git就会把它们自动串成一条时间线。如果使用可视化工具查看Git历史,就可以更清楚地看到提交历史的时间线

现在我们把Git.md回退到上个版本chucitijiao. 首先,Git必须知道当前版本是哪个版本,在Git中,用HEAD(小写也可以)表示当前版本,即最近的提交02863,上个版本就是HEAD^,上上个版本就是HEAD^^,以此类推. 往前100个版本数不过来,可以写成HEAD~100.

git reset命令:

1
2
$ git reset --hard HEAD^
HEAD is now at cdd92a6 chucitijiao

,--hard参数的意义之后再讲.

OK,已经被还原了

现在用git log看看当前版本库的提交日志:

1
2
3
4
5
6
7
8
9
10
11
12
$ git log
commit cdd92a630141982791273c123d60b3ce0ed09932 (HEAD -> master)
Author: 陆昱宽 <191820133@ smail.nju.edu.cn>
Date: Sun Feb 28 23:40:30 2021 +0800

chucixiugai

commit 914f04abce43f4517121ba10957d89bd9658432e
Author: 陆昱宽 <191820133@ smail.nju.edu.cn>
Date: Sun Feb 28 23:39:31 2021 +0800

origin

我们看不到最近的那个版本zaicixiugai了! 好比时间从21世纪穿越回了10世纪,当然看不到后面的历史了! 如果想回去,就要用commit id.

  1. 如果还没关掉GIt窗口,可以把窗口往上翻,找到那个zaicixiugaicommit id028637e...,于是可以指定回到未来的某个版本:
1
2
$ git reset --hard 0286
HEAD is now at 028637e zaicixiugai

版本号没必要写全,写前几位. Git会自动去找.

OK,已经回到未来了.

  1. 如果已经关掉窗口了,Git中有git reflog记录你的每一次命令:
1
2
3
4
5
6
7
$ git reflog --pretty=oneline
028637e (HEAD -> master) HEAD@{0}: reset: moving to 0286
914f04a HEAD@{1}: reset: moving to head^
cdd92a6 HEAD@{2}: reset: moving to HEAD^
028637e (HEAD -> master) HEAD@{3}: commit: zaicixiugai
cdd92a6 HEAD@{4}: commit: 初次修改
914f04a HEAD@{5}: commit: origin

小结

  • HEAD指向的版本就是当前版本,这是个指针. 版本穿梭使用git reset --hard commit_id
  • 穿梭前,用git log可以查看提交历史,以便确定要回退到哪个版本.
  • 要回到未来,可以用git reflog来确定要回到未来的哪个版本.
  • 如果commit(提交)比较多,git log 的内容就会比较多;当满屏放不下,就会显示冒号,回车(往下滚一行)、空格(往下滚一页)可以继续查看剩余内容;退出:英文状态下 按 q 可以退出git log 状态。

撤销修改

readme .txt中添加了一行,但还没有git add:

1
2
3
4
5
6
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
My stupid boss still prefers SVN.

现在想撤销最后一行,可以用 git restore readme.txt, 有两种情况:

  • read.me.txt自修改后还没有被放到暂存区,现在,撤销修改就回到和版本库一模一样的状态 .
  • readme.txt已经添加到暂存区后,又做了修改. 现在,撤销修改就回到和添加到暂存区后的状态.

总之,就是让该文件回到最近一次add时的状态(即 暂存区里的状态)

现在,看看readme.txt的内容:

1
2
3
4
5
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.

果然复原了.

如果我把那句话git add到暂存区了呢? ( 但还没有commit) ,先用git status查看一下,

1
2
3
4
5
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

git告诉我们,修改只是被添加到了暂存区,还没有被提交,可以用 git restore --staged readme.txt 把暂存区的修改撤销掉( unstage ),重新放回工作区:

1
2
3
4
5
6
$ cat readme.txt
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
My stupid boss still prefers SVN.

再用git status查看一下,现在暂存区是干净的,工作区有修改:

1
2
3
4
5
6
$ 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: readme.txt

我们要丢弃工作区的修改,就回到了上上步: git restore readme.txt

1
2
3
4
5
$ git restore readme.txt

$ git status
On branch master
nothing to commit, working tree clean

Over!

如果我不仅把那句话git add了,我还git commit到了版本库, 我们可以用版本回退. 但如果你还把它git push到了 远程版本库,那就完蛋了.

删除文件

情况一: 工作区文件删除,无其它操作

  1. rm readme.txt

可用命令git restore readme.txt恢复文件

情况二: 工作区文件删除,版本库文件删除

  1. rm readme.txt
  2. git rm readme.txt
  3. git commit -m" remove readme.txt "

可用命令git reset --hard HAED^恢复文件( 回到哪个头要看情况,如果当前分支内有这个文件,那就可以回到当前版本,不用去上个版本)

rm是DOS命令,在各个shell都可以用. 用在git仓库中,是删除工作区的文件,用 git restore readme.txt可以还原. 而git rm readme.txt是删除工作区和暂存区的文件, 由于git restore的原理是将工作复原为暂存区中的版本,而暂存区中该文件也被删除了,所以恢复不了, 分支里还有这个文件,所以用版本回退git reset --hard HEAD^

git pull

1、将远程指定分支 拉取到 本地指定分支上:

1
git pull origin <远程分支名>:<本地分支名>

2、将远程指定分支 拉取到 本地当前分支上:

1
git pull origin <远程分支名>

3、将与本地当前分支同名的远程分支 拉取到 本地当前分支上(需先关联远程分支,方法见文章末尾)

1
git pull

在克隆远程项目的时候,本地分支会自动与远程仓库建立追踪关系,可以使用默认的origin来替代远程仓库名

pull强制覆盖本地文件

ref: https://www.jianshu.com/p/1ac2e1f99166

重要提示:如果您有任何本地更改,将会丢失。无论是否有--hard选项,任何未被推送的本地提交都将丢失。 如果您有任何未被Git跟踪的文件(例如上传的用户内容),这些文件将不会受到影响。 下面是正确的方法:

1
git fetch --all

然后,你有两个选择:

1
git reset --hard origin/master

或者如果你在其他分支上:

1
git reset --hard origin/<branch_name>

说明:

git fetch从远程下载最新的,而不尝试合并或rebase任何东西。

然后git reset将主分支重置为您刚刚获取的内容。 --hard选项更改工作树中的所有文件以匹配origin/master中的文件

在重置之前可以通过从master创建一个分支来维护当前的本地提交:

1
2
3
4
git checkout master
git branch new-branch-to-save-current-commits
git fetch --all
git reset --hard origin/master

在此之后,所有旧的提交都将保存在new-branch-to-save-current-commits中。然而,没有提交的更改(即使staged)将会丢失。确保存储和提交任何你需要的东西。

git push

1、将本地当前分支 推送到 远程指定分支上

1
git push origin <本地分支名>:<远程分支名>

2、将本地当前分支 推送到 与本地当前分支同名的远程分支

1
git push origin <本地分支名>

3、将本地当前分支 推送到 与本地当前分支同名的远程分支上(需先关联远程分支])

1
git push

修改commit信息

https://blog.csdn.net/Muscleape/article/details/105637401

远程仓库

添加远程库

把已有的本地仓库与一个git仓库相关联( 建立绑定关系 ):

$ git remote add origin https://github.com/LYK-love/Learning, 添加后,远程库的名字就是origin,这是Git默认的叫法,也可以改名,但没必要.

下一步,就可以把本地库的内容push到远程库上:

1
2
3
4
5
6
7
8
9
10
11
$ git push -u origin master
Enumerating objects: 7, done.
Counting objects: 100% (7/7), done.
Delta compression using up to 12 threads
Compressing objects: 100% (7/7), done.
Writing objects: 100% (7/7), 6.46 KiB | 6.46 MiB/s, done.
Total 7 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), done.
To https://github.com/LYK-love/Learning.git
* [new branch] master -> master
Branch 'master' set up to track remote branch 'master' from 'origin'.

把本地库内容推送到远程,用git push命令,实际上是把当前分支master(本地的那个)推送到远程库(名叫origin).

由于远程库是空的,我们第一次推送master分支时,加上了-u参数. Git不但会把本地的master分支内容推送到远程新的master分支( 现在远程也有一个叫master的分支了),还会把本地的master分支和远程的master分支关联起来,这样在以后的oush或oull时就可以简化命令.

从现在起,只要在本地作了git commit,就可以通过命令git push origin把本地的master分支的最新修改推送到Github,大功告成!

删除远程库

如果添加远程库的时候地址写错了,或者就是想删除远程库,可以用git remote rm <name>命令. 使用前建议先用git remote -v查看远程库信息:

1
2
3
$ git remote -v
origin https://github.com/LYK-love/Learning.git (fetch)
origin https://github.com/LYK-love/Learning.git (push)

然后,根据名字删除.

注意:

  • 此处的"删除"其实是解除了本地和远程库的绑定关系,并没有物理上删除远程库,远程库本身没有任何改动( 也就是说远程库里的内容都还在 )要恢复绑定关系,可以再次用$ git remote add origin https://github.com/LYK-love/Learning , 然后用$ git push -u origin master来推送( -u参数能关联分支 )
  • origin就是你指向的远程库的名字,可以等价于https://github.com/LYK-love/Learning,它指向的是repository, 而master只是这个repository中默认创建的第一个branch. 当我们push的时候,因为originmaster是默认创建的,所以这二者可以省略,但这是个bad practice,因为如果我换一个branch再push的时候,这样就很纠结了.
  • 当然origin这个名字来源于$ git remote add origin https://github.com/LYK-love/Learning,我们也可以把origin改成别的名字,比如阿猫阿狗,如 $ git remote add aMao https://github.com/LYK-love/Learning,那么推送的时候就可以用git push -u aMao master了, 如果你的本地版本库是从远程仓库git clone而来,git会默认把这个远程仓库的地址叫做origin. 这时候依旧可以通过 git remote add 把远程仓库的名称改成'aGou'

Branch

创建分支

创建dev分支, 然后切换到dev分支:

1
2
$ git switch -c dev 
Switched to a new branch 'dev'

切换分支

1
git switch dev 

查看分支

  • 查看本地所有分支:

    1
    git branch
  • 查看远程所有分支

    1
    git branch -r
  • 查看本地及远程的所有分支:

    1
    git branch -a 

删除分支

  • 删除远程分支:

    1
    git push origin :br  (origin 后面有空格)
  • 删除本地分支:

    1
    git branch -D br

关联分支

将本地分支与远程同名分支相关联

1
git branch --set-upstream-to=origin/develop develop

也可以在push时设置:

l
1
2
3
4
git push --set-upstream origin <本地分支名>

# 简写方式:
# git push -u origin <本地分支名>

pull和push同.

创建与合并分支

HEAD严格来说不是指向提交,而是指向mastermaster才是指向提交的,所以,HEAD指向的就是当前分支。

  • 首先,我们创建dev分支,然后切换到dev分支:

  • 然后,用git branch命令查看当前分支:

  • 然后提交:

    1
    2
    3
    4
    $ git add readme.txt 
    $ git commit -m "branch test"
    [dev b17d20e] branch test
    1 file changed, 1 insertion(+)
  • 现在,dev分支的工作完成,我们就可以切换回master分支:

    1
    2
    $ git switch master
    Switched to branch 'master'

切换回master分支后,再查看一个readme.txt文件,刚才添加的内容不见了!( 工作区的不见了!!! )因为那个提交是在dev分支上,而master分支此刻的提交点并没有变:

git-br-on-master
  • 现在,我们把dev分支的工作成果合并到master分支上:

    1
    2
    3
    4
    5
    $ git merge dev
    Updating d46f35e..b17d20e
    Fast-forward
    readme.txt | 1 +
    1 file changed, 1 insertion(+)
  • git merge命令用于合并指定分支到当前分支。合并后,再查看readme.txt的内容(工作区),就可以看到,和dev分支的最新提交是完全一样的。

注意到上面的Fast-forward信息,Git告诉我们,这次合并是“快进模式”,也就是直接把master指向dev的当前提交,所以合并速度非常快。

当然,也不是每次合并都能Fast-forward,我们后面会讲其他方式的合并。

合并完成后,就可以放心地删除dev分支了:

1
2
$ git branch -d dev
Deleted branch dev (was b17d20e).

删除后,查看branch,就只剩下master分支了:

1
2
$ git branch
* master

因为创建、合并和删除分支非常快,所以Git鼓励你使用分支完成某个任务,合并后再删掉分支,这和直接在master分支上工作效果是一样的,但过程更安全。

  • 查看分支:git branch

  • 创建分支:git branch <name>

  • 切换分支:git checkout <name>或者git switch <name>

  • 创建+切换分支:git checkout -b <name>或者git switch -c <name>

  • 合并某分支到当前分支:git merge <name>

  • 删除分支:git branch -d <name>

查看远程分支

查看远程与本地当前分支对应的分支:

1
git branch -vv

查看本地和远程所有分支

1
git branch -a

解决冲突

如果master分支和feature1分支各自都分别有新的提交,变成了这样:

git-br-feature1

这种情况下,Git无法执行“快速合并”,只能试图把各自的修改合并起来,但这种合并就可能会有冲突,我们试试看:

1
2
3
4
$ git merge feature1
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

果然冲突了!Git告诉我们,readme.txt文件存在冲突,必须手动解决冲突后再提交。git status也可以告诉我们冲突的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git status
On branch master
Your branch is ahead of 'origin/master' by 2 commits.
(use "git push" to publish your local commits)

You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)

Unmerged paths:
(use "git add <file>..." to mark resolution)

both modified: readme.txt

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

打开工作区的readme.txt,我们可以直接查看readme.txt的内容:

1
2
3
4
5
6
7
8
9
Git is a distributed version control system.
Git is free software distributed under the GPL.
Git has a mutable index called stage.
Git tracks changes of files.
<<<<<<< HEAD
Creating a new branch is quick & simple.
=======
Creating a new branch is quick AND simple.
>>>>>>> feature1

Git用<<<<<<<=======>>>>>>>标记出不同分支的内容,我们修改如下后保存:

1
Creating a new branch is quick and simple.

再提交:

1
2
3
$ git add readme.txt 
$ git commit -m "conflict fixed"
[master cf810e4] conflict fixed

现在,master分支和feature1分支变成了下图所示:

git-br-conflict-merged

用带参数的git log也可以看到分支的合并情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git log --graph --pretty=oneline --abbrev-commit
* cf810e4 (HEAD -> master) conflict fixed
|\
| * 14096d0 (feature1) AND simple
* | 5dc6824 & simple
|/
* b17d20e branch test
* d46f35e (origin/master) remove test.txt
* b84166e add test.txt
* 519219b git tracks changes
* e43a48b understand how stage works
* 1094adb append GPL
* e475afc add distributed
* eaadf4e wrote a readme file

最后,删除feature1分支:

1
2
$ git branch -d feature1
Deleted branch feature1 (was 14096d0).

工作完成。

  • 当Git无法自动合并分支时,就必须首先解决冲突。解决冲突后,再提交,合并完成。

    解决冲突就是把Git合并失败的文件手动编辑为我们希望的内容,再提交。

    git log --graph命令可以看到分支合并图。

分支管理策略

通常,合并分支时,如果可能,Git会用Fast forward模式,但这种模式下,删除分支后,会丢掉分支信息。

如果要强制禁用Fast forward模式,Git就会在merge时生成一个新的commit,这样,从分支历史上就可以看出分支信息。

下面我们实战一下--no-ff方式的git merge

首先,仍然创建并切换dev分支:

1
2
$ git switch -c dev
Switched to a new branch 'dev'

修改readme.txt文件,并提交一个新的commit:

1
2
3
4
$ git add readme.txt 
$ git commit -m "add merge"
[dev f52c633] add merge
1 file changed, 1 insertion(+)

现在,我们切换回master

1
2
$ git switch master
Switched to branch 'master'

准备合并dev分支,请注意--no-ff参数,表示禁用Fast forward

1
2
3
4
$ git merge --no-ff -m "merge with no-ff" dev
Merge made by the 'recursive' strategy.
readme.txt | 1 +
1 file changed, 1 insertion(+)

因为本次合并要创建一个新的commit,所以加上-m参数,把commit描述写进去。

合并后,我们用git log看看分支历史:

1
2
3
4
5
6
7
$ git log --graph --pretty=oneline --abbrev-commit
* e1e9c68 (HEAD -> master) merge with no-ff
|\
| * f52c633 (dev) add merge
|/
* cf810e4 conflict fixed
...

可以看到,不使用Fast forward模式,merge后就像这样:

git-no-ff-mode
  • 不用Fast forwa模式,提交图就像是 dev分支上做提交, master分支上做提交, 然后在master分支上手动解决冲突再提交 的图一样!.
  • 合并分支时,加上--no-ff参数就可以用普通模式合并,合并后的历史有分支,能看出来曾经做过合并,而fast forward合并就看不出来曾经做过合并。
  • 如果之前masterdev只想相同提交. 现在你在master分支, 对 readme.txt做了修改, 然后switch到了dev分支, add, commit, 现在dev指向新的提交了! 我们switch回到master分支, 打开readme.txt,发现里面的内容是没有修改过的, 这是因为分支是指向提交的, 尽管你在master分支做了修改,但没有提交, 提交是在dev分支上完成的. 因此master分支指向的是上一次提交, 也就是没有修改过的版本.

Bug分支

软件开发中,bug就像家常便饭一样。有了bug就需要修复,在Git中,由于分支是如此的强大,所以,每个bug都可以通过一个新的临时分支来修复,修复后,合并分支,然后将临时分支删除。

当你接到一个修复一个代号101的bug的任务时,很自然地,你想创建一个分支issue-101来修复它,但是,等等,当前正在dev上进行的工作还没有提交:

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch dev
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: hello.py

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

modified: readme.txt

并不是你不想提交,而是工作只进行到一半,还没法提交,预计完成还需1天时间。但是,必须在两个小时内修复该bug,怎么办?

幸好,Git还提供了一个stash功能,可以把当前工作现场“储藏”起来,等以后恢复现场后继续工作:

1
2
$ git stash
Saved working directory and index state WIP on dev: f52c633 add merge

现在,用git status查看工作区,就是干净的(除非有没有被Git管理的文件),因此可以放心地创建分支来修复bug。

首先确定要在哪个分支上修复bug,假定需要在master分支上修复,就从master创建临时分支:

1
2
3
4
5
6
7
$ git checkout master
Switched to branch 'master'
Your branch is ahead of 'origin/master' by 6 commits.
(use "git push" to publish your local commits)

$ git checkout -b issue-101
Switched to a new branch 'issue-101'

现在修复bug,需要把“Git is free software ...”改为“Git is a free software ...”,然后提交:

1
2
3
4
$ git add readme.txt 
$ git commit -m "fix bug 101"
[issue-101 4c805e2] fix bug 101
1 file changed, 1 insertion(+), 1 deletion(-)

修复完成后,切换到master分支,并完成合并,最后删除issue-101分支:

1
2
3
4
5
6
7
8
9
$ git switch master
Switched to branch 'master'
Your branch is ahead of 'origin/master' by 6 commits.
(use "git push" to publish your local commits)

$ git merge --no-ff -m "merged bug fix 101" issue-101
Merge made by the 'recursive' strategy.
readme.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

太棒了,原计划两个小时的bug修复只花了5分钟!现在,是时候接着回到dev分支干活了!

1
2
3
4
5
6
$ git switch dev
Switched to branch 'dev'

$ git status
On branch dev
nothing to commit, working tree clean

工作区是干净的,刚才的工作现场存到哪去了?用git stash list命令看看:

1
2
$ git stash list
stash@{0}: WIP on dev: f52c633 add merge

工作现场还在,Git把stash内容存在某个地方了,但是需要恢复一下,有两个办法:

一是用git stash apply恢复,但是恢复后,stash内容并不删除,你需要用git stash drop来删除;

另一种方式是用git stash pop,恢复的同时把stash内容也删了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ git stash pop
On branch dev
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

new file: hello.py

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

modified: readme.txt

Dropped refs/stash@{0} (5d677e2ee266f39ea296182fb2354265b91b3b2a)

再用git stash list查看,就看不到任何stash内容了:

1
$ git stash list

你可以多次stash,恢复的时候,先用git stash list查看,然后恢复指定的stash,用命令:

1
$ git stash apply stash@{0}

在master分支上修复了bug后,我们要想一想,dev分支是早期从master分支分出来的,所以,这个bug其实在当前dev分支上也存在。

那怎么在dev分支上修复同样的bug?重复操作一次,提交不就行了?

有木有更简单的方法?

有!

同样的bug,要在dev上修复,我们只需要把4c805e2 fix bug 101这个提交所做的修改“复制”到dev分支。注意:我们只想复制4c805e2 fix bug 101这个提交所做的修改,并不是把整个master分支merge过来。

为了方便操作,Git专门提供了一个cherry-pick命令,让我们能复制一个特定的提交到当前分支:

1
2
3
4
5
6
$ git branch
* dev
master
$ git cherry-pick 4c805e2
[master 1d4b803] fix bug 101
1 file changed, 1 insertion(+), 1 deletion(-)

Git自动给dev分支做了一次提交,注意这次提交的commit是1d4b803,它并不同于master的4c805e2,因为这两个commit只是改动相同,但确实是两个不同的commit。用git cherry-pick,我们就不需要在dev分支上手动再把修bug的过程重复一遍。

  • 修复bug时,我们会通过创建新的bug分支进行修复,然后合并,最后删除;

  • 当手头工作没有完成时,先把工作现场git stash一下,然后去修复bug,修复后,再git stash pop,回到工作现场;

  • 在master分支上修复的bug,想要合并到当前dev分支,可以用git cherry-pick <commit>命令,把bug提交的修改“复制”到当前分支,避免重复劳动。

Feature分支

软件开发中,总有无穷无尽的新的功能要不断添加进来。

添加一个新功能时,你肯定不希望因为一些实验性质的代码,把主分支搞乱了,所以,每添加一个新功能,最好新建一个feature分支,在上面开发,完成后,合并,最后,删除该feature分支。

现在,你终于接到了一个新任务:开发代号为Vulcan的新功能,该功能计划用于下一代星际飞船。

于是准备开发:

1
2
$ git switch -c feature-vulcan
Switched to a new branch 'feature-vulcan'

5分钟后,开发完毕:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git add vulcan.py

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

new file: vulcan.py

$ git commit -m "add feature vulcan"
[feature-vulcan 287773e] add feature vulcan
1 file changed, 2 insertions(+)
create mode 100644 vulcan.py

切回dev,准备合并:

1
$ git switch dev

一切顺利的话,feature分支和bug分支是类似的,合并,然后删除。

但是!

就在此时,接到上级命令,因经费不足,新功能必须取消!

虽然白干了,但是这个包含机密资料的分支还是必须就地销毁:

1
2
3
$ git branch -d feature-vulcan
error: The branch 'feature-vulcan' is not fully merged.
If you are sure you want to delete it, run 'git branch -D feature-vulcan'.

销毁失败。Git友情提醒,feature-vulcan分支还没有被合并,如果删除,将丢失掉修改,如果要强行删除,需要使用大写的-D参数。。

现在我们强行删除:

1
2
$ git branch -D feature-vulcan
Deleted branch feature-vulcan (was 287773e).

终于删除成功!

  • 开发一个新feature,最好新建一个分支;
  • 如果要丢弃一个没有被合并过的分支,可以通过git branch -D <name>强行删除。

多人协作

  • master分支是主分支,因此要时刻与远程同步;
  • dev分支是开发分支,团队所有成员都需要在上面工作,所以也需要与远程同步;
  • bug分支只用于在本地修复bug,就没必要推到远程了,除非老板要看看你每周到底修复了几个bug;
  • feature分支是否推到远程,取决于你是否和你的小伙伴合作在上面开发。
  1. 首先,可以试图用git push origin <branch-name>推送自己的修改;
  2. 如果推送失败,则因为远程分支比你的本地更新,需要先用git pull试图合并;
  3. 如果合并有冲突,则解决冲突,并在本地提交;
  4. 没有冲突或者解决掉冲突后,再用git push origin <branch-name>推送就能成功
  • 查看远程库信息,使用git remote -v
    • git remote更详细
    • 上面显示了可以抓取和推送的origin的地址。如果没有推送权限,就看不到push的地址。
  • 本地新建的分支如果不推送到远程,对其他人就是不可见的;
  • 从本地推送分支,使用git push origin branch-name,如果推送失败,先用git pull抓取远程的新提交;
  • 在本地创建和远程分支对应的分支并关联起来( 就是说不用 set-upstream ),使用git switch -c branch-name origin/branch-name,本地和远程分支的名称最好一致.
  • 建立本地分支和远程分支的关联,使用git branch --set-upstream branch-name origin/branch-name
  • 从远程抓取分支,使用git pull,如果有冲突,要先处理冲突。

Rebase

在上一节我们看到了,多人在同一个分支上协作时,很容易出现冲突。即使没有冲突,后push的童鞋不得不先pull,在本地合并,然后才能push成功。

每次合并再push后,分支变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ git log --graph --pretty=oneline --abbrev-commit
* d1be385 (HEAD -> master, origin/master) init hello
* e5e69f1 Merge branch 'dev'
|\
| * 57c53ab (origin/dev, dev) fix env conflict
| |\
| | * 7a5e5dd add env
| * | 7bd91f1 add new env
| |/
* | 12a631b merged bug fix 101
|\ \
| * | 4c805e2 fix bug 101
|/ /
* | e1e9c68 merge with no-ff
|\ \
| |/
| * f52c633 add merge
|/
* cf810e4 conflict fixed

总之看上去很乱,有强迫症的童鞋会问:为什么Git的提交历史不能是一条干净的直线?

其实是可以做到的!

Git有一种称为rebase的操作.

同步后,我们对hello.py这个文件做了两次提交。用git log命令看看:

1
2
3
4
5
6
7
8
9
10
11
$ git log --graph --pretty=oneline --abbrev-commit
* 582d922 (HEAD -> master) add author
* 8875536 add comment
* d1be385 (origin/master) init hello
* e5e69f1 Merge branch 'dev'
|\
| * 57c53ab (origin/dev, dev) fix env conflict
| |\
| | * 7a5e5dd add env
| * | 7bd91f1 add new env
...

注意到Git用(HEAD -> master)(origin/master)标识出当前分支的HEAD和远程origin的位置分别是582d922 add authord1be385 init hello,本地分支比远程分支快两个提交。

现在我们尝试推送本地分支:

1
2
3
4
5
6
7
8
9
$ git push origin master
To github.com:michaelliao/learngit.git
! [rejected] master -> master (fetch first)
error: failed to push some refs to '[email protected]:michaelliao/learngit.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

很不幸,失败了,这说明有人先于我们推送了远程分支。按照经验,先pull一下:

1
2
3
4
5
6
7
8
9
10
11
12
$ git pull
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (1/1), done.
remote: Total 3 (delta 1), reused 3 (delta 1), pack-reused 0
Unpacking objects: 100% (3/3), done.
From github.com:michaelliao/learngit
d1be385..f005ed4 master -> origin/master
* [new tag] v1.0 -> v1.0
Auto-merging hello.py
Merge made by the 'recursive' strategy.
hello.py | 1 +
1 file changed, 1 insertion(+)

再用git status看看状态:

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

nothing to commit, working tree clean

加上刚才合并的提交,现在我们本地分支比远程分支超前3个提交。

git log看看:

1
2
3
4
5
6
7
8
9
$ git log --graph --pretty=oneline --abbrev-commit
* e0ea545 (HEAD -> master) Merge branch 'master' of github.com:michaelliao/learngit
|\
| * f005ed4 (origin/master) set exit=1
* | 582d922 add author
* | 8875536 add comment
|/
* d1be385 init hello
...

对强迫症童鞋来说,现在事情有点不对头,提交历史分叉了。如果现在把本地分支push到远程,有没有问题?

有!

什么问题?

不好看!

有没有解决方法?

有!

这个时候,rebase就派上了用场。我们输入命令git rebase试试:

1
2
3
4
5
6
7
8
9
10
11
12
$ git rebase
First, rewinding head to replay your work on top of it...
Applying: add comment
Using index info to reconstruct a base tree...
M hello.py
Falling back to patching base and 3-way merge...
Auto-merging hello.py
Applying: add author
Using index info to reconstruct a base tree...
M hello.py
Falling back to patching base and 3-way merge...
Auto-merging hello.py

输出了一大堆操作,到底是啥效果?再用git log看看:

1
2
3
4
5
6
$ git log --graph --pretty=oneline --abbrev-commit
* 7e61ed4 (HEAD -> master) add author
* 3611cfe add comment
* f005ed4 (origin/master) set exit=1
* d1be385 init hello
...

原本分叉的提交现在变成一条直线了!这种神奇的操作是怎么实现的?其实原理非常简单。我们注意观察,发现Git把我们本地的提交“挪动”了位置,放到了f005ed4 (origin/master) set exit=1之后,这样,整个提交历史就成了一条直线。rebase操作前后,最终的提交内容是一致的,但是,我们本地的commit修改内容已经变化了,它们的修改不再基于d1be385 init hello,而是基于f005ed4 (origin/master) set exit=1,但最后的提交7e61ed4内容是一致的。

这就是rebase操作的特点:把分叉的提交历史“整理”成一条直线,看上去更直观。缺点是本地的分叉提交已经被修改过了。

最后,通过push操作把本地分支推送到远程:

1
2
3
4
5
6
7
8
9
Mac:~/learngit michael$ git push origin master
Counting objects: 6, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (6/6), 576 bytes | 576.00 KiB/s, done.
Total 6 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 1 local object.
To github.com:michaelliao/learngit.git
f005ed4..7e61ed4 master -> master

再用git log看看效果:

1
2
3
4
5
6
$ git log --graph --pretty=oneline --abbrev-commit
* 7e61ed4 (HEAD -> master, origin/master) add author
* 3611cfe add comment
* f005ed4 set exit=1
* d1be385 init hello
...

远程分支的提交历史也是一条直线。

  • rebase操作可以把本地未push的分叉提交历史整理成直线;
  • rebase的目的是使得我们在查看历史提交的变化时更容易,因为分叉的提交需要三方对比。

合并远程分支

假设你本地在使用的分支为a, 需要合并的远程分支为b

  1. 新建和远程分支对应的本地分支:

    1
    git switch -c b origin/b
  2. 将远程代码pull到本地:

    1
    git pull origin b
  3. 返回到你的分支a

    1
    git switch a
  4. 合并分支a与分支b

    1
    git merge b

Submodule

A git submodule is a record within a host git repository that points to a specific commit in another external repository.

Clone a submodule

When you clone a project that contains submodules, you need to initialize and update the submodules after cloning the repository.

You can do this in one step or in two separate steps.

  1. One-step cloning your project with submodules inside it

    1
    git clone --recurse-submodules <repository_url>
  2. Two-step cloning

    1
    2
    git clone <repository_url>
    cd <project_directory>

    and

    1
    git submodule update --init --recursive

Add a submodule

To add a submodule to your project, follow these steps:

  1. Navigate to your project directory:

    1
    cd /path/to/your/project
  2. Add the submodule:

    1
    git submodule add <repository_url> <submodule_path>
  3. Initialize and update the submodule:

    1
    git submodule update --init --recursive
  4. Commit the changes:

    1
    2
    git add .gitmodules <submodule_path>
    git commit -m "Added submodule <submodule_path>

标签管理


发布一个版本时,我们通常先在版本库中打一个标签(tag),这样,就唯一确定了打标签时刻的版本。将来无论什么时候,取某个标签的版本,就是把那个打标签的时刻的历史版本取出来。所以,标签也是版本库的一个快照。

Git的标签虽然是版本库的快照,但其实它就是指向某个commit的指针(跟分支很像对不对?但是分支可以移动,标签不能移动),所以,创建和删除标签都是瞬间完成的。

Git有commit,为什么还要引入tag?

“请把上周一的那个版本打包发布,commit号是6a5819e...”

“一串乱七八糟的数字不好找!”

如果换一个办法:

“请把上周一的那个版本打包发布,版本号是v1.2”

“好的,按照tag v1.2查找commit就行!”

所以,tag就是一个让人容易记住的有意义的名字,它跟某个commit绑在一起。

创建标签

在Git中打标签非常简单,首先,切换到需要打标签的分支上:

1
2
3
4
5
$ git branch
* dev
master
$ git checkout master
Switched to branch 'master'

然后,敲命令git tag <name>就可以打一个新标签:

1
$ git tag v1.0

可以用命令git tag查看所有标签:

1
2
$ git tag
v1.0

默认标签是打在最新提交的commit上的。有时候,如果忘了打标签,比如,现在已经是周五了,但应该在周一打的标签没有打,怎么办?

方法是找到历史提交的commit id,然后打上就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git log --pretty=oneline --abbrev-commit
12a631b (HEAD -> master, tag: v1.0, origin/master) merged bug fix 101
4c805e2 fix bug 101
e1e9c68 merge with no-ff
f52c633 add merge
cf810e4 conflict fixed
5dc6824 & simple
14096d0 AND simple
b17d20e branch test
d46f35e remove test.txt
b84166e add test.txt
519219b git tracks changes
e43a48b understand how stage works
1094adb append GPL
e475afc add distributed
eaadf4e wrote a readme file

比方说要对add merge这次提交打标签,它对应的commit id是f52c633,敲入命令:

1
$ git tag v0.9 f52c633

再用命令git tag查看标签:

1
2
3
$ git tag
v0.9
v1.0

注意,标签不是按时间顺序列出,而是按字母排序的。可以用git show <tagname>查看标签信息:

1
2
3
4
5
6
7
8
9
$ git show v0.9
commit f52c63349bc3c1593499807e5c8e972b82c8f286 (tag: v0.9)
Author: Michael Liao <[email protected]>
Date: Fri May 18 21:56:54 2018 +0800

add merge

diff --git a/readme.txt b/readme.txt
...

可以看到,v0.9确实打在add merge这次提交上。

还可以创建带有说明的标签,用-a指定标签名,-m指定说明文字:

1
$ git tag -a v0.1 -m "version 0.1 released" 1094adb

用命令git show <tagname>可以看到说明文字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ git show v0.1
tag v0.1
Tagger: Michael Liao <[email protected]>
Date: Fri May 18 22:48:43 2018 +0800

version 0.1 released

commit 1094adb7b9b3807259d8cb349e7df1d4d6477073 (tag: v0.1)
Author: Michael Liao <[email protected]>
Date: Fri May 18 21:06:15 2018 +0800

append GPL

diff --git a/readme.txt b/readme.txt
...

操作标签

如果标签打错了,也可以删除:

1
2
$ git tag -d v0.1
Deleted tag 'v0.1' (was f15b0dd)

因为创建的标签都只存储在本地,不会自动推送到远程。所以,打错的标签可以在本地安全删除。

如果要推送某个标签到远程,使用命令git push origin <tagname>

1
2
3
4
$ git push origin v1.0
Total 0 (delta 0), reused 0 (delta 0)
To github.com:michaelliao/learngit.git
* [new tag] v1.0 -> v1.0

或者,一次性推送全部尚未推送到远程的本地标签:

1
2
3
4
$ git push origin --tags
Total 0 (delta 0), reused 0 (delta 0)
To github.com:michaelliao/learngit.git
* [new tag] v0.9 -> v0.9

如果标签已经推送到远程,要删除远程标签就麻烦一点,先从本地删除:

1
2
$ git tag -d v0.9
Deleted tag 'v0.9' (was f52c633)

然后,从远程删除。删除命令也是push,但是格式如下:

1
2
3
$ git push origin :refs/tags/v0.9
To github.com:michaelliao/learngit.git
- [deleted] v0.9

要看看是否真的从远程库删除了标签,可以登陆GitHub查看。

  • 命令git push origin <tagname>可以推送一个本地标签;
  • 命令git push origin --tags可以推送全部未推送过的本地标签;
  • 命令git tag -d <tagname>可以删除一个本地标签;
  • 命令git push origin :refs/tags/<tagname>可以删除一个远程标签