diff --git a/models/repo/repo.go b/models/repo/repo.go index 819356dfad..605a9e0f3f 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -869,16 +869,6 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos return repos, db.GetEngine(ctx).In("id", ids).Find(&repos) } -// IsRepositoryModelOrDirExist returns true if the repository with given name under user has already existed. -func IsRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { - has, err := IsRepositoryModelExist(ctx, u, repoName) - if err != nil { - return false, err - } - isDir, err := util.IsDir(RepoPath(u.Name, repoName)) - return has || isDir, err -} - func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { return db.GetEngine(ctx).Get(&Repository{ OwnerID: u.ID, diff --git a/models/repo/update.go b/models/repo/update.go index 3228ae11a4..bf560cf695 100644 --- a/models/repo/update.go +++ b/models/repo/update.go @@ -9,8 +9,6 @@ import ( "time" "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) @@ -106,35 +104,6 @@ func (err ErrRepoFilesAlreadyExist) Unwrap() error { return util.ErrAlreadyExist } -// CheckCreateRepository check if doer could create a repository in new owner -func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error { - if !doer.CanCreateRepoIn(owner) { - return ErrReachLimitOfRepo{owner.MaxRepoCreation} - } - - if err := IsUsableRepoName(name); err != nil { - return err - } - - has, err := IsRepositoryModelOrDirExist(ctx, owner, name) - if err != nil { - return fmt.Errorf("IsRepositoryExist: %w", err) - } else if has { - return ErrRepoAlreadyExist{owner.Name, name} - } - - repoPath := RepoPath(owner.Name, name) - isExist, err := util.IsExist(repoPath) - if err != nil { - log.Error("Unable to check if %s exists. Error: %v", repoPath, err) - return err - } - if !overwriteOrAdopt && isExist { - return ErrRepoFilesAlreadyExist{owner.Name, name} - } - return nil -} - // UpdateRepoSize updates the repository size, calculating it using getDirectorySize func UpdateRepoSize(ctx context.Context, repoID, gitSize, lfsSize int64) error { _, err := db.GetEngine(ctx).ID(repoID).Cols("size", "git_size", "lfs_size").NoAutoTime().Update(&Repository{ diff --git a/modules/git/repo.go b/modules/git/repo.go index 7e86b10de9..88acbd30e6 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -186,18 +186,21 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error { // PushOptions options when push to remote type PushOptions struct { - Remote string - Branch string - Force bool - Mirror bool - Env []string - Timeout time.Duration + Remote string + Branch string + Force bool + ForceWithLease string + Mirror bool + Env []string + Timeout time.Duration } // Push pushs local commits to given remote branch. func Push(ctx context.Context, repoPath string, opts PushOptions) error { cmd := gitcmd.NewCommand("push") - if opts.Force { + if opts.ForceWithLease != "" { + cmd.AddOptionFormat("--force-with-lease=%s", opts.ForceWithLease) + } else if opts.Force { cmd.AddArguments("-f") } if opts.Mirror { diff --git a/modules/gitrepo/push.go b/modules/gitrepo/push.go index 18808cac24..920c317f79 100644 --- a/modules/gitrepo/push.go +++ b/modules/gitrepo/push.go @@ -9,6 +9,19 @@ import ( "code.gitea.io/gitea/modules/git" ) -func Push(ctx context.Context, repo Repository, opts git.PushOptions) error { +// PushToExternal pushes a managed repository to an external remote. +func PushToExternal(ctx context.Context, repo Repository, opts git.PushOptions) error { return git.Push(ctx, repoPath(repo), opts) } + +// Push pushes from one managed repository to another managed repository. +func Push(ctx context.Context, fromRepo, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, repoPath(fromRepo), opts) +} + +// PushFromLocal pushes from a local path to a managed repository. +func PushFromLocal(ctx context.Context, fromLocalPath string, toRepo Repository, opts git.PushOptions) error { + opts.Remote = repoPath(toRepo) + return git.Push(ctx, fromLocalPath, opts) +} diff --git a/modules/markup/markdown/markdown_math_test.go b/modules/markup/markdown/markdown_math_test.go index a75f18d36a..9e368cb689 100644 --- a/modules/markup/markdown/markdown_math_test.go +++ b/modules/markup/markdown/markdown_math_test.go @@ -30,6 +30,10 @@ func TestMathRender(t *testing.T) { "$ a $", `

