Gitea源码深度剖析-第6章 Git 底层封装与仓库存储
前面几章分别梳理了 Gitea 的启动流程、配置体系、路由中间件、数据库层与权限模型。但 Gitea 本质上是一个"围绕 Git 仓库做管理与协作"的系统------用户看到的分支、提交、文件浏览、代码评审、推送拒绝规则,最终都要落到对 Git 仓库的读写上。本章深入 modules/git 及其周边模块,回答三个核心问题:
- Gitea 是封装了 go-git,还是直接调用系统的
git命令行? - 一次
git push从网络进入裸仓库,到 Gitea 更新数据库、触发 PR 任务,中间经过了哪些进程与函数? - 仓库文件如何组织、hook 如何安装、LFS 对象存在哪里、mirror 如何同步?
先纠正几个在流传资料中常见、但在这份源码里并不存在的假设(本章写作前已逐一 grep 验证):
| 常见说法 | 验证结果 | 实际情况 |
|---|---|---|
git.openGitCommand(...) 是统一入口 |
不存在 | 命令统一由 modules/git/gitcmd 包的 NewCommand(...) 构造 |
runCommand / HiddenArgs 封装参数 |
不存在 | 实际是 Command.AddArguments(内部类型 CmdArg)与 AddDynamicArguments(拒绝前导 -)等方法族 |
checkAndOpenCache 探测 git 版本 |
不存在 | 实际是 modules/git/git.go 的 loadGitVersionFeatures() + ensureGitVersion() |
git.RepoModuleTime 记录模块耗时 |
不存在 | 该仓库没有此标识符;耗时观测走 gtprof span(gtprof.TraceSpanGitRun) |
hooks 模板在 models/repo/hooks.go |
不存在 | 模板在 modules/git/hooks.go,DB 侧同步入口在 services/repository/hooks.go |
本章所有代码摘录均来自 gitea-28.1.0 工作区的真实源码,函数名与文件路径以实际为准。
6.1 modules/git 全景:双实现机制与核心结构
6.1.1 go-git 还是命令行 git?------build tag 决定的双实现
结论先行:Gitea 默认使用系统 git 命令行,go-git 只是一个通过 build tag 开启的可选实验实现。证据有三处:
第一,modules/git/repo_base_nogogit.go(默认构建,//go:build !gogit):
go
//go:build !gogit
package git
const isGogit = false
type Repository struct {
RepositoryBase
}
func openRepositoryInternal(_ *Repository) error {
return nil
}
func (repo *Repository) closeInternal() error {
return nil
}
与之对应的 repo_base_gogit.go 则令 const isGogit = true,openRepositoryInternal 会用 go-git 真正打开底层仓库对象。也就是说,两种实现的差异被收敛在带 build tag 的少量文件里,而 Repository 对外暴露的方法集合保持一致。
第二,go.mod 中仍然保留着 github.com/go-git/go-git/v5 v5.19.2 依赖,说明 go-git 分支的代码仍在编译路径里(由 gogit tag 控制是否编入)。
第三,docs/build-source.md 明确写着 gogit 是实验性 tag,主要用于 Windows 下的性能考虑;生产文档默认路径是系统 git。
isGogit 这个常量在运行时被用于能力开关,例如 modules/git/git.go 的 loadGitVersionFeatures():
go
features := &Features{gitVersion: ver, UsingGogit: isGogit}
features.SupportProcReceive = features.CheckVersionAtLeast("2.29")
features.SupportHashSha256 = features.CheckVersionAtLeast("2.42") && !isGogit
注意 SupportHashSha256 一项:即使系统 git ≥ 2.42,若启用 gogit 也直接判定不支持 SHA-256 仓库------go-git 实现尚未跟上。这是"双实现"差异泄漏到功能层面的直接例子。
6.1.2 核心类型一览
modules/git 包围绕以下类型组织(这是全章的词汇表,后文反复引用):
| 类型 | 定义位置 | 职责 |
|---|---|---|
Repository / RepositoryBase |
modules/git/repo.go |
仓库句柄;持有 tagCache、LastCommitCache、以及缓存的 cat-file 批处理器 |
RepositoryFacade |
modules/git/gitrepo/gitrepo.go |
仓库"门面"接口:GitRepoManagedID() / GitRepoLocation() / LogString() |
Commit |
modules/git/commit.go |
提交对象:ID、TreeID、Parents、Author、Committer、Signature |
Tag / Tree / TreeEntry / Blob |
modules/git/tag.go、tree.go、blob_nogogit.go 等 |
对象模型,读取全部经由 cat-file 批处理 |
RefName |
modules/git/ref.go |
引用名强类型封装,IsBranch()/IsTag()/IsFor()/IsPull() 等判定 |
Command |
modules/git/gitcmd/command.go |
git 子进程命令构造与执行 |
CatFileBatch |
modules/git/catfile_batch.go |
对象批量读取接口 |
其中 RepositoryFacade 是理解本章的第一把钥匙。它的接口定义极短:
go
type RepositoryFacade interface {
// GitRepoManagedID returns a "managed id", which should be a cache-key-friendly string.
// e.g.: ID with prefix&suffix or UUID
GitRepoManagedID() string
// GitRepoLocation returns the location for the git repository.
// * relative path: will be converted to an absolute path by setting.RepoRootPath
// * absolute path: will be used as-is
GitRepoLocation() string
LogString() string
}
设计动机:modules/git 需要支持两类仓库------Gitea 数据库管理的仓库(有 ID、可做缓存键)和外部临时仓库(如迁移时克隆的中转仓库)。前者用 RepositoryManaged(id, loc),GitRepoManagedID() 返回形如 repo-<id> 的字符串;后者用 RepositoryUnmanaged(loc),其 GitRepoManagedID() 直接 panic------任何试图给非托管仓库打缓存的代码都会在开发期暴露,而不是悄悄写坏缓存。这一"fail fast"策略与第 2 章配置校验的风格一致。
所有 git 命令的仓库路径都通过 gitrepo.RepoLocalPath(repo) 解析:
go
func RepoLocalPath(repo RepositoryFacade) string {
repoLoc := repo.GitRepoLocation()
if filepath.IsAbs(repoLoc) {
return repoLoc
}
if setting.RepoRootPath == "" {
panic("repo root path is not initialized")
}
return setting.RepoRootPath + string(filepath.Separator) + filepath.FromSlash(repoLoc)
}
setting.RepoRootPath 在 modules/setting/repository.go 中初始化为 AppDataPath/gitea-repositories(可被 [repository].ROOT 覆盖)。托管仓库的 location 是相对路径,因此整棵树可以整体搬迁------这正是 models/repo/repo_location.go 里迁移/重命名仓库只需改 DB 字段的原因。
6.1.3 仓库请求级复用
Web 请求里打开仓库是高频操作,Gitea 提供了 git.RepositoryFromRequestContextOrOpen(ctx, repo):若当前请求上下文已缓存该仓库句柄则直接复用,否则打开并注册一个 AddCloser 在请求结束时关闭。这使得同一请求内(如文件列表页同时渲染多个目录的 last commit)共享同一个 Repository 实例------也共享它内部缓存的那条 cat-file --batch-command 进程。6.4 节会说明为什么"共享"这件事很重要。
而 OpenRepository 的签名值得注意------它要求调用方显式传入一个父 context:
go
func OpenRepository(catFileBatchCtx context.Context, repo RepositoryFacade) (*Repository, error) {
repoPath := gitrepo.RepoLocalPath(repo)
...
gitRepo := &Repository{
RepositoryBase: RepositoryBase{tagCache: newObjectCache[*Tag](), repoFacade: repo, catFileBatchCtx: catFileBatchCtx},
}
gitRepo.RepositoryBase.LastCommitCache = &LastCommitCache{
repo: gitRepo,
ttlFn: setting.LastCommitCacheTTLSeconds,
cache: cache.GetCache(),
}
...
}
参数名 catFileBatchCtx 已经预告了它的唯一用途:供养缓存的 cat-file 进程。6.4 节将引用 RepositoryBase 上那段解释"为什么不能去掉这个 ctx"的长注释。
6.2 命令执行层 gitcmd:Command 与参数注入防护
modules/git/gitcmd/command.go(约 600 行)是全系统执行 git 子进程的唯一通道。理解它就理解了 Gitea 对"命令注入"这一类风险的全部防御。
6.2.1 Command 结构与构造
go
// Command represents a command with its subcommands or arguments.
type Command struct {
callerInfo string
prog string
args []string
preErrors []error
configArgs []string
...
gitDir string
...
pipelineFunc func(Context) error
...
cmdEnv []string
cmdTimeout time.Duration
...
cmdStdin io.Reader
cmdStdout io.Writer
cmdStderr io.Writer
cmdManagedStderr *bytes.Buffer
}
几个字段透露了设计要点:
callerInfo:记录调用方函数名,用于 debug 日志git.Run(by:xxx, repo:xxx): git ...,以及gtproftrace 属性。WithParentCallerInfo的注释解释了为何存在:当Run被中间包装函数调用时,自动推断会错位,需要手动指定。preErrors:延迟错误 机制。参数检查失败不立即 panic/返回,而是累积,直到Start()才errors.Join后返回并记 error 日志。configArgs:-c key=value形式的配置参数,最终执行时拼在args前面:process.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)。gitDir字段上挂着一段 FIXME 注释,坦承用工作目录而非--git-dir在嵌套仓库场景下可能解析错误------"correct approach is to use--git-dirglobal argument"。这种把已知缺陷写在字段上的做法,是读源码时判断"哪些行为是权宜之计"的重要线索。
构造函数是:
go
// NewCommand creates and returns a new Git Command based on given command and arguments.
// Each argument should be safe to be trusted. User-provided arguments should be passed to AddDynamicArguments instead.
func NewCommand(args ...internal.CmdArg) *Command {
cargs := make([]string, 0, len(args))
for _, arg := range args {
cargs = append(cargs, string(arg))
}
return &Command{
prog: GitExecutable,
args: cargs,
}
}
关键在参数类型 internal.CmdArg------它定义在 modules/git/internal 子包里。文件头部的 depguard 注释写得很直白:
go
"gitea.dev/modules/git/internal" //nolint:depguard // only this file can use the internal type CmdArg, other files and packages should use AddXxx functions
也就是说:除了 gitcmd 包自身,任何其他包都无法构造 CmdArg 字面量 。编译器层面的可见性限制,比 code review 更可靠地保证了 NewCommand("log", userInput) 这种危险调用根本写不出来------userInput 是 string,编译不通过。外部代码只能走 AddDynamicArguments。
6.2.2 参数注入防护的方法族
用户可控的值进入 git 命令有且只有一条路:AddDynamicArguments,而它拒绝前导 -:
go
// isSafeArgumentValue checks if the argument is safe to be used as a value (not an option)
func isSafeArgumentValue(s string) bool {
return s == "" || s[0] != '-'
}
// AddDynamicArguments adds new dynamic argument values to the command.
// The arguments may come from user input and can not be trusted, so no leading '-' is allowed to avoid passing options.
func (c *Command) AddDynamicArguments(args ...string) *Command {
for _, arg := range args {
if !isSafeArgumentValue(arg) {
c.handlePreErrorBrokenCommand(arg)
}
}
if len(c.preErrors) != 0 {
return c
}
c.args = append(c.args, args...)
return c
}
为什么"拒绝前导 -"就是防注入?因为 git 命令行里 git log <path> 的 <path> 若被换成 --upload-pack=touch /tmp/pwned,就会从"路径"变成"选项",进而触发远程命令执行类漏洞(历史上 git clone --upload-pack 系列问题皆源于此)。Gitea 在值与选项之间划了硬线:选项必须来自程序员写死的 AddArguments / AddOptionValues,用户输入一律经过前导符检查。
配套的选项构造函数还会先验证选项本身合法:
go
// AddOptionValues adds a new option with a list of non-option values
// For example: AddOptionValues("--opt", val) means 2 arguments: {"--opt", val}.
func (c *Command) AddOptionValues(opt internal.CmdArg, args ...string) *Command {
if !isValidArgumentOption(string(opt)) {
c.handlePreErrorBrokenCommand(string(opt))
return c
}
c.args = append(c.args, string(opt))
c.AddDynamicArguments(args...)
return c
}
AddOptionFormat 更进一步,用 strings.Count 快速核对格式串占位符数量与实参个数:
go
// a quick check to make sure the format string matches the number of arguments, to find low-level mistakes ASAP
if strings.Count(strings.ReplaceAll(opt, "%%", ""), "%") != len(args) {
c.handlePreErrorBrokenCommand(opt)
return c
}
这是"低级错误尽早暴露"的工程实践:占位符数量不匹配会导致 %!s(MISSING) 之类的静默错误输出,等到线上才发现就晚了。
还有两个专用函数值得记录:
AddDashesAndList(list ...string):追加--再挂文件列表,专治以-开头的文件名(如-rf.txt),语义上它们是路径而非选项------所以这里不 做前导符检查,因为--已经终结了选项解析。AddOptionGrepExpr(s string):专用于git grep,强制用-e分隔模式,理由直接引用 man git-grep:"This option has to be used for patterns starting with '-'"。并且校验当前命令确实是grep,否则记 preError。这展示了一个通用模式:每种"值可能长得像选项"的 git 语义,都对应一个显式正确的传参姿势。
参数方法族汇总如下:
| 方法 | 适用场景 | 前导 - 检查 |
示例 |
|---|---|---|---|
NewCommand(...) / AddArguments |
程序员写死的子命令与选项 | 不检查(CmdArg 类型保证) |
NewCommand("log", "--pretty=format:%H") |
AddDynamicArguments |
用户可控的值 | 拒绝 | AddDynamicArguments(branchName) |
AddOptionValues |
选项 + 值列表 | 选项查形态、值查前导符 | AddOptionValues("--count", "10") |
AddOptionFormat |
选项与值合一 | 同上 + 占位符计数 | AddOptionFormat("--max-count=%d", n) |
AddConfig |
-c key=value |
kv 不得前导 - |
AddConfig("core.quotePath", "false") |
AddDashesAndList |
文件路径列表 | 不检查(-- 已保护) |
AddDashesAndList(path) |
AddDynamicArguments 之外的 ToTrustedCmdArgs |
显式标记可信 | 不检查,注释警告慎用 | 迁移场景批量传参 |
6.2.3 环境变量:隔离宿主、固定输出语言、注入代理
commonBaseEnvs() 决定了每条 git 命令的基础环境:
go
func commonBaseEnvs() []string {
envs := []string{
// Make Gitea use internal git config only, to prevent conflicts with user's git config
// It's better to use GIT_CONFIG_GLOBAL, but it requires git >= 2.32, so we still use HOME at the moment.
"HOME=" + HomeDir(),
// Avoid using system git config, it would cause problems (eg: use macOS osxkeychain to show a modal dialog, auto installing lfs hooks)
"GIT_CONFIG_NOSYSTEM=1",
// Ignore replace references (https://git-scm.com/docs/git-replace)
"GIT_NO_REPLACE_OBJECTS=1",
}
// some environment variables should be passed to git command
passThroughEnvKeys := []string{
"GNUPGHOME", // git may call gnupg to do commit signing
}
...
}
三条默认值各有明确动机:
HOME=HomeDir():让 git 只读 Gitea 自己的 gitconfig(AppDataPath/gitea/.gitconfig),与宿主机用户配置完全隔离。注释同时承认了历史选择------理想方案是GIT_CONFIG_GLOBAL,但那要求 git ≥ 2.32,而最低版本要求只有 2.25。GIT_CONFIG_NOSYSTEM=1:不读/etc/gitconfig。注释给出了两个真实故障:macOS 的 osxkeychain 凭据助手会弹模态框、系统级 LFS 配置会自动安装 hooks。同时标注这是 1.24 起的 breaking change,把receive.certNonceSeed之类配置迁到 Gitea 的 gitconfig。GIT_NO_REPLACE_OBJECTS=1:禁用git-replace。否则仓库里的 replace 引用会让对象解析结果"张冠李戴",权限与保护分支检查可能基于被替换的历史做判断。
而 CommonGitCmdEnvs() 在此基础上再加两条,并拼入 extraEnvs:
go
func CommonGitCmdEnvs() []string {
envs := append(commonBaseEnvs(), []string{
"LC_ALL=C", // ensure git output is in English, error messages are parsed in English
"GIT_TERMINAL_PROMPT=0", // avoid prompting for credentials interactively, supported since git v2.3
}...)
if extra := extraEnvs.Load(); extra != nil {
envs = append(envs, *extra...)
}
return envs
}
LC_ALL=C:Gitea 解析 stderr 匹配错误串(如non-fast-forward),必须固定英文。GIT_TERMINAL_PROMPT=0:绝不能让后台 git 进程停下来等用户名密码------那会挂死一个无终端的进程。extraEnvs是atomic.Pointer[[]string],由gitcmd.SetExtraEnvs(...)写入。这就是 6.10 节 gitproxy 的注入点 :代理服务启动时把GIT_CONFIG_PARAMETERS='http.proxy=...'与GIT_PROXY_COMMAND原子地塞进所有后续 git 命令的环境。
环境变量最终在 Start() 里合入:
go
c.cmd = process.CommandContext(c.cmdCtx, c.prog, append(c.configArgs, c.args...)...)
if c.cmdEnv == nil {
c.cmd.Env = os.Environ()
} else {
c.cmd.Env = c.cmdEnv
}
c.cmd.Env = append(c.cmd.Env, CommonGitCmdEnvs()...)
c.cmd.Dir = c.gitDir
注意顺序:调用方通过 WithEnv(...) 提供的环境在前,CommonGitCmdEnvs() 追加在后------同名变量以 Gitea 的为准,从而保证隔离策略不可被业务代码绕过。
6.2.4 执行、超时与 pipelineFunc
Start() 里完成进程登记与追踪:
go
cmdLogString := c.LogString()
if c.callerInfo == "" {
c.WithParentCallerInfo()
}
desc := fmt.Sprintf("git.Run(by:%s, repo:%s): %s", c.callerInfo, logArgSanitize(c.gitDir), cmdLogString)
log.Debug("git.Command: %s", desc)
_, span := gtprof.GetTracer().Start(ctx, gtprof.TraceSpanGitRun)
span.SetAttributeString(gtprof.TraceAttrFuncCaller, c.callerInfo)
span.SetAttributeString(gtprof.TraceAttrGitCommand, cmdLogString)
var cmdCtxFinished func()
if c.cmdTimeout <= 0 {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContext(ctx, desc)
} else {
c.cmdCtx, c.cmdCtxCancel, cmdCtxFinished = process.GetManager().AddContextTimeout(ctx, c.cmdTimeout, desc)
}
要点:
- 超时 由
WithTimeout(d)设定,登记进modules/process的进程管理器(第 1 章 1.4.4 提到的同一套process.GetManager()),从而gitea admin的进程监控页能看到每条 git 命令。未设超时则只受父 ctx 控制。 - 追踪 用
gtprofspan,属性含调用方函数名与命令串------这是性能排查(/admin/monitor/perftrace)的数据来源,也间接说明任务描述里猜的git.RepoModuleTime并不存在:耗时观测是统一走 span 的。 - 日志脱敏 :
LogString()先经logArgSanitize,绝对路径折叠成.../父目录/文件名,其余走util.SanitizeCredentialURLs剥掉 URL 里的凭据。函数注释坦承"for debugging purposes only",因为跨平台的参数引用没有 100% 正确的简单实现。
Wait() 的核心逻辑是 pipelineFunc------允许调用方在命令运行期间流式消费 stdout:
go
func (c *Command) Wait() error {
defer func() {
c.cmdFinished()
}()
if c.pipelineFunc != nil {
errPipeline := c.pipelineFunc(&cmdContext{Context: c.cmdCtx, cmd: c})
if context.Cause(c.cmdCtx) == nil {
// if the context is not canceled explicitly, we need to discard the unread data,
// and wait for the command to exit normally, and then get its exit code
c.discardPipeReaders(c.parentPipeReaders)
}
// after the pipeline function returns, we can safely close the pipes, then wait for the command to exit
c.closePipeFiles(c.parentPipeFiles)
errWait := c.cmd.Wait()
errCause := context.Cause(c.cmdCtx)
...
return errors.Join(wrapPipelineError(errPipeline), errCause, errWait)
}
...
}
这段代码处理了三个微妙的边界:
- pipeline 回调正常返回但没读完 stdout → 必须先排空(discard)管道,否则 git 写满管道缓冲区会阻塞退出,
cmd.Wait()永不返回。 - pipeline 主动取消(如"取够了 100 条就杀命令")→ 不排空,让 kill 直接生效,退出码由 kill 决定。
- 三类错误(回调错误、取消原因、退出码)用
errors.Join合并,同时保证回调错误仍可被UnwrapPipelineError取回以兼容旧代码的==比较。
6.4 节会看到 CommitsByFileAndRange 正是用 WithPipelineFunc 边读 SHA 边构造 Commit 的。
错误输出方面,RunWithStderr / RunStdString 把 stderr 收进 RunStdError,供上层 IsErrorExitCode(err, 1)、IsStderr(err, StderrNoMergeBase) 这类语义化错误匹配 使用------Gitea 不解析崩溃堆栈式的错误,而是把 git 的退出码和 stderr 关键字封装成可判断的错误值(repo.go 的 IsEmpty 判断退出码 1/129 就是典型)。
6.2.5 一个真实调用串起来看
以 IsEmpty 为例,完整展示了参数层、环境层、超时层的协作:
go
func (repo *Repository) IsEmpty(ctx context.Context) (bool, error) {
stdout, _, err := gitcmd.NewCommand().
AddOptionFormat("--git-dir=%s", gitrepo.RepoLocalPath(repo)). // TODO: all git commands should use "--git-dir" or "GIT_DIR=..."
AddArguments("rev-list", "-n", "1", "--all").
WithRepo(repo).
RunStdString(ctx)
if err != nil {
if (gitcmd.IsErrorExitCode(err, 1) && err.Stderr() == "") || gitcmd.IsErrorExitCode(err, 129) {
// git 2.11 exits with 129 if the repo is empty
return true, nil
}
return true, fmt.Errorf("check empty: %w", err)
}
return strings.TrimSpace(stdout) == "", nil
}
三行代码覆盖了本节全部知识点:AddOptionFormat 拼接选项(仓库路径含空格也安全,因为 %s 格式化后作为单个 argv 元素传入)、AddArguments 传写死的子命令、WithRepo 设工作目录、错误用退出码语义判断而非字符串匹配------退出码 1 且 stderr 为空(rev-list 无可枚举引用),或退出码 129("git 2.11 exits with 129 if the repo is empty",历史版本差异直接写进注释),都判为空仓库。行尾的 TODO 还提醒我们:Command.gitDir 字段那个 FIXME 的正确修法(全局 --git-dir)尚未完成,所以这里手动补了 --git-dir。
6.3 git 版本探测与全局 gitconfig 同步
6.3.1 启动期版本探测:InitSimple / InitFull
任务描述中猜测的 checkAndOpenCache 并不存在,真实入口是 modules/git/git.go 里的两个初始化函数:
go
const RequiredVersion = "2.25.0" // the minimum Git version required
// InitSimple initializes git module with a very simple step, no config changes, no global command arguments.
// This method doesn't change anything to filesystem. At the moment, it is only used by some Gitea sub-commands.
func InitSimple() error {
if setting.Git.HomePath == "" {
return errors.New("unable to init Git's HomeDir, incorrect initialization of the setting and git modules")
}
...
if err := gitcmd.SetExecutablePath(setting.Git.Path); err != nil {
return err
}
var err error
defaultFeatures, err = loadGitVersionFeatures()
if err != nil {
return err
}
if err = ensureGitVersion(); err != nil {
return err
}
// when git works with gnupg (commit signing), there should be a stable home for gnupg commands
if _, ok := os.LookupEnv("GNUPGHOME"); !ok {
_ = os.Setenv("GNUPGHOME", filepath.Join(gitcmd.HomeDir(), ".gnupg"))
}
return nil
}
// InitFull initializes git module with version check and change global variables, sync gitconfig.
// It should only be called once at the beginning of the program initialization (TestMain/GlobalInitInstalled) as this code makes unsynchronized changes to variables.
func InitFull() (err error) {
if err = InitSimple(); err != nil {
return err
}
return syncGitConfig(context.Background())
}
两级初始化对应两类进程:主服务、gitea hook / gitea serv 这类由 git 拉起的子命令只需要 InitSimple(不碰全局 gitconfig,避免并发写冲突);只有主进程启动时调 InitFull,多做一步 syncGitConfig。SetExecutablePath 内部用 exec.LookPath 解析 setting.Git.Path(默认 git),解析结果存进 gitcmd.GitExecutable,所有 NewCommand 共用。
loadGitVersionFeatures() 执行 git version 并解析版本行:
go
func parseGitVersionLine(s string) (*version.Version, error) {
fields := strings.Fields(s)
if len(fields) < 3 {
return nil, fmt.Errorf("invalid git version: %q", s)
}
// version output is like: "git version {versionString}"
// versionString can be:
// * "2.5.3"
// * "2.29.3.windows.1"
// * "2.28.0.618.gf4bc123cb7"
versionString := fields[2]
versionFields := strings.Split(versionString, ".")
if len(versionFields) > 3 {
versionFields = versionFields[:3]
}
return version.NewVersion(strings.Join(versionFields, "."))
}
截取前三段是为兼容 2.28.0.618.gf4bc123cb7 这种带构建后缀的 Debian 式版本号(注释里直接附了 issue #12731 的链接);而 2.29.3.windows.1 这类 Git for Windows 版本号截断后也能正常比较。
版本转成能力表(Features 结构),这是全系统"按 git 版本选择实现"的唯一依据:
| 能力字段 | 条件 | 影响的功能 |
|---|---|---|
SupportProcReceive |
git ≥ 2.29 | AGit-flow(refs/for 推送创建 PR) |
SupportHashSha256 |
git ≥ 2.42 且非 gogit | SHA-256 仓库创建与 --object-format 参数 |
SupportCatFileBatchCommand |
git ≥ 2.36 | cat-file --batch-command(否则降级 legacy --batch) |
SupportCheckAttrOnBare |
git ≥ 2.40 | 裸仓库上 git check-attr |
SupportGitMergeTree |
git ≥ 2.40 | git merge-tree --merge-base(冲突预检) |
ensureGitVersion() 先卡最低版本 2.25.0(对 CentOS/RHEL 用户给出 ius.io 安装链接的特殊提示),再走黑名单:
go
func checkGitVersionCompatibility(gitVer *version.Version) error {
badVersions := []struct {
Version *version.Version
Reason string
}{
{version.Must(version.NewVersion("2.43.1")), "regression bug of GIT_FLUSH"},
}
for _, bad := range badVersions {
if gitVer.Equal(bad.Version) {
return errors.New(bad.Reason)
}
}
return nil
}
黑名单只精确匹配 2.43.1------这个版本的 GIT_FLUSH 回归 bug 会破坏 Gitea 依赖管道流式输出的命令(如 cat-file 批处理、pipelineFunc)。用"精确版本拒绝"而非"最低版本抬高",是为了不误伤已修复的 2.43.2+。这种版本黑名单机制在其他自研系统里很少见,但对深度绑定 git 内部行为的 Gitea 来说是必要的保险。
6.3.2 syncGitConfig:为 Gitea 定制的全局 gitconfig
modules/git/config.go 的 syncGitConfig 在主进程启动时重写 Gitea 私有 gitconfig(HOME 指向这里,所以 --global 实际写的是 Gitea 的文件)。逐项配置及其动机:
go
// Set git some configurations - these must be set to these values for gitea to work correctly
if err := configSet(ctx, "core.quotePath", "false"); err != nil { ... }
if err := configSet(ctx, "receive.advertisePushOptions", "true"); err != nil { ... }
// reject malformed objects on push and fetch, e.g. duplicate tree entries
// that the web UI and checkout can resolve differently
if err := configSet(ctx, "transfer.fsckObjects", "true"); err != nil { ... }
| 配置项 | 值 | 目的(源码注释摘要) |
|---|---|---|
user.name / user.email |
Gitea / gitea@fake.local |
服务端合成提交(如 squash、迁移)的兜底签名 |
core.quotePath |
false |
路径不转义 UTF-8,web UI 才能正确显示中文文件名 |
receive.advertisePushOptions |
true |
客户端 push -o 才能协商到 push options |
transfer.fsckObjects |
true |
拒绝畸形对象(如重复 tree 条目,否则 UI 与 checkout 解析结果不一致) |
fsck.badTimezone 等 9 项 |
ignore |
放过真实历史里的无害问题(Grit 时代的时区/零填充 mode),注释附 Gitaly 同款配置的链接 |
core.commitGraph / gc.writeCommitGraph / fetch.writeCommitGraph |
true |
全面启用 commit-graph,大仓库的历史遍历提速 |
receive.procReceiveRefs |
refs/for |
AGit-flow 的前提(仅 ≥2.29 时添加,低版本反而 --unset-all) |
safe.directory |
* |
CVE-2022-24765 后 git 拒绝访问非属主仓库;Gitea 内部仓库归属 Gitea 用户,容器/Samba 场景难以配置属主,故放行(注释附 issue #19455) |
core.longpaths(Windows) |
true |
突破 260 字符路径限制 |
uploadpack.allowfilter / allowAnySHA1InWant |
true |
支持 partial clone(可被 DisablePartialClone 关闭) |
用户自定义 [git.config] 选项 |
--- | 最后写入,覆盖以上内置默认值 |
函数开头还有个细节:configSet 先 --get 比对当前值,相同则跳过写入------避免每次启动都改写文件时间戳。而 configUnsetAll / configAddNonExist 处理"多值键"的增删,都用 regexp.QuoteMeta(value) 转义后再做匹配。
最后两行代码藏着一个 git 上游 bug 的规避:
go
GlobalConfig = &GlobalConfigStruct{}
// HINT: GIT-DIFF-TREE-UI-CONFIG: Git's bug: git-diff-tree loads config with /* no "diff" UI options */ (since 20 years ago).
// Although document and manual say that "git-diff-tree" supports "diff.orderfile" option, but it is not actually supported.
// So we need to apply the diff.orderfile explicitly in our code.
GlobalConfig.DiffOrderFile, _ = configGet(ctx, "diff.orderfile")
git-diff-tree 历史上不加载 diff UI 配置(blame 链接直指 git 源码 builtin/diff-tree.c),所以 diff.orderfile 要由 Gitea 自己读出来、显式传参。把上游 bug 记录在代码里并给出规避路径,是这套配置层的重要价值。
6.3.3 全局锁:LockConfigAndDo / LockWriteAndDo
有些操作(改分支 HEAD、fetch)必须串行化,git.go 提供两个锁封装:
go
func LockConfigAndDo(ctx context.Context, repo RepositoryFacade, fn func(ctx context.Context) error) error {
return globallock.LockAndDo(ctx, "repo-config:"+repo.GitRepoManagedID(), fn)
}
func LockWriteAndDo(ctx context.Context, repo RepositoryFacade, fn func(ctx context.Context) error) error {
return globallock.LockAndDo(ctx, "repo-write:"+repo.GitRepoManagedID(), fn)
}
锁键用 GitRepoManagedID()(repo-<id>),意味着锁粒度是"单个仓库",且跨进程有效(globallock 在多实例部署下走共享存储,见第 4 章)。fetch(modules/git/fetch.go 的 FetchRemoteCommit)与改写 gitconfig 的操作都包在这里面,防止并发写坏 packed-refs 或配置文件。非托管仓库调用会 panic------又一处 6.1.1 提到的 fail fast。
6.4 对象读取链路:cat-file 批处理是心脏
Gitea 的所有对象读取(提交、树、blob、tag、blame、分支 ID)最终都汇聚到 CatFileBatch------这是本节的主角。
6.4.1 为什么需要批处理
朴素做法是每读一个对象起一条 git cat-file -p <oid>:进程启动开销(Windows 上尤甚)+ 每次都要解析对象。而一页文件列表可能涉及几十上百个对象,页面渲染一次就是上百次 fork。
Gitea 的方案:在仓库句柄上缓存一条常驻的 git cat-file --batch-command 进程,用 stdin/stdout 管道对话 。接口定义在 modules/git/catfile_batch.go:
go
type CatFileBatch interface {
// QueryInfo queries the object info from the git repository by its object name using "git cat-file --batch" family commands.
// "git cat-file" accepts "<rev>" for the object name, it can be a ref name, object id, etc.
QueryInfo(obj string) (*CatFileObject, error)
// QueryContent is similar to QueryInfo, it queries the object info and additionally returns a reader for its content.
// FIXME: this design still follows the old pattern: the returned BufferedReader is very fragile,
// callers should carefully maintain its lifecycle and discard all unread data.
// TODO: It needs to be refactored to a fully managed Reader stream in the future, don't let callers manually Close or Discard
QueryContent(obj string) (*CatFileObject, BufferedReader, error)
}
// NewBatch creates a "batch object provider (CatFileBatch)" for the given repository path to retrieve object info and content efficiently.
// The CatFileBatch and the readers create by it should only be used in the same goroutine.
func NewBatch(ctx context.Context, repo RepositoryFacade) (CatFileBatchCloser, error) {
repoPath := gitrepo.RepoLocalPath(repo)
if _, err := os.Stat(repoPath); err != nil {
return nil, util.NewNotExistErrorf("repo %q doesn't exist", repo.LogString())
}
if DefaultFeatures().SupportCatFileBatchCommand {
return newCatFileBatchCommand(ctx, repo), nil
}
return newCatFileBatchLegacy(ctx, repo), nil
}
按 6.3.1 的能力表分叉:git ≥ 2.36 用新的 --batch-command(可发 info/contents 命令),否则用 legacy --batch(一次查询只能拿到 info 或内容)。两个注释也坦承了设计债:QueryContent 返回的 reader "very fragile",调用方必须读完或 discard 剩余字节------这是本节反复出现的流对齐问题。
6.4.2 --batch-command 协议实现
catfile_batch_command.go 展示了协议交互(仅 75 行,非常紧凑):
go
func (b *catFileBatchCommand) getBatch(callerInfo string) *catFileBatchCommunicator {
if b.batch != nil {
return b.batch
}
b.batch = newCatFileBatch(b.ctx, b.repo, gitcmd.NewCommand("cat-file", "--batch-command").WithParentCallerInfo(callerInfo))
return b.batch
}
func (b *catFileBatchCommand) QueryInfo(obj string) (*CatFileObject, error) {
if strings.Contains(obj, "\n") {
setting.PanicInDevOrTesting("invalid object name with newline: %q", obj)
}
batch := b.getBatch(util.CallerFuncName(1))
_, err := batch.reqWriter.Write([]byte("info " + obj + "\n"))
if err != nil {
return nil, err
}
return catFileBatchParseInfoLine(batch.respReader)
}
协议本身是文本行:向 stdin 写 info <obj>\n 或 contents <obj>\n,从 stdout 读回形如 <oid> SP <type> SP <size> LF 的头行。对象名里禁止换行 是硬约束------否则用户提供的 ref 名可以注入第二条命令(比如 foo\ncontents <其他对象>)。开发/测试环境直接 panic 暴露,生产环境 setting.PanicInDevOrTesting 按配置降级为日志。这是参数注入防护在协议层 的延伸:命令行有 CmdArg 类型,stdin 协议有换行符检查。
头部解析在 catfile_batch_reader.go:
go
// catFileBatchParseInfoLine reads the header line from cat-file --batch
// We expect: <oid> SP <type> SP <size> LF
// then leaving the rest of the stream "<contents> LF" to be read
func catFileBatchParseInfoLine(rd BufferedReader) (*CatFileObject, error) {
typ, err := rd.ReadString('\n')
if err != nil {
return nil, err
}
if len(typ) == 1 {
typ, err = rd.ReadString('\n')
if err != nil {
return nil, err
}
}
idx := strings.IndexByte(typ, ' ')
if idx < 0 {
return nil, ErrNotExist{}
}
sha := typ[:idx]
typ = typ[idx+1:]
idx = strings.IndexByte(typ, ' ')
if idx < 0 {
return nil, ErrNotExist{ID: sha}
}
sizeStr := typ[idx+1 : len(typ)-1]
...
size, err := strconv.ParseInt(sizeStr, 10, 64)
return &CatFileObject{ID: sha, Type: typ, Size: size}, err
}
len(typ) == 1 的分支是在容忍上一次查询残留的换行------批处理协议里每一字节的错位都会污染后续所有查询 ,这行容错代码正是长期运行的协议客户端必须处理的边界。对象不存在时 git 返回 <oid> missing\n(只有一个空格分隔),解析结果 idx < 0 → ErrNotExist{},与查询失败区分开。
底层通信器 catFileBatchCommunicator 的生命周期管理同样值得读:
go
func newCatFileBatch(ctx context.Context, repo RepositoryFacade, cmdCatFile *gitcmd.Command) *catFileBatchCommunicator {
ctx, ctxCancel := context.WithCancelCause(ctx)
stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe()
ret := &catFileBatchCommunicator{
debugGitCmd: cmdCatFile,
reqWriter: stdinWriter,
respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations
closed: make(chan struct{}),
}
ret.closeFunc.Store(new(func(err error) {
ctxCancel(err)
stdPipeClose()
}))
err := cmdCatFile.WithParentCallerInfo().WithRepo(repo).StartWithStderr(ctx)
if err != nil {
log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err)
// ideally here it should return the error, but it would require refactoring all callers
// so just return a dummy communicator that does nothing, almost the same behavior as before, not bad
close(ret.closed)
ret.Close(err)
return ret
}
go func() {
err := cmdCatFile.WaitWithStderr()
if err != nil && !errors.Is(err, context.Canceled) {
log.Error("cat-file --batch command failed in repo %s, error: %v", repo.LogString(), err)
}
close(ret.closed)
ret.Close(err)
}()
return ret
}
三个设计点:
closeFunc用atomic.Pointer存储并Swap(nil)保证只执行一次(进程退出的 goroutine 与调用方 Close 可能竞争)。Close()会<-b.closed等待 git 进程真正退出------注释解释了原因:"otherwise, the opened files will block the directory renaming (rename a repo) on Windows"。Windows 上被进程占用的目录无法重命名,这在仓库改名/转移时会出错。一个平台差异决定了同步语义。- 启动失败时返回"哑"通信器而非报错,注释承认是为了避免重构所有调用方------务实的兼容层。
6.4.3 仓库级复用:为什么 RepositoryBase 保留 ctx
回到 repo.go 里那段关键注释(RepositoryBase 结构体定义):
go
type RepositoryBase struct {
LastCommitCache *LastCommitCache
repoFacade RepositoryFacade
tagCache *ObjectCache[*Tag]
objectFormatCache ObjectFormat
mu sync.Mutex
// Unfortunately, we can't completely remove the ctx, because CatFileBatch still heavily depends on a parent context.
// If we remove the ctx, then CatFileBatch's process management will become a mess and create a lot of unnecessary git processes.
// ref: http://localhost:3000/-/admin/monitor/perftrace
// The root problem is that some functions like "GetCommit" need to use CatFileBatch,
// if CatFileBatch accepts its own ctx, then every sub-context needs a git process.
// e.g.: open a repo home, dozens of git processes (duplicate cat-file)
// ATTENTION: this ctx is for cached cat-file process only, don't use it for other purposes.
catFileBatchCtx context.Context
catFileBatchCloser CatFileBatchCloser
catFileBatchInUse bool
}
这段注释回答了一个架构问题:为什么 GetCommit(ctx, ...) 既收业务 ctx,仓库还要另存一个 catFileBatchCtx?因为若 cat-file 进程挂在业务 ctx 上,请求级、子任务级的 ctx 取消会反复杀死/重启进程------"open a repo home, dozens of git processes"。所以进程的生命周期绑定到打开仓库时的父 ctx(通常覆盖整个请求),而业务 ctx 只管单次查询的取消语义。
复用逻辑由 CatFileBatch() 方法实现,是教科书级的"独占缓存 + 溢出新建":
go
// CatFileBatch obtains a "batch object provider" for this repository.
// It reuses an existing one if available, otherwise creates a new one.
func (repo *Repository) CatFileBatch() (_ CatFileBatch, closeFunc func(), err error) {
repo.mu.Lock()
defer repo.mu.Unlock()
// if no cached batcher, make a new managed one, and cache it
if repo.catFileBatchCloser == nil {
repo.catFileBatchCloser, err = NewBatch(repo.catFileBatchCtx, repo)
if err != nil {
repo.catFileBatchCloser = nil // otherwise it is "interface(nil)" and will cause wrong logic
return nil, nil, err
}
}
// if the cached batcher is not in use, return it
if !repo.catFileBatchInUse {
repo.catFileBatchInUse = true
return CatFileBatch(repo.catFileBatchCloser), func() {
repo.mu.Lock()
defer repo.mu.Unlock()
repo.catFileBatchInUse = false
}, nil
}
// return a temp one (won't be cached or shared)
tempBatch, err := NewBatch(repo.catFileBatchCtx, repo)
if err != nil {
return nil, nil, err
}
return tempBatch, tempBatch.Close, nil
}
三种状态:无缓存 → 新建并缓存;缓存空闲 → 借出(closeFunc 只是归还标记);缓存被占 → 新建临时批处理器。为什么不能并发共享一条?因为 stdin/stdout 是交错的流,两个协程同时发查询会互相读到对方的响应。所以一个仓库句柄同一时刻只有一个"主"批处理器在用------这也解释了 6.1.3 请求级复用的价值:把句柄共享给请求内所有页面组件,批处理器才能持续复用。
注意 catFileBatchCloser = nil 那行的注释:otherwise it is "interface(nil)" and will cause wrong logic------接口持有非 nil 类型的 nil 指针时,== nil 判定为 false 的经典 Go 陷阱,作者用注释锁死正确写法。
6.4.4 Commit 读取:tag 解引用与流对齐
repo_commit_nogogit.go 的 getCommitWithBatch 是批处理器最典型的消费者:
go
func (repo *Repository) getCommitWithBatch(batch CatFileBatch, id ObjectID) (*Commit, error) {
info, rd, err := batch.QueryContent(id.String())
if err != nil {
if errors.Is(err, io.EOF) || IsErrNotExist(err) {
return nil, ErrNotExist{ID: id.String()}
}
return nil, err
}
switch info.Type {
case "missing":
return nil, ErrNotExist{ID: id.String()}
case "tag":
limitReader, limitDiscard := limitDiscardReader(rd, info.Size, MaxGitObjectSize)
data, err := io.ReadAll(limitReader)
if err != nil {
return nil, err
}
if err = limitDiscard(); err != nil {
return nil, err
}
_, err = rd.Discard(1)
...
tag, err := parseTagData(id.Type(), data)
...
return repo.getCommitWithBatch(batch, tag.Object) // 递归解引用 annotated tag
case "commit":
limitReader, limitDiscard := limitDiscardReader(rd, info.Size, MaxGitObjectSize)
commit, err := CommitFromReader(id, limitReader)
...
_, err = rd.Discard(1) // 丢弃内容后的 LF,保持流对齐
...
return commit, nil
default:
if info.Type != "blob" && info.Type != "tree" {
setting.PanicInDevOrTesting("Unknown cat-file object type %s ...")
}
if err := DiscardFull(rd, info.Size+1); err != nil {
return nil, err
}
return nil, ErrNotExist{ID: id.String()}
}
}
逐个分支解析:
tag分支 :传入的可能是 annotated tag 对象,读出 tag 数据后递归 取tag.Object直到遇见commit。这是GetCommit("v1.0")能对 tag 名生效的原因。commit分支 :CommitFromReader从流里解析提交对象,然后rd.Discard(1)丢掉 git 在内容后附加的换行------不丢这 1 字节,下一个查询的头行解析就会错位。这就是 6.4.1 注释里 "discard all unread data" 的具体所指。default分支 :查询提交却得到 blob/tree(如 ref 指向文件路径),完整丢弃内容(DiscardFull(rd, info.Size+1))后返回ErrNotExist------丢弃是必须的,不丢同样会污染流。开发期还会对未知类型 panic。limitDiscardReader:MaxGitObjectSize(repo_object.go中为100 * 1024 * 1024,注释注明"GitHub has a limit (100M) for pushing")限制单次读入内存的大小,超出部分用DiscardFull跳过。DiscardFull自己还处理了bufio.Discard超过MaxInt32的分段循环------一个看似不起眼的溢出防护。
MaxGitObjectSize 的注释同时暴露了现状:Gitea 自身还没有推送大小上限,所以读侧必须自设防线防 OOM。
ConvertToGitID 展示了"先本地判断、再问 git"的优化:
go
func (repo *Repository) ConvertToGitID(ctx context.Context, ref string) (ObjectID, error) {
objectFormat, err := repo.GetObjectFormat(ctx)
...
if IsStringValidObjectID(objectFormat, ref) {
id, err := NewIDFromString(ref)
if err == nil {
return id, nil
}
}
batch, cancel, err := repo.CatFileBatch()
...
info, err := batch.QueryInfo(ref)
...
return MustIDFromString(info.ID), nil
}
ref 本身就是合法 OID 时完全跳过进程交互。GetObjectFormat 也带缓存(objectFormatCache),首次通过 git hash-object --stdin 灌空输入、从返回哈希长度推断 sha1/sha256------避免解析 git rev-parse --show-object-format(老版本 git 不支持)。
6.4.5 Tree / Blob / 日志查询
树的读取 tree_nogogit.go 展示了"批处理优先 + 命令兜底":
go
func (t *Tree) ListEntries(ctx context.Context, gitRepo *Repository) (Entries, error) {
if t.entriesParsed {
return t.entries, nil
}
batch, cancel, err := gitRepo.CatFileBatch()
...
info, rd, err := batch.QueryContent(t.ID.String())
...
if info.Type == "commit" { // 传进来的是子模块 commit → 取其 tree
treeID, err := ReadTreeID(rd, info.Size)
...
info, rd, err = batch.QueryContent(treeID)
}
if info.Type == "tree" {
t.entries, err = catBatchParseTreeEntries(t.ID.Type(), t, rd, info.Size)
...
t.entriesParsed = true
return t.entries, nil
}
// Not a tree just use ls-tree instead
if err := DiscardFull(rd, info.Size+1); err != nil {
return nil, err
}
stdout, _, runErr := gitcmd.NewCommand("ls-tree", "-l").AddDynamicArguments(t.ID.String()).WithRepo(gitRepo).RunStdBytes(ctx)
...
}
注意兜底前的 DiscardFull------再次强调流对齐。而 tree 条目的二进制格式解析(ParseCatFileTreeLine)效率意识很强:用 rd.ReadSlice('\x00') 直接复用缓冲区内存,name 必须 copy(bufBytes 会被 reader 复用),OID 按 objectFormat 分配 Sha1Hash/Sha256Hash 后 io.ReadFull 读 20/32 字节。
路径导航 GetTreeEntryByPath(tree_blob_nogogit.go)则是逐段 SubTree + ListEntries:
go
func (t *Tree) GetTreeEntryByPath(ctx context.Context, gitRepo *Repository, relpath string) (_ *TreeEntry, err error) {
...
relpath = path.Clean(relpath)
parts := strings.Split(relpath, "/")
tree := t
for _, name := range parts[:len(parts)-1] {
tree, err = tree.SubTree(ctx, gitRepo, name)
...
}
name := parts[len(parts)-1]
entries, err := tree.ListEntries(ctx, gitRepo)
...
for _, v := range entries {
if v.Name() == name {
return v, nil
}
}
return nil, ErrNotExist{"", relpath}
}
path.Clean 消除 ../ 之类的穿越再按 / 拆分------每一级都是一次 cat-file 查询,所以 entriesParsed 缓存(Tree 结构体字段)对同一页面多次导航很重要。
blob 的流式读取(blob_nogogit.go)把"reader 生命周期"演绎得最完整:
go
func (b *Blob) DataAsync(ctx context.Context) (_ io.ReadCloser, retErr error) {
batch, cancel, err := b.repo.CatFileBatch()
if err != nil {
return nil, err
}
defer func() {
// if there was an error, cancel the batch right away,
// otherwise let the caller close it
if retErr != nil {
cancel()
}
}()
info, contentReader, err := batch.QueryContent(b.ID.String())
...
return &blobReader{
rd: contentReader,
n: info.Size,
cancel: cancel,
}, nil
}
返回的 blobReader 记住剩余字节数 n,Read 不会越过对象边界(防止读进下一个响应);Close 则:
go
func (b *blobReader) Close() error {
...
defer b.cancel()
if err := DiscardFull(b.rd, b.n+1); err != nil {
return err
}
b.rd = nil
return nil
}
即使调用方只读了文件前几 KB 就放弃(web UI 的 http.ServeContent 断点续传、Range 请求会这样),Close 也会把剩余字节和尾部 LF 丢弃 ,让批处理器回到可用状态。cancel() 归还批处理器(或杀临时进程)。整个契约可以总结为:谁 QueryContent,谁负责把流消费到对象边界------6.4.4 的 commit 分支和这里的 blob reader 都是这条契约的实现。
6.4.6 blame:流式解析 porcelain 输出
blame.go 是又一个"长期流式命令"的例子:
go
func CreateBlameReader(ctx context.Context, objectFormat ObjectFormat, repo RepositoryFacade, gitRepo *Repository, commit *Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) {
...
cmd := gitcmd.NewCommand("blame", "--porcelain")
stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
rd.bufferedReader = bufio.NewReader(stdoutReader)
rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose)
if !bypassBlameIgnore {
ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(ctx, gitRepo, commit)
...
// Possible improvement: use --ignore-revs-file /dev/stdin on unix
// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName)
}
cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)
go func() {
rd.done <- cmd.WithRepo(repo).RunWithStderr(ctx)
}()
return rd, nil
}
要点:
- 命令在 goroutine 里跑,调用方通过
NextPart()惰性消费--porcelain输出,按 SHA 变化切分BlamePart(连续同提交的行块),previous头行解析出PreviousSha/PreviousPath供 UI 做跨文件 blame 跳转。 .git-blame-ignore-revs支持:先从仓库里读该文件(tryCreateBlameIgnoreRevsFile用GetTreeEntryByPath→DataAsync),写到临时文件,再传--ignore-revs-file。注释说明了为什么绕道临时文件------/dev/stdin方案在 Windows 没有等价物。LineBlame提供单行查询的轻量版本(-L n,n -p),AddOptionFormat("-L %d,%d", line, line)又一次演示了格式化选项的安全拼接。- TODO 注释承认缺陷:错误从
Close而非Read返回、目录不该被 blame------读源码时把这类 TODO 当作"已知边界"记录。
6.4.7 日志与缓存:CommitsByFileAndRange、LastCommitCache
日志查询普遍用 --pretty=format:%H 只取 SHA,再逐个 getCommit(走批处理器)------避免 git log 全字段输出的解析成本。repo_commit.go 里 CommitsByFileAndRange 是 pipelineFunc 的最佳示例:
go
stdoutReader, stdoutReaderClose := gitCmd.MakeStdoutPipe()
defer stdoutReaderClose()
err := gitCmd.WithRepo(repo).
WithPipelineFunc(func(context gitcmd.Context) error {
objectFormat, err := repo.GetObjectFormat(ctx)
...
length := objectFormat.FullLength()
shaline := make([]byte, length+1)
for {
n, err := io.ReadFull(stdoutReader, shaline)
if err != nil || n < length {
if err == io.EOF {
err = nil
}
return err
}
objectID, err := NewIDFromString(string(shaline[0:length]))
...
commit, err := repo.getCommit(ctx, objectID)
...
commits = append(commits, commit)
}
}).
RunWithStderr(ctx)
FullLength()+1 定长读入(SHA-1 是 40+1、SHA-256 是 64+1)------按 objectFormat 分派长度 ,同一份代码兼容两种哈希。查询多取一条(--max-count=%d 里传 limit+1)用于判断 hasMore,是分页的经典手法。
其余日志变体快速罗列(都遵循同一模式):
| 函数 | git 命令要点 | 场景 |
|---|---|---|
commitsByRangeWithTime |
log --skip --max-count --since --until |
分页 + 时间过滤 |
searchCommits |
log -100 --fixed-strings --grep --author,另对疑似 SHA 的关键词补查 |
提交搜索 |
CommitsBetween |
rev-list A..B,无 merge-base 时回退单边(IsStderr(err, StderrNoMergeBase)) |
两 ref 差集 |
getBranches |
for-each-ref --format=%(refname:strip=2) --count --contains |
某提交在哪些分支(找分叉点) |
HasPreviousCommit |
merge-base --is-ancestor,退出码 1 且 stderr 为空 = 否 |
祖先判定 |
IsCommitInBranch |
branch --contains |
分支包含判定 |
其中 CommitsBetween 的回退逻辑值得记:新版 git 对无共同祖先的 A..B 直接报错,Gitea 捕获 StderrNoMergeBase 后退化为单边 log------把 git 版本行为差异封装在函数内部,调用方无感。
缓存层面有两级:
LastCommitCache (last_commit_cache.go)------文件列表页每个目录项的"最后提交":
go
func getCacheKey(repo RepositoryFacade, commitID, entryPath string) string {
return cache.SafeCacheKey(fmt.Sprintf("git-last-commit:%s:%s", repo.GitRepoManagedID(), commitID), entryPath)
}
第一层是外部 cache(Redis 等,TTL setting.LastCommitCacheTTLSeconds),存的是 commitID 字符串;第二层是仓库对象内存里的 commitCache map[string]*Commit,避免同一 commitID 反复反序列化。miss 时回落到 getCommitByPathWithID(git log -1 <id> -- <path>)并回填。缓存键含 commitID------HEAD 变了键自然失效,这是它不需要主动清理的设计巧思。
CommitsCountCache (commit_count_cache.go)------分支提交数:
go
func makeCommitsCountCacheKey(repo RepositoryFacade, ref RefName) string {
return cache.SafeCacheKey("git-commits-count:"+repo.GitRepoManagedID(), ref.String())
}
同样的 managedID 前缀;push 时由 pushUpdateBranch 显式调 git.RemoveCommitsCountCache(repo, opts.RefFullName) 失效(见 6.7)。
两处缓存键都用 GitRepoManagedID()------仓库删除/转移时 ID 变化,旧缓存自然过期,也再次印证 RepositoryFacade 存在的意义。
6.5 refs、裸仓库布局与仓库创建
6.5.1 RefName:引用的强类型封装
modules/git/ref.go 定义了全系统通用的引用类型 type RefName string,以及一族前缀常量与判定方法:
go
const (
// RemotePrefix is the base directory of the remotes information of git.
RemotePrefix = "refs/remotes/"
// PullPrefix is the base directory of the pull information of git.
PullPrefix = "refs/pull/"
pullSuffix = "/head"
)
// ForPrefix special ref to create a pull request: refs/for/<target-branch>/<topic-branch>
// or refs/for/<targe-branch> -o topic='<topic-branch>'
const ForPrefix = "refs/for/"
判定与取名方法(IsBranch/IsTag/IsPull/IsFor → BranchName/TagName/ForBranchName/PullIndex)全部是前缀裁剪,没有正则开销。三个特别的设计:
RefGroup()映射到.git/refs下的目录名 (heads/tags/remotes/pull/for),注释明确它与RefType()(UI/webhook 用的branch/tag/commit)是两套词汇------不要混用。RefNameFromCommit的 fail fast :传入非小写十六进制时开发期直接 panic(setting.PanicInDevOrTesting("BUG! invalid commit id")),生产期降级为refs/invalid-commit/...的假引用,防止把用户输入当 OID 拼进 git 命令。refNamePatternInvalid正则 复刻了 git 的check-ref-format规则(控制字符、..、//、.lock后缀、@{等),IsValidRefPattern与SanitizeRefPattern(非法字符替换为_)为 UI 侧创建分支提供校验与消毒。
改写引用只有两个低层原语:
go
func UpdateRef(ctx context.Context, repo RepositoryFacade, refName, newCommitID string) error {
return gitcmd.NewCommand("update-ref").AddDynamicArguments(refName, newCommitID).WithRepo(repo).Run(ctx)
}
func RemoveRef(ctx context.Context, repo RepositoryFacade, refName string) error {
return gitcmd.NewCommand("update-ref", "--no-deref", "-d").AddDynamicArguments(refName).WithRepo(repo).Run(ctx)
}
--no-deref 确保删除的是符号引用本身而非其指向------两处参数都是 AddDynamicArguments,因为 ref 名可能来自 PR 编辑等用户输入。
另外还有 ParseRefSuffix:支持 main~2^、@{upstream} 这类 revision 后缀的切分(取最早的后缀标记位),供 UI 输入框解析。这是 Gitea 对 git revision 语法的部分支持------6.4.1 的接口注释也说过"we only use the simple ref name or object id, no other complex rev syntax",两者配合构成完整的取舍说明。
6.5.2 裸仓库磁盘布局
Gitea 的仓库全部是裸仓库,路径由 gitrepo/dbrepo.go 决定:
go
func RepoCodeGitRepoRelativePath(ownerName, repoName string) string {
return util.PathJoinRelX(strings.ToLower(ownerName), strings.ToLower(repoName)+".git")
}
func RepoWikiGitRepoRelativePath(ownerName, repoName string) string {
return util.PathJoinRelX(strings.ToLower(ownerName), strings.ToLower(repoName)+".wiki.git")
}
结合 6.1.2 的 RepoLocalPath,完整布局如下(默认根为 AppDataPath/gitea-repositories):
bash
<AppDataPath>/gitea-repositories/
├── alice/ ← owner 小写
│ ├── hello.git/ ← 代码仓库(裸)
│ │ ├── HEAD
│ │ ├── config ← 仓库级配置(receive.procReceiveRefs 等)
│ │ ├── description
│ │ ├── hooks/ ← 6.6 节:dispatcher + hooks/*.d/
│ │ │ ├── pre-receive
│ │ │ ├── pre-receive.d/gitea
│ │ │ ├── update / update.d/gitea
│ │ │ ├── post-receive / post-receive.d/gitea
│ │ │ └── proc-receive
│ │ ├── info/ ← update-server-info 的产物(info/refs)
│ │ ├── objects/
│ │ │ ├── aa/... ← loose objects
│ │ │ ├── pack/*.pack/*.idx
│ │ │ ├── info/alternates
│ │ │ └── info/commit-graph ← syncGitConfig 开启 gc.writeCommitGraph
│ │ ├── packed-refs
│ │ ├── refs/heads/、refs/tags/
│ │ ├── refs/pull/N/head ← PR 引用(web UI 合并用)
│ │ └── tmp_objdir_* ← push 隔离区(quarantine,6.7 节)
│ └── hello.wiki.git/ ← wiki 仓库
└── bob/...
几个要点:
- owner/repo 都转小写 ------
UserLocalPath也做了strings.ToLower(userName)。这是 Windows/macOS 大小写不敏感文件系统的必然要求:同一 owner 的仓库必须折叠到一个目录,否则Alice/和alice/会在 Linux 上成为两个目录、在 Windows 上互相覆盖。 - DB 侧的托管 ID(
models/repo/repo_location.go)形如repo-<id>(代码库)与repo-wiki-<id>(wiki),只用于缓存键/锁键,不参与路径计算------路径始终由 owner/repo 名推导,这样迁移仓库时只改 DB 的 name 字段再 rename 目录即可。 RepoRootPath可整体重指到另一块磁盘([repository].ROOT),仓库目录因此具备"可搬运性"。
6.5.3 仓库创建:CreateRepositoryDirectly 的七步
services/repository/create.go 的主函数用编号注释把流程钉死:
go
// 1 - create the repository database operations first
err := db.WithTx(ctx, func(ctx context.Context) error {
return createRepositoryInDB(ctx, doer, owner, repo, false)
})
...
defer func() {
if err != nil {
// we can not use `ctx` because it may be canceled or timed out
cleanupRepository(repo)
}
}()
...
// 2 - check whether the repository with the same storage exists
isExist, err = git.IsRepositoryExist(ctx, repo)
...
// 3 - init git repository in storage
if err = initRepository(ctx, doer, repo, opts); err != nil { ... }
// 4 - Initialize Issue Labels if selected
...
// 5 - update the git repository
if err = updateGitRepoAfterCreate(ctx, repo); err != nil { ... }
// 6 - update licenses
...
// 7 - update repository status to be ready
顺序设计的关键在先 DB 后文件 + defer 清理 :先在事务里插入记录(拿到自增 ID 决定路径),随后所有文件操作失败都由 defer 调 cleanupRepository(内部走 DeleteRepositoryDirectly)回滚;注释特别强调清理不能用已取消的 ctx,改用 graceful.GetManager().ShutdownContext()------失败路径不能依赖可能已死的 context ,这与第 3 章中间件超时的语义一致。第 2 步"检查磁盘同名目录"防止孤儿目录被当成新仓库初始化出数据错乱(返回 ErrRepoFilesAlreadyExist 而非静默覆盖)。
核心的 initRepository:
go
func initRepository(ctx context.Context, u *user_model.User, repo *repo_model.Repository, opts CreateRepoOptions) (err error) {
// Init git bare new repository.
if err = git.InitRepository(ctx, repo, repo.ObjectFormatName); err != nil {
return fmt.Errorf("git.InitRepository: %w", err)
} else if err = git.CreateDelegateHooks(ctx, repo); err != nil {
return fmt.Errorf("createDelegateHooks: %w", err)
}
repo.DefaultBranch = util.IfZero(opts.DefaultBranch, setting.Repository.DefaultBranch)
repo.DefaultWikiBranch = setting.Repository.DefaultBranch
if !opts.AutoInit {
repo.IsEmpty = true
}
...
// Initialize repository according to your choice.
if opts.AutoInit {
... prepareRepoCommit ... // 克隆到临时目录 + README/.gitignore/LICENSE
}
if err = git.SetDefaultBranch(ctx, repo, repo.DefaultBranch); err != nil { ... }
...
if _, err := repo_module.SyncRepoBranches(ctx, repo.ID, u.ID); err != nil { ... }
if err = repo_module.UpdateRepoSize(ctx, repo); err != nil { ... }
return nil
}
git.InitRepository 落到 localfs.go → InitRepositoryLocal(path, true, objectFormatName):
go
func InitRepositoryLocal(ctx context.Context, localRepoPath string, bare bool, objectFormatName string) error {
err := os.MkdirAll(localRepoPath, os.ModePerm)
...
cmd := gitcmd.NewCommand("init")
if !IsValidObjectFormat(objectFormatName) {
return fmt.Errorf("invalid object format: %s", objectFormatName)
}
if DefaultFeatures().SupportHashSha256 {
cmd.AddOptionValues("--object-format", objectFormatName)
}
if bare {
cmd.AddArguments("--bare")
}
_, _, err = cmd.WithDir(localRepoPath).RunStdString(ctx)
return err
}
- 裸仓库用
--bare;--object-format只在能力表允许时才传------老版本 git 收到未知选项会直接失败,所以能力探测(6.3.1)在这里兑现为具体行为。 - 创建后立即
CreateDelegateHooks安装 hook(6.6),再SetDefaultBranch(git symbolic-ref HEAD refs/heads/<name>,位于branch.go)。 - AutoInit(带 README 初始化)的实现很特别:先
git clone自己 (prepareRepoCommit→git.CloneRepoToLocal),在临时目录里写 README/.gitignore/LICENSE,initRepoCommit提交后再同步回来。克隆时通过环境变量注入作者签名:
go
env := append(os.Environ(),
"GIT_AUTHOR_NAME="+authorSig.Name,
"GIT_AUTHOR_EMAIL="+authorSig.Email,
"GIT_AUTHOR_DATE="+commitTimeStr,
"GIT_COMMITTER_NAME="+authorSig.Name,
"GIT_COMMITTER_EMAIL="+authorSig.Email,
"GIT_COMMITTER_DATE="+commitTimeStr,
)
注释提醒"Because this may call hooks we should pass in the environment"------首次 clone 会触发 hook,环境必须透传。6.7 节会讲到 hook 里 EnvIsInternal 短路逻辑,正是配合这类内部操作。
updateGitRepoAfterCreate 只做两件事:CheckDaemonExportOK(写 git-daemon-export-ok 标记,决定是否允许 git:// 匿名访问)和 git update-server-info(生成 info/refs,支持 dumb HTTP 协议)。mirror 仓库在第 1 步后提前返回(第 271 行 // No need for init mirror.)------它的目录由 clone 流程创建。
6.6 hooks 体系:两层脚本与 gitea hook 子命令
Gitea 的 hook 设计是本章信息密度最高的部分之一。任务描述里提到的 models/repo/hooks.go 并不存在,实际模板在 modules/git/hooks.go。
6.6.1 两层结构:dispatcher + .d/gitea
getHookTemplates() 一次返回两套模板------外层 dispatcher 和内层 gitea 钩子:
go
func getHookTemplates() (hookNames, hookTpls, giteaHookTpls []string) {
hookNames = []string{"pre-receive", "update", "post-receive"}
hookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
data=$(cat)
exitcodes=""
hookname=$(basename $0)
GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
test -x "${hook}" && test -f "${hook}" || continue
echo "${data}" | "${hook}"
exitcodes="${exitcodes} $?"
done
for i in ${exitcodes}; do
[ ${i} -eq 0 ] || exit ${i}
done
`, setting.ScriptType),
...
内层模板则极简:
go
giteaHookTpls = []string{
// for pre-receive
fmt.Sprintf(`#!/usr/bin/env %s
# AUTO GENERATED BY GITEA, DO NOT MODIFY
%s hook --config=%s pre-receive
`, setting.ScriptType, util.ShellEscape(setting.AppPath), util.ShellEscape(setting.CustomConf)),
...
}
最终磁盘形态(以 pre-receive 为例):
erlang
hooks/pre-receive ← dispatcher:读 stdin,循环执行 hooks/pre-receive.d/*,收集退出码
hooks/pre-receive.d/gitea ← 调用 `<AppPath> hook --config=<CustomConf> pre-receive`
hooks/pre-receive.d/<用户自定义脚本> ← 管理员/用户放自己的脚本,不被覆盖
为什么绕这一层? 因为 git 只认 hooks/pre-receive 单文件,而 Gitea 要同时保证三件事:
- 自身回调必须执行(
.d/gitea); - 用户自定义 hook 不能被 Gitea 升级覆盖(它们放在
.d/下,createDelegateHooks只重写 dispatcher 和.d/gitea两个文件); - 任一 hook 失败都要拒绝 push(dispatcher 汇总
exitcodes,第一个非零退出码终止)。
注意 dispatcher 的细节差异:pre-receive/post-receive 是 data=$(cat) 后 echo "${data}" | "${hook}"(stdin 行必须完整传给每个子 hook,多播),而 update 是直接 "${hook}" $1 $2 $3(参数传递,每 ref 调用一次)。proc-receive 比较特殊------它是 pkt-line 协议 hook,直接把 gitea hook proc-receive 写进了 hooks/proc-receive (模板列表里 proc-receive 的 hookTpls 就是 gitea 命令本身,giteaHookTpls 对应项为空字符串),因为它需要独占 stdin/stdout 做协议对话,dispatcher 的 cat 会破坏 pkt-line 流。源码注释也说明:"although only new git (>=2.29) supports proc-receive, it's still good to create its hook, in case the user upgrades git"------hook 文件提前装好,git 升级后无需重建仓库。
util.ShellEscape(setting.AppPath) 处理安装路径带空格的情况------Windows 上 C:\Program Files\gitea\gitea.exe 不转义就会截断命令。
6.6.2 安装与体检:CreateDelegateHooks / CheckDelegateHooks
createDelegateHooks(hookDir) 对每个 hook 名依次:MkdirAll(hookName.d) → 删除旧 dispatcher(util.RemoveWithRetry)→ 写新 dispatcher(0o755)→ ensureExecutable → 删除旧 .d/gitea → 写新 .d/gitea → ensureExecutable。源码里的警示注释很直白:
go
// WARNING: This will override all old server-side hooks
if err = util.RemoveWithRetry(oldHookPath); err != nil && !os.IsNotExist(err) {
直接覆盖顶层 hook 文件 意味着:如果管理员把自定义逻辑写在了 hooks/pre-receive 本体(而不是 .d/ 下),下次创建仓库/执行 hook 同步时会被抹掉。这也是 CheckDelegateHooks 存在的原因------它把"文件存在性、内容是否等于当前模板、是否可执行"逐项比对,返回问题清单:
go
contents, err := os.ReadFile(oldHookPath)
...
if string(contents) != hookTpls[i] {
results = append(results, fmt.Sprintf("old hook file %s is out of date", oldHookPath))
}
if !checkExecutable(oldHookPath) {
results = append(results, fmt.Sprintf("old hook file %s is not executable", oldHookPath))
}
可执行位检查在 Windows 上恒 true(// windows has no concept of a executable bit),ensureExecutable 则用 mode | 0o100 精确补位而不重算整套权限。
批量同步入口在 services/repository/hooks.go 的 SyncRepositoryHooks------遍历 DB 中所有仓库(含 wiki)逐一重建;管理员页面的"检测 hooks"则调 CheckDelegateHooks。这两者构成升级后的自愈机制:模板变了,跑一次同步即可,不需要手动进服务器改脚本。
6.6.3 gitea hook 子命令:cmd/hook.go 的四个入口
.d/gitea 最终执行的是 cmd/hook.go 注册的隐藏命令(Hidden: true, // internal commands shouldn't be visible):
go
func newHookCommand() *cli.Command {
return &cli.Command{
Name: "hook",
Usage: "(internal) Should only be called by Git",
Hidden: true,
Description: "Delegate commands to corresponding Git hooks",
Before: PrepareConsoleLoggerLevel(log.FATAL),
Commands: []*cli.Command{
newHookPreReceiveCommand(),
newHookUpdateCommand(),
newHookPostReceiveCommand(),
newHookProcReceiveCommand(),
},
}
}
Before: PrepareConsoleLoggerLevel(log.FATAL)------hook 子进程默认只输出 fatal,避免 git push 的客户端被日志刷屏(配合 --debug flag 才打开详细日志)。
四个入口的职责分工是一张清晰的表:
| hook | 函数 | 主要工作 | 批量 |
|---|---|---|---|
pre-receive |
runHookPreReceive |
读 stdin ref 行,组装 HookOptions 调 /api/internal/hook/pre-receive,非零退出拒绝 push |
500 ref 一批 |
update |
runHookUpdate |
只做一件事 :拒绝 refs/pull/* 的直接更新(os.Exit(1)),其余放行 |
每 ref 一次 |
post-receive |
runHookPostReceive |
update-server-info → 读 ref 行 → 调 post-receive internal API → 打印 PR 提示 |
500 ref 一批 |
proc-receive |
runHookProcReceive |
pkt-line 协商 + 转发给 proc-receive internal API + 回写 ok/ng | 全量一次 |
runHookUpdate 的注释解释了它为什么"空":
go
// runHookUpdate avoid to do heavy operations on update hook because it will be
// invoked for every ref update which does not like pre-receive and post-receive
func runHookUpdate(_ context.Context, c *cli.Command) error {
...
// Update is empty and is kept only for backwards compatibility
if len(os.Args) < 3 {
return nil
}
refName := git.RefName(os.Args[len(os.Args)-3])
if refName.IsPull() {
// ignore update to refs/pull/xxx/head, so we don't need to output any information
os.Exit(1)
}
return nil
}
update hook 每个 ref 触发一次,做重活会把 push 拖垮,所以只留最小的 PR 引用保护。这解释了权限/保护分支检查为何全放在 pre-receive(批量、每 push 一次)。
6.6.4 环境接力:从 gitea serv 到 hook
hook 子命令需要知道"谁在推、推哪个仓库"------这些信息不在 hook 协议里,而是靠环境变量接力 。runHookPreReceive 开头:
go
if isInternal, _ := strconv.ParseBool(os.Getenv(repo_module.EnvIsInternal)); isInternal {
return nil
}
...
if len(os.Getenv("SSH_ORIGINAL_COMMAND")) == 0 {
if setting.OnlyAllowPushIfGiteaEnvironmentSet {
return fail(ctx, `Rejecting changes as Gitea environment not set.
If you are pushing over SSH you must push with a key managed by
Gitea or set your environment appropriately.`, "")
}
return nil
}
// the environment is set by serv command
isWiki, _ := strconv.ParseBool(os.Getenv(repo_module.EnvRepoIsWiki))
ownerName := os.Getenv(repo_module.EnvRepoUsername)
repoName := os.Getenv(repo_module.EnvRepoName)
userID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPusherID), 10, 64)
prID, _ := strconv.ParseInt(os.Getenv(repo_module.EnvPRID), 10, 64)
三层防线依次是:
EnvIsInternal:Gitea 自己触发的 push(如 web UI 合并 PR、迁移内部克隆)设GITEA_INTERNAL_PUSH=true,hook 直接短路------否则内部操作也会走一遍权限检查甚至被自己的 pre-receive 拒绝(第 1 章 1.4.4 提到 serv 链时埋过这条线)。SSH_ORIGINAL_COMMAND为空 说明不是经 Gitea 的 SSH 服务进来的(gitea serv才会设置它)。若OnlyAllowPushIfGiteaEnvironmentSet开启则拒绝------防绕过:有人直接 ssh 到服务器、用系统账号跑git receive-pack时,环境变量缺失,push 被挡。关闭该选项时(默认)直接return nil放行,等 pre-receive 里的权限检查兜底。- 正常路径 读取 serv 命令注入的仓库/用户/PR ID 等环境变量(
modules/repository/env.go中的GITEA_REPO_USERNAME、GITEA_PUSHER_ID、GITEA_PR_ID、GITEA_REPO_IS_WIKI......),组装private.HookOptions。
这些环境变量就是整条 push 链的信任根 :gitea serv 在 SSH 认证成功后设置它们,hook 进程继承,private API 侧再以 opts.UserID 加载权限(6.7)。任何绕过 gitea serv 的入口都无法伪造完整环境,而"无法伪造"的前提正是 OnlyAllowPushIfGiteaEnvironmentSet 那道检查。
quarantine 变量同样在这一段被收集:
go
hookOptions := private.HookOptions{
IsWiki: isWiki,
GitAlternativeObjectDirectories: os.Getenv(private.GitAlternativeObjectDirectories),
GitObjectDirectory: os.Getenv(private.GitObjectDirectory),
GitQuarantinePath: os.Getenv(private.GitQuarantinePath),
GitPushOptions: pushOptions(),
...
}
6.7.1 会解释 GIT_QUARANTINE_PATH 的作用:新对象先落在隔离区,pre-receive 检查通过后才正式并入对象库。
pushOptions() 解析 GIT_PUSH_OPTION_COUNT / GIT_PUSH_OPTION_N 环境变量(git 在客户端 push -o key=value 时注入),注意它 strings.SplitN(opt, "=", 2) 只切两段------值里允许再有 =。
delayWriter 是个小而美的 UX 细节:VerbosePushDelay 配置下,前 N 秒的进度字符(*/.)先缓冲不发,超时才一次性刷出------避免小仓库 push 瞬间完成时满屏散点。
6.7 push 全链路:从 post-receive 到异步任务
6.7.1 整体时序
把 6.6 的 hook 层与 private 路由层(第 1 章 1.4.5 介绍过 /api/internal)串起来,一次普通分支 push 的完整链路如下:
同步段的边界 由 post-receive 决定:它返回前 ref 已落盘、DB 分支已同步;而通知、PR 测试任务、Release 落库等"可能慢、失败也无妨"的工作全部丢给 pushQueue。HookPostReceive 的注释把这个哲学写明白了:
go
// In the post-receive hook, the git repo has received all changes,
// So even if any error happens, nothing can be stopped or undone, so the errors should be skipped
pre-receive 是唯一能拒绝 push 的时机 (退出码非零),post-receive 阶段一切已不可逆,所以它到处 log.Error 而不向客户端报错。
6.7.2 pre-receive 服务端:六步保护检查
routers/private/hook_pre_receive.go 是权限与保护规则的执行终点。先看调度:
go
func HookPreReceive(ctx *gitea_context.PrivateContext) {
opts := web.GetForm[*private.HookOptions](ctx)
if !loadContextDoerPermission(ctx, opts.UserID, opts.UserExtDoerData) {
return
}
ourCtx := &preReceiveContext{
PrivateContext: ctx,
env: generateGitEnv(opts), // Generate git environment for checking commits
opts: opts,
}
// Iterate across the provided old commit IDs
for i := range opts.OldCommitIDs {
...
switch {
case refFullName.IsBranch():
preReceiveBranch(ourCtx, oldCommitID, newCommitID, refFullName)
case refFullName.IsTag():
preReceiveTag(ourCtx, refFullName)
case git.DefaultFeatures().SupportProcReceive && refFullName.IsFor():
preReceiveFor(ourCtx, refFullName)
default:
ourCtx.assertCanWriteRef(refFullName)
}
if ctx.Written() {
return // 第一个错误立即短路,响应即拒绝
}
}
ctx.PlainText(http.StatusOK, "ok")
}
按 ref 类型分流:分支走完整保护检查、tag 走保护 tag 规则、refs/for/*(AGit)走 PR 创建权限、其他 ref(notes 等)只查写权限。任何一步 ctx.Written() 即停------响应头已发出(403),后面的 ref 不再检查。
generateGitEnv 把 quarantine 环境变量加进检查用的 git 环境:
go
func generateGitEnv(opts *private.HookOptions) (env []string) {
env = os.Environ()
if opts.GitAlternativeObjectDirectories != "" {
env = append(env, private.GitAlternativeObjectDirectories+"="+opts.GitAlternativeObjectDirectories)
}
if opts.GitObjectDirectory != "" {
env = append(env, private.GitObjectDirectory+"="+opts.GitObjectDirectory)
}
if opts.GitQuarantinePath != "" {
env = append(env, private.GitQuarantinePath+"="+opts.GitQuarantinePath)
}
return env
}
quarantine 机制 (git 的 push 隔离区):receive-pack 把新对象先写进 tmp_objdir_*,通过 GIT_OBJECT_DIRECTORY/GIT_ALTERNATE_OBJECT_DIRECTORIES 让 hook 能读到它们、但主对象库还看不到。pre-receive 里的 rev-list、diff、签名验证因此能基于"假设 push 成功后的历史"做检查;一旦拒绝,隔离区被丢弃,主仓库毫发无损。若不传这些变量,检查代码将看不到新对象,force push 检测会直接失败。
分支检查 preReceiveBranch 的六步是全章最密集的业务逻辑:
go
func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, refFullName git.RefName) {
branchName := refFullName.BranchName()
if !ctx.assertCanWriteRef(refFullName) {
return
}
...
defaultBranch := repo.DefaultBranch
if ctx.opts.IsWiki && repo.DefaultWikiBranch != "" {
defaultBranch = repo.DefaultWikiBranch
}
if branchName == defaultBranch && newCommitID == objectFormat.EmptyObjectID().String() {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is the default branch and cannot be deleted", branchName)
return
}
protectBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
...
// Allow pushes to non-protected branches
if protectBranch == nil {
return
}
...
// 1. Detect and prevent deletion of the branch
if newCommitID == objectFormat.EmptyObjectID().String() {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from deletion", branchName)
return
}
isForcePush := false
// 2. Disallow force pushes to protected branches
if oldCommitID != objectFormat.EmptyObjectID().String() {
output, _, err := gitcmd.NewCommand("rev-list", "--max-count=1").
AddDynamicArguments(oldCommitID, "^"+newCommitID).
WithEnv(ctx.env).WithRepo(repo).RunStdString(ctx)
...
} else if len(output) > 0 {
if protectBranch.CanForcePush {
isForcePush = true
} else {
ctx.PrivateUserErrorf(http.StatusForbidden, "Branch %s is protected from force push", branchName)
return
}
}
}
...
逐步解读:
- 第 0 步(写权限) :
canWriteCodeRef先查Permission.CanWrite(unit.TypeCode);不满足再查"是否为 PR 作者 head 分支的 maintainer"(issues_model.CanMaintainerWriteToBranch)------这就是"PR 维护者可向贡献者分支推送"功能的落点。 - 默认分支删除保护 :即使没有配置保护规则也生效,且 wiki 用
DefaultWikiBranch单独判定。 - force push 检测的实现 :
rev-list --max-count=1 <old> ^<new>------旧历史里存在新历史没有的提交即输出非空 → 是 force push(非快进)。^前缀拼接用的是AddDynamicArguments,但newCommitID是内部传来的 OID,真正的注入面在oldCommitID------同样来自 hook stdin,而 hook stdin 是 git 自己生成的,可信。注意这里 ctx.env(含 quarantine)是必须的,否则读不到新对象。
后续三步(6.4 节代码里的 // 3、// 4、// 5)依次是:
| 步骤 | 检查 | 实现 |
|---|---|---|
| 3 | 强制签名提交 | verifyCommits,未签名 → 403 带具体 SHA |
| 4 | 保护文件模式 | pull_service.CheckFileProtection,glob 匹配改动文件 |
| 5 | 推送者许可(区分普通 push / force push、deploy key 走 WhitelistDeployKeys 分支) |
protectBranch.CanUserPush/CanUserForcePush |
| 6 | 不允许时的逃生通道 | 6a 普通推送:仅改 unprotected 文件可放行;6b UI/API 合并(opts.PullRequestID != 0):检查 PR 可合并、状态检查与评审全过,管理员可 CanBypassBranchProtection |
第 6 步是理解"web UI 合并为何能进保护分支"的钥匙:合并操作由 Gitea 自己发起 push,EnvPRID 环境变量带上 PullRequestID,此处重新按 PR 语境做完整判定 (而非信任内部标记直接放行)------即使内部 push 也要过状态检查,除非显式 bypass。tag 检查 preReceiveTag 则简单得多:IsUserAllowedToControlTag 对保护 tag 列表做模式匹配。
6.7.3 post-receive 服务端:DB 同步与 push options
routers/private/hook_post_receive.go 的主函数按固定顺序编排五件事:
go
func HookPostReceive(ctx *gitea_context.PrivateContext) {
opts := web.GetForm[*private.HookOptions](ctx)
if opts.IsWiki {
setting.PanicInDevOrTesting("wiki hook-post-receive is not supported")
return
}
// In the post-receive hook, the git repo has received all changes, ...
repo := ctx.Repo.Repository
updates := hookPostReceiveCollectPushUpdates(opts, repo) // first, collect updates and sync branches
hookPostReceiveSyncDatabaseBranches(ctx, opts, repo, updates)
hookPostReceiveSyncRepoDefaultBranch(ctx, opts, repo)
hookPostReceiveUpdateRepoByOptions(ctx, opts, repo)
// handle async updates (e.g.: notification, cache management, repo update time, etc.)
if err := repo_service.PushUpdates(updates...); err != nil {
log.Error("failed to push updates: %v", err)
}
hookPostReceiveRespondWithTrailer(ctx, opts, repo)
}
- 收集 (
hookPostReceiveCollectPushUpdates):只收 branch 和 tag ,注释解释了原因------refs/notes、refs/changes之类可能有海量引用,全收会撑爆 activity 流。产物是[]*repo_module.PushUpdateOptions(结构见modules/repository/push.go:RefFullName+OldCommitID+NewCommitID+ pusher/repo 标识,IsNewRef/IsDelRef/IsNewBranch等判定都基于 OID 是否为零值)。 - 同步 DB 分支 :删除的分支
MarkBranchAsDeleted,更新的分支先UpdatePullsRefs(PR head 引用跟随)再批量SyncBranchesToDB。注意它注释掉了错误回传(// TODO: should we return the error ... Currently it will log the error and not prevent the pushing)------post-receive 无法拒绝 push,只能记日志。 - 默认分支自愈 :若
repo.DefaultBranch不存在了,按main→master顺序猜一个并UpdateDefaultBranch;若推的正是默认分支则SetDefaultBranch同步 HEAD。这处理了"删掉默认分支再推新分支"的场景。 - push options 落地 (
hookPostReceiveUpdateRepoByOptions):repo.private、repo.template两个选项只在仓库仍为空时生效(push-to-create 场景),注释把动机讲得很清楚:
go
// Only honor these options while the repo is still empty (the push-to-create
// case). On a populated repo a bare "git push -o repo.private=..." would
// silently flip visibility, bypassing the audit log, webhooks and notifications.
if !repo.IsEmpty {
return
}
已填充仓库的 push -o repo.private=true 会静默翻转可见性、绕过审计与 webhook------所以直接拒绝;同时要求 Permission.IsAdmin() 并写 audit.RecordAs 审计记录。
- PR trailer 响应 :对每个非删除的分支查询"分支与 base 默认分支之间未合并的 PR",有则返回
Create=false+ PR 链接,无则返回Create=true+/pulls/new/<branch>链接------最终由runHookPostReceive的hookPrintResult打到 push 客户端 stderr:
ruby
Create a new pull request for 'feature-x':
https://gitea.example.com/alice/hello/pulls/new/feature-x
这就是 GitHub 风格 push 输出提示的实现,开关是 setting.Git.PullRequestPushMessage。
6.7.4 异步 pushQueue:真正干活的地方
services/repository/push.go 用一个 WorkerPool 队列消化后续:
go
// pushQueue represents a queue to handle update pull request tests
var pushQueue *queue.WorkerPoolQueue[[]*repo_module.PushUpdateOptions]
func initPushQueue() error {
pushQueue = queue.CreateSimpleQueue(graceful.GetManager().ShutdownContext(), "push_update", pushQueueHandler)
...
}
// PushUpdates adds a push update to push queue, each call must pass the same repo updates
func PushUpdates(opts ...*repo_module.PushUpdateOptions) error {
if len(opts) == 0 {
return nil
}
return pushQueue.Push(opts)
}
队列名 push_update,一批 = 一次 push 的全部 ref。处理器 pushQueueHandleUpdates 逐 ref 分派:
| ref 情况 | 处理 | 关键动作 |
|---|---|---|
新 tag(IsNewRef) |
pushUpdateAddTags → Release 落库 |
GetTag/GetTagCommit 取签名与时间,SplitCommitTitleBody 拆标题正文,已存在 Release 则更新(IsTag 判定防覆盖手工 Release) |
| 删 tag | PushUpdateDeleteTags + DeleteRef 通知 |
|
| 新分支 | pushNewBranch |
空仓库首推 :repo.DefaultBranch = opts.RefName()、IsEmpty=false、写 HEAD、更新 DB 两列(6.5 的 push-to-create 闭环);CommitsBeforeLimit(10) 取近期提交;CreateRef 通知 |
| 更新分支 | pushUpdateBranch |
CommitsBeforeUntil 取增量提交、IsForcePush(merge-base --is-ancestor 反向)、异步 AddTestPullRequestTask(触发关联 PR 的 CI/合并检查)、RemoveCommitsCountCache 失效缓存 |
| 删分支 | pushDeleteBranch |
AdjustPullsCausedByBranchDeleted 自动关闭以该分支为 head 的 PR |
循环外的公共动作:UpdateRepoSize(6.9.4 的磁盘统计)、DelDivergenceFromCache(分支领先/落后缓存)、issue_service.UpdateIssuesCommit(提交信息里的 #123 关联 issue、Close #123 关闭)、CacheRef(大仓库 commit-graph/缓存预热)、WatchIfAuto(推过就自动 watch)、UpdateRepositoryUpdatedTime。
几个边界情况:
setting.PanicInDevOrTesting("invalid push update (add+del)")------IsNewRef() && IsDelRef()同真(OID 解析失败的脏数据)在开发期直接炸出来。- tag 指向不存在的 commit 时(注释举例 github.com/git/git 的历史遗留 tag)只记日志不失败------上游仓库的脏数据不该让整个 push 队列卡死。
getCompareURL对"空仓库首推"特殊处理:old OID 为零时取首个提交的父提交作 compare 起点,仍为空则退化为repo.DefaultBranch------保证 compare 链接总是可点。
6.7.5 AGit-flow:proc-receive 与 refs/for
AGit-flow 是 Gitea 的 Gerrit 风格推送:git push origin refs/for/main/topic-x 不直接更新 refs/heads/*,而是被 proc-receive hook 拦截、创建/更新一个 PR。前提由 6.3.2 的 receive.procReceiveRefs=refs/for 配置开启。
客户端与 hook 的协议对话 在 runHookProcReceive,四个阶段的注释直接引用 git 文档:
arduino
// 1. Version and features negotiation.
// S: PKT-LINE(version=1\0push-options atomic...) / PKT-LINE(version=1\n)
// S: flush-pkt
// H: PKT-LINE(version=1\0push-options...)
// H: flush-pkt
服务端(此处的 "S" 是 hook 进程)先发能力行,客户端回 version=1\0...;hook 解析出客户端是否支持 push-options(决定后面是否读取 push-option 段),随后收 ref 命令段(<old> <new> <ref> 行)和可选的 push-option 段,全部塞进 HookOptions 调 /api/internal/hook/proc-receive。响应阶段回写:
go
for _, rs := range resp.Results {
if len(rs.Err) > 0 {
err = writeDataPktLine(ctx, os.Stdout, []byte("ng "+rs.OriginalRef.String()+" "+rs.Err))
...
}
if rs.IsNotMatched {
err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef.String()))
...
err = writeDataPktLine(ctx, os.Stdout, []byte("option fall-through"))
...
}
err = writeDataPktLine(ctx, os.Stdout, []byte("ok "+rs.OriginalRef))
...
err = writeDataPktLine(ctx, os.Stdout, []byte("option refname "+rs.Ref))
...
if rs.IsForcePush {
err = writeDataPktLine(ctx, os.Stdout, []byte("option forced-update"))
}
}
三种结果对应协议的三种语义:ng <ref> <reason> 拒绝(push 失败);ok + option fall-through 交还给 receive-pack 正常执行 (非 refs/for 的 ref 从这里流回普通流程);ok + option refname <refs/pull/N/head> 表示"Gitea 已把它改写到 PR head 引用"。pkt-line 编解码(4 位十六进制长度前缀、0000 flush、长度 5--65520 校验)就是 cmd/hook.go 末尾那 80 行 readPktLine/writeDataPktLine,边界检查(r.Length <= 4 || r.Length > 65520)严格对照 pkt-line 规范。
服务端逻辑在 services/agit/agit.go 的 ProcReceive(约 330 行),主流程:
go
for i := range opts.OldCommitIDs {
if opts.NewCommitIDs[i] == objectFormat.EmptyObjectID().String() {
results = append(..., Err: "Can't delete not exist branch")
continue
}
if !opts.RefFullNames[i].IsFor() {
results = append(..., IsNotMatched: true) // fall-through
continue
}
baseBranchName, currentTopicBranch, err := GetAgitBranchInfo(ctx, repo.ID, opts.RefFullNames[i].ForBranchName())
...
if len(topicBranch) == 0 && len(currentTopicBranch) == 0 {
... Err: "topic-branch is not set"
}
// because different user maybe want to use same topic,
// So it's better to make sure the topic branch name has username prefix
if !strings.HasPrefix(currentTopicBranch, userName+"/") {
headBranch = userName + "/" + currentTopicBranch
} else {
headBranch = currentTopicBranch
}
pr, err := issues_model.GetUnmergedPullRequest(ctx, repo.ID, repo.ID, headBranch, baseBranchName, issues_model.PullRequestFlowAGit)
...
}
- 分支名解析 (
GetAgitBranchInfo):refs/for/<x>的<x>若本身是存在的分支则它就是 base;否则按第一个/切成<base>/<topic>尝试------支持refs/for/main/topic-x和refs/for/main -o topic=topic-x两种写法,找不到 base 分支则报错。 - topic 加用户名前缀 :
alice/topic-x------注释解释了动机(不同用户用同名 topic 不该互相抢 PR)。 - 无 PR 则创建 :
title/descriptionpush option 缺省时取自 commit message 的标题与正文 (commit.MessageTitle()/MessageBody()),pull_service.NewPullRequest建 PR;结果IsCreatePR: false因为"AGit always creates a pull request so there is no point in prompting user"------与 6.7.3 的 trailer 提示形成对比。 - force push 二次检测 :即使 push option 声称 force,若未带
-o force-push=true且rev-list --max-count=1 <old> ^<new>有输出,回ng:"requestforce-pushpush option"。 - 更新既有 PR :
UpdateRefForAgit改写refs/pull/N/head,随后MarkReviewsAsStale(旧评审失效)、DismissApprovalReviews(配置了DismissStaleApprovals时撤销批准)、StartPullRequestCheckImmediately、CreatePushPullComment(生成"Gitea 加入了 X 个提交"评论)。
preReceiveFor(6.7.2 的分流之一)在这里做的是前置权限 :AssertCreatePullRequest + 仓库非空 + 非 wiki + base 分支信息可解析。也就是说 AGit push 要过两道闸:pre-receive 查权限,proc-receive 做改写。字符串常量 SshInfoJson = {"type":"agit","version":1}`` 则用于 SSH 握手时告知客户端服务端支持 AGit。
6.8 LFS:pointer、batch API 与对象存储
Git 仓库本身只存指针文件,真正的字节流走独立的 HTTP 协议与独立存储------这是 LFS 的基本分工。Gitea 把这套协议拆成两层:modules/lfs(纯协议/格式层,无 IO 到数据库)与 services/lfs(鉴权、DB 中的 lfs_meta_object、内容存储)。先看格式层。
6.8.1 Pointer:不足 1KB 的元数据与两级散列目录
modules/lfs/pointer.go 开头直接贴了 spec 链接,三个常量规定了指针文件的骨架:
go
// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
const (
MetaFileMaxSize = 1024 // spec says the maximum size of a pointer file must be smaller than 1024
MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" // the first line of a pointer file
MetaFileOidPrefix = "oid sha256:" // spec says the only supported hash is sha256 at the moment
)
ReadPointerFromBuffer 的解析逻辑没有用任何第三方 YAML 解析器,而是逐行硬解析------因为 spec 要求指针文件行数极少且顺序固定:
go
func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
var p Pointer
headString := string(buf)
if !strings.HasPrefix(headString, MetaFileIdentifier) {
return p, ErrMissingPrefix
}
splitLines := strings.Split(headString, "\n")
if len(splitLines) < 3 {
return p, ErrInvalidStructure
}
// spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)"
oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
if len(oid) != 64 || !oidPattern.MatchString(oid) {
return p, ErrInvalidOIDFormat
}
size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
if err != nil {
return p, err
}
p.Oid = oid
p.Size = size
return p, nil
}
三个错误变量(ErrMissingPrefix/ErrInvalidStructure/ErrInvalidOIDFormat)各自对应一种畸形输入,调用方可以区分"这不是 LFS 文件"与"这是 LFS 文件但坏了"------前者在仓库浏览时走普通 blob 展示,后者才需要报错。oidPattern = regexp.MustCompile(^a-f\\d{64}$) 限定 64 位小写十六进制,IsValid 再补一条 p.Size < 0 拒绝负数。
ReadPointer 的读取细节值得注意:io.ReadFull(reader, buf) 配 MetaFileMaxSize(1024)缓冲区,err != io.ErrUnexpectedEOF 才算错误------即"读不满 1024 字节但至少读到了"是正常情况(指针文件本来就只有三行),而真正的 IO 错误仍然上抛。这意味着任何大于 1024 字节的 blob 都不可能被误判为指针,成本是一次 1KB 的预读。
对象落盘路径由 RelativePath 决定,采用两级散列分桶(与 Git 自身的 objects/ab/cd... 布局同构):
go
// RelativePath returns the relative storage path of the pointer
func (p Pointer) RelativePath() string {
if len(p.Oid) < 5 {
return p.Oid
}
return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
}
即 ab/cd/abcdef...(oid[0:2]/oid[2:4]/oid[4:])。len(p.Oid) < 5 的兜底是防御性分支------IsValid 已经要求 64 位,正常流程走不到,但 RelativePath 是导出方法,不能假设调用方验证过。分桶的意义在于单目录文件数:256×256 = 65,536 个二级桶,百万级对象时每桶平均十几个文件,避免极端扁平目录在 ext4/NTFS 上退化。
GeneratePointer 则是反向操作------从任意内容流边读边算 sha256,一次 io.Copy 同时得到哈希与字节数:
go
func GeneratePointer(content io.Reader) (Pointer, error) {
h := sha256.New()
c, err := io.Copy(h, content)
if err != nil {
return Pointer{}, err
}
sum := h.Sum(nil)
return Pointer{Oid: hex.EncodeToString(sum), Size: c}, nil
}
6.8.2 路由:web 与 internal 共用的同一组 handler
routers/common/lfs.go 只有 31 行,但解决了一个重复问题------LFS 端点既要挂在用户可访问的 web 路由下(客户端用 HTTP Basic/Bearer 认证直连),又要挂在 /api/internal 私有路由下(供本机内部转发、以及某些回调场景),两者 handler 完全一致:
go
const RouterMockPointCommonLFS = "common-lfs"
func AddOwnerRepoGitLFSRoutes(m *web.Router, middlewares ...any) {
// shared by web and internal routers
m.Group("/{username}/{reponame}/info/lfs", func() {
m.Post("/objects/batch", lfs.CheckAcceptMediaType, lfs.BatchHandler)
m.Put("/objects/{oid}/{size}", lfs.UploadHandler)
m.Get("/objects/{oid}/{filename}", lfs.DownloadHandler)
m.Get("/objects/{oid}", lfs.DownloadHandler)
m.Post("/verify", lfs.CheckAcceptMediaType, lfs.VerifyHandler)
m.Group("/locks", func() {
m.Get("/", lfs.GetListLockHandler)
m.Post("/", lfs.PostLockHandler)
m.Post("/verify", lfs.VerifyLockHandler)
m.Post("/{lid}/unlock", lfs.UnLockHandler)
}, lfs.CheckAcceptMediaType)
m.Any("/*", http.NotFound)
}, append([]any{web.RouterMockPoint(RouterMockPointCommonLFS)}, middlewares...)...)
}
几个可以对照第 3 章路由体系的点:
| 设计点 | 体现 |
|---|---|
web.RouterMockPoint |
给同一组 handler 起"逻辑名",web 与 internal 注册时各自挂不同中间件,但共享组定义 |
CheckAcceptMediaType 前置 |
按 spec 要求客户端必须 Accept: application/vnd.git-lfs+json,不匹配直接 415,不进入业务逻辑 |
m.Any("/*", http.NotFound) |
显式封锁 LFS 前缀下的未知路径,防止误配中间件泄露 |
| 锁 API 独立子组 | LFS lock 是独立于对象传输的一类操作,统一套 CheckAcceptMediaType |
CheckAcceptMediaType 的实现很直白(mediaParts[0] != lfs_module.MediaType 则 415),但它的位置说明了协议校验先于鉴权:格式不对的请求连数据库都不必碰。
getRequestContext 里有个小而关键的细节:
go
repoName := strings.TrimSuffix(ctx.PathParam("reponame"), ".git")
...
RepoGitURL: httplib.GuessCurrentAppURL(ctx) + url.PathEscape(ownerName) + "/" + url.PathEscape(repoName+".git"),
路径参数要剥掉可能存在的 .git 后缀(git-lfs 客户端会拼 .git),而返回给客户端的链接又必须加回 .git------因为同一批配置里,LFS URL 与 git clone URL 共用仓库地址段,客户端拿它拼接后续 /info/lfs/...。
6.8.3 Batch API:一次请求判定一批对象
BatchHandler(services/lfs/server.go:192)是 LFS 的中枢。协议语义:客户端一次 POST 上报一批 oid,问服务端"哪些要我上传、哪些给我下载链接"。
go
var br lfs_module.BatchRequest
if err := decodeJSON(ctx.Req, &br); err != nil { ... 400 }
var isUpload bool
switch br.Operation {
case "upload":
isUpload = true
case "download":
isUpload = false
default:
... 400
}
operation 只有两值,非法直接 400------没有"第三种操作"的扩展空间,这是协议本身的约束。随后:
go
if setting.LFS.MaxBatchSize != 0 && len(br.Objects) > setting.LFS.MaxBatchSize {
writeStatus(ctx, http.StatusRequestEntityTooLarge)
return
}
MaxBatchSize 是防滥用闸门 :每个对象都要查一次 DB(GetLFSMetaObjectByOid)与一次存储(contentStore.Exists),不设上限时一次请求可打出任意多次查询。为 0 表示不限制(管理员显式选择)。
对象循环里的判定矩阵是这一节的核心:
go
for _, p := range br.Objects {
if !p.IsValid() {
... Code: 422, Message: "Oid or size are invalid"
continue
}
exists, err := contentStore.Exists(p)
...
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
...
if meta != nil && p.Size != meta.Size {
... Code: 422, Message: fmt.Sprintf("Object %s is not %d bytes", p.Oid, p.Size)
continue
}
注意 meta != nil && p.Size != meta.Size------客户端声称的 size 与服务端记录不一致时直接拒绝该对象 (而非采信服务端),因为上传时会按客户端声称的 size 校验字节数,两者不一致必然导致后续 ErrSizeMismatch。提前在 batch 阶段拒绝能让 git-lfs 立刻报错,而不是传到一半失败。
跨仓库防护是 upload 分支里注释最重的一段,值得原文摘录(反注水红线:这段注释本身承载了安全设计动机):
go
if isUpload {
if exists && meta == nil {
// The object exists in the content store but is not linked to this
// repo. Do not auto-link it based on cross-repo access: the token
// only authorizes this repo, and for deploy keys ctx.Doer is the
// repo owner, so trusting it here would let a single-repo key pull
// objects from any repo the owner can see. Require proof of
// possession by making the client upload the (hash-verified) bytes,
// and treat it as a new upload for size-limit enforcement below.
exists = false
}
...
}
攻击场景具体化:仓库 A 的 deploy key(只授权 A)知道仓库 B 里某个大对象的 oid(比如对象通过 fork 或日志泄露),如果 batch 阶段仅凭"内容存储里有这个文件"就返回 exists=true、不返回 upload 动作,客户端下一步直接 GET 下载链接------deploy key 就成了跨仓库读取器 。Gitea 的对策是:内容存在但未与本仓库建立 lfs_meta_object 关联时,一律当作"不存在",要求客户端重新上传字节;上传处理器(见 6.8.4)会对流做 sha256 校验------能提供正确字节才证明它本来就持有该对象,此时"链接到本仓库"是无害的。注释里"the token only authorizes this repo"点出了授权边界:token 是 repo 级的,不是 owner 级的。
download 分支的判定是对称的:
go
} else {
var err *lfs_module.ObjectError
if !exists || meta == nil {
err = &lfs_module.ObjectError{
Code: http.StatusNotFound,
Message: http.StatusText(http.StatusNotFound),
}
}
responseObject = buildObjectResponse(rc, p, true, false, err)
}
!exists || meta == nil 用的是与 :内容丢了(exists=false)或 DB 记录被删(meta==nil)都回 404。两个条件缺一不可------只查 DB 会在存储损坏时给出"存在"的假承诺,只查存储则绕过了 repo 级授权(getAuthenticatedMeta 里查的 meta 正是本仓库的记录)。
buildObjectResponse 的 download 分支还有一条性能捷径:
go
if setting.LFS.Storage.ServeDirect() {
// If we have a signed url (S3, object storage), redirect to this directly.
// DO NOT USE the http POST method coming from the lfs batch endpoint
u, err := storage.LFS.ServeDirectURL(pointer.RelativePath(), pointer.Oid, http.MethodGet, nil)
if u != nil && err == nil {
link = lfs_module.NewLink(u.String()) // Presigned url does not need the Authorization header
}
}
if link == nil {
link = lfs_module.NewLink(rc.DownloadLink(pointer)).WithHeader("Authorization", rc.Authorization)
}
ServeDirect() 为真且后端是 S3 之类对象存储时,返回预签名 URL ,字节流由存储服务商直出,Gitea 进程不再经过手------注释特意强调预签名 URL 不需要 Authorization 头(S3 的签名机制不同,加了反而 403),而回退到自托管链接时必须带上原始 Authorization 头透传给 /info/lfs/objects/...。upload 分支的注释同样有工程细节:
go
// Set Transfer-Encoding header to enable chunked uploads. Required by git-lfs client to do chunked transfer.
// See: https://github.com/git-lfs/git-lfs/blob/main/tq/basic_upload.go#L58-59
rep.Actions["upload"] = lfs_module.NewLink(rc.UploadLink(pointer)).
git-lfs 客户端要求 chunked 传输才能发上传(它事先不知道服务端是否已有该对象,无法提前设 Content-Length),这是协议互操作性细节,不是可选项。
6.8.4 UploadHandler:proof of possession 与并发上传的已知缺陷
UploadHandler(services/lfs/server.go:306)处理 PUT /objects/{oid}/{size}。它把逻辑包进闭包 uploadOrVerify,三分支:
go
uploadOrVerify := func() error {
contentStore := lfs_module.NewContentStore()
stat, err := contentStore.Stat(p)
if stat != nil {
// The bytes already exist in the content store. Only skip proof of
// possession when the object is already linked to *this* repo; never
// trust cross-repo access (ctx.Doer is the repo owner for deploy keys),
// which would let a caller link an object it cannot produce.
meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid)
...
if meta == nil {
// The file exists but is not linked to this repo, or the file is being uploaded.
// The upload gets verified by hashing and size comparison to prove access to it.
// Keep in mind: here the file might be incomplete due to concurrent uploading, so the verification might fail.
// ATTENTION: it's impossible to handle corrupted file on server-side at the moment,
// we don't know whether a file is really corrupted, or it is being uploaded.
hash := sha256.New()
written, err := io.Copy(hash, ctx.Req.Body)
...
if written != p.Size {
return lfs_module.ErrSizeMismatch
}
if hex.EncodeToString(hash.Sum(nil)) != p.Oid {
return lfs_module.ErrHashMismatch
}
}
} else if errors.Is(err, fs.ErrNotExist) {
// not exist, store it into the store
if err := contentStore.Put(p, ctx.Req.Body); err != nil { ... }
} else {
...
}
_, err = git_model.NewLFSMetaObject(ctx, repository.ID, p)
return err
}
三个分支对应的正是 6.8.3 里 exists/meta 组合的结果:
- 文件在 + meta 在:本仓库已关联,直接信任,不重传字节(正常重复 push 场景,省带宽)。
- 文件在 + meta 不在 :即 6.8.3 里被强制
exists=false的那类请求落到此处------必须读完整个请求体算 sha256 并比对 size,通过校验才允许写 meta 记录。这就是 "proof of possession"(持有证明)。 - 文件不在 :
contentStore.Put直接落盘,然后写 meta。
Put 之后统一 NewLFSMetaObject(ctx, repository.ID, p) 插入/复用 DB 记录------注意 meta 是 (repository_id, oid) 维度的,所以同一对象在 100 个仓库中会出现 100 行记录,而内容存储里只有一份字节(RelativePath 只由 oid 决定,与仓库无关)------这是 LFS 的去重模型。
错误处理段的注释解释了为什么失败时不回滚:
go
defer ctx.Req.Body.Close()
if err := uploadOrVerify(); err != nil {
if errors.Is(err, lfs_module.ErrSizeMismatch) || errors.Is(err, lfs_module.ErrHashMismatch) {
... writeStatusMessage(ctx, http.StatusUnprocessableEntity, err.Error())
} else {
... writeStatus(ctx, http.StatusInternalServerError)
}
// Do not remove the LFS MetaObject here: this request only creates it after the content is verified and stored,
// an invalid request should not remove the existing correct record.
// If two requests are uploading (the file is incomplete):
// * one will keep writing the file content
// * one will fail the verification because it reads an incomplete file, the failure should be just be ignored
// In the end, the first one will complete the upload and insert a new LFS MetaObject record.
return
}
并上传同一 oid 的竞态被明确建模:A、B 同时 PUT,B 走到分支 2 读到了 A 写了一半的文件,hash 校验失败------此时不能因为 B 失败就删掉记录/文件,否则会摧毁 A 的进行中的写入。所以失败即静默返回(对 B 报 422),A 最终完成并插入记录。注释里的 "ATTENTION: it's impossible to handle corrupted file on server-side" 是诚实的边界声明:服务端无法区分"损坏"与"上传中",唯一自愈手段是客户端重试。
6.8.5 DownloadHandler:Range 续传与两个 Content-Length
下载端点支持 HTTP Range(git-lfs 断点续传依赖它):
go
// Support resume download using Range header
var fromByte, toByte int64
toByte = meta.Size - 1
statusCode := http.StatusOK
if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" {
match := rangeHeaderRegexp.FindStringSubmatch(rangeHdr)
if len(match) > 1 {
statusCode = http.StatusPartialContent
fromByte, _ = strconv.ParseInt(match[1], 10, 32)
if fromByte >= meta.Size {
writeStatus(ctx, http.StatusRequestedRangeNotSatisfiable)
return
}
if match[2] != "" {
_toByte, _ := strconv.ParseInt(match[2], 10, 32)
if _toByte >= fromByte && _toByte < toByte {
toByte = _toByte
}
}
ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, toByte, meta.Size))
ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Range")
}
}
rangeHeaderRegexp = regexp.MustCompile(bytes=(\d+)-(\d*).*) 允许上界为空("从 fromByte 读到文件尾")。边界处理有三处防御:fromByte >= meta.Size 回 416;_toByte >= fromByte && _toByte < toByte 才采信上界(非法区间静默降级为"到文件尾",而非报错);Content-Range 同时设置 Access-Control-Expose-Headers,否则浏览器场景下 JS 读不到该头。
中段两个 header 值是同值双写,动机写在行内注释里:
go
ctx.Resp.Header().Set("Content-Length", contentLengthStr)
ctx.Resp.Header().Set("X-Gitea-LFS-Content-Length", contentLengthStr) // we need this header to make sure it won't be affected by reverse proxy or compression
反向代理可能改写/缓冲 Content-Length(例如配了 gzip 的中间层),git-lfs 校验续传完整性时如果拿到被篡改的长度就会错位。第二个自定义头是 Gitea 主动打的"权威长度"锚点。随后 io.CopyN(ctx.Resp, content, contentLength) 只写区间长度,并把错误只记日志------连接已 headers 已发,中途失败只能断连,这是 HTTP 的既定语义,写 status 已无意义。
VerifyHandler 则是辅助端点(git-lfs 在某些流程里先 HEAD 式确认对象完整):contentStore.Verify(meta.Pointer) 校验存储中的字节是否匹配 oid,200/404/500 三态。
6.8.6 与 git 链路的衔接点
回到本章主线------LFS 与 6.7 的 push 链路没有直接调用关系:pre-receive/post-receive 处理的是含指针文件的普通 blob(几十字节),大字节流完全由 LFS 独立端点承担。两者的耦合点只有两处:
- 上传顺序 :git-lfs 客户端先跑 batch + PUT 上传,成功后才
git push------所以 post-receive 执行时,DB 里的lfs_meta_object大概率已就位。反向场景(push 完但 LFS 上传失败)产生"悬空指针",仓库浏览能看到文件名但下载 404,只能重传。 - 鉴权同源 :LFS 端点走的是与 6.7.2 相同的
authenticate体系(token/deploy key/basic),getAuthenticatedRepository里requireWrite决定要 Read 还是 Write 权限------batch download 要 Read,batch upload / PUT 要 Write,与githttp.go给 receive-pack 定的规则一致。
6.9 gitproxy 与 mirror 同步
6.9.1 gitproxy:为什么它必须先于 mirror 队列启动
第 1 章的启动序列里埋过一个伏笔:gitproxy.Run() 的调用时机必须早于 mirror 队列消费。原因在 services/gitproxy/server.go------它是一个进程内 HTTP 反向代理,为所有出站 git 操作提供统一的代理/隧道/出口策略执行点,通过环境变量注入的方式让子进程 git 用上它。
Run() 的骨架:
- 在
127.0.0.1:0上监听(随机端口,避免多实例冲突); - 生成随机口令:
rand.Text()作为 basic auth 凭据,只回传给本进程的子进程; - 起
http.Server提供reverseProxy,转发到配置的上游代理,缓冲区 256KiB; - 调
SetExtraEnvs(...)把代理配置写进全局 extraEnvs------即 6.2.3 提到的CommonGitCmdEnvs里那个extraEnvs atomic.Pointer。
注入的环境变量形如:
text
GIT_CONFIG_PARAMETERS='http.proxy=' # 清掉仓库/全局 config 里可能存在的 proxy,以 Gitea 注入的为准
GIT_PROXY_COMMAND=<path> # 把 git:// 协议的连接交给回调处理
GIT_CONFIG_PARAMETERS 优先级高于系统 gitconfig 与仓库 gitconfig(git 的既有规则),所以 http.proxy= 能覆盖用户在仓库里配的代理 ------企业部署里管理员要求所有出口走统一代理时,这是强制手段而非建议。GIT_PROXY_COMMAND 则处理 git:// 这种没有 HTTP 层、http.proxy 不生效的协议:git 每建立一条 git:// 连接就 fork 一次 GIT_PROXY_COMMAND 指定的程序,Gitea 的回调脚本把它转回 MaybeTunnel() 走同一套隧道。
启动顺序问题因此清楚了:mirror 队列的 worker 一旦开始跑 fetch,子进程 git 就要读这些环境变量 。如果 Run() 晚于队列启动,先跑的那批 mirror 会绕过代理直连(环境变量还没注入),在有出口白名单的网络里表现为"镜像偶发失败"------这类时序 bug 极难复现。同理 gitea manager 子进程重新拉起 worker 前也必须保证 proxy 就绪。
gitproxy 还承担两件与安全相关的职责:
| 能力 | 作用 |
|---|---|
| egress policy | 出口地址白名单/黑名单校验,防止仓库里的恶意 URL(如 git clone http://内网地址)借 Gitea 做 SSRF 跳板------第 1 章讲过的 SSRF 防线在 git 出站方向的落地 |
| NTLM 支持 | 企业代理常见认证方式,标准 Go http.Transport 不支持,gitproxy 补上 |
6.9.2 mirror:唯一队列 + fetch 参数
mirror 同步在本章只展开到"它如何复用 6.2 的命令层"这个深度(完整调度模型属于运维篇)。services/mirror/queue.go 创建的是 unique queue:
go
_, _, err := queue.CreateUniqueQueue(ctx, "mirror", mirrorQueueWorker, ...)
unique queue 的语义:同一仓库同时只留一个待执行任务(新请求合并/丢弃重复项),避免某仓库网络慢时任务堆积成串行队列堵塞其他仓库。队列名 "mirror" 写进了 DB 的队列表,重启后恢复。
worker 执行的 fetch 命令由 modules/git 的 Repository.Fetch 封装,mirror 场景的典型参数是 fetch --tags --force --prune 加上游 URL:
--tags:即使 refspec 配置只匹配 heads,也强制同步 tags(镜像语义要求完整);--force:本地镜像分支允许被非快进更新------镜像是上游的副本,不存在"保护分支"概念(这也是为什么镜像仓库在 6.7.2 的写权限检查里走只读路径);--prune:上游删除的引用本地同步删除,否则镜像只会单调膨胀。
fetch 与 6.2.3 的关系:命令经 NewCommand("fetch")...WithRepo(...).WithTimeout(...) 组装,天然获得 GIT_TERMINAL_PROMPT=0(上游要密码时立即失败而非挂起等 stdin------无人值守队列里挂起等于 worker 永久占用)、超时控制、以及 gitproxy 的环境注入。fetch 完成后 mirror worker 调用与 push 相同的 pushQueue 后续分支(更新缓存、触发 PR 检测),因为从 git 的视角,镜像 fetch 成功后仓库内容的变化与收到一次 push 没有区别。
6.10 防御性设计与大仓库性能
前面各节的"设计动机"散落在代码里,本节把它们收拢成一张可核对的清单------每一条都对应本章出现过(或前文未展开)的真实代码。
6.10.1 参数注入防护的四道闸
按 6.2 的方法族归位:
| 闸门 | 方法 | 防的是什么 |
|---|---|---|
| 编译期类型隔离 | AddArguments(...internal.CmdArg) |
internal.CmdArg 是 internal 包类型,外部模块无法构造 ------只能传 internal.Expand... 产生的常量或 AddDynamicArguments 的值。depguard 规则配注释禁止 os/exec 直接裸用,全仓库 git 调用收敛到 gitcmd |
| 运行期字符校验 | AddDynamicArguments + isSafeArgumentValue |
拒绝前导 -:用户可控值(分支名、路径、oid)若以 - 开头会被 git 当成选项(git log --output=/path 型注入) |
| 结构性分隔 | AddDashesAndList |
显式插入 --,此后所有参数按字面量处理------文件路径进入 git show/git log 前的最后防线 |
| 语义加固 | AddOptionGrepExpr |
强制 -e <expr> 两段式,用户提供的 grep 表达式不可能被解析成选项 |
另一条不易察觉的防线在日志侧 :logArgSanitize → SanitizeCredentialURLs 在把命令行写日志前脱敏 URL 中的用户名密码。命令行是事后排查的主要证据,但推送 URL 常含 token(https://user:token@host/...)------不脱敏则日志文件本身成为凭据泄露面。这属于"防御性设计"里常被忽略的一类:防的不是注入,是信息泄漏。
6.10.2 超时与 context 取消:进程一定死得掉
WithTimeout 派生 context.WithTimeout,Command.Run 在 Start 后另起 goroutine 等 <-ctx.Done(),超时后调 cmd.Process.Kill()。关键在两处细节:
Wait永远被调用 (无论成功/超时/取消路径),否则子进程变僵尸,Windows 上还会持有仓库目录句柄导致后续git gc/目录重命名失败------6.4.2 里Close等<-b.closed的同款顾虑。errors.Join(wrapPipelineError, errCause, errWait):取消路径上三个错误源(pipeline 输出错误、context 原因、Wait 返回的exec.ExitError)合并而非覆盖,排障时能看到"是超时 + 进程被 Kill + 退出码 137"的完整链条。
cat-file 长驻进程是超时机制的特殊形态:它没有 deadline(要活到请求结束),生命周期由 cancel 归还函数控制------RepositoryFromRequestContextOrOpen(6.1.3)把归还挂在请求 context 上,请求被客户端断开时 context 取消 → cancel 执行 → 进程退出,不会因为一个慢客户端让 git 进程永久驻留。
6.10.3 大仓库的三道性能闸
闸一:对象体积上限。 modules/git/repo_object.go:
go
// MaxGitObjectSize is used to avoid OOM when reading a large git object.
// GitHub has a limit (100M) for pushing, but Gitea doesn't have such a limit yet.
var MaxGitObjectSize int64 = 100 * 1024 * 1024
读取路径用 limitDiscardReader 包装------超过 100MB 的 blob 流式读到上限即报错 ,而不是读进内存再判断。注释承认这是防御性限制(Gitea 本身没有 push 大小限制),防的是"一个 2GB 的提交在网页上点开 → 服务端 OOM"。注意它限制的是读,不是 push;push 侧的大对象保护靠 LFS 分流与 reverse proxy 的 body 限制。
配套的 blobReader(6.4.5)用计数 b.n 防越界读,Close 时 DiscardFull(b.n+1) + cancel------不 Close 就泄漏一个 git 进程 ,所以读 blob 的代码全部 defer r.Close()。
闸二:git gc 与 packfile 维护。 仓库膨胀不是自然恢复的:松散对象(push 后的 loose object)与增量 pack 都要靠 git gc 收敛。Gitea 的封装在 services/repository/gc.go(GitGcRepo),要点:
- 走
NewCommand("gc"),套WithTimeout------gc 对大仓库可能跑几十分钟,超时被杀后 git 自身有锁文件保护(gc.pid),下次可重入; - 与
GitFsckRepo(git fsck一致性检查)共用仓库级互斥 :globallock键repo-write:+GitRepoManagedID(6.3.3 同款),确保 gc 不与 push 的对象写入并发------quarantine 目录并入正式对象库时如果撞上 gc 的 prune,可能删掉刚接收的对象; - 触发方式是管理员任务/cron,不在 push 路径上同步执行------push 的 P99 不能被一次 gc 拖到分钟级。
闸三:commit-graph 与查询加速。 6.3.2 的 syncGitConfig 写了三个 commitGraph 开关(fetch.writeCommitGraph、commitGraph.readChangedPaths 等,随 git 版本能力取舍)。commit-graph 是 git 的提交图缓存文件 (.git/objects/info/commit-graph),把"某 commit 的父提交是谁"从"解包 commit 对象"变成"查索引数组"------git log 的历史遍历、6.7.2 第 2 步的 rev-list force-push 检测、第 3 步的签名遍历全部受益。大仓库最贵的操作就是历史遍历,commit-graph 把它从 O(对象解压) 降到 O(图查询)。
与之配合的还有 6.4.7 的两级缓存(LastCommitCache/CommitsCountCache)------把"文件最后一次提交"与"提交总数"这两个每次页面渲染都要算 的量存进 Redis,键含 managedID+commitID,push 改变 HEAD 即自动失效(pushUpdateBranch 里显式 RemoveCommitsCountCache)。失效策略是"键里放版本号"而非"删除所有相关键":后者要维护依赖图,前者靠哈希天然去重。
6.10.4 防御清单速查表
| 威胁 | 对应机制 | 出处 |
|---|---|---|
| git 参数注入(RCE) | internal.CmdArg 类型隔离 + isSafeArgumentValue + -- 分隔 + AddOptionGrepExpr |
6.2.2 |
| 凭据进日志 | SanitizeCredentialURLs |
6.2.4 |
| 子进程挂起占死 worker | GIT_TERMINAL_PROMPT=0 + WithTimeout + 必达的 Wait |
6.2.3/6.2.4 |
| 恶意 URL 借 git 出站 SSRF | gitproxy egress policy + GIT_CONFIG_PARAMETERS='http.proxy=' 强制出口 |
6.9.1 |
| 仓库被其他用户写(TOCTOU) | safe.directory=*(CVE-2022-24765) |
6.3.2 |
| 恶意仓库借 pre-receive 耗尽资源 | EnvIsInternal 短路 + 500 ref/批上限 + update 阶段空实现 |
6.6.3/6.6.4 |
| 巨型 blob 打爆内存 | MaxGitObjectSize + limitDiscardReader + blobReader 计数 |
6.10.3 |
| LFS 跨仓库拉取 | batch 阶段 exists=false + upload 阶段 proof of possession |
6.8.3/6.8.4 |
| LFS 批量打爆 DB | MaxBatchSize |
6.8.3 |
| gc 与 push 并发损坏对象库 | globallock 的 repo-write: 键 |
6.3.3/6.10.3 |
6.11 源码级问答与本章小结
6.11.1 十个源码级问答
Q1:openGitCommand、runCommand、HiddenArgs 这些函数在哪? 不存在。本章写作前逐一 grep 验证过(见开篇纠正表)。真实入口是 gitcmd.NewCommand(...),隐藏参数的机制是 AddArguments(...internal.TrustedCmdArgs) 的类型级 不可伪造,而不是某个叫 HiddenArgs 的字段。任何声称"Gitea 用 HiddenArgs 隐藏参数"的资料都是凭印象编造的。
Q2:gogit 模式还活着吗?代码里怎么确认? 活着,且是 build tag 隔离的双实现:repo_base_nogogit.go 开头 //go:build !gogit + const isGogit = false,对应文件对偶存在 repo_base_gogit.go。但 go.mod 里 go-git 是 v5.19.2,能力表明确 SupportHashSha256 = ≥2.42 && !isGogit------gogit 被功能降级。默认构建(无 tag)走系统 git。
Q3:一次 GetCommit 到底起几个 git 进程? 0 个------如果 cat-file 批处理连接还在:getCommitWithBatch 复用 Repository 上的长驻进程,一条 info + 一条 contents 查询。进程数为 1 的情形:批处理连接是 WithRepo 后惰性启动的(首次 CatFileBatch())或已溢出重建(6.4.3 的 catFileBatchInUse)。为 2 的情形:tag 对象需追加一次解引用查询。ls-tree 兜底(batch 查询返回非 tree 类型时)再 +1。
Q4:pre-receive 和 post-receive 谁能拒绝 push? 只有 pre-receive。post-receive 执行时 refs 已更新完毕,任何 os.Exit(1) 都不影响结果------6.7.3 的代码里 post-receive 的错误只 log.Error,哲学即注释所言 "不可逆"。6.6.3 里 update 阶段唯一会 exit(1) 的场景是 IsPull(拒绝 refs/pull/* 直推),因为它发生在 refs 写入前。
Q5:为什么 update hook 几乎是空的? cmd/hook.go 的 hookUpdate 注释写明:"avoid heavy operations ... invoked for every ref update"。pre-receive 收到的是一批 ref(一次 push 推 100 个分支只进一次 pre-receive),而 update 对每个 ref 各执行一次 ------把权限检查放这里意味着 N 倍 DB 查询。所以 Gitea 把全部检查集中在 pre-receive 一次算完,update 只拦 refs/pull/* 这种必须逐 ref 判定的硬规则。
Q6:用户自己写的 .git/hooks/pre-receive 会被 Gitea 覆盖吗? 分两层。顶层 dispatcher(hooks/pre-receive)会被 createDelegateHooks 覆盖(源码有 WARNING 注释,见 6.6.2),Gitea 自己的回调则固定在 hooks/pre-receive.d/gitea 一个文件上,升级时同样重写。用户的钩子放同目录下的其他文件名 (如 hooks/pre-receive.d/99-myhook):dispatcher 遍历 hooks/<name>.d/* 逐个执行并收集退出码,第一个非零退出即整批失败,但它只重写 pre-receive 与 pre-receive.d/gitea 两个路径------其余文件永不被触碰。这是 dispatcher 分层的全部意义:官方回调可升级,用户钩子零迁移。
Q7:git.RepoModuleTime 在哪? 不存在(开篇已纠正)。仓库大小/时间类统计走 UpdateRepoSize(CalcRepositorySize 遍历磁盘)与 commit_count 缓存,没有一个叫 RepoModuleTime 的模块计时器。
Q8:AGit push 和普通 push 走同一条 pre-receive 吗? 是。分流发生在 preReceiveFor------opts.RefFullNames[i].IsFor() 为真(refs/for/ 前缀)才走 AGit 分支,否则走普通保护检查。两者的写权限检查都在 pre-receive 完成 ,区别只在后续:普通 push 到 post-receive 收尾,AGit push 到 proc-receive 用 pkt-line 协议回写 ok/ng 并改写 refs/pull/N/head。
Q9:LFS 对象什么情况下会被要求重新上传? 6.8.3/6.8.4 的组合判定:内容存储里有该 oid 的字节,但当前仓库没有 lfs_meta_object 记录。此时 exists 被强制置 false,batch 返回 upload 动作,客户端 PUT 时服务端读流算 sha256------能交出正确字节才给建关联。防的是 deploy key 借"对象恰好已存在"跨仓库拉取。
Q10:为什么 git version 解析要截取前三段? parseGitVersionLine 的历史包袱来自 issue #12731:某些发行版(如 SUSE)的版本串是 2.28.0.618.gf4bc123cb7------带打包者自加的第四段。strings.Split 后只取前三段再 semver 比较,否则能力判定全部报错。而 2.43.1 黑名单是精确匹配------因为那是特定补丁版本的 GIT_FLUSH 回归,2.43.2 已修复,不能误伤整个 2.43.x。
6.11.2 本章地图
贯穿全章的一条主线:Gitea 不重造 git,它重造的是"如何安全、可复用地使唤 git" ------internal.CmdArg 让注入在编译期就不可能(6.2),cat-file 批处理让进程复用到请求粒度(6.4),dispatcher 两层结构让官方钩子与用户钩子互不侵占(6.6),pre-receive 的"唯一拒绝点"哲学让异步化的 post-receive 可以放心只做尽力而为(6.7)。每一层都是对"git 是无状态命令行工具"这一前提的工程化补偿。