Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统
第 1 章的导航表里,第 8 章对应的关键词是 preparePullRequests 与 pull_service.Init,涉及 models/issues/pull.go、services/pull/、routers/web/repo/pull*.go 三块。从这一章开始,我们进入 Gitea 中横跨面最广的子系统:一个 Pull Request(下文简称 PR)同时牵动数据库事务、git 底层操作、后台任务队列、全局锁、分支保护规则、代码评审状态机与通知扇出。本章按 PR 的真实生命周期推进------数据模型 → 创建 → 引用同步 → diff → 冲突检测 → 可合并判定 → 合并执行 → 更新分支 → 自动合并 → 代码评审 → 触发点 → 全景图。
开始之前,先纠正几处流传较广的误解(本章所有结论都经源码核实):
| 常见说法 | 实际情况 |
|---|---|
PullRequest 结构定义在 models/pull/ |
定义在 models/issues/pull.go;models/pull/ 只有 automerge.go、review_state.go 等少量附属表模型 |
创建 PR 的函数叫 CreatePullRequest |
Web 与 API 共用 pull_service.NewPullRequest(services/pull/pull.go),models/issues 层的同名函数才是 NewPullRequest |
diff 结构是 Diff/CommitDiff/FileDiff |
是 Diff/DiffFile/DiffSection/DiffLine 四级结构(services/gitdiff/gitdiff.go),没有 CommitDiff |
| 合并方式有三种 merge/squash/rebase | repo_model.MergeStyle(models/repo/git.go:14)定义 7 个值:5 种真正执行的样式(merge/rebase/rebase-merge/squash/fast-forward-only,见 8.7.3)+ manually-merged(仅作标记)+ rebase-update-only(源码注释明确 "not a merge style"),实现分散在 merge_merge.go、merge_rebase.go、merge_squash.go、merge_ff_only.go 四个文件 |
冲突检测用 git merge-tree |
有能力探测:git.DefaultFeatures().SupportGitMergeTree 为真才走 merge-tree,否则回退到临时仓库手工三路合并(services/pull/patch.go) |
8.1 数据模型:pull_request 表与对 Issue 的复用
8.1.1 PullRequest 结构体
models/issues/pull.go 中的 PullRequest 是 PR 子系统的中心模型。摘录核心字段(省略加载方法):
go
// models/issues/pull.go
type PullRequest struct {
ID int64 `xorm:"pk autoincr"`
Type PullRequestType
Status PullRequestStatus
ConflictedFiles []string `xorm:"TEXT JSON"`
CommitsAhead int
CommitsBehind int
ChangedProtectedFiles []string `xorm:"TEXT JSON"`
IssueID int64 `xorm:"INDEX"`
Issue *Issue `xorm:"-"`
Index int64
RequestedReviewers []*user_model.User `xorm:"-"`
RequestedReviewersTeams []*org_model.Team `xorm:"-"`
isRequestedReviewersLoaded bool `xorm:"-"`
HeadRepoID int64 `xorm:"INDEX"`
HeadRepo *repo_model.Repository `xorm:"-"`
BaseRepoID int64 `xorm:"INDEX"`
BaseRepo *repo_model.Repository `xorm:"-"`
HeadBranch string
HeadCommitID string `xorm:"-"`
BaseBranch string
MergeBase string `xorm:"VARCHAR(64)"`
AllowMaintainerEdit bool `xorm:"NOT NULL DEFAULT false"`
HasMerged bool `xorm:"INDEX"`
MergedCommitID string `xorm:"VARCHAR(64)"`
MergerID int64 `xorm:"INDEX"`
Merger *user_model.User `xorm:"-"`
MergedUnix timeutil.TimeStamp `xorm:"updated INDEX"`
isHeadRepoLoaded bool `xorm:"-"`
Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"`
}
几个字段值得单独说明,因为它们对应着后文的机制:
IssueID(INDEX) :pull_request表与issue表 1:1 关联的外键。PR 的标题、内容、标签、里程碑、指派人、评论、时间线全部存在issue侧------这是第 7 章讲过的 Comment 多态能够作用于 PR 的原因。Index:PR 在仓库内的编号(URL 里的#123),与issue.index同源------创建时由同一个db.GetNextResourceIndex(ctx, "issue_index", repo.ID)分配,所以 Issue 与 PR 编号永远一致。MergeBase(VARCHAR(64)):head 与 base 的共同祖先 commit,是 diff、squash 消息、冲突检测三个环节的基准点。ConflictedFiles/ChangedProtectedFiles:TEXT JSON序列化的字符串数组,由冲突检测流程写入,用于页面展示与合并判定。HeadCommitID带xorm:"-":不落库,运行时从 head 分支或refs/pull/N/head解析;数据库里唯一持久的"头"是 base repo 内的隐藏 ref。Flow:PullRequestFlowGithub(0,常规分支流)与PullRequestFlowAGit(1,AGit 的 push-based 流,head 分支在源仓库中不存在)。
三个枚举定义紧挨在结构体上方:
go
// Enumerate all the pull request status
const (
PullRequestStatusConflict PullRequestStatus = iota // 0 有冲突
PullRequestStatusChecking // 1 正在检查
PullRequestStatusMergeable // 2 可合并
PullRequestStatusManuallyMerged // 3 被手动合并(侦测到)
PullRequestStatusError // 4 检查出错
PullRequestStatusEmpty // 5 空补丁(head 内容已在 base 中)
PullRequestStatusAncestor // 6 head 是 base 的祖先(已包含)
)
const (
PullRequestGitea PullRequestType = iota
PullRequestGit
)
const (
PullRequestFlowGithub PullRequestFlow = iota
PullRequestFlowAGit
)
注意这七个状态是可合并性(mergeable)状态 ,不是 PR 生命周期状态。PR 的开/关/合并生命周期由 issue.is_closed 与 pull_request.has_merged 两个布尔量承载;status 只回答"现在能不能合"。第 8.12 节的状态机会把两者画在一起。
8.1.2 与 Issue 的 1:1 关系与索引分配
创建 PR 的数据库入口是同文件的 NewPullRequest:
go
// models/issues/pull.go
func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string, pr *PullRequest) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
idx, err := db.GetNextResourceIndex(ctx, "issue_index", repo.ID)
if err != nil {
return fmt.Errorf("generate pull request index failed: %w", err)
}
issue.Index = idx
issue.Title = util.EllipsisDisplayString(issue.Title, 255)
issue.IsPull = true
if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
Repo: repo,
Issue: issue,
LabelIDs: labelIDs,
Attachments: uuids,
}); err != nil {
...
}
pr.Index = issue.Index
pr.BaseRepo = repo
pr.IssueID = issue.ID
if err = db.Insert(ctx, pr); err != nil {
return fmt.Errorf("insert pull repo: %w", err)
}
return nil
})
}
一个事务里完成四件事:分配 issue_index、以 IsPull = true 插入 issue、把同一 index 写进 pr.Index、插入 pull_request 行。"Issue 与 PR 共享编号"不是巧合而是这十几行代码保证的不变量。
查询侧大量使用 JOIN 来表达"开放且未合并"的语义,例如查重(Web 创建前防重复提交就调它):
go
func GetUnmergedPullRequest(ctx context.Context, headRepoID, baseRepoID int64, headBranch, baseBranch string, flow PullRequestFlow) (*PullRequest, error) {
pr := new(PullRequest)
has, err := db.GetEngine(ctx).
Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND flow = ? AND issue.is_closed=?",
headRepoID, headBranch, baseRepoID, baseBranch, false, flow, false).
Join("INNER", "issue", "issue.id=pull_request.issue_id").
Get(pr)
...
}
条件里同时约束 has_merged = false 与 issue.is_closed = false------两者是不同维度:PR 可以被关闭但没合并,也可以合并后 issue 被置为 closed。此外还有 GetPullRequestByIndex(按仓库 + index,走 base_repo_id + index 复合定位)、GetPullRequestByIssueID(issue_id 直查)、GetLatestPullRequestByHeadInfo(head 分支最近一条 PR,用于 push trailer 提示)等一簇查询函数,共同支撑路由层的各种入口。
8.1.3 WIP、审批计数与合并阻塞谓词
同一文件里聚集了一组被合并判定反复调用的谓词函数,它们全部只依赖 PR 的 IssueID 或状态字段:
go
// WIP 判定基于标题前缀(配置项 PULL_REQUEST_WORK_IN_PROGRESS_PREFIXES)
func HasWorkInProgressPrefix(title string) bool {
_, ok := CutWorkInProgressPrefix(title)
return ok
}
// 需要的审批数
func HasEnoughApprovals(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
if protectBranch.RequiredApprovals == 0 {
return true
}
return GetGrantedApprovalsCount(ctx, protectBranch, pr) >= protectBranch.RequiredApprovals
}
// 第 5 章 5.9.5 节引用的函数(models/issues/pull.go:770)
func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
And("type = ?", ReviewTypeApprove).
And("official = ?", true).
And("dismissed = ?", false)
if protectBranch.IgnoreStaleApprovals {
sess = sess.And("stale = ?", false)
}
approvals, err := sess.Count(new(Review))
...
return approvals
}
这里正式回收第 5 章埋下的伏笔:一个审批要"有效"必须同时满足 type = approve、official = true、dismissed = false 三个条件 ;第四个条件 stale = false 只在分支保护开启 IgnoreStaleApprovals 时生效。official(是否为"有资格的评审人"所做)的计算逻辑在 models/issues/review.go 的 RecalculateReviewsOfficial,第 8.10 节详述。第 5 章讨论的 BlockAdminMergeOverride 则决定管理员在 CheckPullMergeable 里能否用 forceMerge 绕过这些检查(见 8.6.3)。
另外三个阻塞谓词同样是简单的条件查询:
go
// 有未 dismiss 的 official reject review 即阻塞
func MergeBlockedByRejectedReview(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool
// 有未处理的 official review request 即阻塞
func MergeBlockedByOfficialReviewRequests(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool
// head 落后于 base(commits_behind > 0)且规则要求最新
func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
}
最后一个直接读 CommitsBehind 这个由 syncCommitDivergence 预先算好落库的字段,体现了"把昂贵的 git 计算移到 push 时、判定时只读缓存"的设计取向。
8.1.4 隐藏 ref:refs/pull/N/head
PR 在 git 侧的存在形式是一条隐藏引用。生成规则只有一行:
go
// models/issues/pull.go
func (pr *PullRequest) GetGitHeadRefName() string {
return git.RefNameFromPullIndex(pr.Index).String()
}
// modules/git/ref.go
const (
PullPrefix = "refs/pull/"
pullSuffix = "/head"
)
func RefNameFromPullIndex(prIndex int64) RefName {
return RefName(PullPrefix + strconv.FormatInt(prIndex, 10) + pullSuffix)
}
即 refs/pull/<index>/head,写在 base 仓库 里。为什么需要它?fork 场景下 head commit 原本只存在于 fork 仓库,base 仓库根本没有这些对象;所有 diff、冲突检测、合并操作都发生在 base 仓库,所以必须把 head commit fetch 进 base 仓库并用一条不会被 gc 当成垃圾的 ref 钉住。GetGitHeadRefName() 在全书出现几十次:diff、merge、状态检查、CI 状态查询(第 14 章)都以它为 head 端点。
8.1.5 CodeOwners 解析的防御性设计
models/issues/pull.go 尾部是 CodeOwners(.gitea/CODEOWNERS)解析器。用户可控的 pattern 编译进正则存在灾难性回溯风险,代码显式使用 regexp2 并设置超时:
go
const codeOwnerMatchTimeout = 150 * time.Millisecond
type CodeOwnerRule struct {
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
Negative bool
Users []*user_model.User
Teams []*org_model.Team
}
...
expr := fmt.Sprintf("^%s$", pattern)
rule.Rule, err = regexp2.Compile(expr, regexp2.None)
...
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
regexp2(而非标准库 regexp)的选择是为了支持负向前瞻(! 排除规则);MatchTimeout 单次 150ms,调用侧还有跨整个 rules×files 匹配循环的总预算。解析结果在 PR 创建、head 推送、规则变更三个时机被 issue_service.PullRequestCodeOwnersReview 使用,用于自动发起 review request------这也是为什么创建 PR 的事务里会调用它。
8.2 PR 创建链路:从三个入口到一次事务
8.2.1 入口一:网页 Compare 页面
routers/web/repo/pull.go 的 CompareAndPullRequestPost(约 1315 行起)是表单入口,骨架如下:
go
// routers/web/repo/pull.go
func CompareAndPullRequestPost(ctx *context.Context) {
form := web.GetForm[*forms.CreateIssueForm](ctx)
repo := ctx.Repo.Repository
comparePageInfo := newComparePageInfo()
err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
...
if ci.CompareBase == "" {
ctx.JSONError(ctx.Tr("repo.pulls.no_common_history"))
return
}
...
// Check if a pull request already exists with the same head and base branch.
pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, repo.ID, ci.HeadRef.ShortName(), ci.BaseRef.ShortName(), issues_model.PullRequestFlowGithub)
...
if pr != nil {
ctx.JSONError(ctx.Tr("repo.pulls.new.already_existed"))
return
}
...
pullIssue := &issues_model.Issue{
RepoID: repo.ID, Repo: repo,
Title: form.Title, PosterID: ctx.Doer.ID, Poster: ctx.Doer,
MilestoneID: milestoneID, IsPull: true, Content: content,
}
pullRequest := &issues_model.PullRequest{
HeadRepoID: ci.HeadRepo.ID, BaseRepoID: repo.ID,
HeadBranch: ci.HeadRef.ShortName(), BaseBranch: ci.BaseRef.ShortName(),
HeadRepo: ci.HeadRepo, BaseRepo: repo,
MergeBase: ci.CompareBase,
Type: issues_model.PullRequestGitea,
AllowMaintainerEdit: form.AllowMaintainerEdit,
}
prOpts := &pull_service.NewPullRequestOptions{ ... }
if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
var pushrejErr *git.ErrPushRejected
switch {
case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): ...
case errors.As(err, &pushrejErr): ... // pre-receive hook 拒绝,展示 hook 输出
case errors.Is(err, user_model.ErrBlockedUser): ...
case errors.Is(err, issues_model.ErrMustCollaborator): ...
default:
ctx.ServerError("CompareAndPullRequest", err)
}
return
}
ctx.JSONRedirect(pullIssue.Link())
}
注意 MergeBase: ci.CompareBase:merge base 在进入服务层之前就由 compare 页面的 parseCompareInfo 算好了(git merge-base 的结果),服务层首次冲突检测直接复用。错误映射分支则说明创建 PR 可能失败于 base 仓库的 pre-receive hook(例如 base 仓库配置了 ref 规则),此时 git.ErrPushRejected 携带的 hook stderr 被原样呈现给用户。
8.2.2 入口二与入口三:REST API 与 AGit
routers/api/v1/repo/pull.go 的 CreatePullRequest 走完全相同的 NewPullRequestOptions 结构体,只是参数从 form 换成 api.CreatePullRequestOption,且 head 分支必须属于同一 fork 网络。AGit 流(PullRequestFlowAGit)则没有"创建页面":用户 git push refs/for/main 时由 push 链路直接构造 PR,head commit 已经在 base 仓库中,因此服务层会走 UpdateRefForAgit 分支(见 8.3.2)。
8.2.3 核心:pull_service.NewPullRequest
三个入口汇聚到 services/pull/pull.go 的 NewPullRequest。它分为"事务前权限检查 → 事务 → 事务后通知"三段:
go
// services/pull/pull.go
func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
repo, issue, labelIDs, uuids, pr, assigneeIDs := opts.Repo, opts.Issue, opts.LabelIDs, opts.AttachmentUUIDs, opts.PullRequest, opts.AssigneeIDs
if err := issue.LoadPoster(ctx); err != nil {
return err
}
if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) {
return user_model.ErrBlockedUser
}
// user should be a collaborator or a member of the organization for base repo
canCreate := issue.Poster.IsAdmin || pr.Flow == issues_model.PullRequestFlowAGit
canAssignProject := canCreate
if !canCreate {
canCreate, err = repo_model.HasAccessToRepoCodeUnit(ctx, repo, issue.Poster.ID)
...
if !canCreate {
// or user should have write permission in the head repo
if err := pr.LoadHeadRepo(ctx); err != nil { return err }
perm, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, issue.Poster)
...
if !perm.CanWrite(unit.TypeCode) {
return issues_model.ErrMustCollaborator
}
...
}
}
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
return err
}
...
权限链的含义:创建 PR 只需要"base 仓库代码单元的读权限"(HasAccessToRepoCodeUnit)或 "head 仓库的写权限"------后者正是 fork PR 的场景:贡献者对上游没有写权限,但必须能推自己的 fork。两条都不满足才报 ErrMustCollaborator。管理员与 AGit 流直接放行。
紧接着的 checkPullRequestBranchMergeable(ctx, pr) 是第一次冲突检测 :在写任何数据库之前,先算出 Status(Mergeable/Conflict/Empty...)、MergeBase、ConflictedFiles、ChangedProtectedFiles。这保证落库的 PR 自带正确的初始可合并状态,也解释了为什么创建可能因 git 层错误直接失败。
事务体:
go
err = db.WithTx(ctx, func(ctx context.Context) error {
err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr)
...
for _, assigneeID := range assigneeIDs { // 指派人 + 其 comment
...
}
if len(opts.ProjectIDs) > 0 && canAssignProject { // 项目卡
...
}
pr.Issue = issue
issue.PullRequest = pr
if pr.Flow == issues_model.PullRequestFlowGithub {
if err = PushToBaseRepo(ctx, pr); err != nil { // fetch head commit + 更新隐藏 ref
return err
}
if err = syncCommitDivergence(ctx, pr); err != nil { // 算 ahead/behind
return err
}
} else { // agit
if err = UpdateRefForAgit(ctx, pr); err != nil {
return err
}
}
// add first push codes comment
if _, _, err := CreatePushPullComment(ctx, issue.Poster, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false); err != nil {
return err
}
if !pr.IsWorkInProgress(ctx) {
reviewNotifiers, err = issue_service.PullRequestCodeOwnersReview(ctx, pr)
...
}
return nil
})
if err != nil {
// cleanup: this will only remove the reference, the real commit will be clean up when next GC
if err1 := git.RemoveRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName()); err1 != nil {
log.Error("RemoveRef: %v", err1)
}
return err
}
三个设计细节:
- git 操作被包进数据库事务 。
PushToBaseRepo是网络/磁盘操作,放在事务里看似反模式,但失败路径需要一致性:任一步失败,数据库回滚,同时显式git.RemoveRef清掉刚写的refs/pull/N/head(注释也承认对象要等下次 GC 才回收)。ref 是"引用计数为零即可回收"的设计,所以删除引用就足够了。 - 先建 push comment 再算状态 。
CreatePushPullComment记录"这些 commit 被推进来了",代码注释解释了顺序动机:状态检查是异步的,先落 comment 让后续测试更容易确定性断言。 - CodeOwners 的 review request 在 WIP 时不发起------避免给半成品 PR 打扰评审人。
事务之后是通知段:ReviewRequestNotify → 逐个 issue_service.ReviewRequest(记录 request 类型的 review 行)→ FindAndUpdateIssueMentions 解析 @提及 → notify_service.NewPullRequest 扇出(数据库通知、邮件、webhook、活动流)。之所以强调"review request 必须先于其他通知",是因为通知订阅者点开 PR 时要能看到自己被请求评审。
8.2.4 创建时序图
8.3 隐藏 ref 的同步与 commit divergence
8.3.1 PushToBaseRepo:为什么是 fetch 不是 push
go
// services/pull/pull.go
// PushToBaseRepo fetches the head branch commit into the base repository and points the PR head ref at it.
func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error {
...
headCommitID, err := git.GetFullCommitID(ctx, pr.HeadRepo, git.BranchPrefix+pr.HeadBranch)
if err != nil {
return err
}
// fetch, not push: pushing objects FetchRemoteCommit already fetched races background repacks
if err := git.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, headCommitID); err != nil {
return fmt.Errorf("unable to fetch head branch %s:%s into base repo %s, err: %w", ...)
}
return git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), headCommitID)
}
函数名叫 "Push" 但实际是 base 仓库主动 fetch head 仓库的指定 commit,然后在本地写 ref 。注释给出了动机:被 fetch 进来的对象如果再走 push 路径,会与后台 repack 竞争。这一步在三个时机发生:PR 创建、每次 head 分支 push(经 UpdatePullsRefs/AddTestPullRequestTask)、目标分支切换。
AGit 没有 head 分支可 fetch,直接写 ref:
go
func UpdateRefForAgit(ctx context.Context, pr *issues_model.PullRequest) (err error) {
...
if err := git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), pr.HeadCommitID); err != nil {
return fmt.Errorf("unable to update ref %s for base repo %s, err: %w", pr.GetGitHeadRefName(), pr.BaseRepo.FullName(), err)
}
return syncCommitDivergence(ctx, pr)
}
8.3.2 push 后的引用同步:UpdatePullsRefs
第 6 章讲过 git push 的 post-receive hook 会回调 Gitea 的私有接口。routers/private/hook_post_receive.go 中,在同步分支表之前先执行:
go
// routers/private/hook_post_receive.go
func hookPostReceiveSyncDatabaseBranches(...) {
...
} else {
branchesToSync = append(branchesToSync, update)
// TODO: should we return the error ...? Currently it will log the error and not prevent the pushing
pull_service.UpdatePullsRefs(ctx, repo, update)
}
...
}
// services/pull/pull.go
// UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head ...
func UpdatePullsRefs(ctx context.Context, repo *repo_model.Repository, update *repo_module.PushUpdateOptions) {
branch := update.RefFullName.BranchName()
prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
...
for _, pr := range prs {
if pr.Flow == issues_model.PullRequestFlowGithub {
if err := PushToBaseRepo(ctx, pr); err != nil {
log.Error("PushToBaseRepo: %v", err)
}
}
}
}
即:任何分支 push 进仓库,凡是 head 指向该分支的开放 PR,其 refs/pull/N/head 立刻刷新 。post-receive 阶段即使失败也无法中止 push(代码已收到全部对象),所以这里只记日志不回滚------错误处理的取舍在注释里写得很直白。同时 hookPostReceiveRespondWithTrailer 会为每个推送的分支生成 PR 提示:不存在开放 PR 就提示"创建 PR"的 URL,存在就直接给出 /pulls/N 跳转(fork 仓库还会先解析上游 base 仓库)。
8.3.3 syncCommitDivergence:ahead/behind 的预计算
go
func syncCommitDivergence(ctx context.Context, pr *issues_model.PullRequest) error {
if err := pr.LoadBaseRepo(ctx); err != nil {
return err
}
divergence, err := git.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
if err != nil {
return err
}
return pr.UpdateCommitDivergence(ctx, divergence.Ahead, divergence.Behind)
}
GetDivergingCommits 底层是 git rev-list --left-right --count base...head,结果写入 commits_ahead/commits_behind。这两个字段的消费者有三处:页面显示 "x commits ahead, y behind"、MergeBlockedByOutdatedBranch 的快速判定、Update branch 按钮的必要性判断。调用时机覆盖创建、head push、base push、target 切换------凡是拓扑可能变化的时刻。
8.3.4 push comment:记录每一次 head 变化
services/pull/comment.go 的 CreatePushPullComment 把 push 变成 PR 时间线上的一条 CommentTypePullRequestPush:普通 push 追加 commit 列表,force push 则走 reconcileOldCommitCommentsForForcePush------因为历史被重写,旧 commit 评论引用的 sha 可能已不可达,需要合并/删除旧记录。单条 comment 最多包含 maxPushCommitsInCommentCount = 1000 个 commit,防止一次巨型 push 撑爆数据库行。该 comment 在创建 PR 与每次 head 同步时生成,第 7 章的 Comment 多态表里它是 pull_request_push 类型的一员。
8.4 diff 与文件变更:从 git 输出到结构化 Diff
PR 页面的"Files changed"标签页是整个子系统里渲染逻辑最重的部分。数据流是:git diff 原始文本 → ParsePatch 结构化 → GetDiffForRender 补充渲染属性 → 模板输出;行内评论则在渲染前由 LoadComments 挂载到对应行上。
8.4.1 四级结构:Diff / DiffFile / DiffSection / DiffLine
services/gitdiff/gitdiff.go 定义了四个核心结构(结构体上省略了渲染专用字段):
go
// services/gitdiff/gitdiff.go
type DiffLine struct {
LeftIdx int // line number, 1-based
RightIdx int // line number, 1-based
Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side
Type DiffLineType
Content string
Comments issues_model.CommentList // related PR code comments
SectionInfo *DiffLineSectionInfo
IsTruncated bool
...
}
type DiffSection struct {
FileName string
Lines []*DiffLine
...
}
type DiffFile struct {
Name string
NameHash string
OldName string
Addition int
Deletion int
Type DiffFileType
EntryMode string
OldEntryMode string
IsCreated bool
IsDeleted bool
IsBin bool
IsLFSFile bool
IsRenamed bool
IsSubmodule bool
Sections []*DiffSection
IsIncomplete bool // file is too large
HasTruncatedLines bool // some lines are too long
...
IsProtected bool
IsViewed bool // User specific
HasChangedSinceLastReview bool // User specific
...
}
层级关系:Diff 持有 Files []*DiffFile;每个 DiffFile 持有若干 DiffSection(对应 diff 输出里的每个 @@ hunk);每个 DiffSection 持有逐行的 DiffLine。DiffLine.Match 字段值得玩味:>0 时它指向对侧 Lines 切片的下标,把"新增行"和"删除行"两两配对,前端用它做"改了哪几个字符"的词级高亮;0 表示上下文行无需配对,-1 表示配对失败。
DiffLineSectionInfo 是"可折叠段"的元数据,结构体注释里附了一张 ASCII 图,精确描述了 hunk 边界行号的语义:
go
type DiffLineSectionInfo struct {
...
// These line "idx" are 1-based line numbers (inclusive)
// LastLeftIdx | LastRightIdx (the last rendered line number before this hunk)
// [up/down/single expander] @@ hunk info @@
// LeftIdx | RightIdx (the next rendered line number after this hunk)
// The hunk has LeftHunkSize lines on left side, RightHunkSize lines on right side.
//
// For example:
// 17 | 31 diff line ...
// [up/down] @@ -40,23 +54,7 @@ ....
// 40 | 54 diff line ...
// (then file end or another hunk)
//
// In this case:
// LastLeftIdx = 17, LastRightIdx = 31
// (left lines 18-39, right lines 31-53 are hidden)
// LeftIdx = 40, RightIdx = 54
// LeftHunkSize = 23, RightHunkSize = 7
LastLeftIdx int
LastRightIdx int
LeftIdx int
RightIdx int
LeftHunkSize int
RightHunkSize int
HiddenCommentIDs []int64 // IDs of hidden comments in this section
}
HiddenCommentIDs 把"折叠段内藏有评论"的事实暴露给模板,让用户展开折叠区时能看到提示------这是评论与 diff 折叠机制的交汇点。
8.4.2 ParsePatch:解析 git diff 输出
入口函数签名(705 行):
go
func ParsePatch(ctx context.Context, maxLines, maxLineCharacters, maxFiles int, reader io.Reader, skipToFile string) (_ *Diff, retErr error)
三个 max* 参数来自仓库级配置(默认约 5000 行/文件、5000 字符/行、100 文件),超限即截断,对应 DiffFile.IsIncomplete / IsTruncated。解析分三层:
ParsePatch外层 :以const cmdDiffHead = "diff --git "为文件头标志切分文件,逐文件调parseOneDiffFile,最后postProcessFiles做编码转换(GBK/Shift_JAR 等非 UTF-8 文件按探测结果转码,避免模板输出乱码)。parseOneDiffFile:解析diff --git a/x b/y之后的扩展头------old mode/new mode(权限位变化)、similarity index/rename from/rename to(重命名)、new file mode/deleted file mode(增删)、Binary files ... differ(二进制)。文件名带空格或引号的"歧义文件名"用启发式回溯解析(isAmbiguous字段即为此存在)。之后调parseHunks解析 hunk 主体。parseHunks(1038 行) :逐行读取,按首字符分派------@开启新 hunk 并解析@@ -l,s +l,s @@得到起始行号;+/-分别生成右侧/左侧递增行号的DiffLine,并通过Match相互配对(增删行交错时由栈式匹配完成);空格是上下文行,两侧行号同步递增;\行(\ No newline at end of file)标记到前一行。非 diff 内容行(例如某些 merge 输出)会形成DiffLineSection类型的折叠段。LFS 指针文件在此被识别为IsLFSFile(后续渲染为"查看原始文件"而非文本)。
postProcessFiles 与渲染前的 GetDiffForRender(1390 行)职责分开:前者是数据修正(编码),后者是渲染增强------填充 linguist 属性(IsGenerated 生成文件、IsVendored vendor 目录,来自仓库的 .gitattributes linguist 配置)、对不超过 MaxFullFileHighlightSizeLimit(256KB)且能在时限(2 秒)内完成的文件做整文件语法高亮(逐行高亮则按需降级)、检测是否需要尾部折叠段(prepareDiffRenderDetail / addTailSection:最后一个 hunk 没到文件结尾时补一个"展开剩余行"的段)。
8.4.3 PR 页面的 diff 入口
路由层 routers/web/repo/pull.go 的 diff handler(tplPullFiles 模板)用 pr.MergeBase...refs/pull/N/head 三点语法取 diff:
go
// services/pull/patch.go(DownloadDiffOrPatch 同样的比较端点)
compareArg := pr.MergeBase + "..." + pr.GetGitHeadRefName()
三点 A...B 表示"从 A 与 B 的 merge base 到 B"------这正是 PR diff 的语义:只展示 head 相对共同祖先的改动,而不包含 base 分支上的并行改动。GetPullDiffStats(routers/web/repo/pull.go 185 行)则用 gitdiff.GetDiffShortStat 拿 "+x / -y" 汇总。GetMergedBaseCommitID(215 行)处理了一个真实边界:从外部迁移来的 PR 可能 MergeBase 为空 ,此时先尝试隐藏 ref,再回退到 patch 文件里记录的 commit,最后用 rev-list -1 --skip=1 取第一个 commit 的父节点作为近似 merge base,并顺手把隐藏 ref 重建出来。
8.4.4 行内评论的定位:Line 的符号约定
行内评论(code comment)要回答两个问题:评论在哪一行?评论当时的代码上下文是什么?models/issues/comment.go 的方案是把答案都存进 comment 行:
go
// models/issues/comment.go
Patch string `xorm:"-"`
PatchQuoted string `xorm:"LONGTEXT patch"`
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string {
if c.Line < 0 {
return "previous"
}
return "proposed"
}
// UnsignedLine returns the LOC of the code comment without + or -
func (c *Comment) UnsignedLine() uint64 {
if c.Line < 0 {
return uint64(c.Line * -1)
}
return uint64(c.Line)
}
Line 的符号承载 diff 侧别 :负数 = previous(旧版/删除侧行号),正数 = proposed(新版/新增侧行号),取绝对值才是 1-based 行号。TreePath 是文件路径,CommitSHA 是评论创建时该行所属的 commit(用于失效判定),Patch 是评论处的代码上下文快照。
Patch 的持久化有一个容易忽略的细节:字段标了 xorm:"-"(不直接入库),真实列是 PatchQuoted。BeforeInsert/BeforeUpdate 钩子在 Patch 非合法 UTF-8 时用 strconv.Quote 转义存储,AfterLoad 再按首字符是否为 " 决定是否 Unquote------这样即使评论的代码上下文来自二进制/GBK 文件也能无损往返。
创建行内评论时,上下文 patch 由 services/pull/review.go 的 createCodeComment 生成:
go
// services/pull/review.go
if line > 0 {
...
if len(commitID) == 0 {
// Get latest commit referencing the commented line
commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
if err == nil {
commitID = commit.ID.String()
} else if !isErrBlameNotFoundOrNotEnoughLines(err) {
return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", ...)
}
}
}
// Only fetch diff if comment is review comment
if len(patch) == 0 && reviewID != 0 {
headCommitID, err := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
...
patch, err = git.GetFileDiffCutAroundLine(ctx,
gitRepo, pr.MergeBase, headCommitID, treePath,
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
)
...
// If patch is still empty (unchanged line), generate code context
if patch == "" && commitID != "" {
patch, err = gitdiff.GeneratePatchForUnchangedLine(ctx, gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines)
...
}
}
三个要点:
lineBlame决定CommitSHA:用git blame找到该行最后被哪次 commit 改动。选line < 0(previous 侧)时 blame 的语义在源码中有 FIXME 注明尚未区分------已知的技术债。- patch 从
merge_base..head截取 :GetFileDiffCutAroundLine只取被评论行上下各setting.UI.CodeCommentLines(默认 4 行)的 diff 片段,与用户当时看到的 diff 视图一致。 - 未变更行的回退 :如果被评论行是上下文行(在
merge_base..head的 diff 里根本没出现),GetFileDiffCutAroundLine返回空串,改用GeneratePatchForUnchangedLine从commitID直接读文件内容生成纯上下文块。这保证"评论任何一行"都可行,而不仅是改过的行。
8.4.5 评论如何挂回 diff 渲染
Diff.LoadComments 把数据库里的评论重新对齐到结构化 diff 的行上:
go
// services/gitdiff/gitdiff.go
func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, currentUser *user_model.User, showOutdatedComments bool) error {
allComments, err := issues_model.FetchCodeComments(ctx, issue, currentUser, showOutdatedComments)
...
for _, file := range diff.Files {
if lineCommits, ok := allComments[file.Name]; ok {
for _, section := range file.Sections {
for _, line := range section.Lines {
if comments, ok := lineCommits[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, comments...)
}
if comments, ok := lineCommits[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, comments...)
}
sort.SliceStable(line.Comments, func(i, j int) bool {
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
})
// Mark expand buttons that have comments in hidden lines
FillHiddenCommentIDsForDiffLine(line, lineCommits)
}
}
}
}
return nil
}
匹配规则与 Comment.Line 的符号约定精确对应:评论以 Line*-1 作为 key 挂到 LeftIdx(旧侧行号),以 Line 作为 key 挂到 RightIdx(新侧行号) 。也就是说 FetchCodeComments 内部把 comment 的 Line 归一化成了"左侧行号取负、右侧行号取正"的 map key,与 DiffLine 的两个行号字段天然对齐。FillHiddenCommentIDsForDiffLine 则把落在折叠隐藏区里的评论 ID 记进 SectionInfo.HiddenCommentIDs,让模板渲染"此处有 N 条隐藏评论"的展开按钮。另有 SyncUserSpecificDiff 填充 IsViewed(已查看标记)与 HasChangedSinceLastReview(上次评审后又变了),数据来源是 models/pull/review_state.go 的 ReviewState.UpdatedFiles------第 8.10 节会再遇到它。
评论失效(invalidated)判定在 push 时触发(8.11.2):InvalidateCodeComments 对每个未失效的 code comment 跑 checkInvalidation------重新 git blame 该行,若 blame 得到的 commit 与评论记录的 CommitSHA 不同,或者行已不存在(NoSuchPath/FileNoEnoughLines),就把 Invalidated 置真,页面上评论转为"Outdated"折叠态。
8.4.6 评论侧的 Diff 复用
CommentAsDiff(1606 行)把单条评论存储的 patch 反向解析成 *Diff 结构,用于时间线里内联展示评论的代码上下文------同一套解析器服务两种视图,这是 diff 层被设计成"纯文本进、结构体出"的直接收益。
8.5 冲突检测的双实现
PR 状态的核心驱动是冲突检测。它在三个时机运行:创建 PR 时(事务前同步执行)、push 后入队异步执行、切换目标分支时同步执行。入口是一个能力探测:
go
// services/pull/patch.go
func checkPullRequestBranchMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("checkPullRequestBranchMergeable: %s", pr))
defer finished()
if git.DefaultFeatures().SupportGitMergeTree {
return checkPullRequestMergeableByMergeTree(ctx, pr)
}
return checkPullRequestMergeableByTmpRepo(ctx, pr)
}
8.5.1 路径 A:git merge-tree(现代 git)
services/pull/merge_tree.go(2026 年新增的文件,文件头版权即 2026)是八步流程:
go
// services/pull/merge_tree.go
func checkPullRequestMergeableByMergeTree(ctx context.Context, pr *issues_model.PullRequest) error {
// 1. Get head commit
// 2. Get/open base repository(同仓库时复用句柄)
// 3. Get head commit id
if pr.Flow == issues_model.PullRequestFlowGithub {
pr.HeadCommitID, err = headGitRepo.GetRefCommitID(ctx, git.BranchPrefix+pr.HeadBranch)
...
} else {
if pr.ID > 0 {
pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
...
} else if pr.HeadCommitID == "" { // for new pull request with agit, the head commit id must be provided
return errors.New("head commit ID is empty for pull request Agit flow")
}
}
// 4. fetch head commit id into the current repository
if !pr.IsSameRepo() {
if !baseGitRepo.IsReferenceExist(ctx, pr.HeadCommitID) {
if err := git.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, pr.HeadCommitID); err != nil { ... }
}
}
// 5. update merge base
baseCommitID, err := baseGitRepo.GetRefCommitID(ctx, git.BranchPrefix+pr.BaseBranch)
...
pr.MergeBase, err = git.MergeBase(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID)
if err != nil {
// if there is no merge base, then it's empty, still need to allow the pull request to be created
pr.Status = issues_model.PullRequestStatusEmpty
return nil
}
// reset conflicted files and changed protected files
pr.ConflictedFiles = nil
pr.ChangedProtectedFiles = nil
// 6. if base == head, then it's an ancestor
if pr.HeadCommitID == pr.MergeBase {
pr.Status = issues_model.PullRequestStatusAncestor
return nil
}
// 7. Check for conflicts
conflicted, err := checkConflictsMergeTree(ctx, pr, baseCommitID)
...
if conflicted || pr.Status == issues_model.PullRequestStatusEmpty {
return nil
}
// 8. Check for protected files changes
return checkPullFilesProtection(ctx, pr, baseGitRepo, pr.HeadCommitID)
}
第 5 步没有 merge base 时不报错而是标记 Empty 并放行------无共同历史的 PR 也允许创建(注释原话 "still need to allow the pull request to be created"),这是导入外部仓库时的现实需求。第 7 步的细节:
go
func checkConflictsMergeTree(ctx context.Context, pr *issues_model.PullRequest, baseCommitID string) (bool, error) {
treeHash, conflict, conflictFiles, err := git.MergeTree(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID, pr.MergeBase)
if err != nil {
return false, fmt.Errorf("MergeTree: %w", err)
}
if conflict {
pr.Status = issues_model.PullRequestStatusConflict
// sometimes git merge-tree will detect conflicts but not list any conflicted files
// so that pr.ConflictedFiles will be empty
pr.ConflictedFiles = conflictFiles
...
return true, nil
}
// Detect whether the pull request introduces changes by comparing the merged tree (treeHash)
// against the current base commit (baseCommitID) using `git diff-tree`. The command returns exit code 0
// if there is no diff between these trees (empty patch) and exit code 1 if there is a diff.
gitErr := gitcmd.NewCommand("diff-tree", "-r", "--quiet").
AddDynamicArguments(treeHash, baseCommitID).WithRepo(pr.BaseRepo).Run(ctx)
switch {
case gitErr == nil:
pr.Status = issues_model.PullRequestStatusEmpty
case gitcmd.IsErrorExitCode(gitErr, 1):
pr.Status = issues_model.PullRequestStatusMergeable
default:
return false, fmt.Errorf("run diff-tree exit abnormally: %w", gitErr)
}
return false, nil
}
一次 git merge-tree(写出合并后 tree 的新式用法)拿到结果 tree hash;有冲突直接落 Conflict 状态;无冲突则用 diff-tree -r --quiet 把合并结果与 base 比较------退出码 0(无差异)意味着 head 的改动已经全部包含在 base 中,标记 Empty ;退出码 1 才是真正的 Mergeable。这个"用退出码判空补丁"的技巧是 Gitea 判定"PR 实际上没带来任何改动"的标准做法。注释还记录了一个真实怪癖:merge-tree 有时报告冲突却不列出任何文件,所以 ConflictedFiles 可能为空但状态仍是 Conflict(页面对此的提示是"有冲突"而非"冲突文件列表")。
8.5.2 路径 B:临时仓库三路合并(兼容旧 git)
checkPullRequestMergeableByTmpRepo 在克隆的临时仓库里手工模拟合并。createTemporaryRepoForPR(services/pull/temp_repo.go)克隆 base 仓库并创建三个本地分支:base(目标分支)、tracking(head ref)、staging(合并工作区)。流程:
go
// services/pull/patch.go
func checkPullRequestMergeableByTmpRepo(ctx context.Context, pr *issues_model.PullRequest) error {
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
...
// 1. update merge base
pr.MergeBase, _, err = gitcmd.NewCommand("merge-base", "--", tmpRepoBaseBranch, tmpRepoTrackingBranch).WithRepo(prCtx.tmpRepo).RunStdString(ctx)
if err != nil {
// 无 merge-base 时回退用 base 分支 tip
pr.MergeBase, err2 = tmpGitRepo.GetRefCommitID(ctx, git.BranchPrefix+tmpRepoBaseBranch)
...
}
pr.MergeBase = strings.TrimSpace(pr.MergeBase)
if pr.HeadCommitID, err = tmpGitRepo.GetRefCommitID(ctx, git.BranchPrefix+tmpRepoTrackingBranch); err != nil { ... }
if pr.HeadCommitID == pr.MergeBase {
pr.Status = issues_model.PullRequestStatusAncestor
return nil
}
// 2. Check for conflicts
conflicts, err := checkConflictsByTmpRepo(ctx, pr, tmpGitRepo, prCtx.tmpBasePath)
...
pr.ChangedProtectedFiles = nil
if conflicts || pr.Status == issues_model.PullRequestStatusEmpty {
return nil
}
// 3. Check for protected files changes
if err = checkPullFilesProtection(ctx, pr, tmpGitRepo, tmpRepoTrackingBranch); err != nil { ... }
pr.Status = issues_model.PullRequestStatusMergeable
return nil
}
冲突检测核心 AttemptThreeWayMerge 复刻了 git 的 merge-one-file 算法:
go
// AttemptThreeWayMerge will attempt to three way merge using git read-tree and then follow the git merge-one-file algorithm ...
func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repository, base, ours, theirs, description string) (bool, []string, error) {
// First we use read-tree to do a simple three-way merge
if err := gitcmd.NewCommand("read-tree", "-m").AddDynamicArguments(base, ours, theirs).WithRepo(gitRepo).RunWithStderr(ctx); err != nil {
return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %w", err)
}
// Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles
unmerged := make(chan *unmergedFile)
go unmergedFiles(ctx, gitRepo, unmerged)
...
for file := range unmerged {
...
if err := attemptMerge(ctx, file, gitPath, gitRepo, &filesToRemove, &filesToAdd); err != nil {
if conflictErr, ok := err.(*errMergeConflict); ok {
conflict = true
if numberOfConflicts < 10 {
conflictedFiles = append(conflictedFiles, conflictErr.filename)
}
numberOfConflicts++
continue
}
return false, nil, err
}
}
// Add and remove files in one command, as this is slow with many files otherwise
if err := gitRepo.RemoveFilesFromIndex(ctx, filesToRemove...); err != nil { ... }
if err := gitRepo.AddObjectsToIndex(ctx, filesToAdd...); err != nil { ... }
return conflict, conflictedFiles, nil
}
read-tree -m base ours theirs 把三路合并写进索引;ls-files -u 列出所有未合并条目(每个文件最多 stage 1/2/3 三个版本);attemptMerge 对每个条目执行单文件判定------注意冲突文件列表只收集前 10 个 (numberOfConflicts < 10),超出部分只计数不列举,防止极端冲突场景撑大数据库行。
attemptMerge 的判定表(完整代码 130-235 行)可归纳为:
| stage 组合 | 场景 | 结果 |
|---|---|---|
| 1 存在,2/3 缺一 | 一侧删除 | 删除侧与未删侧 sha 不同 → 冲突;相同 → 从索引移除 |
| 1 缺,2 存,3 缺或 2==3 | 仅 ours 新增(或两侧相同) | 加入索引(无冲突) |
| 1 缺,2、3 同 sha 不同 mode | 两侧同内容不同权限 | 冲突 |
| 1 缺 2 缺 3 存 | 仅 theirs 新增 | 加入索引 |
| 1、2、3 全缺 | 不可达 | 按分支兜底返回冲突 |
| 2、3 都存(修改两侧) | 双方都改 | 先查 mode:120000 符号链接 / 160000 子模块 / mode 不一致均冲突;否则 unpack-file 取三个 blob,git merge-file base root head 三方合并,非零退出即冲突,成功则 hash-object -w 写回索引 |
注意 git merge-file 的参数顺序与 merge-tree 相反(源码注释 "now git merge-file annoyingly takes a different order to the merge-tree"),这类 git 命令间的语义差异是这个手写实现必须逐条对齐的地方。
8.5.3 空补丁检测(路径 B)
无冲突时还要判断"是否空补丁":
go
// a. Write the newly merged tree and check the new tree-hash
treeHash, _, err = gitcmd.NewCommand("write-tree").WithRepo(tmpGitRepo).RunStdString(ctx)
...
// b. compare the new tree-hash with the base tree hash
if treeHash == baseTree.ID.String() {
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
pr.Status = issues_model.PullRequestStatusEmpty
}
与路径 A 的 diff-tree --quiet 异曲同工:路径 B 用"合并后的 tree 与 base tree 是否同哈希"判空,路径 A 用 diff-tree 退出码。两条路径最终都可能落 Empty 状态------它不是错误,UI 上显示为"没有可合并的更改"。
8.5.4 受保护文件变更检测
两条路径最后都执行 checkPullFilesProtection:
go
func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, headRef string) error {
if pr.Status == issues_model.PullRequestStatusEmpty {
pr.ChangedProtectedFiles = nil
return nil
}
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
if pb == nil {
pr.ChangedProtectedFiles = nil
return nil
}
pr.ChangedProtectedFiles, err = CheckFileProtection(ctx, gitRepo, pr.HeadBranch, pr.MergeBase, headRef, pb.GetProtectedFilePatterns(), 10, os.Environ())
if err != nil && !IsErrFilePathProtected(err) {
return err
}
...
}
CheckFileProtection 拿 merge_base..head 的受影响文件列表与分支保护的 ProtectedFilePatterns(glob,大小写不敏感------路径先 strings.ToLower)逐一匹配,同样最多收集 10 个 。结果写入 ChangedProtectedFiles,在 8.6.2 的 CheckPullBranchProtections 最后一步被消费:改动了受保护文件的 PR 即使审批齐全也不能合(除非管理员 bypass)。
8.6 可合并判定:CheckPullMergeable 与分支保护
冲突检测只回答"git 层面能不能合";"允不允许合"由 CheckPullMergeable 回答。它是合并按钮、自动合并、API 合并三个入口共用的裁判。
8.6.1 判定序列
go
// services/pull/check.go
type MergeCheckType int
const (
MergeCheckTypeGeneral MergeCheckType = iota // general merge checks for "merge", "rebase", "squash", etc
MergeCheckTypeManually // Manually Merged button (mark a PR as merged manually)
MergeCheckTypeAuto // Auto Merge (Scheduled Merge) After Checks Succeed
)
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, forceMerge bool) error {
return db.WithTx(stdCtx, func(ctx context.Context) error {
if pr.HasMerged {
return ErrHasMerged
}
if err := pr.LoadIssue(ctx); err != nil { ... } else if pr.Issue.IsClosed {
return ErrIsClosed
}
if allowedMerge, err := IsUserAllowedToMerge(ctx, pr, *perm, doer); err != nil {
...
} else if !allowedMerge {
return ErrNoPermissionToMerge
}
if mergeCheckType == MergeCheckTypeManually {
// if doer is doing "manually merge" (mark as merged manually), do not check anything
return nil
}
if pr.IsWorkInProgress(ctx) {
return ErrIsWorkInProgress
}
if !pr.IsStatusMergeable() && !pr.IsEmpty() {
return ErrNotMergeableState
}
if pr.IsChecking() {
return ErrIsChecking
}
if errProtection := CheckPullBranchProtections(ctx, pr, false); errProtection != nil {
if !errors.Is(errProtection, ErrNotReadyToMerge) { ... return errProtection }
// * when doing Auto Merge ... skip the branch protection check
if mergeCheckType == MergeCheckTypeAuto {
errProtection = nil
}
// * if the doer tries to "Force Merge", check whether it is really allowed
if forceMerge {
isRepoAdmin := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer)
protectedBranchRule, errForceMerge := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
canForceMerge := isRepoAdmin
if protectedBranchRule != nil {
canForceMerge = git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin)
}
if canForceMerge {
errProtection = nil
}
}
if errProtection != nil {
return errProtection
}
}
if err := checkSigningRequirements(ctx, pr, doer, mergeStyle); err != nil {
return err
}
if noDeps, err := issues_model.IssueNoDependenciesLeft(ctx, pr.Issue); err != nil {
return err
} else if !noDeps {
return ErrDependenciesLeft
}
return nil
})
}
全部检查在一个只读事务里完成。错误值定义在文件顶部的 Err 常量集(ErrIsClosed、ErrNoPermissionToMerge、ErrNotReadyToMerge、ErrHasMerged、ErrIsWorkInProgress、ErrIsChecking、ErrNotMergeableState、ErrDependenciesLeft、ErrHeadCommitsNotAllVerified),路由层把它们映射为用户可读的提示(8.12.3 的决策表列出映射)。
三个 MergeCheckType 的语义差异很关键:
- General:完整检查,分支保护不通过就是不通过。
- Manually :在权限与已合并/已关闭检查后直接返回 nil------"标记为手动合并"按钮不需要任何状态,因为它标记的是"这个 PR 其实已经被合进了 base 分支"的事实。
- Auto :分支保护失败可被静默跳过 (
errProtection = nil)。这不是绕过审批------自动合并队列在入队时已经做过完整检查,此后审批状态可能因新 push 变化,但排在队列里的合并意图是用户在满足条件时授权的;真正落Merge前还会再走一遍 8.9 的判定链。
forceMerge 分支回收第 5 章的伏笔:管理员勾选"强制合并"时,先判断是否仓库 admin,再让 git_model.CanBypassBranchProtection 结合 ProtectedBranch 规则(含第 5 章讲的 BlockAdminMergeOverride 等开关)决定是否放行------能否强制合并不是简单的"admin 就行",而是分支保护规则配置的函数。
checkSigningRequirements 按合并样式区分签名要求(源码注释即为文档):
go
// - fast-forward-only and merge keep the user's commits on the base branch, so
// those commits must all be verified, or the pre-receive hook will reject the
// push with a generic error.
// - fast-forward-only creates no Gitea commit, so Gitea's signing key is not used.
// - merge, rebase, rebase-merge and squash produce a Gitea-signed commit, so
// Gitea must be configured to sign it.
func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle) error {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
if pb == nil || !pb.RequireSignedCommits {
return nil
}
...
if mergeStyle == repo_model.MergeStyleFastForwardOnly || mergeStyle == repo_model.MergeStyleMerge {
verified, err := asymkey_service.AllHeadCommitsVerified(ctx, pr, gitRepo)
...
if !verified {
return ErrHeadCommitsNotAllVerified
}
}
if mergeStyle != repo_model.MergeStyleFastForwardOnly {
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, pr.BaseBranch, pr.GetGitHeadRefName()); err != nil {
return err
}
}
return nil
}
8.6.2 分支保护七项检查
services/pull/merge.go 的 CheckPullBranchProtections 是分支保护规则的消费端,顺序执行:
go
func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullRequest, skipProtectedFilesCheck bool) (err error) {
...
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
if pb == nil {
return nil // 无规则直接放行
}
isPass, err := IsPullCommitStatusPass(ctx, pr)
if !isPass {
return util.ErrorWrap(ErrNotReadyToMerge, "Not all required status checks successful")
}
if !issues_model.HasEnoughApprovals(ctx, pb, pr) {
return util.ErrorWrap(ErrNotReadyToMerge, "Does not have enough approvals")
}
if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) {
return util.ErrorWrap(ErrNotReadyToMerge, "There are requested changes")
}
if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) {
return util.ErrorWrap(ErrNotReadyToMerge, "There are official review requests")
}
if issues_model.MergeBlockedByOutdatedBranch(pb, pr) {
return util.ErrorWrap(ErrNotReadyToMerge, "The head branch is behind the base branch")
}
if !issue_service.HasAllRequiredCodeownerReviews(ctx, pb, pr) {
return util.ErrorWrap(ErrNotReadyToMerge, "There are missing code owner reviews")
}
if skipProtectedFilesCheck {
return nil
}
if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) {
return util.ErrorWrap(ErrNotReadyToMerge, "Changed protected files")
}
return nil
}
七项检查依次是:commit status → 审批数 → 拒绝评审 → 官方评审请求未处理 → 分支落后 → CodeOwners 评审 → 受保护文件改动 。全部失败都包装成同一个哨兵错误 ErrNotReadyToMerge 加不同的可读消息------CheckPullMergeable 只需 errors.Is(err, ErrNotReadyToMerge) 判断"这是可通过 forceMerge/自动合并策略处理的失败",其他错误(git 故障等)原样上抛。skipProtectedFilesCheck 参数供特定调用方跳过最后一项。
commit status 的具体判定在 services/pull/commit_status.go:
go
// IsPullCommitStatusPass returns if all required status checks PASS
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
if pb == nil {
return true, nil
}
if !pb.EnableStatusCheck {
// The branch's own status check is off, but required scoped checks (mandated by the owner or instance admin) still gate the merge.
...
required, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb)
...
if len(required) == 0 {
return true, nil
}
}
state, err := GetPullRequestCommitStatusState(ctx, pr)
...
return state.IsSuccess(), nil
}
EffectiveRequiredContexts 把两类需求合并:scoped workflow(实例/组织级强制 CI 模式,actions_model.GetEffectiveScopedWorkflowSources)与分支规则自己的 StatusCheckContexts------即使规则本身的 status check 被关闭,scoped 强制检查依然拦截合并 。MergeRequiredContextsCommitStatus 用 glob 匹配 required contexts:任何 required context 尚未出现(未 matched)时返回 Pending 而非 Success("没跑 = 没过"),已出现的按最差状态聚合。CI 状态如何产生、第 14 章详述,这里只关注它作为合并门禁的语义。
8.6.3 权限侧:IsUserAllowedToMerge
go
func isUserAllowedToMergeInRepoBranch(ctx context.Context, repoID int64, branch string, p access_model.Permission, user *user_model.User) (bool, error) {
if user == nil { return false, nil }
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repoID, branch)
...
if (p.CanWrite(unit.TypeCode) && pb == nil) || (pb != nil && git_model.IsUserMergeWhitelisted(ctx, pb, user.ID, p)) {
return true, nil
}
return false, nil
}
一句话概括:无分支保护时,对代码单元有写权限即可合;有保护规则时,必须在规则的 merge whitelist 里 (whitelist 可按用户/组织团队配置,第 5 章权限矩阵覆盖了 IsUserMergeWhitelisted 的展开逻辑)。
8.6.4 检查队列:pr_patch_checker
状态 Checking 的产生与消费由唯一队列驱动(services/pull/check.go):
go
// prPatchCheckerQueue represents a queue to handle update pull request tests
var prPatchCheckerQueue *queue.WorkerPoolQueue[string]
var AddPullRequestToCheckQueue = realAddPullRequestToCheckQueue
func StartPullRequestCheckImmediately(ctx context.Context, pr *issues_model.PullRequest) {
if !markPullRequestStatusAsChecking(ctx, pr) {
return
}
AddPullRequestToCheckQueue(pr.ID)
}
// StartPullRequestCheckDelayable will delay the check if the pull request was not updated recently.
// When the "base" branch gets updated, all PRs targeting that "base" branch need to re-check whether
// they are mergeable. When there are too many stale PRs, each "base" branch update will consume a lot
// of system resources. So we can delay the checks for PRs that were not updated recently ...
func StartPullRequestCheckDelayable(ctx context.Context, pr *issues_model.PullRequest) {
if !markPullRequestStatusAsChecking(ctx, pr) { return }
if setting.Repository.PullRequest.DelayCheckForInactiveDays >= 0 {
if err := pr.LoadIssue(ctx); err != nil { return }
duration := 24 * time.Hour * time.Duration(setting.Repository.PullRequest.DelayCheckForInactiveDays)
if pr.Issue.UpdatedUnix.AddDuration(duration) <= timeutil.TimeStampNow() {
return // 不活跃 PR:只标记 Checking,不入队
}
}
AddPullRequestToCheckQueue(pr.ID)
}
三个入队入口的策略差异体现了负载设计:head push 用 Immediately (用户在等结果);base push 用 Delayable (主分支一动,所有目标它的 PR 都要重查,可能几十上百个,不活跃的 PR 只标状态不实际跑,等它被查看时再补,见下一函数);页面查看用 OnView:
go
func StartPullRequestCheckOnView(ctx context.Context, pr *issues_model.PullRequest) {
// TODO: its correctness totally depends on the "unique queue" feature and the global lock.
if pr.Status == issues_model.PullRequestStatusChecking {
...
_, _ = globallock.TryLockAndDo(ctx, getPullWorkingLockKey(pr.ID), func(ctx context.Context) error {
AddPullRequestToCheckQueue(pr.ID) // the queue is a unique queue and won't add the same task again
return nil
})
}
}
这里显式依赖两个并发原语:unique queue (同一 PR 重复入队被去重,queue.ErrAlreadyInQueue 被容忍)与全局锁 pull_working_<id> (检查任务执行时持有,OnView 侧用 TryLock 避免死锁------测试环境下队列是同步执行的,源码对 setting.IsInTesting 做了特判并给出注释)。
消费端:
go
func checkPullRequestMergeable(prID int64) {
ctx := graceful.GetManager().HammerContext()
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Test PR[%d] from patch checking queue", prID))
defer finished()
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
pr, err := issues_model.GetPullRequestByID(ctx, prID)
...
if pr.HasMerged { return nil }
if ok, err := restoreInterruptedMerge(ctx, pr); err != nil {
return err
} else if ok {
return nil
}
if manuallyMergedByPushedCommit(ctx, pr) {
return nil
}
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
...
pr.Status = issues_model.PullRequestStatusError
...
return err
}
return markPullRequestAsMergeable(ctx, pr)
})
...
}
一个检查任务在全局锁内依次尝试:恢复中断的合并 (8.7.5)→ 侦测手动合并 (8.7.6)→ 真正跑冲突检测 → 落终态 。任何一步成功都短路返回。markPullRequestAsMergeable 收尾时还有一个精妙的竞态防御:
go
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
// If the status has not been changed to conflict by the conflict checking functions then we are mergeable
if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable
}
// Make sure there is no waiting test to process before leaving the checking status.
has, err := prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10))
...
if has {
log.Trace("Not updating status for %-v as it is due to be rechecked", pr)
return nil
}
if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
return err
}
// if there is a scheduled merge for this pull request, start the auto merge check (again)
hasScheduledMerge, _, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
...
if hasScheduledMerge {
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
return nil
}
如果检查进行期间又有新任务入队(期间发生了 push),当前结果不落库 直接返回------避免旧结果覆盖新状态;队列里的下一个任务会重算。落库成功后若发现有挂起的自动合并计划,立即触发自动合并复检(8.9 的挂接点之一)。InitializePullRequests 在启动时把 GetInterruptedPullRequestIDs(有 MergedCommitID 但未标 merged 的"中断合并")与遗留 Checking 状态的 PR 重新入队,实现崩溃恢复。
8.7 合并执行:从 Merge 到一次 git push
判定通过后,真正的合并发生在 services/pull/merge.go 的 Merge 中。这一节把这条链路拆成七段:入口与锁、临时仓库与四样式分派、合并消息、崩溃恢复、手动合并侦测、终态落库。
8.7.1 Merge 入口:同一个全局锁
go
// Merge merges pull request to base repository.
// Caller should check PR is ready to be merged (review and status checks)
func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
...
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
...
err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
pr, err := issues_model.GetPullRequestByID(ctx, prID)
...
prConfig := pr.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
// Check if merge style is correct and allowed
if !prConfig.IsMergeStyleAllowed(mergeStyle) {
return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
}
hasCommitBeenMerged, err := hasPullRequestCommitBeenMerged(ctx, pr)
...
if !hasCommitBeenMerged {
_, err = doMergeAndPush(ctx, pr, doer, mergeStyle, message, mergeAndPushOptions{
expectedHeadCommitID: expectedHeadCommitID,
onMergedIntoTempBase: func(commitID string) error {
return recordMergeIntent(ctx, pr, doer, commitID, wasAutoMerged)
},
})
if err != nil {
return err
}
}
_, err = MarkAsMerged(ctx, pr, pr.MergedCommitID, pr.MergedUnix, doer, pr.Status)
...
})
...
pr, err := issues_model.GetPullRequestByID(ctx, prID)
...
addTestPullRequestTaskAfterWebOperation(pr, doer) // keep the same behavior as old code: always call AddTestPullRequestTask
return nil
}
四个设计点值得注意:
- 锁复用 。
getPullWorkingLockKey(prID)就是 8.6.4 里冲突检查用的pull_working_<id>------合并与冲突检测互斥,这正是MarkAsMerged里那句 FIXME("need to set some state to prevent checkPullRequestBranchMergeable from running whilst we are merging")所要求的不变量,只不过 Gitea 用锁而非状态位实现。 - HammerContext。注释写明"即使用户请求被取消也不中止 git 操作"------合并一旦开始就要跑完,避免半途取消留下脏的临时仓库。
- 幂等 。
hasPullRequestCommitBeenMerged确认MergedCommitID已在 base 分支时跳过doMergeAndPush直接走MarkAsMerged,重复点击"合并"不会二次 push(详见 8.7.5)。 - 锁外收尾 。
addTestPullRequestTaskAfterWebOperation在锁释放后异步触发 PR 任务,其注释坦承这是 PR #7082 引入的"与 post-receive hook 重复调用"的权衡,并留了TODO: DUPLICATE-PR-TASK。
Merge 的调用方分两类:网页/API 走 MergeCheckTypeGeneral 完整判定;自动合并(8.9)传 wasAutoMerged=true。
8.7.2 临时仓库与四样式分派:doMergeAndPush
doMergeAndPush 不修改任何数据库字段(它的注释明确说 "without changing any pull information in database"),只负责"在临时仓库里做出合并结果并推回 base":
go
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, message string, opts mergeAndPushOptions) (string, error) {
// Clone base repo.
mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, opts.expectedHeadCommitID)
...
// Merge commits.
switch mergeStyle {
case repo_model.MergeStyleMerge:
if err := doMergeStyleMerge(mergeCtx, message); err != nil { ... }
case repo_model.MergeStyleRebase, repo_model.MergeStyleRebaseMerge:
if err := doMergeStyleRebase(mergeCtx, mergeStyle, message); err != nil { ... }
case repo_model.MergeStyleSquash:
if err := doMergeStyleSquash(mergeCtx, message); err != nil { ... }
case repo_model.MergeStyleFastForwardOnly:
if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil { ... }
default:
return "", ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
}
// OK we should cache our current head and origin/headbranch
mergeHeadSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpRepo, "HEAD")
... // 以及 mergeBaseSHA(original_<base>)与 mergeCommitID(staging),供 LFS push 与 recordMergeIntent 使用
if setting.LFS.StartServer {
if err := LFSPush(ctx, mergeCtx.tmpBasePath, mergeCtx.tmpRepo, mergeHeadSHA, mergeBaseSHA, pr); err != nil { ... }
}
...
if opts.onMergedIntoTempBase != nil {
if err := opts.onMergedIntoTempBase(mergeCommitID); err != nil { ... }
}
mergeCtx.env = repo_module.FullPushingEnvironment(headUser, doer, pr.BaseRepo, pr.BaseRepo.Name, pr.ID, pr.Index)
pushCmd := gitcmd.NewCommand("push", "origin").AddDynamicArguments(tmpRepoBaseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
// Push back to upstream.
// This cause an api call to "/api/internal/hook/post-receive/...",
// If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
if err := mergeCtx.PrepareGitCmd(pushCmd).RunWithStderr(ctx); err != nil {
if strings.Contains(err.Stderr(), "non-fast-forward") {
return "", &git.ErrPushOutOfDate{...}
} else if strings.Contains(err.Stderr(), "! [remote rejected]") {
err := &git.ErrPushRejected{...}
err.GenerateMessage()
return "", err
}
return "", fmt.Errorf("git push: %s", err.Stderr())
}
...
}
时序上的关键顺序是:先 LFS push、再记录合并意图(recordMergeIntent)、最后 git push 。LFS 放最前的理由写在注释里------"failures to push to the lfs should prevent the merge as you can always remerge"(LFS 对象丢了就永远找不回,而合并可以重来)。recordMergeIntent 在 push 前把 merged_commit_id/merged_unix/merger_id 写进数据库(8.7.5 详述),这样 push 成功但进程崩溃时,重启后能凭这条记录恢复。而真正的 git push 会触发 /api/internal/hook/post-receive/...------注释特意强调数据库事务都放在 hook 回调里做,避免与 push 过程死锁。
push 的两类失败被翻译成路由层可识别的错误:ErrPushOutOfDate(base 分支在判定后又被推进,"non-fast-forward")与 ErrPushRejected(! [remote rejected],通常是 pre-receive hook 拒绝,GenerateMessage 会把 hook 的 stderr 输出整理成用户可读的提示)------这就是 8.2.1 里 CompareAndPullRequestPost 展示的 ErrPushRejected 同源错误。
8.7.3 四种合并样式的 git 命令差异
services/pull/ 下每个样式一个文件,命令差异一览:
| MergeStyle | 实现文件 | 核心 git 命令 | 产物 |
|---|---|---|---|
merge |
merge_merge.go | git merge --no-ff --no-commit <tracking> + commitAndSignNoAuthor |
1 个 merge commit(作者为 doer) |
rebase |
merge_rebase.go | rebaseTrackingOnToBase 后 git merge --ff-only <staging>,再按模板 commit --amend |
若干重写后的线性 commit(无 merge commit) |
rebase-merge |
merge_rebase.go | rebaseTrackingOnToBase 后 git merge --no-ff --no-commit <staging> + commitAndSignNoAuthor |
重写后的线性 commit + 1 个 merge commit |
squash |
merge_squash.go | git merge --squash <tracking> + git commit --allow-empty --author='<sig>' |
1 个 squash commit |
fast-forward-only |
merge_ff_only.go | git merge --ff-only <tracking> |
0 个新 commit(纯 fast-forward) |
几个实现细节:
merge 样式 先 --no-commit 停在暂存区,再由 commitAndSignNoAuthor 提交------这样能用 addCommitSigningOptions 统一注入签名参数(signKey == nil 时加 --no-gpg-sign,否则 --gpg-sign=<keyid>):
go
func commitAndSignNoAuthor(ctx *mergeContext, message string) error {
cmdCommit := gitcmd.NewCommand("commit")
if err := git.AddObjectMessageArgument(cmdCommit, git.ObjectCommit, message); err != nil { ... }
addCommitSigningOptions(cmdCommit, ctx.signKey)
if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil { ... }
return nil
}
squash 样式 的作者选择逻辑最讲究------getAuthorSignatureSquash 不直接用 PR 发起人,而是先在 head 分支的提交里找"邮箱能匹配到发起人本人"的签名:
go
uniqueEmails := make(container.Set[string])
for _, commit := range commits {
if commit.Author != nil && uniqueEmails.Add(commit.Author.Email) {
commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
if commitUser != nil && commitUser.ID == ctx.pr.Issue.Poster.ID {
return commit.Author, nil
}
}
}
return ctx.pr.Issue.Poster.NewGitSig(), nil
注释解释了动机:"the poster email might be private or commits might have a different signature than the primary email address"------用户在 Gitea 的主邮箱可能与 git 提交签名不一致,直接用会产生错误的 author。遍历去重(uniqueEmails)避免对同一邮箱反复查库。兜底才用 Poster.NewGitSig()。随后 git commit --allow-empty --author=... 保证即使 squash 结果为空也产生 commit;若开启 AddCoCommitterTrailers 且 committer 与 author 不同,还会追加 Co-authored-by 尾行(AddCommitMessageTailer 会先探测最后一行是否已是 trailer 格式,避免重复追加)。
rebase 系列 先 rebaseTrackingOnToBase 把 head 重放到 base 上(在 merge_prepare.go 中,兼容 Git ≥2.26 的 REBASE_HEAD 与更早版本的 .git/rebase-apply 检测,冲突时返回 ErrRebaseConflicts),切回 base 后再分派:rebase 样式走 doMergeRebaseFastForward------注意它在 fast-forward 前后各取一次 HEAD,若两者相同(fast-forward 未推进任何 commit)直接返回,注释写明是为避免"对未变更的 commit 做无意义 amend";rebase-merge 样式则追加 merge commit。
fast-forward-only 是四者中唯一不产生任何 commit 的,实现也只有一条 git merge --ff-only <tracking>(merge_ff_only.go),其 runMergeCommand 失败时专门识别 "Not possible to fast-forward, aborting" 映射为 ErrMergeDivergingFastForwardOnly。
四种样式共享的失败翻译在 runMergeCommand:
go
// Merge will leave a MERGE_HEAD file in the .git folder if there is a conflict
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "MERGE_HEAD")); statErr == nil {
return ErrMergeConflicts{...}
} else if strings.Contains(err.Stderr(), "refusing to merge unrelated histories") {
return ErrMergeUnrelatedHistories{...}
} else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(err.Stderr(), "Not possible to fast-forward, aborting") {
return ErrMergeDivergingFastForwardOnly{...}
}
判断冲突的依据不是解析 stderr 文案,而是探测 .git/MERGE_HEAD 文件是否存在------这是 git 写入冲突状态的客观信号,比匹配本地化错误文本稳健。
8.7.4 合并消息:模板三级回退
getMergeMessage 决定最终 commit message,采用三级回退:
- 从 base 分支默认分支的 HEAD commit 里读模板文件(不是当前分支):先试
.gitea/default_merge_message/{STYLE}_TEMPLATE.md(如MERGE_TEMPLATE.md、SQUASH_TEMPLATE.md,strings.ToUpper拼出),找不到再试DEFAULT_TEMPLATE.md------这是resolveMergeMessageTemplate的两级;两级都缺才进入第 2 级。 - 内置默认串。squash 是
"<title> (#123)",其余是"Merge pull request '<title>' (#123) from <head> into <base>"(同仓库省略 owner,head 仓库已删则显示<deleted>)。rebase样式在没有模板时返回空 message------注释说明"for fast-forward rebase, do not amend the last commit if there is no template",即不破坏原 commit message。 - 无论哪级,body 都附加
Reviewed-on: <URL>与Reviewed-by: <approvers>两行 trailer。
模板命中时走 expandDefaultMergeMessage,把 {{.PullRequestTitle}} 之类变量展开(vars.ExpandShellLike),可用变量包括 Base/Head 仓库与分支、标题、正文、发起人、PullRequestReference(#123,外部 tracker 时变成 !123)、ClosingIssues(从 ResolveCrossReferences 里筛出 XRefActionCloses 的引用拼成 close #1, #2)。模板拆分规则是"首行做 message,其余做 body"。
8.7.5 中断合并的恢复:recordMergeIntent 与 restoreInterruptedMerge
崩溃恢复是一条贯穿启动、检查、合并三点的链。合并侧的记录动作:
go
func recordMergeIntent(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, commitID string, wasAutoMerged bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
pr.MergedUnix = timeutil.TimeStampNow()
pr.MergedCommitID = commitID
pr.MergerID = doer.ID
_, err := db.GetEngine(ctx).ID(pr.ID).Cols("merged_unix", "merged_commit_id", "merger_id").Update(pr)
...
if wasAutoMerged {
hasScheduledMerge, scheduledMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pr.ID)
...
if hasScheduledMerge {
scheduledMerge.MergedCommitID = commitID
_, err = db.GetEngine(ctx).ID(scheduledMerge.ID).Cols("merged_commit_id").Update(scheduledMerge)
...
}
}
return nil
})
}
它在 git push 之前 执行,此时 has_merged 仍为 false------所以一旦 push 后崩溃,数据库里会留下"有 MergedCommitID 但未标 merged"的状态,即 MarkAsMerged 注释里的第 4 步失败场景。恢复发生在检查任务中:
go
func restoreInterruptedMerge(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
hasCommitBeenMerged, err := hasPullRequestCommitBeenMerged(ctx, pr)
...
if !hasCommitBeenMerged {
return false, nil
}
return MarkAsMerged(ctx, pr, pr.MergedCommitID, pr.MergedUnix, pr.Merger, pr.Status)
}
hasPullRequestCommitBeenMerged 用 git.IsCommitInBranch(MergedCommitID, BaseBranch) 验证提交确实在 base 分支里------只信事实不信记录:push 没成功就不恢复,PR 保持 open 可重新合并。
MarkAsMerged 自身的注释完整描述了这套四步协议:
go
// To make sure the pull request is marked as merged correctly, the caller uses multiple-stage operations:
// 1. Create a temp repo from base, merge the head into the temp repo, and get the merged commit ID and timestamp,
// 2. The merged commit ID and related information are stored into pull request
// 3. Push the merged commit to the base repo
// 4. Call MarkAsMerged to mark the pull request as merged and do post-processing (notification, close issues, etc)
//
// If failure occurs in step 1/2/3: the pull request is still open, the base repo is not changed, the doer can start a new merge.
// If failure occurs in step 4: the pull request can be marked as merged by the merged commit ID stored in it later.
8.7.6 手动合并的侦测:在 Gitea 之外完成的合并
两种路径把"绕过 Gitea 直接 push 合并"的 PR 收编为 merged:
路径一:用户主动标记 (MergedManually,手动合并按钮)。它先校验 MergeStyleManuallyMerged 是否被仓库允许,然后要求用户给出一个 commit ref,并用两道校验确认它真与 PR 相关:
go
commit, err := baseGitRepo.GetCommit(ctx, mergeCommitRef)
...
ok, err := git.IsCommitInBranch(ctx, baseGitRepo, mergeCommitRef, pr.BaseBranch)
if !ok {
return util.NewInvalidArgumentErrorf("commit not found in base branch")
}
_, err = MarkAsMerged(ctx, pr, mergeCommitRef, timeutil.TimeStamp(commit.Author.When.Unix()), doer, issues_model.PullRequestStatusManuallyMerged)
路径二:自动侦测 (manuallyMergedByPushedCommit,每次冲突检查的第一段)。它受 base 仓库 PR 单元配置 AutodetectManualMerge 开关控制,核心是 getMergeCommit:
go
// Check if the pull request is merged into BaseBranch
cmd := gitcmd.NewCommand("merge-base", "--is-ancestor").AddDynamicArguments(prHeadRef, pr.BaseBranch)
if err := cmd.WithRepo(pr.BaseRepo).RunWithStderr(ctx); err != nil {
if gitcmd.IsErrorExitCode(err, 1) {
// prHeadRef is not an ancestor of the base branch
return nil, nil //nolint:nilnil // return nil to indicate that the PR head is not merged
}
// Errors are signaled by a non-zero status that is not 1
return nil, fmt.Errorf("%-v git merge-base --is-ancestor: %w", pr, err)
}
其选择逻辑是:merge-base --is-ancestor 的退出码 1 表示 head 尚不是 base 祖先(正常结果,非错误,其他非零才算失败);确认已合并后执行 rev-list --ancestry-path --merges --reverse <head>..<base> 取输出首行(--reverse 保证一次 push 合并多个 PR 时拿到最老的、即引入该 PR 的 merge commit),输出不足完整 SHA 长度则说明是纯 fast-forward,改用 head commit 本身。merger 的确定也有兜底链:getMergerForManuallyMergedPullRequest 先取 base 分支记录的 Pusher(LoadPusher 对不存在的用户返回 ghost),拿不到再退回 base 仓库 owner。
自动侦测成功后 PR 进入 PullRequestStatusManuallyMerged 终态,与 MergedManually 路径汇合于同一个 MarkAsMerged------这也解释了 8.1.1 状态表里 ManuallyMerged=4 为何是一个"终态"而不是中间态。
8.7.7 终态落库:MarkAsMerged 的乐观锁
MarkAsMerged 用条件更新做并发保护:
go
// We need to save all of the data used to compute this merge as it may have already been changed by checkPullRequestBranchMergeable. FIXME: need to set some state to prevent checkPullRequestBranchMergeable from running whilst we are merging.
if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
And("has_merged = ?", false).
Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").
Update(pr); err != nil {
return false, fmt.Errorf("failed to update pr[%d]: %w", pr.ID, err)
} else if cnt != 1 {
return false, issues_model.ErrIssueAlreadyChanged
}
has_merged = false 作为 WHERE 条件,cnt != 1 即返回 ErrIssueAlreadyChanged------两个并发合并请求只有一个能成功。事务内还完成三件事:删除 scheduled auto merge 记录(比对 MergedCommitID 相同才认定 wasAutoMerged)、SetIssueAsClosed 关闭 Issue、清空 ConflictedFiles。事务外的 markAsMergedPostProcess 则按 wasAutoMerged 分派 notify_service.AutoMergePullRequest 或 MergePullRequest,清 base 分支的 commit 计数缓存,并由 handleCloseCrossReferences 执行 PR 正文里 close #123/reopen #123 的交叉引用动作------ErrDependenciesLeft 被显式容忍(有依赖未满足的 issue 关不掉,但不能让整个合并失败)。该函数失败只记日志不回滚,注释写明理由:"the merge has succeeded, so any other errors are not critical (the merge can't be undone)"。
8.8 Update branch 与变更 target branch
8.6.2 的 MergeBlockedByOutdatedBranch(head 落后 base)给出的出路就是 "Update branch" 按钮;8.6.1 里"落后"判定本身还依赖 base branch 的选择,于是又有"变更目标分支"。这两个操作都在 services/pull/ 中,且与合并共用同一个全局锁。
8.8.1 Update:把 base 合进 head
update.go 的 Update 是 "update branch" 的实现,三个要点:
go
func Update(pr *issues_model.PullRequest, doer *user_model.User, message string, rebase bool) error {
ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
if pr.Flow == issues_model.PullRequestFlowAGit {
// TODO: update of agit flow pull request's head branch is unsupported
return errors.New("update of agit flow pull request's head branch is unsupported")
}
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
...
// TODO: FakePR: if the PR is a fake PR (for example: from Merge Upstream), then no need to check diverging
if pr.ID > 0 {
diffCount, err := git.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
...
} else if diffCount.Behind == 0 {
return fmt.Errorf("HeadBranch of PR %d is up to date", pr.Index)
}
}
...
- AGit 流程(
PullRequestFlowAGit)不支持 update------AGit 的"head 分支"是refs/for/xxx这类评审引用,没有可被合并推进的分支实体。 Behind == 0直接报 "up to date",操作幂等。- 没有选择新的实现,而是复用合并机器------构造一个 head/base 完全颠倒的假 PR:
go
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
// now use a fake reverse PR to switch head&base repos/branches
reversePR := &issues_model.PullRequest{
ID: pr.ID,
HeadRepoID: pr.BaseRepoID,
HeadRepo: pr.BaseRepo,
HeadBranch: pr.BaseBranch,
BaseRepoID: pr.HeadRepoID,
BaseRepo: pr.HeadRepo,
BaseBranch: pr.HeadBranch,
}
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, message, mergeAndPushOptions{})
// TODO: the "update" (merge target branch to PR head branch) operation has finished, there could still be some edge cases:
// * the database was already out of sync: the target branch was already in head branch:
// * so no post-receive hook is really executed, no PR status update
// * then the PR status is stuck in "behind the target branch" (a new push can be used as a workaround)
// * "merge" operation does finish, but the post-receive hook isn't correctly executed due to other reasons:
// * although the target branch is merged into head branch by this "update" (head branch receives new commits)
// * but database isn't updated, so the PR status is still "behind the target branch"
return err
reversePR 复用 doMergeAndPush:临时仓库、四样式分派、push 全部照搬,只是 push 方向变成 base分支 → head仓库的head分支。TODO 注释坦承这是 hack(理想状态应把 merge 函数从 PullRequest 解耦),并记录了两个已知边界:数据库与 git 不同步导致 post-receive hook 不触发时,PR 状态会卡在"behind"(新 push 是 workaround)。rebase=true 分支走 updateHeadByRebaseOnToBase(services/pull/update_rebase.go:复用 createTemporaryRepoForMerge 与 rebaseTrackingOnToBase,LFS push 后 git push -f head_repo staging:refs/heads/<head>,同样翻译 ErrPushOutOfDate/ErrPushRejected),因此权限判定要求 forcePushAllowed。函数末尾 defer addTestPullRequestTaskAfterWebOperation(pr, doer) 保证无论成败都触发一轮 PR 任务刷新。
update 权限由 CheckUserAllowedToUpdate 的五步判定:
| 步骤 | 检查 | 失败结果 |
|---|---|---|
| 1 | base 仓库 PR 单元是否启用 | 单元禁用 → 全部不允许 |
| 2 | pull.Flow == AGit |
→ 全部不允许 |
| 3 | isUserAllowedToPushOrForcePushInRepoBranch(user, HeadRepo, HeadBranch) |
决定 MergeAllowed(push)与 RebaseAllowed(force push) |
| 4 | AllowMaintainerEdit:若用户能合并该 PR(maintainer),则继承 PR 发起人在 head 分支上的推送权限 |
补上 MergeAllowed/RebaseAllowed |
| 5 | base 仓库 PR 配置 AllowMergeUpdate/AllowRebaseUpdate 与 DefaultUpdateStyle |
与步骤 3 结果取与 |
第 4 步是关键的权限交叉:update 发生在 fork 仓库的分支上,maintainer 通常对 fork 无写权限,所以借用"发起人在自己 fork 上的权限"作为代理------发起人有权推自己的分支,就有权接收这次 update。第 5 步的配置项挂在 base 仓库却控制 head 仓库的行为,注释专门点明了这一点。
8.8.2 ChangeTargetBranch:换目标分支要重算的东西比想象中多
pull.go 的 ChangeTargetBranch 在同一把全局锁下依次做九件事:
go
// Current target branch is already the same
if pr.BaseBranch == targetBranch { return nil }
if pr.Issue.IsClosed { return issues_model.ErrIssueIsClosed{...} }
if pr.HasMerged { return ErrPullRequestHasMerged{...} }
...
// Check if pull request for the new target branch already exists
existingPr, err := issues_model.GetUnmergedPullRequest(ctx, pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch, issues_model.PullRequestFlowGithub)
if existingPr != nil { return issues_model.ErrPullRequestAlreadyExists{...} }
...
// Set new target branch
oldBranch := pr.BaseBranch
pr.BaseBranch = targetBranch
// Refresh patch
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil { return err }
// Update target branch, PR diff and status
// This is the same as markPullRequestAsMergeable in check service, but also updates base_branch
if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable
}
// add first push codes comment
return db.WithTx(ctx, func(ctx context.Context) error {
// The UPDATE acquires the transaction lock, if the UPDATE succeeds, it should have updated one row (the "base_branch" is changed)
// If no row is updated, it means the PR has been merged or closed in the meantime
updated, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files", "base_branch")
...
if updated == 0 {
return util.ErrorWrap(util.ErrInvalidArgument, "pull request status has changed")
}
...
除常规的"分支存在/未关闭/未合并/两分支不相等/不存在重复 PR"五项预检外,三段收尾动作各司其职:
go
if err := syncCommitDivergence(ctx, pr); err != nil { ... }
// The "official" flag of existing reviews was computed against the previous
// target branch's protection rules, so re-evaluate it against the new branch.
// Otherwise a stale official approval could bypass the new branch's protection.
pr.Issue.PullRequest = pr
if err := issues_model.RecalculateReviewsOfficial(ctx, pr.Issue); err != nil { ... }
// Create comment
options := &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeChangeTargetBranch,
...
OldRef: oldBranch,
NewRef: targetBranch,
}
...
// Delete all old push comments and insert new push comments
if _, err := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
And("type = ?", issues_model.CommentTypePullRequestPush).
NoAutoCondition().
Delete(new(issues_model.Comment)); err != nil { ... }
_, _, err = CreatePushPullComment(ctx, doer, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false)
return err
RecalculateReviewsOfficial:这是本章回收第 5 章 5.9.5 双人审核伏笔的第二个挂接点 (第一个在 8.10)。注释直白说明了安全动机------Official标志是按旧 target branch 的保护规则算出来的,换分支后若不重算,一个在旧规则下算 official 的过期批准可能绕过新分支的保护。official 与否决定GetGrantedApprovalsCount是否计入,进而决定"双人审核"能否满足。- 删旧 push comment 再建新 :
CommentTypePullRequestPush记录的是 base 分支的 old..new 位置(8.3.4),目标分支一换这些位置全部失效,所以整体删除重建------而不是保留错误的历史。 - 乐观锁边界 :
UpdateColsIfNotMerged返回 0 行说明 PR 在预检后被并发关闭/合并,返回util.ErrInvalidArgument包装的 "pull request status has changed"。注释写明 UPDATE 本身获取事务锁,成功更新一行即证明 base_branch 确实改变了。
8.9 自动合并:automerge 与 automergequeue
自动合并分两个包:services/automerge/(判定与调度)和 services/automergequeue/(队列 item 的定义)。
8.9.1 数据与队列 item:为什么要带 commitID
计划记录存在 pull_auto_merge 表(models/pull/automerge.go),字段包括 doer、merge_style、message、delete_branch_after_merge。触发入口是 ScheduleAutoMerge:
go
// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) (scheduled bool, err error) {
err = db.WithTx(ctx, func(ctx context.Context) error {
if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message, deleteBranchAfterMerge); err != nil {
return err
}
_, err = issues_model.CreateAutoMergeComment(ctx, issues_model.CommentTypePRScheduledToAutoMerge, pull, doer)
return err
})
// Old code made "scheduled" to be true after "ScheduleAutoMerge", but it's not right:
// If the transaction rolls back, then the pull request is not scheduled to auto merge.
// So we should only set "scheduled" to true if there is no error.
scheduled = err == nil
if scheduled {
log.Trace(...)
automergequeue.StartAutoMergeCheckByPullHead(ctx, pull)
}
return scheduled, err
}
scheduled = err == nil 这行注释记录了一个历史 bug:旧代码在事务提交前就置 scheduled=true,事务回滚后调用方误以为已排上自动合并。现在只有事务成功才触发队列。
队列侧的 item 设计是一段值得逐字读的注释:
go
// AutoMergeItem is for the unique queue, so the item type can't be JSON which doesn't have deterministic key order.
// Since the queue is a unique queue, the item must contain commit ID, otherwise the new commit ID will be ignored.
type AutoMergeItem string
两个约束各有来历:unique queue 按 item 字符串去重,JSON 的字段顺序不确定会导致语义相同的 item 键不同,所以用手工拼的 "pr:<pullID>:<commitID>" 字符串;而 commitID 必须进键------若只用 pr:<id>,用户在排了自动合并后又推了新 commit,新事件的入队请求会被去重为"已在队列"而丢弃,自动合并永远盯着旧 head。带上 commitID 后,新 head 产生的是新 item,正常入队。
入队有两种方式:
go
func StartAutoMergeCheckByPullCommit(pullID int64, commitID string) {
AddToQueue(AutoMergeItem("pr:" + strconv.FormatInt(pullID, 10) + ":" + commitID))
}
func StartAutoMergeCheckByPullHead(ctx context.Context, pull *issues_model.PullRequest) {
...
commitID, err := gitRepo.GetRefCommitID(ctx, pull.GetGitHeadRefName())
...
StartAutoMergeCheckByPullCommit(pull.ID, commitID)
}
ByPullHead 从 refs/pull/N/head 现读 head------适合"刚刚发生了某件事"的场景(审批、状态检查、计划自动合并),此时队列不需要记住 commitID,直接查最新;ByPullCommit 接受调用方已知的 commitID。全仓库检索会发现 ByPullCommit 目前唯一的调用方就是 ByPullHead 本身------它的导出是给"已持有 commitID"的调用方预留的接口,与 item 必须带 commitID 的原因(去重键)是同一件事的两面。
8.9.2 判定循环:handlePullRequestAutoMerge
Init 注册 pr_auto_merge unique queue,启动时 populateRecentAutoMergeItems 把最近 24 小时 的计划重新入队(注释:"in case Gitea's restart aborted some scheduled auto-merge pull requests")。每个 item 经 handleAutoMergeItem 解析(strings.Split(item, ":"),必须三段且首段为 pr)后进入判定链:
go
// handlePullRequestAutoMerge merge the pull request if all checks are successful
func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullRequest, expectedHeadCommitID string) error {
_ = pr.LoadIssue(ctx)
if (pr.Issue != nil && pr.Issue.IsClosed) || pr.HasMerged {
// if the PR has been closed or merged, delete the automerge record and skip
_, err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
...
return nil
}
if !pr.IsStatusMergeable() || pr.IsWorkInProgress(ctx) {
// quick check: if the PR can't be merged, just skip
...
}
...
headCommitID, err := baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
...
if headCommitID != expectedHeadCommitID {
return errors.Join(errSkipAutoMerge, errors.New("head commit ID changed"))
}
...
判定链七步(顺序即短路顺序):
| # | 检查 | 失败处理 | 动机 |
|---|---|---|---|
| 1 | Issue 已关或 PR 已合并 | 删除计划记录,正常返回 | 计划已无意义,清理而非重试 |
| 2 | IsStatusMergeable() 且非 WIP |
skip | 快速路径,省掉后续 git 操作 |
| 3 | 计划记录存在 | skip | 用户可能已取消自动合并 |
| 4 | refs/pull/N/head == expectedHeadCommitID |
skip | head 已推进,本 item 过期(新 head 的 item 会另行入队) |
| 5 | head 分支仍存在(GitHub 流查 HeadRepo 分支,AGit 流查引用) |
skip | 分支被删 |
| 6 | IsPullCommitStatusPass |
skip | CI 未过------见 8.11.3 |
| 7 | CheckPullMergeable(..., MergeCheckTypeGeneral, style, false) |
skip | 完整可合并判定,含审批与分支保护 |
第 4 步的注释解释了为何还要"再取一次 SHA":处理"上一次 push 的检查还没跑完就又推了新 commit"的竞态。全部通过后才真正合并:
go
// although expectedHeadCommitID is checked before, we should pass it to the Merge function to
// make it be checked again in case the head commit id changed after the previous check.
if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
if pull_service.IsErrSHADoesNotMatch(err) {
return errors.Join(errSkipAutoMerge, err)
}
// FIXME: if merge failed, we should display some error message to the pull request page, or retry later.
// The resolution is add a new column on automerge table named `error_message` to store the error message and displayed
// on the pull request page. But this should not be finished in a bug fix PR which will be backport to release branch.
return fmt.Errorf("failed to merge PR:%d: %w", pr.ID, err)
}
Merge 内部的 createTemporaryRepoForMerge 会再次 校验 expectedHeadCommitID(8.7.2 的 ErrSHADoesNotMatch)------双重校验把"判定通过"与"实际合并"之间的时间窗压缩到锁内。ErrSHADoesNotMatch 被归为 skip(head 又变了,等新 item),其他错误作为真错误返回并留下 FIXME:自动合并失败没有 UI 反馈机制,作者提议给 pull_auto_merge 加 error_message 列但明确不该混进 backport 的 bugfix PR。合并成功后按计划与仓库默认配置决定是否 DeleteBranchAfterMerge,此后的错误只记日志------注释强调 "the PR has been merged, so no error should be returned after this point",避免调用方把成功当失败重试。
8.9.3 重新入队的触发点:notify 的三处挂接
判定是事件驱动的,services/automerge/notify.go 注册的 notifier 在三类事件后重新入队:
go
func (n *automergeNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, ...) {
// as a missing / blocking reviews could have blocked a pending automerge let's recheck
if review.Type == issues_model.ReviewTypeApprove {
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
}
func (n *automergeNotifier) PullReviewDismiss(ctx context.Context, ...) {
// as reviews could have blocked a pending automerge let's recheck
automergequeue.StartAutoMergeCheckByPullHead(ctx, review.Issue.PullRequest)
}
func (n *automergeNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, ...) {
if !status.State.IsSuccess() { return }
pulls, err := pull_service.GetMergeablePullRequestsByHeadCommitID(ctx, repo, commit.Sha1)
...
for _, pr := range pulls {
automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
}
}
- Approve 评审:此前审批不足挡住了自动合并,批准后重试;
- 撤销评审(dismiss):方向相反------撤销可能使原本满足的条件失效,重检后判定链会正确地 skip;
- commit status 变 Success :CI 是最后到位的信号,只在成功时重试(失败无需重试),并按 head commit 反查相关 PR(
GetMergeablePullRequestsByHeadCommitID)。
再叠加 8.6.4 里 markPullRequestAsMergeable 落库时对 scheduled merge 的重入队,自动合并形成了一个完整的闭环:任何可能改变判定结果的事件都会重新触发一次判定 ,而判定链本身的七步短路保证了重复触发无副作用(unique queue 还会合并同时到达的重复请求)。push 到 head 分支这条路径不需要单独的挂接------它先触发 8.11.1 的 PR 任务与冲突检查,markPullRequestAsMergeable 落库时发现存在 scheduled merge,顺手调 StartAutoMergeCheckByPullHead(8.6.4 的摘录末尾即此调用,源码位于 services/pull/check.go:298),新 head 因而以新读取的 commitID 入队;启动时则由 populateRecentAutoMergeItems 兜底。
8.10 代码评审:审批如何变成一个数字
评审数据在 models/issues/review.go:一条 review 关联一条 review 类型的 comment,关键字段是 Official/CommitID/Stale/Dismissed 四个标志:
go
// Official is a review made by an assigned approver (counts towards approval)
Official bool `xorm:"NOT NULL DEFAULT false"`
CommitID string `xorm:"VARCHAR(64)"`
Stale bool `xorm:"NOT NULL DEFAULT false"`
Dismissed bool `xorm:"NOT NULL DEFAULT false"`
ReviewType 从 ReviewTypePending = iota 起共五种:Pending(草稿评审)、Approve、Comment、Reject、Request。四个标志的语义互相独立:Official 决定是否计入审批数;CommitID 记录评审时的 head commit;Stale 表示 PR 内容已变更、评审过期;Dismissed 表示被管理员或规则撤销。
8.10.1 审批计数与双人审核(回收第 5 章伏笔)
8.1.3 已列过谓词,这里把计数公式钉死(models/issues/pull.go 的 GetGrantedApprovalsCount):
go
// GetGrantedApprovalsCount returns the number of granted approvals for pr. A granted approval must be authored by a user in an approval whitelist.
func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
And("type = ?", ReviewTypeApprove).
And("official = ?", true).
And("dismissed = ?", false)
if protectBranch.IgnoreStaleApprovals {
sess = sess.And("stale = ?", false)
}
approvals, err := sess.Count(new(Review))
...
}
四个条件一一对应 8.10 开头的四个标志,其中 stale 的排除是可配置的 :保护规则字段 IgnoreStaleApprovals 为真时才排除 stale 批准(否则过期批准照算)。HasEnoughApprovals 在 RequiredApprovals == 0 时直接放行,否则比较计数------第 5 章 5.9.5 的"双人审核"即 ProtectedBranch.RequiredApprovals >= 2(models/git/protected_branch.go:59),落在 CheckPullBranchProtections 第二项(8.6.2)。同文件里与 8.6.2 七项检查一一对应的开关还有 BlockOnRejectedReviews、BlockOnOfficialReviewRequests、BlockOnCodeownerReviews、BlockOnOutdatedBranch------每个检查都先问自己的开关,开关关闭则该项不产生阻塞。
Official 不是静态属性,而是按目标分支的保护规则动态计算 的。RecalculateReviewsOfficial(models/issues/review.go:332)在事务内先清掉所有 approve/reject 的 official 标志,再按 IsUserOfficialReviewer 逐条重算,而后者(models/git/protected_branch.go:225)本身也只有两种答案:
go
if !protectBranch.EnableApprovalsWhitelist {
// Anyone with write access is considered official reviewer
writeAccess, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite)
...
return writeAccess, nil
}
if slices.Contains(protectBranch.ApprovalsWhitelistUserIDs, user.ID) {
return true, nil
}
inTeam, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ApprovalsWhitelistTeamIDs)
即:未开白名单时"有 code 写权限就算 official",开了白名单则精确到用户/团队 。这解释了为什么 Gitea 的默认配置下任意 maintainer 的批准都计入,而白名单模式下非名单成员的批准只是摆设。RecalculateReviewsOfficial 有两个触发点构成闭环:创建/提交评审时计算一次,变更 target branch 时全量重算(8.8.2 的那处调用)------两处缺一,都会让 official 标志与它所依据的规则脱钩。
CreateReview 还有一条去重规则:同一用户提交新的 Approve/Reject 时,先 dismiss 掉此人旧的同类评审;DismissApprovalReviews(services/pull/review.go:360)则在规则要求时批量撤销全部批准(8.11.1 的 DismissStaleApprovals 调它)。
8.10.2 提交评审与 stale 判定
SubmitReview(services/pull/review.go:303)在落库前计算 stale,逻辑比"比对 commit"更细:
go
var stale bool
if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject {
stale = false
} else {
if issue.IsClosed {
return nil, nil, ErrSubmitReviewOnClosedPR
}
headCommitID, err := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
...
if headCommitID == commitID {
stale = false
} else {
stale, _, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
...
}
}
三段合起来的语义:只有 Approve/Reject 这两种"表态"才谈得上过期 ------Comment 类评审没有批准效力,无需 stale;已关闭 PR 上的批准被 ErrSubmitReviewOnClosedPR 直接拒绝;commit 不同才调 checkIfPRContentChanged,即 8.11.1 将详述的 git diff --name-only -z new old mergebase------判断"相对 merge base 的补丁是否变化"。这体现了一致性设计:stale 的语义是"评审所见的变更已被覆盖",判定只依赖补丁内容,不依赖 commit 数量 ,所以 rebase 掉又原样推回不会误标。随后 issues_model.SubmitReview 落库并 notify_service.PullRequestReview------这个通知正是 8.9.3 自动合并三处挂接的入口之一。
8.10.3 行内评论与 review 的存储
8.4.4/8.4.5 讲了评论如何存进 diff、如何挂回渲染;从评审视角还剩两点:行内评论可以挂在一个 pendingReviewID 下组成草稿评审(CreateCodeComment 的 pendingReview 参数),提交时并入同一条 review;createCodeComment 对 line > 0 的提议侧行用 lineBlame 验证目标行的 blame commit 是否仍是评论提交时记录的 CommitSHA(checkInvalidation),行已失效则进 Invalidated 状态------这正是 8.11.2 要展开的失效链的入口之一。
ReviewState(models/pull/review_state.go)另有一套 ViewedState,记录评审人对每个文件的"已查看"状态,与审批数无关,纯用于 UI 进度提示。
8.11 审核与合并的触发点
8.11.1 push 到 head 分支:AddTestPullRequestTask 的双向任务
services/repository/push.go 的 pushUpdateBranch 在分支推送后执行 go pull_service.AddTestPullRequestTask(...IsSync: true)。pull.go:393 的这个函数以"被推送的分支"为轴分两遍查 PR:
第一遍 (作为 head 分支):对每个 open PR 执行 PushToBaseRepo 刷新 refs/pull/N/head → CreatePushPullComment 记录 push comment → StartPullRequestCheckImmediately 立即入队冲突检查。
第二遍 (IsSync 为 true 时的同步段,也是最重的一段):
go
if opts.IsSync {
...
if invalidationErr := checkForInvalidation(ctx, headBranchPRs, opts.RepoID, opts.Doer, opts.Branch); ...
for _, pr := range headBranchPRs {
...
if opts.NewCommitID != "" && opts.NewCommitID != objectFormat.EmptyObjectID().String() {
changed, newMergeBase, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID)
...
if newMergeBase != "" && pr.MergeBase != newMergeBase {
pr.MergeBase = newMergeBase
if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base"); ...
}
if changed {
// Mark old reviews as stale if diff to mergebase has changed
if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); ...
// dismiss all approval reviews if protected branch rule item enabled.
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
...
if pb != nil && pb.DismissStaleApprovals {
if err := DismissApprovalReviews(ctx, opts.Doer, pr); ...
}
}
if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitID); ...
if err = syncCommitDivergence(ctx, pr); ...
}
if !pr.IsWorkInProgress(ctx) {
reviewNotifiers, err := issue_service.PullRequestCodeOwnersReview(ctx, pr)
...
}
notify_service.PullRequestSynchronized(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID)
}
}
次序即语义:先算 checkIfPRContentChanged(git diff --name-only -z new old mergebase 接 util.IsEmptyReader,输出非空 = 内容变了),变了才 把旧评审标 stale、才按 DismissStaleApprovals 规则撤销批准------rebase 后原样推回(补丁相同)不会误伤审批;无论变没变都对新 commitID 执行 MarkReviewsAsNotStale,让基于新 head 的评审恢复正常。同时更新 merge_base 与 ahead/behind。第三遍(作为 base 分支)对以该分支为目标的 PR syncCommitDivergence + StartPullRequestCheckDelayable(8.6.4)。
8.11.2 评论失效与评审 stale 的触发
8.11.1 里 checkForInvalidation → InvalidateCodeComments(services/pull/review.go)异步执行:对每个 PR 的行内评论,用 lineBlame 取该行当前的 blame commit,与评论记录的 CommitSHA 比对(checkInvalidation),行内容已被后续提交覆盖则标记评论 invalid。它与 MarkReviewsAsStale 是两层不同粒度:评论失效定位到行,评审 stale 定位到整个 PR 的补丁 ;push 时两者同时刷新,用户在 diff 页面看到的"评论已过期"角标与评审列表里的 stale 标记来自同一次 push 事件。pushDeleteBranch 则走 AdjustPullsCausedByBranchDeleted 处理 head 分支被删的善后。
8.11.3 commit status:合并判定的 CI 闸门(详见第 14 章)
services/pull/commit_status.go 提供三个函数供 8.6.2 第一项与 8.9.2 第 6 步共用:EffectiveRequiredContexts 汇总分支保护规则的 required contexts 与 scoped workflow 检查 (后者即使规则自身关闭状态检查也会生效------pullMergeBoxData.hasRequiredStatusContexts 的注释写明了这一点);MergeRequiredContextsCommitStatus 用 glob 匹配聚合各 context 状态,任一 required context 未匹配到则返回 Pending(宁可拦下不放行);IsPullCommitStatusPass 无 required contexts(pb == nil)时直接放行。commit status 的上报、聚合与 Gitea Actions 的关系是第 14 章的主题,此处只需记住:它在判定链里排第一,是自动化最先置否的一票。
8.12 全景图
8.12.1 PR 状态机
补充:has_merged = true 是独立于 status 的终态开关(8.7.7),status 保留为 Mergeable/ManuallyMerged 等以区分合并方式;Conflict = 0 是零值,所以从未检测过的 PR 与冲突 PR 在数值上相同,conflicted_files 才是实际展示依据。
8.12.2 创建到合并的全链路时序
8.12.3 合并判定决策表
CheckPullMergeable 与检查链返回的错误,经 errors.Is(err, ErrNotReadyToMerge) 等包装后由路由层翻译为用户提示。决策表按判定顺序排列(前项拦截则后项不执行):
| 顺序 | 判定条件 | 返回错误 | 路由层表现 | 触发修复动作 |
|---|---|---|---|---|
| 1 | HasMerged |
ErrHasMerged |
已合并提示 | --- |
| 2 | IsClosed |
ErrIsClosed |
已关闭提示 | --- |
| 3 | 无合并权限 | ErrNoPermissionToMerge |
权限不足 | 分支保护 merge whitelist |
| 4 | MergeStyleManually |
直接放行 | 手动合并入口 | --- |
| 5 | WIP 标题 | ErrIsWorkInProgress |
"仍为 WIP" | 去掉 WIP 前缀 |
| 6 | status ∉ {Mergeable, Empty} | ErrNotMergeableState |
"正在检查/有冲突" | 8.5 冲突处理 / Update branch |
| 7 | status == Checking | ErrIsChecking |
排队等待 | 自动刷新 |
| 8 | commit status 未过 | ErrNotReadyToMerge: Not all required status checks successful |
CI 未通过 | 第 14 章 |
| 9 | 审批不足 | ErrNotReadyToMerge: Does not have enough approvals |
需 N 次批准 | 8.10 评审 |
| 10 | 存在 Reject | ErrNotReadyToMerge: There are requested changes |
有变更请求 | 撤销 reject |
| 11 | 存在 official review request | ErrNotReadyToMerge: There are official review requests |
待处理评审请求 | 响应 request |
| 12 | head 落后 base | ErrNotReadyToMerge: The head branch is behind the base branch |
分支落后 | 8.8 Update branch |
| 13 | CodeOwners 未满足 | ErrNotReadyToMerge: There are missing code owner reviews |
缺少代码所有者评审 | 8.1.5 定向请求 |
| 14 | 变更受保护文件 | ErrNotReadyToMerge: Changed protected files |
保护文件变更 | 管理员介入 / forceMerge |
| 15 | forceMerge && CanBypassBranchProtection |
使 8--14 的 ErrNotReadyToMerge 归零 |
管理员强制合并 | 第 5 章 BlockAdminMergeOverride |
| 16 | 签名要求不满足 | checkSigningRequirements / ErrHeadCommitsNotAllVerified |
签名策略拦截 | 配置签名 |
| 17 | 依赖检查未过 | ErrDependenciesLeft |
依赖未完成 | 关联 issue |
| 18 | 全部通过 | nil |
合并按钮可用 | 8.7 执行 |
决策表与代码的对应关系:第 8--14 项全部包在 util.ErrorWrap(ErrNotReadyToMerge, ...) 里(8.6.2),路由层只需 errors.Is 判断一个哨兵错误,再据内部文案分流;第 6/7 项对应 8.6.1 的状态前置检查;第 15 项即第 5 章伏笔 forceMerge 参数的落地------它不是独立检查,而是在第 8--14 项失败后的一次"赦免"尝试(8.6.1 摘录里 if forceMerge { ... errProtection = nil }),自动合并恒传 false(8.9.2),网页端的"强制合并"勾选传 true。表格里的每一行,都能在 8.6 的摘录中找到产生它的那一条 return。
至此,从 8.1 的一张表、8.2 的一次事务、8.4 的一次 diff 解析、8.5/8.6 的一次判定、8.7 的一次 push,到 8.9/8.10 的自动闭环与 8.11 的事件触发,Pull Request 的全生命周期闭合。第 9 章将进入 Issue 之上的另一条主线------通知与 Webhook 如何把这里的每一个事件扇出到站内、邮件与第三方系统。