From 3015856afcc034f18b70d272d239f954fcaedb2c Mon Sep 17 00:00:00 2001 From: Rob Gonnella Date: Thu, 21 May 2026 08:35:32 -0400 Subject: [PATCH] feat: adds option to force overwrite new branch for /contents route Adds an option "force_overwrite_new_branch" when posting to /repos/{owner}/{repo}/contents to modify multiple files in a repository. When user provides both "branch" and "new_branch" options, and "new_branch" already exists, the "force_overwrite_new_branch" option allows the user to overwrite the existing branch. Under the hood this amounts to a "git push --force". [Issue #12600](https://codeberg.org/forgejo/forgejo/issues/12600) --- .gitignore | 3 + modules/structs/repo_file.go | 2 + routers/api/v1/repo/file.go | 18 ++++- services/packages/cargo/index.go | 2 +- services/repository/files/cherry_pick.go | 2 +- services/repository/files/patch.go | 2 +- services/repository/files/temp_repo.go | 3 +- services/repository/files/update.go | 23 +++--- services/repository/files/upload.go | 2 +- templates/swagger/v1_json.tmpl | 20 +++++ tests/forgery/fs.go | 2 +- .../integration/api_repo_files_change_test.go | 76 +++++++++++++++++++ 12 files changed, 134 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index c524583ce8..4e50b137a8 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ prime/ # Manpage /man tests/integration/api_activitypub_person_inbox_useractivity_test.go + +# Mise version management +mise.toml diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 242343493b..90582e00ce 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -20,6 +20,8 @@ type FileOptions struct { Dates CommitDateOptions `json:"dates"` // Add a Signed-off-by trailer by the committer at the end of the commit log message. Signoff bool `json:"signoff"` + // (optional) will do a force-push if the new branch already exists + ForceOverwriteNewBranch bool `json:"force_overwrite_new_branch"` } // CreateFileOptions options for creating files diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 223cb80093..7d1baa29c9 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -533,7 +533,8 @@ func ChangeFiles(ctx *context.APIContext) { Author: apiOpts.Dates.Author, Committer: apiOpts.Dates.Committer, }, - Signoff: apiOpts.Signoff, + Signoff: apiOpts.Signoff, + ForceOverwriteNewBranch: apiOpts.ForceOverwriteNewBranch, } if opts.Dates.Author.IsZero() { opts.Dates.Author = time.Now() @@ -634,7 +635,8 @@ func CreateFile(ctx *context.APIContext) { Author: apiOpts.Dates.Author, Committer: apiOpts.Dates.Committer, }, - Signoff: apiOpts.Signoff, + Signoff: apiOpts.Signoff, + ForceOverwriteNewBranch: apiOpts.ForceOverwriteNewBranch, } if opts.Dates.Author.IsZero() { opts.Dates.Author = time.Now() @@ -741,7 +743,8 @@ func UpdateFile(ctx *context.APIContext) { Author: apiOpts.Dates.Author, Committer: apiOpts.Dates.Committer, }, - Signoff: apiOpts.Signoff, + Signoff: apiOpts.Signoff, + ForceOverwriteNewBranch: apiOpts.ForceOverwriteNewBranch, } if opts.Dates.Author.IsZero() { opts.Dates.Author = time.Now() @@ -767,6 +770,12 @@ func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) { ctx.Error(http.StatusForbidden, "Access", err) return } + if git.IsErrPushRejected(err) { + rejectErr := err.(*git.ErrPushRejected) + rejectErr.GenerateMessage() + ctx.Error(http.StatusForbidden, "PushRejected", rejectErr) + return + } if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHAOrCommitIDNotProvided(err) || @@ -910,7 +919,8 @@ func DeleteFile(ctx *context.APIContext) { Author: apiOpts.Dates.Author, Committer: apiOpts.Dates.Committer, }, - Signoff: apiOpts.Signoff, + Signoff: apiOpts.Signoff, + ForceOverwriteNewBranch: apiOpts.ForceOverwriteNewBranch, } if opts.Dates.Author.IsZero() { opts.Dates.Author = time.Now() diff --git a/services/packages/cargo/index.go b/services/packages/cargo/index.go index 9afcd79571..274c5857d8 100644 --- a/services/packages/cargo/index.go +++ b/services/packages/cargo/index.go @@ -302,7 +302,7 @@ func alterRepositoryContent(ctx context.Context, doer *user_model.User, repo *re return err } - return t.Push(doer, commitHash, repo.DefaultBranch) + return t.Push(doer, commitHash, repo.DefaultBranch, false) } func writeObjectToIndex(t *files_service.TemporaryUploadRepository, path string, r io.Reader) error { diff --git a/services/repository/files/cherry_pick.go b/services/repository/files/cherry_pick.go index 0e88a29230..d779b1b417 100644 --- a/services/repository/files/cherry_pick.go +++ b/services/repository/files/cherry_pick.go @@ -121,7 +121,7 @@ func CherryPick(ctx context.Context, repo *repo_model.Repository, doer *user_mod } // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + if err := t.Push(doer, commitHash, opts.NewBranch, false); err != nil { return nil, err } diff --git a/services/repository/files/patch.go b/services/repository/files/patch.go index 18b5226c02..c8fa5d0403 100644 --- a/services/repository/files/patch.go +++ b/services/repository/files/patch.go @@ -175,7 +175,7 @@ func ApplyDiffPatch(ctx context.Context, repo *repo_model.Repository, doer *user } // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + if err := t.Push(doer, commitHash, opts.NewBranch, false); err != nil { return nil, err } diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index d10689b1fc..bda8af385e 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -298,12 +298,13 @@ func (t *TemporaryUploadRepository) CommitTreeWithDate(parent string, author, co } // Push the provided commitHash to the repository branch by the provided user -func (t *TemporaryUploadRepository) Push(doer *user_model.User, commitHash, branch string) error { +func (t *TemporaryUploadRepository) Push(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(t.ctx, t.basePath, git.PushOptions{ Remote: t.repo.RepoPath(), Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch), + Force: force, Env: env, }); err != nil { if git.IsErrPushOutOfDate(err) { diff --git a/services/repository/files/update.go b/services/repository/files/update.go index e97c487846..0c23ff5270 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -48,15 +48,16 @@ type ChangeRepoFile struct { // ChangeRepoFilesOptions holds the repository files update options type ChangeRepoFilesOptions struct { - LastCommitID string - OldBranch string - NewBranch string - Message string - Files []*ChangeRepoFile - Author *IdentityOptions - Committer *IdentityOptions - Dates *CommitDateOptions - Signoff bool + LastCommitID string + OldBranch string + NewBranch string + Message string + Files []*ChangeRepoFile + Author *IdentityOptions + Committer *IdentityOptions + Dates *CommitDateOptions + Signoff bool + ForceOverwriteNewBranch bool } type RepoFileOptions struct { @@ -136,7 +137,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use // If we aren't branching to a new branch, make sure user can commit to the given branch if opts.NewBranch != opts.OldBranch { existingBranch, err := gitRepo.GetBranch(opts.NewBranch) - if existingBranch != nil { + if existingBranch != nil && !opts.ForceOverwriteNewBranch { return nil, git_model.ErrBranchAlreadyExists{ BranchName: opts.NewBranch, } @@ -263,7 +264,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use } // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + if err := t.Push(doer, commitHash, opts.NewBranch, opts.ForceOverwriteNewBranch); err != nil { log.Error("%T %v", err, err) return nil, err } diff --git a/services/repository/files/upload.go b/services/repository/files/upload.go index 032cb107ad..77e4b38037 100644 --- a/services/repository/files/upload.go +++ b/services/repository/files/upload.go @@ -158,7 +158,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use } // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + if err := t.Push(doer, commitHash, opts.NewBranch, false); err != nil { return err } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3b04fd4146..9a220ad28d 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23800,6 +23800,11 @@ }, "x-go-name": "Files" }, + "force_overwrite_new_branch": { + "description": "(optional) will do a force-push if the new branch already exists", + "type": "boolean", + "x-go-name": "ForceOverwriteNewBranch" + }, "message": { "description": "message (optional) for the commit of this file. if not supplied, a default message will be used", "type": "string", @@ -24503,6 +24508,11 @@ "dates": { "$ref": "#/definitions/CommitDateOptions" }, + "force_overwrite_new_branch": { + "description": "(optional) will do a force-push if the new branch already exists", + "type": "boolean", + "x-go-name": "ForceOverwriteNewBranch" + }, "message": { "description": "message (optional) for the commit of this file. if not supplied, a default message will be used", "type": "string", @@ -25483,6 +25493,11 @@ "dates": { "$ref": "#/definitions/CommitDateOptions" }, + "force_overwrite_new_branch": { + "description": "(optional) will do a force-push if the new branch already exists", + "type": "boolean", + "x-go-name": "ForceOverwriteNewBranch" + }, "message": { "description": "message (optional) for the commit of this file. if not supplied, a default message will be used", "type": "string", @@ -30472,6 +30487,11 @@ "dates": { "$ref": "#/definitions/CommitDateOptions" }, + "force_overwrite_new_branch": { + "description": "(optional) will do a force-push if the new branch already exists", + "type": "boolean", + "x-go-name": "ForceOverwriteNewBranch" + }, "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", diff --git a/tests/forgery/fs.go b/tests/forgery/fs.go index f41b193702..010f2cd8f5 100644 --- a/tests/forgery/fs.go +++ b/tests/forgery/fs.go @@ -108,5 +108,5 @@ func initRepo(doer *user_model.User, repo *repo_model.Repository, format git.Obj return "", err } - return commitHash, t.Push(doer, commitHash, repo.DefaultBranch) + return commitHash, t.Push(doer, commitHash, repo.DefaultBranch, false) } diff --git a/tests/integration/api_repo_files_change_test.go b/tests/integration/api_repo_files_change_test.go index 341464f1de..b24539f29d 100644 --- a/tests/integration/api_repo_files_change_test.go +++ b/tests/integration/api_repo_files_change_test.go @@ -310,5 +310,81 @@ func TestAPIChangeFiles(t *testing.T) { req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions). AddTokenAuth(token4) MakeRequest(t, req, http.StatusForbidden) + + // Test fails creating files in a branch that already exists without force push + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.BranchName = repo1.DefaultBranch + changeFilesOptions.NewBranchName = "develop" + changeFilesOptions.ForceOverwriteNewBranch = 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) + MakeRequest(t, req, http.StatusUnprocessableEntity) + + // Test succeeds creating files in a branch that already exists with force push + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.BranchName = repo1.DefaultBranch + changeFilesOptions.NewBranchName = "develop" + changeFilesOptions.ForceOverwriteNewBranch = 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 files in a branch that already exists with force push and branch protection + protectionReq := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{ + RuleName: "develop", + BranchName: "develop", + EnablePush: true, + }).AddTokenAuth(token2) + MakeRequest(t, protectionReq, http.StatusCreated) + changeFilesOptions = getChangeFilesOptions() + changeFilesOptions.BranchName = repo1.DefaultBranch + changeFilesOptions.NewBranchName = "develop" + changeFilesOptions.ForceOverwriteNewBranch = 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) + MakeRequest(t, req, http.StatusForbidden) }) }