跳到主要内容
预计阅读 33 分钟

效率翻倍——Git高级技巧

大部分开发者只用了 Git 能力的冰山一角。addcommitpushpullmerge——就这五板斧打天下。但当你面对”手头有改动但要临时切分支”、“不知道哪次提交引入了 bug”、“只想克隆仓库的一个子目录”这类场景时,五板斧就不够用了。这一章我们来认识一批”冷门但高效”的 Git 工具,每一个都能在特定场景下帮你省下大量时间。

📋 开篇自测:你已经知道多少?

  1. git stash 除了 pushpop,还有哪些子命令?能否只暂存指定的几个文件?
  2. 如何在不切换分支的前提下,同时在两个分支上工作?
  3. 当项目有 1000 次提交,某次引入了 bug,如何用最少的测试次数定位到那次提交?
  4. git submodulegit subtree 的本质区别是什么?
  5. 一个仓库有 50GB,但你只需要其中一个目录的代码,怎么操作?

一、git stash深入——随身口袋

日常开发中最常见的烦恼之一:你正在 feature 分支上写代码写到一半,突然需要切到 main 分支去修一个线上问题。但当前的修改还没写完,提交一个半成品不合适,丢掉又舍不得。

git stash 就是你的随身口袋——把手头的半成品往里一塞,工作区瞬间变干净,等忙完了再掏出来继续。

基本操作流程

# 把当前所有已跟踪文件的修改(包括暂存区和工作区)塞进口袋
$ git stash push -m "登录功能写了一半"
Saved working directory and index state On feature: 登录功能写了一半

# 查看口袋里都有什么
$ git stash list
stash@{0}: On feature: 登录功能写了一半
stash@{1}: On main: 修复样式问题的临时改动

# 把最新一条取出来并从口袋中移除
$ git stash pop

# 把最新一条取出来但保留在口袋中(可以反复取用)
$ git stash apply

# 只取出口袋中的第二条
$ git stash apply stash@{1}

# 丢弃口袋中的某一条
$ git stash drop stash@{1}

# 清空整个口袋
$ git stash clear

只暂存部分文件

有时你不想把所有改动都塞进口袋,只想暂存其中几个文件。Git 2.13+ 支持指定路径:

# 只暂存 src/auth.py 和 src/config.py 的改动
$ git stash push -m "认证模块改动" src/auth.py src/config.py

# 交互式选择要暂存的代码块(hunk级别的精细控制)
$ git stash push -p

-p(patch)模式会逐个展示每一块改动,让你选择 y(暂存)、n(跳过)或 s(拆分成更小的块)。

暂存未跟踪文件和被忽略文件

默认情况下,git stash 只处理已跟踪文件的改动。新创建但未 git add 过的文件不会被 stash。

# 连同未跟踪的新文件一起暂存
$ git stash push -u -m "包含新文件"

# 连同 .gitignore 中忽略的文件也一起暂存(比如本地配置文件)
$ git stash push -a -m "包含所有文件"

从stash创建分支

如果暂存了很久的改动取出来时发现有冲突(因为原分支已经变化很多),可以直接从 stash 创建一个新分支:

# 基于stash创建新分支,自动pop出改动
$ git stash branch fix-from-stash stash@{0}

⚠️ 常见误区 误区:stash 是跟分支绑定的。 虽然 git stash list 会显示”On feature: …”,但这只是记录了创建 stash 时所在的分支名。你完全可以在 main 分支上 stash,然后在 develop 分支上 pop 出来。stash 是全局的,不属于任何分支。


二、git worktree——分身术

git stash 能让你临时放下手头的活去处理别的事,但有一个局限——你一次只能在一个工作目录中操作一个分支。如果你想同时在两个分支上工作呢?比如一边跑着 feature 分支的开发服务器,一边在 bugfix 分支上修 bug?

git worktree(Git 2.5+ 引入)允许你为同一个仓库创建多个工作目录,每个目录检出不同的分支。它们共享同一个 .git 对象数据库,所以不会像 git clone 那样浪费磁盘空间。

把它想象成分身术——一个灵魂(.git 对象数据库)同时驾驭多个肉身(工作目录),每个肉身可以独立行动。

# 当前在 /project 目录,正在 feature 分支上开发
$ git branch
* feature
  main
  hotfix-123

# 创建一个新的工作目录来处理 hotfix-123 分支
$ git worktree add ../project-hotfix hotfix-123
Preparing worktree (checking out 'hotfix-123')
HEAD is now at a1b2c3d Fix critical issue

