1. 建立 Git 仓库
  2. 保存更改
  3. 检查仓库
  4. 回顾旧有提交
  5. 撤销更改
  6. 改写历史

    建立 Git 仓库

hero

原文链接:Setting up a repository

本教程对一些最重要的 Git 命令进行了简明的介绍。作为教程的开始,“建立 Git 仓库”这一章介绍了建立一个新的版本控制项目所需要的工具。随后的章节会介绍 Git 的日常命令。

学习完本章的内容,你应该能够建立一个 Git 仓库、记录项目快照保存备案、并能查看项目的历史。


git init

git init 命令创建一个新的 Git 仓库。使用这个命令可以将一个现有的未版本化的项目转化为 Git 仓库,或者初始化一个空仓库。其他大部分 Git 命令都只能在初始化的仓库内使用,所以这个命令通常是你在新项目中运行的第一个命令。

执行 git init 命令会在项目的根目录中创建一个 .git 子目录,这个子目录里包含了这个 Git 仓库所需的全部元数据。除了 .git 目录外,项目中其他已存在的目录结构不会发生变化(这一点与 SVN 不同,Git 不需要在每一个子目录中都创建一个 .git 文件夹)。

使用

git init

该命令表示将当前的文件夹转化为 Git 仓库。这一操作在当前路径中添加了 .git 文件夹并开始记录项目的版本。

git init <directory>

该命令表示在指定的文件夹中创建一个空的 Git 仓库。运行这个命令会创建一个仅包含 .git 子文件夹的,名为 <directory> 的文件夹。

git init --bare <directory>

该命令表示初始化一个空的 Git 仓库并忽略工作目录。共享的 Git 仓库在创建的时候都应该带上 --bare 参数(参见下面的详述)。按照惯例,使用 --bare 参数进行初始化的项目以 .git 作为结尾。举例来说,一个叫做 my-project 的裸版本项目应该存储在名为 my-project.git 的目录下。

详述

相较 SVN 而言,git init 命令是一个相当简单的创建新版本控制的工程的命令。Git 不需要你创建仓库、引入文件、检出工作副本。仅需要 cd 进入项目文件夹然后运行 git init,你就会得到一个拥有全部功能的 Git 仓库。

然后,对于大多数的项目来说,git init 命令仅仅会在创建中央仓库时被执行一次。开发者们通常不使用该命令来创建他们的本地仓库——他们会使用 git clone 命令来将一个已存在的项目复制到本地。

裸仓库(Bare Repositories)

带有 --bare 参数的初始化命令会创建一个没有工作路径的仓库,在这样的仓库里无法进行文件编辑以及提交更改。由于对非裸仓库(non-bare repository)进行分支推送有重写更改的潜在风险,因此远端的中央仓库通常被创建为裸仓库【注】。你可以将 --bare 参数看做是一种将一个仓库标记为存储设施,使之与开发环境相区别的方式。几乎在所有的 Git 工作方式下,中央仓库都是裸仓库,而开发者的本地仓库都是非裸仓库。

bare repo

示例

由于创建本地工程拷贝最便捷的方式还是使用 git clone,因此 git init 命令最常见的使用案例还是创建远端的中央仓库:

ssh <user>@<host>
cd path/above/repo 
git init --bare my-project.git

首先,SSH 连接到存放中央仓库的服务端;然后找到你想要存储项目的位置;最后,使用 --bare 参数创建一个中央存储仓库。之后开发者们便可以通过 clone 的命令创建一个本地开发机的副本。

git clone

git clone 命令会拷贝一个现存的 Git 仓库。这有点像 SVN 的 svn checkout,唯一不同的一点是 Git 的工作副本是一个完整(full-fledged)的 Git 仓库,它拥有自己的历史、能够管理文件,是一个与原有仓库完全隔离的环境。

为了方便起见,该命令会自动创建一个叫做 origin 的远程连接自动与远程仓库相连。这使得本地与远端的互动变得极为简单。

使用

git clone <repo>

该命令表示将位于 <repo> 的远程仓库克隆至本地。这个远程的仓库可以位于本地文件系统中或者位于可以通过 HTTP 或 SSH 连接的远端机器中。

git clone <repo> <directory>

该命令表示将位于 <repo> 的远程仓库克隆至本地的 <direcotory> 路径中。

详述

如果一个项目存在中央仓库,这种情况下 git clone 命令是最常见的获取本地开发副本的方式。如同 git init 命令一样,克隆命令通常也是一次性的操作 —— 一旦开发者获取了工作副本,之后所有的版本控制操作和分工协作都是通过他们的本地仓库来进行管理。

库间协作(Repo-To-Repo Collaboration)

最重要的一点还是要明白 Git “工作副本”的理念与你通过从 SVN 仓库中检出代码获得的工作副本有很大的不同。其不同之处在于,Git 的中央仓库与工作副本都是完整的 Git 仓库,他们之间没有任何区别。

这就使得 Git 与 SVN 的协作的方式迥异。SVN 依赖于中央仓库与工作副本之间的联系,而 Git 的协作模式是基于库与库之间的互动。SVN 是在工作副本与中央仓库之间进行检入检出,而 Git 是在两个任意仓库之间进行 pushpull

svn collaboration git collaboration

当然,你也完全可以给指定的 Git 仓库以特别的含义。比如通过简单的指定一个 Git 仓库作为“中央”仓库,你就可以复制一个 Git 的集中式工作流程。关键的一点是,这是通过约定来实现的而不是固化到版本控制系统(VCS,version control system)内部的自身实现。

示例

下面的例子展示了如何获取存储在远端服务器的中央仓库的工作副本的。例子中我们使用 SSH 方式,远端服务器地址为 example.com,用户名为 john

git clone ssh://john@example.com/path/to/my-project.git 
cd my-project
# 开始在这个项目上工作

第一个命令在本地的 my-project 文件夹初始化了一新的 Git 仓库,并将中央仓库的文本内容拉取到本地仓库中。之后就可以 cd 进入到本地项目中开始编辑文件、提交快照或者与其他的库进行互动协作。注意到项目 .git 的后缀在副本仓库中被移除了,这表明本地的副本是非裸仓库的状态。

git config

