Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions models/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix()
}

baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch)
baseBranchName := opts.BaseRepo.GetDefaultPRBaseBranch(ctx)
baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseBranchName)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -555,7 +556,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
BranchDisplayName: branchDisplayName,
BranchName: branch.Name,
BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)),
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name),
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(ctx, opts.BaseRepo, branch.Name),
CommitTime: branch.CommitTime,
})
}
Expand Down
99 changes: 99 additions & 0 deletions models/repo/pull_request_default.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"context"
"fmt"
"strings"

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/util"
)

// ErrDefaultPRBaseBranchNotExist represents an error that branch with such name does not exist.
type ErrDefaultPRBaseBranchNotExist struct {
RepoID int64
BranchName string
}

// IsErrDefaultPRBaseBranchNotExist checks if an error is an ErrDefaultPRBaseBranchNotExist.
func IsErrDefaultPRBaseBranchNotExist(err error) bool {
_, ok := err.(ErrDefaultPRBaseBranchNotExist)
return ok
}

func (err ErrDefaultPRBaseBranchNotExist) Error() string {
return fmt.Sprintf("default PR base branch does not exist [repo_id: %d name: %s]", err.RepoID, err.BranchName)
}

func (err ErrDefaultPRBaseBranchNotExist) Unwrap() error {
return util.ErrNotExist
}

// GetDefaultPRBaseBranchSetting returns the configured base branch for new pull requests.
// It returns an empty string when unset or pull requests are disabled.
func (repo *Repository) GetDefaultPRBaseBranchSetting(ctx context.Context) string {
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
if err != nil {
return ""
}
cfg := prUnit.PullRequestsConfig()
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.DefaultPRBaseBranch)
}

// GetDefaultPRBaseBranch returns the preferred base branch for new pull requests.
// It falls back to the repository default branch when unset or invalid.
func (repo *Repository) GetDefaultPRBaseBranch(ctx context.Context) string {
preferred := repo.GetDefaultPRBaseBranchSetting(ctx)
if preferred != "" {
exists, err := isBranchNameExists(ctx, repo.ID, preferred)
if err == nil && exists {
return preferred
}
}
return repo.DefaultBranch
}

// ValidateDefaultPRBaseBranch checks whether a preferred base branch is valid.
func (repo *Repository) ValidateDefaultPRBaseBranch(ctx context.Context, branch string) error {
branch = strings.TrimSpace(branch)
if branch == "" {
return nil
}

exists, err := isBranchNameExists(ctx, repo.ID, branch)
if err != nil {
return err
}
if !exists {
return ErrDefaultPRBaseBranchNotExist{
RepoID: repo.ID,
BranchName: branch,
}
}
return nil
}

func isBranchNameExists(ctx context.Context, repoID int64, branchName string) (bool, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't there some similar functions?

Copy link
Author

@tototomate123 tototomate123 Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's git_model.IsBranchExist, but importing models/git from models/repo creates an import cycle. I kept a small local helper here to avoid that.

type branch struct {
IsDeleted bool `xorm:"is_deleted"`
}
var b branch
has, err := db.GetEngine(ctx).
Where("repo_id = ?", repoID).
And("name = ?", branchName).
Get(&b)
if err != nil {
return false, err
}
if !has {
return false, nil
}
return !b.IsDeleted, nil
}
35 changes: 35 additions & 0 deletions models/repo/pull_request_default_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
"testing"

"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"

"github.com/stretchr/testify/assert"
)

func TestDefaultPRBaseBranchSelection(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())

ctx := t.Context()
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1})

assert.Equal(t, repo.DefaultBranch, repo.GetDefaultPRBaseBranch(ctx))

repo.Units = nil
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
assert.NoError(t, err)
prConfig := prUnit.PullRequestsConfig()
prConfig.DefaultPRBaseBranch = "branch2"
prUnit.Config = prConfig
assert.NoError(t, UpdateRepoUnit(ctx, prUnit))
repo.Units = nil
assert.Equal(t, "branch2", repo.GetDefaultPRBaseBranch(ctx))

err = repo.ValidateDefaultPRBaseBranch(ctx, "does-not-exist")
assert.True(t, IsErrDefaultPRBaseBranchNotExist(err))
}
5 changes: 3 additions & 2 deletions models/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,16 +613,17 @@ func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) strin
return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID))
}

