前言在上一篇文章中我们创建了仓库并了解了 Git 的三大核心区域。本节我们将学习如何往仓库里添加文件、提交修改如何查看历史提交记录以及当代码写错、误删文件时如何利用 Git 的“后悔药”功能回退到过去的版本。掌握了这些你才算真正能用 Git 来管理你的日常代码了。我们先在此附上之前的Git 三大核心区域图方便后续理解1 基础工作流修改与提交在 Git 中提交代码永远遵循一个核心流程先在工作区修改然后add到暂存区最后commit到本地仓库。这一节我们先梳理常规操作命令然后再通过一个真实的实验戴上“透视镜”看看这两个命令在底层对.git目录到底做了什么。1.1 日常操作命令速览将文件添加到暂存区git addgit add命令非常灵活支持多种添加方式# 添加单个文件gitaddfile1.txt# 添加多个文件用空格隔开gitaddfile1.txt file2.txt# 添加整个目录及其下的所有文件gitaddmy_dir/# 添加当前目录下的所有修改最常用注意后面的点gitadd.此时被添加的文件就已经进入了暂存区stage等待被正式提交。提交到本地仓库git commit暂存区的内容准备好后就可以使用git commit将其正式记录到本地仓库了gitcommit-m本次提交的描述信息关于-m后面的描述信息这部分内容由用户自己填写绝对不能省略。它用于记录你这次提交的细节比如“修复了登录页面的bug”、“新增了用户注册功能”是写给未来的自己和同事看的。如果你不写Git 会默认弹出一个 Vim 编辑器让你输入很多新手会卡在这里出不来。执行成功后终端会反馈类似这样的信息1 file changed, 1 insertions()。这表示 1 个文件被改动插入了 1 行内容。例如当我们创建文件ReadMe并在里面写入一行hello git然后进行提交$touchReadMe $vimReadMe $catReadMe hellogit$ls-a....git ReadMe $gitaddReadMe $gitcommit-madd first file[master(root-commit)d49ee64]addfirstfile1filechanged,1insertion()create mode100644ReadMe1.2 实验探究add 与 commit 在 .git 里做了什么首先我们在工作区创建一个名为ReadMe的文件并写入内容echohello gitReadMe现在我们用tree命令看一下当前.git目录的结构$ tree .git .git|-- branches|-- config|-- description|-- HEAD|-- hooks||-- applypatch-msg.sample||-- commit-msg.sample||-- post-update.sample||-- pre-applypatch.sample||-- pre-commit.sample||-- prepare-commit-msg.sample||-- pre-push.sample||-- pre-rebase.sample||-- update.sample|-- info||-- exclude|-- objects||-- info||-- pack|-- refs|-- heads|-- tags9directories,13files你会发现此时.git目录里并没有index文件objects文件夹里也是空的除了它自带的info和pack子目录。1.2.1 解析 git add暂存区与对象的诞生现在我们执行添加命令gitaddReadMe此时我们再次查看.git目录$ tree .git .git|-- branches|-- config|-- description|-- HEAD|-- hooks||-- applypatch-msg.sample||-- commit-msg.sample||-- post-update.sample||-- pre-applypatch.sample||-- pre-commit.sample||-- prepare-commit-msg.sample||-- pre-push.sample||-- pre-rebase.sample||-- update.sample|-- index|-- info||-- exclude|-- objects||-- c1|||-- 5317abfb6c6264a5ec209b86200d460e433fb5||-- info||-- pack|-- refs|-- heads|-- tags10directories,15files对比操作前你会发现两个明显的变化多了一个index文件这就是我们所说的暂存区。objects目录下多了一个以两个字符命名的文件夹比如c1/里面包含了一个长串命名的文件。这证明了git add到底做了什么它其实做了两件事将ReadMe的内容经过哈希计算打包成一个Git 对象Blob存入objects库中。在index暂存区中记录下这个对象的哈希索引以及文件的名字。避坑提醒怎么看index里的内容很多新手好奇直接用cat .git/index去看暂存区。千万不要这么做index是二进制文件直接看会是一堆乱码甚至可能破坏文件导致 Git 报错“index file corrupt”。正确的查看方式是使用 Git 的底层命令gitls-files--stage输出结果如下$gitls-files--stage100644c15317abfb6c6264a5ec209b86200d460e433fb50ReadMe这四列信息到底代表什么我们逐一来拆解100644文件模式/Mode这代表了文件的类型和权限。100代表这是一个普通文件如果是120000则是符号链接040000则是目录。644是 Linux 系统下的文件权限代表文件所有者可读写其他人只读即rw-r--r--。c15317ab...哈希值/对象 ID这就是我们刚才说的指向objects库的索引。它是一长串 40 位的 SHA-1 哈希值。当你执行add时Git 把ReadMe的内容算出了这个“指纹”并以此命名存入了.git/objects目录。只要你把这里的哈希值拿到.git/objects里去找就一定能找到对应的内容。0暂存区阶段/Stage 编号这个数字代表冲突阶段。在正常提交时它永远是0。当我们后面学习分支合并遇到代码冲突时Git 会在这里放入1公共祖先版本、2当前分支版本和3目标分支版本用来帮你在合并冲突时对比差异。ReadMe文件名也就是当前工作区对应的文件路径。这行输出完美印证了暂存区index里存放的不是文件实体而是文件的权限、内容的哈希索引、当前的冲突状态以及文件名。真正的文件内容早就被藏进objects库里了1.2.2 解析 git commit版本历史的生成接下来我们把暂存区的内容正式提交$gitcommit-madd first file[master(root-commit)d49ee64]addfirstfile1filechanged,1insertion()create mode100644ReadMe提交成功后我们再次敲下tree .git来看看底层发生了多么丰富的变化$ tree .git .git|-- branches|-- COMMIT_EDITMSG-- 新增上一次提交的 message|-- config|-- description|-- HEAD|-- hooks||-- applypatch-msg.sample||--......|-- index|-- info||-- exclude|-- logs-- 新增记录 HEAD 和分支的移动历史||-- HEAD||-- refs||-- heads||-- master|-- objects||-- 03|||-- 5ed82ed5da9274ba3bb5453a0df906e543505e-- 新增Tree 对象||--21|||-- 2a245a33305fef508aaf0406b7647113171417-- 新增Commit 对象||-- c1|||-- 5317abfb6c6264a5ec209b86200d460e433fb5-- 原来ReadMe 的 Blob 对象||-- info||-- pack|-- refs|-- heads||-- master-- 新增里面存着最新的 Commit ID|-- tags15directories,21files这些新增的文件都是干嘛的顺着新增的目录我们挑几个最核心的文件来看看它们的作用objects/里的三大对象核心存储Blob存放文件的真实内容之前在add时已经生成。Tree树对象记录当前项目的目录结构包含哪些文件、权限是什么、对应的 Blob 是谁。Commit提交对象记录这次提交的作者、时间、提交信息并指向那棵 Tree。refs/heads/master分支指针里面写着最新 Commit 的哈希值。COMMIT_EDITMSG一个文本文件缓存了你刚才填写的提交信息。logs/操作日志记录 HEAD 指针的移动轨迹。我们这里可以再看看objects里多出的内容之前add时里面只有一个c1开头的对象也就是 ReadMe 的内容。现在多了两个03/5ed82e...树对象 Tree它代表当前项目的目录结构里面记录着ReadMe的索引指向c1。21/2a245a...提交对象 Commit它记录了是谁、在什么时候提交的提交信息是什么并且指向了上面的 Tree 对象。我们可以用cat-file查看一下这个 Tree 对象和 Commit 对象里到底写了啥$gitcat-file-p035ed82ed5da9274ba3bb5453a0df906e543505e100644blob c15317abfb6c6264a5ec209b86200d460e433fb5 ReadMe $gitcat-file-p212a245a33305fef508aaf0406b7647113171417 tree 035ed82ed5da9274ba3bb5453a0df906e543505e author xcxxxxxxxqq.com17932606400800 committer xcxxxxxxxqq.com17932606400800addfirstfile看看refs/heads/master里写了什么这个文件现在有内容了。如果你用cat查看它$cat.git/refs/heads/master 212a245a33305fef508aaf0406b7647113171417你会发现里面存的正是刚才那个 Commit 对象的完整哈希值这证明了一个核心事实分支并不是一份代码的拷贝而只是一个保存了某个 Commit ID 的普通文本文件。那我们怎么知道当前在哪个分支上呢这就得看HEAD了$cat.git/HEAD ref: refs/heads/masterHEAD里存的并不是哈希值而是一个指向分支的引用。这就形成了一条清晰的引用链HEAD指向分支 → 分支指向最新的Commit→Commit指向Tree→Tree指向各个文件的Blob。这条链上的每一环都只是存了一个哈希值真正的数据全部躺在objects库里。这也解释了为什么 Git 切换分支、回退版本都如此之快——它只需要改写几个几十字节的小文件而不需要搬运任何代码内容。总结一下git commit做了什么Git 根据暂存区生成一个树对象Tree存入objects。生成一个提交对象Commit记录提交信息并指向那棵树存入objects。将当前分支的指针refs/heads/master更新为这个新 Commit 的 ID。在logs目录里留下你这次操作的记录。1.3 进阶认知Git 跟踪的是“修改”而非“文件”为什么我们可以多次add不同的文件然后只commit一次因为需要提交的文件通通被add到了暂存区中commit是一次性提交暂存区的所有修改。假设你在工作区新建了file1和file2你只add file1然后commit那么file2依然留在工作区不会被提交。$touchfile1 file2 $gitaddfile1 $gitcommit-madd file1[master 6b64713]addfile11filechanged,0insertions(),0deletions(-)create mode100644file1 $gitstatus# On branch master# Untracked files:# (use git add file... to include in what will be committed)## file2nothing added to commit but untracked files present(usegit addto track)代码原理解释看最后git status的输出重点在Untracked files未跟踪的文件这一项下面赫然写着file2。这证明了file2只是在工作区被创建了它既没有被add进暂存区也没有被commit进版本库。Git 现在的态度是“我知道有这个文件但我不管它。”同时注意看git commit那行的输出1 file changed, 0 insertions(), 0 deletions(-)。因为我们使用了touch命令只创建了空文件没有写内容所以插入和删除的行数都是 0。如果你想把file2也纳入版本控制只需要再次执行git add file2然后再commit一次即可。2 查看状态与差异对比在日常开发中我们经常需要知道当前工作区是什么状态或者到底改了哪些代码。这就轮到git status和git diff大显身手了。2.1 查看当前状态git statusgit status是最常用的命令之一它会告诉你当前工作区和暂存区的状态比如哪些文件被修改了哪些文件已经add等待提交了。gitstatus如果工作区是干净的输出会提示nothing to commit, working tree clean如果有修改输出会列出具体状态。为了更直观地分辨建议执行git config --global color.ui true开启颜色显示。开启后Git 会用红色列出未暂存的更改用绿色列出已暂存等待提交的更改。2.2 查看具体差异git diffgit status只告诉你哪些文件变了git diff则能告诉你具体哪里变了具体到哪一行加了哪一行删了# 查看工作区与暂存区的差异还没 add 的内容gitdiff# 查看指定文件的差异gitdifffile1.txt# 查看工作区与当前版本库最新版本HEAD的差异gitdiffHEAD -- file1.txt$gitdiffdiff--gita/ReadMe b/ReadMe index b768e02..7ac97d4100644--- a/ReadMe b/ReadMe -1,2 1,3 hellogithello world hello hello逐行拆解diff --git a/ReadMe b/ReadMe告诉你当前对比的是哪个文件。a/代表修改前的版本b/代表修改后的版本。index b768e02..7ac97d4 100644暂存区/版本库中该文件的哈希值变化b768e02变为了7ac97d4以及文件权限100644 代表普通文件。--- a/ReadMe与 b/ReadMe分别代表旧文件和新文件的标记。 -1,2 1,3 这是极其重要的定位符Hunk Header。-1,2表示旧文件从第 1 行开始共显示 2 行1,3表示新文件从第 1 行开始共显示 3 行。最后是具体的代码变动以空格开头的行如hello git表示没有任何变化的上下文代码。以开头的行如hello hello表示新增的内容。(如果是以-开头则表示被删除的内容。)2.3 丢弃工作区的修改git checkout -- [file]通过git status和git diff如果你发现工作区的代码改乱了并且还没有执行git add你想直接放弃这些修改让文件回到上一次commit时的状态可以使用gitcheckout -- ReadMe但是对于上面的指令--绝对不可以省略git checkout -- ReadMe意思是“把工作区ReadMe文件的修改丢弃掉从版本库中重新拉取最新的覆盖它”。git checkout ReadMe省略了--Git 会以为你想切换到名为ReadMe的分支如果正好没有这个分支会直接报错如果运气不好你刚好有一个同名分支那就会直接切换分支后果不堪设想。3 查看历史与版本回退在日常开发中我们经常会遇到这样的问题“我之前改过什么”、“上周那个稳定的版本长什么样”或者最让人崩溃的“我代码写错了怎么退回昨天的版本”。这一节我们将学习如何查看历史提交记录并掌握 Git 最强大的“时光机”功能——版本回退。3.1 查看提交历史git log想要查看过去都有哪些提交使用git log命令。它会按时间顺序从最近到最远显示所有的提交记录$gitlog commit 15ca2044350b1a1ab1829f378cb5b20f0876dd39 Author: xxxxxxxxxqq.comDate: Thu Oct2918:48:3720260800addfile1 file2, modify ReadMe commit 212a245a33305fef508aaf0406b7647113171417 Author: xxxxxxxxxqq.comDate: Thu Oct2915:57:2020260800addfirstfile输出的信息非常丰富包含commit id即那一长串哈希值这是每个版本的唯一编号。Author / Date作者和提交时间。提交信息即-m后面写的内容。如果提交历史非常长满屏的英文看起来会让人眼花缭乱。我们可以在后面加上--prettyoneline参数让每条记录只显示一行非常清爽$gitlog--prettyoneline 15ca2044350b1a1ab1829f378cb5b20f0876dd39addfile1 file2, modify ReadMe 212a245a33305fef508aaf0406b7647113171417addfirstfile3.2 时光机深入理解 git reset 的三种模式在 Git 中用HEAD表示当前版本最新提交上一个版本就是HEAD^上上一个版本就是HEAD^^如果往上 100 个版本写成HEAD~100。回退版本的核心命令是git reset它的完整语法是gitreset[--soft|--mixed|--hard][HEAD]git reset的本质是移动当前分支如 master的指针让它指向过去某个特定的 Commit。但是根据你后面跟的参数不同它对暂存区Index和工作区Working Directory的处理方式完全不同--soft软重置只动指针动作仅仅把HEAD指针移动到目标版本。影响暂存区和工作区完全不动。结果你回退版本后之前提交的内容全都会保留在暂存区里。就好像你刚刚做完了git add正准备重新commit一样。--mixed混合重置Git 的默认模式使用时可以不用带该参数动指针 清空暂存区动作移动HEAD指针同时清空暂存区撤销 git add。影响工作区保留修改。结果你回退版本后之前提交的内容依然在你的工作区代码里但状态变成了“已修改未暂存”红色的状态你需要重新git add和git commit。--hard硬重置最危险也是最常用的全盘重置动作移动HEAD指针同时重置暂存区和工作区。影响工作区所有未提交的修改和暂存区的内容全部被无情抹除。结果你的代码瞬间回到了目标版本的样子。那些没提交的代码彻底灰飞烟灭很难找回。模式移动指针 (HEAD)重置暂存区 (Index)重置工作区 (Working Tree)常用场景–soft✅❌❌提交信息写错了想撤回重新提交。–mixed✅✅❌想把已经 add 的代码撤回到工作区。–hard✅✅✅彻底放弃当前修改回到某个历史版本。3.3 依据场景进行版本回退掌握了上面的三种模式我们就可以应对各种真实场景了场景一彻底不要现在的代码了直接回退比如你写了一堆乱七八糟的代码想直接回到上一个干净的版本gitreset--hardHEAD^场景二提交信息写错了或者漏提交了文件你想撤销上一次commit修改信息或补充文件然后重新提交# 撤销上一次 commit但保留代码在暂存区gitreset--softHEAD^# 修改或补充后重新提交gitcommit-m新的提交信息场景三想回退到指定的历史版本拿着git log --prettyoneline查到的commit id比如212a245gitreset--hard212a245为什么 Git 回退这么快结合我们前面讲的底层原理Git 的版本回退本质上仅仅是移动HEAD和当前分支的指针修改refs/heads/master里的哈希值并不需要像传统软件那样去复制或删除大量文件所以速度是“秒回”级别的。3.4 后悔药找不到了 commit id 怎么办git reflog想象一个极其经典的翻车场景你一顿操作猛如虎用git reset --hard HEAD^退回了一个版本。结果第二天你突然发现昨天退回去的那个版本里有个重要功能你想再回到“未来”但你打开git log发现那个未来的版本因为指针退后已经不在日志里显示了怎么办别慌只要你的代码曾经提交过Git 就默默帮你记着。使用git reflog命令$gitlog--prettyoneline 15ca2044350b1a1ab1829f378cb5b20f0876dd39addfile1 file2, modify ReadMe 212a245a33305fef508aaf0406b7647113171417addfirstfile$gitreflog 15ca204 HEAD{0}: reset: moving to HEAD^ dfb1be1 HEAD{1}: commit: modify ReadMe 15ca204 HEAD{2}: commit:addfile1 file2, modify ReadMe 212a245 HEAD{3}: commit(initial):addfirstfilereflog记录了 HEAD 指针的每一次移动。你可以清楚地看到你刚才执行过reset操作并且能看到那个“丢失”的commit id比如dfb1be1。拿到 id 后再次执行git reset --hard dfb1be1你就能穿越回未来了$gitreset--harddfb1be1 HEAD is now at dfb1be1 modify ReadMe $gitlog--prettyoneline dfb1be17813a4d9e9a06ff0d0fa26899e1a429bf modify ReadMe 15ca2044350b1a1ab1829f378cb5b20f0876dd39addfile1 file2, modify ReadMe 212a245a33305fef508aaf0406b7647113171417addfirstfile总结git log是给你看历史版本的而git reflog是给你看“你所有的操作轨迹”的。找不到 commit id 时找reflog准没错。4 删除文件与撤销修改在开发过程中除了新增和修改代码我们还会经常遇到“删错文件”或者“想丢弃修改”的情况。这一节我们将结合前面讲的三大核心区域把删除和撤销的底层逻辑彻底理清。4.1 删除文件git rm如果你在工作区直接按Delete键或者使用rm命令删除了文件此时 Git 并不知道你是有意删除还是误删。查看git status会提示该文件处于deleted状态。$rmfile1 $gitstatus# On branch master# Changes not staged for commit:# (use git add/rm file... to update what will be committed)# (use git checkout -- file... to discard changes in working directory)## deleted: file1#no changes added to commit(usegit addand/orgit commit -a)要真正将删除操作记录到版本库中有两种方式方式一手动两步走# 1. 先在工作区删除文件rmfile1# 2. 将删除操作添加到暂存区gitaddfile1# 或者使用 git rm file1# 3. 提交gitcommit-m删除file1文件但是这个方式在老版本的 Git 中会有坑如果你用的 Git 版本较老2.0 之前你使用git add file1会看到一个长长的警告$gitaddfile1 warning: You rangit addwith neither-A (--all)or--ignore-removal, whose behaviour will changeinGit2.0with respect to paths you removed. Paths likefile1that are removed from your working tree are ignored with this version of Git. *git add --ignore-removal pathspec,whichis the current default, ignores paths you removed from your working tree. *git add --all pathspecwillletyou also record the removals. Rungit statusto check the paths you removed from your working tree.这个警告的意思是老版本 Git 为了兼容性默认忽略了你的删除操作。此时直接commit你会发现终端提示no changes added to commit文件根本没被删掉。正确做法是使用-A或--all参数告诉 Git 把删除操作也记录到暂存区$gitadd-Afile1 $gitcommit-mdelete file1[master b3b2c9a]delete file11filechanged,0insertions(),0deletions(-)delete mode100644file1方式二使用git rm一步到位最推荐为了避免上述老版本的兼容性问题也为了少敲几次命令Git 提供了专门的删除命令gitrmfile1这条命令相当于做了两件事一是把工作区的文件物理删除二是把这次删除操作直接放入了暂存区。执行完后直接git commit即可绝对不会遇到警告。5 小结本篇我们按照真实的开发工作流不仅梳理了 Git 的日常高频命令还深入底层揭开了暂存区和提交对象的神秘面纱。回顾一下我们主要掌握了以下核心内容核心工作流与底层原理使用git add将修改放入暂存区使用git commit提交。使用git ls-files --stage查看了.git/index暂存区的内容并彻底明白了add会生成 Blob 对象commit会生成 Tree 对象和 Commit 对象并最终移动分支指针。状态查看与差异对比学会用git status查看全局状态用git diff查看具体代码差异懂得了如何阅读和-的输出并掌握了用git checkout -- [file]丢弃工作区修改。时光机与后悔药吃透了git log查看历史深入理解了git reset的--soft、--mixed和--hard三种模式的巨大区别并掌握了用git reflog找回“丢失”的版本。撤销与删除理解了“删除也是一种修改”学会了使用git rm规范地删除文件并针对未add、已add未commit、已commit三种场景制定了对应的撤销方案。现在你不仅能够游刃有余地在本地仓库里进行代码管理甚至还能透过现象看到 Git 底层“对象与指针”的本质。当你再次面对一长串哈希值时相信你已经不再觉得那是魔法了。不过目前我们所有的操作都还是“单线程”的。在真实的团队协作中如果你正在开发一个耗时两周的新功能而线上突然出现了一个紧急 Bug你总不能把写到一半的代码直接提交上去吧这时候我们就需要用到 Git 最核心的杀手锏——分支管理。下一篇我们将学习如何在不影响主线代码的情况下优雅地开辟“平行宇宙”。我们下期见