git config 命令让你可以从命令行配置 Git 或者一个单独仓库的配置项。这一命令可以对用户信息、使用偏好、仓库行为等一系列配置项进行设置。下文列出了一些常用的配置选项。

使用

git config user.name <name>

该命令表示定义在当前仓库中每次提交使用的作者姓名。一般情况下你会使用 --global 参数为当前用户设置配置项(译者注:表示本机的 Git 在不同的仓库下都默认使用该用户名)。

git config --global user.name <name>

该命令表示定义当前用户提交使用的作者姓名。

git config --global user.email <email>

该命令表示定义当前用户提交使用的电子邮箱。

git config --global alias.<alias-name> <git-command>

该命令表示为 Git 命令创建缩写。

git config --system core.editor <editor>

该命令表示在本机定义类似 Git commit 命令使用的文本编辑器。<editor> 参数应为启动所需编辑器的命令(例如 vi)。

git config --global --edit

该命令表示使用文本编辑器打开全局配置文件以便手工配置。

详述

由于所有的配置信息都存储在纯文本文件里,因此 git config 命令确实是一个非常便捷易用的命令行命令。通常来说,当你第一次在一个新的开发环境上工作时配置 Git,或者你想让配置通用于所有情况,你应该使用 --global 参数。

Git 将配置项分别存储在三个不同的文件中,这使配置项可以划分为仓库级、用户级和系统级三个不同的作用域。

  • <repo>/.git/config —— 仓库级作用域配置项。
  • ~/.gitconfig —— 用户级作用域配置项,使用 --global 参数时会将配置项存储在该文件中。
  • $(prefix)/etc/gitconfig —— 系统级作用域配置项。

当这些配置项出现冲突时,仓库级优先级最高,用户级次之,而系统级最低。如果你打开任意一个配置文件,你就会看到与下方内容十分相似的文件内容:

[user] 
name = John Smith
email = john@example.com
[alias]
st = status
co = checkout
br = branch
up = rebase
ci = commit
[core]
editor = vim

你可以手动修改这些值,这与使用 git config 命令效用相同。

示例

当你安装完 Git 之后你要做的第一件事儿就是告诉 Git 你的姓名和邮箱地址,并制定一些默认的配置项。通常来说初始化的配置看起来是这样的:

# 告诉 Git 你是谁
git config --global user.name "John Smith"
git config --global user.email john@example.com

# 选择你最喜欢的编辑器
git config --global core.editor vim

# 加入一些和 svn 差不多的简写
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.up rebase
git config --global alias.ci commit

这些命令会生成前文所述的 ~./gitconfig 文件。


  • 【注】:私以为这里没有很好的说明什么是裸仓库。裸仓库仅仅记录版本历史信息,仓库中只有 .git 子目录而没有其他工程文件的仓库,因此在此仓库中无法执行各种 Git 命令,只能通过其克隆仓库使用 push 命令对其进行更新。

保存更改

enter image description here

原文链接:Saving changes

git add

git add 命令将工作目录的更改添加到暂存区(staging area)。这一命令告诉 Git 我们想要在下一次的提交中包含对于某个特定文件的更改。然而, git add 命令对于仓库状态的影响微乎其微——事实上只有当 git commit 命令运行之时,更改才会得以记录。

我们还需要 git status 命令与上述两个命令结合使用,以便查看工作目录和暂存区的状态。

使用

git add <file>

<file> 文件中的所有更改暂存以便下次提交。

git add <directory>

<directory> 路径中的所有更改暂存以便下次提交。

git add -p

开始一个交互式的暂存会话,以便我们选择一个文件的一部分来添加到下一次提交中。这个命令会给你提供一个更改的文件区块(chunk)并提示你输入一个命令。命令说明如下:

  • y:暂存该区块
  • n:不暂存该区块
  • q:退出(不暂存该区块并忽略之后的全部区块)
  • a:暂存该区块并暂存之后的全部区块
  • d:不暂存该区块并忽略该文件之后的全部区块
  • s:将区块拆分为更小的区块(只有在区块不相连的时候才可以使用)
  • g:选择并跳转到一个区块
  • /:根据给定的正则表达式选择一个区块
  • j:将该区块标记为不确定,并跳转到下一个不确定区块
  • J:将该区块标记为不确定,并跳转到下一区块
  • k:将该区块标记为不确定,并跳转到上一个不确定区块
  • K:将该区块标记为不确定,并跳转到上一区块
  • e:手动编辑区块
  • ?:打印帮助

译者注: 这一系列命令的详细解释都可以在输入 git add -p 命令之后输入 ? 来查看,由于原文没有列全,译者将其全部列出以资参考。在实际使用中发现 Git 默认将一个文件中的全部更改当做是一个区块,当更改点之间有间隔时,可以被拆分为不同的区块,之后就可以使用 g j J k K 等一些列命令来在各个区块间跳转操作,十分的方便。

详述

git addgit commit 命令组成了基本的 Git 工作流。他们是将项目版本记录到仓库历史中的命令,因此不管团队采取何种协作模型,这两个命令都是每一个 Git 的使用者所必须理解掌握的。

一个工程的开发过程围绕着 编辑/暂存/提交 的基本模式运行。首先,我们在工作目录中编辑文件,并使用 git add 暂存更改。当我们对暂存快照感到满意时,我们使用 git commit 将其提交到项目历史中。

enter image description here

git add 命令不应与 svn add 相混淆,后者的意思是将文件添加到仓库中。与之相反,git add 是在更加抽象的层次上处理更改。这意味着对一个文件的每次改动你都需要使用 git add 命令来进行提交,而对于 svn add 来说,每个文件的多次改动仅需要提交一次。尽管听起来有些繁杂冗余,但这种工作流使得项目更容易保持组织化。

暂存区

暂存区是一个 Git 的独家特性,如果你以前是玩 SVN 的(甚至是 Mercurial),你都需要花些时间来思考一下这个特性的原理。我们可以将其理解为工作目录与项目历史记录之间的缓冲区

