Gitea 分支"推送成功但界面不可见"故障复盘
环境:Gitea 1.24.3(Docker 部署,数据目录 ./gitea 挂载到容器 /data)
结论先行:迁移链路中 rclone(SMB 后端)挂载不保存 Linux 执行权限位,导致旧仓库的 Git hook 脚本全部变成 644/664 失去执行权限;Git 对不可执行的 hook 设计为静默跳过,于是 push 写入 git 磁盘成功、但负责把分支同步进数据库的 hook 从未执行,Gitea 界面/API 读的是数据库 branch 表,因此"显示成功但看不到"。
1. 故障现象
| 现象 | 说明 |
|---|---|
| 业务侧推送"成功" | 业务系统执行 git push 后拿到成功响应和 commit hash,读回校验也通过 |
| Gitea 界面看不到分支 | /<owner>/<repo>/branches、REST API /api/v1/repos/.../branches、/graph 页面均只显示迁移前的 main |
| 界面手工创建分支同样失效 | 在 Gitea 网页上点"新建分支",提示创建成功,但分支列表中不出现 |
| 影响范围 | 所有迁移前已存在的仓库;迁移后新建的仓库完全正常 |
| git 命令行正常 | git ls-remote 能看到全部分支,clone 也正常 |
2. 技术背景:Gitea 的双存储模型
理解本故障的关键前提:Gitea 对分支信息维护着两份数据,靠 hook 保持一致。
┌─────────────────────────────────────────────┐
│ ① Git 磁盘存储(唯一真相) │
│ repositories/<owner>/<repo>.git/ │
│ refs/heads/<branch> → commit │
│ objects/ │
│ 读者:git ls-remote / git clone / git push │
│ push 写入这里成功 = git 层面已成功 │
└─────────────────────────────────────────────┘
⇡ 靠 hook 同步 ⇣
┌─────────────────────────────────────────────┐
│ ② 数据库 branch 表(展示缓存) │
│ 读者:/branches 页面、REST API、/graph 页面、│
│ 分支保护、ahead/behind 统计 │
│ "界面上看到的分支" = 这张表里的行 │
└─────────────────────────────────────────────┘
界面分支列表的数据源可以从源码确认(Gitea 1.24):
services/repository/branch.go 的 LoadBranches() 执行 db.FindAndCount[git_model.Branch] ------ 只查数据库,不执行 git for-each-ref 8。
2.1 界面"创建分支"的底层流程
一个反直觉的事实:在 Gitea 网页上创建分支,底层是一次真实的 git push------Gitea 把自己仓库的磁盘路径当作 remote,向自己推送。
源码链路(Gitea 1.24,已逐文件核对):
浏览器 POST /{owner}/{repo}/branches/_new/branch/<源分支>
└─ routers/web/repo/branch.go:178 CreateBranch() 解析表单
└─ services/repository/branch.go:42 CreateNewBranch() 取源分支 commit
└─ services/repository/branch.go:378 CreateNewBranchFromCommit()
git.Push(ctx, repo.RepoPath(), git.PushOptions{
Remote: repo.RepoPath(), // remote 就是仓库自己
Branch: "<commit>:refs/heads/<新分支>",
})
上述链路对应源码:网页入口
CreateBranch()7,服务层CreateNewBranchFromCommit()的 git push 实现 8。
2.2 push 之后的完整链路(界面创建与外部 push 殊途同归)
git-receive-pack 接收 push
├─ ① 写入磁盘:objects/ + refs/heads/<branch> ✅ 到这里 push 就返回"成功"
│
└─ ② 执行钩子 hooks/post-receive(dispatcher)
└─ hooks/post-receive.d/gitea
内容:/usr/local/bin/gitea hook --config=/data/gitea/conf/app.ini post-receive
└─ 回调 Gitea 内部 API /api/internal/hook/post-receive/...
└─ services/repository/push.go:57 PushUpdates() 进入队列
└─ pushNewBranch() (push.go:183)
└─ models/git/branch.go:206 AddBranches()
→ 数据库 branch 表插入新行 ✅ 界面从此可见
② 的回调与写库链路对应源码:
PushUpdates()/pushNewBranch()9,AddBranches()10。
也就是说:git 磁盘写入(①)与数据库同步(②)是两个独立步骤,① 的成功不以 ② 为前提。② 完全依赖 hook 脚本能够被 git 执行。
3. 排查过程
- 对比两个数据源 :
git ls-remote返回 全部分支,而 REST API/branches只返回main(且其记录停留在迁移前)。结论:git 磁盘数据完整,数据库 branch 表没有同步 → 问题在"git → DB"的同步链路,即 hook。 - 检查受影响仓库的 hooks 目录 (容器卷
gitea/git/repositories/<owner>/<repo>.git/hooks/):hook 脚本存在、内容正确(无硬编码旧路径),但权限为-rw-rw-r--(664),没有执行位。 - 检查迁移备份源:备份服务器及更早的 rclone 挂载点上,同一批 hook 文件同样是 664 → 权限在迁移链路中就已经丢失,不是新服务器上被破坏的。
- 对照组:迁移后新建的仓库 hook 为 755(Gitea 在容器内生成),工作正常。
关键证据
受影响仓库的 hook 文件(备份服务器上):
$ ls -ln post-receive.d/gitea
-rw-rw-r-- 1 1000 1000 134 Dec 11 2025 post-receive.d/gitea ← 无执行位
hook 内容本身没有任何问题(排除硬编码旧路径的嫌疑):
bash
# hooks/post-receive.d/gitea ------ 内容正确,唯一的问题是权限
#!/usr/bin/env bash
/usr/local/bin/gitea hook --config=/data/gitea/conf/app.ini post-receive
Gitea 自动生成的 dispatcher(hooks/post-receive)中对不可执行 hook 的处理方式------静默跳过:
bash
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue # ← 664 时 test -x 失败,直接 continue
echo "${data}" | "${hook}"
done
4. 根因:执行权限丢失的因果链
原服务器(Docker 卷 ./gitea:/data)
hooks 755 ✅(Gitea 容器内生成)
│
▼ rclone mount(远端为 SMB 协议后端)
SMB 协议不承载 Linux 权限位(mode bits)
挂载点上读出的文件权限由 rclone 按 --file-perms/umask 合成(最终权限 = 权限模板 & ~umask [11]) [1][2]
→ hooks 在挂载点上已经表现为 664 ❌(权限位在这一跳丢失,且不可逆)
│
▼ rsync -avh 备份到备份服务器
rsync -a(归档模式,保留权限/属主/时间等属性 [4])忠实保留"源端呈现出的权限" → 备份副本 664 ❌
│
▼ rsync -avhP 恢复到新服务器
目标 hooks 仍为 664 ❌
│
▼ 运行期
git receive-pack 执行 hook 前 test -x 失败 → 静默跳过
→ ① 磁盘写入成功(push 返回成功)
→ ② DB 同步从未发生(界面/API 不可见)
📖 rclone 官方文档对此有明确说明:挂载点默认权限为
--file-perms 0666 --dir-perms 0777(--umask默认 002),并直接警告 "This means you will not be able to start any programs from the mount. To be able to do that you must add execute permissions, e.g.--file-perms 0777" 1。本故障中的 hook 脚本正是被这句话命中的"programs"。
三个机制叠加,造成了"无报错的全静默失败":
- SMB 不保存 Linux 权限位:权限在 rclone 挂载这一跳就已经丢失,之后任何忠实复制(rsync -a)都只能复制到已经损坏的权限。这不是 rsync 的错。
- git 对不可执行 hook 静默忽略:Git 官方文档要求 hook 必须可执行才会被调用 3,对不可执行的 hook 静默跳过是既定设计(避免一个坏 hook 阻塞所有推送),所以 push 全程没有任何报错,客户端拿到的是货真价实的成功。
- Gitea 界面读 DB 而非 git:磁盘与 DB 脱节时,界面忠实展示了 DB 的落后状态。
为什么只有旧仓库受影响、新仓库正常
新仓库的 hooks 是迁移完成后由 Gitea 在容器内直接生成的(755),未经过 rclone/SMB/rsync 链路。
隐藏副作用:迁移期间分支保护形同虚设
负责推送前校验的 pre-receive / proc-receive hook 同样因无执行位被静默跳过。这意味着在故障存续期间,分支保护规则、推送权限校验均未生效,所有推送直接放行。复盘时应评估这期间是否有本应被拦截的推送。
5. 修复过程
在 Gitea 站点管理后台执行两个操作,分别对应链路的不同环节:
| 操作 | 作用 |
|---|---|
| 「重新同步所有仓库的 pre-receive、update 和 post-receive 钩子」 | 修断链点:重新生成所有仓库的 hook 文件并恢复正确权限(755),此后的 push 恢复完整链路 |
| 「将缺少的分支从 git 数据同步到数据库」 | 补历史欠账:扫描 git 磁盘 refs,把故障期间漏同步的分支批量回填 DB branch 表 |
等价的命令行方式(容器内执行)5:
bash
gitea admin regenerate hooks # 重建所有仓库 hook
gitea doctor --run sync-branches --fix # 将 git 数据中的分支同步到数据库
修复后验证:ls-remote 与 REST API 分支列表一致;界面手工创建分支、业务侧推送新建分支均即时可见。
6. 预防与改进建议
6.1 备份/迁移方式(根因层)
- Gitea 数据目录不要经 rclone(SMB/网盘类后端)挂载点做文件级备份。这类后端不保存 Linux 权限位,任何"先挂载再 rsync"的链路都会丢权限。
- 推荐替代方案(任选其一):
tar打包后再传输:tar 归档内完整保存权限与属主,如tar -czpf gitea-backup.tar.gz gitea/;- 使用
gitea dump做应用级备份(Gitea 官方备份与恢复指南还明确要求:恢复后要chown -R git:git /data并admin regenerate hooks6); - 若必须经 rsync 直接传,源端应是原生 Linux 文件系统而非 rclone 挂载点。
- 若只能以 rclone 挂载点为源,可在挂载时加
--file-perms 0777 --dir-perms 0777 --umask 000让挂载点合成可执行位,rsync 即可保留执行权限。注意副作用:所有文件都会带上执行位,粒度不可控,仅作规避手段而非推荐方案。
7. 参考文献
- rclone 官方文档 --- rclone mount 命令与选项:
--file-perms(默认 0666)、--dir-perms(默认 0777)、--umask(默认 002)的定义与默认值;官方明确说明默认权限下 "you will not be able to start any programs from the mount"。 - rclone GitHub Wiki --- Systemd rclone mount:官方推荐的 systemd 挂载模板,其中默认环境变量
RCLONE_MOUNT_FILE_PERMS="0666"、RCLONE_MOUNT_UMASK="022",印证默认合成权限为 0664。 - Git 官方文档 --- githooks:Git 钩子机制官方说明,hook 必须可执行(executable)才会被 Git 调用。
- rsync(1) man page:
-a, --archive等价于-rlptgoD,递归并保留权限、属主、时间戳等属性------它保留的是源端呈现出的权限。 - Gitea 官方文档 --- 命令行(admin regenerate hooks):
regenerate hooks:重新生成所有仓库的 Git Hooks。 - Gitea 官方文档 --- 备份与恢复:恢复流程要求调整文件权限(
chown -R git:git /data)并重新生成仓库 Git 钩子,否则 push 相关功能异常。 - Gitea 源码 release/v1.24 --- routers/web/repo/branch.go:网页创建分支入口
CreateBranch()。 - Gitea 源码 release/v1.24 --- services/repository/branch.go:
CreateNewBranchFromCommit()内部以 git push 创建分支;LoadBranches()仅从数据库读取分支列表。 - Gitea 源码 release/v1.24 --- services/repository/push.go:hook 回调后的
PushUpdates()队列与pushNewBranch()。 - Gitea 源码 release/v1.24 --- models/git/branch.go:
AddBranches()写入数据库 branch 表。 - Ask Ubuntu --- What is "umask" and how does it work?:权限位与 umask 的按位计算规则(最终权限 = 权限模板 & ~umask)。