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

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

第 1 章的导航表里,第 8 章对应的关键词是 preparePullRequests 与 pull_service.Init,涉及 models/issues/pull.go、services/pull/、routers/web/repo/pull*.go 三块。从这一章开始,我们进入 Gitea 中横跨面最广的子系统:一个 Pull Request(下文简称 PR)同时牵动数据库事务、git 底层操作、后台任务队列、全局锁、分支保护规则、代码评审状态机与通知扇出。本章按 PR 的真实生命周期推进------数据模型 → 创建 → 引用同步 → diff → 冲突检测 → 可合并判定 → 合并执行 → 更新分支 → 自动合并 → 代码评审 → 触发点 → 全景图。

开始之前,先纠正几处流传较广的误解(本章所有结论都经源码核实):

常见说法 实际情况
PullRequest 结构定义在 models/pull/ 定义在 models/issues/pull.go;models/pull/ 只有 automerge.go、review_state.go 等少量附属表模型
创建 PR 的函数叫 CreatePullRequest Web 与 API 共用 pull_service.NewPullRequest(services/pull/pull.go),models/issues 层的同名函数才是 NewPullRequest
diff 结构是 Diff/CommitDiff/FileDiff 是 Diff/DiffFile/DiffSection/DiffLine 四级结构(services/gitdiff/gitdiff.go),没有 CommitDiff
合并方式有三种 merge/squash/rebase repo_model.MergeStyle(models/repo/git.go:14)定义 7 个值:5 种真正执行的样式(merge/rebase/rebase-merge/squash/fast-forward-only,见 8.7.3)+ manually-merged(仅作标记)+ rebase-update-only(源码注释明确 "not a merge style"),实现分散在 merge_merge.go、merge_rebase.go、merge_squash.go、merge_ff_only.go 四个文件
冲突检测用 git merge-tree 有能力探测:git.DefaultFeatures().SupportGitMergeTree 为真才走 merge-tree,否则回退到临时仓库手工三路合并(services/pull/patch.go)

8.1 数据模型:pull_request 表与对 Issue 的复用

8.1.1 PullRequest 结构体

models/issues/pull.go 中的 PullRequest 是 PR 子系统的中心模型。摘录核心字段(省略加载方法):

go 复制代码
// models/issues/pull.go
type PullRequest struct {
	ID              int64 `xorm:"pk autoincr"`
	Type            PullRequestType
	Status          PullRequestStatus
	ConflictedFiles []string `xorm:"TEXT JSON"`
	CommitsAhead    int
	CommitsBehind   int

	ChangedProtectedFiles []string `xorm:"TEXT JSON"`

	IssueID                    int64  `xorm:"INDEX"`
	Issue                      *Issue `xorm:"-"`
	Index                      int64
	RequestedReviewers         []*user_model.User `xorm:"-"`
	RequestedReviewersTeams    []*org_model.Team  `xorm:"-"`
	isRequestedReviewersLoaded bool               `xorm:"-"`

	HeadRepoID          int64                  `xorm:"INDEX"`
	HeadRepo            *repo_model.Repository `xorm:"-"`
	BaseRepoID          int64                  `xorm:"INDEX"`
	BaseRepo            *repo_model.Repository `xorm:"-"`
	HeadBranch          string
	HeadCommitID        string `xorm:"-"`
	BaseBranch          string
	MergeBase           string `xorm:"VARCHAR(64)"`
	AllowMaintainerEdit bool   `xorm:"NOT NULL DEFAULT false"`

	HasMerged      bool               `xorm:"INDEX"`
	MergedCommitID string             `xorm:"VARCHAR(64)"`
	MergerID       int64              `xorm:"INDEX"`
	Merger         *user_model.User   `xorm:"-"`
	MergedUnix     timeutil.TimeStamp `xorm:"updated INDEX"`

	isHeadRepoLoaded bool `xorm:"-"`

	Flow PullRequestFlow `xorm:"NOT NULL DEFAULT 0"`
}

几个字段值得单独说明,因为它们对应着后文的机制:

  • IssueID(INDEX) :pull_request 表与 issue 表 1:1 关联的外键。PR 的标题、内容、标签、里程碑、指派人、评论、时间线全部存在 issue 侧------这是第 7 章讲过的 Comment 多态能够作用于 PR 的原因。
  • Index :PR 在仓库内的编号(URL 里的 #123),与 issue.index 同源------创建时由同一个 db.GetNextResourceIndex(ctx, "issue_index", repo.ID) 分配,所以 Issue 与 PR 编号永远一致。
  • MergeBase(VARCHAR(64)):head 与 base 的共同祖先 commit,是 diff、squash 消息、冲突检测三个环节的基准点。
  • ConflictedFiles / ChangedProtectedFiles :TEXT JSON 序列化的字符串数组,由冲突检测流程写入,用于页面展示与合并判定。
  • HeadCommitID 带 xorm:"-" :不落库,运行时从 head 分支或 refs/pull/N/head 解析;数据库里唯一持久的"头"是 base repo 内的隐藏 ref。
  • Flow :PullRequestFlowGithub(0,常规分支流)与 PullRequestFlowAGit(1,AGit 的 push-based 流,head 分支在源仓库中不存在)。

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

go 复制代码
// Enumerate all the pull request status
const (
	PullRequestStatusConflict PullRequestStatus = iota // 0 有冲突
	PullRequestStatusChecking                          // 1 正在检查
	PullRequestStatusMergeable                         // 2 可合并
	PullRequestStatusManuallyMerged                    // 3 被手动合并(侦测到)
	PullRequestStatusError                             // 4 检查出错
	PullRequestStatusEmpty                             // 5 空补丁(head 内容已在 base 中)
	PullRequestStatusAncestor                          // 6 head 是 base 的祖先(已包含)
)

const (
	PullRequestGitea PullRequestType = iota
	PullRequestGit
)

const (
	PullRequestFlowGithub PullRequestFlow = iota
	PullRequestFlowAGit
)

注意这七个状态是可合并性(mergeable)状态 ,不是 PR 生命周期状态。PR 的开/关/合并生命周期由 issue.is_closed 与 pull_request.has_merged 两个布尔量承载;status 只回答"现在能不能合"。第 8.12 节的状态机会把两者画在一起。

8.1.2 与 Issue 的 1:1 关系与索引分配

创建 PR 的数据库入口是同文件的 NewPullRequest:

go 复制代码
// models/issues/pull.go
func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Issue, labelIDs []int64, uuids []string, pr *PullRequest) (err error) {
	return db.WithTx(ctx, func(ctx context.Context) error {
		idx, err := db.GetNextResourceIndex(ctx, "issue_index", repo.ID)
		if err != nil {
			return fmt.Errorf("generate pull request index failed: %w", err)
		}

		issue.Index = idx
		issue.Title = util.EllipsisDisplayString(issue.Title, 255)
		issue.IsPull = true

		if err = NewIssueWithIndex(ctx, issue.Poster, NewIssueOptions{
			Repo:        repo,
			Issue:       issue,
			LabelIDs:    labelIDs,
			Attachments: uuids,
		}); err != nil {
			...
		}

		pr.Index = issue.Index
		pr.BaseRepo = repo
		pr.IssueID = issue.ID
		if err = db.Insert(ctx, pr); err != nil {
			return fmt.Errorf("insert pull repo: %w", err)
		}
		return nil
	})
}

一个事务里完成四件事:分配 issue_index、以 IsPull = true 插入 issue、把同一 index 写进 pr.Index、插入 pull_request 行。"Issue 与 PR 共享编号"不是巧合而是这十几行代码保证的不变量。

查询侧大量使用 JOIN 来表达"开放且未合并"的语义,例如查重(Web 创建前防重复提交就调它):

go 复制代码
func GetUnmergedPullRequest(ctx context.Context, headRepoID, baseRepoID int64, headBranch, baseBranch string, flow PullRequestFlow) (*PullRequest, error) {
	pr := new(PullRequest)
	has, err := db.GetEngine(ctx).
		Where("head_repo_id=? AND head_branch=? AND base_repo_id=? AND base_branch=? AND has_merged=? AND flow = ? AND issue.is_closed=?",
			headRepoID, headBranch, baseRepoID, baseBranch, false, flow, false).
		Join("INNER", "issue", "issue.id=pull_request.issue_id").
		Get(pr)
	...
}

条件里同时约束 has_merged = false 与 issue.is_closed = false------两者是不同维度:PR 可以被关闭但没合并,也可以合并后 issue 被置为 closed。此外还有 GetPullRequestByIndex(按仓库 + index,走 base_repo_id + index 复合定位)、GetPullRequestByIssueID(issue_id 直查)、GetLatestPullRequestByHeadInfo(head 分支最近一条 PR,用于 push trailer 提示)等一簇查询函数,共同支撑路由层的各种入口。

8.1.3 WIP、审批计数与合并阻塞谓词

同一文件里聚集了一组被合并判定反复调用的谓词函数,它们全部只依赖 PR 的 IssueID 或状态字段:

go 复制代码
// WIP 判定基于标题前缀(配置项 PULL_REQUEST_WORK_IN_PROGRESS_PREFIXES)
func HasWorkInProgressPrefix(title string) bool {
	_, ok := CutWorkInProgressPrefix(title)
	return ok
}

// 需要的审批数
func HasEnoughApprovals(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
	if protectBranch.RequiredApprovals == 0 {
		return true
	}
	return GetGrantedApprovalsCount(ctx, protectBranch, pr) >= protectBranch.RequiredApprovals
}

// 第 5 章 5.9.5 节引用的函数(models/issues/pull.go:770)
func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
	sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
		And("type = ?", ReviewTypeApprove).
		And("official = ?", true).
		And("dismissed = ?", false)
	if protectBranch.IgnoreStaleApprovals {
		sess = sess.And("stale = ?", false)
	}
	approvals, err := sess.Count(new(Review))
	...
	return approvals
}

这里正式回收第 5 章埋下的伏笔:一个审批要"有效"必须同时满足 type = approve、official = true、dismissed = false 三个条件 ;第四个条件 stale = false 只在分支保护开启 IgnoreStaleApprovals 时生效。official(是否为"有资格的评审人"所做)的计算逻辑在 models/issues/review.go 的 RecalculateReviewsOfficial,第 8.10 节详述。第 5 章讨论的 BlockAdminMergeOverride 则决定管理员在 CheckPullMergeable 里能否用 forceMerge 绕过这些检查(见 8.6.3)。

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