暂存区可以让我们在将改动提交到项目历史记录之前,先把相关的改动进行分组并放入高度集中的快照中,这样就避免了我们一股脑的将上次提交之后的所有改动全部提交到历史记录中。由此我们可以对先各种不相干的文件进行编辑,之后再通过将相关改动分别添加到暂存区,将每个区块依次提交,最终把这些改动划分成逻辑上有相关性的几次提交。在任何的版本控制系统中,创建原子提交都是至关重要的,这有助于在对项目的其他部分影响最小的前提下,更便捷的追踪 bug 和恢复改动。

例子

当我们开始一个新项目时,git add 命令与 svn import 命令有着相同的功能。使用下面两个命令来在当前目录创建一个初始化提交:

git add
git commit

当我们的项目已经良好运行时,新添加的文件可以通过给 git add 命令传递参数的方式添加:

git add hello.py
git commit

上述命令也可以用来记录对现存文件的更改。要注意的一点是,Git 并不对新文件中暂存的更改和仓库原有文件中暂存的更改做任何区分。

git commit

git commit 命令将暂存的快照提交到项目历史中。已经提交的快照可以被看做是项目的“安全”版本 —— 除非你显式要求更改(例如使用各种 Git 命令),不然 Git 不会对其做任何的改动。与 git add 命令一样,本命令也是最重要的 Git 命令之一。

尽管名字相同,但这一命令和 svn commit 的意义却大相径庭。本命令中快照是被提交到本地仓库,与其他的 Git 仓库完全没有任何交互。

使用

git commit

该命令表示提交暂存的快照。这条命令会打开一个文本编辑器并提示你输入提交信息。只有当你填写完提交信息、保存文件并关闭编辑器时才算创建了一个真正的提交。

git commit -m "<message>"

该命令表示提交暂存的快照。与上一命令不同的是,本命令并不启动文本编辑器,而是使用 <message> 作为提交的信息。

git commit -a

该命令表示提交工作目录中所有更改的快照。这次提交仅包含对监视文件tracked files,指使用 git add 命令添加过的文件)的修改。

详述

Git 与 SVN 最根本的的区别就是:SVN 中工作副本总是被提交到中央仓库,而 Git 的快照总是被提交到本地仓库。Git 在你准备好之前不会强迫你与中央仓库进行交互。正如暂存区是工作目录与提交历史之间的缓冲区一样,每位开发者的本地仓库也是开发者与中央仓库之间的缓冲区。

Git 的这一原理改变了 Git 用户的基本开发方式,让他们可以在本地仓库先积累一部分提交而不必每次改动都直接提交到中央仓库。与 SVN 的协作方式相比,Git 的优势不胜枚举:Git 让我们可以轻而易举地将一个功能拆分为几个原子提交、将相关联的提交组合在一起、在提交到中央仓库之前清理本地的提交历史;同时它还让开发者们可以在相互独立的环境下开发,直到方便的时候再进行整合。

快照与差异

除了实际使用上的差别,SVN 和 Git 的底层实现也遵循着完全不同的设计理念 —— SVN 追踪文件的差异,而 Git 的版本控制模型基于快照。举例来说,SVN 的提交由与添加到仓库的源文件的 diff 差异组成,而 Git 每次提交都要记录每个文件的全部内容

enter image description here

这使得 Git 的许多操作比 SVN 要快得多,既然一个特定版本的文件不必是差异的“集合体”,那么每个文件的完整版本都可以立即从 Git 的内部数据库中获取。

从分支、合并工具和协作开发工作流,Git 的版本控制模型的各个方面都受到了 Git 的快照模型的深远影响。

示例

下例假定你已经在一个名为 hello.py 的文件中编辑了一些内容,并准备将其提交到项目历史中。首先你需要使用 git add 将文件暂存,之后你可以提交暂存的快照:

git add hello.py
git commit

这一命令会打开一个文本编辑器(该编辑器可以使用 git config 进行自定义配置),并要求输入提交信息,并展示一系列待提交的文件:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: hello.py

Git 的提交信息没有特定的格式约束,但规范的提交格式总是在第一行用不超过 50 个字符总结提交内容,之后留一行空白行,然后在对更改内容进行详细的阐述。如下所示:

修改 hello.py 的信息展示

- 更新 sayHello() 方法使之输出用户名
- 修改 sayGoodbye() 方法使之输出更加友好的信息

注意上文中的时态,许多开发者在写提交信息的时候喜欢使用现在时态。这让提交信息读起来更像对仓库进行操作,使得许多重写历史的操作更加直观。

检查仓库

enter image description here

原文链接:Inspecting a repository

git status

git status 命令展示了工作目录和暂存区的状态,让我们查看已经暂存和尚未被暂存的改动,已经未被 Git 追踪的文件。该命令的输出并不展示任何提交到项目历史的信息。如果需要展示项目历史的相关信息应该使用 git log 命令。

使用

git status

该命令展示了已暂存、未暂存和未跟踪的所有文件。

详述

git status 命令是一个相当直截了当的命令,它直接展示了 git addgit commit 的运行情况。展示的状态信息还包含对已暂存和未暂存文件的相关说明。git status 执行时的输出包含如下所示的三个类别:

# On branch master
# Changes to be committed:
# (use "git reset HEAD <file>..." to unstage)
#
# modified: 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: main.py
#
# Untracked files:
# (use "git add <file>..." to include in what will be committed)
#
# hello.pyc

忽略的文件

未跟踪的文件通常分为两种:未被添加到项目(即使用 git add)或未被提交到项目中(即使用 git commit)的文件,或者诸如 .pyc .obj .exe 等编译为二进制的文件。将前者显示到 git status 的输出中是十分有益的,但如果显示后者的话会给我们观察仓库的状态造成一些困扰。

因此 Git 允许我们完全忽略掉这些文件,实现的方式是将要忽略的文件路径写入名为 .gitignore 的特殊文件中。我们可以在单独一行中写上任何要忽略的文件,* 符号可以被当做通配符使用。例如,将 *.pyc 添加到项目根目录的 .gitignore 文件中可以防止编译后的 Python 模块出现在 git status 的展示中。

示例

在提交更改之前检查仓库状态是一个好习惯,这样我们就不会把不想提交的东西不小心提交上去。这个例子展示了在暂存和提交一个快照之后的仓库状态:

