Git:从入门到精通

前言

git的命令行非常的简洁,博主基本上只使用git命令行工具,比如windows上的git bash。同时,为了加深理解,强烈建议初学某个功能或命令的时候,动手尝试一下。动手才是检验真理的唯一标准。
本文的立意并非是全面讲解git,而是从浅处着手,逐渐深入,直到接触到暂存区、底层存储等令人“眼花缭乱”的概念。


导读

  1. 概览
  2. 搭建git测试服务器
  3. 常规命令
  4. git暂存区
  5. git的底层存储
  6. 进阶命令
  7. 参考文档

概览

  • 版本控制系统(VCS)
    版本控制系统是用来记录一个或若干个文件内容的变化,以便将来查阅特定版本修订情况的系统。版本控制系统不仅可以应用于软件源代码的文本文件,而且可以对任何类型的文件进行版本控制。
  • 版本控制系统的分类:
    • 集中式版本控制系统,比如svn
      • 集中式的版本控制系统使用 单一的服务器 集中管理 所有文件的修订情况,客户端能只能获取到文件的某一版本(比如最新版本)的快照(集中式版本控制系统通常有全局递增的数字版本号)
      • 缺点:
        • 中央服务器的单点故障问题
    • 分布式版本控制系统,比如git
      • 客户端会将代码仓库完整的镜像下来,也就是说每个客户端都拥有文件的所有修订版本。分布式版本控制系统的操作可以分为两类:
        • 对本地仓库的操作,比如使用git commit将修改提交到本地仓库
        • 对远程仓库的操作,比如使用git push将本地仓库的更新,推送到远程仓库上

搭建git测试服务器

关于git的远程仓库,大家可以使用github,也可以自己搭建gitlab,在这里我们只是在centos上搭建一个非常简单的、用于测试的git服务器。

  • 安装git
sudo yum install -y git curl curl-devel expat expat-devel gettext gettext-devel openssl openssl-devel zlib zlib-devel perl perl-devel  
  • 创建用户和组
sudo groupadd git  
sudo useradd git -g git -s /bin/bash  
  • 将用户的公钥放到git用户的HOME目录的.ssh/authorized_keys中
sudo mkdir -p ~git/.ssh  
sudo touch ~git/.ssh/authorized_keys  
sudo chown -R git:git ~git/.ssh  
sudo chmod 600 ~git/.ssh/authorized_keys

sudo vim ~git/.ssh/authorized_keys  
#将用户的公钥贴进该文件(每行一个),然后保存、退出vim
  • 初始化git仓库
#在这里我们创建一个名称是timchow的仓库
[vagrant@hadoop-100 ~]$ sudo mkdir -p ~git/git-repository
[vagrant@hadoop-100 ~]$ sudo git init --bare ~git/git-repository/timchow.git
Initialized empty Git repository in /home/git/git-repository/timchow.git/  
[vagrant@hadoop-100 ~]$ sudo chown -R git:git ~git/git-repository/
  • 克隆仓库
# 测试git服务器是否成功搭建
[vagrant@hadoop-100 ~]$ git clone git@192.168.100.100:/home/git/git-repository/timchow.git
Cloning into 'timchow'...  
warning: You appear to have cloned an empty repository.  

常规命令

1,git init [/path/to/your/respository]
创建一个空的版本库或重新初始化已经存在的版本库。一个git版本库就是带有objectsrefs/headsrefs/tags子目录以及模版文件的.git目录。同时也会创建指向当前分支的HEAD文件
重新初始化一个已经存在的版本库是安全的,因为git init不会重写已经存在的任何东西。重新运行git init的主要原因是:

  • 将新增加的模版拷贝到git版本库(执行git init的时候,会将 模版目录下的文件和目录 拷贝到git版本库,模版目录的路径可以通过--template选项或$GIT_TEMPLATE_DIR环境变量指定)
  • 当指定--separate-git-dir选项的时候,版本库会被移动到另外一个地方

git init命令还有一个比较常用的选项:--bare。当指定这个选项的时候,会创建一个裸仓库裸仓库没有工作目录(working directory),只保存修订版本,可以直接作为服务器仓库。比如:

[git@hadoop-100 git-repository]$ git init --bare timchow.git
Initialized empty Git repository in /home/git/git-repository/timchow.git/  
[git@hadoop-100 git-repository]$ ls timchow.git/
branches  config  description  HEAD  hooks  info  objects  refs  


当没有给git init命令指定路径的时候,缺省的路径是当前目录(.)。

2,git remote
git remote命令是用来管理远程仓库的:

  • git remote -v
    显示所有的远程仓库的名称和url

  • git remote add <name> <url>
    添加远程仓库(name相当于给远程仓库起的一个别名),比如:

