名校课程推荐 | MIT《CS 实用工具课程》-版本控制

版本控制

如果你需要不定期对工作内容进行改动,那么能追踪这些改动的功能就非常有用。它可以记录哪些地方发生了改动、如何撤销改动、谁进行了改动,甚至为什么有这样的改动。版本控制系统(VCSs) 就能实现这个功能。这些工具允许你向一组文件提交更改,同时提供描述更改的消息,还支持查看和撤销你过去所做的更改。

大多数VCS支持在多个用户之间共同更改历史记录。这样可以方便进行协作:你可以看到我做的改动,我也可以看到你做的改动。由于VCS追踪改动,它经常能够弄清楚如何合并某些相关的更改。

有很多 VCS,它们支持的功能有所不同,比如运作方式还有与用户的互动方式。这里我们将重点关注git,它是最常用的VCS工具之一,但我建议你们也

可以看看Mercurial

好了,知识点来了

Git是黑魔法吗?

并不是...你需要了解数据模型。我们会跳过一些细节,但大致来说,git的核心是提交(commit)。

  • 每次提交都有唯一的名字,"修订版本"的一大串哈希值比如998622294a6c520db718867354bf98348ae3c7e2 通常缩减成它的前缀形式:9986222

  • 提交信息包括作者+commit

  • 同时还有父辈提交的哈希,通常就是前一个提交的哈希

  • 提交也表示两次修改的差异(diff),表示你从父辈提交到当前提交的操作(比如,删除该文件中的这一行,将这些行添加到该文件中,重命名该文件,等等)

    • 实际上,git存储了完整的提交前后状态

    • 可能不希望存储发生变动的大文件

初始状态下,仓库(repository),也就是git管理的文件夹,没有任何内容或提交。我们来设置下:

$ git init hackers
$ cd hackers
$ git status

我们可以从这里的输出开始,一起来深入研究理解这块内容。

首先,"在主分支上"

  • 不要总是使用哈希

  • 分支是指向哈希的名称

  • 主分支通常是"最新"提交的名称。每做一次提交,主分支名会指向新提交的哈希。

  • 专用名称HEAD指"当前"名称

  • 你也可以用git branch(或者git tag)创建你自己的名字,之后我们会讲到

我们先跳过"No commits yet(尚未提交)",因为这里没有内容。

