From 511311b4664a7dfe07fc384e7ae7ba9dce5f52a7 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/21] 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 e68fa9daa585630aae165a27ff24b36e7f893309 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/21] 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 cb57589d6901e042f54b9608f08b3f86ca1b2d94 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/21] 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 018e8351c5efd36503ac757fe8d598cda8d73088 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/21] 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 2cd45eaa819544ad36a60c035f4c21abfad4a778 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/21] 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 a451831d60a887181fba8691456874462244235c 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/21] 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 9d9346dc3e02ceba7fa89becd77cda577dbcc746 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/21] 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 df0409de2592446d7ab0537c2344468d4c9b0bd0 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/21] 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 3a2cd52a3402f66a6bf07d8659f219606cca8a32 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/21] 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 e173f2d44746f2928a52d446e5f7601a69384d86 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/21] 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 e2e00cbe904e72208aab66edbb613b6aad855709 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/21] 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 6afe3c72f546526dfb35d63d0f94380941d3aa46 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/21] 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 0ad1a3da8f128ba33624d781a4dc64cab9b660dd 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/21] 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 eb9561a2523c43ecd260abc7e27b6fa0cc2b623a 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/21] 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 0877c276f3e7810ccdafab9acd15334ad605f169 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/21] 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" From 36bb5ece85c0e188e843034112ffc080c36160eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Wed, 22 Apr 2026 21:22:53 +0200 Subject: [PATCH 16/21] feat: open discussions immediately load data afterwards --- doc/gitlab.nvim.txt | 1 + lua/gitlab/actions/discussions/init.lua | 55 ++++++++++++++++------- lua/gitlab/actions/discussions/tree.lua | 12 ++--- lua/gitlab/actions/discussions/winbar.lua | 4 +- lua/gitlab/annotations.lua | 1 + lua/gitlab/indicators/diagnostics.lua | 3 ++ lua/gitlab/init.lua | 4 -- lua/gitlab/state.lua | 1 + lua/gitlab/utils/init.lua | 3 ++ 9 files changed, 57 insertions(+), 27 deletions(-) diff --git a/doc/gitlab.nvim.txt b/doc/gitlab.nvim.txt index ac2ba05d..fae8b12e 100644 --- a/doc/gitlab.nvim.txt +++ b/doc/gitlab.nvim.txt @@ -264,6 +264,7 @@ you call this function with no values the defaults will be used: }, spinner_chars = { "/", "|", "\\", "-" }, -- Characters for the refresh animation auto_open = true, -- Automatically open when the reviewer is opened + enter_on_open = true, -- Automatically enter the discussion tree when it is opened default_view = "discussions", -- Show "discussions" or "notes" by default blacklist = {}, -- List of usernames to remove from tree (bots, CI, etc) sort_by = "latest_reply", -- Sort discussion tree by the "latest_reply", or by "original_comment", see `:h gitlab.nvim.toggle_sort_method` diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 95a6c37d..72f32c58 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -112,17 +112,23 @@ end ---@param callback function? ---@param view_type "discussions"|"notes" Defines the view type to select (useful for overriding the default view type when jumping to discussion tree when it's closed). M.open = function(callback, view_type) + local original_window = vim.api.nvim_get_current_win() -- The window from which ther user called M.open + view_type = view_type and view_type or state.settings.discussion_tree.default_view + state.DISCUSSION_DATA = u.ensure_table(state.DISCUSSION_DATA) state.DISCUSSION_DATA.discussions = u.ensure_table(state.DISCUSSION_DATA.discussions) state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(state.DISCUSSION_DATA.unlinked_discussions) state.DRAFT_NOTES = u.ensure_table(state.DRAFT_NOTES) - -- Make buffers, get and set buffer numbers, set filetypes + -- Make discussion split window and buffers, store buffer numbers local split, linked_bufnr, unlinked_bufnr = M.create_split_and_bufs() M.split = split M.linked_bufnr = linked_bufnr M.unlinked_bufnr = unlinked_bufnr + M.split_visible = true + split:mount() + -- Set window and buffer local options to discussion tree split after mounting the split for opt, val in pairs(state.settings.discussion_tree.winopts) do vim.api.nvim_set_option_value(opt, val, { win = M.split.winid }) end @@ -130,28 +136,43 @@ M.open = function(callback, view_type) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.linked_bufnr }) vim.api.nvim_set_option_value("filetype", "gitlab", { buf = M.unlinked_bufnr }) - M.split = split - M.split_visible = true - split:mount() + -- Set autocmds to clean up state when discussions buffers are deleted manually + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = M.linked_bufnr, + callback = function() + M.linked_bufnr = nil + end, + }) + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = M.unlinked_bufnr, + callback = function() + M.unlinked_bufnr = nil + end, + }) + + -- Set autocmd to clean up state when discussions split is closed manually + vim.api.nvim_create_autocmd("WinClosed", { + pattern = tostring(M.split.winid), + callback = M.close, + }) -- Initialize winbar module with data from buffers winbar.start_timer() winbar.set_buffers(M.linked_bufnr, M.unlinked_bufnr) winbar.switch_view_type(view_type) - local current_window = vim.api.nvim_get_current_win() -- Save user's current window in case they switched while content was loading - vim.api.nvim_set_current_win(M.split.winid) - - common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) - M.rebuild_discussion_tree() + -- Rebuild trees in order to set keymaps and make buffers protected M.rebuild_unlinked_discussion_tree() + M.rebuild_discussion_tree() - -- Set default buffer - local default_buffer = winbar.bufnr_map[view_type] - vim.api.nvim_set_current_buf(default_buffer) - common.switch_can_edit_bufs(false, M.linked_bufnr, M.unlinked_bufnr) + local win_to_enter = not state.settings.discussion_tree.enter_on_open and original_window or M.split.winid + if vim.api.nvim_win_is_valid(win_to_enter) then + vim.api.nvim_set_current_win(win_to_enter) + end + + -- Relooad data + draft_notes.rebuild_view(view_type == "notes") - vim.api.nvim_set_current_win(current_window) if type(callback) == "function" then callback() end @@ -441,6 +462,7 @@ M.rebuild_discussion_tree = function() end local current_node = discussions_tree.get_node_at_cursor(M.discussion_tree, M.last_node_at_cursor) + local current_cursor_column = vim.api.nvim_win_get_cursor(0)[2] local expanded_node_ids = M.gather_expanded_node_ids(M.discussion_tree) common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) @@ -463,7 +485,7 @@ M.rebuild_discussion_tree = function() tree_utils.open_node_by_id(discussion_tree, id) end discussion_tree:render() - discussions_tree.restore_cursor_position(M.split.winid, discussion_tree, current_node) + discussions_tree.restore_cursor_position(M.split.winid, discussion_tree, current_cursor_column, current_node, nil) M.set_tree_keymaps(discussion_tree, M.linked_bufnr, false) M.discussion_tree = discussion_tree @@ -479,6 +501,7 @@ M.rebuild_unlinked_discussion_tree = function() end local current_node = discussions_tree.get_node_at_cursor(M.unlinked_discussion_tree, M.last_node_at_cursor) + local current_cursor_column = vim.api.nvim_win_get_cursor(0)[2] local expanded_node_ids = M.gather_expanded_node_ids(M.unlinked_discussion_tree) common.switch_can_edit_bufs(true, M.linked_bufnr, M.unlinked_bufnr) @@ -501,7 +524,7 @@ M.rebuild_unlinked_discussion_tree = function() tree_utils.open_node_by_id(unlinked_discussion_tree, id) end unlinked_discussion_tree:render() - discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_node) + discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_cursor_column, current_node, nil) M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index e4f192ba..ffc838fc 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -387,6 +387,7 @@ M.toggle_nodes = function(winid, tree, unlinked, opts) return end local root_node = common.get_root_node(tree, current_node) + local current_cursor_column = vim.api.nvim_win_get_cursor(winid)[2] for _, node in ipairs(tree:get_nodes()) do if opts.toggle_resolved then if @@ -426,7 +427,7 @@ M.toggle_nodes = function(winid, tree, unlinked, opts) end end tree:render() - M.restore_cursor_position(winid, tree, current_node, root_node) + M.restore_cursor_position(winid, tree, current_cursor_column, current_node, root_node) end -- Get current node for restoring cursor position @@ -446,9 +447,10 @@ end ---Restore cursor position to the original node if possible ---@param winid integer Window number of the discussions split ---@param tree NuiTree The inline discussion tree or the unlinked discussion tree +---@param cursor_column integer The original column of the cursor ---@param original_node NuiTree.Node|nil The last node with the cursor ---@param root_node NuiTree.Node|nil The root node of the last node with the cursor -M.restore_cursor_position = function(winid, tree, original_node, root_node) +M.restore_cursor_position = function(winid, tree, cursor_column, original_node, root_node) if original_node == nil or tree == nil then return end @@ -460,10 +462,8 @@ M.restore_cursor_position = function(winid, tree, original_node, root_node) _, line_number = tree:get_node("-" .. tostring(root_node.id)) end end - if line_number ~= nil then - if vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_set_cursor(winid, { line_number, 0 }) - end + if line_number ~= nil and winid and vim.api.nvim_win_is_valid(winid) then + vim.api.nvim_win_set_cursor(winid, { line_number, cursor_column or 0 }) end end diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index 6dcd6011..cf5bc3f7 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -281,8 +281,10 @@ M.switch_view_type = function(override) M.current_view_type = "discussions" end end - + local discussions = require("gitlab.actions.discussions") + vim.api.nvim_set_option_value("winfixbuf", false, { win = discussions.split.winid }) vim.api.nvim_set_current_buf(M.bufnr_map[M.current_view_type]) + vim.api.nvim_set_option_value("winfixbuf", true, { win = discussions.split.winid }) M.update_winbar() end diff --git a/lua/gitlab/annotations.lua b/lua/gitlab/annotations.lua index 747dfb98..fcca3ba4 100644 --- a/lua/gitlab/annotations.lua +++ b/lua/gitlab/annotations.lua @@ -300,6 +300,7 @@ ---@class DiscussionSettings: table ---@field expanders? ExpanderOpts -- Customize the expander icons in the discussion tree ---@field auto_open? boolean -- Automatically open when the reviewer is opened +---@field enter_on_open? boolean -- Automatically enter the discussion tree when it is opened ---@field default_view? string - Show "discussions" or "notes" by default ---@field blacklist? table -- List of usernames to remove from tree (bots, CI, etc) ---@field keep_current_open? boolean -- If true, current discussion stays open even if it should otherwise be closed when toggling diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index 02ec9baa..f6d8ed42 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -113,6 +113,9 @@ end ---Filter and place the diagnostics for the given buffer. ---@param bufnr number The number of the buffer for placing diagnostics. M.place_diagnostics = function(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end if not state.settings.discussion_signs.enabled then return end diff --git a/lua/gitlab/init.lua b/lua/gitlab/init.lua index 2ef453ae..c6f286ea 100644 --- a/lua/gitlab/init.lua +++ b/lua/gitlab/init.lua @@ -30,8 +30,6 @@ local latest_pipeline = state.dependencies.latest_pipeline local revisions = state.dependencies.revisions local merge_requests_dep = state.dependencies.merge_requests local merge_requests_by_username_dep = state.dependencies.merge_requests_by_username -local draft_notes_dep = state.dependencies.draft_notes -local discussion_data = state.dependencies.discussion_data ---@param args Settings | {} | nil ---@return nil @@ -101,8 +99,6 @@ return { async.sequence({ info, user, - u.merge(draft_notes_dep, { refresh = true }), - u.merge(discussion_data, { refresh = true }), }, discussions.open)() end end, diff --git a/lua/gitlab/state.lua b/lua/gitlab/state.lua index 35c058a6..dd8766ca 100644 --- a/lua/gitlab/state.lua +++ b/lua/gitlab/state.lua @@ -167,6 +167,7 @@ M.settings = { }, spinner_chars = { "-", "\\", "|", "/" }, auto_open = true, + enter_on_open = true, default_view = "discussions", blacklist = {}, sort_by = "latest_reply", diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 0b239630..46fba69a 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -536,6 +536,9 @@ M.get_line_content = function(bufnr, start) end M.switch_can_edit_buf = function(buf, bool) + if not vim.api.nvim_buf_is_valid(buf) then + return + end vim.api.nvim_set_option_value("modifiable", bool, { buf = buf }) vim.api.nvim_set_option_value("readonly", not bool, { buf = buf }) end From 3edacdb6d96590dd40f4623a423f4909f9719967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Sun, 26 Apr 2026 15:56:16 +0200 Subject: [PATCH 17/21] fix: improve restoring cursor position after rebuilding trees --- lua/gitlab/actions/discussions/init.lua | 4 ++-- lua/gitlab/actions/discussions/tree.lua | 30 +++++++++++-------------- lua/gitlab/utils/init.lua | 9 -------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 72f32c58..278b0d45 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -524,7 +524,7 @@ M.rebuild_unlinked_discussion_tree = function() tree_utils.open_node_by_id(unlinked_discussion_tree, id) end unlinked_discussion_tree:render() - discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_cursor_column, current_node, nil) + discussions_tree.restore_cursor_position(M.split.winid, unlinked_discussion_tree, current_cursor_column, current_node) M.set_tree_keymaps(unlinked_discussion_tree, M.unlinked_bufnr, true) M.unlinked_discussion_tree = unlinked_discussion_tree @@ -704,7 +704,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.toggle_node then vim.keymap.set("n", keymaps.discussion_tree.toggle_node, function() - tree_utils.toggle_node(tree) + tree_utils.toggle_node(M.split.winid, tree) end, { buffer = bufnr, desc = "Toggle node", nowait = keymaps.discussion_tree.toggle_node_nowait }) end diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index ffc838fc..6210248e 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -256,10 +256,6 @@ M.create_node_list_by_file_name = function(node_list) return discussion_by_file_name end -local attach_uuid = function(str) - return { text = str, id = u.uuid() } -end - ---Build note node body ---@param note Note|DraftNote ---@param resolve_info table? @@ -267,18 +263,19 @@ end ---@return NuiTree.Node[] local function build_note_body(note, resolve_info) local text_nodes = {} - for bodyLine in u.split_by_new_lines(note.body or note.note) do - local line = attach_uuid(bodyLine) + local i = 0 + for body_line in u.split_by_new_lines(note.body or note.note) do table.insert( text_nodes, NuiTree.Node({ new_line = (type(note.position) == "table" and note.position.new_line), old_line = (type(note.position) == "table" and note.position.old_line), - text = line.text, - id = line.id, + text = body_line, + id = string.format("%d:%d", note.id, i), type = "note_body", }, {}) ) + i = i + 1 end local symbol = "" @@ -377,8 +374,8 @@ end ---@field keep_current_open boolean Whether to keep the current discussion open even if it should otherwise be closed. ---This function expands/collapses all nodes and their children according to the opts. ----@param tree NuiTree ---@param winid integer +---@param tree NuiTree ---@param unlinked boolean ---@param opts ToggleNodesOptions M.toggle_nodes = function(winid, tree, unlinked, opts) @@ -518,14 +515,14 @@ M.open_node_by_id = function(tree, id) end -- This function (settings.keymaps.discussion_tree.toggle_node) expands/collapses the current node and its children -M.toggle_node = function(tree) +---@param winid integer The id if the tree split. +---@param tree NuiTree The current discussion tree. +M.toggle_node = function(winid, tree) local node = tree:get_node() - if node == nil then - return - end + local current_cursor_column = vim.api.nvim_win_get_cursor(winid)[2] -- Switch to the "note" node from "note_body" nodes to enable toggling discussions inside comments - if node.type == "note_body" then + if node ~= nil and node.type == "note_body" then node = tree:get_node(node:get_parent_id()) end if node == nil then @@ -533,9 +530,7 @@ M.toggle_node = function(tree) end local children = node:get_child_ids() - if node == nil then - return - end + if node:is_expanded() then node:collapse() if common.is_node_note(node) then @@ -553,6 +548,7 @@ M.toggle_node = function(tree) end tree:render() + M.restore_cursor_position(winid, tree, current_cursor_column, node, common.get_root_node(tree, node)) end return M diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 46fba69a..944086e3 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -510,15 +510,6 @@ M.get_root_path = function() return vim.fn.fnamemodify(path, ":p:h:h:h:h") end -local random = math.random -M.uuid = function() - local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" - return string.gsub(template, "[xy]", function(c) - local v = (c == "x") and random(0, 0xf) or random(8, 0xb) - return string.format("%x", v) - end) -end - M.remove_last_chunk = function(sentence) local words = {} for word in sentence:gmatch("%S+") do From df542342dcad10c971b386bd03fd59c5c046e1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 27 Apr 2026 23:58:00 +0200 Subject: [PATCH 18/21] refactor: move view type switching from winbar to discussions --- lua/gitlab/actions/discussions/init.lua | 29 +++++++++++++----- lua/gitlab/actions/discussions/winbar.lua | 36 ++--------------------- 2 files changed, 24 insertions(+), 41 deletions(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 278b0d45..62caccc5 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -114,7 +114,7 @@ end M.open = function(callback, view_type) local original_window = vim.api.nvim_get_current_win() -- The window from which ther user called M.open - view_type = view_type and view_type or state.settings.discussion_tree.default_view + M.current_view_type = view_type and view_type or state.settings.discussion_tree.default_view state.DISCUSSION_DATA = u.ensure_table(state.DISCUSSION_DATA) state.DISCUSSION_DATA.discussions = u.ensure_table(state.DISCUSSION_DATA.discussions) state.DISCUSSION_DATA.unlinked_discussions = u.ensure_table(state.DISCUSSION_DATA.unlinked_discussions) @@ -156,22 +156,22 @@ M.open = function(callback, view_type) callback = M.close, }) - -- Initialize winbar module with data from buffers + -- Initialize winbar winbar.start_timer() - winbar.set_buffers(M.linked_bufnr, M.unlinked_bufnr) - winbar.switch_view_type(view_type) -- Rebuild trees in order to set keymaps and make buffers protected + M.switch_view_type(M.current_view_type) M.rebuild_unlinked_discussion_tree() M.rebuild_discussion_tree() + -- Focus the correct window local win_to_enter = not state.settings.discussion_tree.enter_on_open and original_window or M.split.winid if vim.api.nvim_win_is_valid(win_to_enter) then vim.api.nvim_set_current_win(win_to_enter) end -- Relooad data - draft_notes.rebuild_view(view_type == "notes") + draft_notes.rebuild_view(false, true) if type(callback) == "function" then callback() @@ -216,7 +216,7 @@ M.move_to_discussion_tree = function() end M.discussion_tree:render() vim.api.nvim_set_current_win(M.split.winid) - winbar.switch_view_type("discussions") + M.switch_view_type("discussions") vim.api.nvim_win_set_cursor(M.split.winid, { line_number, 0 }) end @@ -760,7 +760,7 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) if keymaps.discussion_tree.switch_view then vim.keymap.set("n", keymaps.discussion_tree.switch_view, function() - winbar.switch_view_type() + M.switch_view_type() end, { buffer = bufnr, desc = "Change view type between discussions and notes", @@ -827,6 +827,21 @@ M.set_tree_keymaps = function(tree, bufnr, unlinked) emoji.init_popup(tree, bufnr) end +---Toggles the current view type (or sets it to `override`) and then updates the view. +---@param override? "discussions"|"notes" The view type to select. +M.switch_view_type = function(override) + vim.api.nvim_set_option_value("winfixbuf", false, { win = M.split.winid }) + if override == "discussions" or M.current_view_type == "notes" then + M.current_view_type = "discussions" + vim.api.nvim_set_current_buf(M.linked_bufnr) + elseif override == "notes" or M.current_view_type == "discussions" then + M.current_view_type = "notes" + vim.api.nvim_set_current_buf(M.unlinked_bufnr) + end + vim.api.nvim_set_option_value("winfixbuf", true, { win = M.split.winid }) + winbar.update_winbar() +end + ---Toggle comments tree type between "simple" and "by_file_name" M.toggle_tree_type = function() if state.settings.discussion_tree.tree_type == "simple" then diff --git a/lua/gitlab/actions/discussions/winbar.lua b/lua/gitlab/actions/discussions/winbar.lua index cf5bc3f7..cae784ad 100644 --- a/lua/gitlab/actions/discussions/winbar.lua +++ b/lua/gitlab/actions/discussions/winbar.lua @@ -2,20 +2,7 @@ local u = require("gitlab.utils") local List = require("gitlab.utils.list") local state = require("gitlab.state") -local M = { - bufnr_map = { - discussions = nil, - notes = nil, - }, - current_view_type = state.settings.discussion_tree.default_view, -} - -M.set_buffers = function(linked_bufnr, unlinked_bufnr) - M.bufnr_map = { - discussions = linked_bufnr, - notes = unlinked_bufnr, - } -end +local M = {} ---@param nodes Discussion[]|UnlinkedDiscussion[]|nil ---@return number, number, number @@ -158,7 +145,7 @@ end ---@param t WinbarTable M.make_winbar = function(t) - local discussions_focused = M.current_view_type == "discussions" + local discussions_focused = require("gitlab.actions.discussions").current_view_type == "discussions" local discussion_text = add_drafts_and_resolvable( "Comments:", t.resolvable_discussions, @@ -269,25 +256,6 @@ M.get_ahead_behind = function(ahead, behind) return a .. "↑ " .. b .. "↓" end ----Toggles the current view type (or sets it to `override`) and then updates the view. ----@param override "discussions"|"notes" Defines the view type to select. -M.switch_view_type = function(override) - if override then - M.current_view_type = override - else - if M.current_view_type == "discussions" then - M.current_view_type = "notes" - elseif M.current_view_type == "notes" then - M.current_view_type = "discussions" - end - end - local discussions = require("gitlab.actions.discussions") - vim.api.nvim_set_option_value("winfixbuf", false, { win = discussions.split.winid }) - vim.api.nvim_set_current_buf(M.bufnr_map[M.current_view_type]) - vim.api.nvim_set_option_value("winfixbuf", true, { win = discussions.split.winid }) - M.update_winbar() -end - ---Set up a timer to update the winbar periodically M.start_timer = function() M.cleanup_timer() From 3519c2566f048a1a90a88b2e8bfed4327d1927c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 27 Apr 2026 23:58:29 +0200 Subject: [PATCH 19/21] fix: protect from invalid line number --- lua/gitlab/actions/discussions/tree.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index 6210248e..9f93ca6c 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -460,7 +460,8 @@ M.restore_cursor_position = function(winid, tree, cursor_column, original_node, end end if line_number ~= nil and winid and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_set_cursor(winid, { line_number, cursor_column or 0 }) + local last_line = vim.fn.line("$") + vim.api.nvim_win_set_cursor(winid, { math.min(line_number, last_line), cursor_column or 0 }) end end From 83cbd1aca2223af63bc6f153372a4bbd9d3b7e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Mon, 27 Apr 2026 23:59:05 +0200 Subject: [PATCH 20/21] refactor: replace deprecated function --- lua/gitlab/actions/discussions/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index 62caccc5..d2563022 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -89,7 +89,7 @@ M.initialize_discussions = function() M.refresh_diagnostics() end) reviewer.set_callback_for_buf_read(function(args) - vim.api.nvim_buf_set_option(args.buf, "modifiable", false) + vim.api.nvim_set_option_value("modifiable", false, { buf = args.buf }) reviewer.set_keymaps(args.buf) reviewer.set_reviewer_autocommands(args.buf) end) From 4186a8da0586b6c1198e3570d3381a7f5eff14dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20F=2E=20Bortl=C3=ADk?= Date: Tue, 28 Apr 2026 00:05:24 +0200 Subject: [PATCH 21/21] fix: use correct type annotation for optional parameters --- lua/gitlab/actions/common.lua | 6 +++--- lua/gitlab/actions/discussions/init.lua | 2 +- lua/gitlab/actions/discussions/tree.lua | 10 +++++----- lua/gitlab/actions/merge.lua | 2 +- lua/gitlab/git_async.lua | 2 +- lua/gitlab/indicators/diagnostics.lua | 2 +- lua/gitlab/popup.lua | 6 +++--- lua/gitlab/utils/init.lua | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/lua/gitlab/actions/common.lua b/lua/gitlab/actions/common.lua index 30140c7c..d8e66fbe 100644 --- a/lua/gitlab/actions/common.lua +++ b/lua/gitlab/actions/common.lua @@ -127,7 +127,7 @@ M.print_node = function(tree) end ---Check if type of node is note or note body ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return boolean M.is_node_note = function(node) if node and (node.type == "note_body" or node.type == "note") then @@ -139,7 +139,7 @@ end ---Get root node ---@param tree NuiTree ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return NuiTree.Node? M.get_root_node = function(tree, node) if not node then @@ -155,7 +155,7 @@ end ---Get note node ---@param tree NuiTree ----@param node NuiTree.Node? +---@param node? NuiTree.Node ---@return NuiTree.Node? M.get_note_node = function(tree, node) if not node then diff --git a/lua/gitlab/actions/discussions/init.lua b/lua/gitlab/actions/discussions/init.lua index d2563022..1e49ee75 100644 --- a/lua/gitlab/actions/discussions/init.lua +++ b/lua/gitlab/actions/discussions/init.lua @@ -109,7 +109,7 @@ end ---Opens the discussion tree, sets the keybindings. It also ---creates the tree for notes (which are not linked to specific lines of code) ----@param callback function? +---@param callback? function ---@param view_type "discussions"|"notes" Defines the view type to select (useful for overriding the default view type when jumping to discussion tree when it's closed). M.open = function(callback, view_type) local original_window = vim.api.nvim_get_current_win() -- The window from which ther user called M.open diff --git a/lua/gitlab/actions/discussions/tree.lua b/lua/gitlab/actions/discussions/tree.lua index 9f93ca6c..91c76c42 100644 --- a/lua/gitlab/actions/discussions/tree.lua +++ b/lua/gitlab/actions/discussions/tree.lua @@ -11,7 +11,7 @@ local M = {} ---Create nodes for NuiTree from discussions ---@param items Discussion[] ----@param unlinked boolean? False or nil means that discussions are linked to code lines +---@param unlinked? boolean False or nil means that discussions are linked to code lines ---@return NuiTree.Node[] M.add_discussions_to_table = function(items, unlinked) local t = {} @@ -102,7 +102,7 @@ end ---Create path node ---@param relative_path string ---@param full_path string ----@param child_nodes NuiTree.Node[]? +---@param child_nodes? NuiTree.Node[] ---@return NuiTree.Node local function create_path_node(relative_path, full_path, child_nodes) return NuiTree.Node({ @@ -154,7 +154,7 @@ end ---Create file name node ---@param file_name string ---@param full_file_path string ----@param child_nodes NuiTree.Node[]? +---@param child_nodes? NuiTree.Node[] ---@return NuiTree.Node local function create_file_name_node(file_name, full_file_path, child_nodes) local icon, icon_hl = u.get_icon(file_name) @@ -258,7 +258,7 @@ end ---Build note node body ---@param note Note|DraftNote ----@param resolve_info table? +---@param resolve_info? table ---@return string ---@return NuiTree.Node[] local function build_note_body(note, resolve_info) @@ -294,7 +294,7 @@ end ---Build note node ---@param note Note|DraftNote ----@param resolve_info table? +---@param resolve_info? table ---@return NuiTree.Node ---@return string ---@return NuiTree.Node[] diff --git a/lua/gitlab/actions/merge.lua b/lua/gitlab/actions/merge.lua index 5e0e5478..c8f5e097 100644 --- a/lua/gitlab/actions/merge.lua +++ b/lua/gitlab/actions/merge.lua @@ -46,7 +46,7 @@ M.merge = function(opts) end ---@param merge_body MergeOpts ----@param squash_message string? +---@param squash_message? string M.confirm_merge = function(merge_body, squash_message) if squash_message ~= nil then merge_body.squash_message = squash_message diff --git a/lua/gitlab/git_async.lua b/lua/gitlab/git_async.lua index 578b0ec5..ba7bf675 100644 --- a/lua/gitlab/git_async.lua +++ b/lua/gitlab/git_async.lua @@ -27,7 +27,7 @@ end ---@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. +---@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 diff --git a/lua/gitlab/indicators/diagnostics.lua b/lua/gitlab/indicators/diagnostics.lua index f6d8ed42..44abec68 100644 --- a/lua/gitlab/indicators/diagnostics.lua +++ b/lua/gitlab/indicators/diagnostics.lua @@ -88,7 +88,7 @@ end ---@param namespace number namespace for diagnostics ---@param bufnr number the bufnr for placing the diagnostics ---@param diagnostics table see :h vim.diagnostic.set ----@param opts table? see :h vim.diagnostic.set +---@param opts? table see :h vim.diagnostic.set local set_diagnostics = function(namespace, bufnr, diagnostics, opts) vim.diagnostic.set(namespace, bufnr, diagnostics, opts) require("gitlab.indicators.signs").set_signs(diagnostics, bufnr) diff --git a/lua/gitlab/popup.lua b/lua/gitlab/popup.lua index caa55f6d..1f95bf38 100644 --- a/lua/gitlab/popup.lua +++ b/lua/gitlab/popup.lua @@ -5,9 +5,9 @@ local M = {} ---Get the popup view_opts ---@param title string The string to appear on top of the popup ---@param user_settings table|nil User-defined popup settings ----@param width number? Override default width ----@param height number? Override default height ----@param zindex number? Override default zindex +---@param width? number Override default width +---@param height? number Override default height +---@param zindex? number Override default zindex ---@return table M.create_popup_state = function(title, user_settings, width, height, zindex) local settings = u.merge(require("gitlab.state").settings.popup, user_settings or {}) diff --git a/lua/gitlab/utils/init.lua b/lua/gitlab/utils/init.lua index 944086e3..e65648b0 100644 --- a/lua/gitlab/utils/init.lua +++ b/lua/gitlab/utils/init.lua @@ -657,7 +657,7 @@ M.get_web_url = function() M.notify("Could not get Gitlab URL", vim.log.levels.ERROR) end ----@param url string? +---@param url? string M.open_in_browser = function(url) if vim.fn.has("mac") == 1 then vim.fn.jobstart({ "open", url })