a

` + nl, }, + { + "$a$$b$", + `

ab

` + nl, + }, { "$a$ $b$", `

a b

` + nl, @@ -59,7 +63,7 @@ func TestMathRender(t *testing.T) { `

a$b $a a$b b$

` + nl, }, { - "a$x$", + "a$x$", // Pattern: "word$other$" The real world example is: "Price is between US$1 and US$2.", so don't parse this. `

a$x$

` + nl, }, { @@ -70,6 +74,10 @@ func TestMathRender(t *testing.T) { "$a$ ($b$) [$c$] {$d$}", `

a (b) [$c$] {$d$}

` + nl, }, + { + "[$a$](link)", + `

a

` + nl, + }, { "$$a$$", `

a

` + nl, diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index a711d1e1cd..564861df90 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -54,6 +54,10 @@ func isAlphanumeric(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') } +func isInMarkdownLinkText(block text.Reader, lineAfter []byte) bool { + return block.PrecendingCharacter() == '[' && bytes.HasPrefix(lineAfter, []byte("](")) +} + // Parse parses the current line and returns a result of parsing. func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { line, _ := block.PeekLine() @@ -115,7 +119,9 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. } // check valid ending character isValidEndingChar := isPunctuation(succeedingCharacter) || isParenthesesClose(succeedingCharacter) || - succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 + succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 || + succeedingCharacter == '$' || + isInMarkdownLinkText(block, line[i+len(stopMark):]) if checkSurrounding && !isValidEndingChar { break } diff --git a/modules/setting/config_provider.go b/modules/setting/config_provider.go index b6f9f07f98..57dc23b17f 100644 --- a/modules/setting/config_provider.go +++ b/modules/setting/config_provider.go @@ -337,14 +337,14 @@ func LogStartupProblem(skip int, level log.Level, format string, args ...any) { func deprecatedSetting(rootCfg ConfigProvider, oldSection, oldKey, newSection, newKey, version string) { if rootCfg.Section(oldSection).HasKey(oldKey) { - LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents, please use `[%s].%s` instead because this fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version) + LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` present, please use `[%s].%s` instead because this fallback will be/has been removed in %s", oldSection, oldKey, newSection, newKey, version) } } // deprecatedSettingDB add a hint that the configuration has been moved to database but still kept in app.ini func deprecatedSettingDB(rootCfg ConfigProvider, oldSection, oldKey string) { if rootCfg.Section(oldSection).HasKey(oldKey) { - LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` presents but it won't take effect because it has been moved to admin panel -> config setting", oldSection, oldKey) + LogStartupProblem(1, log.ERROR, "Deprecation: config option `[%s].%s` present but it won't take effect because it has been moved to admin panel -> config setting", oldSection, oldKey) } } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index c1c85837fc..47973a5f6a 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -292,6 +292,21 @@ type RenameBranchRepoOption struct { Name string `json:"name" binding:"Required;GitRefName;MaxSize(100)"` } +// UpdateBranchRepoOption options when updating a branch reference in a repository +// swagger:model +type UpdateBranchRepoOption struct { + // New commit SHA (or any ref) the branch should point to + // + // required: true + NewCommitID string `json:"new_commit_id" binding:"Required"` + + // Expected old commit SHA of the branch; if provided it must match the current tip + OldCommitID string `json:"old_commit_id"` + + // Force update even if the change is not a fast-forward + Force bool `json:"force"` +} + // TransferRepoOption options when transfer a repository's ownership // swagger:model type TransferRepoOption struct { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 3722b711a2..c2f9fe068a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1242,6 +1242,7 @@ func Routes() *web.Router { m.Get("/*", repo.GetBranch) m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch) m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), repo.CreateBranch) + m.Put("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.UpdateBranchRepoOption{}), repo.UpdateBranch) m.Patch("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.RenameBranchRepoOption{}), repo.RenameBranch) }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) m.Group("/branch_protections", func() { diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index b9060e9cbd..4624d7e738 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -380,6 +380,81 @@ func ListBranches(ctx *context.APIContext) { ctx.JSON(http.StatusOK, apiBranches) } +// UpdateBranch moves a branch reference to a new commit. +func UpdateBranch(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/branches/{branch} repository repoUpdateBranch + // --- + // summary: Update a branch reference to a new commit + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: branch + // in: path + // description: name of the branch + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateBranchRepoOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" + + opt := web.GetForm(ctx).(*api.UpdateBranchRepoOption) + + branchName := ctx.PathParam("*") + repo := ctx.Repo.Repository + + if repo.IsEmpty { + ctx.APIError(http.StatusNotFound, "Git Repository is empty.") + return + } + + if repo.IsMirror { + ctx.APIError(http.StatusForbidden, "Git Repository is a mirror.") + return + } + + // permission check has been done in api.go + if err := repo_service.UpdateBranch(ctx, repo, ctx.Repo.GitRepo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil { + switch { + case git_model.IsErrBranchNotExist(err): + ctx.APIErrorNotFound(err) + case errors.Is(err, util.ErrInvalidArgument): + ctx.APIError(http.StatusUnprocessableEntity, err) + case git.IsErrPushRejected(err): + rej := err.(*git.ErrPushRejected) + ctx.APIError(http.StatusForbidden, rej.Message) + default: + ctx.APIErrorInternal(err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + // RenameBranch renames a repository's branch. func RenameBranch(ctx *context.APIContext) { // swagger:operation PATCH /repos/{owner}/{repo}/branches/{branch} repository repoRenameBranch diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index b80a9c14ba..310839374b 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -147,6 +147,8 @@ type swaggerParameterBodies struct { // in:body CreateBranchRepoOption api.CreateBranchRepoOption + // in:body + UpdateBranchRepoOption api.UpdateBranchRepoOption // in:body CreateBranchProtectionOption api.CreateBranchProtectionOption diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index f21f568231..2b0ba9072d 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -15,6 +15,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" @@ -133,8 +134,7 @@ func RestoreBranchPost(ctx *context.Context) { return } - if err := git.Push(ctx, ctx.Repo.Repository.RepoPath(), git.PushOptions{ - Remote: ctx.Repo.Repository.RepoPath(), + if err := gitrepo.Push(ctx, ctx.Repo.Repository, ctx.Repo.Repository, git.PushOptions{ Branch: fmt.Sprintf("%s:%s%s", deletedBranch.CommitID, git.BranchPrefix, deletedBranch.Name), Env: repo_module.PushingEnvironment(ctx.Doer, ctx.Repo.Repository), }); err != nil { diff --git a/routers/web/repo/editor_util.go b/routers/web/repo/editor_util.go index f910f0bd40..07bcb474f0 100644 --- a/routers/web/repo/editor_util.go +++ b/routers/web/repo/editor_util.go @@ -13,6 +13,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" @@ -102,8 +103,7 @@ func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) st } func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error { - return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{ - Remote: targetRepo.RepoPath(), + return gitrepo.Push(ctx, baseRepo, targetRepo, git.PushOptions{ Branch: baseBranchName + ":" + targetBranchName, Env: repo_module.PushingEnvironment(doer, targetRepo), }) diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index edad756b6b..35124c5c3e 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -16,6 +16,7 @@ import ( "code.gitea.io/gitea/models/renderhelper" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" @@ -141,8 +142,7 @@ func NewComment(ctx *context.Context) { if prHeadCommitID != headBranchCommitID { // force push to base repo - err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{ - Remote: pull.BaseRepo.RepoPath(), + err := gitrepo.Push(ctx, pull.HeadRepo, pull.BaseRepo, git.PushOptions{ Branch: pull.HeadBranch + ":" + prHeadRef, Force: true, Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo), diff --git a/routers/web/repo/migrate.go b/routers/web/repo/migrate.go index ea15e90e5c..8f4adb2ad2 100644 --- a/routers/web/repo/migrate.go +++ b/routers/web/repo/migrate.go @@ -25,6 +25,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" + repo_service "code.gitea.io/gitea/services/repository" "code.gitea.io/gitea/services/task" ) @@ -237,7 +238,7 @@ func MigratePost(ctx *context.Context) { opts.AWSSecretAccessKey = form.AWSSecretAccessKey } - err = repo_model.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false) + err = repo_service.CheckCreateRepository(ctx, ctx.Doer, ctxUser, opts.RepoName, false) if err != nil { handleMigrateError(ctx, ctxUser, err, "MigratePost", tpl, form) return diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go index a558231df1..c7a19062d2 100644 --- a/routers/web/repo/setting/lfs.go +++ b/routers/web/repo/setting/lfs.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/git/pipeline" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" @@ -112,7 +113,7 @@ func LFSLocks(ctx *context.Context) { } defer cleanup() - if err := git.Clone(ctx, ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{ + if err := gitrepo.CloneRepoToLocal(ctx, ctx.Repo.Repository, tmpBasePath, git.CloneRepoOptions{ Bare: true, Shared: true, }); err != nil { diff --git a/services/context/csrf.go b/services/context/csrf.go index f190465bdb..aa99f34b03 100644 --- a/services/context/csrf.go +++ b/services/context/csrf.go @@ -118,7 +118,7 @@ func (c *csrfProtector) PrepareForSessionUser(ctx *Context) { if uidChanged { _ = ctx.Session.Set(c.opt.oldSessionKey, c.id) } else if cookieToken != "" { - // If cookie token presents, re-use existing unexpired token, else generate a new one. + // If cookie token present, re-use existing unexpired token, else generate a new one. if issueTime, ok := ParseCsrfToken(cookieToken); ok { dur := time.Since(issueTime) // issueTime is not a monotonic-clock, the server time may change a lot to an early time. if dur >= -CsrfTokenRegenerationInterval && dur <= CsrfTokenRegenerationInterval { diff --git a/services/doctor/misc.go b/services/doctor/misc.go index ce7eea1dcc..445ff61ffb 100644 --- a/services/doctor/misc.go +++ b/services/doctor/misc.go @@ -215,7 +215,7 @@ func checkCommitGraph(ctx context.Context, logger log.Logger, autofix bool) erro if !isExist { numNeedUpdate++ if autofix { - if err := git.WriteCommitGraph(ctx, repo.RepoPath()); err != nil { + if err := gitrepo.WriteCommitGraph(ctx, repo); err != nil { logger.Error("Unable to write commit-graph in %s. Error: %v", repo.FullName(), err) return err } diff --git a/services/mirror/mirror_push.go b/services/mirror/mirror_push.go index b61345e830..bae189ba87 100644 --- a/services/mirror/mirror_push.go +++ b/services/mirror/mirror_push.go @@ -153,7 +153,7 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error { log.Trace("Pushing %s mirror[%d] remote %s", storageRepo.RelativePath(), m.ID, m.RemoteName) envs := proxy.EnvWithProxy(remoteURL.URL) - if err := gitrepo.Push(ctx, storageRepo, git.PushOptions{ + if err := gitrepo.PushToExternal(ctx, storageRepo, git.PushOptions{ Remote: m.RemoteName, Force: true, Mirror: true, diff --git a/services/pull/pull.go b/services/pull/pull.go index 04f48f0565..ecc0b2c7ce 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -570,13 +570,11 @@ func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, pre log.Error("Unable to load head repository for PR[%d] Error: %v", pr.ID, err) return err } - headRepoPath := pr.HeadRepo.RepoPath() if err := pr.LoadBaseRepo(ctx); err != nil { log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err) return err } - baseRepoPath := pr.BaseRepo.RepoPath() if err = pr.LoadIssue(ctx); err != nil { return fmt.Errorf("unable to load issue %d for pr %d: %w", pr.IssueID, pr.ID, err) @@ -587,8 +585,7 @@ func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, pre gitRefName := pr.GetGitHeadRefName() - if err := git.Push(ctx, headRepoPath, git.PushOptions{ - Remote: baseRepoPath, + if err := gitrepo.Push(ctx, pr.HeadRepo, pr.BaseRepo, git.PushOptions{ Branch: prefixHeadBranch + pr.HeadBranch + ":" + gitRefName, Force: true, // Use InternalPushingEnvironment here because we know that pre-receive and post-receive do not run on a refs/pulls/... diff --git a/services/repository/branch.go b/services/repository/branch.go index 0a2fd30620..fd1e7d0414 100644 --- a/services/repository/branch.go +++ b/services/repository/branch.go @@ -385,8 +385,7 @@ func CreateNewBranchFromCommit(ctx context.Context, doer *user_model.User, repo return err } - if err := git.Push(ctx, repo.RepoPath(), git.PushOptions{ - Remote: repo.RepoPath(), + if err := gitrepo.Push(ctx, repo, repo, git.PushOptions{ Branch: fmt.Sprintf("%s:%s%s", commitID, git.BranchPrefix, branchName), Env: repo_module.PushingEnvironment(doer, repo), }); err != nil { @@ -483,6 +482,64 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m return "", nil } +// UpdateBranch moves a branch reference to the provided commit. permission check should be done before calling this function. +func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, doer *user_model.User, branchName, newCommitID, expectedOldCommitID string, force bool) error { + branch, err := git_model.GetBranch(ctx, repo.ID, branchName) + if err != nil { + return err + } + if branch.IsDeleted { + return git_model.ErrBranchNotExist{ + BranchName: branchName, + } + } + + if expectedOldCommitID != "" { + expectedID, err := gitRepo.ConvertToGitID(expectedOldCommitID) + if err != nil { + return fmt.Errorf("ConvertToGitID(old): %w", err) + } + if expectedID.String() != branch.CommitID { + return util.NewInvalidArgumentErrorf("branch commit does not match [expected: %s, given: %s]", expectedID.String(), branch.CommitID) + } + } + + newID, err := gitRepo.ConvertToGitID(newCommitID) + if err != nil { + return fmt.Errorf("ConvertToGitID(new): %w", err) + } + newCommit, err := gitRepo.GetCommit(newID.String()) + if err != nil { + return err + } + + if newCommit.ID.String() == branch.CommitID { + return nil + } + + isForcePush, err := newCommit.IsForcePush(branch.CommitID) + if err != nil { + return err + } + if isForcePush && !force { + return util.NewInvalidArgumentErrorf("Force push %s need a confirm force parameter", branchName) + } + + pushOpts := git.PushOptions{ + Remote: repo.RepoPath(), + Branch: fmt.Sprintf("%s:%s%s", newCommit.ID.String(), git.BranchPrefix, branchName), + Env: repo_module.PushingEnvironment(doer, repo), + Force: isForcePush || force, + } + + if expectedOldCommitID != "" { + pushOpts.ForceWithLease = fmt.Sprintf("%s:%s", git.BranchPrefix+branchName, branch.CommitID) + } + + // branch protection will be checked in the pre received hook, so that we don't need any check here + return gitrepo.Push(ctx, repo, repo, pushOpts) +} + var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default") func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error { diff --git a/services/repository/files/temp_repo.go b/services/repository/files/temp_repo.go index 731f23855d..b7f4afdebc 100644 --- a/services/repository/files/temp_repo.go +++ b/services/repository/files/temp_repo.go @@ -18,6 +18,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git/gitcmd" + "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" @@ -362,8 +363,7 @@ func (t *TemporaryUploadRepository) CommitTree(ctx context.Context, opts *Commit 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(), + if err := gitrepo.PushFromLocal(ctx, t.basePath, t.repo, git.PushOptions{ Branch: strings.TrimSpace(commitHash) + ":" + git.BranchPrefix + strings.TrimSpace(branch), Env: env, Force: force, diff --git a/services/repository/generate.go b/services/repository/generate.go index caf15265a0..3ec31dac22 100644 --- a/services/repository/generate.go +++ b/services/repository/generate.go @@ -230,8 +230,7 @@ func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *r ) // Clone to temporary path and do the init commit. - templateRepoPath := templateRepo.RepoPath() - if err := git.Clone(ctx, templateRepoPath, tmpDir, git.CloneRepoOptions{ + if err := gitrepo.CloneRepoToLocal(ctx, templateRepo, tmpDir, git.CloneRepoOptions{ Depth: 1, Branch: templateRepo.DefaultBranch, }); err != nil { diff --git a/services/repository/merge_upstream.go b/services/repository/merge_upstream.go index 8d6f11372c..692b801303 100644 --- a/services/repository/merge_upstream.go +++ b/services/repository/merge_upstream.go @@ -11,6 +11,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/util" @@ -33,8 +34,7 @@ func MergeUpstream(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_ return "up-to-date", nil } - err = git.Push(ctx, repo.BaseRepo.RepoPath(), git.PushOptions{ - Remote: repo.RepoPath(), + err = gitrepo.Push(ctx, repo.BaseRepo, repo, git.PushOptions{ Branch: fmt.Sprintf("%s:%s", divergingInfo.BaseBranchName, branch), Env: repo_module.PushingEnvironment(doer, repo), }) diff --git a/services/repository/migrate.go b/services/repository/migrate.go index acac6fd9ad..8f515326ad 100644 --- a/services/repository/migrate.go +++ b/services/repository/migrate.go @@ -74,8 +74,6 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, repo *repo_model.Repository, opts migration.MigrateOptions, httpTransport *http.Transport, ) (*repo_model.Repository, error) { - repoPath := repo.RepoPath() - if u.IsOrganization() { t, err := organization.OrgFromUser(u).GetOwnerTeam(ctx) if err != nil { @@ -92,7 +90,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, return repo, fmt.Errorf("failed to remove existing repo dir %q, err: %w", repo.FullName(), err) } - if err := git.Clone(ctx, opts.CloneAddr, repoPath, git.CloneRepoOptions{ + if err := gitrepo.CloneExternalRepo(ctx, opts.CloneAddr, repo, git.CloneRepoOptions{ Mirror: true, Quiet: true, Timeout: migrateTimeout, @@ -104,7 +102,7 @@ func MigrateRepositoryGitData(ctx context.Context, u *user_model.User, return repo, fmt.Errorf("clone error: %w", err) } - if err := git.WriteCommitGraph(ctx, repoPath); err != nil { + if err := gitrepo.WriteCommitGraph(ctx, repo); err != nil { return repo, err } diff --git a/services/repository/repository.go b/services/repository/repository.go index acc5ce56cf..93fbcb51f7 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -345,3 +345,31 @@ func HasWiki(ctx context.Context, repo *repo_model.Repository) bool { } return hasWiki && err == nil } + +// CheckCreateRepository check if doer could create a repository in new owner +func CheckCreateRepository(ctx context.Context, doer, owner *user_model.User, name string, overwriteOrAdopt bool) error { + if !doer.CanCreateRepoIn(owner) { + return repo_model.ErrReachLimitOfRepo{Limit: owner.MaxRepoCreation} + } + + if err := repo_model.IsUsableRepoName(name); err != nil { + return err + } + + has, err := repo_model.IsRepositoryModelExist(ctx, owner, name) + if err != nil { + return err + } else if has { + return repo_model.ErrRepoAlreadyExist{Uname: owner.Name, Name: name} + } + repo := repo_model.StorageRepo(repo_model.RelativePath(owner.Name, name)) + isExist, err := gitrepo.IsRepositoryExist(ctx, repo) + if err != nil { + log.Error("Unable to check if %s exists. Error: %v", repo.RelativePath(), err) + return err + } + if !overwriteOrAdopt && isExist { + return repo_model.ErrRepoFilesAlreadyExist{Uname: owner.Name, Name: name} + } + return nil +} diff --git a/services/repository/transfer.go b/services/repository/transfer.go index 98307a447a..af477fc7f1 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -90,6 +90,17 @@ func AcceptTransferOwnership(ctx context.Context, repo *repo_model.Repository, d return nil } +// isRepositoryModelOrDirExist returns true if the repository with given name under user has already existed. +func isRepositoryModelOrDirExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) { + has, err := repo_model.IsRepositoryModelExist(ctx, u, repoName) + if err != nil { + return false, err + } + repo := repo_model.StorageRepo(repo_model.RelativePath(u.Name, repoName)) + isExist, err := gitrepo.IsRepositoryExist(ctx, repo) + return has || isExist, err +} + // transferOwnership transfers all corresponding repository items from old user to new one. func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName string, repo *repo_model.Repository, teams []*organization.Team) (err error) { repoRenamed := false @@ -143,7 +154,7 @@ func transferOwnership(ctx context.Context, doer *user_model.User, newOwnerName newOwnerName = newOwner.Name // ensure capitalisation matches // Check if new owner has repository with same name. - if has, err := repo_model.IsRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil { + if has, err := isRepositoryModelOrDirExist(ctx, newOwner, repo.Name); err != nil { return fmt.Errorf("IsRepositoryExist: %w", err) } else if has { return repo_model.ErrRepoAlreadyExist{ @@ -345,7 +356,7 @@ func changeRepositoryName(ctx context.Context, repo *repo_model.Repository, newR return err } - has, err := repo_model.IsRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName) + has, err := isRepositoryModelOrDirExist(ctx, repo.Owner, newRepoName) if err != nil { return fmt.Errorf("IsRepositoryExist: %w", err) } else if has { diff --git a/services/wiki/wiki.go b/services/wiki/wiki.go index 6a57a9a63e..5f74817ef3 100644 --- a/services/wiki/wiki.go +++ b/services/wiki/wiki.go @@ -25,8 +25,6 @@ import ( repo_service "code.gitea.io/gitea/services/repository" ) -const DefaultRemote = "origin" - func getWikiWorkingLockKey(repoID int64) string { return fmt.Sprintf("wiki_working_%d", repoID) } @@ -214,8 +212,7 @@ func updateWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model return err } - if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{ - Remote: DefaultRemote, + if err := gitrepo.PushFromLocal(gitRepo.Ctx, basePath, repo.WikiStorageRepo(), git.PushOptions{ Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch), Env: repo_module.FullPushingEnvironment( doer, @@ -333,8 +330,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model return err } - if err := git.Push(gitRepo.Ctx, basePath, git.PushOptions{ - Remote: DefaultRemote, + if err := gitrepo.PushFromLocal(gitRepo.Ctx, basePath, repo.WikiStorageRepo(), git.PushOptions{ Branch: fmt.Sprintf("%s:%s%s", commitHash.String(), git.BranchPrefix, repo.DefaultWikiBranch), Env: repo_module.FullPushingEnvironment( doer, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index a6853c46ea..5226e7e457 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6912,6 +6912,66 @@ } } }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update a branch reference to a new commit", + "operationId": "repoUpdateBranch", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the branch", + "name": "branch", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateBranchRepoOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "409": { + "$ref": "#/responses/conflict" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, "delete": { "produces": [ "application/json" @@ -28981,6 +29041,31 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateBranchRepoOption": { + "description": "UpdateBranchRepoOption options when updating a branch reference in a repository", + "type": "object", + "required": [ + "new_commit_id" + ], + "properties": { + "force": { + "description": "Force update even if the change is not a fast-forward", + "type": "boolean", + "x-go-name": "Force" + }, + "new_commit_id": { + "description": "New commit SHA (or any ref) the branch should point to", + "type": "string", + "x-go-name": "NewCommitID" + }, + "old_commit_id": { + "description": "Expected old commit SHA of the branch; if provided it must match the current tip", + "type": "string", + "x-go-name": "OldCommitID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "UpdateFileOptions": { "description": "UpdateFileOptions options for updating or creating a file\nNote: `author` and `committer` are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)", "type": "object", diff --git a/tests/integration/api_branch_test.go b/tests/integration/api_branch_test.go index 2147ef9d0d..043aa10c7f 100644 --- a/tests/integration/api_branch_test.go +++ b/tests/integration/api_branch_test.go @@ -4,6 +4,8 @@ package integration import ( + "encoding/base64" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -243,6 +245,79 @@ func TestAPIRenameBranch(t *testing.T) { }) } +func TestAPIUpdateBranchReference(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + ctx := NewAPITestContext(t, "user2", "update-branch", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + giteaURL.Path = ctx.GitPath() + + var defaultBranch string + t.Run("CreateRepo", doAPICreateRepository(ctx, false, func(t *testing.T, repo api.Repository) { + defaultBranch = repo.DefaultBranch + })) + + createBranchReq := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/branches", ctx.Username, ctx.Reponame), &api.CreateBranchRepoOption{ + BranchName: "feature", + OldRefName: defaultBranch, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, createBranchReq, http.StatusCreated) + + var featureInitialCommit string + t.Run("LoadFeatureBranch", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + featureInitialCommit = branch.Commit.ID + assert.NotEmpty(t, featureInitialCommit) + })) + + content := base64.StdEncoding.EncodeToString([]byte("branch update test")) + var newCommit string + doAPICreateFile(ctx, "docs/update.txt", &api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: defaultBranch, + NewBranchName: defaultBranch, + Message: "add docs/update.txt", + }, + ContentBase64: content, + }, func(t *testing.T, resp api.FileResponse) { + newCommit = resp.Commit.SHA + assert.NotEmpty(t, newCommit) + })(t) + + updateReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: newCommit, + OldCommitID: featureInitialCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, updateReq, http.StatusNoContent) + + t.Run("FastForwardApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + assert.Equal(t, newCommit, branch.Commit.ID) + })) + + staleReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: newCommit, + OldCommitID: featureInitialCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, staleReq, http.StatusUnprocessableEntity) + + nonFFReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: featureInitialCommit, + OldCommitID: newCommit, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, nonFFReq, http.StatusUnprocessableEntity) + + forceReq := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, "feature"), &api.UpdateBranchRepoOption{ + NewCommitID: featureInitialCommit, + OldCommitID: newCommit, + Force: true, + }).AddTokenAuth(ctx.Token) + ctx.Session.MakeRequest(t, forceReq, http.StatusNoContent) + + t.Run("ForceApplied", doAPIGetBranch(ctx, "feature", func(t *testing.T, branch api.Branch) { + assert.Equal(t, featureInitialCommit, branch.Commit.ID) + })) + }) +} + func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to string, expectedHTTPStatus int) *httptest.ResponseRecorder { token := getUserToken(t, doerName, auth_model.AccessTokenScopeWriteRepository) req := NewRequestWithJSON(t, "PATCH", "api/v1/repos/"+ownerName+"/"+repoName+"/branches/"+from, &api.RenameBranchRepoOption{ diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 9ceb087005..86b1a037a0 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -17,7 +17,7 @@ import {POST} from '../../modules/fetch.ts'; import { EventEditorContentChanged, initTextareaMarkdown, - textareaInsertText, + replaceTextareaSelection, triggerEditorContentChanged, } from './EditorMarkdown.ts'; import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts'; @@ -273,7 +273,7 @@ export class ComboMarkdownEditor { let cols = parseInt(addTablePanel.querySelector('[name=cols]')!.value); rows = Math.max(1, Math.min(100, rows)); cols = Math.max(1, Math.min(100, cols)); - textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); + replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`); addTablePanelTippy.hide(); }); } diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts index 2240e2f41b..da7bbcfef7 100644 --- a/web_src/js/features/comp/EditorMarkdown.ts +++ b/web_src/js/features/comp/EditorMarkdown.ts @@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) { target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true})); } -export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) { - const startPos = textarea.selectionStart; - const endPos = textarea.selectionEnd; - textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos); - textarea.selectionStart = startPos; - textarea.selectionEnd = startPos + value.length; +/** replace selected text or insert text by creating a new edit history entry, + * e.g. CTRL-Z works after this */ +export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) { + const before = textarea.value.slice(0, textarea.selectionStart); + const after = textarea.value.slice(textarea.selectionEnd); + textarea.focus(); - triggerEditorContentChanged(textarea); + let success = false; + try { + success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated + } catch {} + + // fall back to regular replacement + if (!success) { + textarea.value = `${before}${text}${after}`; + triggerEditorContentChanged(textarea); + } } type TextareaValueSelection = { @@ -176,7 +185,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}}; } -function handleNewline(textarea: HTMLTextAreaElement, e: Event) { +function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) { const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd}); if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled) e.preventDefault(); @@ -185,6 +194,28 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) { triggerEditorContentChanged(textarea); } +// Keys that act as dead keys will not work because the spec dictates that such keys are +// emitted as `Dead` in e.key instead of the actual key. +const pairs = new Map([ + ["'", "'"], + ['"', '"'], + ['`', '`'], + ['(', ')'], + ['[', ']'], + ['{', '}'], + ['<', '>'], +]); + +function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void { + const selStart = textarea.selectionStart; + const selEnd = textarea.selectionEnd; + if (selEnd === selStart) return; // do not process when no selection + e.preventDefault(); + const inner = textarea.value.substring(selStart, selEnd); + replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`); + textarea.setSelectionRange(selStart + 1, selEnd + 1); +} + function isTextExpanderShown(textarea: HTMLElement): boolean { return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions')); } @@ -198,6 +229,8 @@ export function initTextareaMarkdown(textarea: HTMLTextAreaElement) { } else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) { // use Enter to insert a new line with the same indention and prefix handleNewline(textarea, e); + } else if (pairs.has(e.key)) { + handlePairCharacter(textarea, e); } }); } diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index 92593e7092..6aff4242ba 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -1,5 +1,5 @@ import {imageInfo} from '../../utils/image.ts'; -import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts'; +import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts'; import { DropzoneCustomEventRemovedFile, DropzoneCustomEventUploadDone, @@ -43,7 +43,7 @@ class TextareaEditor { } insertPlaceholder(value: string) { - textareaInsertText(this.editor, value); + replaceTextareaSelection(this.editor, value); } replacePlaceholder(oldVal: string, newVal: string) {