git remote add origin git@192.168.100.100:/home/git/git-repository/timchow.git  
  • git remote rename <oldname> <newname>
    重命名远程仓库的名称(url不变)

  • git remote remove <name>
    删除名称为name的远程仓库

  • git remote set-url <name> <newurl>
    更改名称为name的远程仓库的url,比如:

[vagrant@hadoop-100 timchow]$ git remote set-url origin git@192.168.100.100:/home/git/git-repository/timchow.git
[vagrant@hadoop-100 timchow]$ git remote -v
origin  git@192.168.100.100:/home/git/git-repository/timchow.git (fetch)  
origin  git@192.168.100.100:/home/git/git-repository/timchow.git (push)  

3,git push
使用本地的引用更新远程仓库的引用,并且会发送相关的对象在git push的时候,也会在本地给 远程分支 建立 远程追踪分支)。git push的语法是:

git push [<repository> [<refspec>...]]  


其中repository既可以是远程仓库的URL,也可以是远程仓库的名称。
在git中,分支(branches)标签(tags)对commit对象的引用,更多关于git对象的细节,将在git的底层存储小节中进行讲述。因此,我们可以使用git push将分支或标签推送到远程仓库上。
refspec参数的完整格式是:[+][[<src>]:]<dst>(中括号内的部分是可省的),它表示使用本地的src引用 更新 远程仓库上的dst引用。当省略src,而不省略冒号的时候,表示使用本地的空引用去更新远程仓库的某个引用,也就意味着删除远程仓库上的引用,比如:

# 将本地仓库的master分支推送到远程仓库origin的development分支
[vagrant@hadoop-100 timchow]$ git push origin master:development
Total 0 (delta 0), reused 0 (delta 0)  
To git@192.168.100.100:/home/git/git-repository/timchow.git  
 * [new branch]      master -> development

# 删除远程仓库origin上的development分支
[vagrant@hadoop-100 timchow]$ git push origin :development
To git@192.168.100.100:/home/git/git-repository/timchow.git  
 - [deleted]         development

# 将本地仓库的master分支推送到远程仓库origin的test-tag标签
[vagrant@hadoop-100 timchow]$ git push origin master:refs/tags/test-tag
Total 0 (delta 0), reused 0 (delta 0)  
To git@192.168.100.100:/home/git/git-repository/timchow.git  
 * [new tag]         master -> test-tag

# 删除远程仓库origin的test-tag标签
[vagrant@hadoop-100 timchow]$ git push origin :refs/tags/test-tag
To git@192.168.100.100:/home/git/git-repository/timchow.git  
 - [deleted]         test-tag


当同时省略src和冒号的时候,会将名称为dst的本地引用推送到远程仓库的同名引用,比如:

# 创建本地分支test-branch
[vagrant@hadoop-100 timchow]$ git branch test-branch

# 将本地分支test-branch推送到远程仓库origin的test-branch分支
[vagrant@hadoop-100 timchow]$ git push origin test-branch
Total 0 (delta 0), reused 0 (delta 0)  
To git@192.168.100.100:/home/git/git-repository/timchow.git  
 * [new branch]      test-branch -> test-branch


向远程仓库推送分支的时候,可以使用git push (-u|--set-upstream) <remote-repository> [<local-branch>:]<remote-branch>将本地分支和远程分支关联起来。以后,在<local-branch>分支上,执行不带参数的git push的时候,就会自动将<local-branch>推送到远程分支<remote-repository>/<remote-branch>。比如:

# 将本地分支test-branch推送到远程仓库origin的test-branch分支,并将本地分支和远程分支关联起来
[vagrant@hadoop-100 timchow]$ git push -u origin test-branch
Branch test-branch set up to track remote branch test-branch from origin.  
Everything up-to-date

# 之后,可以直接执行git push,而不必指定远程版本库名 和 分支名
[vagrant@hadoop-100 timchow]$ git push
Everything up-to-date  


在建立关联后,打开.git/config会发现类似的内容:

 12 [branch "test-branch"]
 13     remote = origin
 14     merge = refs/heads/test-branch

4,git clone <remote-repository> [<directory>]
将远程版本库克隆到新创建的目录中。如果没有指定<directory>参数,则使用远程版本库名去掉.git后缀作为目录名。git clone除了将远程版本库完整的镜像下来之外,还会做如下的事情:

  • 通过执行git remote -v命令或查看.git/config文件可以看到,git clone命令自动给远程版本库起了个“别名”:origin
  • 通过执行git branch -r命令可以看到,git clone命令自动地在本地为 远程版本库的每个分支创建了远程追踪分支
  • 通过执行git branch -vv命令或查看.git/config文件可以看到,git clone命令自动地将 本地版本库的master分支和远程版本库的master分支关联起来了

git暂存区

在git中,博主认为最难理解的概念就是暂存区了。理解了暂存区以及后面的底层存储之后,git将不再神秘。至于为什么引入暂存区的概念,请大家自己探究。