# 编辑 hello.py
git status
# hello.py 列在 "Changes not staged for commit(未被暂存的改动)" 下面
git add hello.py
git status
# hello.py 列在 "Changes to be committed(准备提交的改动)" 下面
git commit
git status
# nothing to commit (working directory clean),提示没有文件可以提交,工作区域是干净的

第一个状态输出会展示文件没有被暂存,git add 之后的状态将会被反映在下一个 git status 命令中,最终的状态输出会告诉你并无可提交文件,工作目录会匹配到最近的提交。一些 Git 命令(例如 git merge)需要工作目录是干净的(clean)以免不小心覆盖更改。

git log

git log 命令展示了提交的快照。该命令允许你列出项目历史记录并进行过滤,以及寻找特定的提交。该命令与 git status 命令各司其职,后者用于检查工作目录和暂存区域,而前者仅对提交历史记录进行操作。

enter image description here

命令的输出可以通过多种方式进行自定义,通过使用一些简单过滤就可以让输出变为完全自定义的格式。下面列出了一些常见的 git log 的配置项:

使用

git log

该命令表示使用默认的格式展示全部的提交历史。如果输出结果占据了超过一屏的容量,你可以使用空格滚屏或按 q 退出。

git log -n <commit>

该命令表示通过 <commit> 来限制显示提交的数量,例如 git log -n 3 表示仅展示 3 条提交。

git log --oneline

该命令表示将每条提交浓缩为一行。这有利于对项目历史记录进行高层次的概览。

git log --stat

该命令除去普通 git log 命令展示的内容之外,还包含了更改的文件及其增删行数等信息。

git log -p

该命令展示了代表每次提交生成的补丁。包含了每次提交的全部 diff 结果,这也是我们在项目历史中可以获取的最详细的视图表示。

git log --author="<pattern>"

该命令表示查找特定作者的提交。<pattern> 参数可以是普通的字符串也可以是正则表达式。

git log --grep="<pattern>"

该命令表示查找包含匹配 <pattern> 的特殊提交信息的提交,<pattern> 参数可以是普通的字符串也可以是正则表达式。

git log <since>..<until>

该命令表示展示 <since><until> 的提交。这两个参数均可以是提交的 ID、分支名、HEAD 或者任何形式的修订版本引用

git log <file>

该命令仅显示包含特定文件的提交,这是一个便捷的查看特定文件提交历史的方式。

git log --graph --decorate

上面的命令展示了一些有用的命令参数,--graph 参数会在提交信息左侧用文本绘制一个图形;--decorate 参数给展示的提交添加了分支名称或者 tag 号。

详述

git log 命令是 Git 浏览仓库历史的一个基本工具。他可以用来帮助我们找到项目的一个特定版本;当要合并一个实现新特性的分支时查看该分支的改动情况;还可以用来查看那个开发人员在磨洋工。

commit 3157ee3718e180a9476bf2e5cab8e3f1e78a73b7
Author: John Smith

尽管上面的内容直截了当,但是第一行我们还是得解释一下。这行提交后产生的 40 个字符的字符串是提交内容的 SHA-1 总和校验码。这样做有两个目的:

  1. 保证了提交的完整性 —— 如果提交内容有损坏,提交就会生成不同的总和校验码。
  2. 给每次提交生成了唯一的 ID。

这个 ID 可以用在诸如 git log <since>..<until> 的命令中用于指代一次特定的提交。比方说 git log 3157e..5ab91 命令就会展示 ID 在 3157e5ab91 之间的所有提交。除了使用总和校验码,分支名称(在分支模型这一章里有详述)和 HEAD 关键字也是可以指代特定提交的通用方法。HEAD 总是指代当前提交(是一个分支或者是一个特定的提交)。

~ 字符是用来表示一个提交的父提交。比如说 3157e~1 指代 3157e 提交的前一个提交,而 HEAD~3 则表示当前提交的曾祖提交。

所有的这些标示方法都是为了能让我们操作特定的提交。git log 命令就是我进进行操作的起始命令,以便我们找到想操作的特定提交。

示例

在使用的章节我们提供了很多使用 git log 的例子,但要记住多个命令参数是可以在一个命令中联合使用的。

git log --author="John Smith" -p hello.py

这将会展示 John Smith 对文件 hello.py 所做改动的全部 diff。

.. 双点语法是比较分支的利器,下一个例子展示了如何查看所有在 som-feature 上独有而 master 上没有的提交。

git log --oneline master..some-feature

回顾旧有提交

enter image description here

git checkout

git checkout 命令提供三个不同的功能:检查文件、检查提交和查看分支。在本模块中我们只关心前两个功能。

检出对应提交可以使整个工作目录匹配到该提交的状态。这可以用来检查你项目的旧有状态而不改变当前状态。检出一个文件可以让我们查看这个特定文件的旧有版本,而不改变项目中其他文件的状态。

使用

git checkout master

该命令表示回到 master 分支。分支的概念会在下一章中进行详述,在这里我们暂且把它当做返回项目的“当前状态”吧。

git checkout <commit> <file>

该命令表示检出一个文件的先前版本。这会将工作目录中的 <file> 文件变为一个 <commit> 提交状态下的复制文件,并将其添加到暂存区。

git checkout <commit>

该命令表示将所有工作目录中的文件匹配到一个特定的提交状态下。<commit> 参数可以是提交的哈希值或者 tag 标记。这一操作会使我们进入分离 HEAD(detached HEAD) 状态。

详述

所有版本控制系统的整体想法是储存一个“安全”的项目副本,这样我们就可以不必担心代码库(code base)遭到灭顶之灾。一旦我们建立起项目历史,git checkout 命令就是将保存的快照“加载”到本地开发机上的一个简单易行的方法。

对旧版本的检出是一个只读操作。查看旧版本项目不会对你的仓库造成任何破坏,项目的“当前”状态仍然完好无损地存在于 master 分支上(详情请见分支这一章)。在正常的开发过程中,HEAD 通常指代 master 分支或者其他本地分支,但是当你检出之前的提交时,HEAD 便不再指向一个分支,而直接指向一个提交。这就是所谓“分离 HEAD”的状态,以下是对这一状态的可视化的表达:

enter image description here