# 现在你有两个独立的工作目录:
# /project          → feature 分支(你的主战场)
# /project-hotfix   → hotfix-123 分支(修 bug 的战场)

# 查看所有工作目录
$ git worktree list
/project            a1b2c3d [feature]
/project-hotfix     e4f5a6b [hotfix-123]

# 在 hotfix 目录中独立工作
$ cd ../project-hotfix
$ vim src/critical_fix.py
$ git add . && git commit -m "修复关键安全漏洞"

# 修完 bug 后,删除额外的工作目录
$ cd /project
$ git worktree remove ../project-hotfix

worktree 的关键规则

  1. 每个分支只能在一个 worktree 中检出——你不能在两个 worktree 里同时检出 main 分支
  2. 额外的 worktree 里没有完整的 .git 目录,只有一个 .git 文件指向主仓库
  3. 所有 worktree 共享 objects、refs、config 等数据——在一个 worktree 中创建的 commit 在另一个中立即可见

🤔 想一想 git worktreegit clone 都能让你同时在两个目录中工作。它们的本质区别是什么?在磁盘空间、引用共享、推送同步等方面分别有什么差异?


三、git bisect——二分法捉虫

你的项目有一千次提交。上周五还好好的功能,这周一就出 bug 了。周末有二十个同事提交了 50 次代码,到底是哪次引入的 bug?一个个 checkout 来测试要测到什么时候?

git bisect 使用二分查找算法来帮你定位问题。1000 次提交,最多只需要测试约 10 次(log₂1000 ≈ 10),就能精确找到引入 bug 的那一次提交。

手动二分

# 第一步:开始 bisect 会话
$ git bisect start

# 第二步:标记当前提交为"有问题的"
$ git bisect bad

# 第三步:标记一个已知正常的历史提交
$ git bisect good v1.2.0
Bisecting: 500 revisions left to test after this (roughly 9 steps)
[commit-hash] Some commit message

# Git 自动 checkout 到中间位置的提交
# 你测试一下当前版本是否有 bug

# 如果当前版本没问题:
$ git bisect good
Bisecting: 250 revisions left to test after this (roughly 8 steps)

# 如果当前版本有问题:
$ git bisect bad
Bisecting: 125 revisions left to test after this (roughly 7 steps)

# 不断重复,直到 Git 找到第一个有问题的提交
# ...
abc1234 is the first bad commit
commit abc1234
Author: 某同事 <someone@example.com>
Date:   Sat Oct 14 15:30:00 2025 +0800

    重构查询模块

# 结束 bisect,回到原来的分支
$ git bisect reset

自动化 bisect

如果你有一个测试脚本能自动判断当前版本是否有 bug(返回0表示正常,返回非0表示有问题),那整个过程可以完全自动化:

# 编写测试脚本 test_bug.sh
#!/bin/bash
python -m pytest tests/test_query.py -q
# pytest 通过返回0,失败返回非0

# 一键自动完成整个二分查找
$ git bisect start HEAD v1.2.0
$ git bisect run ./test_bug.sh

# Git 会自动在 good/bad 之间跳转,运行测试脚本,最终报告结果
# 全程无需人工干预
running ./test_bug.sh
...
abc1234 is the first bad commit

$ git bisect reset

这在持续集成中特别有用。当测试套件突然失败时,一行 bisect run 命令就能自动定位到罪魁祸首。


四、git submodule vs git subtree——外部依赖管理

当你的项目需要引用另一个独立维护的仓库(比如共享的组件库、协议定义等),有两种主要方案。

git submodule——外链引用

submodule 的方式是在你的仓库中记录一个指向外部仓库特定 commit 的指针。就像在文章中插入一个超链接——内容不在你这里,你只是指向了一个外部地址。

# 添加一个子模块
$ git submodule add https://github.com/shared/component-lib.git libs/component
Cloning into 'libs/component'...

# 这会产生两个变化:
# 1. 创建 .gitmodules 文件(记录子模块的 URL 和路径)
# 2. 在 libs/component 中检出外部仓库的代码

$ cat .gitmodules
[submodule "libs/component"]
    path = libs/component
    url = https://github.com/shared/component-lib.git
# 克隆包含子模块的仓库时,需要额外初始化
$ git clone https://github.com/myteam/project.git
$ cd project
$ git submodule init
$ git submodule update

