Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ __debug_bin*
# Visual Studio
/.vs/

# mise version managment tool
mise.toml

*.cgo1.go
*.cgo2.c
_cgo_defun.c
Expand Down Expand Up @@ -121,4 +124,3 @@ prime/
/AGENT.md
/CLAUDE.md
/llms.txt

19 changes: 19 additions & 0 deletions models/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,25 @@ func (err ErrBranchAlreadyExists) Unwrap() error {
return util.ErrAlreadyExist
}

// ErrBranchProtected represents an error that a branch cannot be force updated due to branch protections
type ErrBranchProtected struct {
BranchName string
}

// IsErrBranchAlreadyExists checks if an error is an ErrBranchAlreadyExists.
func IsErrBranchProtected(err error) bool {
_, ok := err.(ErrBranchProtected)
return ok
}

func (err ErrBranchProtected) Error() string {
return fmt.Sprintf("branch cannot be force updated due to branch protection rules [name: %s]", err.BranchName)
}

func (err ErrBranchProtected) Unwrap() error {
return util.ErrPermissionDenied
}

// ErrBranchNameConflict represents an error that branch name conflicts with other branch.
type ErrBranchNameConflict struct {
BranchName string
Expand Down
2 changes: 2 additions & 0 deletions modules/structs/repo_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type FileOptions struct {
BranchName string `json:"branch" binding:"GitRefName;MaxSize(100)"`
// new_branch (optional) will make a new branch from `branch` before creating the file
NewBranchName string `json:"new_branch" binding:"GitRefName;MaxSize(100)"`
// force (optional) will force update the new branch if it already exists
Force bool `json:"force"`
// `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"`
Expand Down
3 changes: 2 additions & 1 deletion routers/api/v1/repo/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) {
Message: commonOpts.Message,
OldBranch: commonOpts.BranchName,
NewBranch: commonOpts.NewBranchName,
Force: commonOpts.Force,
Committer: &files_service.IdentityOptions{
GitUserName: commonOpts.Committer.Name,
GitUserEmail: commonOpts.Committer.Email,
Expand Down Expand Up @@ -595,7 +596,7 @@ func handleChangeRepoFilesError(ctx *context.APIContext, err error) {
ctx.APIError(http.StatusForbidden, err)
return
}
if git_model.IsErrBranchAlreadyExists(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) ||
if git_model.IsErrBranchAlreadyExists(err) || git_model.IsErrBranchProtected(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) ||
files_service.IsErrFilePathInvalid(err) || files_service.IsErrRepoFileAlreadyExists(err) ||
files_service.IsErrCommitIDDoesNotMatch(err) || files_service.IsErrSHAOrCommitIDNotProvided(err) {
ctx.APIError(http.StatusUnprocessableEntity, err)
Expand Down
2 changes: 1 addition & 1 deletion services/packages/cargo/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion services/repository/files/cherry_pick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion services/repository/files/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion services/repository/files/temp_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,14 @@ 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
Expand Down
32 changes: 25 additions & 7 deletions services/repository/files/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type ChangeRepoFilesOptions struct {
Committer *IdentityOptions
Dates *CommitDateOptions
Signoff bool
Force bool
}

type RepoFileOptions struct {
Expand Down Expand Up @@ -168,19 +169,30 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
}

// A NewBranch can be specified for the file to be created/updated in a new branch.
// Check to make sure the branch does not already exist, otherwise we can't proceed.
// If we aren't branching to a new branch, make sure user can commit to the given branch
// Check to to see if the branch already exist. If it does, ensure Force option is set
// and VerifyBranchProtection passes
if opts.NewBranch != opts.OldBranch {
exist, err := git_model.IsBranchExist(ctx, repo.ID, opts.NewBranch)
if err != nil {
return nil, err
}

if exist {
return nil, git_model.ErrBranchAlreadyExists{
BranchName: opts.NewBranch,
if opts.Force {
// ensure branch can be force pushed
if err := VerifyBranchProtection(ctx, repo, doer, opts.NewBranch, treePaths, opts.Force); err != nil {
return nil, git_model.ErrBranchProtected{
BranchName: opts.NewBranch,
}
}
} else {
// 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 {
} else if err := VerifyBranchProtection(ctx, repo, doer, opts.OldBranch, treePaths, opts.Force); err != nil {
return nil, err
}

Expand Down Expand Up @@ -303,7 +315,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 {
if err := t.Push(ctx, doer, commitHash, opts.NewBranch, opts.Force); err != nil {
log.Error("%T %v", err, err)
return nil, err
}
Expand Down Expand Up @@ -685,7 +697,7 @@ func writeRepoObjectForRename(ctx context.Context, t *TemporaryUploadRepository,
}

// VerifyBranchProtection verify the branch protection for modifying the given treePath on the given branch
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string) error {
func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, branchName string, treePaths []string, force bool) error {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branchName)
if err != nil {
return err
Expand All @@ -695,6 +707,7 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do
globUnprotected := protectedBranch.GetUnprotectedFilePatterns()
globProtected := protectedBranch.GetProtectedFilePatterns()
canUserPush := protectedBranch.CanUserPush(ctx, doer)
canUserForcePush := protectedBranch.CanUserForcePush(ctx, doer)
for _, treePath := range treePaths {
isUnprotectedFile := false
if len(globUnprotected) != 0 {
Expand All @@ -705,6 +718,11 @@ func VerifyBranchProtection(ctx context.Context, repo *repo_model.Repository, do
UserName: doer.LowerName,
}
}
if force && !canUserForcePush && !isUnprotectedFile {
return ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
if protectedBranch.IsProtectedFile(globProtected, treePath) {
return pull_service.ErrFilePathProtected{
Path: treePath,
Expand Down
20 changes: 20 additions & 0 deletions templates/swagger/v1_json.tmpl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions tests/integration/api_repo_file_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,54 @@ func TestAPICreateFile(t *testing.T) {
assert.Equal(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
assert.Equal(t, createFileOptions.Message+"\n", fileResponse.Commit.Message)

// Test fails creating a file in a branch that already exists without force
createFileOptions = getCreateFileOptions()
createFileOptions.BranchName = repo1.DefaultBranch
createFileOptions.NewBranchName = "develop"
createFileOptions.Force = false
fileID++
treePath = fmt.Sprintf("new/file%d.txt", fileID)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusUnprocessableEntity)

// Test succeeds creating a file in a branch that already exists with force
createFileOptions = getCreateFileOptions()
createFileOptions.BranchName = repo1.DefaultBranch
createFileOptions.NewBranchName = "develop"
createFileOptions.Force = true
fileID++
treePath = fmt.Sprintf("new/file%d.txt", fileID)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusCreated)
DecodeJSON(t, resp, &fileResponse)
expectedHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/develop/new/file%d.txt", fileID)
expectedDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/develop/new/file%d.txt", fileID)
assert.Equal(t, expectedSHA, fileResponse.Content.SHA)
assert.Equal(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
assert.Equal(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
assert.Equal(t, createFileOptions.Message+"\n", fileResponse.Commit.Message)

// Test fails creating a file in a branch that already exists with force and branch protection enabled
createFileOptions = getCreateFileOptions()
createFileOptions.BranchName = repo1.DefaultBranch
createFileOptions.NewBranchName = "develop"
createFileOptions.Force = true
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)
fileID++
treePath = fmt.Sprintf("new/file%d.txt", fileID)
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusUnprocessableEntity)

// Test creating a file without a message
createFileOptions = getCreateFileOptions()
createFileOptions.Message = ""
Expand Down
48 changes: 48 additions & 0 deletions tests/integration/api_repo_file_delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,54 @@ func TestAPIDeleteFile(t *testing.T) {
assert.Nil(t, fileResponse.Content)
assert.Equal(t, deleteFileOptions.Message+"\n", fileResponse.Commit.Message)

// Test fails deleting a file in a branch that already exists without force
fileID++
treePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, treePath)
deleteFileOptions = getDeleteFileOptions()
deleteFileOptions.BranchName = repo1.DefaultBranch
deleteFileOptions.NewBranchName = "develop"
deleteFileOptions.Force = false
req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusUnprocessableEntity)

// Test succeeds deleting a file in a branch that already exists with force
fileID++
treePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, treePath)
deleteFileOptions = getDeleteFileOptions()
deleteFileOptions.BranchName = repo1.DefaultBranch
deleteFileOptions.NewBranchName = "develop"
deleteFileOptions.Force = true
req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
AddTokenAuth(token2)
resp = MakeRequest(t, req, http.StatusOK)
DecodeJSON(t, resp, &fileResponse)
assert.NotNil(t, fileResponse)
assert.Nil(t, fileResponse.Content)
assert.Equal(t, deleteFileOptions.Message+"\n", fileResponse.Commit.Message)

// Test fails creating a file in a branch that already exists with force and branch protection enabled
fileID++
treePath = fmt.Sprintf("delete/file%d.txt", fileID)
createFile(user2, repo1, treePath)
deleteFileOptions = getDeleteFileOptions()
deleteFileOptions.BranchName = repo1.DefaultBranch
deleteFileOptions.NewBranchName = "develop"
deleteFileOptions.Force = true
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)
req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
AddTokenAuth(token2)
MakeRequest(t, req, http.StatusUnprocessableEntity)

// Test deleting file without a message
fileID++
treePath = fmt.Sprintf("delete/file%d.txt", fileID)
Expand Down
Loading