另一方面,检出旧有文件则不会影响仓库的当前状态。我们可以和提交其他文件一样再次将就有版本的文件提交到新快照中。因此这里 git checkout 实际上是一种将单个文件恢复(revert)到旧有版本的方式。

enter image description here

实例

检查旧有版本

这个例子假定我们已经开始编码实现一个疯狂的想法,但是我们还不确定要不要保留这块儿代码。为了早作决断,我们查看了一下在疯狂编码之前的项目状态。首先,我们需要找到想要查看的版本 ID。

git log --oneline

假设我们的项目历史看起来是这样的:

b7119f2 Continue doing crazy things
872fa7e Try something crazy
a1e8fb5 Make some important changes to hello.py
435b61d Create hello.py
9773e52 Initial import

我们可以使用 git checkout 命令检测这个“Make some important changes to hello.py” 这个提交:

git checkout a1e8fb5

这使得我们的工作目录匹配到 a1e8fb5 提交之后的状态。我们可以查看文件、编译项目、运行测试甚至对文件进行编辑而不必担心丢失项目当前状态。因为我们以上的操作都不会被保存在仓库中。如果想继续开发之前那个疯狂的想法,我们需要回到项目当前状态:

git checkout master

以上例子是假设我们正在默认的 master 分支上开发,分支的问题我们还会在分支那章进行透彻的分析。

一旦我们回到 master 分支,就可以使用 git revert 或者 git reset 来撤销不需要的修改。

检出文件

如果我们只对单个文件感兴趣,我们也可以使用 git checkout 来拉取文件的旧有版本。举例来说,如果我们想查看旧有提交中的 hello.py,我们可以使用以下命令:

git checkout a1e8fb5 hello.py

需要记住的是这与检出一个提交不尽相同,这一操作会影响项目的当前状态。旧版文件修订会显示为“待提交的修改(Change to be committed)”,使我们能够恢复(revert)到文件的先前版本。如果不想保存之前的旧版,我们可以使用以下命令签出最近的版本:

git checkout HEAD hello.py

撤销更改

enter image description here

原文链接:Undoing Changes

译者按:原作中内容编排有误,本节内容实际上包含了上节“回顾旧有提交”的全部内容(也就是 git checkout 命令),因此本节仅译后三个命令:git revert git resetgit clean


本教程提供了所有处理一个软件项目先前版本的必要技巧。首先本文展示了如何检查旧有提交,之后阐述了在项目历史中恢复(revert)公共提交与在本地重置(reset)未发布的更改的区别。

git revert

git revert 命令撤销一个已提交的快照。但其并非将提交从项目历史中移除,而是计算出如何撤销因这次提交而产生的更改(译者注:也就是进行一个反向提交,将增添的内容删除,将删除的内容补回),同时在此基础上追加一个新提交。这防止了 Git 出现丢失历史的情况,这一点对于历史记录的完整性和合作的可靠性是非常重要的。

enter image description here

使用

git revert <commit>

该命令表示生成一个可以撤销因 <commit> 提交而带来的所有更改的新提交,并将其应用到当前分支上。

详述

我们应该在准备从项目历史中移除一个完整的提交时使用 git revert 命令。如果当你发现一个正在定位的 bug 是由一个提交引起时,这一命令将十分有用。使用该命令可是使我们免去寻找 bug,修复 bug 然后手动进行一次新提交的麻烦。

恢复(revert)与重置(reset)

要着重理解 git revert 命令是撤销一个单独的提交,而不是通过删除一系列提交回到项目的一个先前的状态。在 Git 里,后一种操作叫做重置,而非恢复。

enter image description here

恢复相比重置有两大优势:

首先,恢复操作不会更改项目历史。因此恢复对于已经发布到公共仓库中的提交而言是一个“安全”的操作。对于改变公共历史的危险性的详述请参加下一节。

其次, git revert 命令可以以历史记录中任意一次单独提交作为操作的对象,而 git reset 只能从当前提交开始进行重置。比方说我们想要使用 git reset 撤销一个旧有提交,我们就需要将目标提交和当前提交之间的所有提交全部移除,然后重新提交目标提交之后的所有提交。毫无疑问这并非一个优雅的撤销方案。

示例

下面的例子是对 git revert 的一个简单的演示。例子中我们提交了一个快照,然后又使用恢复将其立即撤销掉。

# 编辑一些跟踪文件

# 提交快照
git commit -m "进行一些将会被撤销的更改"

# 恢复我们刚刚创建的提交
git revert HEAD

图形表示如下:

enter image description here

注意到在恢复之后,第四次提交依然在项目历史中。git revert 命令添加了一个新提交来撤销更改而非将旧有提交直接删除。操作的结果是第三次提交和第五次提交有着相同的代码(因为第四次提交被恢复了),而第四次提交依然存在于项目历史中以便将来可以再次找到这个提交。

git reset

如果说 git revert 是一个撤销更改的“安全”方法,那么 git reset 就是可以被认为是一个“危险”的方法。当使用 git reset 撤销更改(被撤销的提交将不会被引用或者日志引用所引用)时,我们就无法找回原有提交的副本,也就是说,这是一个永久性的撤销方法。在必须要使用这个工具时请务必当心,因为这是唯一一个有可能使我们丢失掉工作记录的 Git 命令。

git checkout 命令相似,git reset 命令也是一个配合参数使用的多用途命令。虽然经常被用作在暂存区域和工作目录中撤销更改,但该命令也可用来删除提交快照。在任何情况下,这一命令都只应用来撤销本地更改 —— 我们永远都不应该重置已经发布并被其他开发者使用的公共快照。

使用

git reset <file>

该命令表示将特定文件从暂存区域移除,将工作目录置为未改动状态。这一操作将一个文件取消暂存并不重写任何修改。

git reset

该命令表示将暂存区域重置为最近提交的状态并将工作目录设为未改动。这一操作将所有文件置为未暂存且不重写任何更改,这给我们机会得以重头开始重建暂存的快照。

git reset --hard

该命令表示将暂存区域和工作目录重置为最近提交的状态,为了不对改动进行暂存,--hard 参数告诉 Git 重写工作目录的所有改动。换句话说就是:这一操作去除了所有未提交的更改。所以在使用该命令之前我们需要确保确实要丢弃所有本地改动。

