diff --git a/modules/git/commit.go b/modules/git/commit.go index 012ba975e8..705a97c927 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -437,6 +437,27 @@ func (c *Commit) GetBranchName() (string, error) { return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil } +// GetAllBranches returns a slice with all branches that contains this commit +func (c *Commit) GetAllBranches() ([]string, error) { + branchList := make([]string, 0) + + cmd := NewCommand(c.repo.Ctx, "branch", "--format=%(refname:short)", "--contains").AddDynamicArguments(c.ID.String()) + data, _, err := cmd.RunStdString(&RunOpts{Dir: c.repo.Path}) + if err != nil { + return branchList, err + } + + branchNames := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n") + for _, branch := range branchNames { + branch = strings.TrimSpace(branch) + if branch != "" { + branchList = append(branchList, branch) + } + } + + return branchList, nil +} + // CommitFileStatus represents status of files in a commit. type CommitFileStatus struct { Added []string diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 58814d3b2e..0199ec8cbd 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1341,6 +1341,9 @@ func Routes() *web.Route { m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) + m.Group("/sync_fork", func() { + m.Post("/{branch}", repo.SyncForkBranch) + }, reqToken(), reqRepoWriter(unit.TypeCode)) }, repoAssignment()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) diff --git a/routers/api/v1/repo/sync_fork.go b/routers/api/v1/repo/sync_fork.go new file mode 100644 index 0000000000..fcc339db58 --- /dev/null +++ b/routers/api/v1/repo/sync_fork.go @@ -0,0 +1,54 @@ +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// SyncForkBranch syncs a fork branch with the base branch +func SyncForkBranch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/sync_fork/{branch} repository repoSyncForkBranch + // --- + // summary: Syncs a fork + // 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: The branch + // type: string + // required: true + // responses: + // "400": + // "$ref": "#/responses/error" + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + if !ctx.Repo.Repository.IsFork { + ctx.Error(http.StatusBadRequest, "NoFork", "The Repo must be a fork") + return + } + + branch := ctx.Params("branch") + + err := repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index bede21be17..8ddcd5b86c 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -737,3 +737,15 @@ func PrepareBranchList(ctx *context.Context) { } ctx.Data["Branches"] = brs } + +func SyncFork(ctx *context.Context) { + branch := ctx.Params("branch") + + err := repo_service.SyncFork(ctx, ctx.Doer, ctx.Repo.Repository, branch) + if err != nil { + ctx.ServerError("SyncFork", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/src/branch/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName))) +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index e48865a2f5..d03d079998 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -50,6 +50,7 @@ import ( "code.gitea.io/gitea/routers/web/feed" issue_service "code.gitea.io/gitea/services/issue" files_service "code.gitea.io/gitea/services/repository/files" + repo_service "code.gitea.io/gitea/services/repository" "github.com/nektos/act/pkg/model" @@ -1107,6 +1108,26 @@ PostRecentBranchCheck: } } + if ctx.Repo.Repository.IsFork && ctx.Repo.IsViewBranch && len(ctx.Repo.TreePath) == 0 && ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { + err = ctx.Repo.Repository.GetBaseRepo(ctx) + if err != nil { + ctx.ServerError("GetBaseRepo", err) + return + } + + canSync, err := repo_service.CanSyncFork(ctx, ctx.Repo.Repository, ctx.Repo.BranchName) + if err != nil { + ctx.ServerError("CanSync", err) + return + } + + if canSync { + ctx.Data["CanSyncFork"] = canSync + ctx.Data["SyncForkLink"] = fmt.Sprintf("%s/sync_fork/%s", ctx.Repo.RepoLink, util.PathEscapeSegments(ctx.Repo.BranchName)) + ctx.Data["BaseBranchLink"] = fmt.Sprintf("%s/src/branch/%s", ctx.Repo.Repository.BaseRepo.HTMLURL(), util.PathEscapeSegments(ctx.Repo.BranchName)) + } + } + ctx.Data["Paths"] = paths branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() diff --git a/routers/web/web.go b/routers/web/web.go index caea7bdd1e..e59b716165 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1558,6 +1558,8 @@ func registerRoutes(m *web.Route) { m.Get("/forks", repo.Forks) }, context.RepoRef(), reqRepoCodeReader) m.Get("/commit/{sha:([a-f0-9]{4,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, reqRepoCodeReader, repo.RawDiff) + + m.Get("/sync_fork/{branch}", repo.MustBeNotEmpty, reqRepoCodeWriter, repo.SyncFork) }, ignSignIn, context.RepoAssignment, context.UnitTypes()) m.Post("/{username}/{reponame}/lastcommit/*", ignSignInAndCsrf, context.RepoAssignment, context.UnitTypes(), context.RepoRefByType(context.RepoRefCommit), reqRepoCodeReader, repo.LastCommit) diff --git a/services/repository/sync_fork.go b/services/repository/sync_fork.go new file mode 100644 index 0000000000..8b8ae6aae3 --- /dev/null +++ b/services/repository/sync_fork.go @@ -0,0 +1,121 @@ +package repository + +import ( + "context" + "fmt" + "slices" + + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + repo_module "code.gitea.io/gitea/modules/repository" +) + +// SyncFork syncs a branch of a fork with the base repo +func SyncFork(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, branch string) error { + err := repo.GetBaseRepo(ctx) + if err != nil { + return err + } + + tmpPath, err := repo_module.CreateTemporaryPath("sync") + if err != nil { + return err + } + defer repo_module.RemoveTemporaryPath(tmpPath) + + err = git.NewCommand(ctx, "clone", "-b").AddDynamicArguments(branch, repo.RepoPath(), tmpPath).Run(&git.RunOpts{Dir: tmpPath}) + if err != nil { + return fmt.Errorf("Clone: %v", err) + } + + gitRepo, err := git.OpenRepository(ctx, tmpPath) + if err != nil { + return err + } + defer gitRepo.Close() + + command := git.NewCommand(gitRepo.Ctx, "remote", "add", "upstream") + command = command.AddDynamicArguments(repo.BaseRepo.RepoPath()) + err = command.Run(&git.RunOpts{Dir: gitRepo.Path}) + if err != nil { + return fmt.Errorf("RemoteAdd: %v", err) + } + + err = git.NewCommand(gitRepo.Ctx, "fetch", "upstream").Run(&git.RunOpts{Dir: gitRepo.Path}) + if err != nil { + return fmt.Errorf("FetchUpstream: %v", err) + } + + command = git.NewCommand(gitRepo.Ctx, "checkout") + command = command.AddDynamicArguments(branch) + err = command.Run(&git.RunOpts{Dir: gitRepo.Path}) + if err != nil { + return fmt.Errorf("Checkout: %v", err) + } + + command = git.NewCommand(gitRepo.Ctx, "rebase") + command = command.AddDynamicArguments(fmt.Sprintf("upstream/%s", branch)) + err = command.Run(&git.RunOpts{Dir: gitRepo.Path}) + if err != nil { + return fmt.Errorf("Rebase: %v", err) + } + + pushEnv := repo_module.PushingEnvironment(doer, repo) + err = git.NewCommand(ctx, "push", "origin").AddDynamicArguments(branch).Run(&git.RunOpts{Dir: tmpPath, Env: pushEnv}) + if err != nil { + return fmt.Errorf("Push: %v", err) + } + + return nil +} + +// CanSyncFork returns if a branch of the fork can be synced with the base repo +func CanSyncFork(ctx context.Context, repo *repo_model.Repository, branch string) (bool, error) { + forkBranch, err := git_model.GetBranch(ctx, repo.ID, branch) + if err != nil { + return false, err + } + + baseBranch, err := git_model.GetBranch(ctx, repo.BaseRepo.ID, branch) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + // If the base repo don't have the branch, we don't need to continue + return false, nil + } + return false, err + } + + // If both branches has the same latest commit, we don't need to sync + if forkBranch.CommitID == baseBranch.CommitID { + return false, nil + } + + // If the fork has newer commits, we can't sync + if forkBranch.CommitTime >= baseBranch.CommitTime { + return false, nil + } + + // Check if the latest commit of the fork is also in the base + gitRepo, err := git.OpenRepository(ctx, repo.BaseRepo.RepoPath()) + if err != nil { + return false, err + } + defer gitRepo.Close() + + commit, err := gitRepo.GetCommit(forkBranch.CommitID) + if err != nil { + if git.IsErrNotExist(err) { + return false, nil + } + return false, err + } + + branchList, err := commit.GetAllBranches() + if err != nil { + return false, err + } + + return slices.Contains(branchList, branch), nil +} diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 5e27d9160c..73db9bc9c8 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -178,6 +178,18 @@ {{end}} + + {{if .CanSyncFork}} +
+
+ This branch is behind {{printf "%s/%s:%s" .Repository.BaseRepo.OwnerName .Repository.BaseRepo.Name .BranchName}} +
+ + Sync + +
+ {{end}} + {{if .IsViewFile}} {{template "repo/view_file" .}} {{else if .IsBlame}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8a40cf76d4..4253e5348f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13388,6 +13388,52 @@ } } }, + "/repos/{owner}/{repo}/sync_fork/{branch}": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Syncs a fork", + "operationId": "repoSyncForkBranch", + "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": "The branch", + "name": "branch", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/tags": { "get": { "produces": [