暂存区也称为index或stage,它是介于工作目录(working direcotry) 和 git目录(git directory,也就是.git目录)之间的中间状态,很多git命令都涉及到暂存区的概念,比如git commitgit addgit diffgit status等。

1.png
(图片说明:使用git checkout将版本库中的内容检出到工作目录)
(图片说明:使用git add将工作目录中的修改更新到暂存区)
(图片说明:使用git commit将暂存区中的内容提交到版本库)

  • 当执行git add时,不仅会在git对象库(位于:.git/objects目录)中,创建保存文件内容的blob对象;还会将该blob对象的id记录到暂存区的文件索引
  • 当执行git commit时,会将暂存区的目录树写到git对象库中,同时当前分支会指向新生成的commit对象
  • 当执行git diff时,比较的其实是工作目录 和 暂存区 的差异
  • 当执行git rm --cache <filename>时,会直接从暂存区中删除文件,工作目录不会发生改变

暂存区保存的其实就是包含文件索引的目录树。该目录树中记录了文件名、文件的状态信息(时间戳、文件长度等),以及文件对应的blob对象的id。暂存区保存在.git/index文件中。git版本库的结构如下图所示:
1.jpg
其中,HEAD指向 当前所在的分支,而分支(和标签)则是指向commit对象的引用,接下来介绍git中的对象。


git的底层存储

值得说明的是:git底层存储的是文件快照,也就是整个文件的内容。而不是一个版本相对于另外一个版本的差分。
在git中有四类对象:blob,tree,commit,tag。git的对象存储在对象库中(位于.git/objects)。

1,blob对象
blob对象用来存储文件内容,需要特别强调的是:blob只保存文件的内容,而忽略到其他元数据,比如文件名、路径等。blob对象的id就是文件内容的sha1值。可以使用git hash-object <filename>命令计算文件内容的sha1值,假设文件内容的sha1值是95f8cd0ca82c7d33423d8fb9b52bf6995eec3757,那么对应的blob对象存储在.git/objects/95/f8cd0ca82c7d33423d8fb9b52bf6995eec3757文件中。当执行git add <filename>命令时,就会生成相应的blob对象(也会将blob对象的id更新到暂存区的文件索引中)。
可以使用git cat-file -t <SHA1值>,来查看对象的类型。
使用git cat-file -p <SHA1值>,来查看对象的内容。比如:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -t 95f8cd0ca82c7d33423d8fb9b52bf6995eec3757
blob

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -p 95f8cd0ca82c7d33423d8fb9b52bf6995eec3757
this is a  
I am a new line  

2,tree对象
每次提交的时候(也就是执行git commit)的时候,都会生成一个tree对象(当然也会生成一个commit对象),一个tree对象代表当次提交时的目录信息,其内容包含:

  • 该目录下的 文件的文件名,以及文件所对应的blob对象
  • 该目录下的 子目录的目录名,以及子目录所对应的tree对象

也就是说,从项目根目录开始,每个目录都对应一个tree对象,因为目录具有层级关系,所以tree对象也具有包含关系。比如:

# 获取当前分支所指向的commit对象
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git rev-parse HEAD
9836fb0b78ecd9006b6575c361fd4e3d7642d31d

# 查看commit对象的内容,commit对象中包含项目根目录所对应的tree对象
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -p 9836fb0b78ecd9006b6575c361fd4e3d7642d31d
tree 7bcdfcafe040d3c35240445aea8cafeb8db7067c  
parent c20fc0e94630718b35b3ef50964b6c8269876c21  
author zhoujingjiang(周井江.海外事业部.海外技术中心) <zhoujingjiang@ds.gome.com.cn> 1499880364 +0800  
committer zhoujingjiang(周井江.海外事业部.海外技术中心) <zhoujingjiang@ds.gome.com.cn> 1499880364 +0800

this is a message

# 查看项目根目录所对应的tree对象,可以看到其中包含 文件名以及文件对应的blob对象;子目录名以及子目录所对应的tree对象  
# 例子中的a,d是项目根目录下的文件;b是项目根目录下的子目录
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -p 7bcdfcafe040d3c35240445aea8cafeb8db7067c
100644 blob 95f8cd0ca82c7d33423d8fb9b52bf6995eec3757    a  
040000 tree 2aaf3de65932e828fbcdd5a09085f1277a4c0e81    b  
100644 blob 41ea8d852675618eb71cc40abbff3b5a2cd53d4e    d

# 继续看子目录b所对应的tree对象包含什么
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -p 2aaf3de65932e828fbcdd5a09085f1277a4c0e81
100644 blob 5e5e2c3d099404a4e6badbbf030b261faf4a9fa3    c  
# 从命令的运行结果可以看到子目录b下,不再包含子目录了,并且有一个叫c的文件  