git reset <commit>

该命令表示将当前分支的顶端移动至 <commit> 提交处,将暂存区域重置,但不处理工作目录。所有自 <commit> 提交以后的修改都存留在工作目录中,我们可以使用可以提交更加原子化快照的清理工具来重新提交项目历史。

git reset --hard <commit>

该命令表示将当前分支的顶端移动至 <commit> 提交处,且将暂存区和工作目录都重置。这一操作不但删除了未提交的改动,还将 <commit> 提交之后的所有提交全部删除了。

详述

以上的调用都是用来从仓库中删除更改的。如果不使用 --hard 标记,git reset 就是一个通过撤销已暂存的更改或者删除一系列已提交的快照然后从头开始处理它们的清除仓库的一种方式。而 --hard 参数用于处理当我们尝试性的编码出现了严重的错误,我们需要清除掉它们以找回一个干净的工作区域时。

鉴于恢复是用来安全的撤销公共提交git reset 就是用来撤销本地提交的。由于两者实现的目的不同,因此两个命令的实现方式也大相径庭:重置完全删除了一系列更改,而恢复保留了原有更改序列并使用新的提交来进行撤销操作。

enter image description here

不要重置公共历史

<commit> 提交已经被推送到公共仓库中后,我们就永远不应该使用 git reset <commit> 操作。当推送一个提交后,我们必须认为其他的开发者已经依赖于这一提交。

移除一个其他团队成员依赖的提交会对协作开发造成很多问题。当他们尝试与你的仓库进行同步时,会发现你的仓库看起来像一块历史记录突然消失掉。下面的图示表明了当我们尝试重置公共提交时会发生什么。例子中 origin/master 分支是你本地 master 分支的中央仓库版本。

enter image description here

当我们在重置后添加新的提交,Git 会认为我们的本地历史与 origin/master 产生分歧,在合并提交时需要先同步本地仓库,这会困扰和阻挠我们团队的开发。

要点在于确保仅在本地出错的尝试代码中使用 git reset <commit> 命令而不要在公共改动上使用。如果我们想要修复一个公共提交,请使用为此量身定做的 git revert 方法。

示例

撤销文件暂存

当准备提交暂存的快照时会经常使用到 git reset 命令。下一个例子假设我们有 hello.pymain.py 两个文件且我们已经将其添加到仓库中。

# 编辑 hello.py 和 main.py 文件

# 将当前所有未跟踪文件暂存
git add .

# 意识到 hello.py 和 main.py 的改动应该被提交在不同的快照中

# 撤销 main.py 的暂存
git reset main.py

# 仅提交 hello.py
git commit -m "Make some changes to hello.py"

# 将 main.py 提交到另一个快照中
git add main.py
git commit -m "Edit main.py"

正如例子所展示的,git reset 命令通过撤销不需要出现在下次提交中的改动,帮助我们进行精准集中的提交。

删除本地提交

下一个例子展示了一个更高级的使用案例。它展示了当你写了一大段新代码而且进行了一次新提交以后,决定彻底干掉这些内容的处理方案。

# 新建一个叫做 `foo.py` 的文件然后写点什么

# 将其提交到项目历史
git add foo.py
git commit -m "开始开发一个疯狂的功能"

# 再次编辑 `foo.py` 并改动了其他的跟踪文件

# 提交另一个快照
git commit -a -m "继续搞我的疯狂功能"

# 决定废除这个功能并删除相关提交
git reset --hard HEAD~2

git reset HEAD~2 命令将当前分支后退了两次提交,有效的从项目历史中删除了我们刚才创建的两次提交。记住这种重置只能用于未公开的提交,如果我们已经将提交推送到公共仓库中,请勿进行上述操作。

git clean

git clean 命令移除工作目录中未跟踪的文件。这是一个十分方便的命令,因为检查 git status 里那些文件时未跟踪的然后手工移除它们实在是一项毫无意义的操作。与常见的 rm 命令一样,git clean 命令是不可撤销的,因此在运行之前要确保确实需要删除这些未跟踪的文件。

git clean 命令经常与 git reset --hard 命令结合使用。由于重置操作仅影响跟踪文件,因此需要有另一个命令来处理未跟踪的文件。结合使用以上两个命令可以使你的工作目录准确无误的回到一个特定提交所处的状态。

使用

git clean -n

该命令表示进行一次“清除演练”,它会给我们展示那些文件将要被移除而并不移除它们。

git clean -f

该命令表示在当前目录中删除未跟踪的文件。除非配置项 clean.requireForce 配置为 false(默认配置是 true),不然 -f 参数(表示 force)是必须填写的(译者注:如果配置项为 true,运行 git clean 不加 -f 参数会拒绝执行)。这一操作不会删除.gitignore 中指定的未跟踪的文件或文件夹。

git clean -f <path>

该命令表示移除指定路径下的未跟踪文件。

git clean -df

该命令表示移除当前目录下的未跟踪文件和未跟踪文件夹

git clean -xf

该命令表示移除当前目录下的未跟踪文件以及被 Git 忽略的全部文件。

详述

git reset --hard 命令与 git clean -f 命令是我们的一对好帮手,当我们在本地仓库进行了一些脑残开发时,这俩兄弟能帮我们及时烧毁证据。运行这两个命令可以使我们的工作目录匹配到最近一次提交,使我们获得一个干净的工作环境。

git clean 命令也可以用来清除构建之后的工作目录。例如它可以轻松的删除 .o.exe 等 C 语言编译器生成的二进制文件。在打包发布一个项目时,这一操作有时是必须的。-x 参数就是为此量身订做的。

要记住的是,除了 git reset 命令之外,git clean 是 Git 为数不多的有可能永久删除提交的命令之一,操作时务必要小心。事实上 Git 的维护者在这样基本的操作上还要求添加 -f 参数,就是为了防止误删重要的内容。这就防止了我们运行一个简单的 git clean 命令就不小心删掉了全部内容。

示例

下面的例子删除了工作目录的所有改动,包括还没有没暂存的新文件。例子假设我们已经提交了一些快照且进行了一些实验性质的开发。

# 编辑已存在文件
# 添加新文件
# 突然不知道我在干啥了

