From 9a542bc36fec2999f99b58c9b0e715d901beeb0d 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/11] 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 1040deff..b7335d24 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -179,6 +179,8 @@ you call this function with no values the defaults will be used: approve = "glA", -- Approve MR revoke = "glR", -- Revoke MR approval merge = "glM", -- Merge the feature branch to the target branch and close MR + 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 @@ -1078,6 +1080,20 @@ Gitlab online. 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 a3fcb3ac..5d59140b 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 DiscussionSigns: table @@ -199,6 +200,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 da066e6b..0a43cbd2 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 5db54a11..3a27c77b 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -82,6 +82,8 @@ M.settings = { approve = "glA", revoke = "glR", merge = "glM", + rebase = "glrr", + rebase_skip_ci = "glrs", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -197,6 +199,9 @@ M.settings = { border = "rounded", }, }, + rebase_mr = { + skip_ci = false, + }, choose_merge_request = { open_reviewer = true, }, @@ -419,6 +424,18 @@ M.set_global_keymaps = function() end, { desc = "Merge MR", nowait = keymaps.global.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 70dbbb91957142ba32961d4f55849f8d3b74cc0a 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/11] 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 6b486be15673b52b60f3abeec4fe5097ed67d419 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/11] docs: add keymap description --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 12ca0cb7..67af9a41 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ glrd Delete reviewer glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR +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 d528de87583bbdff93380e82574171f56761cf28 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/11] 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 284b0c27a76146d2ef0e52f3e4d7a82262ed0f2c 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/11] docs: replace spaces with tabs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 67af9a41..1d88b8d9 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ glrd Delete reviewer glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR -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 94ac72eb12c2cdb783fa133ff92b5e2f19536ea1 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/11] 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 1d88b8d9..a198062a 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,9 @@ glrd Delete reviewer glA Approve MR glR Revoke MR approval glM Merge the feature branch to the target branch and close MR -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 b7335d24..83c04f93 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -181,6 +181,7 @@ you call this function with no values the defaults will be used: merge = "glM", -- Merge the feature branch to the target branch and close MR 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 @@ -1087,12 +1088,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 0a43cbd2..a98da566 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 3a27c77b..d264387e 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -84,6 +84,7 @@ M.settings = { merge = "glM", rebase = "glrr", rebase_skip_ci = "glrs", + rebase_force = "glrf", create_mr = "glC", choose_merge_request = "glc", start_review = "glS", @@ -436,6 +437,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 f7a2dec32a23e19b2be108ee56e870fd3c7236ec 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/11] 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 83c04f93..fa7462b2 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -835,6 +835,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 a98da566..1d35b4fe 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 bf6b796d9d3ca9ccb1691e6fb79c9dd1e59d6184 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/11] 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 a198062a..ff149004 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,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 fa7462b2..8d9cae14 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: 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 5d59140b..4bf6f4a2 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -368,6 +368,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 d264387e..560fc7dd 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -88,6 +88,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", @@ -329,6 +330,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 3787d5471e79adcdf6afeff5d23f7f748466137d 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/11] 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 49d2495b163821633af5227033ad3d1e90e3f616 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/11] 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 ff149004..074f462e 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 @@ -142,7 +142,7 @@ glR Revoke MR approval glM Merge the feature branch to the target branch and close MR 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 8d9cae14..2f586d96 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 @@ -612,6 +612,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 a9de8707868a53bebba9d67e945a59e6eb3d2553 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/11] 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,