From c611fccb90ddd2422d70b3fde98125d196e29205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 3 Mar 2026 22:58:45 +0100 Subject: [PATCH 01/15] feat: implement rebasing --- cmd/app/rebase_mr.go | 57 +++++++++++++++++++++++++ cmd/app/rebase_mr_test.go | 78 +++++++++++++++++++++++++++++++++++ cmd/app/server.go | 6 +++ doc/gitlab.nvim.txt | 16 +++++++ lua/gitlab/actions/rebase.lua | 49 ++++++++++++++++++++++ lua/gitlab/annotations.lua | 4 ++ lua/gitlab/init.lua | 2 + lua/gitlab/state.lua | 17 ++++++++ 8 files changed, 229 insertions(+) create mode 100644 cmd/app/rebase_mr.go create mode 100644 cmd/app/rebase_mr_test.go create mode 100644 lua/gitlab/actions/rebase.lua diff --git a/cmd/app/rebase_mr.go b/cmd/app/rebase_mr.go new file mode 100644 index 00000000..8bc164e3 --- /dev/null +++ b/cmd/app/rebase_mr.go @@ -0,0 +1,57 @@ +package app + +import ( + "encoding/json" + "fmt" + "net/http" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type RebaseMrRequest struct { + SkipCI bool `json:"skip_ci,omitempty"` +} + +type MergeRequestRebaser interface { + RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) +} + +type mergeRequestRebaserService struct { + data + client MergeRequestRebaser +} + +/* Rebases a merge request on the server */ +func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + payload := r.Context().Value(payload("payload")).(*RebaseMrRequest) + + opts := gitlab.RebaseMergeRequestOptions{ + SkipCI: &payload.SkipCI, + } + + res, err := a.client.RebaseMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) + + if err != nil { + handleError(w, err, "Could not rebase MR", http.StatusInternalServerError) + return + } + + if res.StatusCode >= 300 { + handleError(w, GenericError{r.URL.Path}, "Could not rebase MR", res.StatusCode) + return + } + + skippingCI := "" + if payload.SkipCI { + skippingCI = " (skipping CI)" + } + response := SuccessResponse{Message: fmt.Sprintf("MR rebased successfully%s", skippingCI)} + + w.WriteHeader(http.StatusOK) + + err = json.NewEncoder(w).Encode(response) + if err != nil { + handleError(w, err, "Could not encode response", http.StatusInternalServerError) + } +} diff --git a/cmd/app/rebase_mr_test.go b/cmd/app/rebase_mr_test.go new file mode 100644 index 00000000..9d14fc3f --- /dev/null +++ b/cmd/app/rebase_mr_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "net/http" + "testing" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type fakeMergeRequestRebaser struct { + testBase +} + +func (f fakeMergeRequestRebaser) RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, err + } + + return resp, err +} + +func TestRebaseHandler(t *testing.T) { + var testRebaseMrPayload = RebaseMrRequest{SkipCI: false} + t.Run("Rebases merge request", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased successfully") + }) + var testRebaseMrPayloadSkipCI = RebaseMrRequest{SkipCI: true} + t.Run("Rebases merge request and skips CI", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayloadSkipCI) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased successfully (skipping CI)") + }) + t.Run("Handles errors from Gitlab client", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{testBase{errFromGitlab: true}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + checkErrorFromGitlab(t, data, "Could not rebase MR") + }) + t.Run("Handles non-200s from Gitlab", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{testBase{status: http.StatusSeeOther}}}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data, _ := getFailData(t, svc, request) + checkNon200(t, data, "Could not rebase MR", "/mr/rebase") + }) +} diff --git a/cmd/app/server.go b/cmd/app/server.go index f143904a..404c5bbe 100644 --- a/cmd/app/server.go +++ b/cmd/app/server.go @@ -117,6 +117,12 @@ func CreateRouter(gitlabClient *Client, projectInfo *ProjectInfo, s *shutdownSer withPayloadValidation(methodToPayload{http.MethodPost: newPayload[AcceptMergeRequestRequest]}), withMethodCheck(http.MethodPost), )) + m.HandleFunc("/mr/rebase", middleware( + mergeRequestRebaserService{d, gitlabClient}, + withMr(d, gitlabClient), + withPayloadValidation(methodToPayload{http.MethodPost: newPayload[RebaseMrRequest]}), + withMethodCheck(http.MethodPost), + )) m.HandleFunc("/mr/discussions/list", middleware( discussionsListerService{d, gitlabClient}, withMr(d, gitlabClient), diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index b95b55aa..6e22604a 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -183,6 +183,8 @@ you call this function with no values the defaults will be used: revoke = "glR", -- Revoke MR approval merge = "glM", -- Merge the feature branch to the target branch and close MR set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds + rebase = "glrr", -- Rebase the feature branch of the MR on the server and pull the new state + rebase_skip_ci = "glrs", -- Same as `rebase`, but skip the CI pipeline create_mr = "glC", -- Create a new MR for currently checked-out feature branch choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch) start_review = "glS", -- Start review for the currently checked-out branch @@ -1091,6 +1093,20 @@ request" page). You can see the current settings in the Summary view, see Use the `keymaps.popup.perform_action` to merge the MR with your message. + *gitlab.nvim.rebase* +gitlab.rebase({opts}) ~ + +Rebases the feature branch of the MR on the server and pulls the new state of +the target branch. +>lua + require("gitlab").rebase() + require("gitlab").rebase({ skip_ci = true }) +< + Parameters: ~ + • {opts}: (table|nil) Keyword arguments that can be used to override + default behavior. + • {skip_ci}: (bool) If true, the CI pipeline will be skipped. + *gitlab.nvim.data* gitlab.data({resources}, {cb}) ~ diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua new file mode 100644 index 00000000..72e5bb4f --- /dev/null +++ b/lua/gitlab/actions/rebase.lua @@ -0,0 +1,49 @@ +local u = require("gitlab.utils") + +local M = {} + +---@class RebaseOpts +---@field skip_ci boolean? + +local can_rebase = function() + local git = require("gitlab.git") + -- Check if there are local changes (couldn't run `git pull` after rebasing) + local has_clean_tree, err = git.has_clean_tree() + if not has_clean_tree then + u.notify("Cannot rebase when there are changed files", vim.log.levels.ERROR) + return false + elseif err ~= nil then + u.notify("Error while inspecting working tree", vim.log.levels.ERROR) + return false + end + return true +end + +---@param opts RebaseOpts +M.rebase = function(opts) + if not can_rebase() then + return + end + + -- TODO: check that MR needs rebasing (requires https://github.com/harrisoncramer/gitlab.nvim/pull/532) + + local state = require("gitlab.state") + local rebase_body = { skip_ci = state.settings.rebase_mr.skip_ci } + if opts and opts.skip_ci ~= nil then + rebase_body.skip_ci = opts.skip_ci + end + + M.confirm_rebase(rebase_body) +end + +---@param merge_body RebaseOpts +M.confirm_rebase = function(merge_body) + local job = require("gitlab.job") + job.run_job("/mr/rebase", "POST", merge_body, function(data) + u.notify(data.message, vim.log.levels.INFO) + u.notify("Implement pulling", vim.log.levels.INFO) + u.notify("Implement updating the reviewer", vim.log.levels.INFO) + end) +end + +return M diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 4b8dfa6c..72269ff2 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -165,6 +165,7 @@ ---@field discussion_signs? DiscussionSigns -- The settings for discussion signs/diagnostics ---@field pipeline? PipelineSettings -- The settings for the pipeline popup ---@field create_mr? CreateMrSettings -- The settings when creating an MR +---@field rebase_mr? RebaseMrSettings -- The settings when rebasing an MR ---@field colors? ColorSettings --- Colors settings for the plugin ---@class ServerSettings @@ -203,6 +204,9 @@ ---@field title_input? TitleInputSettings ---@field fork? ForkSettings +---@class RebaseMrSettings: table +---@field skip_ci? boolean -- Whether to skip CI after rabasing + ---@class ForkSettings: table ---@field enabled? boolean -- If making an MR from a fork ---@field forked_project_id? number -- The Gitlab ID of the project you are merging into. If nil, will be prompted. diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index e788a518..d9d92493 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -8,6 +8,7 @@ local reviewer = require("gitlab.reviewer") local discussions = require("gitlab.actions.discussions") local merge_requests = require("gitlab.actions.merge_requests") local merge = require("gitlab.actions.merge") +local rebase = require("gitlab.actions.rebase") local summary = require("gitlab.actions.summary") local data = require("gitlab.actions.data") local assignees_and_reviewers = require("gitlab.actions.assignees_and_reviewers") @@ -88,6 +89,7 @@ return { end, pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), + rebase = async.sequence({ u.merge(info, { refresh = true }) }, rebase.rebase), -- Discussion Tree Actions 🌴 toggle_discussions = function() if discussions.split_visible then diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7a570e8d..7cbb7245 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -86,6 +86,8 @@ M.settings = { revoke = "glR", merge = "glM", set_auto_merge = "glm", + rebase = "glrr", + rebase_skip_ci = "glrs", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -201,6 +203,9 @@ M.settings = { border = "rounded", }, }, + rebase_mr = { + skip_ci = false, + }, choose_merge_request = { open_reviewer = true, }, @@ -431,6 +436,18 @@ M.set_global_keymaps = function() end, { desc = "Set MR to auto-merge", nowait = keymaps.global.set_auto_merge_nowait }) end + if keymaps.global.rebase then + vim.keymap.set("n", keymaps.global.rebase, function() + require("gitlab").rebase() + end, { desc = "Rebase MR", nowait = keymaps.global.rebase_nowait }) + end + + if keymaps.global.rebase_skip_ci then + vim.keymap.set("n", keymaps.global.rebase_skip_ci, function() + require("gitlab").rebase({ skip_ci = true }) + end, { desc = "Rebase MR and skip CI", nowait = keymaps.global.rebase_skip_ci_nowait }) + end + if keymaps.global.copy_mr_url then vim.keymap.set("n", keymaps.global.copy_mr_url, function() require("gitlab").copy_mr_url() From 514e4fb765e24187bf8c8c9fd9d8e43e6ad7ac79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 3 Mar 2026 23:48:34 +0100 Subject: [PATCH 02/15] feat: pull the remote branch after rebasing --- lua/gitlab/actions/rebase.lua | 17 ++++++++++++++--- lua/gitlab/git.lua | 28 ++++++++++++++++++++++++++++ lua/gitlab/reviewer/init.lua | 25 +++++++++++++------------ 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index 72e5bb4f..fe9d3cd4 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -7,7 +7,7 @@ local M = {} local can_rebase = function() local git = require("gitlab.git") - -- Check if there are local changes (couldn't run `git pull` after rebasing) + -- Check if there are local changes (we wouldn't be able to run `git pull` after rebasing) local has_clean_tree, err = git.has_clean_tree() if not has_clean_tree then u.notify("Cannot rebase when there are changed files", vim.log.levels.ERROR) @@ -41,8 +41,19 @@ M.confirm_rebase = function(merge_body) local job = require("gitlab.job") job.run_job("/mr/rebase", "POST", merge_body, function(data) u.notify(data.message, vim.log.levels.INFO) - u.notify("Implement pulling", vim.log.levels.INFO) - u.notify("Implement updating the reviewer", vim.log.levels.INFO) + local git = require("gitlab.git") + local state = require("gitlab.state") + local success = git.pull(state.settings.connection_settings.remote, state.INFO.source_branch, { "--rebase" }) + if success then + u.notify( + string.format( + "Pulled `%s %s` successfully", + state.settings.connection_settings.remote, + state.INFO.source_branch + ), + vim.log.levels.INFO + ) + end end) end diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index ae139df5..76909268 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -104,6 +104,34 @@ M.get_ahead_behind = function(current_branch, remote_branch) return tonumber(ahead), tonumber(behind) end +---Pull the branch from remote +---@param remote string +---@param branch string +---@param opts string[]? +---@return boolean success True if the branch has been pulled successfully +M.pull = function(remote, branch, opts) + local current_branch = M.get_current_branch() + if not current_branch then + return false + end + if current_branch ~= branch then + local u = require("gitlab.utils") + u.notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) + return false + end + local args = { "git", "pull" } + for _, opt in ipairs(opts or {}) do + table.insert(args, opt) + end + table.insert(args, remote) + table.insert(args, branch) + local _, err = run_system(args) + if err ~= nil then + return false + end + return true +end + ---Return the name of the current branch or nil if it can't be retrieved ---@return string|nil M.get_current_branch = function() diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 76d24f32..cec65321 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -6,7 +6,6 @@ local List = require("gitlab.utils.list") local u = require("gitlab.utils") local state = require("gitlab.state") -local git = require("gitlab.git") local hunks = require("gitlab.hunks") local async = require("diffview.async") local diffview_lib = require("diffview.lib") @@ -29,18 +28,16 @@ M.init = function() end end --- Opens the reviewer window. +-- Opens the reviewer windows. M.open = function() - local diff_refs = state.INFO.diff_refs - if diff_refs == nil then - u.notify("Gitlab did not provide diff refs required to review this MR", vim.log.levels.ERROR) - return - end + local git = require("gitlab.git") - if diff_refs.base_sha == "" or diff_refs.head_sha == "" then - u.notify("Merge request contains no changes", vim.log.levels.ERROR) + local remote_target_branch = + string.format("%s/%s", state.settings.connection_settings.remote, state.INFO.target_branch) + if not git.fetch_remote_branch(remote_target_branch) then return end + git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) local diffview_open_command = "DiffviewOpen" @@ -53,17 +50,22 @@ M.open = function() diffview_open_command = diffview_open_command .. " --imply-local" else u.notify( - "Your working tree has changes, cannot use 'imply_local' setting for gitlab reviews.\n Stash or commit all changes to use.", + "Working tree unclean, cannot use 'imply_local' for review. Stash or commit all changes to use.", vim.log.levels.WARN ) state.settings.reviewer_settings.diffview.imply_local = false end end - vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) + local full_command = string.format("%s %s..%s", diffview_open_command, remote_target_branch, state.INFO.source_branch) + vim.api.nvim_command(full_command) M.is_open = true local cur_view = diffview_lib.get_current_view() + if cur_view == nil then + u.notify("Could not find Diffview view", vim.log.levels.ERROR) + return + end M.diffview_layout = cur_view.cur_layout M.tabnr = vim.api.nvim_get_current_tabpage() @@ -94,7 +96,6 @@ M.open = function() require("gitlab").toggle_discussions() -- Fetches data and opens discussions end - git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) git.check_mr_in_good_condition() end From aae713d117be0107bcbaf84878fcd96a50788efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 4 Mar 2026 10:02:23 +0100 Subject: [PATCH 03/15] docs: add keymap description --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4b12b1fe..1d364478 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,8 @@ glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR glm Set MR to merge automatically when the pipeline succeeds +glrr Rebase the feature branch of the MR on the server and pull the new state +glrs Same as `glrr`, but skip the CI pipeline glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch From 5e172d2c42fe923bef5da5306da0b4c4b91be857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 17 Mar 2026 17:19:38 +0100 Subject: [PATCH 04/15] fix: don't use branch as MR base --- lua/gitlab/reviewer/init.lua | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index cec65321..fa189041 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -30,13 +30,18 @@ end -- Opens the reviewer windows. M.open = function() - local git = require("gitlab.git") + local diff_refs = state.INFO.diff_refs + if diff_refs == nil then + u.notify("Gitlab did not provide diff refs required to review this MR", vim.log.levels.ERROR) + return + end - local remote_target_branch = - string.format("%s/%s", state.settings.connection_settings.remote, state.INFO.target_branch) - if not git.fetch_remote_branch(remote_target_branch) then + if diff_refs.base_sha == "" or diff_refs.head_sha == "" then + u.notify("Merge request contains no changes", vim.log.levels.ERROR) return end + + local git = require("gitlab.git") git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) local diffview_open_command = "DiffviewOpen" @@ -49,16 +54,12 @@ M.open = function() if has_clean_tree then diffview_open_command = diffview_open_command .. " --imply-local" else - u.notify( - "Working tree unclean, cannot use 'imply_local' for review. Stash or commit all changes to use.", - vim.log.levels.WARN - ) + u.notify("Working tree unclean. Stash or commit all changes to use 'imply_local'.", vim.log.levels.WARN) state.settings.reviewer_settings.diffview.imply_local = false end end - local full_command = string.format("%s %s..%s", diffview_open_command, remote_target_branch, state.INFO.source_branch) - vim.api.nvim_command(full_command) + vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, state.INFO.source_branch)) M.is_open = true local cur_view = diffview_lib.get_current_view() From 0efee05a02d07ce1209970174903c488744c8980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 30 Mar 2026 15:13:54 +0200 Subject: [PATCH 05/15] docs: replace spaces with tabs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1d364478..07aeae25 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,8 @@ glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR glm Set MR to merge automatically when the pipeline succeeds -glrr Rebase the feature branch of the MR on the server and pull the new state -glrs Same as `glrr`, but skip the CI pipeline +glrr Rebase the feature branch of the MR on the server and pull the new state +glrs Same as `glrr`, but skip the CI pipeline glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch From 9ab8f9238e7595a9e40d62889923da4f03f25f19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Thu, 9 Apr 2026 01:41:37 +0200 Subject: [PATCH 06/15] feat: don't rebase if MR already is rebased --- README.md | 3 ++- doc/gitlab.nvim.txt | 8 ++++++-- lua/gitlab/actions/rebase.lua | 19 +++++++++++++++---- lua/gitlab/init.lua | 2 +- lua/gitlab/state.lua | 7 +++++++ 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 07aeae25..2bdba4ce 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,9 @@ glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR glm Set MR to merge automatically when the pipeline succeeds -glrr Rebase the feature branch of the MR on the server and pull the new state +glrr Rebase the feature branch of the MR on the server (if not already rebased) and pull the new state glrs Same as `glrr`, but skip the CI pipeline +glrf Same as `rebase`, but rebase even if MR already is rebased glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 6e22604a..331b85b5 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -185,6 +185,7 @@ you call this function with no values the defaults will be used: set_auto_merge = "glm", -- Set MR to merge automatically when the pipeline succeeds rebase = "glrr", -- Rebase the feature branch of the MR on the server and pull the new state rebase_skip_ci = "glrs", -- Same as `rebase`, but skip the CI pipeline + rebase_force = "glrf", -- Same as `rebase`, but rebase even if MR already is rebased create_mr = "glC", -- Create a new MR for currently checked-out feature branch choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch) start_review = "glS", -- Start review for the currently checked-out branch @@ -1100,12 +1101,15 @@ Rebases the feature branch of the MR on the server and pulls the new state of the target branch. >lua require("gitlab").rebase() - require("gitlab").rebase({ skip_ci = true }) + require("gitlab").rebase({ skip_ci = true, force = true }) < Parameters: ~ • {opts}: (table|nil) Keyword arguments that can be used to override default behavior. - • {skip_ci}: (bool) If true, the CI pipeline will be skipped. + • {skip_ci}: (bool) If true, a CI pipeline is not created (this + may lead to a failed Mergeability Check (CI_MUST_PASS). + • {force}: (bool) If true, MR is rebased even if MR already is + rebased (this may run an unnecessary CI pipeline). *gitlab.nvim.data* gitlab.data({resources}, {cb}) ~ diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index fe9d3cd4..1368c202 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -3,7 +3,8 @@ local u = require("gitlab.utils") local M = {} ---@class RebaseOpts ----@field skip_ci boolean? +---@field skip_ci boolean? If true, a CI pipeline is not created. +---@field force boolean? If true, MR is rebased even if MR already is rebased. local can_rebase = function() local git = require("gitlab.git") @@ -21,15 +22,25 @@ end ---@param opts RebaseOpts M.rebase = function(opts) + opts = opts or {} if not can_rebase() then return end - -- TODO: check that MR needs rebasing (requires https://github.com/harrisoncramer/gitlab.nvim/pull/532) - local state = require("gitlab.state") + + if not opts.force then + local need_rebase = vim.iter(state.MERGEABILITY):find(function(c) + return c.identifier == "NEED_REBASE" + end) + if need_rebase and need_rebase.status == "SUCCESS" then + u.notify("MR is already rebased", vim.log.levels.ERROR) + return + end + end + local rebase_body = { skip_ci = state.settings.rebase_mr.skip_ci } - if opts and opts.skip_ci ~= nil then + if opts.skip_ci ~= nil then rebase_body.skip_ci = opts.skip_ci end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index d9d92493..7d7a5ceb 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -89,7 +89,7 @@ return { end, pipeline = async.sequence({ latest_pipeline }, pipeline.open), merge = async.sequence({ u.merge(info, { refresh = true }) }, merge.merge), - rebase = async.sequence({ u.merge(info, { refresh = true }) }, rebase.rebase), + rebase = async.sequence({ u.merge(mergeability, { refresh = true }), info }, rebase.rebase), -- Discussion Tree Actions 🌴 toggle_discussions = function() if discussions.split_visible then diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7cbb7245..7d2143d1 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -88,6 +88,7 @@ M.settings = { set_auto_merge = "glm", rebase = "glrr", rebase_skip_ci = "glrs", + rebase_force = "glrf", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -448,6 +449,12 @@ M.set_global_keymaps = function() end, { desc = "Rebase MR and skip CI", nowait = keymaps.global.rebase_skip_ci_nowait }) end + if keymaps.global.rebase_force then + vim.keymap.set("n", keymaps.global.rebase_force, function() + require("gitlab").rebase({ force = true }) + end, { desc = "Force rebase MR", nowait = keymaps.global.rebase_force_nowait }) + end + if keymaps.global.copy_mr_url then vim.keymap.set("n", keymaps.global.copy_mr_url, function() require("gitlab").copy_mr_url() From f3791649d611246b451826f9f142dab83011a52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 10 Apr 2026 19:40:41 +0200 Subject: [PATCH 07/15] fix: update local state after rebase properly --- cmd/app/rebase_mr.go | 31 +++++++++-- cmd/app/rebase_mr_test.go | 50 +++++++++++++---- doc/gitlab.nvim.txt | 19 +++++++ lua/gitlab/actions/rebase.lua | 78 ++++++++++++++++----------- lua/gitlab/git.lua | 58 ++++++++++++-------- lua/gitlab/indicators/diagnostics.lua | 5 +- lua/gitlab/init.lua | 3 ++ lua/gitlab/reviewer/init.lua | 53 +++++++++++------- 8 files changed, 208 insertions(+), 89 deletions(-) diff --git a/cmd/app/rebase_mr.go b/cmd/app/rebase_mr.go index 8bc164e3..6034b135 100644 --- a/cmd/app/rebase_mr.go +++ b/cmd/app/rebase_mr.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" gitlab "gitlab.com/gitlab-org/api/client-go" ) @@ -16,14 +17,18 @@ type MergeRequestRebaser interface { RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) } +type MergeRequestRebaserClient interface { + MergeRequestRebaser + MergeRequestGetter +} + type mergeRequestRebaserService struct { data - client MergeRequestRebaser + client MergeRequestRebaserClient } /* Rebases a merge request on the server */ func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - payload := r.Context().Value(payload("payload")).(*RebaseMrRequest) opts := gitlab.RebaseMergeRequestOptions{ @@ -31,7 +36,6 @@ func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Req } res, err := a.client.RebaseMergeRequest(a.projectInfo.ProjectId, a.projectInfo.MergeId, &opts) - if err != nil { handleError(w, err, "Could not rebase MR", http.StatusInternalServerError) return @@ -42,11 +46,30 @@ func (a mergeRequestRebaserService) ServeHTTP(w http.ResponseWriter, r *http.Req return } + // Poll until rebase completes (GitLab rebase is async) + for { + mr, _, getMrErr := a.client.GetMergeRequest( + a.projectInfo.ProjectId, + a.projectInfo.MergeId, + &gitlab.GetMergeRequestsOptions{ + IncludeRebaseInProgress: gitlab.Ptr(true), + }, + ) + if getMrErr != nil { + handleError(w, getMrErr, "Could not check rebase status", http.StatusInternalServerError) + return + } + if !mr.RebaseInProgress { + break + } + time.Sleep(1 * time.Second) + } + skippingCI := "" if payload.SkipCI { skippingCI = " (skipping CI)" } - response := SuccessResponse{Message: fmt.Sprintf("MR rebased successfully%s", skippingCI)} + response := SuccessResponse{Message: fmt.Sprintf("MR rebased on server%s", skippingCI)} w.WriteHeader(http.StatusOK) diff --git a/cmd/app/rebase_mr_test.go b/cmd/app/rebase_mr_test.go index 9d14fc3f..0571d7e3 100644 --- a/cmd/app/rebase_mr_test.go +++ b/cmd/app/rebase_mr_test.go @@ -7,11 +7,13 @@ import ( gitlab "gitlab.com/gitlab-org/api/client-go" ) -type fakeMergeRequestRebaser struct { +type fakeMergeRequestRebaserClient struct { testBase + rebaseInProgressCount int // number of times to return RebaseInProgress: true + getMergeRequestCalls int // tracks how many times GetMergeRequest was called } -func (f fakeMergeRequestRebaser) RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { +func (f *fakeMergeRequestRebaserClient) RebaseMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.RebaseMergeRequestOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Response, error) { resp, err := f.handleGitlabError() if err != nil { return nil, err @@ -20,12 +22,40 @@ func (f fakeMergeRequestRebaser) RebaseMergeRequest(pid interface{}, mergeReques return resp, err } +func (f *fakeMergeRequestRebaserClient) GetMergeRequest(pid interface{}, mergeRequest int64, opt *gitlab.GetMergeRequestsOptions, options ...gitlab.RequestOptionFunc) (*gitlab.MergeRequest, *gitlab.Response, error) { + resp, err := f.handleGitlabError() + if err != nil { + return nil, nil, err + } + + f.getMergeRequestCalls++ + rebaseInProgress := f.getMergeRequestCalls <= f.rebaseInProgressCount + + return &gitlab.MergeRequest{RebaseInProgress: rebaseInProgress}, resp, err +} + func TestRebaseHandler(t *testing.T) { var testRebaseMrPayload = RebaseMrRequest{SkipCI: false} - t.Run("Rebases merge request", func(t *testing.T) { + t.Run("Rebases merge request when rebase completes immediately", func(t *testing.T) { + request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 0} + svc := middleware( + mergeRequestRebaserService{testProjectData, fakeClient}, + withMr(testProjectData, fakeMergeRequestLister{}), + withPayloadValidation(methodToPayload{ + http.MethodPost: newPayload[RebaseMrRequest], + }), + withMethodCheck(http.MethodPost), + ) + data := getSuccessData(t, svc, request) + assert(t, data.Message, "MR rebased on server") + assert(t, fakeClient.getMergeRequestCalls, 1) + }) + t.Run("Rebases merge request and polls until rebase completes", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) + fakeClient := &fakeMergeRequestRebaserClient{rebaseInProgressCount: 1} svc := middleware( - mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + mergeRequestRebaserService{testProjectData, fakeClient}, withMr(testProjectData, fakeMergeRequestLister{}), withPayloadValidation(methodToPayload{ http.MethodPost: newPayload[RebaseMrRequest], @@ -33,13 +63,15 @@ func TestRebaseHandler(t *testing.T) { withMethodCheck(http.MethodPost), ) data := getSuccessData(t, svc, request) - assert(t, data.Message, "MR rebased successfully") + assert(t, data.Message, "MR rebased on server") + assert(t, fakeClient.getMergeRequestCalls, 2) }) var testRebaseMrPayloadSkipCI = RebaseMrRequest{SkipCI: true} t.Run("Rebases merge request and skips CI", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayloadSkipCI) + fakeClient := &fakeMergeRequestRebaserClient{} svc := middleware( - mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{}}, + mergeRequestRebaserService{testProjectData, fakeClient}, withMr(testProjectData, fakeMergeRequestLister{}), withPayloadValidation(methodToPayload{ http.MethodPost: newPayload[RebaseMrRequest], @@ -47,12 +79,12 @@ func TestRebaseHandler(t *testing.T) { withMethodCheck(http.MethodPost), ) data := getSuccessData(t, svc, request) - assert(t, data.Message, "MR rebased successfully (skipping CI)") + assert(t, data.Message, "MR rebased on server (skipping CI)") }) t.Run("Handles errors from Gitlab client", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) svc := middleware( - mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{testBase{errFromGitlab: true}}}, + mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{errFromGitlab: true}}}, withMr(testProjectData, fakeMergeRequestLister{}), withPayloadValidation(methodToPayload{ http.MethodPost: newPayload[RebaseMrRequest], @@ -65,7 +97,7 @@ func TestRebaseHandler(t *testing.T) { t.Run("Handles non-200s from Gitlab", func(t *testing.T) { request := makeRequest(t, http.MethodPost, "/mr/rebase", testRebaseMrPayload) svc := middleware( - mergeRequestRebaserService{testProjectData, fakeMergeRequestRebaser{testBase{status: http.StatusSeeOther}}}, + mergeRequestRebaserService{testProjectData, &fakeMergeRequestRebaserClient{testBase: testBase{status: http.StatusSeeOther}}}, withMr(testProjectData, fakeMergeRequestLister{}), withPayloadValidation(methodToPayload{ http.MethodPost: newPayload[RebaseMrRequest], diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 331b85b5..aab51396 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -841,6 +841,25 @@ Opens the reviewer pane. Can be used from anywhere within Neovim after the plugin is loaded. If run twice, will open a second reviewer pane. >lua require("gitlab").review() +< + *gitlab.nvim.reload_review* +gitlab.reload_review() ~ + +Loads new MR state from Gitlab. Then if diffview.api is available (with the +https://github.com/dlyongemallo/diffview.nvim fork) applies the new diff refs +to the existing diffview, otherwise (with +https://github.com/sindrets/diffview.nvim) closes and re-opens the reviewer. + +>lua + require("gitlab").reload_review() +< + *gitlab.nvim.close_review* +gitlab.close_review() ~ + +Closes the reviewer tab and discussion tree and cleans up (e.g., removes +winbar timer). +>lua + require("gitlab").close_review() < *gitlab.nvim.summary* gitlab.summary() ~ diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index 1368c202..a905b8b2 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -1,15 +1,12 @@ -local u = require("gitlab.utils") +-- This module is responsible for rebasing the MR on the Gitlab server and updating the local +-- branch and reviewer state. local M = {} ----@class RebaseOpts ----@field skip_ci boolean? If true, a CI pipeline is not created. ----@field force boolean? If true, MR is rebased even if MR already is rebased. - local can_rebase = function() - local git = require("gitlab.git") + local u = require("gitlab.utils") -- Check if there are local changes (we wouldn't be able to run `git pull` after rebasing) - local has_clean_tree, err = git.has_clean_tree() + local has_clean_tree, err = require("gitlab.git").has_clean_tree() if not has_clean_tree then u.notify("Cannot rebase when there are changed files", vim.log.levels.ERROR) return false @@ -20,6 +17,44 @@ local can_rebase = function() return true end +---@class RebaseOpts +---@field skip_ci boolean? If true, a CI pipeline is not created. +---@field force boolean? If true, MR is rebased even if MR already is rebased. + +---Callback to run after the async `git pull` call exits +---@param result string|nil The stdout from the `git pull` call if any. +---@param err string|nil The stderr from the `git pull` call if any. +local on_pull_exit = function(result, err) + if result ~= nil then + local reviewer = require("gitlab.reviewer") + if reviewer.tabnr ~= nil then + reviewer.reload() + end + elseif err ~= nil then + require("gitlab.utils").notify(err, vim.log.levels.ERROR) + end +end + +---@class RebaseBody +---@field skip_ci boolean? If true, a CI pipeline is not created. + +---@param rebase_body RebaseBody +local confirm_rebase = function(rebase_body) + local u = require("gitlab.utils") + u.notify("Rebase in progress", vim.log.levels.INFO) + local job = require("gitlab.job") + job.run_job("/mr/rebase", "POST", rebase_body, function(data) + u.notify(data.message .. ", updating local state", vim.log.levels.INFO) + local state = require("gitlab.state") + require("gitlab.git").pull_async( + state.settings.connection_settings.remote, + state.INFO.source_branch, + on_pull_exit, + { "--rebase" } + ) + end) +end + ---@param opts RebaseOpts M.rebase = function(opts) opts = opts or {} @@ -30,11 +65,11 @@ M.rebase = function(opts) local state = require("gitlab.state") if not opts.force then - local need_rebase = vim.iter(state.MERGEABILITY):find(function(c) + local need_rebase_check = vim.iter(state.MERGEABILITY):find(function(c) return c.identifier == "NEED_REBASE" end) - if need_rebase and need_rebase.status == "SUCCESS" then - u.notify("MR is already rebased", vim.log.levels.ERROR) + if need_rebase_check and need_rebase_check.status == "SUCCESS" then + require("gitlab.utils").notify("MR is already rebased", vim.log.levels.ERROR) return end end @@ -44,28 +79,7 @@ M.rebase = function(opts) rebase_body.skip_ci = opts.skip_ci end - M.confirm_rebase(rebase_body) -end - ----@param merge_body RebaseOpts -M.confirm_rebase = function(merge_body) - local job = require("gitlab.job") - job.run_job("/mr/rebase", "POST", merge_body, function(data) - u.notify(data.message, vim.log.levels.INFO) - local git = require("gitlab.git") - local state = require("gitlab.state") - local success = git.pull(state.settings.connection_settings.remote, state.INFO.source_branch, { "--rebase" }) - if success then - u.notify( - string.format( - "Pulled `%s %s` successfully", - state.settings.connection_settings.remote, - state.INFO.source_branch - ), - vim.log.levels.INFO - ) - end - end) + confirm_rebase(rebase_body) end return M diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 76909268..90ac018c 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -14,6 +14,26 @@ local run_system = function(command) return result, nil end +---Function to run when an async system call finishes. Receives the command's stdout as result when +---successful, or the command's stderr as err when unsuccessful. +---@alias OnExitCallback fun(result:string|nil, err:string|nil) + +---Runs a system command asynchronously +---@param command string[] +---@param on_exit OnExitCallback +local run_system_async = function(command, on_exit) + vim.system(command, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + require("gitlab.utils").notify(result.stderr, vim.log.levels.ERROR) + on_exit(nil, result.stderr) + else + on_exit(vim.fn.trim(result.stdout), nil) + end + end) + end) +end + ---Returns all branches for the current repository ---@param args table|nil extra arguments for `git branch` ---@return string|nil, string|nil @@ -23,7 +43,7 @@ M.branches = function(args) return run_system(u.combine({ "git", "branch" }, args or {})) end ----Returns true if the working tree hasn't got any changes that haven't been commited +---Returns true if the working tree hasn't got any changes that haven't been committed ---@return boolean, string|nil M.has_clean_tree = function() local changes, err = run_system({ "git", "status", "--short", "--untracked-files=no" }) @@ -63,7 +83,7 @@ end ---Fetch the remote branch ---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") ----@return boolean fetch_successfull False if an error occurred while fetching, true otherwise. +---@return boolean fetch_successful False if an error occurred while fetching, true otherwise. M.fetch_remote_branch = function(remote_branch) local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") local _, fetch_err = run_system({ "git", "fetch", remote, branch }) @@ -104,32 +124,24 @@ M.get_ahead_behind = function(current_branch, remote_branch) return tonumber(ahead), tonumber(behind) end ----Pull the branch from remote ----@param remote string ----@param branch string ----@param opts string[]? ----@return boolean success True if the branch has been pulled successfully -M.pull = function(remote, branch, opts) +---Pull a branch asynchronously from a remote and execute callback on exit. +---@param remote string The remote from which to pull. +---@param branch string The branch to pull. +---@param on_exit OnExitCallback The callback to execute when the command finishes. +---@param args string[]? Extra arguments passed to the `git pull` command. +M.pull_async = function(remote, branch, on_exit, args) local current_branch = M.get_current_branch() if not current_branch then - return false + return end if current_branch ~= branch then - local u = require("gitlab.utils") - u.notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) - return false - end - local args = { "git", "pull" } - for _, opt in ipairs(opts or {}) do - table.insert(args, opt) - end - table.insert(args, remote) - table.insert(args, branch) - local _, err = run_system(args) - if err ~= nil then - return false + require("gitlab.utils").notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) + return end - return true + local cmd = { "git", "pull" } + vim.list_extend(cmd, args or {}) + vim.list_extend(cmd, { remote, branch }) + run_system_async(cmd, on_exit) end ---Return the name of the current branch or nil if it can't be retrieved diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index ccdd9363..02ec9baa 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -1,5 +1,4 @@ local u = require("gitlab.utils") -local diffview_lib = require("diffview.lib") local indicators_common = require("gitlab.indicators.common") local actions_common = require("gitlab.actions.common") local List = require("gitlab.utils.list") @@ -102,7 +101,7 @@ M.refresh_diagnostics = function() M.clear_diagnostics() M.placeable_discussions = indicators_common.filter_placeable_discussions() - local view = diffview_lib.get_current_view() + local view = require("gitlab.reviewer").diffview if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return @@ -117,7 +116,7 @@ M.place_diagnostics = function(bufnr) if not state.settings.discussion_signs.enabled then return end - local view = diffview_lib.get_current_view() + local view = require("gitlab.reviewer").diffview if view == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 7d7a5ceb..2ef453ae 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -84,6 +84,9 @@ return { review = async.sequence({ u.merge(info, { refresh = true }), revisions, user }, function() reviewer.open() end), + reload_review = function() + reviewer.reload() + end, close_review = function() reviewer.close() end, diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index fa189041..1b2f4a23 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -8,7 +8,6 @@ local u = require("gitlab.utils") local state = require("gitlab.state") local hunks = require("gitlab.hunks") local async = require("diffview.async") -local diffview_lib = require("diffview.lib") local M = { is_open = false, @@ -59,15 +58,15 @@ M.open = function() end end - vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, state.INFO.source_branch)) + vim.api.nvim_command(string.format("%s %s..%s", diffview_open_command, diff_refs.base_sha, diff_refs.head_sha)) M.is_open = true - local cur_view = diffview_lib.get_current_view() - if cur_view == nil then + M.diffview = require("diffview.lib").get_current_view() + if M.diffview == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end - M.diffview_layout = cur_view.cur_layout + M.diffview_layout = M.diffview.cur_layout M.tabnr = vim.api.nvim_get_current_tabpage() if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then @@ -102,11 +101,32 @@ end -- Closes the reviewer and cleans up M.close = function() - vim.cmd("DiffviewClose") + if M.tabnr ~= nil and vim.api.nvim_tabpage_is_valid(M.tabnr) then + vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(M.tabnr)) + end local discussions = require("gitlab.actions.discussions") discussions.close() end +---Loads new INFO state from Gitlab, then if diffview.api is available applies the new diff refs to +---the existing diffview, otherwise closes and re-opens the reviewer. +M.reload = function() + state.load_new_state("info", function() + state.load_new_state("revisions", function() + local has_api, api = pcall(require, "diffview.api") + if has_api then + api.set_revs( + string.format("%s..%s", state.INFO.diff_refs.base_sha, state.INFO.diff_refs.head_sha), + { view = M.diffview } + ) + else + M.close() + M.open() + end + end) + end) +end + --- Jumps to the location provided in the reviewer window ---@param file_name string The file name after change. ---@param old_file_name string The file name before change (different from file_name for renamed/moved files). @@ -121,13 +141,13 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) return end vim.api.nvim_set_current_tabpage(M.tabnr) - local view = diffview_lib.get_current_view() - if view == nil then + + if M.diffview == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) return end - local files = view.panel:ordered_file_list() + local files = M.diffview.panel:ordered_file_list() local file = List.new(files):find(function(f) local oldpath = f.oldpath ~= nil and f.oldpath or f.path return new_buffer and f.path == file_name or oldpath == old_file_name @@ -139,9 +159,9 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) ) return end - async.await(view:set_file(file)) + async.await(M.diffview:set_file(file)) - local layout = view.cur_layout + local layout = M.diffview.cur_layout local number_of_lines if new_buffer then layout.b:focus() @@ -164,11 +184,10 @@ end ---@param current_win integer The ID of the currently focused window ---@return DiffviewInfo | nil M.get_reviewer_data = function(current_win) - local view = diffview_lib.get_current_view() - if view == nil then + if M.diffview == nil then return end - local layout = view.cur_layout + local layout = M.diffview.cur_layout local old_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) local new_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) @@ -218,8 +237,7 @@ end ---@param current_win integer The ID of the currently focused window ---@return boolean M.is_new_sha_focused = function(current_win) - local view = diffview_lib.get_current_view() - local layout = view.cur_layout + local layout = M.diffview.cur_layout local b_win = u.get_window_id_by_buffer_id(layout.b.file.bufnr) local a_win = u.get_window_id_by_buffer_id(layout.a.file.bufnr) if a_win ~= current_win and b_win ~= current_win then @@ -231,8 +249,7 @@ end ---Get currently shown file data M.get_current_file_data = function() - local view = diffview_lib.get_current_view() - return view and view.panel and view.panel.cur_file + return M.diffview and M.diffview.panel and M.diffview.panel.cur_file end ---Get currently shown file path From 8ebfb901c7b322bf21357a894ec284baed2acc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Fri, 10 Apr 2026 20:06:18 +0200 Subject: [PATCH 08/15] feat: add global mapping for reloading review --- README.md | 1 + doc/gitlab.nvim.txt | 1 + lua/gitlab/annotations.lua | 1 + lua/gitlab/state.lua | 7 +++++++ 4 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 2bdba4ce..2ada3c0c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ glrf Same as `rebase`, but rebase even if MR already is rebased glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch +gl Load new MR state from Gitlab and apply new diff refs to the diff view gls Show the editable summary of the MR glu Copy the URL of the MR to the system clipboard glo Open the URL of the MR in the default Internet browser diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index aab51396..1587d9bb 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -189,6 +189,7 @@ you call this function with no values the defaults will be used: create_mr = "glC", -- Create a new MR for currently checked-out feature branch choose_merge_request = "glc", -- Chose MR for review (if necessary check out the feature branch) start_review = "glS", -- Start review for the currently checked-out branch + reload_review = "gl", -- Load new MR state from Gitlab and apply new diff refs to the diff view summary = "gls", -- Show the editable summary of the MR copy_mr_url = "glu", -- Copy the URL of the MR to the system clipboard open_in_browser = "glo", -- Openthe URL of the MR in the default Internet browser diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 72269ff2..378547bd 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -373,6 +373,7 @@ ---@field create_mr? string -- Create a new MR for currently checked-out feature branch ---@field choose_merge_request? string -- Chose MR for review (if necessary check out the feature branch) ---@field start_review? string -- Start review for the currently checked-out branch +---@field reload_review? string -- Load new MR state from Gitlab and apply new diff refs to the diff view ---@field summary? string -- Show the editable summary of the MR ---@field copy_mr_url? string -- Copy the URL of the MR to the system clipboard ---@field open_in_browser? string -- Openthe URL of the MR in the default Internet browser diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 7d2143d1..c27aeba6 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -92,6 +92,7 @@ M.settings = { create_mr = "glC", choose_merge_request = "glc", start_review = "glS", + reload_review = "gl", summary = "gls", copy_mr_url = "glu", open_in_browser = "glo", @@ -335,6 +336,12 @@ M.set_global_keymaps = function() end, { desc = "Start Gitlab review", nowait = keymaps.global.start_review_nowait }) end + if keymaps.global.reload_review then + vim.keymap.set("n", keymaps.global.reload_review, function() + require("gitlab").reload_review() + end, { desc = "Reload Gitlab review", nowait = keymaps.global.reload_review_nowait }) + end + if keymaps.global.choose_merge_request then vim.keymap.set("n", keymaps.global.choose_merge_request, function() require("gitlab").choose_merge_request() From 7ceae66148db1fe24c4dc6ce9f2214460ecfc2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 11 Apr 2026 09:03:54 +0200 Subject: [PATCH 09/15] fix: abort rebase when there are conflicts --- lua/gitlab/actions/rebase.lua | 47 +++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index a905b8b2..e89abea5 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -3,7 +3,12 @@ local M = {} -local can_rebase = function() +---@class RebaseOpts +---@field skip_ci boolean? If true, a CI pipeline is not created. +---@field force boolean? If true, MR is rebased even if MR already is rebased. + +---@param opts RebaseOpts +local can_rebase = function(opts) local u = require("gitlab.utils") -- Check if there are local changes (we wouldn't be able to run `git pull` after rebasing) local has_clean_tree, err = require("gitlab.git").has_clean_tree() @@ -14,13 +19,28 @@ local can_rebase = function() u.notify("Error while inspecting working tree", vim.log.levels.ERROR) return false end + + local state = require("gitlab.state") + + local already_rebased = vim.iter(state.MERGEABILITY):find(function(check) + return check.identifier == "NEED_REBASE" and check.status == "SUCCESS" + end) + if already_rebased and not opts.force then + u.notify("MR is already rebased", vim.log.levels.ERROR) + return false + end + + local has_conflicts = vim.iter(state.MERGEABILITY):find(function(check) + return check.identifier == "CONFLICT" and check.status ~= "SUCCESS" + end) + if has_conflicts then + u.notify("Rebase locally, resolve all conflicts, then push the branch", vim.log.levels.ERROR) + return false + end + return true end ----@class RebaseOpts ----@field skip_ci boolean? If true, a CI pipeline is not created. ----@field force boolean? If true, MR is rebased even if MR already is rebased. - ---Callback to run after the async `git pull` call exits ---@param result string|nil The stdout from the `git pull` call if any. ---@param err string|nil The stderr from the `git pull` call if any. @@ -58,23 +78,12 @@ end ---@param opts RebaseOpts M.rebase = function(opts) opts = opts or {} - if not can_rebase() then - return - end - - local state = require("gitlab.state") - if not opts.force then - local need_rebase_check = vim.iter(state.MERGEABILITY):find(function(c) - return c.identifier == "NEED_REBASE" - end) - if need_rebase_check and need_rebase_check.status == "SUCCESS" then - require("gitlab.utils").notify("MR is already rebased", vim.log.levels.ERROR) - return - end + if not can_rebase(opts) then + return end - local rebase_body = { skip_ci = state.settings.rebase_mr.skip_ci } + local rebase_body = { skip_ci = require("gitlab.state").settings.rebase_mr.skip_ci } if opts.skip_ci ~= nil then rebase_body.skip_ci = opts.skip_ci end From c91e36420fecfbc3086569a1b02a51ecc198a4bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sat, 11 Apr 2026 09:04:10 +0200 Subject: [PATCH 10/15] docs: mention rebase in the docs --- README.md | 4 ++-- doc/gitlab.nvim.txt | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2ada3c0c..b6e4dff9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like: -- Create, approve, and merge MRs for the current branch +- Create, approve, rebase, and merge MRs for the current branch - Read and edit an MR description - Add or remove reviewers and assignees - Resolve, reply to, and unresolve discussion threads @@ -145,7 +145,7 @@ glM Merge the feature branch to the target branch and close MR glm Set MR to merge automatically when the pipeline succeeds glrr Rebase the feature branch of the MR on the server (if not already rebased) and pull the new state glrs Same as `glrr`, but skip the CI pipeline -glrf Same as `rebase`, but rebase even if MR already is rebased +glrf Same as `glrr`, but rebase even if MR already is rebased glC Create a new MR for currently checked-out feature branch glc Chose MR for review glS Start review for the currently checked-out branch diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index 1587d9bb..a0e78318 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -33,7 +33,7 @@ OVERVIEW *gitlab.nvim.overview* This Neovim plugin is designed to make it easy to review Gitlab MRs from within the editor. This means you can do things like: -- Create, approve, and merge MRs for the current branch +- Create, approve, rebase, and merge MRs for the current branch - Read and edit an MR description - Add or remove reviewers and assignees - Resolve, reply to, and unresolve discussion threads @@ -618,6 +618,19 @@ this command to work. See |gitlab.nvim.merge| for more help on this function. +REBASING AN MR *gitlab.nvim.rebasing-an-mr* + +The `rebase` action will rebase an MR on the server, wait for the rebase to +take effect and then update the local reviewer state. The MR must be in a +"rebasable" state for this command to work, i.e., the worktree must be clean +and there must be no conflicts. +>lua + require("gitlab").rebase() + require("gitlab").rebase({ skip_ci = true, force = true }) +< +See |gitlab.nvim.rebase| for more help on this function. + + CREATING AN MR *gitlab.nvim.creating-an-mr* To create an MR for the current branch, make sure you have the branch checked From e31a9768ef2f0430c4dc76a9b1e938d517c84155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 12 Apr 2026 19:12:00 +0200 Subject: [PATCH 11/15] refactor: rename tabnr to tabid The `vim.api.nvim_get_current_tabpage()` function returns a tab ID, not a tab number, as exemplified by the usage: `vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(M.tabid))` --- lua/gitlab/actions/comment.lua | 6 +++--- lua/gitlab/actions/rebase.lua | 2 +- lua/gitlab/reviewer/init.lua | 26 +++++++++++++------------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/lua/gitlab/actions/comment.lua b/lua/gitlab/actions/comment.lua index 086cd1e6..56617464 100644 --- a/lua/gitlab/actions/comment.lua +++ b/lua/gitlab/actions/comment.lua @@ -300,14 +300,14 @@ end ---@return boolean M.can_create_comment = function(must_be_visual) -- Check that diffview is initialized - if reviewer.tabnr == nil then + if reviewer.tabid == nil then u.notify("Reviewer must be initialized first", vim.log.levels.ERROR) return false end -- Check that we are in the Diffview tab - local tabnr = vim.api.nvim_get_current_tabpage() - if tabnr ~= reviewer.tabnr then + local tabid = vim.api.nvim_get_current_tabpage() + if tabid ~= reviewer.tabid then u.notify("Comments can only be left in the reviewer pane", vim.log.levels.ERROR) return false end diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index e89abea5..e84507eb 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -47,7 +47,7 @@ end local on_pull_exit = function(result, err) if result ~= nil then local reviewer = require("gitlab.reviewer") - if reviewer.tabnr ~= nil then + if reviewer.tabid ~= nil then reviewer.reload() end elseif err ~= nil then diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 1b2f4a23..9f7b398b 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -12,7 +12,7 @@ local async = require("diffview.async") local M = { is_open = false, bufnr = nil, - tabnr = nil, + tabid = nil, stored_win = nil, buf_winids = {}, } @@ -67,7 +67,7 @@ M.open = function() return end M.diffview_layout = M.diffview.cur_layout - M.tabnr = vim.api.nvim_get_current_tabpage() + M.tabid = vim.api.nvim_get_current_tabpage() if state.settings.discussion_diagnostic ~= nil or state.settings.discussion_sign ~= nil then u.notify( @@ -78,13 +78,13 @@ M.open = function() -- Register Diffview hook for close event to set tab page # to nil local on_diffview_closed = function(view) - if view.tabpage == M.tabnr then - M.tabnr = nil + if view.tabpage == M.tabid then + M.tabid = nil require("gitlab.actions.discussions.winbar").cleanup_timer() end end require("diffview.config").user_emitter:on("view_closed", function(_, args) - if M.tabnr == args.tabpage then + if M.tabid == args.tabpage then M.is_open = false on_diffview_closed(args) end @@ -101,8 +101,8 @@ end -- Closes the reviewer and cleans up M.close = function() - if M.tabnr ~= nil and vim.api.nvim_tabpage_is_valid(M.tabnr) then - vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(M.tabnr)) + if M.tabid ~= nil and vim.api.nvim_tabpage_is_valid(M.tabid) then + vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(M.tabid)) end local discussions = require("gitlab.actions.discussions") discussions.close() @@ -136,11 +136,11 @@ M.jump = function(file_name, old_file_name, line_number, new_buffer) -- Draft comments don't have `old_file_name` set old_file_name = old_file_name or file_name - if M.tabnr == nil then + if M.tabid == nil then u.notify("Can't jump to Diffvew. Is it open?", vim.log.levels.ERROR) return end - vim.api.nvim_set_current_tabpage(M.tabnr) + vim.api.nvim_set_current_tabpage(M.tabid) if M.diffview == nil then u.notify("Could not find Diffview view", vim.log.levels.ERROR) @@ -288,7 +288,7 @@ M.set_callback_for_file_changed = function(callback) pattern = { "DiffviewDiffBufWinEnter" }, group = group, callback = function(...) - if M.tabnr == vim.api.nvim_get_current_tabpage() then + if M.tabid == vim.api.nvim_get_current_tabpage() then callback(...) end end, @@ -303,7 +303,7 @@ M.set_callback_for_buf_read = function(callback) pattern = { "DiffviewDiffBufRead" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, @@ -318,7 +318,7 @@ M.set_callback_for_reviewer_leave = function(callback) pattern = { "DiffviewViewLeave", "DiffviewViewClosed" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, @@ -334,7 +334,7 @@ M.set_callback_for_reviewer_enter = function(callback) pattern = { "DiffviewViewEnter", "DiffviewViewOpened" }, group = group, callback = function(...) - if vim.api.nvim_get_current_tabpage() == M.tabnr then + if vim.api.nvim_get_current_tabpage() == M.tabid then callback(...) end end, From f05be7d4ee35e7203f8d193ce41a706adfb39e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 19 Apr 2026 08:37:45 +0200 Subject: [PATCH 12/15] feat: enable setting default rebase_mr.force value --- doc/gitlab.nvim.txt | 4 ++++ lua/gitlab/actions/rebase.lua | 8 +++++++- lua/gitlab/annotations.lua | 3 ++- lua/gitlab/state.lua | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index a0e78318..ac2ba05d 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -395,6 +395,10 @@ you call this function with no values the defaults will be used: border = "rounded", }, }, + rebase_mr = { + skip_ci = false, -- If true, a CI pipeline is not created. Can be overridden in gitlab.rebase call. + force = false, -- If true, MR is rebased even if MR already is rebased. Can be overridden in gitlab.rebase call. + }, colors = { discussion_tree = { username = "Keyword", diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index e84507eb..10b80d1a 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -25,7 +25,13 @@ local can_rebase = function(opts) local already_rebased = vim.iter(state.MERGEABILITY):find(function(check) return check.identifier == "NEED_REBASE" and check.status == "SUCCESS" end) - if already_rebased and not opts.force then + + local force = require("gitlab.state").settings.rebase_mr.force + if opts.force ~= nil then + force = opts.force + end + + if already_rebased and not force then u.notify("MR is already rebased", vim.log.levels.ERROR) return false end diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 378547bd..747dfb98 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -205,7 +205,8 @@ ---@field fork? ForkSettings ---@class RebaseMrSettings: table ----@field skip_ci? boolean -- Whether to skip CI after rabasing +---@field skip_ci? boolean -- If true, a CI pipeline is not created. +---@field force? boolean -- If true, MR is rebased even if MR already is rebased. ---@class ForkSettings: table ---@field enabled? boolean -- If making an MR from a fork diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index c27aeba6..35c058a6 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -207,6 +207,7 @@ M.settings = { }, rebase_mr = { skip_ci = false, + force = false, }, choose_merge_request = { open_reviewer = true, From 25438be10e437299aa83cf4651d1dee3844b5e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 20 Apr 2026 18:15:07 +0200 Subject: [PATCH 13/15] fix: enable rebasing when check for conflict is inactive --- lua/gitlab/actions/rebase.lua | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index 10b80d1a..d3c27fcb 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -37,10 +37,14 @@ local can_rebase = function(opts) end local has_conflicts = vim.iter(state.MERGEABILITY):find(function(check) - return check.identifier == "CONFLICT" and check.status ~= "SUCCESS" + return check.identifier == "CONFLICT" and check.status ~= "SUCCESS" and check.status ~= "INACTIVE" end) if has_conflicts then - u.notify("Rebase locally, resolve all conflicts, then push the branch", vim.log.levels.ERROR) + if has_conflicts.status == "FAILED" then + u.notify("Rebase locally, resolve all conflicts, then push the branch", vim.log.levels.ERROR) + else + u.notify("Check for conflicts hasn't passed successfully", vim.log.levels.WARN) + end return false end From 963d7818c22b056dedff4b2d8ddaaf26225d7a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 22 Apr 2026 00:29:10 +0200 Subject: [PATCH 14/15] perf: check branch up-to-date asynchronously Introducing more async git calls to prevent blocking UI (cursor and winbar updates) when checking if the local branch is up-to-date with the remote tracking branch. These checks can be async if we just want to notify the user and the following actions are not blocked by the branch being out-of-sync. --- lua/gitlab/actions/discussions/init.lua | 9 +- lua/gitlab/actions/rebase.lua | 2 +- lua/gitlab/actions/summary.lua | 3 +- lua/gitlab/git.lua | 71 +++++--------- lua/gitlab/git_async.lua | 124 ++++++++++++++++++++++++ lua/gitlab/reviewer/init.lua | 2 +- 6 files changed, 159 insertions(+), 52 deletions(-) create mode 100644 lua/gitlab/git_async.lua diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index eb1d6c4c..95a6c37d 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -56,8 +56,13 @@ end ---@param callback function|nil M.load_discussions = function(callback) local git = require("gitlab.git") - local ahead, behind = git.get_ahead_behind(git.get_current_branch(), git.get_remote_branch()) - state.ahead_behind = { ahead, behind } + require("gitlab.git_async").get_ahead_behind( + git.get_current_branch(), + git.get_remote_branch(), + function(ahead, behind) + state.ahead_behind = { ahead, behind } + end + ) state.discussion_tree.last_updated = nil state.load_new_state("discussion_data", function(data) if not state.DISCUSSION_DATA then diff --git a/lua/gitlab/actions/rebase.lua b/lua/gitlab/actions/rebase.lua index d3c27fcb..91bb6114 100644 --- a/lua/gitlab/actions/rebase.lua +++ b/lua/gitlab/actions/rebase.lua @@ -76,7 +76,7 @@ local confirm_rebase = function(rebase_body) job.run_job("/mr/rebase", "POST", rebase_body, function(data) u.notify(data.message .. ", updating local state", vim.log.levels.INFO) local state = require("gitlab.state") - require("gitlab.git").pull_async( + require("gitlab.git_async").pull( state.settings.connection_settings.remote, state.INFO.source_branch, on_pull_exit, diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 651b7a47..8f2fe0a9 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -32,6 +32,8 @@ M.summary = function() return end + require("gitlab.git_async").check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) + local title = state.INFO.title local description_lines = common.build_content(state.INFO.description) local info_lines = state.settings.info.enabled and M.build_info_lines() or { "" } @@ -89,7 +91,6 @@ M.summary = function() vim.api.nvim_set_current_buf(description_popup.bufnr) end) - git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) git.check_mr_in_good_condition() end diff --git a/lua/gitlab/git.lua b/lua/gitlab/git.lua index 90ac018c..01f0df2e 100644 --- a/lua/gitlab/git.lua +++ b/lua/gitlab/git.lua @@ -14,26 +14,6 @@ local run_system = function(command) return result, nil end ----Function to run when an async system call finishes. Receives the command's stdout as result when ----successful, or the command's stderr as err when unsuccessful. ----@alias OnExitCallback fun(result:string|nil, err:string|nil) - ----Runs a system command asynchronously ----@param command string[] ----@param on_exit OnExitCallback -local run_system_async = function(command, on_exit) - vim.system(command, { text = true }, function(result) - vim.schedule(function() - if result.code ~= 0 then - require("gitlab.utils").notify(result.stderr, vim.log.levels.ERROR) - on_exit(nil, result.stderr) - else - on_exit(vim.fn.trim(result.stdout), nil) - end - end) - end) -end - ---Returns all branches for the current repository ---@param args table|nil extra arguments for `git branch` ---@return string|nil, string|nil @@ -85,7 +65,11 @@ end ---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") ---@return boolean fetch_successful False if an error occurred while fetching, true otherwise. M.fetch_remote_branch = function(remote_branch) - local remote, branch = string.match(remote_branch, "([^/]+)/(.*)") + local remote, branch = string.match(remote_branch, "([^/]+)/(.+)") + if not remote or not branch then + require("gitlab.utils").notify("Invalid remote branch format: " .. remote_branch, vim.log.levels.ERROR) + return false + end local _, fetch_err = run_system({ "git", "fetch", remote, branch }) if fetch_err ~= nil then require("gitlab.utils").notify("Error fetching remote-tracking branch: " .. fetch_err, vim.log.levels.ERROR) @@ -124,26 +108,6 @@ M.get_ahead_behind = function(current_branch, remote_branch) return tonumber(ahead), tonumber(behind) end ----Pull a branch asynchronously from a remote and execute callback on exit. ----@param remote string The remote from which to pull. ----@param branch string The branch to pull. ----@param on_exit OnExitCallback The callback to execute when the command finishes. ----@param args string[]? Extra arguments passed to the `git pull` command. -M.pull_async = function(remote, branch, on_exit, args) - local current_branch = M.get_current_branch() - if not current_branch then - return - end - if current_branch ~= branch then - require("gitlab.utils").notify("Cannot pull. Remote branch is not the same as current branch", vim.log.levels.ERROR) - return - end - local cmd = { "git", "pull" } - vim.list_extend(cmd, args or {}) - vim.list_extend(cmd, { remote, branch }) - run_system_async(cmd, on_exit) -end - ---Return the name of the current branch or nil if it can't be retrieved ---@return string|nil M.get_current_branch = function() @@ -199,13 +163,13 @@ M.contains_branch = function(current_branch) return run_system({ "git", "branch", "-r", "--contains", current_branch }) end ----Returns true if `branch` is up-to-date on remote, otherwise false and warns user ----@param log_level integer +--- Returns true if `branch` is up-to-date on remote, otherwise false and warns user +---@param ahead integer|nil The number of commits the current branch is ahead of remote +---@param behind integer|nil The number of commits the current branch is behind remote +---@param remote_branch string|nil The remote branch, e.g., origin/feature-branch +---@param log_level number ---@return boolean -M.check_current_branch_up_to_date_on_remote = function(log_level) - local current_branch = M.get_current_branch() - local remote_branch = M.get_remote_branch() - local ahead, behind = M.get_ahead_behind(current_branch, remote_branch) +M.evaluate_ahead_behind = function(ahead, behind, remote_branch, log_level) if ahead == nil or behind == nil then return false end @@ -235,6 +199,19 @@ M.check_current_branch_up_to_date_on_remote = function(log_level) return true -- Checks passed, branch is up-to-date end +--- Returns true if `branch` is up-to-date on remote, otherwise false and notifies user. +--- This is a blocking function. For a non-blocking version use +--- gitlab.git_async.check_current_branch_up_to_date_on_remote. +---@param log_level number The log level with which user will be notified +---@return boolean +M.check_current_branch_up_to_date_on_remote = function(log_level) + local current_branch = M.get_current_branch() + local remote_branch = M.get_remote_branch() + local ahead, behind = M.get_ahead_behind(current_branch, remote_branch) + require("gitlab.state").ahead_behind = { ahead, behind } + return M.evaluate_ahead_behind(ahead, behind, remote_branch, log_level) +end + ---Warns user if the current MR is in a bad state (closed, has conflicts, merged) M.check_mr_in_good_condition = function() local state = require("gitlab.state") diff --git a/lua/gitlab/git_async.lua b/lua/gitlab/git_async.lua new file mode 100644 index 00000000..578b0ec5 --- /dev/null +++ b/lua/gitlab/git_async.lua @@ -0,0 +1,124 @@ +local M = {} + +---Function to run when an async system call finishes. Receives the command's stdout as result when +---successful, or the command's stderr as err when unsuccessful. +---@alias OnExitCallback fun(result:string|nil, err:string|nil) + +---Function to run on the result of getting the ahead and behind in the get_ahead_behind function. +---@alias GetAheadBehindCallback fun(ahead:integer|nil, behind:integer|nil, remote_branch:string|nil) + +---Runs a system command asynchronously +---@param command string[] +---@param on_exit OnExitCallback +local run_system_async = function(command, on_exit) + vim.system(command, { text = true }, function(result) + vim.schedule(function() + if result.code ~= 0 then + require("gitlab.utils").notify(result.stderr, vim.log.levels.ERROR) + on_exit(nil, result.stderr) + else + on_exit(vim.fn.trim(result.stdout), nil) + end + end) + end) +end + +---Pull a branch asynchronously from a remote and execute callback on exit. +---@param remote string The remote from which to pull. +---@param branch string The branch to pull. +---@param on_exit OnExitCallback The callback to execute when the command finishes. +---@param args string[]? Extra arguments passed to the `git pull` command. +M.pull = function(remote, branch, on_exit, args) + local current_branch = require("gitlab.git").get_current_branch() + if not current_branch then + return + end + if current_branch ~= branch then + on_exit(nil, "Cannot pull. Remote branch is not the same as current branch") + return + end + local cmd = { "git", "pull" } + vim.list_extend(cmd, args or {}) + vim.list_extend(cmd, { remote, branch }) + run_system_async(cmd, on_exit) +end + +---Fetch the remote branch +---@param remote_branch string The name of the repo and branch to fetch (e.g., "origin/some_branch") +---@param on_exit OnExitCallback +M.fetch_remote_branch = function(remote_branch, on_exit) + local remote, branch = string.match(remote_branch, "([^/]+)/(.+)") + if not remote or not branch then + on_exit(nil, "Invalid remote branch format: " .. remote_branch) + return + end + run_system_async({ "git", "fetch", remote, branch }, on_exit) +end + +--- Determines the number of commits the current branch is ahead of or behind the remote branch and +--- runs on_exit callback with the values. +---@param current_branch string|nil +---@param remote_branch string|nil +---@param on_exit GetAheadBehindCallback +M.get_ahead_behind = function(current_branch, remote_branch, on_exit) + if current_branch == nil or remote_branch == nil then + on_exit(nil, nil, remote_branch) + return + end + + ---Callback to run after the async `git fetch` call exits + ---@param result string|nil The stdout from the `git fetch` call if any. + ---@param err string|nil The stderr from the `git fetch` call if any. + local fetch_remote_branch_callback = function(result, err) + if result ~= nil then + local u = require("gitlab.utils") + run_system_async( + { "git", "rev-list", "--left-right", "--count", current_branch .. "..." .. remote_branch }, + --- The function to run after the async `git rev-list` call exits + ---@param r string|nil The stdout from the `git rev-list` call if any. + ---@param e string|nil The stderr from the `git rev-list` call if any. + function(r, e) + if e ~= nil or r == nil then + u.notify("Could not determine if branch is up-to-date: " .. (e or "unknown error"), vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + return + end + local ahead, behind = r:match("(%d+)%s+(%d+)") + if ahead == nil or behind == nil then + u.notify("Error parsing ahead/behind information", vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + return + end + on_exit(tonumber(ahead), tonumber(behind), remote_branch) + return + end + ) + elseif err ~= nil then + require("gitlab.utils").notify("Error fetching remote-tracking branch: " .. err, vim.log.levels.ERROR) + on_exit(nil, nil, remote_branch) + end + end + + M.fetch_remote_branch(remote_branch, fetch_remote_branch_callback) +end + +---Callback to run on the result of getting the ahead and behind in the get_ahead_behind function. +---@param ahead integer|nil The number of commits the current branch is ahead of remote +---@param behind integer|nil The number of commits the current branch is behind remote +---@param remote_branch string|nil The remote from which to pull. +local check_up_to_date_callback = function(ahead, behind, remote_branch) + require("gitlab.state").ahead_behind = { ahead, behind } + require("gitlab.git").evaluate_ahead_behind(ahead, behind, remote_branch, vim.log.levels.WARN) +end + +--- Evaluates if `branch` is up-to-date on remote and warns user. +--- This is a non-blocking function. For a blocking version that can be used to abort further +--- execution if branch is not up-to-date use gitlab.git.check_current_branch_up_to_date_on_remote. +M.check_current_branch_up_to_date_on_remote = function() + local git = require("gitlab.git") + local current_branch = git.get_current_branch() + local remote_branch = git.get_remote_branch() + M.get_ahead_behind(current_branch, remote_branch, check_up_to_date_callback) +end + +return M diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 9f7b398b..2db01299 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -40,8 +40,8 @@ M.open = function() return end + require("gitlab.git_async").check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) local git = require("gitlab.git") - git.check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) local diffview_open_command = "DiffviewOpen" From 567f8a9719e3d043d6a79950c261aba3a742c1ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 22 Apr 2026 20:47:12 +0200 Subject: [PATCH 15/15] fix: remove unnecessary argument --- lua/gitlab/actions/summary.lua | 2 +- lua/gitlab/reviewer/init.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/gitlab/actions/summary.lua b/lua/gitlab/actions/summary.lua index 8f2fe0a9..13509ba7 100644 --- a/lua/gitlab/actions/summary.lua +++ b/lua/gitlab/actions/summary.lua @@ -32,7 +32,7 @@ M.summary = function() return end - require("gitlab.git_async").check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) + require("gitlab.git_async").check_current_branch_up_to_date_on_remote() local title = state.INFO.title local description_lines = common.build_content(state.INFO.description) diff --git a/lua/gitlab/reviewer/init.lua b/lua/gitlab/reviewer/init.lua index 2db01299..dd9f8243 100644 --- a/lua/gitlab/reviewer/init.lua +++ b/lua/gitlab/reviewer/init.lua @@ -40,7 +40,7 @@ M.open = function() return end - require("gitlab.git_async").check_current_branch_up_to_date_on_remote(vim.log.levels.WARN) + require("gitlab.git_async").check_current_branch_up_to_date_on_remote() local git = require("gitlab.git") local diffview_open_command = "DiffviewOpen"