下面看一个tree对象包含tree对象的例子:
1.png

3,commit对象
每次提交(也就是执行git commit)的时候,都会生成一个commit对象(同时也会生成一个tree对象)。commit对象中包含:

  • 作者、提交者、提交日期、提交日志等
  • 父commit,父commit可能有多个,比如在git merge的时候,会产生一个新的commit对象,该commit对象就有2个父commit。如果只在当前分支上进行修改,然后提交的话,那么父commit就是该分支的上一次commit
  • tree对象,它记录了当次提交时,项目根目录的信息

下面看一个例子:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git cat-file -p HEAD
tree 8e975d674b20c62f13ef9e3d469b038dd4f67ab7  
parent 9836fb0b78ecd9006b6575c361fd4e3d7642d31d  
author zhoujingjiang(周井江.海外事业部.海外技术中心) <zhoujingjiang@ds.gome.com.cn> 1499882682 +0800  
committer zhoujingjiang(周井江.海外事业部.海外技术中心) <zhoujingjiang@ds.gome.com.cn> 1499882682 +0800

mmm  

4,tag对象
tag对象比较好理解,其实就是给commit对象起一个更易读的名字。


分支

  • 本地分支(local branch)
    • 本地版本库上的分支
    • 可以通过git branch <new-branch-name> [<start-point>]git checkout -b <new-branch-name> [<start-point>]创建本地分支
      • 如果<start-point>参数是远程跟踪分支,那么以上两条命令会自动地 新创建的本地分支 远程跟踪分支所对应的远程分支 之间建立关联(也就是追踪分支)
  • 远程分支(remote branch)
    • 远程版本库上的普通分支,比如192.168.100.100:/home/git/git-repoistory/timchow.git上的test-branch分支
    • 可以使用git push [(-u|--set-upstream)] <remote-repository-name-or-url> [+][[<local-branch-name>]:]<remote-branch-name>建立或更新远程分支
  • 远程跟踪分支(remote-tracking branch)
    • 本地版本库用来记录 远程版本库的某个远程分支的状态(即指向哪一个commit对象)的。远程跟踪分支以“<仓库名>/<分支名>”的形式命名。远程跟踪分支在本地是只读的,用户无法修改其指向,其指向在用户与远程版本库通信时自动改变,比如用户执行git fetchgit pull等从远程仓库获取数据的操作时
    • 可以通过git branch -r查看所有的远程跟踪分支
    • 可以通过git pull <remote-repository-name-or-url> <remote-branch>git fetch <remote-repository-name-or-url> <remote-branch>在本地版本库创建远程跟踪分支
  • 跟踪分支(tracking branch)
    • 跟踪分支主要为 本地分支 和 远程分支 之间 建立关联。这样在git pullgit push的时候,git就可以自动的识别去从哪个远程版本库的哪个分支拉取数据或将数据推送到哪个远程版本库的哪个分支上
    • 有很多方式,在本地分支和远程分支之间建立关联。比如:
      • git push (-u|--set-upstream) ...
      • git checkout -b ...
      • git branch (--set-upstream-to=<upstream> | -u <upstream>) [<branchname>]
    • 可以通过git branch -vv来查看本地分支和远程分支之间的超前落后情况
    • 可以通过.git/config查看本地分支和远程分支之间的关联关系,比如:
[branch "test"]
        remote = .
        merge = refs/heads/master
[branch "test2"]
        remote = .
        merge = refs/heads/master

分支合并

1,git merge

git merge用于分支合并,它用来把其他分支commit的内容合并到当前分支。下面用一个实例,来讲解:

  • 在master分支上创建测试文件git_merge_test.txt
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ git init git_merge_test.git
Initialized empty Git repository in C:/Users/zhoujingjiang.DS/Desktop/git_merge_test.git/.git/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ cd git_merge_test.git/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ echo "line 1" >> git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git add git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git commit -m "this is first commit on master" git_merge_test.txt
[master (root-commit) 9377280] this is first commit on master
 1 file changed, 1 insertion(+)
 create mode 100644 git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ echo "line 2" >> git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git commit -m "this is second commit on master" git_merge_test.txt  
[master 458de11] this is second commit on master
 1 file changed, 1 insertion(+)  
  • 以master分支为起点,创建新分支test-git-merge-branch
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git branch test-git-merge-branch master

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git branch -a
* master
  test-git-merge-branch

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git checkout test-git-merge-branch
Switched to branch 'test-git-merge-branch'

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (test-git-merge-branch)  
$ echo "line 3" >> git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (test-git-merge-branch)  
$ git commit -m "this is first commit on test-git-merge-branch" git_merge_test.txt  
[test-git-merge-branch 385845c] this is first commit on test-git-merge-branch
 1 file changed, 1 insertion(+)
  • 切换到master分支,修改文件,再将test-git-merge-branch上的修改merge到master分支上:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (test-git-merge-branch)  