go 复制代码
// 有未 dismiss 的 official reject review 即阻塞
func MergeBlockedByRejectedReview(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool
// 有未处理的 official review request 即阻塞
func MergeBlockedByOfficialReviewRequests(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool
// head 落后于 base(commits_behind > 0)且规则要求最新
func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *PullRequest) bool {
	return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
}

最后一个直接读 CommitsBehind 这个由 syncCommitDivergence 预先算好落库的字段,体现了"把昂贵的 git 计算移到 push 时、判定时只读缓存"的设计取向。

8.1.4 隐藏 ref:refs/pull/N/head

PR 在 git 侧的存在形式是一条隐藏引用。生成规则只有一行:

go 复制代码
// models/issues/pull.go
func (pr *PullRequest) GetGitHeadRefName() string {
	return git.RefNameFromPullIndex(pr.Index).String()
}

// modules/git/ref.go
const (
	PullPrefix = "refs/pull/"
	pullSuffix = "/head"
)
func RefNameFromPullIndex(prIndex int64) RefName {
	return RefName(PullPrefix + strconv.FormatInt(prIndex, 10) + pullSuffix)
}

即 refs/pull/<index>/head,写在 base 仓库 里。为什么需要它?fork 场景下 head commit 原本只存在于 fork 仓库,base 仓库根本没有这些对象;所有 diff、冲突检测、合并操作都发生在 base 仓库,所以必须把 head commit fetch 进 base 仓库并用一条不会被 gc 当成垃圾的 ref 钉住。GetGitHeadRefName() 在全书出现几十次:diff、merge、状态检查、CI 状态查询(第 14 章)都以它为 head 端点。

8.1.5 CodeOwners 解析的防御性设计

models/issues/pull.go 尾部是 CodeOwners(.gitea/CODEOWNERS)解析器。用户可控的 pattern 编译进正则存在灾难性回溯风险,代码显式使用 regexp2 并设置超时:

go 复制代码
const codeOwnerMatchTimeout = 150 * time.Millisecond

type CodeOwnerRule struct {
	Rule     *regexp2.Regexp // it supports negative lookahead, does better for end users
	Negative bool
	Users    []*user_model.User
	Teams    []*org_model.Team
}
...
expr := fmt.Sprintf("^%s$", pattern)
rule.Rule, err = regexp2.Compile(expr, regexp2.None)
...
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
rule.Rule.MatchTimeout = codeOwnerMatchTimeout

regexp2(而非标准库 regexp)的选择是为了支持负向前瞻(! 排除规则);MatchTimeout 单次 150ms,调用侧还有跨整个 rules×files 匹配循环的总预算。解析结果在 PR 创建、head 推送、规则变更三个时机被 issue_service.PullRequestCodeOwnersReview 使用,用于自动发起 review request------这也是为什么创建 PR 的事务里会调用它。

8.2 PR 创建链路:从三个入口到一次事务

8.2.1 入口一:网页 Compare 页面

routers/web/repo/pull.go 的 CompareAndPullRequestPost(约 1315 行起)是表单入口,骨架如下:

go 复制代码
// routers/web/repo/pull.go
func CompareAndPullRequestPost(ctx *context.Context) {
	form := web.GetForm[*forms.CreateIssueForm](ctx)
	repo := ctx.Repo.Repository
	comparePageInfo := newComparePageInfo()
	err := comparePageInfo.parseCompareInfo(ctx, ctx.PathParam("*"))
	...
	if ci.CompareBase == "" {
		ctx.JSONError(ctx.Tr("repo.pulls.no_common_history"))
		return
	}
	...
	// Check if a pull request already exists with the same head and base branch.
	pr, err := issues_model.GetUnmergedPullRequest(ctx, ci.HeadRepo.ID, repo.ID, ci.HeadRef.ShortName(), ci.BaseRef.ShortName(), issues_model.PullRequestFlowGithub)
	...
	if pr != nil {
		ctx.JSONError(ctx.Tr("repo.pulls.new.already_existed"))
		return
	}
	...
	pullIssue := &issues_model.Issue{
		RepoID: repo.ID, Repo: repo,
		Title: form.Title, PosterID: ctx.Doer.ID, Poster: ctx.Doer,
		MilestoneID: milestoneID, IsPull: true, Content: content,
	}
	pullRequest := &issues_model.PullRequest{
		HeadRepoID: ci.HeadRepo.ID, BaseRepoID: repo.ID,
		HeadBranch: ci.HeadRef.ShortName(), BaseBranch: ci.BaseRef.ShortName(),
		HeadRepo: ci.HeadRepo, BaseRepo: repo,
		MergeBase: ci.CompareBase,
		Type:      issues_model.PullRequestGitea,
		AllowMaintainerEdit: form.AllowMaintainerEdit,
	}
	prOpts := &pull_service.NewPullRequestOptions{ ... }
	if err := pull_service.NewPullRequest(ctx, prOpts); err != nil {
		var pushrejErr *git.ErrPushRejected
		switch {
		case repo_model.IsErrUserDoesNotHaveAccessToRepo(err): ...
		case errors.As(err, &pushrejErr): ...   // pre-receive hook 拒绝,展示 hook 输出
		case errors.Is(err, user_model.ErrBlockedUser): ...
		case errors.Is(err, issues_model.ErrMustCollaborator): ...
		default:
			ctx.ServerError("CompareAndPullRequest", err)
		}
		return
	}
	ctx.JSONRedirect(pullIssue.Link())
}

注意 MergeBase: ci.CompareBase:merge base 在进入服务层之前就由 compare 页面的 parseCompareInfo 算好了(git merge-base 的结果),服务层首次冲突检测直接复用。错误映射分支则说明创建 PR 可能失败于 base 仓库的 pre-receive hook(例如 base 仓库配置了 ref 规则),此时 git.ErrPushRejected 携带的 hook stderr 被原样呈现给用户。

8.2.2 入口二与入口三:REST API 与 AGit

routers/api/v1/repo/pull.go 的 CreatePullRequest 走完全相同的 NewPullRequestOptions 结构体,只是参数从 form 换成 api.CreatePullRequestOption,且 head 分支必须属于同一 fork 网络。AGit 流(PullRequestFlowAGit)则没有"创建页面":用户 git push refs/for/main 时由 push 链路直接构造 PR,head commit 已经在 base 仓库中,因此服务层会走 UpdateRefForAgit 分支(见 8.3.2)。

8.2.3 核心:pull_service.NewPullRequest

三个入口汇聚到 services/pull/pull.go 的 NewPullRequest。它分为"事务前权限检查 → 事务 → 事务后通知"三段:

go 复制代码
// services/pull/pull.go
func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
	repo, issue, labelIDs, uuids, pr, assigneeIDs := opts.Repo, opts.Issue, opts.LabelIDs, opts.AttachmentUUIDs, opts.PullRequest, opts.AssigneeIDs
	if err := issue.LoadPoster(ctx); err != nil {
		return err
	}

	if user_model.IsUserBlockedBy(ctx, issue.Poster, repo.OwnerID) || user_model.IsUserBlockedBy(ctx, issue.Poster, assigneeIDs...) {
		return user_model.ErrBlockedUser
	}

	// user should be a collaborator or a member of the organization for base repo
	canCreate := issue.Poster.IsAdmin || pr.Flow == issues_model.PullRequestFlowAGit
	canAssignProject := canCreate
	if !canCreate {
		canCreate, err = repo_model.HasAccessToRepoCodeUnit(ctx, repo, issue.Poster.ID)
		...
		if !canCreate {
			// or user should have write permission in the head repo
			if err := pr.LoadHeadRepo(ctx); err != nil { return err }
			perm, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, issue.Poster)
			...
			if !perm.CanWrite(unit.TypeCode) {
				return issues_model.ErrMustCollaborator
			}
			...
		}
	}

	if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
		return err
	}
	...

权限链的含义:创建 PR 只需要"base 仓库代码单元的读权限"(HasAccessToRepoCodeUnit)或 "head 仓库的写权限"------后者正是 fork PR 的场景:贡献者对上游没有写权限,但必须能推自己的 fork。两条都不满足才报 ErrMustCollaborator。管理员与 AGit 流直接放行。

紧接着的 checkPullRequestBranchMergeable(ctx, pr) 是第一次冲突检测 :在写任何数据库之前,先算出 Status(Mergeable/Conflict/Empty...)、MergeBase、ConflictedFiles、ChangedProtectedFiles。这保证落库的 PR 自带正确的初始可合并状态,也解释了为什么创建可能因 git 层错误直接失败。

事务体:

go 复制代码
	err = db.WithTx(ctx, func(ctx context.Context) error {
		err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr)
		...
		for _, assigneeID := range assigneeIDs {          // 指派人 + 其 comment
			...
		}
		if len(opts.ProjectIDs) > 0 && canAssignProject { // 项目卡
			...
		}
		pr.Issue = issue
		issue.PullRequest = pr

		if pr.Flow == issues_model.PullRequestFlowGithub {
			if err = PushToBaseRepo(ctx, pr); err != nil {   // fetch head commit + 更新隐藏 ref
				return err
			}
			if err = syncCommitDivergence(ctx, pr); err != nil { // 算 ahead/behind
				return err
			}
		} else { // agit
			if err = UpdateRefForAgit(ctx, pr); err != nil {
				return err
			}
		}

		// add first push codes comment
		if _, _, err := CreatePushPullComment(ctx, issue.Poster, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false); err != nil {
			return err
		}

		if !pr.IsWorkInProgress(ctx) {
			reviewNotifiers, err = issue_service.PullRequestCodeOwnersReview(ctx, pr)
			...
		}
		return nil
	})
	if err != nil {
		// cleanup: this will only remove the reference, the real commit will be clean up when next GC
		if err1 := git.RemoveRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName()); err1 != nil {
			log.Error("RemoveRef: %v", err1)
		}
		return err
	}

三个设计细节:

  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

go 复制代码
// services/pull/pull.go
// PushToBaseRepo fetches the head branch commit into the base repository and points the PR head ref at it.
func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) error {
	...
	headCommitID, err := git.GetFullCommitID(ctx, pr.HeadRepo, git.BranchPrefix+pr.HeadBranch)
	if err != nil {
		return err
	}
	// fetch, not push: pushing objects FetchRemoteCommit already fetched races background repacks
	if err := git.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, headCommitID); err != nil {
		return fmt.Errorf("unable to fetch head branch %s:%s into base repo %s, err: %w", ...)
	}
	return git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), headCommitID)
}

函数名叫 "Push" 但实际是 base 仓库主动 fetch head 仓库的指定 commit,然后在本地写 ref 。注释给出了动机:被 fetch 进来的对象如果再走 push 路径,会与后台 repack 竞争。这一步在三个时机发生:PR 创建、每次 head 分支 push(经 UpdatePullsRefs/AddTestPullRequestTask)、目标分支切换。

AGit 没有 head 分支可 fetch,直接写 ref:

go 复制代码
func UpdateRefForAgit(ctx context.Context, pr *issues_model.PullRequest) (err error) {
	...
	if err := git.UpdateRef(ctx, pr.BaseRepo, pr.GetGitHeadRefName(), pr.HeadCommitID); err != nil {
		return fmt.Errorf("unable to update ref %s for base repo %s, err: %w", pr.GetGitHeadRefName(), pr.BaseRepo.FullName(), err)
	}
	return syncCommitDivergence(ctx, pr)
}

8.3.2 push 后的引用同步:UpdatePullsRefs

第 6 章讲过 git push 的 post-receive hook 会回调 Gitea 的私有接口。routers/private/hook_post_receive.go 中,在同步分支表之前先执行:

go 复制代码
// routers/private/hook_post_receive.go
func hookPostReceiveSyncDatabaseBranches(...) {
	...
	} else {
		branchesToSync = append(branchesToSync, update)
		// TODO: should we return the error ...? Currently it will log the error and not prevent the pushing
		pull_service.UpdatePullsRefs(ctx, repo, update)
	}
	...
}

// services/pull/pull.go
// UpdatePullsRefs update all the PRs head file pointers like /refs/pull/1/head ...
func UpdatePullsRefs(ctx context.Context, repo *repo_model.Repository, update *repo_module.PushUpdateOptions) {
	branch := update.RefFullName.BranchName()
	prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch)
	...
	for _, pr := range prs {
		if pr.Flow == issues_model.PullRequestFlowGithub {
			if err := PushToBaseRepo(ctx, pr); err != nil {
				log.Error("PushToBaseRepo: %v", err)
			}
		}
	}
}

即:任何分支 push 进仓库,凡是 head 指向该分支的开放 PR,其 refs/pull/N/head 立刻刷新 。post-receive 阶段即使失败也无法中止 push(代码已收到全部对象),所以这里只记日志不回滚------错误处理的取舍在注释里写得很直白。同时 hookPostReceiveRespondWithTrailer 会为每个推送的分支生成 PR 提示:不存在开放 PR 就提示"创建 PR"的 URL,存在就直接给出 /pulls/N 跳转(fork 仓库还会先解析上游 base 仓库)。

8.3.3 syncCommitDivergence:ahead/behind 的预计算

go 复制代码
func syncCommitDivergence(ctx context.Context, pr *issues_model.PullRequest) error {
	if err := pr.LoadBaseRepo(ctx); err != nil {
		return err
	}
	divergence, err := git.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
	if err != nil {
		return err
	}
	return pr.UpdateCommitDivergence(ctx, divergence.Ahead, divergence.Behind)
}

GetDivergingCommits 底层是 git rev-list --left-right --count base...head,结果写入 commits_ahead/commits_behind。这两个字段的消费者有三处:页面显示 "x commits ahead, y behind"、MergeBlockedByOutdatedBranch 的快速判定、Update branch 按钮的必要性判断。调用时机覆盖创建、head push、base push、target 切换------凡是拓扑可能变化的时刻。

8.3.4 push comment:记录每一次 head 变化

services/pull/comment.go 的 CreatePushPullComment 把 push 变成 PR 时间线上的一条 CommentTypePullRequestPush:普通 push 追加 commit 列表,force push 则走 reconcileOldCommitCommentsForForcePush------因为历史被重写,旧 commit 评论引用的 sha 可能已不可达,需要合并/删除旧记录。单条 comment 最多包含 maxPushCommitsInCommentCount = 1000 个 commit,防止一次巨型 push 撑爆数据库行。该 comment 在创建 PR 与每次 head 同步时生成,第 7 章的 Comment 多态表里它是 pull_request_push 类型的一员。

8.4 diff 与文件变更:从 git 输出到结构化 Diff

PR 页面的"Files changed"标签页是整个子系统里渲染逻辑最重的部分。数据流是:git diff 原始文本 → ParsePatch 结构化 → GetDiffForRender 补充渲染属性 → 模板输出;行内评论则在渲染前由 LoadComments 挂载到对应行上。

8.4.1 四级结构:Diff / DiffFile / DiffSection / DiffLine

services/gitdiff/gitdiff.go 定义了四个核心结构(结构体上省略了渲染专用字段):

go 复制代码
// services/gitdiff/gitdiff.go
type DiffLine struct {
	LeftIdx     int // line number, 1-based
	RightIdx    int // line number, 1-based
	Match       int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side
	Type        DiffLineType
	Content     string
	Comments    issues_model.CommentList // related PR code comments
	SectionInfo *DiffLineSectionInfo
	IsTruncated bool
	...
}

type DiffSection struct {
	FileName string
	Lines    []*DiffLine
	...
}

type DiffFile struct {
	Name         string
	NameHash     string
	OldName      string
	Addition     int
	Deletion     int
	Type         DiffFileType
	EntryMode    string
	OldEntryMode string
	IsCreated    bool
	IsDeleted    bool
	IsBin        bool
	IsLFSFile    bool
	IsRenamed    bool
	IsSubmodule  bool
	Sections     []*DiffSection
	IsIncomplete      bool // file is too large
	HasTruncatedLines bool // some lines are too long
	...
	IsProtected bool
	IsViewed                  bool // User specific
	HasChangedSinceLastReview bool // User specific
	...
}