func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, branchName string) string {
func (repo *Repository) ComposeBranchCompareURL(ctx context.Context, baseRepo *Repository, branchName string) string {
if baseRepo == nil {
baseRepo = repo
}
baseBranch := baseRepo.GetDefaultPRBaseBranch(ctx)
var cmpBranchEscaped string
if repo.ID != baseRepo.ID {
cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name))
}
cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName))
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseRepo.DefaultBranch), cmpBranchEscaped)
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped)
}

// IsOwnedBy returns true when user owns this repository
Expand Down
1 change: 1 addition & 0 deletions models/repo/repo_unit.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ type PullRequestsConfig struct {
DefaultDeleteBranchAfterMerge bool
DefaultMergeStyle MergeStyle
DefaultAllowMaintainerEdit bool
DefaultPRBaseBranch string
}

// FromDB fills up a PullRequestsConfig from serialized format.
Expand Down
11 changes: 11 additions & 0 deletions modules/git/gitcmd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,17 @@ func (c *Command) closePipeFiles(files []*os.File) {
}

func (c *Command) Wait() error {
done := make(chan struct{})
defer close(done)
if c.cmd != nil && c.cmd.Process != nil && c.cmdCtx != nil {
go func() {
select {
case <-c.cmdCtx.Done():
_ = c.cmd.Process.Kill()
case <-done:
}
}()
}
defer func() {
// The reader in another goroutine might be still reading the stdout, so we shouldn't close the pipes here
// MakeStdoutPipe returns a closer function to force callers to close the pipe correctly
Expand Down
13 changes: 9 additions & 4 deletions modules/git/gitcmd/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,15 @@ func TestRunWithContextTimeout(t *testing.T) {
require.NoError(t, err)
})
t.Run("WithTimeout", func(t *testing.T) {
cmd := NewCommand("hash-object", "--stdin")
_, _, pipeClose := cmd.MakeStdinStdoutPipe()
defer pipeClose()
err := cmd.WithTimeout(1 * time.Millisecond).Run(t.Context())
cmd := NewCommand("cat-file", "--batch")
stdin, stdinClose := cmd.MakeStdinPipe()
defer stdinClose()
time.AfterFunc(200*time.Millisecond, func() { _ = stdin.Close() })
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
start := time.Now()
err := cmd.WithTimeout(50 * time.Millisecond).Run(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.Less(t, time.Since(start), time.Second)
})
}
41 changes: 21 additions & 20 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,27 @@ type Repository struct {
Fork bool `json:"fork"`
Template bool `json:"template"`
// the original repository if this repository is a fork, otherwise null
Parent *Repository `json:"parent,omitempty"`
Mirror bool `json:"mirror"`
Size int `json:"size"`
Language string `json:"language"`
LanguagesURL string `json:"languages_url"`
HTMLURL string `json:"html_url"`
URL string `json:"url"`
Link string `json:"link"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"`
Website string `json:"website"`
Stars int `json:"stars_count"`
Forks int `json:"forks_count"`
Watchers int `json:"watchers_count"`
OpenIssues int `json:"open_issues_count"`
OpenPulls int `json:"open_pr_counter"`
Releases int `json:"release_counter"`
DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"`
Parent *Repository `json:"parent,omitempty"`
Mirror bool `json:"mirror"`
Size int `json:"size"`
Language string `json:"language"`
LanguagesURL string `json:"languages_url"`
HTMLURL string `json:"html_url"`
URL string `json:"url"`
Link string `json:"link"`
SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"`
Website string `json:"website"`
Stars int `json:"stars_count"`
Forks int `json:"forks_count"`
Watchers int `json:"watchers_count"`
OpenIssues int `json:"open_issues_count"`
OpenPulls int `json:"open_pr_counter"`
Releases int `json:"release_counter"`
DefaultBranch string `json:"default_branch"`
DefaultPRBaseBranch string `json:"default_pr_base_branch,omitempty"`
Archived bool `json:"archived"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Expand Down
4 changes: 4 additions & 0 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,10 @@
"repo.settings.pulls.ignore_whitespace": "Ignore Whitespace for Conflicts",
"repo.settings.pulls.enable_autodetect_manual_merge": "Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)",
"repo.settings.pulls.allow_rebase_update": "Enable updating pull request branch by rebase",
"repo.settings.pulls.default_pr_base_branch": "Default PR base branch",
"repo.settings.pulls.default_pr_base_branch_desc": "Preselect this branch as the base when creating a new pull request. Leave empty to use the repository default branch.",
"repo.settings.pulls.default_pr_base_branch_default": "Use repository default branch (%s)",
"repo.settings.pulls.default_pr_base_branch_invalid": "Default PR base branch does not exist.",
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",
"repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default",
"repo.settings.releases_desc": "Enable Repository Releases",
Expand Down
2 changes: 1 addition & 1 deletion routers/web/repo/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
}

// 4 get base and head refs
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.DefaultBranch)
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetDefaultPRBaseBranch(ctx))
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API endpoint for comparing branches (routers/api/v1/repo/pull.go) also uses a default base branch when none is specified, but it's not updated to use the configured default PR base branch. For consistency, consider whether the API compare endpoint should also respect the configured default PR base branch setting when no base branch is explicitly provided in the request.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API compare handler intentionally defaults to DefaultBranch. Keeping it as-is avoids changing API semantics; opinions welcome.

headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)

baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
Expand Down
18 changes: 18 additions & 0 deletions routers/web/repo/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/modules/web"
repo_router "code.gitea.io/gitea/routers/web/repo"
actions_service "code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
Expand Down Expand Up @@ -88,6 +89,11 @@ func SettingsCtxData(ctx *context.Context) {
return
}
ctx.Data["PushMirrors"] = pushMirrors

repo_router.PrepareBranchList(ctx)
if ctx.Written() {
return
}
}

// Settings show a repository's settings page
Expand Down Expand Up @@ -554,6 +560,17 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
}
}

defaultPRBaseBranch := strings.TrimSpace(form.DefaultPRBaseBranch)
if err := repo.ValidateDefaultPRBaseBranch(ctx, defaultPRBaseBranch); err != nil {
if repo_model.IsErrDefaultPRBaseBranchNotExist(err) {
ctx.Flash.Error(ctx.Tr("repo.settings.pulls.default_pr_base_branch_invalid"))
ctx.Redirect(repo.Link() + "/settings")
return
}
ctx.ServerError("ValidateDefaultPRBaseBranch", err)
return
}

if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() {
if !validation.IsValidExternalURL(form.ExternalTrackerURL) {
ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error"))
Expand Down Expand Up @@ -622,6 +639,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge,
DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle),
DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit,
DefaultPRBaseBranch: defaultPRBaseBranch,
}))
} else if !unit_model.TypePullRequests.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests)
Expand Down
5 changes: 5 additions & 0 deletions services/context/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,11 @@ func RepoAssignment(ctx *Context) {
}
ctx.Data["CanCompareOrPull"] = canCompare
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
defaultPRBaseBranch := repo.GetDefaultPRBaseBranch(ctx)
if ctx.Repo.PullRequest.BaseRepo != nil {
defaultPRBaseBranch = ctx.Repo.PullRequest.BaseRepo.GetDefaultPRBaseBranch(ctx)
}
ctx.Data["DefaultPRBaseBranch"] = defaultPRBaseBranch

if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)
Expand Down
1 change: 1 addition & 0 deletions services/convert/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
DefaultMergeStyle: string(defaultMergeStyle),
DefaultAllowMaintainerEdit: defaultAllowMaintainerEdit,
DefaultPRBaseBranch: repo.GetDefaultPRBaseBranchSetting(ctx),
AvatarURL: repo.AvatarLink(ctx),
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
MirrorInterval: mirrorInterval,
Expand Down
1 change: 1 addition & 0 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ type RepoSettingForm struct {
PullsAllowRebaseUpdate bool
DefaultDeleteBranchAfterMerge bool
DefaultAllowMaintainerEdit bool
DefaultPRBaseBranch string `form:"default_pr_base_branch"`
EnableTimetracker bool
AllowOnlyContributorsToTrackTime bool
EnableIssueDependencies bool
Expand Down
6 changes: 6 additions & 0 deletions services/repository/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,12 @@ func DeleteBranchAfterMerge(ctx context.Context, doer *user_model.User, prID int
if exist {
return errFailedToDelete(util.ErrUnprocessableContent)
}
if pr.HeadRepoID == pr.BaseRepoID {
preferred := pr.BaseRepo.GetDefaultPRBaseBranchSetting(ctx)
if preferred != "" && pr.HeadBranch == preferred {
return errFailedToDelete(util.ErrPermissionDenied)
}
}

if err := CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, doer); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
Expand Down
Loading