$ git checkout master
Switched to branch 'master'  

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ echo "line 4" >>git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git commit -m "this is third commit on master" git_merge_test.txt  
[master 103b9bc] this is third commit on master
 1 file changed, 1 insertion(+)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master)  
$ git merge test-git-merge-branch
Auto-merging git_merge_test.txt  
CONFLICT (content): Merge conflict in git_merge_test.txt  
Automatic merge failed; fix conflicts and then commit the result.

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ git status -s
UU git_merge_test.txt  
  • 此时,产生了冲突因为两个分支,都修改了同一个文件的同一行
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ cat git_merge_test.txt
line 1  
line 2  
<<<<<<< HEAD  
line 4  
=======
line 3  
>>>>>>> test-git-merge-branch


其中,“=======”分隔符上面的部分是HEAD分支(也就是当前所在的分支,master)的内容;下面的部分是test-git-merge-branch分支中的内容。之后人工解决冲突,比如:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ cat git_merge_test.txt
line 1  
line 2  
line 4  
line 3  


使用git add将冲突标记为已解决

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ git add git_merge_test.txt

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ git status -s
M  git_merge_test.txt  


然后,使用git commit进行提交:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/timchow (master|MERGING)  
$ git commit -m "merge from test-git-merge-branch"
[master dc23c21] merge from test-git-merge-branch
  • 最后,可以使用git log --graph [<filename>]查看某个文件的分支合并图:
    1.jpg
    git merge的过程中,git会进行一些特殊处理:找到两个分支的末端和它们的共同祖先,然后进行三方合并,之后对三方合并的结果做快照,并创建一个指向它的commit对象。
2,git rebase

大家可以先阅读一下这篇文档
首先,大家应该了解的是:在git push的时候,git会比较commit history,如果commit history不一致,git就会拒绝提交(一种比较危险的做法是:使用git push -f将远程版本库上的分支强制 更新为 本地版本库的!!!但是,这样会影响其他人的工作!!!)。
对于这种情况,可以使用git rebase来解决。仍然使用一个例子进行讲解:

  • 在我们的测试服务器上创建一个空的版本库:
[git@hadoop-100 git-repository]$ cd /home/git/git-repository/
[git@hadoop-100 git-repository]$ git init --bare test-rebase.git
Initialized empty Git repository in /home/git/git-repository/test-rebase.git/  
  • 构造一个需要rebase的现场:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ git clone git@192.168.100.100:/home/git/git-repository/test-rebase.git test-rebase-1
Cloning into 'test-rebase-1'...  
warning: You appear to have cloned an empty repository.

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ git clone git@192.168.100.100:/home/git/git-repository/test-rebase.git test-rebase-2
Cloning into 'test-rebase-2'...  
warning: You appear to have cloned an empty repository.

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ cd test-rebase-1/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ echo "this is line 1" >> text

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git add text text

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git commit -m "first commit" text
[master (root-commit) 083c189] first commit
 1 file changed, 1 insertion(+)
 create mode 100644 text

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git push -u origin master
Counting objects: 3, done.  
Writing objects: 100% (3/3), 213 bytes | 0 bytes/s, done.  
Total 3 (delta 0), reused 0 (delta 0)  
To 192.168.100.100:/home/git/git-repository/test-rebase.git  
 * [new branch]      master -> master
Branch master set up to track remote branch master from origin.

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ echo "this is line 2" >> text

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git commit -m "second commit on test-rebase-1" text
[master e6752d5] second commit on test-rebase-1
 1 file changed, 1 insertion(+)

# !!!commit之后没有git push哦!!!


进入到test-rebase-2,先更新master分支到最新状态,然后修改,提交,推送:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ cd ../test-rebase-2/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-2 (master)  
$ git pull origin master
remote: Counting objects: 3, done.  
remote: Total 3 (delta 0), reused 0 (delta 0)  
Unpacking objects: 100% (3/3), done.  
From 192.168.100.100:/home/git/git-repository/test-rebase  
 * branch            master     -> FETCH_HEAD
 * [new branch]      master     -> origin/master

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-2 (master)  
$ echo "this is line 3" >> text

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-2 (master)  
$ git commit -m "first commit in test-rebase-2" text
[master 781fba4] first commit in test-rebase-2
 1 file changed, 1 insertion(+)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-2 (master)  
$ git push
Counting objects: 3, done.  
Writing objects: 100% (3/3), 260 bytes | 0 bytes/s, done.  
Total 3 (delta 0), reused 0 (delta 0)  
To 192.168.100.100:/home/git/git-repository/test-rebase.git  
   083c189..781fba4  master -> master


回到test-rebase-1,推送之前的提交到远程版本库:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-2 (master)  
$ cd ../test-rebase-1/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git push
To 192.168.100.100:/home/git/git-repository/test-rebase.git  
 ! [rejected]        master -> master (fetch first)