层级关系:Diff 持有 Files []*DiffFile;每个 DiffFile 持有若干 DiffSection(对应 diff 输出里的每个 @@ hunk);每个 DiffSection 持有逐行的 DiffLine。DiffLine.Match 字段值得玩味:>0 时它指向对侧 Lines 切片的下标,把"新增行"和"删除行"两两配对,前端用它做"改了哪几个字符"的词级高亮;0 表示上下文行无需配对,-1 表示配对失败。

DiffLineSectionInfo 是"可折叠段"的元数据,结构体注释里附了一张 ASCII 图,精确描述了 hunk 边界行号的语义:

go 复制代码
type DiffLineSectionInfo struct {
	...
	// These line "idx" are 1-based line numbers (inclusive)
	//   LastLeftIdx | LastRightIdx   (the last rendered line number before this hunk)
	//   [up/down/single expander] @@ hunk info @@
	//   LeftIdx     | RightIdx       (the next rendered line number after this hunk)
	//   The hunk has LeftHunkSize lines on left side, RightHunkSize lines on right side.
	//
	// For example:
	//   17 | 31    diff line ...
	//   [up/down] @@ -40,23 +54,7 @@ ....
	//   40 | 54    diff line ...
	//   (then file end or another hunk)
	//
	// In this case:
	//   LastLeftIdx = 17, LastRightIdx = 31
	//   (left lines 18-39, right lines 31-53 are hidden)
	//   LeftIdx = 40, RightIdx = 54
	//   LeftHunkSize = 23, RightHunkSize = 7

	LastLeftIdx   int
	LastRightIdx  int
	LeftIdx       int
	RightIdx      int
	LeftHunkSize  int
	RightHunkSize int

	HiddenCommentIDs []int64 // IDs of hidden comments in this section
}

HiddenCommentIDs 把"折叠段内藏有评论"的事实暴露给模板,让用户展开折叠区时能看到提示------这是评论与 diff 折叠机制的交汇点。

8.4.2 ParsePatch:解析 git diff 输出

入口函数签名(705 行):

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

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

  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:

go 复制代码
// services/pull/patch.go(DownloadDiffOrPatch 同样的比较端点)
compareArg := pr.MergeBase + "..." + pr.GetGitHeadRefName()

三点 A...B 表示"从 A 与 B 的 merge base 到 B"------这正是 PR diff 的语义:只展示 head 相对共同祖先的改动,而不包含 base 分支上的并行改动。GetPullDiffStats(routers/web/repo/pull.go 185 行)则用 gitdiff.GetDiffShortStat 拿 "+x / -y" 汇总。GetMergedBaseCommitID(215 行)处理了一个真实边界:从外部迁移来的 PR 可能 MergeBase 为空 ,此时先尝试隐藏 ref,再回退到 patch 文件里记录的 commit,最后用 rev-list -1 --skip=1 取第一个 commit 的父节点作为近似 merge base,并顺手把隐藏 ref 重建出来。

8.4.4 行内评论的定位:Line 的符号约定

行内评论(code comment)要回答两个问题:评论在哪一行?评论当时的代码上下文是什么?models/issues/comment.go 的方案是把答案都存进 comment 行:

go 复制代码
// models/issues/comment.go
Patch       string `xorm:"-"`
PatchQuoted string `xorm:"LONGTEXT patch"`

// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string {
	if c.Line < 0 {
		return "previous"
	}
	return "proposed"
}

// UnsignedLine returns the LOC of the code comment without + or -
func (c *Comment) UnsignedLine() uint64 {
	if c.Line < 0 {
		return uint64(c.Line * -1)
	}
	return uint64(c.Line)
}

Line 的符号承载 diff 侧别 :负数 = previous(旧版/删除侧行号),正数 = proposed(新版/新增侧行号),取绝对值才是 1-based 行号。TreePath 是文件路径,CommitSHA 是评论创建时该行所属的 commit(用于失效判定),Patch 是评论处的代码上下文快照。

Patch 的持久化有一个容易忽略的细节:字段标了 xorm:"-"(不直接入库),真实列是 PatchQuoted。BeforeInsert/BeforeUpdate 钩子在 Patch 非合法 UTF-8 时用 strconv.Quote 转义存储,AfterLoad 再按首字符是否为 " 决定是否 Unquote------这样即使评论的代码上下文来自二进制/GBK 文件也能无损往返。

创建行内评论时,上下文 patch 由 services/pull/review.go 的 createCodeComment 生成:

go 复制代码
// services/pull/review.go
	if line > 0 {
		...
		if len(commitID) == 0 {
			// Get latest commit referencing the commented line
			commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
			if err == nil {
				commitID = commit.ID.String()
			} else if !isErrBlameNotFoundOrNotEnoughLines(err) {
				return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", ...)
			}
		}
	}

	// Only fetch diff if comment is review comment
	if len(patch) == 0 && reviewID != 0 {
		headCommitID, err := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
		...
		patch, err = git.GetFileDiffCutAroundLine(ctx,
			gitRepo, pr.MergeBase, headCommitID, treePath,
			int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
		)
		...
		// If patch is still empty (unchanged line), generate code context
		if patch == "" && commitID != "" {
			patch, err = gitdiff.GeneratePatchForUnchangedLine(ctx, gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines)
			...
		}
	}

三个要点:

  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 的行上:

go 复制代码
// services/gitdiff/gitdiff.go
func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, currentUser *user_model.User, showOutdatedComments bool) error {
	allComments, err := issues_model.FetchCodeComments(ctx, issue, currentUser, showOutdatedComments)
	...
	for _, file := range diff.Files {
		if lineCommits, ok := allComments[file.Name]; ok {
			for _, section := range file.Sections {
				for _, line := range section.Lines {
					if comments, ok := lineCommits[int64(line.LeftIdx*-1)]; ok {
						line.Comments = append(line.Comments, comments...)
					}
					if comments, ok := lineCommits[int64(line.RightIdx)]; ok {
						line.Comments = append(line.Comments, comments...)
					}
					sort.SliceStable(line.Comments, func(i, j int) bool {
						return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
					})
					// Mark expand buttons that have comments in hidden lines
					FillHiddenCommentIDsForDiffLine(line, lineCommits)
				}
			}
		}
	}
	return nil
}

匹配规则与 Comment.Line 的符号约定精确对应:评论以 Line*-1 作为 key 挂到 LeftIdx(旧侧行号),以 Line 作为 key 挂到 RightIdx(新侧行号) 。也就是说 FetchCodeComments 内部把 comment 的 Line 归一化成了"左侧行号取负、右侧行号取正"的 map key,与 DiffLine 的两个行号字段天然对齐。FillHiddenCommentIDsForDiffLine 则把落在折叠隐藏区里的评论 ID 记进 SectionInfo.HiddenCommentIDs,让模板渲染"此处有 N 条隐藏评论"的展开按钮。另有 SyncUserSpecificDiff 填充 IsViewed(已查看标记)与 HasChangedSinceLastReview(上次评审后又变了),数据来源是 models/pull/review_state.go 的 ReviewState.UpdatedFiles------第 8.10 节会再遇到它。

评论失效(invalidated)判定在 push 时触发(8.11.2):InvalidateCodeComments 对每个未失效的 code comment 跑 checkInvalidation------重新 git blame 该行,若 blame 得到的 commit 与评论记录的 CommitSHA 不同,或者行已不存在(NoSuchPath/FileNoEnoughLines),就把 Invalidated 置真,页面上评论转为"Outdated"折叠态。

8.4.6 评论侧的 Diff 复用

CommentAsDiff(1606 行)把单条评论存储的 patch 反向解析成 *Diff 结构,用于时间线里内联展示评论的代码上下文------同一套解析器服务两种视图,这是 diff 层被设计成"纯文本进、结构体出"的直接收益。

8.5 冲突检测的双实现

PR 状态的核心驱动是冲突检测。它在三个时机运行:创建 PR 时(事务前同步执行)、push 后入队异步执行、切换目标分支时同步执行。入口是一个能力探测:

go 复制代码
// services/pull/patch.go
func checkPullRequestBranchMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
	ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("checkPullRequestBranchMergeable: %s", pr))
	defer finished()

	if git.DefaultFeatures().SupportGitMergeTree {
		return checkPullRequestMergeableByMergeTree(ctx, pr)
	}

	return checkPullRequestMergeableByTmpRepo(ctx, pr)
}

8.5.1 路径 A:git merge-tree(现代 git)

services/pull/merge_tree.go(2026 年新增的文件,文件头版权即 2026)是八步流程:

go 复制代码
// services/pull/merge_tree.go
func checkPullRequestMergeableByMergeTree(ctx context.Context, pr *issues_model.PullRequest) error {
	// 1. Get head commit
	// 2. Get/open base repository(同仓库时复用句柄)
	// 3. Get head commit id
	if pr.Flow == issues_model.PullRequestFlowGithub {
		pr.HeadCommitID, err = headGitRepo.GetRefCommitID(ctx, git.BranchPrefix+pr.HeadBranch)
		...
	} else {
		if pr.ID > 0 {
			pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
			...
		} else if pr.HeadCommitID == "" { // for new pull request with agit, the head commit id must be provided
			return errors.New("head commit ID is empty for pull request Agit flow")
		}
	}
	// 4. fetch head commit id into the current repository
	if !pr.IsSameRepo() {
		if !baseGitRepo.IsReferenceExist(ctx, pr.HeadCommitID) {
			if err := git.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, pr.HeadCommitID); err != nil { ... }
		}
	}
	// 5. update merge base
	baseCommitID, err := baseGitRepo.GetRefCommitID(ctx, git.BranchPrefix+pr.BaseBranch)
	...
	pr.MergeBase, err = git.MergeBase(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID)
	if err != nil {
		// if there is no merge base, then it's empty, still need to allow the pull request to be created
		pr.Status = issues_model.PullRequestStatusEmpty
		return nil
	}

	// reset conflicted files and changed protected files
	pr.ConflictedFiles = nil
	pr.ChangedProtectedFiles = nil

	// 6. if base == head, then it's an ancestor
	if pr.HeadCommitID == pr.MergeBase {
		pr.Status = issues_model.PullRequestStatusAncestor
		return nil
	}

	// 7. Check for conflicts
	conflicted, err := checkConflictsMergeTree(ctx, pr, baseCommitID)
	...
	if conflicted || pr.Status == issues_model.PullRequestStatusEmpty {
		return nil
	}

	// 8. Check for protected files changes
	return checkPullFilesProtection(ctx, pr, baseGitRepo, pr.HeadCommitID)
}

第 5 步没有 merge base 时不报错而是标记 Empty 并放行------无共同历史的 PR 也允许创建(注释原话 "still need to allow the pull request to be created"),这是导入外部仓库时的现实需求。第 7 步的细节:

go 复制代码
func checkConflictsMergeTree(ctx context.Context, pr *issues_model.PullRequest, baseCommitID string) (bool, error) {
	treeHash, conflict, conflictFiles, err := git.MergeTree(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID, pr.MergeBase)
	if err != nil {
		return false, fmt.Errorf("MergeTree: %w", err)
	}
	if conflict {
		pr.Status = issues_model.PullRequestStatusConflict
		// sometimes git merge-tree will detect conflicts but not list any conflicted files
		// so that pr.ConflictedFiles will be empty
		pr.ConflictedFiles = conflictFiles
		...
		return true, nil
	}

	// Detect whether the pull request introduces changes by comparing the merged tree (treeHash)
	// against the current base commit (baseCommitID) using `git diff-tree`. The command returns exit code 0
	// if there is no diff between these trees (empty patch) and exit code 1 if there is a diff.
	gitErr := gitcmd.NewCommand("diff-tree", "-r", "--quiet").
		AddDynamicArguments(treeHash, baseCommitID).WithRepo(pr.BaseRepo).Run(ctx)
	switch {
	case gitErr == nil:
		pr.Status = issues_model.PullRequestStatusEmpty
	case gitcmd.IsErrorExitCode(gitErr, 1):
		pr.Status = issues_model.PullRequestStatusMergeable
	default:
		return false, fmt.Errorf("run diff-tree exit abnormally: %w", gitErr)
	}
	return false, nil
}

一次 git merge-tree(写出合并后 tree 的新式用法)拿到结果 tree hash;有冲突直接落 Conflict 状态;无冲突则用 diff-tree -r --quiet 把合并结果与 base 比较------退出码 0(无差异)意味着 head 的改动已经全部包含在 base 中,标记 Empty ;退出码 1 才是真正的 Mergeable。这个"用退出码判空补丁"的技巧是 Gitea 判定"PR 实际上没带来任何改动"的标准做法。注释还记录了一个真实怪癖:merge-tree 有时报告冲突却不列出任何文件,所以 ConflictedFiles 可能为空但状态仍是 Conflict(页面对此的提示是"有冲突"而非"冲突文件列表")。

8.5.2 路径 B:临时仓库三路合并(兼容旧 git)

checkPullRequestMergeableByTmpRepo 在克隆的临时仓库里手工模拟合并。createTemporaryRepoForPR(services/pull/temp_repo.go)克隆 base 仓库并创建三个本地分支:base(目标分支)、tracking(head ref)、staging(合并工作区)。流程:

go 复制代码
// services/pull/patch.go
func checkPullRequestMergeableByTmpRepo(ctx context.Context, pr *issues_model.PullRequest) error {
	prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
	...
	// 1. update merge base
	pr.MergeBase, _, err = gitcmd.NewCommand("merge-base", "--", tmpRepoBaseBranch, tmpRepoTrackingBranch).WithRepo(prCtx.tmpRepo).RunStdString(ctx)
	if err != nil {
		// 无 merge-base 时回退用 base 分支 tip
		pr.MergeBase, err2 = tmpGitRepo.GetRefCommitID(ctx, git.BranchPrefix+tmpRepoBaseBranch)
		...
	}
	pr.MergeBase = strings.TrimSpace(pr.MergeBase)
	if pr.HeadCommitID, err = tmpGitRepo.GetRefCommitID(ctx, git.BranchPrefix+tmpRepoTrackingBranch); err != nil { ... }

	if pr.HeadCommitID == pr.MergeBase {
		pr.Status = issues_model.PullRequestStatusAncestor
		return nil
	}

	// 2. Check for conflicts
	conflicts, err := checkConflictsByTmpRepo(ctx, pr, tmpGitRepo, prCtx.tmpBasePath)
	...
	pr.ChangedProtectedFiles = nil
	if conflicts || pr.Status == issues_model.PullRequestStatusEmpty {
		return nil
	}

	// 3. Check for protected files changes
	if err = checkPullFilesProtection(ctx, pr, tmpGitRepo, tmpRepoTrackingBranch); err != nil { ... }

	pr.Status = issues_model.PullRequestStatusMergeable
	return nil
}

冲突检测核心 AttemptThreeWayMerge 复刻了 git 的 merge-one-file 算法:

go 复制代码
// AttemptThreeWayMerge will attempt to three way merge using git read-tree and then follow the git merge-one-file algorithm ...
func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repository, base, ours, theirs, description string) (bool, []string, error) {
	// First we use read-tree to do a simple three-way merge
	if err := gitcmd.NewCommand("read-tree", "-m").AddDynamicArguments(base, ours, theirs).WithRepo(gitRepo).RunWithStderr(ctx); err != nil {
		return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %w", err)
	}
	// Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles
	unmerged := make(chan *unmergedFile)
	go unmergedFiles(ctx, gitRepo, unmerged)
	...
	for file := range unmerged {
		...
		if err := attemptMerge(ctx, file, gitPath, gitRepo, &filesToRemove, &filesToAdd); err != nil {
			if conflictErr, ok := err.(*errMergeConflict); ok {
				conflict = true
				if numberOfConflicts < 10 {
					conflictedFiles = append(conflictedFiles, conflictErr.filename)
				}
				numberOfConflicts++
				continue
			}
			return false, nil, err
		}
	}
	// Add and remove files in one command, as this is slow with many files otherwise
	if err := gitRepo.RemoveFilesFromIndex(ctx, filesToRemove...); err != nil { ... }
	if err := gitRepo.AddObjectsToIndex(ctx, filesToAdd...); err != nil { ... }
	return conflict, conflictedFiles, nil
}

read-tree -m base ours theirs 把三路合并写进索引;ls-files -u 列出所有未合并条目(每个文件最多 stage 1/2/3 三个版本);attemptMerge 对每个条目执行单文件判定------注意冲突文件列表只收集前 10 个 (numberOfConflicts < 10),超出部分只计数不列举,防止极端冲突场景撑大数据库行。

attemptMerge 的判定表(完整代码 130-235 行)可归纳为:

stage 组合 场景 结果
1 存在,2/3 缺一 一侧删除 删除侧与未删侧 sha 不同 → 冲突;相同 → 从索引移除
1 缺,2 存,3 缺或 2==3 仅 ours 新增(或两侧相同) 加入索引(无冲突)
1 缺,2、3 同 sha 不同 mode 两侧同内容不同权限 冲突
1 缺 2 缺 3 存 仅 theirs 新增 加入索引
1、2、3 全缺 不可达 按分支兜底返回冲突
2、3 都存(修改两侧) 双方都改 先查 mode:120000 符号链接 / 160000 子模块 / mode 不一致均冲突;否则 unpack-file 取三个 blob,git merge-file base root head 三方合并,非零退出即冲突,成功则 hash-object -w 写回索引

注意 git merge-file 的参数顺序与 merge-tree 相反(源码注释 "now git merge-file annoyingly takes a different order to the merge-tree"),这类 git 命令间的语义差异是这个手写实现必须逐条对齐的地方。

8.5.3 空补丁检测(路径 B)

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

go 复制代码
	// a. Write the newly merged tree and check the new tree-hash
	treeHash, _, err = gitcmd.NewCommand("write-tree").WithRepo(tmpGitRepo).RunStdString(ctx)
	...
	// b. compare the new tree-hash with the base tree hash
	if treeHash == baseTree.ID.String() {
		log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
		pr.Status = issues_model.PullRequestStatusEmpty
	}

与路径 A 的 diff-tree --quiet 异曲同工:路径 B 用"合并后的 tree 与 base tree 是否同哈希"判空,路径 A 用 diff-tree 退出码。两条路径最终都可能落 Empty 状态------它不是错误,UI 上显示为"没有可合并的更改"。

8.5.4 受保护文件变更检测

两条路径最后都执行 checkPullFilesProtection:

go 复制代码
func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, headRef string) error {
	if pr.Status == issues_model.PullRequestStatusEmpty {
		pr.ChangedProtectedFiles = nil
		return nil
	}
	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
	...
	if pb == nil {
		pr.ChangedProtectedFiles = nil
		return nil
	}
	pr.ChangedProtectedFiles, err = CheckFileProtection(ctx, gitRepo, pr.HeadBranch, pr.MergeBase, headRef, pb.GetProtectedFilePatterns(), 10, os.Environ())
	if err != nil && !IsErrFilePathProtected(err) {
		return err
	}
	...
}

CheckFileProtection 拿 merge_base..head 的受影响文件列表与分支保护的 ProtectedFilePatterns(glob,大小写不敏感------路径先 strings.ToLower)逐一匹配,同样最多收集 10 个 。结果写入 ChangedProtectedFiles,在 8.6.2 的 CheckPullBranchProtections 最后一步被消费:改动了受保护文件的 PR 即使审批齐全也不能合(除非管理员 bypass)。

8.6 可合并判定:CheckPullMergeable 与分支保护

冲突检测只回答"git 层面能不能合";"允不允许合"由 CheckPullMergeable 回答。它是合并按钮、自动合并、API 合并三个入口共用的裁判。

8.6.1 判定序列

go 复制代码
// services/pull/check.go
type MergeCheckType int
const (
	MergeCheckTypeGeneral  MergeCheckType = iota // general merge checks for "merge", "rebase", "squash", etc
	MergeCheckTypeManually                       // Manually Merged button (mark a PR as merged manually)
	MergeCheckTypeAuto                           // Auto Merge (Scheduled Merge) After Checks Succeed
)

func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, forceMerge bool) error {
	return db.WithTx(stdCtx, func(ctx context.Context) error {
		if pr.HasMerged {
			return ErrHasMerged
		}
		if err := pr.LoadIssue(ctx); err != nil { ... } else if pr.Issue.IsClosed {
			return ErrIsClosed
		}
		if allowedMerge, err := IsUserAllowedToMerge(ctx, pr, *perm, doer); err != nil {
			...
		} else if !allowedMerge {
			return ErrNoPermissionToMerge
		}
		if mergeCheckType == MergeCheckTypeManually {
			// if doer is doing "manually merge" (mark as merged manually), do not check anything
			return nil
		}
		if pr.IsWorkInProgress(ctx) {
			return ErrIsWorkInProgress
		}
		if !pr.IsStatusMergeable() && !pr.IsEmpty() {
			return ErrNotMergeableState
		}
		if pr.IsChecking() {
			return ErrIsChecking
		}
		if errProtection := CheckPullBranchProtections(ctx, pr, false); errProtection != nil {
			if !errors.Is(errProtection, ErrNotReadyToMerge) { ... return errProtection }

			// * when doing Auto Merge ... skip the branch protection check
			if mergeCheckType == MergeCheckTypeAuto {
				errProtection = nil
			}
			// * if the doer tries to "Force Merge", check whether it is really allowed
			if forceMerge {
				isRepoAdmin := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer)
				protectedBranchRule, errForceMerge := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
				...
				canForceMerge := isRepoAdmin
				if protectedBranchRule != nil {
					canForceMerge = git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin)
				}
				if canForceMerge {
					errProtection = nil
				}
			}
			if errProtection != nil {
				return errProtection
			}
		}
		if err := checkSigningRequirements(ctx, pr, doer, mergeStyle); err != nil {
			return err
		}
		if noDeps, err := issues_model.IssueNoDependenciesLeft(ctx, pr.Issue); err != nil {
			return err
		} else if !noDeps {
			return ErrDependenciesLeft
		}
		return nil
	})
}

全部检查在一个只读事务里完成。错误值定义在文件顶部的 Err 常量集(ErrIsClosed、ErrNoPermissionToMerge、ErrNotReadyToMerge、ErrHasMerged、ErrIsWorkInProgress、ErrIsChecking、ErrNotMergeableState、ErrDependenciesLeft、ErrHeadCommitsNotAllVerified),路由层把它们映射为用户可读的提示(8.12.3 的决策表列出映射)。

三个 MergeCheckType 的语义差异很关键:

  • General:完整检查,分支保护不通过就是不通过。
  • Manually :在权限与已合并/已关闭检查后直接返回 nil------"标记为手动合并"按钮不需要任何状态,因为它标记的是"这个 PR 其实已经被合进了 base 分支"的事实。
  • Auto :分支保护失败可被静默跳过 (errProtection = nil)。这不是绕过审批------自动合并队列在入队时已经做过完整检查,此后审批状态可能因新 push 变化,但排在队列里的合并意图是用户在满足条件时授权的;真正落 Merge 前还会再走一遍 8.9 的判定链。

forceMerge 分支回收第 5 章的伏笔:管理员勾选"强制合并"时,先判断是否仓库 admin,再让 git_model.CanBypassBranchProtection 结合 ProtectedBranch 规则(含第 5 章讲的 BlockAdminMergeOverride 等开关)决定是否放行------能否强制合并不是简单的"admin 就行",而是分支保护规则配置的函数。

checkSigningRequirements 按合并样式区分签名要求(源码注释即为文档):

go 复制代码
//   - fast-forward-only and merge keep the user's commits on the base branch, so
//     those commits must all be verified, or the pre-receive hook will reject the
//     push with a generic error.
//   - fast-forward-only creates no Gitea commit, so Gitea's signing key is not used.
//   - merge, rebase, rebase-merge and squash produce a Gitea-signed commit, so
//     Gitea must be configured to sign it.
func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle) error {
	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
	...
	if pb == nil || !pb.RequireSignedCommits {
		return nil
	}
	...
	if mergeStyle == repo_model.MergeStyleFastForwardOnly || mergeStyle == repo_model.MergeStyleMerge {
		verified, err := asymkey_service.AllHeadCommitsVerified(ctx, pr, gitRepo)
		...
		if !verified {
			return ErrHeadCommitsNotAllVerified
		}
	}
	if mergeStyle != repo_model.MergeStyleFastForwardOnly {
		if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo, pr.BaseBranch, pr.GetGitHeadRefName()); err != nil {
			return err
		}
	}
	return nil
}

8.6.2 分支保护七项检查

services/pull/merge.go 的 CheckPullBranchProtections 是分支保护规则的消费端,顺序执行:

go 复制代码
func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullRequest, skipProtectedFilesCheck bool) (err error) {
	...
	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
	...
	if pb == nil {
		return nil // 无规则直接放行
	}

	isPass, err := IsPullCommitStatusPass(ctx, pr)
	if !isPass {
		return util.ErrorWrap(ErrNotReadyToMerge, "Not all required status checks successful")
	}
	if !issues_model.HasEnoughApprovals(ctx, pb, pr) {
		return util.ErrorWrap(ErrNotReadyToMerge, "Does not have enough approvals")
	}
	if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) {
		return util.ErrorWrap(ErrNotReadyToMerge, "There are requested changes")
	}
	if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) {
		return util.ErrorWrap(ErrNotReadyToMerge, "There are official review requests")
	}
	if issues_model.MergeBlockedByOutdatedBranch(pb, pr) {
		return util.ErrorWrap(ErrNotReadyToMerge, "The head branch is behind the base branch")
	}
	if !issue_service.HasAllRequiredCodeownerReviews(ctx, pb, pr) {
		return util.ErrorWrap(ErrNotReadyToMerge, "There are missing code owner reviews")
	}
	if skipProtectedFilesCheck {
		return nil
	}
	if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) {
		return util.ErrorWrap(ErrNotReadyToMerge, "Changed protected files")
	}
	return nil
}

七项检查依次是:commit status → 审批数 → 拒绝评审 → 官方评审请求未处理 → 分支落后 → CodeOwners 评审 → 受保护文件改动 。全部失败都包装成同一个哨兵错误 ErrNotReadyToMerge 加不同的可读消息------CheckPullMergeable 只需 errors.Is(err, ErrNotReadyToMerge) 判断"这是可通过 forceMerge/自动合并策略处理的失败",其他错误(git 故障等)原样上抛。skipProtectedFilesCheck 参数供特定调用方跳过最后一项。