# 或者一步到位
$ git clone --recurse-submodules https://github.com/myteam/project.git
# 更新子模块到远程最新版本
$ cd libs/component
$ git pull origin main
$ cd ../..
$ git add libs/component
$ git commit -m "更新 component-lib 到最新版本"

git subtree——内嵌合并

subtree 的方式是把外部仓库的代码直接复制到你的仓库中,成为你仓库的一部分。就像把参考文献的全文直接附在论文后面——所有内容都在你这里,不依赖外部链接。

# 添加远程仓库引用
$ git remote add component-lib https://github.com/shared/component-lib.git

# 把外部仓库的 main 分支合并到本地的 libs/component 目录
$ git subtree add --prefix=libs/component component-lib main --squash
# --squash 会把外部仓库的所有历史压缩成一个提交

# 后续拉取外部仓库的更新
$ git subtree pull --prefix=libs/component component-lib main --squash

# 如果你修改了本地的 libs/component 代码,还可以推回去
$ git subtree push --prefix=libs/component component-lib main

对比与选择

对比维度submodulesubtree
代码存放独立仓库,通过指针引用直接嵌入到主仓库
克隆体验需要 --recurse-submodules普通 clone 即可获取全部代码
版本锁定锁定到外部仓库的某个 commit通过 squash 合并特定版本
本地修改需要进入子模块目录单独提交直接在主仓库中修改提交
回推变更在子模块目录中 pushgit subtree push
历史记录主仓库和子模块历史完全分离合并后历史混在一起
适用场景外部库频繁独立更新,多项目共享偶尔同步更新,对方协作较少

⚠️ 常见误区 误区:submodule 比 subtree 差,应该始终用 subtree。 两者各有适用场景。如果多个项目都要引用同一个库,并且这个库自身在活跃开发中,submodule 更合适——它让每个项目独立决定何时更新到库的哪个版本。subtree 则更适合”把外部代码拿过来用,偶尔同步”的场景,对使用者更透明。


五、sparse-checkout——只取所需

大型单体仓库(monorepo)动辄几十 GB,里面可能包含几十个团队的代码。但你只负责其中一个模块,完全没必要把整个仓库都拉下来。

sparse-checkout(稀疏检出) 允许你只检出仓库中的特定目录或文件,其余内容不会出现在你的工作目录中。

# 第一步:克隆仓库但不检出任何文件
$ git clone --no-checkout https://github.com/company/monorepo.git
$ cd monorepo

# 第二步:启用 sparse-checkout 的 cone 模式
$ git sparse-checkout init --cone

# 第三步:指定你需要的目录
$ git sparse-checkout set src/backend/user-service docs/api

# 第四步:执行检出
$ git checkout main

这样你的工作目录中只会出现 src/backend/user-service/docs/api/ 这两个目录,其余几十 GB 的内容不会占用你的磁盘空间。

# 查看当前稀疏检出的规则
$ git sparse-checkout list
src/backend/user-service
docs/api

# 追加需要的目录
$ git sparse-checkout add src/shared/utils

# 重新设置(覆盖之前的规则)
$ git sparse-checkout set src/frontend/portal

# 关闭稀疏检出,恢复完整工作目录
$ git sparse-checkout disable

如果连 git clone 的下载量都想缩减,可以配合 partial clone(部分克隆) 使用:

# 只克隆提交历史和 tree 对象,blob(文件内容)按需下载
$ git clone --filter=blob:none --sparse https://github.com/company/monorepo.git
$ cd monorepo
$ git sparse-checkout set src/backend/user-service

这样初始克隆几乎是瞬间完成的,只有当你 checkout 特定目录时才会下载对应的文件内容。


六、git blame 与 git log -S——代码侦探术

git blame——每行代码的身份证

git blame 会对文件的每一行标注:什么时候哪次提交中写下了这行代码。

$ git blame src/auth/login.py
^a1b2c3d (张三 2025-03-01 10:15:30 +0800  1) import hashlib
a1b2c3d4 (张三 2025-03-01 10:15:30 +0800  2) import jwt
e5f6a7b8 (李四 2025-06-15 14:22:10 +0800  3) from datetime import timedelta
^a1b2c3d (张三 2025-03-01 10:15:30 +0800  4)
c9d0e1f2 (王五 2025-09-20 09:45:00 +0800  5) TOKEN_EXPIRY = 3600  # 改为1小时
e5f6a7b8 (李四 2025-06-15 14:22:10 +0800  6) MAX_RETRIES = 3

