diff --git a/README.md b/README.md index 0748191a9..5ccb2d8f3 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,7 @@ neogit.setup { ["y"] = "ShowRefs", ["$"] = "CommandHistory", ["Y"] = "YankSelected", + ["gp"] = "GoToParentRepo", [""] = "RefreshBuffer", [""] = "GoToFile", [""] = "PeekFile", diff --git a/doc/neogit.txt b/doc/neogit.txt index 84cfda845..bf50beae5 100644 --- a/doc/neogit.txt +++ b/doc/neogit.txt @@ -484,6 +484,7 @@ The following mappings can all be customized via the setup function. ["y"] = "ShowRefs", ["$"] = "CommandHistory", ["Y"] = "YankSelected", + ["gp"] = "GoToParentRepo", [""] = "RefreshBuffer", [""] = "GoToFile", [""] = "PeekFile", diff --git a/lua/neogit/buffers/status/actions.lua b/lua/neogit/buffers/status/actions.lua index 4ad14d8bc..0c0bbc1c5 100644 --- a/lua/neogit/buffers/status/actions.lua +++ b/lua/neogit/buffers/status/actions.lua @@ -1276,6 +1276,18 @@ M.n_unstage_staged = function(self) end) end +---Opens neogit on the parent repo if if we are in a submodule +---@param self StatusBuffer +M.n_goto_parent_repo = function(self) + return function() + local parent = self:parent_repo() + if parent then + self:close() + require("neogit").open { cwd = parent } + end + end +end + ---@param self StatusBuffer ---@return fun(): nil M.n_goto_file = function(self) @@ -1284,6 +1296,12 @@ M.n_goto_file = function(self) -- Goto FILE if item and item.absolute_path then + if self:has_submodule(item.absolute_path) then + self:close() + require("neogit").open { cwd = item.absolute_path } + return + end + local cursor = translate_cursor_location(self, item) self:close() vim.schedule_wrap(open)("edit", item.absolute_path, cursor) diff --git a/lua/neogit/buffers/status/init.lua b/lua/neogit/buffers/status/init.lua index 387d7d23f..df818ebed 100644 --- a/lua/neogit/buffers/status/init.lua +++ b/lua/neogit/buffers/status/init.lua @@ -22,6 +22,41 @@ M.__index = M local instances = {} +---@class SubmoduleInfo +---@field submodules string[] A list with the relative paths to the project's submodules +---@field parent_repo string? If we are in a submodule, cache the abs path to the parent repo + +---@type table +local submodule_info_per_root = {} + +---@return string? +function M:parent_repo() + local info = submodule_info_per_root[self.root] + return info and info.parent_repo +end + +---@return string[] +function M:submodules() + local info = submodule_info_per_root[self.root] + return info and info.submodules or {} +end + +---@param abs_path string +---@return boolean +function M:has_submodule(abs_path) + local dir = require("plenary.path"):new(abs_path) + if not dir:exists() or not dir:is_dir() then + return false + end + local rel_path = dir:make_relative(self.cwd) + for _, submodule in ipairs(self:submodules()) do + if submodule == rel_path then + return true + end + end + return false +end + ---@param instance StatusBuffer ---@param dir string function M.register(instance, dir) @@ -29,6 +64,10 @@ function M.register(instance, dir) logger.debug("[STATUS] Registering instance for: " .. dir) instances[dir] = instance + submodule_info_per_root[instance.root] = { + submodules = git.submodule.list(), + parent_repo = git.rev_parse.parent_repo(), + } end ---@param dir? string @@ -170,6 +209,7 @@ function M:open(kind) [mappings["Unstage"]] = self:_action("n_unstage"), [mappings["UnstageStaged"]] = self:_action("n_unstage_staged"), [mappings["GoToFile"]] = self:_action("n_goto_file"), + [mappings["GoToParentRepo"]] = self:_action("n_goto_parent_repo"), [mappings["TabOpen"]] = self:_action("n_tab_open"), [mappings["SplitOpen"]] = self:_action("n_split_open"), [mappings["VSplitOpen"]] = self:_action("n_vertical_split_open"), diff --git a/lua/neogit/config.lua b/lua/neogit/config.lua index 6a47bd8fb..cf3930ba6 100644 --- a/lua/neogit/config.lua +++ b/lua/neogit/config.lua @@ -222,6 +222,7 @@ end ---| "Untrack" ---| "RefreshBuffer" ---| "GoToFile" +---| "GoToParentRepo", ---| "PeekFile" ---| "VSplitOpen" ---| "SplitOpen" @@ -705,6 +706,7 @@ function M.get_default_values() ["y"] = "ShowRefs", ["$"] = "CommandHistory", ["Y"] = "YankSelected", + ["gp"] = "GoToParentRepo", [""] = "RefreshBuffer", [""] = "GoToFile", [""] = "PeekFile", diff --git a/lua/neogit/lib/git.lua b/lua/neogit/lib/git.lua index ac8dc4c26..6f30449a1 100644 --- a/lua/neogit/lib/git.lua +++ b/lua/neogit/lib/git.lua @@ -25,6 +25,7 @@ ---@field sequencer NeogitGitSequencer ---@field stash NeogitGitStash ---@field status NeogitGitStatus +---@field submodule NeogitGitSubmodule ---@field tag NeogitGitTag ---@field worktree NeogitGitWorktree ---@field hooks NeogitGitHooks diff --git a/lua/neogit/lib/git/cli.lua b/lua/neogit/lib/git/cli.lua index f4eb5cf19..fe2bdc176 100644 --- a/lua/neogit/lib/git/cli.lua +++ b/lua/neogit/lib/git/cli.lua @@ -84,6 +84,8 @@ end ---@field null_separated self ---@field porcelain fun(string): self +---@class GitCommandSubmodule: GitCommandBuilder + ---@class GitCommandLog: GitCommandBuilder ---@field oneline self ---@field branches self @@ -321,6 +323,7 @@ end ---@field no_flags self ---@field symbolic self ---@field symbolic_full_name self +---@field show_superproject_working_tree self ---@field abbrev_ref fun(ref: string): self ---@class GitCommandCherryPick: GitCommandBuilder @@ -374,6 +377,7 @@ end ---@field show-ref GitCommandShowRef ---@field stash GitCommandStash ---@field status GitCommandStatus +---@field submodule GitCommandSubmodule ---@field tag GitCommandTag ---@field update-index GitCommandUpdateIndex ---@field update-ref GitCommandUpdateRef @@ -468,6 +472,8 @@ local configurations = { }, }, + submodule = config {}, + log = config { flags = { oneline = "--oneline", @@ -971,6 +977,7 @@ local configurations = { no_flags = "--no-flags", symbolic = "--symbolic", symbolic_full_name = "--symbolic-full-name", + show_superproject_working_tree = "--show-superproject-working-tree", }, options = { abbrev_ref = "--abbrev-ref", diff --git a/lua/neogit/lib/git/rev_parse.lua b/lua/neogit/lib/git/rev_parse.lua index b44d98196..978ae4089 100644 --- a/lua/neogit/lib/git/rev_parse.lua +++ b/lua/neogit/lib/git/rev_parse.lua @@ -39,4 +39,9 @@ function M.full_name(rev) .call({ hidden = true, ignore_error = true }).stdout[1] end +---@return string? +function M.parent_repo() + return git.cli["rev-parse"].show_superproject_working_tree.call({ hidden = true, ignore_error = true }).stdout[1] +end + return M diff --git a/lua/neogit/lib/git/submodule.lua b/lua/neogit/lib/git/submodule.lua new file mode 100644 index 000000000..c1ad1f3ee --- /dev/null +++ b/lua/neogit/lib/git/submodule.lua @@ -0,0 +1,14 @@ +local git = require("neogit.lib.git") + +---@class NeogitGitSubmodule +local M = {} + +---@return string[] +function M.list() + local result = git.cli.submodule.call({ hidden = true, ignore_error = true }).stdout + return vim.tbl_map(function(el) + return vim.split(vim.trim(el), " +", { trimempty = true })[2] + end, result) +end + +return M diff --git a/spec/buffers/status_buffer_spec.rb b/spec/buffers/status_buffer_spec.rb index 48facd466..6eb27a698 100644 --- a/spec/buffers/status_buffer_spec.rb +++ b/spec/buffers/status_buffer_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "spec_helper" +require "fileutils" RSpec.describe "Status Buffer", :git, :nvim do it "renders, raising no errors" do @@ -82,4 +83,82 @@ # context "with tracked file" do # end end + + describe "submodule navigation" do + let(:submodule_path) { File.join("deps", "nested-submodule") } + let(:submodule_repo_root) { File.expand_path(submodule_path) } + let!(:submodule_source_dir) { Dir.mktmpdir("neogit-submodule-source") } + + before do + initialize_submodule_source + + git.config("protocol.file.allow", "always") + unless system("git", "-c", "protocol.file.allow=always", "submodule", "add", submodule_source_dir, submodule_path) + raise "Failed to add submodule" + end + + git.commit("Add submodule") + + File.open(File.join(submodule_path, "file.txt"), "a") { _1.puts("local change") } + nvim.lua(<<~LUA) + local status = require("neogit.buffers.status") + local instance = status.instance() + if instance then + status.register(instance, vim.uv.cwd()) + end + LUA + nvim.refresh + end + + after do + FileUtils.remove_entry(submodule_source_dir) if File.directory?(submodule_source_dir) + end + + it "opens submodule status and returns to the parent repo twice" do + # First jump and back + await do + expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)") + end + + nvim.move_to_line(submodule_path) + nvim.keys("") + + await do + expect(nvim.fn("getcwd", [])).to eq(submodule_repo_root) + expect(nvim.screen.join("\n")).to include("modified file.txt") + end + + nvim.keys("gp") + + await do + expect(nvim.fn("getcwd", [])).to eq(Dir.pwd) + expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)") + end + + # Second jump and back + nvim.move_to_line(submodule_path) + nvim.keys("") + + await do + expect(nvim.fn("getcwd", [])).to eq(submodule_repo_root) + expect(nvim.screen.join("\n")).to include("modified file.txt") + end + + nvim.keys("gp") + + await do + expect(nvim.fn("getcwd", [])).to eq(Dir.pwd) + expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)") + end + end + + def initialize_submodule_source + repo = Git.init(submodule_source_dir) + repo.config("user.email", "test@example.com") + repo.config("user.name", "tester") + File.write(File.join(submodule_source_dir, "file.txt"), "submodule file\n") + repo.add("file.txt") + repo.commit("Initial submodule commit") + end + end end