commit status 的具体判定在 services/pull/commit_status.go:

go 复制代码
// IsPullCommitStatusPass returns if all required status checks PASS
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
	...
	if pb == nil {
		return true, nil
	}
	if !pb.EnableStatusCheck {
		// The branch's own status check is off, but required scoped checks (mandated by the owner or instance admin) still gate the merge.
		...
		required, err := EffectiveRequiredContexts(ctx, pr.BaseRepo, pb)
		...
		if len(required) == 0 {
			return true, nil
		}
	}
	state, err := GetPullRequestCommitStatusState(ctx, pr)
	...
	return state.IsSuccess(), nil
}

EffectiveRequiredContexts 把两类需求合并:scoped workflow(实例/组织级强制 CI 模式,actions_model.GetEffectiveScopedWorkflowSources)与分支规则自己的 StatusCheckContexts------即使规则本身的 status check 被关闭,scoped 强制检查依然拦截合并 。MergeRequiredContextsCommitStatus 用 glob 匹配 required contexts:任何 required context 尚未出现(未 matched)时返回 Pending 而非 Success("没跑 = 没过"),已出现的按最差状态聚合。CI 状态如何产生、第 14 章详述,这里只关注它作为合并门禁的语义。

8.6.3 权限侧:IsUserAllowedToMerge

go 复制代码
func isUserAllowedToMergeInRepoBranch(ctx context.Context, repoID int64, branch string, p access_model.Permission, user *user_model.User) (bool, error) {
	if user == nil { return false, nil }
	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repoID, branch)
	...
	if (p.CanWrite(unit.TypeCode) && pb == nil) || (pb != nil && git_model.IsUserMergeWhitelisted(ctx, pb, user.ID, p)) {
		return true, nil
	}
	return false, nil
}

一句话概括:无分支保护时,对代码单元有写权限即可合;有保护规则时,必须在规则的 merge whitelist 里 (whitelist 可按用户/组织团队配置,第 5 章权限矩阵覆盖了 IsUserMergeWhitelisted 的展开逻辑)。

8.6.4 检查队列:pr_patch_checker

状态 Checking 的产生与消费由唯一队列驱动(services/pull/check.go):

go 复制代码
// prPatchCheckerQueue represents a queue to handle update pull request tests
var prPatchCheckerQueue *queue.WorkerPoolQueue[string]

var AddPullRequestToCheckQueue = realAddPullRequestToCheckQueue

func StartPullRequestCheckImmediately(ctx context.Context, pr *issues_model.PullRequest) {
	if !markPullRequestStatusAsChecking(ctx, pr) {
		return
	}
	AddPullRequestToCheckQueue(pr.ID)
}

// StartPullRequestCheckDelayable will delay the check if the pull request was not updated recently.
// When the "base" branch gets updated, all PRs targeting that "base" branch need to re-check whether
// they are mergeable. When there are too many stale PRs, each "base" branch update will consume a lot
// of system resources. So we can delay the checks for PRs that were not updated recently ...
func StartPullRequestCheckDelayable(ctx context.Context, pr *issues_model.PullRequest) {
	if !markPullRequestStatusAsChecking(ctx, pr) { return }
	if setting.Repository.PullRequest.DelayCheckForInactiveDays >= 0 {
		if err := pr.LoadIssue(ctx); err != nil { return }
		duration := 24 * time.Hour * time.Duration(setting.Repository.PullRequest.DelayCheckForInactiveDays)
		if pr.Issue.UpdatedUnix.AddDuration(duration) <= timeutil.TimeStampNow() {
			return // 不活跃 PR:只标记 Checking,不入队
		}
	}
	AddPullRequestToCheckQueue(pr.ID)
}

三个入队入口的策略差异体现了负载设计:head push 用 Immediately (用户在等结果);base push 用 Delayable (主分支一动,所有目标它的 PR 都要重查,可能几十上百个,不活跃的 PR 只标状态不实际跑,等它被查看时再补,见下一函数);页面查看用 OnView:

go 复制代码
func StartPullRequestCheckOnView(ctx context.Context, pr *issues_model.PullRequest) {
	// TODO: its correctness totally depends on the "unique queue" feature and the global lock.
	if pr.Status == issues_model.PullRequestStatusChecking {
		...
		_, _ = globallock.TryLockAndDo(ctx, getPullWorkingLockKey(pr.ID), func(ctx context.Context) error {
			AddPullRequestToCheckQueue(pr.ID) // the queue is a unique queue and won't add the same task again
			return nil
		})
	}
}

这里显式依赖两个并发原语:unique queue (同一 PR 重复入队被去重,queue.ErrAlreadyInQueue 被容忍)与全局锁 pull_working_<id> (检查任务执行时持有,OnView 侧用 TryLock 避免死锁------测试环境下队列是同步执行的,源码对 setting.IsInTesting 做了特判并给出注释)。

消费端:

go 复制代码
func checkPullRequestMergeable(prID int64) {
	ctx := graceful.GetManager().HammerContext()
	ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Test PR[%d] from patch checking queue", prID))
	defer finished()

	err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
		pr, err := issues_model.GetPullRequestByID(ctx, prID)
		...
		if pr.HasMerged { return nil }

		if ok, err := restoreInterruptedMerge(ctx, pr); err != nil {
			return err
		} else if ok {
			return nil
		}
		if manuallyMergedByPushedCommit(ctx, pr) {
			return nil
		}
		if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
			...
			pr.Status = issues_model.PullRequestStatusError
			...
			return err
		}
		return markPullRequestAsMergeable(ctx, pr)
	})
	...
}

一个检查任务在全局锁内依次尝试:恢复中断的合并 (8.7.5)→ 侦测手动合并 (8.7.6)→ 真正跑冲突检测 → 落终态 。任何一步成功都短路返回。markPullRequestAsMergeable 收尾时还有一个精妙的竞态防御:

go 复制代码
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
	// If the status has not been changed to conflict by the conflict checking functions then we are mergeable
	if pr.Status == issues_model.PullRequestStatusChecking {
		pr.Status = issues_model.PullRequestStatusMergeable
	}
	// Make sure there is no waiting test to process before leaving the checking status.
	has, err := prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10))
	...
	if has {
		log.Trace("Not updating status for %-v as it is due to be rechecked", pr)
		return nil
	}
	if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
		return err
	}
	// if there is a scheduled merge for this pull request, start the auto merge check (again)
	hasScheduledMerge, _, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
	...
	if hasScheduledMerge {
		automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
	}
	return nil
}

如果检查进行期间又有新任务入队(期间发生了 push),当前结果不落库 直接返回------避免旧结果覆盖新状态;队列里的下一个任务会重算。落库成功后若发现有挂起的自动合并计划,立即触发自动合并复检(8.9 的挂接点之一)。InitializePullRequests 在启动时把 GetInterruptedPullRequestIDs(有 MergedCommitID 但未标 merged 的"中断合并")与遗留 Checking 状态的 PR 重新入队,实现崩溃恢复。

8.7 合并执行:从 Merge 到一次 git push

判定通过后,真正的合并发生在 services/pull/merge.go 的 Merge 中。这一节把这条链路拆成七段:入口与锁、临时仓库与四样式分派、合并消息、崩溃恢复、手动合并侦测、终态落库。

8.7.1 Merge 入口:同一个全局锁

go 复制代码
// Merge merges pull request to base repository.
// Caller should check PR is ready to be merged (review and status checks)
func Merge(outerCtx context.Context, prID int64, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
        ...
        ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
        ...
        err := globallock.LockAndDo(ctx, getPullWorkingLockKey(prID), func(ctx context.Context) error {
                pr, err := issues_model.GetPullRequestByID(ctx, prID)
                ...
                prConfig := pr.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()

                // Check if merge style is correct and allowed
                if !prConfig.IsMergeStyleAllowed(mergeStyle) {
                        return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
                }

                hasCommitBeenMerged, err := hasPullRequestCommitBeenMerged(ctx, pr)
                ...
                if !hasCommitBeenMerged {
                        _, err = doMergeAndPush(ctx, pr, doer, mergeStyle, message, mergeAndPushOptions{
                                expectedHeadCommitID: expectedHeadCommitID,
                                onMergedIntoTempBase: func(commitID string) error {
                                        return recordMergeIntent(ctx, pr, doer, commitID, wasAutoMerged)
                                },
                        })
                        if err != nil {
                                return err
                        }
                }

                _, err = MarkAsMerged(ctx, pr, pr.MergedCommitID, pr.MergedUnix, doer, pr.Status)
                ...
        })
        ...
        pr, err := issues_model.GetPullRequestByID(ctx, prID)
        ...
        addTestPullRequestTaskAfterWebOperation(pr, doer) // keep the same behavior as old code: always call AddTestPullRequestTask
        return nil
}

四个设计点值得注意:

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

go 复制代码
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, message string, opts mergeAndPushOptions) (string, error) {
        // Clone base repo.
        mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, opts.expectedHeadCommitID)
        ...
        // Merge commits.
        switch mergeStyle {
        case repo_model.MergeStyleMerge:
                if err := doMergeStyleMerge(mergeCtx, message); err != nil { ... }
        case repo_model.MergeStyleRebase, repo_model.MergeStyleRebaseMerge:
                if err := doMergeStyleRebase(mergeCtx, mergeStyle, message); err != nil { ... }
        case repo_model.MergeStyleSquash:
                if err := doMergeStyleSquash(mergeCtx, message); err != nil { ... }
        case repo_model.MergeStyleFastForwardOnly:
                if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil { ... }
        default:
                return "", ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
        }

        // OK we should cache our current head and origin/headbranch
        mergeHeadSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpRepo, "HEAD")
        ... // 以及 mergeBaseSHA(original_<base>)与 mergeCommitID(staging),供 LFS push 与 recordMergeIntent 使用
        if setting.LFS.StartServer {
                if err := LFSPush(ctx, mergeCtx.tmpBasePath, mergeCtx.tmpRepo, mergeHeadSHA, mergeBaseSHA, pr); err != nil { ... }
        }
        ...
        if opts.onMergedIntoTempBase != nil {
                if err := opts.onMergedIntoTempBase(mergeCommitID); err != nil { ... }
        }

        mergeCtx.env = repo_module.FullPushingEnvironment(headUser, doer, pr.BaseRepo, pr.BaseRepo.Name, pr.ID, pr.Index)
        pushCmd := gitcmd.NewCommand("push", "origin").AddDynamicArguments(tmpRepoBaseBranch + ":" + git.BranchPrefix + pr.BaseBranch)

        // Push back to upstream.
        // This cause an api call to "/api/internal/hook/post-receive/...",
        // If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
        if err := mergeCtx.PrepareGitCmd(pushCmd).RunWithStderr(ctx); err != nil {
                if strings.Contains(err.Stderr(), "non-fast-forward") {
                        return "", &git.ErrPushOutOfDate{...}
                } else if strings.Contains(err.Stderr(), "! [remote rejected]") {
                        err := &git.ErrPushRejected{...}
                        err.GenerateMessage()
                        return "", err
                }
                return "", fmt.Errorf("git push: %s", err.Stderr())
        }
        ...
}

时序上的关键顺序是:先 LFS push、再记录合并意图(recordMergeIntent)、最后 git push 。LFS 放最前的理由写在注释里------"failures to push to the lfs should prevent the merge as you can always remerge"(LFS 对象丢了就永远找不回,而合并可以重来)。recordMergeIntent 在 push 前把 merged_commit_id/merged_unix/merger_id 写进数据库(8.7.5 详述),这样 push 成功但进程崩溃时,重启后能凭这条记录恢复。而真正的 git push 会触发 /api/internal/hook/post-receive/...------注释特意强调数据库事务都放在 hook 回调里做,避免与 push 过程死锁。

push 的两类失败被翻译成路由层可识别的错误:ErrPushOutOfDate(base 分支在判定后又被推进,"non-fast-forward")与 ErrPushRejected(! [remote rejected],通常是 pre-receive hook 拒绝,GenerateMessage 会把 hook 的 stderr 输出整理成用户可读的提示)------这就是 8.2.1 里 CompareAndPullRequestPost 展示的 ErrPushRejected 同源错误。

8.7.3 四种合并样式的 git 命令差异

services/pull/ 下每个样式一个文件,命令差异一览:

MergeStyle 实现文件 核心 git 命令 产物
merge merge_merge.go git merge --no-ff --no-commit <tracking> + commitAndSignNoAuthor 1 个 merge commit(作者为 doer)
rebase merge_rebase.go rebaseTrackingOnToBase 后 git merge --ff-only <staging>,再按模板 commit --amend 若干重写后的线性 commit(无 merge commit)
rebase-merge merge_rebase.go rebaseTrackingOnToBase 后 git merge --no-ff --no-commit <staging> + commitAndSignNoAuthor 重写后的线性 commit + 1 个 merge commit
squash merge_squash.go git merge --squash <tracking> + git commit --allow-empty --author='<sig>' 1 个 squash commit
fast-forward-only merge_ff_only.go git merge --ff-only <tracking> 0 个新 commit(纯 fast-forward)

几个实现细节:

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