# 撤销已跟踪的文件
git reset --hard

# 删除未跟踪的文件
git clean -df

通过运行 重置/清理 命令序列,工作目录和暂存区域将会与最近提交一致,同时 git status 将会展示一个干净的工作目录。现在我们可以重头开始工作了。

注意,与 git reset 的第二个例子不同,新文件不会添加进仓库中。因此这些文件不会被 git reset --hard 影响而需要 git clean 来对其进行删除。

改写历史

enter image description here

原文链接:Rewriting history


简介

Git 的主要工作是确保你不会丢失提交的更改,但是它也给了我们对于开发工作流程的完整控制权。Git 既能让我们确切定义项目历史,又包含了丢失提交的潜在可能。Git 提供了重写历史的免责声明,表示使用这些命令可能会导致项目代码的丢失。

本教程讨论了一些常见的重写已提交快照的原因,并展示了如何避免这样操作可能带来的陷阱。

git commit --amend

git commit --amend 命令是一个修复最近一次提交的便捷方法。这个命令可以帮助我们将暂存区的更改与先前一次提交合并,而不必重新提交一次新快照。当然这一命令也简单的用作编辑先前提交的提交信息而不更改其提交快照。

enter image description here

但是该命令不仅是 修改 最近的提交,而是 完全替换 掉最近的提交。对于 Git 来说,这次提交看起来就像一个全新的提交(在上图中我们用星号 * 标注了一下)。在公共仓库中进行开发时,请牢记这一点。

使用

git commit --amend

该命令表示将暂存区域与最近一次提交合并,并用合并后的快照替换最近一次的提交。如果暂存区为空时运行该命令,我们可以修改之前提交的提交信息而不对其快照进行改动。

详述

在我们的日常开发过程中,经常会进行一些马虎的提交 —— 例如忘记暂存文件啊、提交信息有误啊什么的。--amend 参数是弥补这些过失的便捷方式。

不要修改公共提交

git reset 这一节,我们讨论了不能重置公共提交的原因。对于修改来说也是一样的:永远不要修改已经推送到公共仓库的提交

修改后的提交实际上是一个全新的提交,而之前的提交就从项目历史中被删除了。这与重置公共快照的结果是一样的。如果你修改了一个其他开发者依赖的提交,他们就会感觉该提交好像从项目历史中消失了一样。这会给开发者造成混乱,而且恢复起来十分复杂。

示例

下面的例子展示了基于 Git 开发的场景。我们编辑了一些文件,然后准备将其作为一个快照提交,但是我们在第一次提交的时候漏掉了一个文件。修复这个错误只需要将这个文件暂存,然后使用 --amend 参数进行提交。

# 编辑了 hello.py 文件和 main.py 文件
git add hello.py
git commit

# 突然发现忘记提交 main.py 文件
git add main.py
git commit --amend --no-edit

运行命令时编辑器会显示上次的提交信息,如果加了 --no-edit 参数我们就可以追加提交而不修改提交信息。当然我们也可以修改提交的信息,或者你不加这个参数打开了编辑器,然后直接保存关闭也是不会修改提交信息的。最后生成的提交会替换掉之前那个不完整的提交,然后我们提交的对于 hello.pymain.py 的改动看起来就会在一个单独的提交快照中。

git rebase

衍合是将一个分支移动到一个新的基准提交的过程。大致的过程如图所示:

enter image description here

从内容上看,衍合仅仅是上一个分支从一个提交上嫁接到另一个提交上。但是从内部实现上看,Git 是通过创建新的提交然后将其应用的特定的基础提交上来实现这一过程的,这一操作确实重写了我们的项目历史。即使分支看来其和之前相同,但是它确实有全新的提交组成的,请务必理解这一点。

使用

git rebase <base>

该命令表示将当前分支衍合到 <base> 上。这里的 <base> 可以是任何形式的提交引用(ID、分支名称、tag 标签、或者是指向最近提交的 HEAD 指针)。

详述

使用衍合的主要原因是为了保持线性的项目历史。比方我们可以思考这样一个场景:当你开始开发一个功能的时候,master 分支也增加了几个提交:

enter image description here

我们有两种将开发的功能整合到 master 分支上的选择:直接合并(merge)或者先衍合再合并。前一种选择会生成一个 3-way 合并【注1】,以及一个合并提交;而后者则会生成一个快进合并(fast-forward merge)【注2】并产生一个完美的线性历史。下图演示了为何衍合到 master 上会促成一个快进合并。

enter image description here

衍合是一个常用的将上游【注3】更改整合到本地仓库中的方式。当我们想查看一下远端项目进展的时候,使用 git merge 拉取远端会生成一个冗余的提交;而衍合却好像在说:“我想以大家已经完成的部分为基准【注4】追加我的改动。”

不要衍合公共历史

正如我们讨论的 git commit --amendgit reset 一样,我们不应衍合已经提交到公共仓库中的提交。衍合会使用新提交替换旧有提交,这会导致项目历史的一部分好像突然消失了一样。

示例

下面的例子结合了 git rebasegit merge 命令以保持线性提交历史。这是一个快捷简便的手段来保证我们的合并是快进合并。

# 开始一个新功能
git checkout -b new-feature master
# 编辑文件
git commit -a -m "开始开发新功能"

整到一半儿我们发现我们的项目里有个安全漏洞:

# 基于master分支建立一个修复(hotfix)分支
git checkout -b hotfix master
# 编辑文件
git commit -a -m "修复安全漏洞"
# 并入master分支
git checkout master
git merge hotfix
git branch -d hotfix

在将修复分支合并进主干分支之后,我们的项目历史产生了分岔(forked)。我们并不采取简单的合并的方式,而是使用衍合来整合新功能分支以保持线性的项目历史:

git checkout new-feature
git rebase master

这就将新功能移动到主干分支的顶端,我们就可以在主干分支上进行一次教科书式的快进合并:

git checkout master
git merge new-feature

git rebase -i

在运行 git rebase 命令时添加参数 -i 会打开一个衍合的交互式会话。与盲目的将所有改动提交到新基准上不同,交互式的衍合允许我们在会话中替换个别提交。这允许我们通过删除、合并和替换已存在提交序列的方式来清理项目历史。这就像一个升级版的 git commit --amend

