Gitea源码深度剖析-第8章 Pull Request 全生命周期:Gitea 最复杂的子系统

0 阅读59分钟

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 的函数叫 CreatePullRequestWeb 与 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/rebaserepo_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 子系统的中心模型。摘录核心字段(省略加载方法):

// 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 分支在源仓库中不存在)。

三个枚举定义紧挨在结构体上方:

// 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:

// 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 创建前防重复提交就调它):

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 或状态字段:

// 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)。

另外三个阻塞谓词同样是简单的条件查询:

// 有未 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 侧的存在形式是一条隐藏引用。生成规则只有一行:

// 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 并设置超时:

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 行起)是表单入口,骨架如下:

// 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。它分为"事务前权限检查 → 事务 → 事务后通知"三段:

// 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 层错误直接失败。

事务体:

	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
	}

三个设计细节:

  1. git 操作被包进数据库事务。PushToBaseRepo 是网络/磁盘操作,放在事务里看似反模式,但失败路径需要一致性:任一步失败,数据库回滚,同时显式 git.RemoveRef 清掉刚写的 refs/pull/N/head(注释也承认对象要等下次 GC 才回收)。ref 是"引用计数为零即可回收"的设计,所以删除引用就足够了。
  2. 先建 push comment 再算状态。CreatePushPullComment 记录"这些 commit 被推进来了",代码注释解释了顺序动机:状态检查是异步的,先落 comment 让后续测试更容易确定性断言。
  3. CodeOwners 的 review request 在 WIP 时不发起——避免给半成品 PR 打扰评审人。

事务之后是通知段:ReviewRequestNotify → 逐个 issue_service.ReviewRequest(记录 request 类型的 review 行)→ FindAndUpdateIssueMentions 解析 @提及 → notify_service.NewPullRequest 扇出(数据库通知、邮件、webhook、活动流)。之所以强调"review request 必须先于其他通知",是因为通知订阅者点开 PR 时要能看到自己被请求评审。

8.2.4 创建时序图

sequenceDiagram
    participant U as 用户/客户端
    participant R as routers (CompareAndPullRequestPost / API CreatePullRequest)
    participant P as pull_service.NewPullRequest
    participant DB as 数据库(事务)
    participant G as base 仓库(git)

    U->>R: 提交创建表单
    R->>R: parseCompareInfo(算 CompareBase) + GetUnmergedPullRequest 查重
    R->>P: NewPullRequest(opts)
    P->>P: BlockedUser 检查 → HasAccessToRepoCodeUnit / head write 权限
    P->>G: checkPullRequestBranchMergeable(首次冲突检测)
    G-->>P: Status / MergeBase / ConflictedFiles
    P->>DB: BEGIN
    P->>DB: issues_model.NewPullRequest(分配 issue_index, IsPull=true, 插 pull 行)
    P->>DB: 指派人 / 项目卡
    P->>G: PushToBaseRepo(fetch head commit, UpdateRef refs/pull/N/head)
    P->>P: syncCommitDivergence(算 commits_ahead/behind)
    P->>DB: CreatePushPullComment(首个 push comment)
    P->>DB: CodeOwners review request
    P->>DB: COMMIT
    alt 事务失败
        P->>G: git.RemoveRef(refs/pull/N/head) 清理
        P-->>R: 错误(ErrMustCollaborator / ErrPushRejected / ...)
        R-->>U: JSONError / hook 输出
    else 成功
        P->>P: ReviewRequestNotify + ReviewRequest 记录
        P->>P: notify_service.NewPullRequest(邮件/webhook/活动流)
        R-->>U: 302 → /owner/repo/pulls/N
    end

8.3 隐藏 ref 的同步与 commit divergence

8.3.1 PushToBaseRepo:为什么是 fetch 不是 push

// 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:

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 中,在同步分支表之前先执行:

// 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 的预计算

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 定义了四个核心结构(结构体上省略了渲染专用字段):

// 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 边界行号的语义:

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 行):

func ParsePatch(ctx context.Context, maxLines, maxLineCharacters, maxFiles int, reader io.Reader, skipToFile string) (_ *Diff, retErr error)

三个 max* 参数来自仓库级配置(默认约 5000 行/文件、5000 字符/行、100 文件),超限即截断,对应 DiffFile.IsIncomplete / IsTruncated。解析分三层:

  1. ParsePatch 外层:以 const cmdDiffHead = "diff --git " 为文件头标志切分文件,逐文件调 parseOneDiffFile,最后 postProcessFiles 做编码转换(GBK/Shift_JAR 等非 UTF-8 文件按探测结果转码,避免模板输出乱码)。
  2. 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 主体。
  3. 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:

// 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 行:

// 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 生成:

// 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)
			...
		}
	}

三个要点:

  1. lineBlame 决定 CommitSHA:用 git blame 找到该行最后被哪次 commit 改动。选 line < 0(previous 侧)时 blame 的语义在源码中有 FIXME 注明尚未区分——已知的技术债。
  2. patch 从 merge_base..head 截取:GetFileDiffCutAroundLine 只取被评论行上下各 setting.UI.CodeCommentLines(默认 4 行)的 diff 片段,与用户当时看到的 diff 视图一致。
  3. 未变更行的回退:如果被评论行是上下文行(在 merge_base..head 的 diff 里根本没出现),GetFileDiffCutAroundLine 返回空串,改用 GeneratePatchForUnchangedLine 从 commitID 直接读文件内容生成纯上下文块。这保证"评论任何一行"都可行,而不仅是改过的行。

8.4.5 评论如何挂回 diff 渲染

Diff.LoadComments 把数据库里的评论重新对齐到结构化 diff 的行上:

// 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 后入队异步执行、切换目标分支时同步执行。入口是一个能力探测:

// 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)是八步流程:

// 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 步的细节:

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(合并工作区)。流程:

// 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 算法:

// 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)

无冲突时还要判断"是否空补丁":

	// 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:

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 判定序列

// 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 按合并样式区分签名要求(源码注释即为文档):

//   - 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 是分支保护规则的消费端,顺序执行:

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:

// 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

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):

// 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:

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 做了特判并给出注释)。

消费端:

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 收尾时还有一个精妙的竞态防御:

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 入口:同一个全局锁

// 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
}

四个设计点值得注意:

  1. 锁复用。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 用锁而非状态位实现。
  2. HammerContext。注释写明"即使用户请求被取消也不中止 git 操作"——合并一旦开始就要跑完,避免半途取消留下脏的临时仓库。
  3. 幂等。hasPullRequestCommitBeenMerged 确认 MergedCommitID 已在 base 分支时跳过 doMergeAndPush 直接走 MarkAsMerged,重复点击"合并"不会二次 push(详见 8.7.5)。
  4. 锁外收尾。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":

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 命令产物
mergemerge_merge.gogit merge --no-ff --no-commit <tracking> + commitAndSignNoAuthor1 个 merge commit(作者为 doer)
rebasemerge_rebase.gorebaseTrackingOnToBase 后 git merge --ff-only <staging>,再按模板 commit --amend若干重写后的线性 commit(无 merge commit)
rebase-mergemerge_rebase.gorebaseTrackingOnToBase 后 git merge --no-ff --no-commit <staging> + commitAndSignNoAuthor重写后的线性 commit + 1 个 merge commit
squashmerge_squash.gogit merge --squash <tracking> + git commit --allow-empty --author='<sig>'1 个 squash commit
fast-forward-onlymerge_ff_only.gogit merge --ff-only <tracking>0 个新 commit(纯 fast-forward)

几个实现细节:

merge 样式先 --no-commit 停在暂存区,再由 commitAndSignNoAuthor 提交——这样能用 addCommitSigningOptions 统一注入签名参数(signKey == nil 时加 --no-gpg-sign,否则 --gpg-sign=<keyid>):

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 分支的提交里找"邮箱能匹配到发起人本人"的签名:

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:

// 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,采用三级回退:

  1. 从 base 分支默认分支的 HEAD commit里读模板文件(不是当前分支):先试 .gitea/default_merge_message/{STYLE}_TEMPLATE.md(如 MERGE_TEMPLATE.md、SQUASH_TEMPLATE.md,strings.ToUpper 拼出),找不到再试 DEFAULT_TEMPLATE.md——这是 resolveMergeMessageTemplate 的两级;两级都缺才进入第 2 级。
  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。
  3. 无论哪级,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

崩溃恢复是一条贯穿启动、检查、合并三点的链。合并侧的记录动作:

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 步失败场景。恢复发生在检查任务中:

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 自身的注释完整描述了这套四步协议:

// 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 相关:

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:

// 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 用条件更新做并发保护:

// 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" 的实现,三个要点:

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)
                }
        }
        ...
  1. AGit 流程(PullRequestFlowAGit)不支持 update——AGit 的"head 分支"是 refs/for/xxx 这类评审引用,没有可被合并推进的分支实体。
  2. Behind == 0 直接报 "up to date",操作幂等。
  3. 没有选择新的实现,而是复用合并机器——构造一个 head/base 完全颠倒的假 PR:
// 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 的五步判定:

步骤检查失败结果
1base 仓库 PR 单元是否启用单元禁用 → 全部不允许
2pull.Flow == AGit→ 全部不允许
3isUserAllowedToPushOrForcePushInRepoBranch(user, HeadRepo, HeadBranch)决定 MergeAllowed(push)与 RebaseAllowed(force push)
4AllowMaintainerEdit:若用户能合并该 PR(maintainer),则继承 PR 发起人在 head 分支上的推送权限补上 MergeAllowed/RebaseAllowed
5base 仓库 PR 配置 AllowMergeUpdate/AllowRebaseUpdate 与 DefaultUpdateStyle与步骤 3 结果取与

第 4 步是关键的权限交叉:update 发生在 fork 仓库的分支上,maintainer 通常对 fork 无写权限,所以借用"发起人在自己 fork 上的权限"作为代理——发起人有权推自己的分支,就有权接收这次 update。第 5 步的配置项挂在 base 仓库却控制 head 仓库的行为,注释专门点明了这一点。

8.8.2 ChangeTargetBranch:换目标分支要重算的东西比想象中多

pull.go 的 ChangeTargetBranch 在同一把全局锁下依次做九件事:

// 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"五项预检外,三段收尾动作各司其职:

        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:

// 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 设计是一段值得逐字读的注释:

// 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,正常入队。

入队有两种方式:

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)后进入判定链:

// 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"))
        }
        ...

判定链七步(顺序即短路顺序):

#检查失败处理动机
1Issue 已关或 PR 已合并删除计划记录,正常返回计划已无意义,清理而非重试
2IsStatusMergeable() 且非 WIPskip快速路径,省掉后续 git 操作
3计划记录存在skip用户可能已取消自动合并
4refs/pull/N/head == expectedHeadCommitIDskiphead 已推进,本 item 过期(新 head 的 item 会另行入队)
5head 分支仍存在(GitHub 流查 HeadRepo 分支,AGit 流查引用)skip分支被删
6IsPullCommitStatusPassskipCI 未过——见 8.11.3
7CheckPullMergeable(..., MergeCheckTypeGeneral, style, false)skip完整可合并判定,含审批与分支保护

第 4 步的注释解释了为何还要"再取一次 SHA":处理"上一次 push 的检查还没跑完就又推了新 commit"的竞态。全部通过后才真正合并:

        // 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 在三类事件后重新入队:

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 四个标志:

// 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):

// 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)本身也只有两种答案:

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"更细:

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 时的同步段,也是最重的一段):

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 状态机

stateDiagram-v2
    [*] --> Checking: 创建 / push / 换 target
    Checking --> Mergeable: 无冲突 (merge_tree / tmpRepo)
    Checking --> Conflict: 有冲突 (status=0)
    Checking --> Error: 检测异常
    Conflict --> Checking: push / update branch
    Mergeable --> Checking: base 前进 / push
    Mergeable --> [*]: CheckPullMergeable 通过 → Merge
    state &#34;Ancestor (head 已被 base 包含)&#34; as Ancestor
    Checking --> Ancestor: 8.5.1 merge-tree 判定
    state &#34;ManuallyMerged (4)&#34; as MM
    Checking --> MM: 8.7.6 侦测 / MergedManually
    state &#34;Empty (5)&#34; as Empty
    Checking --> Empty: 无可合并变更
    Empty --> Mergeable: 8.6.1 `!IsEmpty()` 放行后照常判定
    MM --> [*]

补充:has_merged = true 是独立于 status 的终态开关(8.7.7),status 保留为 Mergeable/ManuallyMerged 等以区分合并方式;Conflict = 0 是零值,所以从未检测过的 PR 与冲突 PR 在数值上相同,conflicted_files 才是实际展示依据。

8.12.2 创建到合并的全链路时序

sequenceDiagram
    participant U as 用户/浏览器
    participant W as routers/web (CompareAndPullRequestPost)
    participant P as services/pull (NewPullRequest)
    participant DB as 数据库
    participant G as base 仓库 git
    participant Q as pr_patch_checker 队列
    participant M as services/pull (Merge)
    participant H as post-receive hook

    U->>W: 提交 Compare 表单
    W->>W: parseCompareInfo + 查重 + 构造 Issue/PullRequest
    W->>P: NewPullRequest (BlockedUser/协作者校验)
    P->>P: checkPullRequestBranchMergeable (首次冲突检测)
    P->>DB: 事务: Issue(IsPull) + PullRequest + syncCommitDivergence
    P->>G: PushToBaseRepo → refs/pull/N/head
    P->>DB: CreatePushPullComment + CodeOwners
    P->>Q: StartPullRequestCheckImmediately
    Note over Q: 8.6.4 锁内重算并落 Mergeable/Conflict
    U->>M: 点击合并 (style, message, expectedHeadCommitID)
    M->>M: globallock + CheckPullMergeable(General)
    M->>G: 临时仓库 doMergeAndPush (四样式)
    M->>DB: recordMergeIntent (push 前)
    M->>G: git push base 分支
    G->>H: /api/internal/hook/post-receive
    H->>DB: UpdatePullsRefs / push 事件 (事务在 hook 内)
    M->>DB: MarkAsMerged (乐观锁) + 关 Issue
    M->>M: markAsMergedPostProcess → notify + 交叉引用

8.12.3 合并判定决策表

CheckPullMergeable 与检查链返回的错误,经 errors.Is(err, ErrNotReadyToMerge) 等包装后由路由层翻译为用户提示。决策表按判定顺序排列(前项拦截则后项不执行):

顺序判定条件返回错误路由层表现触发修复动作
1HasMergedErrHasMerged已合并提示—
2IsClosedErrIsClosed已关闭提示—
3无合并权限ErrNoPermissionToMerge权限不足分支保护 merge whitelist
4MergeStyleManually直接放行手动合并入口—
5WIP 标题ErrIsWorkInProgress"仍为 WIP"去掉 WIP 前缀
6status ∉ {Mergeable, Empty}ErrNotMergeableState"正在检查/有冲突"8.5 冲突处理 / Update branch
7status == CheckingErrIsChecking排队等待自动刷新
8commit status 未过ErrNotReadyToMerge: Not all required status checks successfulCI 未通过第 14 章
9审批不足ErrNotReadyToMerge: Does not have enough approvals需 N 次批准8.10 评审
10存在 RejectErrNotReadyToMerge: There are requested changes有变更请求撤销 reject
11存在 official review requestErrNotReadyToMerge: There are official review requests待处理评审请求响应 request
12head 落后 baseErrNotReadyToMerge: The head branch is behind the base branch分支落后8.8 Update branch
13CodeOwners 未满足ErrNotReadyToMerge: There are missing code owner reviews缺少代码所有者评审8.1.5 定向请求
14变更受保护文件ErrNotReadyToMerge: Changed protected files保护文件变更管理员介入 / forceMerge
15forceMerge && 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 如何把这里的每一个事件扇出到站内、邮件与第三方系统。