error: failed to push some refs to 'git@192.168.100.100:/home/git/git-repository/test-rebase.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.  


可以发现,git push被拒绝了~

  • 使用git fetch(不是git pullgit pull = git fetch + git merge)将 远程追踪分支 的状态更新到与远程版本库相同:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git fetch origin master
remote: Counting objects: 5, done.  
remote: Total 3 (delta 0), reused 0 (delta 0)  
Unpacking objects: 100% (3/3), done.  
From 192.168.100.100:/home/git/git-repository/test-rebase  
 * branch            master     -> FETCH_HEAD
   083c189..781fba4  master     -> origin/master
  • 开始rebase:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git rebase origin/master
First, rewinding head to replay your work on top of it...  
Applying: second commit on test-rebase-1  
Using index info to reconstruct a base tree...  
M       text  
Falling back to patching base and 3-way merge...  
Auto-merging text  
CONFLICT (content): Merge conflict in text  
error: Failed to merge in the changes.  
Patch failed at 0001 second commit on test-rebase-1  
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".  
If you prefer to skip this patch, run "git rebase --skip" instead.  
To check out the original branch and stop rebasing, run "git rebase --abort".


zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master|REBASE 1/1)  
$ git status -s
UU text  


可以发现,rebase的时候,发生冲突了,如果想要停止git rebase,可以使用git rebase --abort。此时,我就不abort了~

  • 打开冲突的文件text,人工解决冲突,之后使用git add标记该冲突已经resolved:
# 假设已经解决了冲突
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master|REBASE 1/1)  
$ git add text
  • 使用git rebase --continue,继续解决下一个冲突,一直到解决了最后一个冲突(注意:无需git commit提交):
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master|REBASE 1/1)  
$ git rebase  --continue
Applying: second commit on test-rebase-1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-rebase-1 (master)  
$ git status -s
  • 使用git log --graph看一下commit history:
$ git log --graph text
* commit 41c87d8830491b22a25dd3587e9c347a88d91f82
| Author: timchow <744475502@qq.com>
| Date:   Mon Jul 17 18:18:12 2017 +0800
|
|     second commit on test-rebase-1
|
* commit 781fba44a2c7ef5524b0294fe31813003d514d6b
| Author: timchow <744475502@qq.com>
| Date:   Mon Jul 17 18:21:30 2017 +0800
|
|     first commit in test-rebase-2
|
* commit 083c1895481ba2eb2dd81dda6bf10f3b84f8206d
  Author: timchow <744475502@qq.com>
  Date:   Mon Jul 17 18:16:36 2017 +0800

      first commit


我们会发现,commit history是线性的!非常清晰。
git rebase <another>的原理是:

  • 将当前分支的所有commit取消掉,并把它们临时保存为补丁(这些补丁放在.git/rebase-apply/目录)
  • 将当前分支更新为<another>
  • 把补丁应用到当前分支上
3,git cherry-pick

git cherry-pick可以选择一个分支的一个或多个commit进行合并;相比之下,git merge可以称为完全合并。比如,线上的分支是v1.0,正在开发的分支是v2.0,如果只想把v2.0的某几个特性合并到线上分支v1.0,那么就应该使用git cherry-pick,而不是git merge,因为git merge会将v2.0上所有的改动都合并到v1.0,进而导致版本混乱。
下面举一个例子:

  • 构造出上面的例子的场景
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ git init test-cherry-pick
Initialized empty Git repository in C:/Users/zhoujingjiang.DS/Desktop/test-cherry-pick/.git/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop  
$ cd test-cherry-pick/

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (master)  
$ git checkout -b v1.0
Switched to a new branch 'v1.0'

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ echo "this is feature1" >>feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git add feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git commit -m "this is feature1" feature1
[v1.0 (root-commit) 8aaa499] this is feature1
 1 file changed, 1 insertion(+)
 create mode 100644 feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ echo "this is feature2" >>feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git add feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git commit -m "this is feature2" feature2
[v1.0 465cfc7] this is feature2
 1 file changed, 1 insertion(+)
 create mode 100644 feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git branch v2.0 v1.0

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git checkout v2.0
Switched to branch 'v2.0'

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ echo "new functions of feature1" >>feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git commit -m "new functions of feature1" feature1
[v2.0 1af5ca7] new functions of feature1
 1 file changed, 1 insertion(+)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ echo "this is feature3" >>feature3

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git add feature3

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git commit -m "this is feature3" feature3
[v2.0 52e94e9] this is feature3
 1 file changed, 1 insertion(+)
 create mode 100644 feature3


现在,只想把v2.0中对feature1的修改,合并到v1.0。并不想将feature3合并到v1.0。

  • 通过git log找到要合并的commitid:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git log --pretty=oneline