go 复制代码
func commitAndSignNoAuthor(ctx *mergeContext, message string) error {
        cmdCommit := gitcmd.NewCommand("commit")
        if err := git.AddObjectMessageArgument(cmdCommit, git.ObjectCommit, message); err != nil { ... }
        addCommitSigningOptions(cmdCommit, ctx.signKey)
        if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil { ... }
        return nil
}

squash 样式 的作者选择逻辑最讲究------getAuthorSignatureSquash 不直接用 PR 发起人,而是先在 head 分支的提交里找"邮箱能匹配到发起人本人"的签名:

go 复制代码
uniqueEmails := make(container.Set[string])
for _, commit := range commits {
        if commit.Author != nil && uniqueEmails.Add(commit.Author.Email) {
                commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
                if commitUser != nil && commitUser.ID == ctx.pr.Issue.Poster.ID {
                        return commit.Author, nil
                }
        }
}
return ctx.pr.Issue.Poster.NewGitSig(), nil

注释解释了动机:"the poster email might be private or commits might have a different signature than the primary email address"------用户在 Gitea 的主邮箱可能与 git 提交签名不一致,直接用会产生错误的 author。遍历去重(uniqueEmails)避免对同一邮箱反复查库。兜底才用 Poster.NewGitSig()。随后 git commit --allow-empty --author=... 保证即使 squash 结果为空也产生 commit;若开启 AddCoCommitterTrailers 且 committer 与 author 不同,还会追加 Co-authored-by 尾行(AddCommitMessageTailer 会先探测最后一行是否已是 trailer 格式,避免重复追加)。

rebase 系列 先 rebaseTrackingOnToBase 把 head 重放到 base 上(在 merge_prepare.go 中,兼容 Git ≥2.26 的 REBASE_HEAD 与更早版本的 .git/rebase-apply 检测,冲突时返回 ErrRebaseConflicts),切回 base 后再分派:rebase 样式走 doMergeRebaseFastForward------注意它在 fast-forward 前后各取一次 HEAD,若两者相同(fast-forward 未推进任何 commit)直接返回,注释写明是为避免"对未变更的 commit 做无意义 amend";rebase-merge 样式则追加 merge commit。

fast-forward-only 是四者中唯一不产生任何 commit 的,实现也只有一条 git merge --ff-only <tracking>(merge_ff_only.go),其 runMergeCommand 失败时专门识别 "Not possible to fast-forward, aborting" 映射为 ErrMergeDivergingFastForwardOnly。

四种样式共享的失败翻译在 runMergeCommand:

go 复制代码
// Merge will leave a MERGE_HEAD file in the .git folder if there is a conflict
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "MERGE_HEAD")); statErr == nil {
        return ErrMergeConflicts{...}
} else if strings.Contains(err.Stderr(), "refusing to merge unrelated histories") {
        return ErrMergeUnrelatedHistories{...}
} else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(err.Stderr(), "Not possible to fast-forward, aborting") {
        return ErrMergeDivergingFastForwardOnly{...}
}

判断冲突的依据不是解析 stderr 文案,而是探测 .git/MERGE_HEAD 文件是否存在------这是 git 写入冲突状态的客观信号,比匹配本地化错误文本稳健。

8.7.4 合并消息:模板三级回退

getMergeMessage 决定最终 commit message,采用三级回退:

  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

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

go 复制代码
func recordMergeIntent(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, commitID string, wasAutoMerged bool) error {
        return db.WithTx(ctx, func(ctx context.Context) error {
                pr.MergedUnix = timeutil.TimeStampNow()
                pr.MergedCommitID = commitID
                pr.MergerID = doer.ID
                _, err := db.GetEngine(ctx).ID(pr.ID).Cols("merged_unix", "merged_commit_id", "merger_id").Update(pr)
                ...
                if wasAutoMerged {
                        hasScheduledMerge, scheduledMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pr.ID)
                        ...
                        if hasScheduledMerge {
                                scheduledMerge.MergedCommitID = commitID
                                _, err = db.GetEngine(ctx).ID(scheduledMerge.ID).Cols("merged_commit_id").Update(scheduledMerge)
                                ...
                        }
                }
                return nil
        })
}

它在 git push 之前 执行,此时 has_merged 仍为 false------所以一旦 push 后崩溃,数据库里会留下"有 MergedCommitID 但未标 merged"的状态,即 MarkAsMerged 注释里的第 4 步失败场景。恢复发生在检查任务中:

go 复制代码
func restoreInterruptedMerge(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
        hasCommitBeenMerged, err := hasPullRequestCommitBeenMerged(ctx, pr)
        ...
        if !hasCommitBeenMerged {
                return false, nil
        }
        return MarkAsMerged(ctx, pr, pr.MergedCommitID, pr.MergedUnix, pr.Merger, pr.Status)
}

hasPullRequestCommitBeenMerged 用 git.IsCommitInBranch(MergedCommitID, BaseBranch) 验证提交确实在 base 分支里------只信事实不信记录:push 没成功就不恢复,PR 保持 open 可重新合并。

MarkAsMerged 自身的注释完整描述了这套四步协议:

go 复制代码
// To make sure the pull request is marked as merged correctly, the caller uses multiple-stage operations:
//  1. Create a temp repo from base, merge the head into the temp repo, and get the merged commit ID and timestamp,
//  2. The merged commit ID and related information are stored into pull request
//  3. Push the merged commit to the base repo
//  4. Call MarkAsMerged to mark the pull request as merged and do post-processing (notification, close issues, etc)
//
// If failure occurs in step 1/2/3: the pull request is still open, the base repo is not changed, the doer can start a new merge.
// If failure occurs in step 4: the pull request can be marked as merged by the merged commit ID stored in it later.

8.7.6 手动合并的侦测:在 Gitea 之外完成的合并

两种路径把"绕过 Gitea 直接 push 合并"的 PR 收编为 merged:

路径一:用户主动标记 (MergedManually,手动合并按钮)。它先校验 MergeStyleManuallyMerged 是否被仓库允许,然后要求用户给出一个 commit ref,并用两道校验确认它真与 PR 相关:

go 复制代码
commit, err := baseGitRepo.GetCommit(ctx, mergeCommitRef)
...
ok, err := git.IsCommitInBranch(ctx, baseGitRepo, mergeCommitRef, pr.BaseBranch)
if !ok {
        return util.NewInvalidArgumentErrorf("commit not found in base branch")
}
_, err = MarkAsMerged(ctx, pr, mergeCommitRef, timeutil.TimeStamp(commit.Author.When.Unix()), doer, issues_model.PullRequestStatusManuallyMerged)

路径二:自动侦测 (manuallyMergedByPushedCommit,每次冲突检查的第一段)。它受 base 仓库 PR 单元配置 AutodetectManualMerge 开关控制,核心是 getMergeCommit:

go 复制代码
// Check if the pull request is merged into BaseBranch
cmd := gitcmd.NewCommand("merge-base", "--is-ancestor").AddDynamicArguments(prHeadRef, pr.BaseBranch)
if err := cmd.WithRepo(pr.BaseRepo).RunWithStderr(ctx); err != nil {
        if gitcmd.IsErrorExitCode(err, 1) {
                // prHeadRef is not an ancestor of the base branch
                return nil, nil //nolint:nilnil // return nil to indicate that the PR head is not merged
        }
        // Errors are signaled by a non-zero status that is not 1
        return nil, fmt.Errorf("%-v git merge-base --is-ancestor: %w", pr, err)
}

其选择逻辑是:merge-base --is-ancestor 的退出码 1 表示 head 尚不是 base 祖先(正常结果,非错误,其他非零才算失败);确认已合并后执行 rev-list --ancestry-path --merges --reverse <head>..<base> 取输出首行(--reverse 保证一次 push 合并多个 PR 时拿到最老的、即引入该 PR 的 merge commit),输出不足完整 SHA 长度则说明是纯 fast-forward,改用 head commit 本身。merger 的确定也有兜底链:getMergerForManuallyMergedPullRequest 先取 base 分支记录的 Pusher(LoadPusher 对不存在的用户返回 ghost),拿不到再退回 base 仓库 owner。

自动侦测成功后 PR 进入 PullRequestStatusManuallyMerged 终态,与 MergedManually 路径汇合于同一个 MarkAsMerged------这也解释了 8.1.1 状态表里 ManuallyMerged=4 为何是一个"终态"而不是中间态。

8.7.7 终态落库:MarkAsMerged 的乐观锁

MarkAsMerged 用条件更新做并发保护:

go 复制代码
// We need to save all of the data used to compute this merge as it may have already been changed by checkPullRequestBranchMergeable. FIXME: need to set some state to prevent checkPullRequestBranchMergeable from running whilst we are merging.
if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
        And("has_merged = ?", false).
        Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").
        Update(pr); err != nil {
        return false, fmt.Errorf("failed to update pr[%d]: %w", pr.ID, err)
} else if cnt != 1 {
        return false, issues_model.ErrIssueAlreadyChanged
}

has_merged = false 作为 WHERE 条件,cnt != 1 即返回 ErrIssueAlreadyChanged------两个并发合并请求只有一个能成功。事务内还完成三件事:删除 scheduled auto merge 记录(比对 MergedCommitID 相同才认定 wasAutoMerged)、SetIssueAsClosed 关闭 Issue、清空 ConflictedFiles。事务外的 markAsMergedPostProcess 则按 wasAutoMerged 分派 notify_service.AutoMergePullRequest 或 MergePullRequest,清 base 分支的 commit 计数缓存,并由 handleCloseCrossReferences 执行 PR 正文里 close #123/reopen #123 的交叉引用动作------ErrDependenciesLeft 被显式容忍(有依赖未满足的 issue 关不掉,但不能让整个合并失败)。该函数失败只记日志不回滚,注释写明理由:"the merge has succeeded, so any other errors are not critical (the merge can't be undone)"。

8.8 Update branch 与变更 target branch

8.6.2 的 MergeBlockedByOutdatedBranch(head 落后 base)给出的出路就是 "Update branch" 按钮;8.6.1 里"落后"判定本身还依赖 base branch 的选择,于是又有"变更目标分支"。这两个操作都在 services/pull/ 中,且与合并共用同一个全局锁。

8.8.1 Update:把 base 合进 head

update.go 的 Update 是 "update branch" 的实现,三个要点:

go 复制代码
func Update(pr *issues_model.PullRequest, doer *user_model.User, message string, rebase bool) error {
        ctx := graceful.GetManager().HammerContext() // don't abort the git operation even if the user's request is canceled
        if pr.Flow == issues_model.PullRequestFlowAGit {
                // TODO: update of agit flow pull request's head branch is unsupported
                return errors.New("update of agit flow pull request's head branch is unsupported")
        }

        releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
        ...
        // TODO: FakePR: if the PR is a fake PR (for example: from Merge Upstream), then no need to check diverging
        if pr.ID > 0 {
                diffCount, err := git.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
                ...
                } else if diffCount.Behind == 0 {
                        return fmt.Errorf("HeadBranch of PR %d is up to date", pr.Index)
                }
        }
        ...
  1. AGit 流程(PullRequestFlowAGit)不支持 update------AGit 的"head 分支"是 refs/for/xxx 这类评审引用,没有可被合并推进的分支实体。
  2. Behind == 0 直接报 "up to date",操作幂等。
  3. 没有选择新的实现,而是复用合并机器------构造一个 head/base 完全颠倒的假 PR:
go 复制代码
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
// now use a fake reverse PR to switch head&base repos/branches
reversePR := &issues_model.PullRequest{
        ID: pr.ID,

        HeadRepoID: pr.BaseRepoID,
        HeadRepo:   pr.BaseRepo,
        HeadBranch: pr.BaseBranch,

        BaseRepoID: pr.HeadRepoID,
        BaseRepo:   pr.HeadRepo,
        BaseBranch: pr.HeadBranch,
}

_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, message, mergeAndPushOptions{})
// TODO: the "update" (merge target branch to PR head branch) operation has finished, there could still be some edge cases:
// * the database was already out of sync: the target branch was already in head branch:
//   * so no post-receive hook is really executed, no PR status update
//   * then the PR status is stuck in "behind the target branch" (a new push can be used as a workaround)
// * "merge" operation does finish, but the post-receive hook isn't correctly executed due to other reasons:
//   * although the target branch is merged into head branch by this "update" (head branch receives new commits)
//   * but database isn't updated, so the PR status is still "behind the target branch"
return err

reversePR 复用 doMergeAndPush:临时仓库、四样式分派、push 全部照搬,只是 push 方向变成 base分支 → head仓库的head分支。TODO 注释坦承这是 hack(理想状态应把 merge 函数从 PullRequest 解耦),并记录了两个已知边界:数据库与 git 不同步导致 post-receive hook 不触发时,PR 状态会卡在"behind"(新 push 是 workaround)。rebase=true 分支走 updateHeadByRebaseOnToBase(services/pull/update_rebase.go:复用 createTemporaryRepoForMerge 与 rebaseTrackingOnToBase,LFS push 后 git push -f head_repo staging:refs/heads/<head>,同样翻译 ErrPushOutOfDate/ErrPushRejected),因此权限判定要求 forcePushAllowed。函数末尾 defer addTestPullRequestTaskAfterWebOperation(pr, doer) 保证无论成败都触发一轮 PR 任务刷新。