使用

git rebase -i <bare>

该命令表示将当前分支采用交互式衍合会话的方式衍合到 <base> 基准上。这一操作会打开一个编辑器,我们可以在编辑器上输入命令(下文会有详述)来操作每一个需要衍合的提交。这些命令决定了特定的提交如何被嫁接到新基准上。通过改变交互式编辑器中提交列表的顺序,我们就可以改变提交本身的顺序。

详述

交互式的衍合让我们可以完全控制项目历史的样子。这给予了开发者极大的自由,使得我们可以在专注于编码的时候先提交一坨混乱的历史,然后事后再进行历史的整理。

大多数开发者喜欢在将特性分支提交到主干分支之前,采用交互式衍合来润色分支提交。开发者在提交到“官方”项目历史之前,可以合并细碎的提交、删除废弃的提交以及让其他的提交显得井然有序。在旁人看来,这个新特性的提交就像由一系列经过缜密规划的提交组成的。

示例

# 开始开发新特性
git checkout -b new-feature master
# 编辑文件
git commit -a -m "开始开发新特性"
# 编辑更多的文件
git commit -a -m "修复之前提交的问题"

# 在主干分支上直接整一个提交
git checkout master
# 编辑文件
git commit -a -m "修复安全漏洞"

# 开始交互式衍合会话
git checkout new-feature
git rebase -i master

最后一个命令会打开一个编辑器,编辑器上写有新特性分支上的两个提交和一些命令介绍:

pick 32618c4 开始开发新特性
pick 62eed47 修复之前提交的问题

我们可以修改每个提交前面的 pick 命令,来决定在衍合过程中如何移动这些提交。在我们的例子中,我们仅仅使用压缩(squash)命令来合并两个提交。

pick 32618c4 开始开发新特性
squash 62eed47 修复之前提交的问题

保存并关闭编辑器就会开始衍合。这会打开一个新的编辑器要求给合并的快照填写提交信息。填写完信息之后衍合就完成了。我们可以通过 git log 的输出查看合并的提交。整个过程如图所示:

enter image description here

注意到合并的提交与两个原始提交拥有的 ID 均不同,这告诉我们它确实是一个新提交。

最后我们可以将我们润色后的分支采用快进合并的方式整合到主干分支上:

git checkout master
git merge new-feature

交互式提交的真正强大之处,在主干分支的历史中可窥一斑 —— 62eed47 这个提交(就是上文“修复之前提交的问题”这个提交)俨然无迹可寻。在旁人看来,我们就像精明的开发者,第一次提交就恰到好处的完成了 new-feature 分支的实现。交互式衍合就是这样让一个项目的历史记录整洁而有意义。

译者注:对于 git rebase -i 来说,上文仅列出 pickedit 两条命令,还有许多命令没有展示出来,实际上运行该命令之后,编辑器中会提示以下命令:

  • p, pick:表示采用这一提交;
  • r, reword:表示采用这一提交,但在衍合期间修正其提交信息;
  • e, edit:表示采用这一提交,但在衍合期间在此处暂停,进行 amend 操作;
  • s, squash:表示采用这一提交,但和之前的提交合并,并保留本提交的提交信息;
  • f, fixup:表示采用这一提交,和之前的提交合并,但是使用之前那次提交的提交信息;
  • x, exec:表示再此中断,余下操作继续使用命令行进行。

git reflog

Git 使用一种叫做引用日志(reflog)的机制记录分支顶端更新的踪迹。这可以使我们回溯到那些没有被任何分支或 tag 号引用的修改集中。重写历史之后,引用日志保留了旧有分支状态的信息,并允许我们在需要的时候回溯到旧有状态。

使用

git reflog

该命令表示展示本地仓库的引用日志。

git reflog --relative-date

该命令表示展示引用日志的相关日期信息(例如会展示 2 week ago,两星期之前)。

详述

每当当前 HEAD 指针更新时(例如切换分支、拉取更改、重写历史或是简单的添加新提交),一个新的内容就会被加入引用日志。

示例

我们通过运行一个示例来理解 git reflog

0a2e358 HEAD@{0}: reset: moving to HEAD~2
0254ea7 HEAD@{1}: checkout: moving from 2.2 to master
c10f740 HEAD@{2}: checkout: moving from master to 2.2

上文的引用日志展示了从 master 分支切换到 2.2 分支上,然后又切换回来,然后在此基础上重置了当前分支到一个旧有提交上。最新的活动位于日志最顶端并标记为 HEAD@{0}

如果你是不小心切换回主干分支上的,引用日志会包含在你不小心丢弃的两个提前之前的主干分支的引用(0254ea7)。

git reset --hard 0254ea7

使用 git reset 命令就会把主干分支改回之前的样子。这样就提供了一个防止误修改历史的安全举措。

重要的一点是,引用日志仅仅在当改动已经被提交到本地仓库中时才会提供这一安全保障,而且它也仅记录 HEAD 指针的动向。


注释:

  • 【注1】3-way merge,译作三路合并,三路表示原始点 A,以及针对 A 的两个改动 B 和 C 这三路。在合并时会把 B 和 C 均与 A 进行比较,获取合并结果的方式。著名的 Diff 工具 KDiff3 中的 3 便是取三路合并之意。
  • 【注2】fast-forward merge,快进合并,表示不产生额外提交,保持线性历史的一种合并,如下图所示:

    fast-forward

    右图就是一个快进,它不产生分岔,也不会多出额外的提交,这种提交多用于合并无足轻重的分支;而左图则是一个带有 --no-ff 参数(也就是 no-fast-forward)的非快进提交,它使得历史更复杂(但同时也更加易懂),并产生了新提交,多用于合并重要的分支。大部分情况下,如果上游有新的提交,都会采取非快进的方式来合并,除非使用衍合命令。可以说,衍合的目的就是为了进行快进。这一点在下一章中会有详述。

  • 【注3】upstream,译作上游,指的是远端分支。Git Pro 书里译者说将分支想象成河流,主干分支便是上游。

  • 【注4】base,译作基准,表示需要衍合的分支在衍合后的之前一次提交。rebase 衍合顾名思义就是更换基准之意。

参考文献: