Gitea源码深度剖析-第6章 Git 底层封装与仓库存储

Gitea源码深度剖析-第6章 Git 底层封装与仓库存储

前面几章分别梳理了 Gitea 的启动流程、配置体系、路由中间件、数据库层与权限模型。但 Gitea 本质上是一个"围绕 Git 仓库做管理与协作"的系统------用户看到的分支、提交、文件浏览、代码评审、推送拒绝规则,最终都要落到对 Git 仓库的读写上。本章深入 modules/git 及其周边模块,回答三个核心问题:

  1. Gitea 是封装了 go-git,还是直接调用系统的 git 命令行?
  2. 一次 git push 从网络进入裸仓库,到 Gitea 更新数据库、触发 PR 任务,中间经过了哪些进程与函数?
  3. 仓库文件如何组织、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 ...,以及 gtprof trace 属性。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-dir global 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
	}
	...
}

三条默认值各有明确动机:

  1. HOME=HomeDir():让 git 只读 Gitea 自己的 gitconfig(AppDataPath/gitea/.gitconfig),与宿主机用户配置完全隔离。注释同时承认了历史选择------理想方案是 GIT_CONFIG_GLOBAL,但那要求 git ≥ 2.32,而最低版本要求只有 2.25。
  2. GIT_CONFIG_NOSYSTEM=1:不读 /etc/gitconfig。注释给出了两个真实故障:macOS 的 osxkeychain 凭据助手会弹模态框、系统级 LFS 配置会自动安装 hooks。同时标注这是 1.24 起的 breaking change,把 receive.certNonceSeed 之类配置迁到 Gitea 的 gitconfig。
  3. 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 控制。
  • 追踪 用 gtprof span,属性含调用方函数名与命令串------这是性能排查(/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)
	}
	...
}

这段代码处理了三个微妙的边界:

  1. pipeline 回调正常返回但没读完 stdout → 必须先排空(discard)管道,否则 git 写满管道缓冲区会阻塞退出,cmd.Wait() 永不返回。
  2. pipeline 主动取消(如"取够了 100 条就杀命令")→ 不排空,让 kill 直接生效,退出码由 kill 决定。
  3. 三类错误(回调错误、取消原因、退出码)用 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
}

三个设计点:

  1. closeFunc 用 atomic.Pointer 存储并 Swap(nil) 保证只执行一次(进程退出的 goroutine 与调用方 Close 可能竞争)。
  2. Close() 会 <-b.closed 等待 git 进程真正退出------注释解释了原因:"otherwise, the opened files will block the directory renaming (rename a repo) on Windows"。Windows 上被进程占用的目录无法重命名,这在仓库改名/转移时会出错。一个平台差异决定了同步语义。
  3. 启动失败时返回"哑"通信器而非报错,注释承认是为了避免重构所有调用方------务实的兼容层。

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)全部是前缀裁剪,没有正则开销。三个特别的设计:

  1. RefGroup() 映射到 .git/refs 下的目录名 (heads/tags/remotes/pull/for),注释明确它与 RefType()(UI/webhook 用的 branch/tag/commit)是两套词汇------不要混用。
  2. RefNameFromCommit 的 fail fast :传入非小写十六进制时开发期直接 panic(setting.PanicInDevOrTesting("BUG! invalid commit id")),生产期降级为 refs/invalid-commit/... 的假引用,防止把用户输入当 OID 拼进 git 命令。
  3. 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 要同时保证三件事:

  1. 自身回调必须执行(.d/gitea);
  2. 用户自定义 hook 不能被 Gitea 升级覆盖(它们放在 .d/ 下,createDelegateHooks 只重写 dispatcher 和 .d/gitea 两个文件);
  3. 任一 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)

三层防线依次是:

  1. EnvIsInternal :Gitea 自己触发的 push(如 web UI 合并 PR、迁移内部克隆)设 GITEA_INTERNAL_PUSH=true,hook 直接短路------否则内部操作也会走一遍权限检查甚至被自己的 pre-receive 拒绝(第 1 章 1.4.4 提到 serv 链时埋过这条线)。
  2. SSH_ORIGINAL_COMMAND 为空 说明不是经 Gitea 的 SSH 服务进来的(gitea serv 才会设置它)。若 OnlyAllowPushIfGiteaEnvironmentSet 开启则拒绝------防绕过:有人直接 ssh 到服务器、用系统账号跑 git receive-pack 时,环境变量缺失,push 被挡。关闭该选项时(默认)直接 return nil 放行,等 pre-receive 里的权限检查兜底。
  3. 正常路径 读取 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 的完整链路如下:

sequenceDiagram participant C as git 客户端 participant S as gitea serv / http Git participant R as receive-pack (裸仓库) participant H as hooks/*.d/gitea participant P as gitea hook 子进程 participant I as /api/internal (private 路由) participant Q as pushQueue (异步) C->>S: git push (SSH key / HTTP 认证) S->>S: 鉴权 Write 权限, 注入 GITEA_* 环境变量 S->>R: exec git receive-pack (带 quarantine 环境) R->>R: 接收 packfile → tmp_objdir (隔离区) R->>H: pre-receive (stdin: old new ref ...) H->>P: gitea hook pre-receive P->>I: POST /api/internal/hook/pre-receive (500 ref/批) I->>I: 权限/保护分支/force push/签名/保护文件检查 I-->>P: 200 ok 或 403 (非零退出 → push 被拒) R->>H: update (每 ref) H->>P: gitea hook update (仅拦 refs/pull/*) R->>R: 更新 refs, 对象并入主对象库 R->>H: post-receive H->>P: gitea hook post-receive P->>P: git update-server-info P->>I: POST /api/internal/hook/post-receive (500 ref/批) I->>I: 同步 DB 分支/默认分支/push options I->>Q: repo_service.PushUpdates(updates...) I-->>P: PR 创建/查看提示 (trailer) P-->>C: stderr: &#34;Create a new pull request ...&#34; Q->>Q: pushQueueHandleUpdates (异步): PR 任务/通知/Release/缓存失效

同步段的边界 由 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/description push 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:"request force-push push 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 组合的结果:

  1. 文件在 + meta 在:本仓库已关联,直接信任,不重传字节(正常重复 push 场景,省带宽)。
  2. 文件在 + meta 不在 :即 6.8.3 里被强制 exists=false 的那类请求落到此处------必须读完整个请求体算 sha256 并比对 size,通过校验才允许写 meta 记录。这就是 "proof of possession"(持有证明)。
  3. 文件不在 :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 独立端点承担。两者的耦合点只有两处:

  1. 上传顺序 :git-lfs 客户端先跑 batch + PUT 上传,成功后才 git push------所以 post-receive 执行时,DB 里的 lfs_meta_object 大概率已就位。反向场景(push 完但 LFS 上传失败)产生"悬空指针",仓库浏览能看到文件名但下载 404,只能重传。
  2. 鉴权同源 :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() 的骨架:

  1. 在 127.0.0.1:0 上监听(随机端口,避免多实例冲突);
  2. 生成随机口令:rand.Text() 作为 basic auth 凭据,只回传给本进程的子进程;
  3. 起 http.Server 提供 reverseProxy,转发到配置的上游代理,缓冲区 256KiB;
  4. 调 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()。关键在两处细节:

  1. Wait 永远被调用 (无论成功/超时/取消路径),否则子进程变僵尸,Windows 上还会持有仓库目录句柄导致后续 git gc/目录重命名失败------6.4.2 里 Close 等 <-b.closed 的同款顾虑。
  2. 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 本章地图

graph TD A[&#34;6.1 双实现与核心类型<br/>gogit tag / RepositoryFacade&#34;] --> B[&#34;6.2 gitcmd 命令层<br/>CmdArg 注入防护 / 环境变量&#34;] B --> C[&#34;6.3 版本探测与 gitconfig<br/>能力表 / syncGitConfig / globallock&#34;] C --> D[&#34;6.4 对象读取<br/>cat-file 批处理(心脏)&#34;] D --> E[&#34;6.5 refs 与仓库创建<br/>RefName / 裸仓库布局 / 七步创建&#34;] E --> F[&#34;6.6 hooks 两层结构<br/>dispatcher + gitea hook 子命令&#34;] F --> G[&#34;6.7 push 全链路<br/>pre-receive 六步 / pushQueue / AGit&#34;] G --> H[&#34;6.8 LFS<br/>pointer / batch / proof of possession&#34;] G --> I[&#34;6.9 gitproxy + mirror<br/>环境注入 / unique queue&#34;] D --> J[&#34;6.10 防御与性能<br/>四道闸 / MaxGitObjectSize / commit-graph&#34;] H --> J I --> J J --> K[&#34;6.11 源码级问答&#34;]

贯穿全章的一条主线:Gitea 不重造 git,它重造的是"如何安全、可复用地使唤 git" ------internal.CmdArg 让注入在编译期就不可能(6.2),cat-file 批处理让进程复用到请求粒度(6.4),dispatcher 两层结构让官方钩子与用户钩子互不侵占(6.6),pre-receive 的"唯一拒绝点"哲学让异步化的 post-receive 可以放心只做尽力而为(6.7)。每一层都是对"git 是无状态命令行工具"这一前提的工程化补偿。

相关推荐
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第2章 启动流程与配置体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第4章 数据库层与 ORM 体系(xorm)
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第5章 权限体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第13章 前端工程与模板体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第9章 REST API v1 体系
git·开源
鶴哥只手遮天1 小时前
Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统
git·开源
TTATTC1 小时前
告别开源 Agent 的本地运维折磨:拆解阿里 QoderWork 的沙盒隔离架构与三级渐进式 Skills 编排实践
docker·开源
除不掉的灰色1 小时前
git回退提交前
git
武雄(小星Ai)1 小时前
Hermes Agent 周报:v0.21.6 发布,多账号连接器上线,6 项安全修复
开源·llm·周报·ai agent·hermes