然后是"nothing to commit(不能提交)"

  • 每个提交都包含与你所做更改的不同之处。但是这个不同最初是如何构建的呢?

  • 总是提交自上次提交以来所做的所有更改

    • 有时你只想提交其中部分更改(如,不是TODO

    • 有时你想把一个更改分解为多个提交,这样每个提交有一个单独的提交信息

  • git允许分期更改以构建提交

    • 使用git add添加更改到文件暂存区

      • 使用git add -p仅添加部分更改到文件

      • 没有参数的git add对"所有已知文件"进行操作

    • 使用git rm同时从工作区和索引中删除文件

    • 使用git reset清空暂存区更改集

      • 注意,这不会改变你的任何文件!它只意味着提交中不会包含任何更改

      • git reset FILEgit reset -p仅删除部分暂存区更改

    • git diff --staged显示与暂存区更改差异

    • git diff显示剩余更改差异

    • git commit创建一个新提交

      • git commit -a提交全部更改

      • git help add获取更多帮助信息

在进行上述操作的过程中,运行git status(显示当前的版本库状态),看看git认为你在做什么,这很有用!

提交

好了,我们介绍了提交,接下来呢?

  • git log(或git log --oneline)查看最近更改

  • git log -p查看全部更改

  • git show master显示某个提交

    • -p全部差异/补丁
  • git checkout NAME回到提交时的状态

    • 如果NAME是一个提交哈希值,git说它们是"分离的",这就是说没有NAME指向这个提交,所以如果我们进行了提交,没人会知道它们。
  • git revert NAME恢复更改

    • NAME反向提交使用diff
  • git diff NAME..比较旧版本和当前版本

    • a..b是提交范围,如果其中一个被省略,则表示HEAD
  • git reset NAME显示所有提交

    • -p在这里也适用
  • git reset NAMEmaster更改为指向某个提交(实际上取消了所有的提交)

    • 为什么?reset不是修改暂存区更改吗?reset有另一种形式(参考git help reset),它会将HEAD设置为指定名称所指向的提交

    • 注意,这没有改变任何文件- git diff现在实际上显示git diff NAME..

命名

显然在git中命名很重要,它对于理解git中的操作很关键。我们讨论了提交哈希、主分支和HEAD,但还有很多内容。

  • git branch b可以生成你自己的分支(像主分支那样)

    • 创建一个新名字b,它指向HEAD上的提交

    • 这时你仍然在主分支上,如果有新的提交,主分支会指向到那个新的提交,b不会

    • 通过git checkout b切换到某个分支

      • 现在任何提交都会更新b

      • 通过git checkout master切换回主分支

        • 所有在b的更改都被隐藏
      • 这很方便能测试出所有更改

  • 标签是另一种永远不会改变的名字,它们有自己的消息,通常用于标记发布+变更日志。

  • NAME^表示NAME之前的提交

    • 可以重复使用:NAME^^^

    • 使用~

      • ~是暂时的,而^是父辈的

      • ~~^^一样

      • X~3表示早于X的三个提交

      • 你不会想用^3

    • git diff HEAD^

  • -表示"前一个名字"

  • 大多数命令操作在HEAD上,除非你给出其他参数

整理提交

你的提交历史经常会以这种形式结尾:

  • add feature x – 可能会有关于x的提交信息!

  • forgot to add file

  • fix bug

  • typo

  • typo2

  • typo2

  • actually fix

  • actually actually fix

  • tests pass

  • fix example code

  • typo

  • x

  • x

  • x

  • x

对于git来说这没有问题,但对你之后想要了解或者其他想知道发生什么更改的人来说,这并不是很有帮助。Git会让你整理这些信息:

  • git commit --amend:将暂存的更改折叠到之前的提交中

    • 注意这会更改之前的提交,产生一个新的哈希!
  • git rebase -i HEAD~13很厉害,对于最近13次提交中的每一次提交,选择进行操作:

    • 默认是pick,什么也不做

    • r:更改提交信息

    • e:更改提交(添加或移除文件)

    • s:合并前一次提交并编辑提交信息

    • f:"fixup"-合并前一次提交;丢弃提交信息

    • HEAD指向当前最近一次提交

    • 通常称为挤压提交

    • 它真正的作用是:将HEAD倒回到重定基线,然后按照指示的顺序重新提交

  • git reset --hard NAME:将所有文件的状态重置为NAME(如果没有指定名称,则为HEAD),便于撤消更改。

其他工具

版本控制的一个常见使用场景是允许多人对一组文件进行更改,而不会影响到其他人。或者更确切地说,就算影响到了其他人,他们也不会只是默默地覆盖对方的更改。

Git是分布式的VCS:每个人都有整个版本库的本地副本(其他人选择发布的所有东西的本地副本)。一些VCS是集中式的(如subversion):所有提交都在一个服务器中,客户端只有他们"检出"的文件。从根本上来说,它们只有当前的文件,如果需要其他文件需要找服务器。

Git版本库的每一个副本都可以看作"remote",你可以用git clone ADDRESS(替代git init)复制一个已经存在的git版本库。这会创建一个指向ADDRESS的名为origin的远程端。你可以用git fetch REMOTE从一个远程端获取名称和它们指向的提交。远程端的所有名称都以REMOTE/NAME供你使用,你可以像使用本地名称一样使用它们。

如果你有远程端的写权限,你可以用git push改变远程的名字指向你完成的提交。例如,我们把远程的主分支名字origin指向主分支当前指向的提交:

  • git push origin master: master

  • 方便起见,你可以用-uorigin/master设置为从当前分支git push时的默认目标

  • 考虑:这有什么用?git push origin master:HEAD^

通常你会使用GitHub, GitLab, BitBucket或其他东西作为你的远程端。对于git来说,他们没有什么特别的,就是命名和提交。如果有人在主分支进行了更改并更新了github/master来指向他们的提交(我们马上会回到这个问题),那么当你用git fetch github从github拉取远程代码,你可以通过git log github/master看到他们的更改。

他人协作

目前为止,分支看起来没什么用:你可以创建他们,在他们上面作业,但之后呢?最后,你都会让主分支指向他们,对吧?

  • 如果在实现一个功能时你要修复某方面怎么办?

  • 如果其他人同时对主分支进行更改怎么办?

将一个分支上的更改与另一个分支上的更改进行合并是不可避免的,无论是你还是其他人做出的更改。Git可以通过git merge NAME实现。merge

  • 查找HEADNAME共同一个提交父辈的最近点(比如他们的分叉点)

  • (尝试)应用所有更改到当前HEAD

  • 创建一个包含所有这些更改的提交,并列出HEADNAME为它的父辈

  • HEAD设置到该提交的哈希中

一旦你的大功能完成了,你就可以把它的分支合并到master主分支中,git会确保你不会丢失任何一个分支的更改!

如果你以前用过git,你应该知道merge的另一个名字pull。如果进行git pull REMOTE BRANCH操作,那就相当于

  • git fetch REMOTE

  • git merge REMOTE/BRANCH

  • push一样,REMOTEBRANCH经常被忽略,而是使用"追踪"远程分支(像-u一样)

这通常很有效,只要合并分支上的更改是不相交的。如果不是的话,就会出现合并冲突,听起来很可怕……

  • 合并冲突就是git告诉你它不知道最终的差异

  • git会暂停并要求你完成暂存合并冲突

  • 在编辑器中打开冲突文件并查找大量的尖括号(<<<<<<<)。=======上面的内容是共同父辈提交后在HEAD中所做的更改,它下面的内容是自共同提交以来NAME中所做的更改。

  • git mergetool非常方便- 它会打开一个有diff工具的编辑器

  • 通过确定文件目前的状态,解决了某个冲突后,输入git add暂存这些更改

  • 解决所有冲突后,输入git commit进行提交结束

    • 可以输入git merge --abort放弃提交

解决了你第一个合并冲突后,现在你可以输入git push发布已经完成的更改了。

当你push推送时,如果你更新了你正在推送的远程名称,git会检查没有丢失任何其他人的作业。它通过检查远程名称的当前提交是否为正在推送的提交的父辈来实现这一点。如果是,git可以安全地更新名称,这叫做fast forward。如果不是,git会拒绝更新远程名称,并告诉你还有更改。

如果你的推送被拒绝了,怎么办?

  • 使用git pull(例如fetch+merge)合并远程更改

  • 使用--force强制推送:这会丢失其他人的更改!

    • 还有--force-with-lease,它只会在远程名称自上次从该远程拉取后没有更改的情况下强制更改。更安全

    • 如果你已经重定你之前推送的本地提交的基线("历史重写",最好不要这样做),那你必须强制推送。思考以下为什么!

  • 尝试在远程更改"之上"应用你的更改

    • 这是rebase

      • 回退从共同父辈开始的所有本地提交

      • fast-forward HEAD到远程名称上的提交

      • 按顺序应用本地提交

        • 可能会有冲突,你需要手动解决冲突

        • git rebase --continue--abort

      • 点击这里了解更多

    • git pull --rebase会为你启动这个过程

    • 该选择合并还是变基是一个热度很高的话题!好的相关内容推荐:

拓展阅读

git

Learn git branching

How to explain git in simple words

Git from the bottom up

Git for computer scientists

Oh shit, git!

The Pro Git book

练习

  1. 在版本库尝试修改某个文件。当你使用 git stash 会发生什么?当执行 git log --all --oneline 时会显示什么?运行 git stash pop 命令来撤销 git stash 操作,什么时候会用到这一技巧?

  2. 使用git 时的一个常见错误是提交本不应该由git 管理的大文件,或是将含有敏感信息的文件提交给git 。尝试向版本库中添加一个文件并添加提交,然后将其从历史中删除( 这篇文章也许会有帮助)。如果你像让git管理大文件,可以看一下这篇文章

  3. Git对于撤销更改非常方便,但是即使是最不可能的更改,你也要熟悉

    1. 如果一个文件在某个提交中被误修改,可以通过git revert恢复。但是如果一个提交中有多个更改,revert可能不是最佳操作。如何使用git checkout从指定提交恢复一个文件版本?
    2. 创建一个分支,在该分支中进行提交然后删除该提交。你还能恢复这个提交吗?研究一下git reflog。(注意,快速恢复暂存的内容,git会定期自动清理没有指向的提交。)
    3. 如果你喜欢用git reset --hard代替git reset,那会很容易丢失更改。但是,由于更改是在暂存区的,我们可以恢复它们。(可以看一下git fsck --lost-found以及.git/lost-found
  4. 在任何git repo中,在.git/hooks文件夹下,你会找到一堆以.sample结尾的脚本。如果你重命名它们而名字中没有.sample,它们将基于它们的名字运行。例如,pre-commit将在提交之前执行。尝试一下

  5. 与多数命令行工具一样,git 也提供了一个名为 ~/.gitconfig 配置文件(或dotfile)。用~/.gitconfig 创建一个别名,使你运行 git graph可以得到 git log --oneline --decorate --all --graph 的输出结果(这是快速实现提交图可视化的一个很好用的命令)

  6. ~/.gitignore_global 中创建全局忽略规则,这对于防止如添加RSA密钥这种常见错误很有用。创建一个~/.gitignore_global文件并添加*rsa规则,然后测试它在repo中是否有效。

  7. 熟悉git之后,你会发现自己会遇到一些常见的任务,比如编辑你的.gitignoregit extras提供了一堆与git集成的小工具。例如,git ignore PATTERN会将指定的模式添加到repo的.gitignore文件中,而git ignore -io LANGUAGE会从gitignore.io中获取该语言的常见忽略规则。安装git extras工具并尝试使用一些工具,如git aliasgit ignore

  8. Git GUI程序有时是一个很好的资源。尝试在git repo中运行gitk,探索界面的不同部分,然后运行gitk --all,有什么不同?

  9. 一旦你习惯了命令行应用程序,你可能会觉得GUI工具很繁琐/臃肿。基于ncurses的工具是介于两者之间的一个很好过渡,它可以从命令行导航,并且仍然提供一个交互界面。Git有tig,尝试安装它并在repo中运行。你可以在这里找到一些用法示例。

客服