diff --git a/services/context/repo.go b/services/context/repo.go index 4c31b07b347f6..67e7af46317c1 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -173,6 +173,16 @@ func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *r protectedBranch.Repo = targetRepo canPushWithProtection = protectedBranch.CanUserPush(ctx, doer) protectionRequireSigned = protectedBranch.RequireSignedCommits + // If branch-wide push is restricted, allow direct commit when the + // URL-derived tree path matches an unprotected file pattern. The + // pre-receive hook re-checks every path the commit actually touches + // (e.g. rename source and destination). + if !canPushWithProtection && ctx.Repo.TreePath != "" && protectedBranch.UnprotectedFilePatterns != "" { + globs := protectedBranch.GetUnprotectedFilePatterns() + if protectedBranch.IsUnprotectedFile(globs, ctx.Repo.TreePath) { + canPushWithProtection = true + } + } } targetGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, targetRepo) diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go index 4a06159690783..1b2476f43ed3a 100644 --- a/tests/integration/editor_test.go +++ b/tests/integration/editor_test.go @@ -98,6 +98,42 @@ func testEditorProtectedBranch(t *testing.T) { resp := testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/", map[string]string{"tree_path": "test-protected-branch.txt", "commit_choice": "direct"}) assert.Equal(t, http.StatusBadRequest, resp.Code) assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) + + // Change "master" branch to mark files under "docs/" as unprotected + req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ + "rule_name": "master", + "protected_file_patterns": "", + "unprotected_file_patterns": "docs/*.md", + "enable_push": "true", + }) + session.MakeRequest(t, req, http.StatusSeeOther) + flashMsg = session.GetCookieFlashMessage() + assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg) + + resp = testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/docs/new.md", map[string]string{"tree_path": "docs/new.md", "commit_choice": "direct"}) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Contains(t, resp.Body.String(), `"redirect":"/user2/repo1/src/branch/master/docs/new.md"`) + + // Form's destination (renamed.md) is decided by the pre-receive hook, not the controller. + resp = testEditorActionPostRequest(t, session, "/user2/repo1/_edit/master/docs/new.md", map[string]string{ + "content": "renamed via editor", + "commit_choice": "direct", + "tree_path": "docs/renamed.md", + }) + assert.Equal(t, http.StatusOK, resp.Code) + assert.Contains(t, resp.Body.String(), `"redirect":"/user2/repo1/src/branch/master/docs/renamed.md"`) + + // Protected source path: controller rejects up-front regardless of unprotected destination. + resp = testEditorActionPostRequest(t, session, "/user2/repo1/_edit/master/README.md", map[string]string{ + "commit_choice": "direct", + "tree_path": "docs/from-readme.md", + }) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) + + resp = testEditorActionPostRequest(t, session, "/user2/repo1/_delete/master/README.md", map[string]string{"commit_choice": "direct"}) + assert.Equal(t, http.StatusBadRequest, resp.Code) + assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) } func testEditorActionPostRequest(t *testing.T, session *TestSession, requestPath string, params map[string]string) *httptest.ResponseRecorder {