update 权限由 CheckUserAllowedToUpdate 的五步判定:

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

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

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

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

go 复制代码
// Current target branch is already the same
if pr.BaseBranch == targetBranch { return nil }
if pr.Issue.IsClosed { return issues_model.ErrIssueIsClosed{...} }
if pr.HasMerged { return ErrPullRequestHasMerged{...} }
...
// Check if pull request for the new target branch already exists
existingPr, err := issues_model.GetUnmergedPullRequest(ctx, pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch, issues_model.PullRequestFlowGithub)
if existingPr != nil { return issues_model.ErrPullRequestAlreadyExists{...} }
...
// Set new target branch
oldBranch := pr.BaseBranch
pr.BaseBranch = targetBranch

// Refresh patch
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil { return err }

// Update target branch, PR diff and status
// This is the same as markPullRequestAsMergeable in check service, but also updates base_branch
if pr.Status == issues_model.PullRequestStatusChecking {
        pr.Status = issues_model.PullRequestStatusMergeable
}

// add first push codes comment
return db.WithTx(ctx, func(ctx context.Context) error {
        // The UPDATE acquires the transaction lock, if the UPDATE succeeds, it should have updated one row (the "base_branch" is changed)
        // If no row is updated, it means the PR has been merged or closed in the meantime
        updated, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files", "base_branch")
        ...
        if updated == 0 {
                return util.ErrorWrap(util.ErrInvalidArgument, "pull request status has changed")
        }
        ...

除常规的"分支存在/未关闭/未合并/两分支不相等/不存在重复 PR"五项预检外,三段收尾动作各司其职:

go 复制代码
        if err := syncCommitDivergence(ctx, pr); err != nil { ... }

        // The "official" flag of existing reviews was computed against the previous
        // target branch's protection rules, so re-evaluate it against the new branch.
        // Otherwise a stale official approval could bypass the new branch's protection.
        pr.Issue.PullRequest = pr
        if err := issues_model.RecalculateReviewsOfficial(ctx, pr.Issue); err != nil { ... }

        // Create comment
        options := &issues_model.CreateCommentOptions{
                Type:   issues_model.CommentTypeChangeTargetBranch,
                ...
                OldRef: oldBranch,
                NewRef: targetBranch,
        }
        ...
        // Delete all old push comments and insert new push comments
        if _, err := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
                And("type = ?", issues_model.CommentTypePullRequestPush).
                NoAutoCondition().
                Delete(new(issues_model.Comment)); err != nil { ... }

        _, _, err = CreatePushPullComment(ctx, doer, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitHeadRefName(), false)
        return err
  • RecalculateReviewsOfficial :这是本章回收第 5 章 5.9.5 双人审核伏笔的第二个挂接点 (第一个在 8.10)。注释直白说明了安全动机------Official 标志是按旧 target branch 的保护规则算出来的,换分支后若不重算,一个在旧规则下算 official 的过期批准可能绕过新分支的保护。official 与否决定 GetGrantedApprovalsCount 是否计入,进而决定"双人审核"能否满足。
  • 删旧 push comment 再建新 :CommentTypePullRequestPush 记录的是 base 分支的 old..new 位置(8.3.4),目标分支一换这些位置全部失效,所以整体删除重建------而不是保留错误的历史。
  • 乐观锁边界 :UpdateColsIfNotMerged 返回 0 行说明 PR 在预检后被并发关闭/合并,返回 util.ErrInvalidArgument 包装的 "pull request status has changed"。注释写明 UPDATE 本身获取事务锁,成功更新一行即证明 base_branch 确实改变了。

8.9 自动合并:automerge 与 automergequeue

自动合并分两个包:services/automerge/(判定与调度)和 services/automergequeue/(队列 item 的定义)。

8.9.1 数据与队列 item:为什么要带 commitID

计划记录存在 pull_auto_merge 表(models/pull/automerge.go),字段包括 doer、merge_style、message、delete_branch_after_merge。触发入口是 ScheduleAutoMerge:

go 复制代码
// ScheduleAutoMerge if schedule is false and no error, pull can be merged directly
func ScheduleAutoMerge(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest, style repo_model.MergeStyle, message string, deleteBranchAfterMerge bool) (scheduled bool, err error) {
        err = db.WithTx(ctx, func(ctx context.Context) error {
                if err := pull_model.ScheduleAutoMerge(ctx, doer, pull.ID, style, message, deleteBranchAfterMerge); err != nil {
                        return err
                }
                _, err = issues_model.CreateAutoMergeComment(ctx, issues_model.CommentTypePRScheduledToAutoMerge, pull, doer)
                return err
        })
        // Old code made "scheduled" to be true after "ScheduleAutoMerge", but it's not right:
        // If the transaction rolls back, then the pull request is not scheduled to auto merge.
        // So we should only set "scheduled" to true if there is no error.
        scheduled = err == nil
        if scheduled {
                log.Trace(...)
                automergequeue.StartAutoMergeCheckByPullHead(ctx, pull)
        }
        return scheduled, err
}

scheduled = err == nil 这行注释记录了一个历史 bug:旧代码在事务提交前就置 scheduled=true,事务回滚后调用方误以为已排上自动合并。现在只有事务成功才触发队列。

队列侧的 item 设计是一段值得逐字读的注释:

go 复制代码
// AutoMergeItem is for the unique queue, so the item type can't be JSON which doesn't have deterministic key order.
// Since the queue is a unique queue, the item must contain commit ID, otherwise the new commit ID will be ignored.
type AutoMergeItem string

两个约束各有来历:unique queue 按 item 字符串去重,JSON 的字段顺序不确定会导致语义相同的 item 键不同,所以用手工拼的 "pr:<pullID>:<commitID>" 字符串;而 commitID 必须进键------若只用 pr:<id>,用户在排了自动合并后又推了新 commit,新事件的入队请求会被去重为"已在队列"而丢弃,自动合并永远盯着旧 head。带上 commitID 后,新 head 产生的是新 item,正常入队。

入队有两种方式:

go 复制代码
func StartAutoMergeCheckByPullCommit(pullID int64, commitID string) {
        AddToQueue(AutoMergeItem("pr:" + strconv.FormatInt(pullID, 10) + ":" + commitID))
}

func StartAutoMergeCheckByPullHead(ctx context.Context, pull *issues_model.PullRequest) {
        ...
        commitID, err := gitRepo.GetRefCommitID(ctx, pull.GetGitHeadRefName())
        ...
        StartAutoMergeCheckByPullCommit(pull.ID, commitID)
}

ByPullHead 从 refs/pull/N/head 现读 head------适合"刚刚发生了某件事"的场景(审批、状态检查、计划自动合并),此时队列不需要记住 commitID,直接查最新;ByPullCommit 接受调用方已知的 commitID。全仓库检索会发现 ByPullCommit 目前唯一的调用方就是 ByPullHead 本身------它的导出是给"已持有 commitID"的调用方预留的接口,与 item 必须带 commitID 的原因(去重键)是同一件事的两面。

8.9.2 判定循环:handlePullRequestAutoMerge

Init 注册 pr_auto_merge unique queue,启动时 populateRecentAutoMergeItems 把最近 24 小时 的计划重新入队(注释:"in case Gitea's restart aborted some scheduled auto-merge pull requests")。每个 item 经 handleAutoMergeItem 解析(strings.Split(item, ":"),必须三段且首段为 pr)后进入判定链:

go 复制代码
// handlePullRequestAutoMerge merge the pull request if all checks are successful
func handlePullRequestAutoMerge(ctx context.Context, pr *issues_model.PullRequest, expectedHeadCommitID string) error {
        _ = pr.LoadIssue(ctx)
        if (pr.Issue != nil && pr.Issue.IsClosed) || pr.HasMerged {
                // if the PR has been closed or merged, delete the automerge record and skip
                _, err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
                ...
                return nil
        }

        if !pr.IsStatusMergeable() || pr.IsWorkInProgress(ctx) {
                // quick check: if the PR can't be merged, just skip
                ...
        }
        ...
        headCommitID, err := baseGitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
        ...
        if headCommitID != expectedHeadCommitID {
                return errors.Join(errSkipAutoMerge, errors.New("head commit ID changed"))
        }
        ...

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

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

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

go 复制代码
        // although expectedHeadCommitID is checked before, we should pass it to the Merge function to
        // make it be checked again in case the head commit id changed after the previous check.
        if err := pull_service.Merge(ctx, pr.ID, doer, scheduledPRM.MergeStyle, expectedHeadCommitID, scheduledPRM.Message, true); err != nil {
                if pull_service.IsErrSHADoesNotMatch(err) {
                        return errors.Join(errSkipAutoMerge, err)
                }
                // FIXME: if merge failed, we should display some error message to the pull request page, or retry later.
                // The resolution is add a new column on automerge table named `error_message` to store the error message and displayed
                // on the pull request page. But this should not be finished in a bug fix PR which will be backport to release branch.
                return fmt.Errorf("failed to merge PR:%d: %w", pr.ID, err)
        }

Merge 内部的 createTemporaryRepoForMerge 会再次 校验 expectedHeadCommitID(8.7.2 的 ErrSHADoesNotMatch)------双重校验把"判定通过"与"实际合并"之间的时间窗压缩到锁内。ErrSHADoesNotMatch 被归为 skip(head 又变了,等新 item),其他错误作为真错误返回并留下 FIXME:自动合并失败没有 UI 反馈机制,作者提议给 pull_auto_merge 加 error_message 列但明确不该混进 backport 的 bugfix PR。合并成功后按计划与仓库默认配置决定是否 DeleteBranchAfterMerge,此后的错误只记日志------注释强调 "the PR has been merged, so no error should be returned after this point",避免调用方把成功当失败重试。

8.9.3 重新入队的触发点:notify 的三处挂接

判定是事件驱动的,services/automerge/notify.go 注册的 notifier 在三类事件后重新入队:

go 复制代码
func (n *automergeNotifier) PullRequestReview(ctx context.Context, pr *issues_model.PullRequest, review *issues_model.Review, ...) {
        // as a missing / blocking reviews could have blocked a pending automerge let's recheck
        if review.Type == issues_model.ReviewTypeApprove {
                automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
        }
}

func (n *automergeNotifier) PullReviewDismiss(ctx context.Context, ...) {
        // as reviews could have blocked a pending automerge let's recheck
        automergequeue.StartAutoMergeCheckByPullHead(ctx, review.Issue.PullRequest)
}

func (n *automergeNotifier) CreateCommitStatus(ctx context.Context, repo *repo_model.Repository, commit *repository.PushCommit, ...) {
        if !status.State.IsSuccess() { return }

        pulls, err := pull_service.GetMergeablePullRequestsByHeadCommitID(ctx, repo, commit.Sha1)
        ...
        for _, pr := range pulls {
                automergequeue.StartAutoMergeCheckByPullHead(ctx, pr)
        }
}
  • Approve 评审:此前审批不足挡住了自动合并,批准后重试;
  • 撤销评审(dismiss):方向相反------撤销可能使原本满足的条件失效,重检后判定链会正确地 skip;
  • commit status 变 Success :CI 是最后到位的信号,只在成功时重试(失败无需重试),并按 head commit 反查相关 PR(GetMergeablePullRequestsByHeadCommitID)。

再叠加 8.6.4 里 markPullRequestAsMergeable 落库时对 scheduled merge 的重入队,自动合并形成了一个完整的闭环:任何可能改变判定结果的事件都会重新触发一次判定 ,而判定链本身的七步短路保证了重复触发无副作用(unique queue 还会合并同时到达的重复请求)。push 到 head 分支这条路径不需要单独的挂接------它先触发 8.11.1 的 PR 任务与冲突检查,markPullRequestAsMergeable 落库时发现存在 scheduled merge,顺手调 StartAutoMergeCheckByPullHead(8.6.4 的摘录末尾即此调用,源码位于 services/pull/check.go:298),新 head 因而以新读取的 commitID 入队;启动时则由 populateRecentAutoMergeItems 兜底。

8.10 代码评审:审批如何变成一个数字

评审数据在 models/issues/review.go:一条 review 关联一条 review 类型的 comment,关键字段是 Official/CommitID/Stale/Dismissed 四个标志:

go 复制代码
// Official is a review made by an assigned approver (counts towards approval)
Official  bool   `xorm:"NOT NULL DEFAULT false"`
CommitID  string `xorm:"VARCHAR(64)"`
Stale     bool   `xorm:"NOT NULL DEFAULT false"`
Dismissed bool   `xorm:"NOT NULL DEFAULT false"`

ReviewType 从 ReviewTypePending = iota 起共五种:Pending(草稿评审)、Approve、Comment、Reject、Request。四个标志的语义互相独立:Official 决定是否计入审批数;CommitID 记录评审时的 head commit;Stale 表示 PR 内容已变更、评审过期;Dismissed 表示被管理员或规则撤销。

8.10.1 审批计数与双人审核(回收第 5 章伏笔)

8.1.3 已列过谓词,这里把计数公式钉死(models/issues/pull.go 的 GetGrantedApprovalsCount):

go 复制代码
// GetGrantedApprovalsCount returns the number of granted approvals for pr. A granted approval must be authored by a user in an approval whitelist.
func GetGrantedApprovalsCount(ctx context.Context, protectBranch *git_model.ProtectedBranch, pr *PullRequest) int64 {
        sess := db.GetEngine(ctx).Where("issue_id = ?", pr.IssueID).
                And("type = ?", ReviewTypeApprove).
                And("official = ?", true).
                And("dismissed = ?", false)
        if protectBranch.IgnoreStaleApprovals {
                sess = sess.And("stale = ?", false)
        }
        approvals, err := sess.Count(new(Review))
        ...
}

四个条件一一对应 8.10 开头的四个标志,其中 stale 的排除是可配置的 :保护规则字段 IgnoreStaleApprovals 为真时才排除 stale 批准(否则过期批准照算)。HasEnoughApprovals 在 RequiredApprovals == 0 时直接放行,否则比较计数------第 5 章 5.9.5 的"双人审核"即 ProtectedBranch.RequiredApprovals >= 2(models/git/protected_branch.go:59),落在 CheckPullBranchProtections 第二项(8.6.2)。同文件里与 8.6.2 七项检查一一对应的开关还有 BlockOnRejectedReviews、BlockOnOfficialReviewRequests、BlockOnCodeownerReviews、BlockOnOutdatedBranch------每个检查都先问自己的开关,开关关闭则该项不产生阻塞。

Official 不是静态属性,而是按目标分支的保护规则动态计算 的。RecalculateReviewsOfficial(models/issues/review.go:332)在事务内先清掉所有 approve/reject 的 official 标志,再按 IsUserOfficialReviewer 逐条重算,而后者(models/git/protected_branch.go:225)本身也只有两种答案:

go 复制代码
if !protectBranch.EnableApprovalsWhitelist {
        // Anyone with write access is considered official reviewer
        writeAccess, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeCode, perm.AccessModeWrite)
        ...
        return writeAccess, nil
}

if slices.Contains(protectBranch.ApprovalsWhitelistUserIDs, user.ID) {
        return true, nil
}
inTeam, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.ApprovalsWhitelistTeamIDs)

即:未开白名单时"有 code 写权限就算 official",开了白名单则精确到用户/团队 。这解释了为什么 Gitea 的默认配置下任意 maintainer 的批准都计入,而白名单模式下非名单成员的批准只是摆设。RecalculateReviewsOfficial 有两个触发点构成闭环:创建/提交评审时计算一次,变更 target branch 时全量重算(8.8.2 的那处调用)------两处缺一,都会让 official 标志与它所依据的规则脱钩。

CreateReview 还有一条去重规则:同一用户提交新的 Approve/Reject 时,先 dismiss 掉此人旧的同类评审;DismissApprovalReviews(services/pull/review.go:360)则在规则要求时批量撤销全部批准(8.11.1 的 DismissStaleApprovals 调它)。

8.10.2 提交评审与 stale 判定

SubmitReview(services/pull/review.go:303)在落库前计算 stale,逻辑比"比对 commit"更细:

go 复制代码
var stale bool
if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject {
        stale = false
} else {
        if issue.IsClosed {
                return nil, nil, ErrSubmitReviewOnClosedPR
        }

        headCommitID, err := gitRepo.GetRefCommitID(ctx, pr.GetGitHeadRefName())
        ...
        if headCommitID == commitID {
                stale = false
        } else {
                stale, _, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
                ...
        }
}

三段合起来的语义:只有 Approve/Reject 这两种"表态"才谈得上过期 ------Comment 类评审没有批准效力,无需 stale;已关闭 PR 上的批准被 ErrSubmitReviewOnClosedPR 直接拒绝;commit 不同才调 checkIfPRContentChanged,即 8.11.1 将详述的 git diff --name-only -z new old mergebase------判断"相对 merge base 的补丁是否变化"。这体现了一致性设计:stale 的语义是"评审所见的变更已被覆盖",判定只依赖补丁内容,不依赖 commit 数量 ,所以 rebase 掉又原样推回不会误标。随后 issues_model.SubmitReview 落库并 notify_service.PullRequestReview------这个通知正是 8.9.3 自动合并三处挂接的入口之一。

8.10.3 行内评论与 review 的存储

8.4.4/8.4.5 讲了评论如何存进 diff、如何挂回渲染;从评审视角还剩两点:行内评论可以挂在一个 pendingReviewID 下组成草稿评审(CreateCodeComment 的 pendingReview 参数),提交时并入同一条 review;createCodeComment 对 line > 0 的提议侧行用 lineBlame 验证目标行的 blame commit 是否仍是评论提交时记录的 CommitSHA(checkInvalidation),行已失效则进 Invalidated 状态------这正是 8.11.2 要展开的失效链的入口之一。

ReviewState(models/pull/review_state.go)另有一套 ViewedState,记录评审人对每个文件的"已查看"状态,与审批数无关,纯用于 UI 进度提示。

8.11 审核与合并的触发点

8.11.1 push 到 head 分支:AddTestPullRequestTask 的双向任务

services/repository/push.go 的 pushUpdateBranch 在分支推送后执行 go pull_service.AddTestPullRequestTask(...IsSync: true)。pull.go:393 的这个函数以"被推送的分支"为轴分两遍查 PR:

第一遍 (作为 head 分支):对每个 open PR 执行 PushToBaseRepo 刷新 refs/pull/N/head → CreatePushPullComment 记录 push comment → StartPullRequestCheckImmediately 立即入队冲突检查。

第二遍 (IsSync 为 true 时的同步段,也是最重的一段):

go 复制代码
if opts.IsSync {
        ...
        if invalidationErr := checkForInvalidation(ctx, headBranchPRs, opts.RepoID, opts.Doer, opts.Branch); ... 
        for _, pr := range headBranchPRs {
                ...
                if opts.NewCommitID != "" && opts.NewCommitID != objectFormat.EmptyObjectID().String() {
                        changed, newMergeBase, err := checkIfPRContentChanged(ctx, pr, opts.OldCommitID, opts.NewCommitID)
                        ...
                        if newMergeBase != "" && pr.MergeBase != newMergeBase {
                                pr.MergeBase = newMergeBase
                                if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base"); ... 
                        }
                        if changed {
                                // Mark old reviews as stale if diff to mergebase has changed
                                if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); ... 

                                // dismiss all approval reviews if protected branch rule item enabled.
                                pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
                                ...
                                if pb != nil && pb.DismissStaleApprovals {
                                        if err := DismissApprovalReviews(ctx, opts.Doer, pr); ... 
                                }
                        }
                        if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, opts.NewCommitID); ... 
                        if err = syncCommitDivergence(ctx, pr); ... 
                }

                if !pr.IsWorkInProgress(ctx) {
                        reviewNotifiers, err := issue_service.PullRequestCodeOwnersReview(ctx, pr)
                        ...
                }

                notify_service.PullRequestSynchronized(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID)
        }
}