52e94e9ad708f3ef65858e804b3ba3a7afc78852 this is feature3  
1af5ca75fb61c46fb6997c9e7a54771edc322c41 new functions of feature1  
465cfc705bef3d0741ef1142f26784f28d5bc92b this is feature2  
8aaa499bcb8ab8f49a6784023c9afb20a6eb4303 this is feature1  


要合并到v1.0的commit id是:1af5ca75fb61c46fb6997c9e7a54771edc322c41

  • 使用git cherry-pick进行合并:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git cherry-pick 1af5ca75fb61c46fb6997c9e7a54771edc322c41
[v1.0 9286ca6] new functions of feature1
 Date: Tue Jul 18 14:09:34 2017 +0800
 1 file changed, 1 insertion(+)


当然在进行git cherry-pick的时候,仍然可能出现冲突。解决方法和git merge一样。


回滚

1,git revert

通过生成新的提交来撤销某些已经存在的提交。git revert不会改变commit history(这很重要,因为git push的时候,git会比较commit history,如果commit history不一致,就会导致push被拒绝)。
下面继续使用 学习git cherry-pick时 的版本库,进行举例:

  • 通过git log找到要撤销的commit
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v1.0)  
$ git checkout v2.0
Switched to branch 'v2.0'

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git log --pretty=oneline
52e94e9ad708f3ef65858e804b3ba3a7afc78852 this is feature3  
1af5ca75fb61c46fb6997c9e7a54771edc322c41 new functions of feature1  
465cfc705bef3d0741ef1142f26784f28d5bc92b this is feature2  
8aaa499bcb8ab8f49a6784023c9afb20a6eb4303 this is feature1  


假设想要撤销的commit是1af5ca75fb61c46fb6997c9e7a54771edc322c41

  • 制造一个在git revert时,会产生冲突的场景:
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ sed -i 's/\(^new.*$\)/\1 with a suffix/g' feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git commit -m "add a suffix" feature1
[v2.0 8e42832] add a suffix
 1 file changed, 1 insertion(+), 1 deletion(-)
  • 开始git revert
    git rebase非常类似,git revert也可能产生冲突,产生冲突时,可以通过git revert --abort结束revert;也可以人工解决冲突,解决完成之后,需要使用git add将冲突标记为“已解决”,然后使用git revert --continue继续revert,一直到所有的冲突都被解决,git revert完成。在整个过程中无需git commit
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git revert 1af5ca75fb61c46fb6997c9e7a54771edc322c41
error: could not revert 1af5ca7... new functions of feature1  
hint: after resolving the conflicts, mark the corrected paths  
hint: with 'git add <paths>' or 'git rm <paths>'  
hint: and commit the result with 'git commit'

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0|REVERTING)  
$ git status -s
UU feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0|REVERTING)  
$ vim feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0|REVERTING)  
$ cat feature1
this is feature1  
with a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0|REVERTING)  
$ git add feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0|REVERTING)  
$ git revert --continue
[v2.0 69e509e] Revert "new functions of feature1"
 1 file changed, 1 insertion(+), 1 deletion(-)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git log --graph feature1
* commit 69e509ed1e4cb4e89707d71afaef34359a3dca17
| Author: timchow <744475502@qq.com>
| Date:   Tue Jul 18 15:52:16 2017 +0800
|
|     Revert "new functions of feature1"
|
|     This reverts commit 1af5ca75fb61c46fb6997c9e7a54771edc322c41.
|
* commit 8e42832569f63bde1a522c755a3d3c238bfa669d
| Author: timchow <744475502@qq.com>
| Date:   Tue Jul 18 15:44:43 2017 +0800
|
|     add a suffix
|
* commit 1af5ca75fb61c46fb6997c9e7a54771edc322c41
| Author: timchow <744475502@qq.com>
| Date:   Tue Jul 18 14:09:34 2017 +0800
|
|     new functions of feature1
|
* commit 8aaa499bcb8ab8f49a6784023c9afb20a6eb4303
  Author: timchow <744475502@qq.com>
  Date:   Tue Jul 18 14:05:14 2017 +0800

      this is feature1

通过git log看到,git revert并没有修改commit history,而是产生了一个新的commit。

2,git reset [<options>...] <commit>

我们知道,分支和标签其实就是指向commit对象的引用,可以通过git reset重置(reset)当前分支所指向的commit。
git reset有两个重要的选项:--soft(默认)、--hard

  • git reset --soft:仅改变当前当前分支的指向,不改变工作目录暂存区
  • git reset --hard:不仅改变当前分支的指向,还会重置工作目录和暂存区,丢弃所有的改动

