速成课 · No. 17
Git 记录你项目的每一个版本,让整个团队改动同一批文件而不会互相覆盖。围绕它的恐惧几乎全部来自陌生的词——commit、branch、rebase——而不是真正的难度。学会那寥寥几个术语,Git 就从一个让人发怵的东西,变成你拥有过的最让人安心的安全网。
只讲精髓 · 每个想法一个画面 · 掌握术语
在命令之前,先看问题。一旦你感受到版本控制消除的那种痛,Git 的每一个特性读起来就都是显而易见的答案,而不是莫名其妙的术语。
塞满 _final_v2_FINAL 文件的文件夹
一张桌子被 report.doc、report_v2.doc、report_final.doc、report_final_FINAL.doc 淹没——没人确定哪个是最新的,而周二那个好版本永远丢了。
人人都经历过手动留版本的方式:复制文件,加个后缀,碰运气。它瞬间就崩了——你分不清哪个最新,找不回被覆盖的版本,两个人一起编辑就意味着其中一个的工作会丢。版本控制正是为终结这一切而生的工具:一个项目,它的全部历史被安全保存,每一个版本都可以找回。
Git 维护一条快照时间线
一台相机,每次你说「保存这个」就把你整个项目拍下来——于是你可以翻回去,看到过去任意时刻一切的模样。
Git 是几乎人人都在用的版本控制系统。它把你的项目记录成一连串 snapshot——每一个都是你选择保存的那一刻所有文件的完整画面。结果是一条你可以穿行的时间线:看到什么变了、何时变的、由谁改的,并回到任意过去的状态。它是一个永远不会忘记任何一次保存的保存按钮。
repository 是项目加上它的全部历史
一本书,把它曾经的每一份草稿都装订在后面——你手里同时握着当前的正文和它一路走来的完整记录。
repository(或 repo)就是被 Git 跟踪之后的项目文件夹:当前的文件,外加 每一次拍过的 snapshot 那段隐藏的完整历史。clone 一个 repo,你拿到的不只是今天的代码,而是整个故事。这就是你工作的单元——repo 就是「Git 记住的一个项目」,本课其余的一切都是你在它里面做的事。
版本控制用一个项目和一段完整、可恢复的历史,取代了那一堆 _final_FINAL 副本。Git 把这段历史保存为一条 snapshot 时间线。
Git 里最重要的一个词就是 commit——时间线上一个保存下来的 snapshot。理解一个 commit 是怎么做出来的,你就理解了整个系统的心跳。
一个 commit 是一个保存下来的 snapshot
在某个选定的瞬间给你整个项目拍一张照,连同一张注明这是什么时刻的便签一起归档——永久钉在时间线上。
一个 commit 是你项目历史中的一个 snapshot——文件的一份记录状态,带着唯一的 ID 和一条便签。每个 commit 都指回它前面那个,构成一条链,那就是你的历史。你不会不停地 commit;你在有意义的节点 commit——「加了登录」「修了日期 bug」——这样时间线读起来就是一连串经过深思的步骤,每一步都是你可以回到的地方。
staging area:封箱之前先装好箱
桌上一个发货纸箱:你把这一批要发的东西恰好放进去,其余的搁在一边,然后才把它封上、寄出。
Git 不会自动把你改过的所有东西都拍进快照。你先通过 staging 来挑选哪些改动进入下一个 commit——把它们加入 staging area,也就是你正在打包的那个箱子。然后 commit 只把那个箱子封上。这两步——先把该放一起的东西 stage 起来,再 commit——正是 Git 历史能保持干净的原因:是你来决定每个 snapshot 装什么,而不是一股脑把所有改动全倒进去。
message 是写给未来的便签
给那个封好的箱子贴上标签,写明里面是什么——这样几个月后,有人扫一眼货架就知道每个箱子装着什么,不用打开。
每个 commit 都带着一条 commit message,说明改了什么、为什么改。它当下感觉像杂活,日后却是纯金:当你在追那个把东西弄坏的改动,或在弄懂某一行为什么存在时,这些 message 就是项目的故事。一条好的 message(「修复发票导出里的时区 bug」)把历史从一堵晦涩 ID 的墙,变成一份可读的 log。为半年后读到它的人而写——那人通常就是你。
HEAD 是你的「你在这里」标记
时间线地图上那个「你在这里」的小点——它标出你此刻正站在哪一个 snapshot 上。
HEAD 是 Git 指向你当前在历史中所处位置的指针——通常是你当前这条工作线上最新的那个 commit。当你做一个新 commit 时,它被加在 HEAD 紧后面,HEAD 也随之前移到它。你很少直接动 HEAD,但这个词到处都是,而知道它无非就是「我在时间线上的当前位置」,就能让你读到的大部分内容不再神秘。
一个 commit 是一个保存下来的 snapshot。你把该放一起的那些改动恰好 stage 起来,再用一条 message 把它们封上——而 HEAD 标出你站在哪里。
branch 是 Git 从一个花哨的保存按钮,变成一种让许多人——或许多想法——同时推进而互不踩脚的方式的地方。
一个 branch 是一条平行的工作线
把手稿复印一份,这样你可以在自己这份上大胆地涂改重写,而原稿保持干净,别人也继续在上面工作。
一个 branch 是一条独立的开发线——你自己的那份时间线副本,你可以在上面做 commit 而不影响别人的工作。你开一个 branch 来做某个功能或试某个想法,在上面随意 commit,而主版本保持原样,直到你准备好。branch 正是一个团队能在一个 repo 里同时做十件事而不乱套的方式:每个人都在自己的线上。
main 是那条受信任的线
书的那个正式、上架的版本,人人都信赖它——草稿在别处发生,只有完成、获批的工作才会被装订进去。
按惯例,主 branch 叫 main(在更老的 repo 里你还会见到 master)。它意在保存项目稳定、可用的版本——你会拿去发布的那个。新工作发生在其他 branch 上,只在准备好并经过评审后才汇入 main。把 main 当作受信任的线、而绝不当草稿纸,正是大多数健康团队工作流背后那条朴素的纪律。
切换 branch 会改变你的文件
换一下桌上摊开的是哪一份草稿——还是同一张桌子,但现在瞬间显示的是这份工作的一个完全不同的版本。
当你 switch(或 check out)到一个 branch 时,Git 会把你文件夹里的文件改成与那个 branch 的 snapshot 一致。切到你的功能 branch,你的改动就出现;切回 main,它们就从眼前消失(被安全存着,没丢)。在 Git 里 branch 既便宜又即时,所以每个功能或修复都开一个 branch 是常态、是被鼓励的——不是一桩重活,而是日常的一步。
一个 branch 是一条平行的工作线,你在上面随意 commit 而不打扰任何人。main 是受信任的版本;新工作待在它自己的 branch 上,直到它准备好。
分到各个 branch 上的工作,终究得重新合到一起。merge 是 Git 把这些线重新接上的方式——而 conflict 是它唯一需要你来定夺的那一刻。
merge 把两个 branch 重新合到一起
把你那份单独草稿上的改动折回正式手稿里,于是这本书现在既包含原文,也包含你的新章节。
merge 把一个 branch 上的 commit 合并进另一个——通常是把一个完成的功能 branch 折回 main。Git 看每一边改了什么,把它们织进一条同时包含两边工作的时间线。多数情况下这是自动且毫不费力的:两个 branch 碰的是不同的文件或不同的部分,Git 直接把它们缝起来。
conflict 是对同一行的两处编辑
两位编辑各自把同一句话以不同方式重写了——没有机器能知道你指的是哪一个,于是它停下来问你。
一个 merge conflict 发生在两个 branch 都以互不相容的方式改了同一个文件的同一部分时。Git 没法猜哪个版本是对的,于是它暂停下来,把那个位置标出来,把两个版本并排展示。这不是错误,也不是灾难——这是 Git 正确地拒绝悄悄丢掉某人的工作。conflict 第一次让人发怵,恰恰是因为那里的利害(别丢工作)很清楚。
解决一个 conflict 不过是做选择
读一读两句重写后的句子,然后写下你真正想要的最终版本——留一句、留另一句、或两者揉一起——然后继续往前。
你通过把被标记的文件编辑成你想要的版本来 resolve 一个 conflict——留你的、留对方的、或把两者合起来——再把结果 commit。整个谜团就这么点:一个 conflict 是 Git 把一个只有人才能做的决定交到你手上,而解决它就是做那个决定。当 branch 短命、你又经常 merge 时,conflict 就更小、更少,因为两边永远不会漂得太远。
merge 把两个 branch 织进一条时间线。一个 conflict 不过是对同一行的两处编辑——Git 拒绝去猜,转而请你来选。
到目前为止一切都在你机器上。与他人协作——或为你的工作备份——意味着把你的 local repo 连到云端一个共享的 repo 上。
local 和 remote 是两份副本
你家里那份个人的手稿工作副本,和办公室里那份共享的主副本,整个团队都和它保持同步。
Git 是 distributed 的:你在自己机器上有一份完整的 repo 副本(local),而服务器上通常还有一份共享副本(remote)——放在 GitHub 或 GitLab 这类服务上。你所有工作都在 local 做——快,甚至离线也行——再和 remote 同步以分享它、并拉入别人的工作。默认的 remote 按惯例叫 origin。两份副本,有意地保持同步。
clone、push、pull、fetch
把办公室的主副本复印一份带回家(clone),把你写完的稿页寄回去(push),再把别人所有的新稿页收回来(pull)。
四个词在 local 和 remote 之间搬动工作。clone 给一个 remote repo 做出你的第一份 local 副本。push 把你的 commit 送上 remote,好让别人看到它们。pull 把别人新的 commit 取下来进你的副本。fetch 下载新东西但还不 merge 它,好让你先看一眼。掌握这四个,你就能协作——它们就是保持同步的全部词汇。
pull request 是团队评审工作的方式
把你的新章节交给编辑们,他们读它、评论、批准,然后它才被装订进那本正式的书。
在 GitHub 这类平台上,你通常不会直接 merge 进 main。你 push 你的 branch,然后开一个 pull request(PR)——一份合并你工作的提议,队友在这里 review 它、留下评论、批准,之后它才汇入 main。这是那个检查点:第二双眼睛在这里抓出问题,团队在这里一致认定一处改动已经就绪。现实世界里大多数真正的 Git 协作,其实都发生在 PR 里。
local 是你的副本;remote(origin)是那份共享的。clone、push、pull、fetch 让它们保持同步——而 pull request 是团队在 merge 之前评审的地方。
Git 最深的安慰,是几乎没有东西会真的丢掉。懂得如何读 history、如何安全地往回退一步,正是把 Git 从吓人变成让人安心的关键。
log 是项目的故事
翻看那一摞标了日期、贴了标签的每一份草稿——精确地看到每一步改了什么、由谁做的。
log 是 commit 的列表——你项目按顺序排列的历史,每一条都带着它的 message、作者和时间。它回答「发生了什么、何时发生的?」,是理解任何代码库、追查任何 bug 的起点。当某处坏了,log 让你找到引入它的那个确切 commit,因为每一处改动都被记录下来了。能读的历史,就是能调试的历史。
安全地撤销:revert 还是 reset
撤销一个错误的两种方式:在记录上加一条注明清楚的更正(revert),或悄悄把最后一页撕掉、当它从没发生过(reset)。
Git 给你安全的回退方式。revert 创建一个新 commit 来撤销前一个——错误留在历史里,连同它的撤销一起记录在上面,这对共享工作来说既诚实又安全。reset 把你退回到更早的一个 commit,重写近期历史,仿佛那些 commit 从没存在过——很强大,但对别人已经拿到的工作很危险。经验法则是:公开的就 revert,reset 只用在私有的、local 的工作上。
rebase 重放你的工作;尊重那条黄金法则
与其把你的旁支草稿钉到手稿上、留下一道看得见的接缝(merge),不如把你的改动干干净净地重新誊到最新版本之上,仿佛你本来就是从它开始的(rebase)。
rebase 是 merge 的另一种选择:它不用一个 merge commit 把两条线接起来,而是把你的 commit 重放到另一个 branch 之上,做出一条整洁、笔直的历史。它很适合在分享之前清理你自己的 branch。但它会重写 commit,所以那条 黄金法则 是:绝不要 rebase 别人已经 pull 过的历史。重写共享历史,你会让所有人失同步。私有的工作就 rebase;要合并共享的工作就用 merge。
log 是那个可读的故事;revert 和 reset 是你的回退方式。rebase 做出整洁的历史——但绝不要重写别人已经 pull 过的历史。
Git 的命令很多,但好的实践是一份很短的习惯清单。照着做,Git 就一直是一张让你安心的安全网,而不是偶尔的紧急情况。
小而专注、带清晰 message 的 commit
一本相册,每张照片都用一句说明捕捉一个清晰的瞬间——远比一张把所有东西一次拍下来的模糊照片有用得多。
要 小而频繁 地 commit,每个 commit 是一处连贯的改动,配一条解释它的 message。细小、贴好标签的 commit 让历史可读、让 bug 易追、让撤销精准——你可以 revert 掉一处精确的改动而不丢其余。一个把一周混杂工作全倒进去的巨型 commit 则正相反:没法评审、没法理清、没法部分撤销。这份纪律花的是几秒钟,回报却贯穿项目的整个生命。
每个功能一个 branch,经常 merge
每个新章节都在自己那份副本上起草,并趁它还小就折回去——这样没有哪份草稿会从书上漂得太远,以至于把它重新接上变成一场恶仗。
每一件工作都在它 自己的 branch 上做,让它短命,并经常把它 merge 回去。长命的 branch 会从 main 漂得很远,把 merge 变成一堆痛苦的 conflict;小 branch 经常 merge 就一直好办。再配上 push 之前先 pull——先把别人的改动拉进来——这样你就持续地集成,而不是在最后撞车。这个节奏正是顺畅团队 Git 的核心。
- 先 pull 最新的,再开始,这样你是在当前工作之上构建。- 在 branch 上工作,绝不直接在脆弱的 main 上。- 一边做一边做 小 commit,配 清晰的 message。- 趁 branch 还短就 经常 merge,把 conflict 保持得很小。- push 你的工作,并开一个 pull request 供评审。- 共享工作用 revert 撤销;绝不 rebase 别人已有的历史。
- repository / commit / message——被跟踪的项目、一个 snapshot、以及它的便签。- staging area / HEAD——你 commit 前打包的那个箱子,以及你的「你在这里」标记。- branch / main——一条平行的工作线,以及那个受信任的版本。- merge / conflict——把 branch 重新接上,以及你要解决的同一行冲突。- local / remote / origin——你的副本、那份共享副本、以及它的默认名字。- clone / push / pull / fetch——让各份副本保持同步的四个动作。- pull request / revert / reset / rebase——评审、安全撤销、以及重写历史。
- 你的 历史读起来 像一个由小步、带说明的步骤组成的清晰故事。- 新工作待在 branch 上;main 始终可用。- 你 push 之前先 pull,并趁 branch 还小就 merge。- 共享工作你伸手去拿 revert,从不重写共享的 历史。- Git 感觉像一张 安全网——你放手实验,知道自己总能退回来。
好的 Git 是几个习惯:先 pull,给你的工作开 branch,小而带清晰 message 地 commit,经常 merge,merge 之前先评审。命令很多;纪律很短。