常用参数:

# 只看第 10-20 行
$ git blame -L 10,20 src/auth/login.py

# 忽略空白变更(避免因为格式化导致的blame误判)
$ git blame -w src/auth/login.py

# 检测行在文件内的移动(函数被移到了文件的另一个位置)
$ git blame -M src/auth/login.py

# 检测行从其他文件复制过来的情况
$ git blame -C src/auth/login.py

git log -S——搜索代码变更

如果你想知道”某个函数是什么时候被添加的”或者”某段代码是什么时候被删除的”,git log -S 是你的利器。它会搜索引入或删除了指定字符串的所有提交。

# 找出哪些提交增加或删除了 "TOKEN_EXPIRY" 这个字符串
$ git log -S "TOKEN_EXPIRY" --oneline
c9d0e1f TOKEN_EXPIRY 7200 改为 3600
a1b2c3d 初始化认证模块

# 使用正则表达式搜索(-G 参数)
$ git log -G "def validate_.*token" --oneline
f1e2d3c 重构 token 验证逻辑
a1b2c3d 初始化认证模块

# 搭配 -p 查看具体的代码变更
$ git log -S "TOKEN_EXPIRY" -p

-S-G 的区别:-S 寻找的是使指定字符串的出现次数发生变化的提交(也就是添加或删除了该字符串),而 -G 寻找的是diff中包含匹配该正则表达式的行的提交。


七、git tag——给历史打上里程碑

标签是给某个特定提交打上有意义的名字,通常用于标记发布版本。

轻量标签 vs 附注标签

# 轻量标签:只是一个指向 commit 的引用,不包含额外信息
$ git tag v0.9-beta

# 附注标签:包含打标签者信息、日期、附注消息,是一个完整的 Git 对象
$ git tag -a v1.0.0 -m "第一个正式发布版本"

生产环境推荐使用附注标签。它记录了谁在什么时间打的标签,并且可以被 GPG 签名来验证真实性。

语义化版本管理

遵循 Semantic Versioning 规范(MAJOR.MINOR.PATCH)可以让标签更有意义:

# 修复 bug,不影响 API → 补丁版本号 +1
$ git tag -a v1.0.1 -m "修复登录超时问题"

# 新增功能,向后兼容 → 次版本号 +1
$ git tag -a v1.1.0 -m "新增 OAuth2.0 登录支持"

# 不兼容的 API 变更 → 主版本号 +1
$ git tag -a v2.0.0 -m "重构认证架构,不再支持旧版 token 格式"
# 查看所有标签
$ git tag -l

# 查看匹配特定模式的标签
$ git tag -l "v1.*"

# 给历史提交打标签
$ git tag -a v0.8.0 abc1234 -m "回溯标记 v0.8.0 版本"

# 推送标签到远程(标签默认不会被 push 推送)
$ git push origin v1.0.0

# 推送所有标签
$ git push origin --tags

# 删除本地标签
$ git tag -d v0.9-beta

# 删除远程标签
$ git push origin --delete v0.9-beta

八、git clean——扫除杂物

工作目录中经常会出现一些未跟踪的文件——编译产物、临时日志、编辑器备份文件等。git clean 帮你批量清理这些”杂物”。

# 预览哪些文件会被清理(强烈建议先用 -n 预览!)
$ git clean -n
Would remove build/output.js
Would remove temp_debug.log
Would remove __pycache__/

# 确认无误后强制清理(-f 是必须的安全锁)
$ git clean -f

# 连同未跟踪的目录一起清理
$ git clean -fd

# 连同 .gitignore 中被忽略的文件一起清理(比如 node_modules、dist)
$ git clean -fxd

# 交互式选择要清理哪些文件
$ git clean -i

⚠️ 常见误区 误区:git clean 可以撤销。 git clean 删除的是未跟踪的文件——这些文件从来没有被 Git 管理过,所以没有任何历史记录可以恢复。这就是为什么 Git 要求你必须加 -f 参数才能真正执行删除,为什么强烈建议你先用 -n 预览。如果你误删了重要文件,只有通过操作系统层面的恢复工具才可能找回来。


九、git archive——打包发布

当你需要给客户或部署系统提供一份干净的源码包(不含 .git 目录),git archive 是最合适的工具。它只打包 Git 已跟踪的文件,因此被 .gitignore 忽略且从未被跟踪过的文件自然不会出现在包中。

