Git Hook 与 实战 Git 命令
写在前面
用 Git 这么多年,我最大的感受是:
新手把 Git 当存储工具,老鸟把 Git 当工作流引擎。
同样是 git commit,有人只是完成提交,有人却在 commit message 中记录完整的项目历史;同样是 git push,有人等待 CI 告诉自己结果,有人已经在本地完成了一半 CI 检查。
这篇文章分为两部分:
- Git Hook 实战
- 老鸟常用的 Git 命令与工作流技巧
文中的命令均以常见 Git 工作流为基础,适合直接改造到自己的项目中。
一、Git Hook 是什么
Git Hook 是 Git 内置的事件触发器,会在特定 Git 操作之前或之后自动执行脚本。
它们默认位于仓库的 .git/hooks/ 目录中。Git 初始化仓库时,通常会生成一批带有 .sample 后缀的示例文件。删除 .sample 后缀,并确保脚本具有可执行权限,对应 Hook 就会生效。
Git Hook 的核心特点
-
本地触发:脚本在开发者本地执行,不依赖远程服务器。
-
默认可绕过:大多数客户端 Hook 都可以通过
--no-verify显式跳过。 -
不会进入版本库:
.git/hooks/默认不受 Git 版本控制,团队共享需要额外配置。 -
脚本语言不限:只要系统能够执行,Bash、Python、Node.js、PowerShell 等都可以使用。
Hook 的执行位置
.git/
└── hooks/
├── pre-commit.sample
├── commit-msg.sample
├── pre-push.sample
└── post-merge.sample
常见 Hook 的触发时机如下:
| Hook | 触发时机 | 常见用途 |
|---|---|---|
pre-commit | 创建提交之前 | lint、格式化、类型检查 |
commit-msg | 提交信息生成之后 | 校验 commit message |
pre-push | 推送到远程之前 | 执行测试、构建和质量检查 |
post-merge | 合并完成之后 | 安装依赖、执行迁移脚本 |
二、最常用的几个 Hook
1. pre-commit
pre-commit 在提交创建之前执行,最适合用来运行快速检查。
典型用途包括:
-
ESLint 检查;
-
Prettier 格式化;
-
TypeScript 类型检查;
-
单元测试;
-
检查是否误提交敏感文件。
例如,在 .git/hooks/pre-commit 中写入:
#!/usr/bin/env sh
echo "Running lint..."
npm run lint
if [ $? -ne 0 ]; then
echo "Lint failed. Commit aborted."
exit 1
fi
echo "Pre-commit checks passed."
2. commit-msg
commit-msg 可以读取提交信息文件,并校验提交信息是否符合团队规范。
例如,要求提交信息符合 Conventional Commits:
feat: add user login
fix: handle empty response
docs: update installation guide
refactor: simplify request client
一个简单的校验脚本如下:
#!/usr/bin/env sh
commit_msg_file="$1"
commit_msg=$(head -n 1 "$commit_msg_file")
if ! echo "$commit_msg" | grep -Eq '^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+'; then
echo "Invalid commit message."
echo "Expected format: type(scope): description"
exit 1
fi
3. pre-push
pre-push 在推送之前执行,适合运行比 pre-commit 更耗时的检查。
例如:
-
单元测试;
-
集成测试;
-
前端生产构建;
-
Docker 镜像构建;
-
数据库迁移校验。
它的优势在于:把部分 CI 检查前移到本地,减少推送后等待失败结果的时间。
4. post-merge
post-merge 在合并完成之后执行,适合处理依赖和环境同步。
例如:
#!/usr/bin/env sh
if git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep -q '^package-lock.json$'; then
echo "package-lock.json changed. Installing dependencies..."
npm install
fi
三、实战:用 pre-push 拦截 CI 失败
1. 场景痛点
假设我维护的是一个 Next.js + Docker 项目,CI/CD 部署在 Gitee Go 上。
每次推送代码之后,需要等待几分钟才能知道构建是否成功。很多失败原因其实可以在本地复现,例如:
-
依赖版本冲突;
-
TypeScript 类型错误;
-
Next.js 编译失败;
-
Dockerfile 配置错误;
-
构建环境变量缺失。
与其等待远程 CI 失败,不如在本地推送之前先完成关键检查。
2. Hook 设计思路
并不是每次推送都需要执行完整 Docker 构建。因此可以根据变更文件类型进行分流:
| 变更类型 | 执行操作 | 特点 |
|---|---|---|
Dockerfile、依赖文件 | 执行完整 docker build | 最严格,但速度较慢 |
| 源代码文件 | 执行 next build | 速度较快,可发现类型和编译错误 |
| README、文档等 | 直接放行 | 不影响构建产物 |
3. 完整 pre-push 脚本
将以下脚本保存为:
.git/hooks/pre-push
#!/usr/bin/env bash
set -e
REMOTE_NAME="$1"
REMOTE_URL="$2"
echo "Checking files changed since the last push..."
changed_files=$(
git diff --name-only HEAD@{push} HEAD 2>/dev/null ||
git diff --name-only HEAD~1 HEAD
)
if [ -z "$changed_files" ]; then
echo "No changed files detected. Push allowed."
exit 0
fi
echo "Changed files:"
echo "$changed_files"
needs_docker_build=false
needs_next_build=false
while IFS= read -r file; do
case "$file" in
Dockerfile|docker-compose.yml|docker-compose.*.yml)
needs_docker_build=true
;;
package.json|package-lock.json|pnpm-lock.yaml|yarn.lock)
needs_docker_build=true
needs_next_build=true
;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs)
needs_next_build=true
;;
esac
done <<< "$changed_files"
if [ "$needs_docker_build" = true ]; then
echo "Docker-related files changed."
echo "Running Docker build..."
docker build -t local-check .
echo "Docker build passed."
elif [ "$needs_next_build" = true ]; then
echo "Source files changed."
echo "Running Next.js build..."
npm run build
echo "Next.js build passed."
else
echo "Only documentation or non-build files changed."
echo "Skipping build checks."
fi
echo "Pre-push checks passed."
exit 0
在 Linux 或 Git Bash 环境中,还需要确保脚本具有执行权限:
chmod +x .git/hooks/pre-push
4. Windows 下的兼容性问题
问题一:Bash 环境差异
Windows 默认没有完整的 Bash 环境,但 Git for Windows 提供的 Git Bash 通常可以执行这类脚本。
实际使用时需要注意:
-
路径分隔符差异;
-
read命令行为差异; -
Shell 脚本的换行符;
-
Docker、Node.js 是否已加入系统 PATH。
建议在投入团队使用前,至少在 Git Bash 中完整测试一次。
问题二:首次推送没有 HEAD@{push}
首次推送时,本地可能还没有对应的远程跟踪分支,因此:
git diff --name-only HEAD@{push} HEAD
可能执行失败。
脚本中使用了回退逻辑:
git diff --name-only HEAD@{push} HEAD 2>/dev/null ||
git diff --name-only HEAD~1 HEAD
如果无法找到上一次推送位置,就回退到比较最近一次提交。
问题三:团队如何共享 Hook
.git/hooks/ 默认不会进入版本库,因此团队成员无法直接通过 git pull 获取 Hook。
常见解决方案有三种:
- 使用 Husky:将 Hook 配置纳入
package.json和项目目录。 - 维护统一脚本目录:将脚本放在
scripts/hooks/,再通过安装脚本复制到.git/hooks/。 - 使用 pre-commit framework:通过统一框架管理跨语言 Hook。
四、老鸟常用的 Git 命令
1. interactive rebase:整理提交历史
开发过程中经常会出现以下提交:
WIP
fix typo
fix again
update
final
really final
这些提交对开发过程有帮助,但不适合作为最终 PR 历史。
可以使用交互式 rebase 整理最近 5 条提交:
git rebase -i HEAD~5
打开编辑器后,通常会看到:
pick 1a2b3c4 feat: add login page
pick 2b3c4d5 fix: adjust button style
pick 3c4d5e6 fix: fix typo
pick 4d5e6f7 test: add login test
pick 5e6f7a8 docs: update README
将需要合并的提交改为 squash 或 fixup:
pick 1a2b3c4 feat: add login page
squash 2b3c4d5 fix: adjust button style
fixup 3c4d5e6 fix: fix typo
pick 4d5e6f7 test: add login test
pick 5e6f7a8 docs: update README
两者的区别:
| 操作 | 是否保留提交信息 | 适用场景 |
|---|---|---|
squash | 保留并允许重新编辑 | 多个提交需要合并并整理说明 |
fixup | 丢弃当前提交信息 | 纯粹修复前一个提交 |
整理之后,原来的 5 条提交就可以变成更清晰的功能单元。
注意:
rebase -i会改写提交历史,只适合处理尚未被他人依赖的分支。
2. cherry-pick:搬运单个提交
如果同事在其他分支修复了一个 bug,而你只需要这个修复,不想合并整个分支,可以使用:
git cherry-pick <commit-hash>
例如:
git cherry-pick 8f3a2c1
Git 会将指定提交的修改应用到当前分支,并生成一个新的提交。
适合场景:
-
将紧急 bug 修复同步到多个维护分支;
-
将某个独立功能搬到 release 分支;
-
只引入一个提交,不合并整个开发分支。
如果发生冲突:
git status
解决冲突后:
git add .
git cherry-pick --continue
如果想取消本次操作:
git cherry-pick --abort
3. reflog:Git 的救命稻草
误执行了 reset --hard、rebase 搞乱了历史,或者删除了错误的分支,不一定意味着提交永远丢失。
Git 通常会通过 reflog 记录本地引用和 HEAD 的移动轨迹:
git reflog
输出可能类似:
8f3a2c1 HEAD@{0}: checkout: moving from feature to main
4d5e6f7 HEAD@{1}: rebase finished: returning to refs/heads/feature
1a2b3c4 HEAD@{2}: commit: feat: add login
找到目标提交后,可以通过新分支恢复:
git branch rescue 4d5e6f7
或者让当前分支回到指定提交:
git reset --hard 4d5e6f7
一句口诀:
reflog 在,提交就在。
需要注意的是,reflog 主要是本地记录,并不等同于远程备份。长期不使用的对象仍可能被 Git 垃圾回收。
4. bisect:二分查找引入 Bug 的提交
线上出现 Bug,但不知道是哪次提交引入时,可以使用 git bisect。
首先开始二分:
git bisect start
标记当前版本存在问题:
git bisect bad
再标记一个确认正常的历史版本:
git bisect good <good-commit>
Git 会自动切换到中间提交。测试当前版本后,告诉 Git 结果:
git bisect good
或者:
git bisect bad
重复这个过程,Git 最终会定位到第一个引入 Bug 的提交。
完成后退出二分模式:
git bisect reset
自动化 bisect
如果项目已经有可自动执行的测试脚本,可以将测试命令交给 Git:
git bisect run npm test
测试命令返回值为:
-
0:当前提交正常; -
非
0:当前提交异常。
5. stash:暂存工作区
开发到一半时,突然需要切分支修复紧急 Bug,但当前修改还不适合提交,可以使用:
git stash push -m "wip: user profile page"
切换分支处理完问题后,回到原分支:
git stash list
git stash pop
常用进阶操作:
# 查看某条 stash 的内容
git stash show -p stash@{0}
# 应用 stash,但保留 stash 记录
git stash apply stash@{0}
# 删除某条 stash
git stash drop stash@{0}
# 删除所有 stash
git stash clear
# 连同未跟踪文件一起暂存
git stash push -u -m "wip: include untracked files"
建议为 stash 添加清晰的说明,避免一段时间后无法判断内容属于哪个任务。
6. worktree:多分支并行工作
传统切分支的方式需要在同一目录中反复切换。大型项目中,每次切换可能还要重新安装依赖、重新构建或重启开发服务器。
git worktree 可以将多个分支检出到不同目录:
git worktree add ../project-hotfix hotfix/payment-error
创建后,可以得到类似结构:
project/
├── .git/
├── feature/
└── project-hotfix/
常见场景:
-
等待 PR Review 时开始开发新功能;
-
修复紧急 Bug 时保留当前开发现场;
-
同时运行多个分支的开发服务器;
-
对比不同分支的构建结果。
查看当前 worktree:
git worktree list
删除不再需要的 worktree:
git worktree remove ../project-hotfix
7. amend:修改最近一次提交
刚提交完才发现漏加文件,或者提交信息中有 typo,可以使用:
git add forgotten-file.ts
git commit --amend
如果只想修改提交信息:
git commit --amend -m "feat: add user profile"
如果最近一次提交已经推送到远程,amend 会改写提交历史。此时通常需要:
git push --force-with-lease
建议只对自己尚未被他人使用的提交执行 amend。
8. log:高级查看方式
普通日志:
git log
单行查看:
git log --oneline
查看分支图:
git log --oneline --graph --decorate --all
查看某个文件的提交历史:
git log -- path/to/file.ts
查看某次提交修改了哪些内容:
git show <commit-hash>
查看某个作者的提交:
git log --author="Alice"
一个非常实用的组合命令是:
git log --oneline --graph --decorate --all --stat
9. clean:清理未跟踪文件
查看哪些文件会被删除:
git clean -n
确认无误后,删除未跟踪文件:
git clean -f
同时删除未跟踪目录:
git clean -fd
删除被 .gitignore 忽略的文件:
git clean -fdX
彻底清理未跟踪文件和被忽略文件:
git clean -fdx
git clean 具有破坏性,建议始终先使用 -n 预览结果。
10. config:进阶配置
查看全部配置:
git config --list
设置全局用户名和邮箱:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
设置默认分支名:
git config --global init.defaultBranch main
启用自动换行处理:
git config --global core.autocrlf true
设置常用别名:
git config --global alias.lg "log --oneline --graph --decorate --all"
git config --global alias.st "status --short"
git config --global alias.co checkout
git config --global alias.br branch
设置后可以直接使用:
git lg
git st
git co main
五、组合技:老鸟的真实工作流
场景一:开发新功能,中途需要修紧急 Bug
假设当前正在开发新功能:
git status
git stash push -u -m "wip: new payment flow"
切换到主分支并创建修复分支:
git switch main
git pull --ff-only
git switch -c hotfix/payment-error
完成修复并提交:
git add .
git commit -m "fix: handle payment error"
git push -u origin hotfix/payment-error
回到原功能分支:
git switch feature/payment
git stash pop
场景二:PR Review 后整理提交历史
先查看提交历史:
git log --oneline --decorate -10
交互式整理最近 5 条提交:
git rebase -i HEAD~5
解决冲突后继续:
git add .
git rebase --continue
整理完成后,使用更安全的强制推送:
git push --force-with-lease
为什么优先使用 --force-with-lease
--force 会直接覆盖远程分支,可能误删其他人的新提交。
--force-with-lease 会先检查远程分支是否仍处于预期状态。如果远程已经出现了其他人的新提交,Git 会拒绝推送,从而降低误覆盖他人工作的风险。
六、Git 命令的心智模型
| 命令 | 主要用途 | 风险等级 |
|---|---|---|
rebase -i | 整理提交历史 | 中:会改写历史 |
cherry-pick | 搬运单条提交 | 低 |
reflog | 找回误操作后的提交 | 无:主要用于读取记录 |
bisect | 二分定位问题提交 | 无:主要用于定位问题 |
stash | 暂存工作区修改 | 低 |
worktree | 多分支并行工作 | 低 |
amend | 修改最近一次提交 | 中:可能改写历史 |
reset --hard | 强制重置工作区 | 高:可能丢失未提交修改 |
push --force | 强制覆盖远程分支 | 高 |
push --force-with-lease | 安全地强制更新远程分支 | 中 |
新手可以先记住三件事:
- 所有改写历史的命令,例如
rebase、amend、reset --hard,优先只用于自己的分支。 --force-with-lease通常优于--force。reflog是后悔药,但不要依赖它,规范的提交和分支操作才是根本。
写在最后
Git 的强大之处不在于命令数量多,而在于可以把这些命令组合成适合团队的工作流。
-
Hook 是工作流的自动化层;
-
Git 命令 是工作流的手动控制层;
-
CI/CD 是远程环境中的最终质量保障层。
把三者结合起来,可以在本地尽早发现问题,在提交历史中保留清晰上下文,并减少无效的 CI 等待时间。