传统 Docusaurus 部署流程通常是:源码推 GitHub → GitHub Actions 构建 → rsync 到服务器。这套流程依赖外部 CI、排队等待、链路长、排查麻烦。
.deploy-git 方案 的思路完全不同:在项目根目录下创建一个独立的 Git 裸仓库 .deploy-git,以 build/ 为其工作目录(work-tree),专门用于追踪构建产物。每次本地 npm run build 后,将 build/ 的内容提交到 .deploy-git,然后通过 git push 直接推送到服务器上的 bare repo,服务器通过 post-receive hook 自动检出到网站根目录。
这套方案的核心优势在于:构建在本地完成,传输走 Git 协议且只传增量,部署结果即时反馈,不依赖任何外部服务。
整个流程只有 3 步:
npm run build → 生成 build/build/ 的内容提交到 .deploy-git,然后 git push 到服务器post-receive hook 将文件检出到网站根目录项目根目录/ ├── .git/ ← 日常开发的 Git 仓库(跟踪源码) ├── .deploy-git/ ← 部署专用 Git 裸仓库 │ ├── objects/ ← Git 对象存储(commit / tree / blob) │ ├── refs/ ← 分支引用 │ └── config ← 配置(含 remote 地址) ├── build/ ← 构建产物(.deploy-git 的 work-tree) │ ├── index.html │ ├── assets/ │ └── ... ├── docs/ ← 源码文档 ├── src/ ← 源码 └── package.json
.deploy-git 和 .git 是完全独立的两个 Git 仓库。一个管源码,一个管部署产物。互不干扰。
| 维度 | 传统 GitHub Actions | .deploy-git 直推 |
|---|---|---|
| 构建位置 | GitHub Runner(需排队) | 本地(即时执行) |
| 传输方式 | rsync 全量对比 | Git 增量(只传变化) |
| 部署反馈 | 等 CI 跑完才知道结果 | 秒级反馈,终端直接看 |
| 外部依赖 | 依赖 GitHub + Actions | 零依赖,SSH + Git 即可 |
| 版本回滚 | 重跑 Action / 手动 rsync | git revert 一键回退 |
| 源码仓库 | 可能混入 build 产物 | 完全分离,干干净净 |
| 部署失败排障 | 翻 CI 日志 | 本地终端直接看 |
SSH 登录到服务器,执行以下命令:
# 创建 bare 仓库 mkdir -p /var/repo/docusite.git cd /var/repo/docusite.git git init --bare # 创建 post-receive hook(自动部署到网站目录) cat > hooks/post-receive << 'EOF' #!/bin/bash TARGET="/www/wwwroot/haoyelaiga.com/docusite" git --work-tree="$TARGET" checkout -f EOF chmod +x hooks/post-receive # 修正 HEAD 指向 main(如果推送的是 main 分支) git symbolic-ref HEAD refs/heads/main
hook 文件在 Windows 上创建后 scp 到 Linux 会带 CRLF 结尾,导致 #!/bin/bash\r 无法识别。需要用 sed -i 's/\r$//' 转换,或者直接在服务器上用 vi/nano 创建。
# 在项目根目录执行 git init --bare .deploy-git git --git-dir=.deploy-git remote add origin \ root@111.230.81.144:/var/repo/docusite.git # 关闭自动 CRLF 转换(消除 LF→CRLF warning) git --git-dir=.deploy-git config core.autocrlf false
在 package.json 的 scripts 中添加:
"upload": "git --git-dir=.deploy-git --work-tree=build add -A && git --git-dir=.deploy-git --work-tree=build commit -m deploy --allow-empty && git --git-dir=.deploy-git --work-tree=build push -f origin HEAD:main", "ship": "npm run build && npm run upload"
在 .gitignore 中添加,防止源码仓库误追踪:
# Deploy git repo
.deploy-git
为了验证 Git 是否真的只传输有变化的文件,我们设计了一套三阶段测试。
| 测试 | 操作 | 预期 |
|---|---|---|
| 测试 1 | 正常构建(有博客文章更新) | 有一定量的变更 |
| 测试 2 | 不修改任何源文件,再次构建 | 几乎无变更 |
| 测试 3 | 只修改 1 个 markdown 文档,构建 | 观察实际影响范围 |
| 指标 | 测试 1 有文章更新 |
测试 2 不改源码 |
测试 3 改 1 个 doc |
|---|---|---|---|
| 变更文件数 | 162 | 0 | 313 |
| 新增 Git 对象 | +319 | +1(仅 commit) | +612 |
| 新增存储大小 | +1,363 KB | +0 KB | +2,173 KB |
| Tree hash | 变化 | 完全一致 ✅ | 变化 |
连续两次 git commit(源码未改),tree hash 完全一致。Git 识别出文件内容没有变化,只创建了一个空的 commit 对象(~180 字节),没有创建任何新的 blob(文件内容对象)。push 时也只传输了这个 tiny commit。
| 文件类型 | 数量 | 变化原因 |
|---|---|---|
| JS 文件 | 16 | webpack content hash 重新计算,文件名变化 |
| HTML 文件 | 294 | 引用的 JS/CSS hash 变了,路径更新 |
| XML 文件 | 2 | atom.xml / rss.xml 博客 feed 更新 |
| 其他 | 1 | sitemap.xml |
| 实际内容变化 | 2 | 仅中/英文版 getting-started 页面 ← 我们修改的那个 doc |
以下是验证 Git 增量传输的各种方法:
# 查看最近两次提交的 tree hash $t1 = git --git-dir=.deploy-git rev-parse "HEAD^{tree}" $t2 = git --git-dir=.deploy-git rev-parse "HEAD~1^{tree}" Write-Host "当前 tree: $t1" Write-Host "上一个 tree: $t2" # 如果相同 → 文件内容没变,只传了 commit
# 查看松散对象数量和大小 git --git-dir=.deploy-git count-objects -v # 输出示例: count: 152 ← 松散对象数 size: 1849 ← 松散对象大小 (KB) in-pack: 5545 ← 已打包对象数 size-pack: 4711 ← 打包后大小 (KB) # 对比两次 count 的变化: # 如果只 +1 → 只新增了 commit 对象,文件内容没变 👍
# push 输出中会显示传输了多少对象 npm run upload # 输出示例: Enumerating objects: 1, done. ← 只枚举了 1 个对象 Counting objects: 100% (1/1), done. Writing objects: 100% (1/1), 178 bytes ← 只传了 178 字节 Total 1 (delta 0) ← delta 为 0 To 111.230.81.144:/var/repo/xxx.git abc123..def456 HEAD -> main
# 查看与上一次提交相比,哪些文件变了 git --git-dir=.deploy-git diff --stat HEAD~1 HEAD # 如果不改源码重新构建,应该是空的(0 files changed)
# 在服务器上查看接收的对象 ssh root@服务器IP "cat /var/repo/xxx.git/objects/info/packs"
这是因为 post-receive hook 中的 git checkout -f 会把 tree 中的所有文件全部写出到磁盘。即使文件内容没变,也会重新写入,导致时间戳更新。这是 checkout 命令的特性,不代表文件内容被重新传输了。传输阶段(git push)确实是增量的。
--allow-empty?因为 git commit 在内容没变化时会拒绝创建新 commit。但部署需要一个新 commit 来触发推送到服务器。--allow-empty 确保即使 build/ 的内容和上次一样,也能生成一个 commit 来推动部署流程。
build/ 下的 .gitattributes 要加吗?不需要。只需在 .deploy-git 中设置 core.autocrlf false 即可消除 LF→CRLF 的 warning。构建产物最终部署到 Linux 服务器,LF 换行符完全没问题。
在本地执行:
# 查看部署历史 git --git-dir=.deploy-git log --oneline -10 # 回退到指定版本并强制推送 git --git-dir=.deploy-git reset --hard <commit-hash> git --git-dir=.deploy-git --work-tree=build push -f origin HEAD:main
不需要。git checkout -f 会覆盖已有文件,但不会删除不在 tree 中的额外文件(除非用 git clean -fd)。如果之前有旧文件残留,建议先清空一次:rm -rf /网站目录/*。
git init --bare 默认 HEAD 指向 master,如果推送的是 main,需要 git symbolic-ref HEAD refs/heads/main.deploy-git 目录build/ 文件传到服务器,之后才是增量Git 确实只传输内容有变化的文件。
虽然 Docusaurus 每次构建会因 content hash 策略导致少量 JS/CSS 文件名变化(从而连锁引起 HTML 更新),但 如果源文件没改,重新构建出来的内容完全一致,Git 不会重复存储也不会重复传输。
验证结果:不修改任何源文件的情况下连续两次构建并 push,tree hash 完全一致,新增 Git 对象数为 0(仅一个空的 commit 对象)。
这套方案的核心设计理念:
整个流程只需一条命令:npm run ship → 构建 + 增量传输 + 服务器自动部署,一步到位。