# 打包当前 HEAD 为 tar.gz 格式
$ git archive --format=tar.gz --prefix=myproject-v1.0/ HEAD > myproject-v1.0.tar.gz

# 打包指定标签的版本为 zip 格式
$ git archive --format=zip --prefix=myproject-v1.0/ v1.0.0 > myproject-v1.0.zip

# 只打包特定目录
$ git archive HEAD src/backend/ docs/ > partial-archive.tar

# 利用 .gitattributes 排除不需要打包的文件
# 在 .gitattributes 中添加:
# tests/           export-ignore
# .github/         export-ignore
# *.test.js        export-ignore

--prefix 参数会让压缩包内的文件都放在一个以指定名称命名的顶层目录下,解压后不会散落一地——这是发布软件包的最佳实践。


十、实战:用 worktree 同时修复 bug 和开发新功能

让我们把这一章学到的技巧串联起来,模拟一个真实的工作场景:你正在开发新功能,突然接到通知要修复一个紧急 bug,修完后还要打一个发布标签。

# === 场景准备 ===
$ mkdir multi-task-demo && cd multi-task-demo
$ git init
$ echo "v1 code" > app.py
$ git add app.py && git commit -m "v1.0.0 发布"
$ git tag -a v1.0.0 -m "初始发布版本"

# 你在 feature 分支上开发新功能
$ git switch -c feature/payment
$ echo "payment module" > payment.py
$ git add payment.py && git commit -m "开始支付模块开发"
$ echo "payment logic WIP" >> payment.py  # 还在写,未提交

# === 紧急情况:线上出 bug 了! ===

# 方案一(传统做法):先 stash,再切分支
# 方案二(本章推荐):用 worktree 开辟第二战场

# 创建一个 hotfix 工作目录,基于 main 分支
$ git stash push -m "支付模块开发中"
$ git worktree add ../multi-task-hotfix main
$ cd ../multi-task-hotfix

# 在 hotfix 目录中创建修复分支
$ git switch -c hotfix/login-crash
$ echo "v1 code - fixed" > app.py
$ git add app.py && git commit -m "修复登录崩溃问题"

# 合并修复到 main
$ git switch main
$ git merge --no-ff hotfix/login-crash -m "合并登录崩溃修复"

# 打一个补丁版本标签
$ git tag -a v1.0.1 -m "修复登录崩溃"

# === 修复完成,回到开发 ===
$ cd ../multi-task-demo

# 清理 hotfix 工作目录
$ git worktree remove ../multi-task-hotfix

# 恢复之前的开发进度
$ git stash pop
# 继续在 feature/payment 分支上开发...

# 验证标签和提交记录
$ git log --all --oneline --graph
* abc1234 (hotfix/login-crash, main) 修复登录崩溃问题
| * def5678 (HEAD -> feature/payment) 开始支付模块开发
|/
* 789abcd (tag: v1.0.0) v1.0.0 发布

整个过程中,你的 feature/payment 分支上的开发进度完全不受影响。worktree 让你不需要在分支之间反复切换,也不需要把半成品代码 commit 或 stash。

🤔 想一想 在上面的实战中,我们同时使用了 stash 和 worktree。想想看,如果你不 stash 就直接创建 worktree,未提交的改动会出现在新的 worktree 中吗?为什么?提示:回忆一下 worktree 共享的是什么、不共享的是什么。


📝 掌握度自测

  1. git stash push -p 的作用是什么?它和 git stash push 有什么区别?
  2. git worktree 创建的多个工作目录之间共享哪些数据?有什么限制?
  3. git bisect run 如何实现自动化二分查找?测试脚本需要满足什么条件?
  4. 你的项目需要引用一个频繁更新的公共组件库,应该用 submodule 还是 subtree?为什么?
  5. git clean -fxd 中的 -x-d 分别表示什么?这个命令执行后能撤销吗?

💡 自我评估

  • 答对5题:你已经掌握了一套高效的 Git 工具箱,足以应对大部分复杂的开发场景
  • 答对3-4题:核心工具理解到位,建议动手实践 worktree 和 bisect 来加深体感
  • 答对0-2题:这些工具比较”冷门”,日常用到的机会可能不多。建议先掌握 stash 和 tag,其他工具在遇到对应场景时再来查阅

购买课程解锁全部内容

版本控制不翻车:Git 从基础到团队协作

¥29.90