次序即语义:先算 checkIfPRContentChanged(git diff --name-only -z new old mergebase 接 util.IsEmptyReader,输出非空 = 内容变了),变了才 把旧评审标 stale、才按 DismissStaleApprovals 规则撤销批准------rebase 后原样推回(补丁相同)不会误伤审批;无论变没变都对新 commitID 执行 MarkReviewsAsNotStale,让基于新 head 的评审恢复正常。同时更新 merge_base 与 ahead/behind。第三遍(作为 base 分支)对以该分支为目标的 PR syncCommitDivergence + StartPullRequestCheckDelayable(8.6.4)。

8.11.2 评论失效与评审 stale 的触发

8.11.1 里 checkForInvalidation → InvalidateCodeComments(services/pull/review.go)异步执行:对每个 PR 的行内评论,用 lineBlame 取该行当前的 blame commit,与评论记录的 CommitSHA 比对(checkInvalidation),行内容已被后续提交覆盖则标记评论 invalid。它与 MarkReviewsAsStale 是两层不同粒度:评论失效定位到行,评审 stale 定位到整个 PR 的补丁 ;push 时两者同时刷新,用户在 diff 页面看到的"评论已过期"角标与评审列表里的 stale 标记来自同一次 push 事件。pushDeleteBranch 则走 AdjustPullsCausedByBranchDeleted 处理 head 分支被删的善后。

8.11.3 commit status:合并判定的 CI 闸门(详见第 14 章)

services/pull/commit_status.go 提供三个函数供 8.6.2 第一项与 8.9.2 第 6 步共用:EffectiveRequiredContexts 汇总分支保护规则的 required contexts 与 scoped workflow 检查 (后者即使规则自身关闭状态检查也会生效------pullMergeBoxData.hasRequiredStatusContexts 的注释写明了这一点);MergeRequiredContextsCommitStatus 用 glob 匹配聚合各 context 状态,任一 required context 未匹配到则返回 Pending(宁可拦下不放行);IsPullCommitStatusPass 无 required contexts(pb == nil)时直接放行。commit status 的上报、聚合与 Gitea Actions 的关系是第 14 章的主题,此处只需记住:它在判定链里排第一,是自动化最先置否的一票。

8.12 全景图

8.12.1 PR 状态机

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) 等包装后由路由层翻译为用户提示。决策表按判定顺序排列(前项拦截则后项不执行):

顺序 判定条件 返回错误 路由层表现 触发修复动作
1 HasMerged ErrHasMerged 已合并提示 ---
2 IsClosed ErrIsClosed 已关闭提示 ---
3 无合并权限 ErrNoPermissionToMerge 权限不足 分支保护 merge whitelist
4 MergeStyleManually 直接放行 手动合并入口 ---
5 WIP 标题 ErrIsWorkInProgress "仍为 WIP" 去掉 WIP 前缀
6 status ∉ {Mergeable, Empty} ErrNotMergeableState "正在检查/有冲突" 8.5 冲突处理 / Update branch
7 status == Checking ErrIsChecking 排队等待 自动刷新
8 commit status 未过 ErrNotReadyToMerge: Not all required status checks successful CI 未通过 第 14 章
9 审批不足 ErrNotReadyToMerge: Does not have enough approvals 需 N 次批准 8.10 评审
10 存在 Reject ErrNotReadyToMerge: There are requested changes 有变更请求 撤销 reject
11 存在 official review request ErrNotReadyToMerge: There are official review requests 待处理评审请求 响应 request
12 head 落后 base ErrNotReadyToMerge: The head branch is behind the base branch 分支落后 8.8 Update branch
13 CodeOwners 未满足 ErrNotReadyToMerge: There are missing code owner reviews 缺少代码所有者评审 8.1.5 定向请求
14 变更受保护文件 ErrNotReadyToMerge: Changed protected files 保护文件变更 管理员介入 / forceMerge
15 forceMerge && CanBypassBranchProtection 使 8--14 的 ErrNotReadyToMerge 归零 管理员强制合并 第 5 章 BlockAdminMergeOverride
16 签名要求不满足 checkSigningRequirements / ErrHeadCommitsNotAllVerified 签名策略拦截 配置签名
17 依赖检查未过 ErrDependenciesLeft 依赖未完成 关联 issue
18 全部通过 nil 合并按钮可用 8.7 执行

决策表与代码的对应关系:第 8--14 项全部包在 util.ErrorWrap(ErrNotReadyToMerge, ...) 里(8.6.2),路由层只需 errors.Is 判断一个哨兵错误,再据内部文案分流;第 6/7 项对应 8.6.1 的状态前置检查;第 15 项即第 5 章伏笔 forceMerge 参数的落地------它不是独立检查,而是在第 8--14 项失败后的一次"赦免"尝试(8.6.1 摘录里 if forceMerge { ... errProtection = nil }),自动合并恒传 false(8.9.2),网页端的"强制合并"勾选传 true。表格里的每一行,都能在 8.6 的摘录中找到产生它的那一条 return。

至此,从 8.1 的一张表、8.2 的一次事务、8.4 的一次 diff 解析、8.5/8.6 的一次判定、8.7 的一次 push,到 8.9/8.10 的自动闭环与 8.11 的事件触发,Pull Request 的全生命周期闭合。第 9 章将进入 Issue 之上的另一条主线------通知与 Webhook 如何把这里的每一个事件扇出到站内、邮件与第三方系统。

相关推荐
TTATTC1 小时前
告别开源 Agent 的本地运维折磨:拆解阿里 QoderWork 的沙盒隔离架构与三级渐进式 Skills 编排实践
docker·开源
除不掉的灰色1 小时前
git回退提交前
git
武雄(小星Ai)1 小时前
Hermes Agent 周报:v0.21.6 发布,多账号连接器上线,6 项安全修复
开源·llm·周报·ai agent·hermes
认真恰饭1 小时前
Git push时警告邮箱暴露,推送失败
git
程序员老赵1 小时前
Docker 部署 Redmine:轻松搭建私有化项目管理平台
docker·容器·开源
银河麒麟操作系统3 小时前
中国操作系统创新技术成果亮相国际开源顶会
开源
A心有千千结4 小时前
通过 SSH 反向代理让服务器借用本机网络访问 GitHub 的最佳实践
linux·服务器·网络·git·ssh·github
SL_staff4 小时前
计息规则不再等IT:JVS-Rules函数计算器在金融计息场景的技术实践
java·git·全栈
CAD老兵4 小时前
中国 3D CAD 的突围之路:当 AI 开始挑战几何内核
人工智能·开源·github