仍然以上面的版本库作为例子:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git log --pretty=oneline
69e509ed1e4cb4e89707d71afaef34359a3dca17 Revert "new functions of feature1"  
8e42832569f63bde1a522c755a3d3c238bfa669d add a suffix  
52e94e9ad708f3ef65858e804b3ba3a7afc78852 this is feature3  
1af5ca75fb61c46fb6997c9e7a54771edc322c41 new functions of feature1  
465cfc705bef3d0741ef1142f26784f28d5bc92b this is feature2  
8aaa499bcb8ab8f49a6784023c9afb20a6eb4303 this is feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git reset --hard Head~1
HEAD is now at 8e42832 add a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git log --pretty=oneline
8e42832569f63bde1a522c755a3d3c238bfa669d add a suffix  
52e94e9ad708f3ef65858e804b3ba3a7afc78852 this is feature3  
1af5ca75fb61c46fb6997c9e7a54771edc322c41 new functions of feature1  
465cfc705bef3d0741ef1142f26784f28d5bc92b this is feature2  
8aaa499bcb8ab8f49a6784023c9afb20a6eb4303 this is feature1  


可以发现git reset也引起了commit history的改变,所以在git push的时候,可能被拒绝。

3,git stash

许多git命令都要求工作目录是“干净”的,比如git revertgit merge等。如果在执行这类命令的时候,工作目录上已经有改动,但是既不想丢弃,也不想提交这些改动,那么就可以使用git stash将这些未提交的改动“暂存”起来(存在.git/refs/stash),并且可以使用git stash多次保存工作进度;还可以通过git stash list显示进度列表;使用git stash pop [<stash>]恢复工作进度;使用git stash drop [<stash>]删除工作进度。
仍然使用上面的版本库作为例子:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ ls
feature1  feature2  feature3

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ echo "another line of feature1" >>feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash
Saved working directory and index state WIP on v2.0: 8e42832 add a suffix  
HEAD is now at 8e42832 add a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ echo "another line of feature2" >>feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash
Saved working directory and index state WIP on v2.0: 8e42832 add a suffix  
HEAD is now at 8e42832 add a suffix

# 最新的工作进度在上面
zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash list
stash@{0}: WIP on v2.0: 8e42832 add a suffix  
stash@{1}: WIP on v2.0: 8e42832 add a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ echo "another line of feature3" >>feature3

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash
Saved working directory and index state WIP on v2.0: 8e42832 add a suffix  
HEAD is now at 8e42832 add a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash list
stash@{0}: WIP on v2.0: 8e42832 add a suffix  
stash@{1}: WIP on v2.0: 8e42832 add a suffix  
stash@{2}: WIP on v2.0: 8e42832 add a suffix

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash drop stash@{0}
Dropped stash@{0} (d9f7dcdeb9c328f5c326fa74f5621d18ee019942)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash pop stash@{1}
On branch v2.0  
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:   feature1

no changes added to commit (use "git add" and/or "git commit -a")  
Dropped stash@{1} (c24e5aab913dfeebdb7cf70bc7774b9b05eb88f1)

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git stash pop
On branch v2.0  
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:   feature1
        modified:   feature2

no changes added to commit (use "git add" and/or "git commit -a")  
Dropped refs/stash@{0} (ea754d575e063a9fa1db3bfe66ee986bd14a8cac)  
4,git checkout

git checkout的作用是将版本库中特定的修订版本检出到工作目录(切换分支),也可以利用它撤销未提交的修改

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git status -s
 M feature1
 M feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git checkout feature1

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git status -s
 M feature2

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git checkout .

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git status -s

归档

对于java项目,部署到线上的是打好的jar包或war包。但是对于python、php等项目,部署到线上的可能直接就是源代码。此时,我们是不希望 代码目录中包含跟版本库相关的文件 的(比如svn中的.svn目录、git中的.git目录)。对于svn,可以使用svn export命令。对与git则可以使用git archive命令。

git archive [--format=(tar|zip)] [-o <outputfile> | --output <outputfile>] <tree-ish> [<path>...]

  • --format用来指定结果文件的格式:tar或zip
  • -o--output用来指定输出文件的名称
  • <tree-ish>参考这篇文档
  • <path>(可省)用来指定将哪些文件归档

比如:

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ ls
feature1  feature2  feature3  myproject.zip

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ git archive --format=zip -o myproject.zip HEAD

zhoujingjiang@ZHOUJINGJIANG MINGW64 ~/Desktop/test-cherry-pick (v2.0)  
$ ls
feature1  feature2  feature3  myproject.zip  

一点说明

个人建议:

  • 多用git rebase,而不是git pull + 解决冲突
  • 多用git revert,而不是git reset
  • 如果是内部项目,多用git的多分支开发,而不是fork + pull request

参考文档

感谢浏览tim chow的作品!

如果您喜欢,可以分享到: 更多

如果您有任何疑问或想要与tim chow进行交流

可点此给tim chow发信

如有问题,也可在下面留言: