Git Hook 与 实战 Git 命令
写在前面
用 Git 这么多年,我最大的感受是:
新手把 Git 当存储工具,老鸟把 Git 当工作流引擎。
同样是 git commit,有人只是完成提交,有人却在 commit message 中记录完整的项目历史;同样是 git push,有人等待 CI 告诉自己结果,有人已经在本地完成了一半 CI 检查。
这篇文章分为两部分:
- Git Hook 实战
- 老鸟常用的 Git 命令与工作流技巧
一、Git Hook 是什么
Git Hook 是 Git 内置的事件触发器,会在特定 Git 操作之前或之后自动执行脚本。
普通仓库的 Hook 默认位于 .git/hooks/,也可以通过 core.hooksPath 指定其他目录。Git 初始化仓库时,通常会生成一批带有 .sample 后缀的示例文件。删除 .sample 后缀,并确保脚本具有可执行权限,对应 Hook 就会生效。
Git Hook 的核心特点
-
本文关注客户端 Hook:
pre-commit、pre-push等在开发者本地执行;Git 也有运行在接收端的pre-receive、update等 Hook。 -
不能代替服务端规则:
git commit --no-verify可跳过pre-commit和commit-msg,git push --no-verify可跳过pre-push,但不是所有 Hook 都支持这一选项。 -
不会进入版本库:
.git/hooks/默认不受 Git 版本控制,团队共享需要额外配置。 -
脚本语言不限:只要系统能够执行,Bash、Python、Node.js、PowerShell 等都可以使用。
Hook 的执行位置
.git/
└── hooks/
├── pre-commit.sample
├── commit-msg.sample
├── pre-push.sample
└── post-merge.sample
常见 Hook 的触发时机如下:
触发时机、参数和退出码以 Git 官方 Hook 文档为准。数据库迁移等有副作用的操作,不宜在没有环境隔离和确认的情况下自动执行。
二、最常用的几个 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
下面只演示团队约定的标题格式,并允许 feat!:、feat(api)!: 这样的破坏性变更标记;它不是完整的 Conventional Commits 校验器:
#!/usr/bin/env sh
commit_msg_file="$1"
commit_msg=$(head -n 1 "$commit_msg_file")
if ! printf '%s\n' "$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
完整规范还涉及正文、footer 和 BREAKING CHANGE;可参考 Conventional Commits 1.0.0,实际团队也要决定如何处理 merge、revert 等提交。
3. pre-push
pre-push 在推送之前执行,适合运行比 pre-commit 更耗时的检查。
例如:
-
单元测试;
-
集成测试;
-
前端生产构建;
-
Docker 镜像构建;
-
数据库迁移校验。
它的优势在于:把部分 CI 检查前移到本地,减少推送后等待失败结果的时间。
4. post-merge
post-merge 在成功合并后执行,适合提醒或处理依赖同步。下面以普通 merge 为例;squash merge 尚未形成新提交,因此单独跳过。Hook 失败也不会撤销已经完成的合并。
例如:
#!/usr/bin/env sh
[ "$1" = "1" ] && exit 0 # 本例不处理 squash merge。
if git diff --quiet ORIG_HEAD HEAD -- package-lock.json; then
exit 0
else
status=$?
[ "$status" -eq 1 ] || exit "$status" # 1 表示有差异,其他非零值是错误。
echo "package-lock.json changed. Installing locked dependencies..."
npm ci
fi
三、实战:用 pre-push 拦截 CI 失败
1. 场景痛点
假设我维护的是一个 Next.js + Docker 项目,CI/CD 部署在 Gitee Go 上。
每次推送代码之后,需要等待几分钟才能知道构建是否成功。很多失败原因其实可以在本地复现,例如:
-
依赖版本冲突;
-
TypeScript 类型错误;
-
Next.js 编译失败;
-
Dockerfile 配置错误;
-
构建环境变量缺失。
2. 先确认:这次到底推送什么
pre-push 的两个命令行参数是远程名称和地址。真正要更新的引用由 Git 通过标准输入传入,每行格式是:
<local-ref> <local-oid> <remote-ref> <remote-oid>
这里的 local-oid 是准备推送的对象,remote-oid 是本次推送协商时远程引用的旧对象。新建远程引用时,旧 OID 全为 0;删除引用时,本地 OID 全为 0。一次推送可能有多行,不能只看 HEAD。协议说明
原来那种 git diff HEAD@{push} HEAD 的写法不够可靠:@{push} 表示按配置推导的推送目标所对应的本地远程跟踪引用,不是“上次 push 的固定记录”,也不一定对应这次显式指定的远程或 refspec。找不到它时退回 HEAD~1,又会漏掉此前尚未推送的提交;只有根提交的仓库甚至没有 HEAD~1。引用语法说明
还有一个容易忽略的问题:npm run build 和 docker build . 使用当前工作区,而不是自动检出 stdin 中的那个提交。 在 main 上推送 feature,不能拿 main 构建成功来证明 feature 没问题。
3. 本例的范围与分流规则
为了让示例保持可理解,也不擅自替读者切分支、stash 或 reset,先约定:
-
项目是单根目录的 npm + Next.js + Docker 项目,已有锁文件,根目录有
Dockerfile;不直接套用于 monorepo、子模块或稀疏检出,也不应借助assume-unchanged等标记隐藏文件修改。 -
一次只检查一个分支的创建或更新。它必须是当前检出的本地分支,而且待推送 OID 必须等于
HEAD;非当前分支、detached HEAD、多个分支更新都会拒绝。请到正确的分支或独立 worktree 中逐个推送,并写明本地分支名,例如git push origin main:target。HEAD:target、裸 OID 等写法不在本例契约内,即使它们指向当前 HEAD,也可能因本地 ref 名不匹配而被拒绝。 -
删除引用不构建。标签创建或更新明确跳过,由发布 CI 验证;因此“一个分支更新 + 若干删除/标签”可以通过,但只对那个分支执行本地检查。其他类型的非删除更新拒绝。
-
暂存区、受跟踪文件和未被忽略的未跟踪文件都必须干净。脚本在构建后再检查一次;构建期间也不要编辑文件或切分支。
正常快进更新时,比较 stdin 给出的远程旧提交与待推送提交,按两个最终文件树的净差异分流,不声称逐个验证中间提交:
README 白名单是项目约定,不是 Git 的保证。如果项目会读取它生成页面或版本信息,就应移出白名单。这里也不把所有 .md 或 docs/ 都当成无关文件。Docker 与应用检查分别判断,两类变更同时出现时都会运行;不能默认 Dockerfile 已包含应用构建步骤。
4. 完整 pre-push 脚本
在未设置自定义 core.hooksPath 的普通仓库中,将以下脚本保存为 .git/hooks/pre-push:
#!/usr/bin/env bash
set -euo pipefail
die() { printf 'pre-push: %s\n' "$*" >&2; exit 1; }
# stdin 每行:本地 ref、本地 OID、远程 ref、远程旧 OID。
branch_updates=0
while read -r local_ref local_oid remote_ref remote_oid; do
if [[ "$local_oid" =~ ^0+$ ]]; then
printf 'Skip deletion: %s\n' "$remote_ref"
continue
fi
case "$remote_ref" in
refs/tags/*)
printf 'Tag not build-checked: %s (use release CI)\n' "$remote_ref"
continue
;;
refs/heads/*)
branch_updates=$((branch_updates + 1))
push_ref=$local_ref
new_oid=$local_oid
old_oid=$remote_oid
;;
*) die "Unsupported destination: $remote_ref" ;;
esac
done
# 删除和标签不计入分支构建;多个分支更新不能共用一次当前工作区构建。
if [[ "$branch_updates" -eq 0 ]]; then
echo 'No branch update to build-check.'
exit 0
fi
[[ "$branch_updates" -eq 1 ]] || die 'Push one branch at a time.'
check_worktree() {
current_ref=$(git symbolic-ref --quiet HEAD) || die 'Check out the branch first.'
head_oid=$(git rev-parse --verify HEAD) || die 'Cannot read HEAD.'
[[ "$push_ref" == "$current_ref" && "$new_oid" == "$head_oid" ]] ||
die 'Pushed branch/OID is not the checked-out branch/HEAD.'
state=$(git status --porcelain --untracked-files=normal --ignore-submodules=none) ||
die 'Cannot inspect worktree.'
[[ -z "$state" ]] || die 'Commit or stash tracked/untracked changes first.'
}
check_worktree
needs_docker=false
needs_app=false
# 新分支、旧对象缺失或非快进更新:不猜比较起点,直接完整检查。
if [[ "$old_oid" =~ ^0+$ ]] ||
! git cat-file -e "${old_oid}^{commit}" 2>/dev/null ||
! git merge-base --is-ancestor "$old_oid" "$new_oid"; then
echo 'No reliable fast-forward baseline; run both builds.'
needs_docker=true
needs_app=true
else
# 用文件保存 NUL 分隔的路径,避免空格、换行及命令替换破坏文件名。
changed=$(mktemp)
trap 'rm -f -- "$changed"' EXIT
git diff --no-ext-diff --no-textconv --no-renames --name-only -z \
"$old_oid" "$new_oid" -- > "$changed" || die 'Cannot compare pushed trees.'
while IFS= read -r -d '' file; do
case "$file" in
README.md|LICENSE)
;; # 仅当项目确认它们不参与构建时,才可保留这个白名单。
Dockerfile|Dockerfile.*|.dockerignore|compose*.yml|compose*.yaml|docker-compose*.yml|docker-compose*.yaml)
needs_docker=true ;;
package.json|package-lock.json|npm-shrinkwrap.json|.npmrc)
needs_docker=true
needs_app=true ;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs|*.css|*.scss)
needs_app=true ;;
*)
# 资源、配置、MDX 及其他未知输入:宁可多检查,不默认当作纯文档。
needs_docker=true
needs_app=true ;;
esac
done < "$changed"
fi
if [[ "$needs_app" == true ]]; then
npm ci || die 'Dependency installation failed.'
npm run build || die 'Application build failed.'
fi
if [[ "$needs_docker" == true ]]; then
docker build -t local-check . || die 'Docker build failed.'
fi
# 构建若改了受跟踪文件,或期间换分支/改了 HEAD,就拒绝此次推送。
check_worktree
if [[ "$needs_app" == false && "$needs_docker" == false ]]; then
echo 'Same tree or allowlisted documentation only; builds skipped.'
else
echo 'Selected local build checks passed; remote CI is still required.'
fi
在 Linux 或 Git Bash 环境中,确保脚本具有执行权限:
chmod +x .git/hooks/pre-push
脚本用 git diff --name-only -z 输出 NUL 分隔的文件名,避免空格、换行等特殊字符被误拆;--no-renames 将重命名按删除与新增处理,不会因为旧名字在文档白名单里而漏掉新名字。差异比较本身失败时拒绝推送。git diff 文档
git merge-base --is-ancestor 用来确认旧提交是不是新提交的祖先;不能确认时,本例选择完整检查,而不是把它当作“没有变更”。构建通过也不会替 Git 放宽非快进推送或服务端保护规则。git merge-base 文档
5. 使用前还要确认的边界
本地预检不等于可重复构建
npm ci 需要 package-lock.json 或 npm-shrinkwrap.json,会重建 node_modules;pnpm、Yarn 项目应改成对应的锁定安装命令,不能只改文件匹配规则。npm ci 文档
“工作区干净”仍不包含被忽略的 .env.local、构建缓存等全部输入,系统版本和 Docker 基础镜像也可能不同。脚本没有清理这些内容,也不会自动删除你的文件。因此它只提前运行选定的本地检查,不保证远程 CI 一定通过。重要分支和发布标签仍要在 CI 中检出准确 OID,按团队规定验证。
正常更新的分流也假定基线已经受过检查。如果远程基线从未验证,或团队不接受按路径跳过,最简单的做法就是对每次符合范围的分支推送都运行两种构建。
Windows 与团队共享
脚本需要 Bash;Git for Windows 提供的 Git Bash 通常能运行,但仍要确认 Node.js、npm、Docker 在 PATH 中,并使用 LF 换行。可以在仓库的 .gitattributes 中为共享脚本目录声明 LF,例如:
scripts/hooks/* text eol=lf
.git/hooks/ 本身不受版本控制。团队可选择:
- Husky:在项目中维护 Hook,并按所用版本安装。
- 共享脚本目录:提交
scripts/hooks/,每个克隆显式设置git config core.hooksPath scripts/hooks,或由安装脚本复制到默认 Hook 目录。 - pre-commit framework:用统一框架管理跨语言检查。
只提交脚本不代表其他人的 Git 已启用它。安装后应至少在 Linux/macOS 或团队实际使用的 Git Bash 环境中测试一次;不要靠 --no-verify 绕过本例拒绝的推送方式来替代检查。
四、老鸟常用的 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
两者的区别:
整理之后,原来的 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
优先创建 rescue 分支保住提交。如果确实要让当前分支回到指定提交,先备份现场;下面的 --hard 会丢弃相关未提交修改:
git reset --hard 4d5e6f7
一句口诀:
先查 reflog,找到提交后尽快建分支保住它。
需要注意的是,reflog 主要是本地记录,并不等同于远程备份。记录会过期,不再可达的对象可能被垃圾回收;从未提交的文件修改也不靠 reflog 保存。
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:当前提交正常; -
1~127(除125):当前提交异常; -
125:当前提交无法测试,跳过; -
其他退出码:中止二分流程。
因此,不能简单把所有非零值都理解成“发现了 Bug”。测试命令不存在时常见的 127 也会被当作异常,开始前应先确认命令能正常运行。git bisect run 文档
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 添加清晰的说明,避免一段时间后无法判断内容属于哪个任务。默认不包含未跟踪文件;-u 会包含它们,但不包含被忽略文件。pop 出现冲突时 stash 通常会保留,先解决冲突再处理记录,不要反复 pop。drop、clear 前务必确认内容已不再需要。
6. worktree:多分支并行工作
传统切分支的方式需要在同一目录中反复切换。大型项目中,每次切换可能还要重新安装依赖、重新构建或重启开发服务器。
git worktree 可以将多个分支检出到不同目录。假设下面的 hotfix 分支已经存在:
git worktree add ../project-hotfix hotfix/payment-error
若要同时新建分支,可用 git worktree add -b hotfix/payment-error ../project-hotfix main。同一个分支默认不能同时检出到两个工作区。
创建后,可以得到类似结构:
workspace/
├── project/ # 原工作区
│ └── .git/
└── project-hotfix/ # 新工作区,与 project 同级
常见场景:
-
等待 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 --only -m "feat: add user profile"
这里的 --only 避免把已经暂存的其他修改顺便纳入提交。若最近一次提交已经推送到远程,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 具有破坏性,而且删除的未跟踪文件通常不能通过 reflog 恢复。预览时应带上与实际删除一致的选项:例如 git clean -ndx 对应 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
按团队约定设置换行处理,例如某些 Windows 工作流会使用:
git config --global core.autocrlf true
core.autocrlf true 不是所有平台通用的推荐值。共享规则优先写入 .gitattributes,Shell 脚本尤其要注意 LF。
设置常用别名:
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 会拒绝推送,从而降低误覆盖风险。
但它不是绝对保险:编辑器或后台任务执行 fetch 可能刷新这个预期值,使你尚未审阅的远程提交也被视为“已知”。重要分支应遵守团队规则,必要时显式指定已经核实的旧 OID:git push --force-with-lease=refs/heads/feature:<expected-old-oid> origin feature,不要原样复制占位符。git push 官方安全说明
六、Git 命令的心智模型
新手可以先记住三件事:
- 所有改写历史的命令,例如
rebase、amend、reset --hard,优先只用于自己的分支。 --force-with-lease通常优于--force。reflog是后悔药,但不要依赖它,规范的提交和分支操作才是根本。
写在最后
Git 的强大之处不在于命令数量多,而在于可以把这些命令组合成适合团队的工作流。
-
Hook 是工作流的自动化层;
-
Git 命令 是工作流的手动控制层;
-
CI/CD 是远程环境中的最终质量保障层。
把三者结合起来,可以在本地尽早发现问题,在提交历史中保留清晰上下文,并减少无效的 CI 等待时间。