1
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-10-09 23:33:30 +03:00

feat: adds option to force update new branch in contents routes (#35592)

Allows users to specify a "force" option in API /contents routes when
modifying files in a new branch. When "force" is true, and the branch
already exists, a force push will occur provided the branch does not
have a branch protection rule that disables force pushing.

This is useful as a way to manage a branch remotely through only the
API. For example in an automated release tool you can pull commits,
analyze, and update a release PR branch all remotely without needing to
clone or perform any local git operations.

Resolve #35538

---------

Co-authored-by: Rob Gonnella <rob.gonnella@papayapay.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Rob Gonnella
2025-10-07 00:23:14 -04:00
committed by GitHub
parent ad2ff67343
commit c9e7fde8b3
11 changed files with 160 additions and 47 deletions

4
.gitignore vendored
View File

@@ -25,6 +25,9 @@ __debug_bin*
# Visual Studio
/.vs/
# mise version managment tool
mise.toml
*.cgo1.go
*.cgo2.c
_cgo_defun.c
@@ -121,4 +124,3 @@ prime/
/AGENT.md
/CLAUDE.md
/llms.txt

View File

@@ -98,28 +98,31 @@ func (err *ErrPushRejected) Unwrap() error {
// GenerateMessage generates the remote message from the stderr
func (err *ErrPushRejected) GenerateMessage() {
messageBuilder := &strings.Builder{}
i := strings.Index(err.StdErr, "remote: ")
if i < 0 {
err.Message = ""
// The stderr is like this:
//
// > remote: error: push is rejected .....
// > To /work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git
// > ! [remote rejected] 44e67c77559211d21b630b902cdcc6ab9d4a4f51 -> develop (pre-receive hook declined)
// > error: failed to push some refs to '/work/gitea/tests/integration/gitea-integration-sqlite/gitea-repositories/user2/repo1.git'
//
// The local message contains sensitive information, so we only need the remote message
const prefixRemote = "remote: "
const prefixError = "error: "
pos := strings.Index(err.StdErr, prefixRemote)
if pos < 0 {
err.Message = "push is rejected"
return
}
for {
if len(err.StdErr) <= i+8 {
break
}
if err.StdErr[i:i+8] != "remote: " {
break
}
i += 8
nl := strings.IndexByte(err.StdErr[i:], '\n')
if nl >= 0 {
messageBuilder.WriteString(err.StdErr[i : i+nl+1])
i = i + nl + 1
} else {
messageBuilder.WriteString(err.StdErr[i:])
i = len(err.StdErr)
messageBuilder := &strings.Builder{}
lines := strings.SplitSeq(err.StdErr, "\n")
for line := range lines {
line, ok := strings.CutPrefix(line, prefixRemote)
if !ok {
continue
}
line = strings.TrimPrefix(line, prefixError)
messageBuilder.WriteString(strings.TrimSpace(line) + "\n")
}
err.Message = strings.TrimSpace(messageBuilder.String())
}

View File

@@ -8,12 +8,14 @@ import "time"
// FileOptions options for all file APIs
type FileOptions struct {
// message (optional) for the commit of this file. if not supplied, a default message will be used
// message (optional) is the commit message of the changes. If not supplied, a default message will be used
Message string `json:"message"`
// branch (optional) to base this file from. if not given, the default branch is used
// branch (optional) is the base branch for the changes. If not supplied, the default branch is used
BranchName string `json:"branch" binding:"GitRefName;MaxSize(100)"`
// new_branch (optional) will make a new branch from `branch` before creating the file
// new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch
NewBranchName string `json:"new_branch" binding:"GitRefName;MaxSize(100)"`
// force_push (optional) will do a force-push if the new branch already exists
ForcePush bool `json:"force_push"`
// `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)
Author Identity `json:"author"`
Committer Identity `json:"committer"`

View File

@@ -355,6 +355,7 @@ func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) {
Message: commonOpts.Message,
OldBranch: commonOpts.BranchName,
NewBranch: commonOpts.NewBranchName,
ForcePush: commonOpts.ForcePush,
Committer: &files_service.IdentityOptions{
GitUserName: commonOpts.Committer.Name,
GitUserEmail: commonOpts.Committer.Email,
@@ -591,6 +592,11 @@ func UpdateFile(ctx *context.APIContext) {
}
func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
if git.IsErrPushRejected(err) {
err := err.(*git.ErrPushRejected)
ctx.APIError(http.StatusForbidden, err.Message)
return
}
if files_service.IsErrUserCannotCommit(err) || pull_service.IsErrFilePathProtected(err) {
ctx.APIError(http.StatusForbidden, err)
return

View File

@@ -306,7 +306,7 @@ func alterRepositoryContent(ctx context.Context, doer *user_model.User, repo *re
return err
}
return t.Push(ctx, doer, commitHash, repo.DefaultBranch)
return t.Push(ctx, doer, commitHash, repo.DefaultBranch, false)
}
func writeObjectToIndex(ctx context.Context, t *files_service.TemporaryUploadRepository, path string, r io.Reader) error {

View File

@@ -131,7 +131,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
return nil, err
}

View File

@@ -201,7 +201,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, false); err != nil {
return nil, err
}

View File

@@ -354,20 +354,18 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit
}
// Push the provided commitHash to the repository branch by the provided user
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string) error {
func (t *TemporaryUploadRepository) Push(ctx context.Context, doer *user_model.User, commitHash, branch string, force bool) error {
// Because calls hooks we need to pass in the environment
env := repo_module.PushingEnvironment(doer, t.repo)
if err := git.Push(ctx, t.basePath, git.PushOptions{
Remote: t.repo.RepoPath(),
Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch),
Env: env,
Force: force,
}); err != nil {
if git.IsErrPushOutOfDate(err) {
return err
} else if git.IsErrPushRejected(err) {
rejectErr := err.(*git.ErrPushRejected)
log.Info("Unable to push back to repo from temporary repo due to rejection: %s (%s)\nStdout: %s\nStderr: %s\nError: %v",
t.repo.FullName(), t.basePath, rejectErr.StdOut, rejectErr.StdErr, rejectErr.Err)
return err
}
log.Error("Unable to push back to repo from temporary repo: %s (%s)\nError: %v",

View File

@@ -60,6 +60,7 @@ type ChangeRepoFilesOptions struct {
Committer *IdentityOptions
Dates *CommitDateOptions
Signoff bool
ForcePush bool
}
type RepoFileOptions struct {
@@ -176,8 +177,11 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
return nil, err
}
if exist {
return nil, git_model.ErrBranchAlreadyExists{
BranchName: opts.NewBranch,
if !opts.ForcePush {
// branch exists but force option not set
return nil, git_model.ErrBranchAlreadyExists{
BranchName: opts.NewBranch,
}
}
}
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths); err != nil {
@@ -303,8 +307,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
}
// Then push this tree to NewBranch
if err := t.Push(ctx, doer, commitHash, opts.NewBranch); err != nil {
log.Error("%T %v", err, err)
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, opts.ForcePush); err != nil {
return nil, err
}

View File

@@ -21995,7 +21995,7 @@
"$ref": "#/definitions/Identity"
},
"branch": {
"description": "branch (optional) to base this file from. if not given, the default branch is used",
"description": "branch (optional) is the base branch for the changes. If not supplied, the default branch is used",
"type": "string",
"x-go-name": "BranchName"
},
@@ -22013,13 +22013,18 @@
},
"x-go-name": "Files"
},
"force_push": {
"description": "force_push (optional) will do a force-push if the new branch already exists",
"type": "boolean",
"x-go-name": "ForcePush"
},
"message": {
"description": "message (optional) for the commit of this file. if not supplied, a default message will be used",
"description": "message (optional) is the commit message of the changes. If not supplied, a default message will be used",
"type": "string",
"x-go-name": "Message"
},
"new_branch": {
"description": "new_branch (optional) will make a new branch from `branch` before creating the file",
"description": "new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch",
"type": "string",
"x-go-name": "NewBranchName"
},
@@ -22834,7 +22839,7 @@
"$ref": "#/definitions/Identity"
},
"branch": {
"description": "branch (optional) to base this file from. if not given, the default branch is used",
"description": "branch (optional) is the base branch for the changes. If not supplied, the default branch is used",
"type": "string",
"x-go-name": "BranchName"
},
@@ -22849,13 +22854,18 @@
"dates": {
"$ref": "#/definitions/CommitDateOptions"
},
"force_push": {
"description": "force_push (optional) will do a force-push if the new branch already exists",
"type": "boolean",
"x-go-name": "ForcePush"
},
"message": {
"description": "message (optional) for the commit of this file. if not supplied, a default message will be used",
"description": "message (optional) is the commit message of the changes. If not supplied, a default message will be used",
"type": "string",
"x-go-name": "Message"
},
"new_branch": {
"description": "new_branch (optional) will make a new branch from `branch` before creating the file",
"description": "new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch",
"type": "string",
"x-go-name": "NewBranchName"
},
@@ -23847,7 +23857,7 @@
"$ref": "#/definitions/Identity"
},
"branch": {
"description": "branch (optional) to base this file from. if not given, the default branch is used",
"description": "branch (optional) is the base branch for the changes. If not supplied, the default branch is used",
"type": "string",
"x-go-name": "BranchName"
},
@@ -23857,13 +23867,18 @@
"dates": {
"$ref": "#/definitions/CommitDateOptions"
},
"force_push": {
"description": "force_push (optional) will do a force-push if the new branch already exists",
"type": "boolean",
"x-go-name": "ForcePush"
},
"message": {
"description": "message (optional) for the commit of this file. if not supplied, a default message will be used",
"description": "message (optional) is the commit message of the changes. If not supplied, a default message will be used",
"type": "string",
"x-go-name": "Message"
},
"new_branch": {
"description": "new_branch (optional) will make a new branch from `branch` before creating the file",
"description": "new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch",
"type": "string",
"x-go-name": "NewBranchName"
},
@@ -28639,7 +28654,7 @@
"$ref": "#/definitions/Identity"
},
"branch": {
"description": "branch (optional) to base this file from. if not given, the default branch is used",
"description": "branch (optional) is the base branch for the changes. If not supplied, the default branch is used",
"type": "string",
"x-go-name": "BranchName"
},
@@ -28654,18 +28669,23 @@
"dates": {
"$ref": "#/definitions/CommitDateOptions"
},
"force_push": {
"description": "force_push (optional) will do a force-push if the new branch already exists",
"type": "boolean",
"x-go-name": "ForcePush"
},
"from_path": {
"description": "from_path (optional) is the path of the original file which will be moved/renamed to the path in the URL",
"type": "string",
"x-go-name": "FromPath"
},
"message": {
"description": "message (optional) for the commit of this file. if not supplied, a default message will be used",
"description": "message (optional) is the commit message of the changes. If not supplied, a default message will be used",
"type": "string",
"x-go-name": "Message"
},
"new_branch": {
"description": "new_branch (optional) will make a new branch from `branch` before creating the file",
"description": "new_branch (optional) will make a new branch from base branch for the changes. If not supplied, the changes will be committed to the base branch",
"type": "string",
"x-go-name": "NewBranchName"
},

View File

@@ -161,9 +161,88 @@ func TestAPIChangeFiles(t *testing.T) {
assert.Equal(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
assert.Equal(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
assert.Nil(t, filesResponse.Files[2])
assert.Equal(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
// Test fails creating a file in a branch that already exists without force
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = false
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
assert.Contains(t, resp.Body.String(), `"message":"branch already exists [name: develop]"`)
// Test succeeds creating a file in a branch that already exists with force
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = true
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &filesResponse)
expectedCreateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/develop/new/file%d.txt", fileID)
expectedCreateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/develop/new/file%d.txt", fileID)
expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/develop/update/file%d.txt", fileID)
expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/develop/update/file%d.txt", fileID)
assert.Equal(t, expectedCreateSHA, filesResponse.Files[0].SHA)
assert.Equal(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL)
assert.Equal(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL)
assert.Equal(t, expectedUpdateSHA, filesResponse.Files[1].SHA)
assert.Equal(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
assert.Equal(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
assert.Nil(t, filesResponse.Files[2])
assert.Equal(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
// Test fails creating a file in a branch that already exists with force and branch protection enabled
protectionReq := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{
RuleName: "develop",
BranchName: "develop",
Priority: 1,
EnablePush: true,
EnableForcePush: false,
}).AddTokenAuth(token2)
MakeRequest(t, protectionReq, http.StatusCreated)
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch
changeFilesOptions.NewBranchName = "develop"
changeFilesOptions.ForcePush = true
fileID++
createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
changeFilesOptions.Files[0].Path = createTreePath
changeFilesOptions.Files[1].Path = updateTreePath
changeFilesOptions.Files[2].Path = deleteTreePath
createFile(user2, repo1, updateTreePath)
createFile(user2, repo1, deleteTreePath)
url = fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusForbidden)
assert.Contains(t, resp.Body.String(), `"message":"branch develop is protected from force push"`)
// Test updating a file and renaming it
changeFilesOptions = getChangeFilesOptions()
changeFilesOptions.BranchName = repo1.DefaultBranch