Skip to content

Commit bf6c453

Browse files
committed
feat: Basic submodule support
New functionality: - When hitting `<cr>` on a submodule entry with changes in the status view, a new neogit instance is opened with the submodule as root. - When inside the status view of a submodule, hitting `gp` (default for the new `GoToParentRepo` mapping) opens a new neogit instance with the parent repo as root. Implementation details: - On new neogit instance creation, information about submodules and parent repo is cached. - Helper functions are defined to get the submodule under the cursor or the parent repo if present. - New git commands were added to: - Get a list of the submodules in the repo (in the new `git/submodule.lua` file) - Get the path to the parent repo (as a new flag for the `rev_parse` command) - The feature was validated manually but a new integration test was also introduced.
1 parent 8c75d6d commit bf6c453

File tree

10 files changed

+168
-0
lines changed

10 files changed

+168
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ neogit.setup {
482482
["y"] = "ShowRefs",
483483
["$"] = "CommandHistory",
484484
["Y"] = "YankSelected",
485+
["gp"] = "GoToParentRepo",
485486
["<c-r>"] = "RefreshBuffer",
486487
["<cr>"] = "GoToFile",
487488
["<s-cr>"] = "PeekFile",

doc/neogit.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ The following mappings can all be customized via the setup function.
484484
["y"] = "ShowRefs",
485485
["$"] = "CommandHistory",
486486
["Y"] = "YankSelected",
487+
["gp"] = "GoToParentRepo",
487488
["<c-r>"] = "RefreshBuffer",
488489
["<cr>"] = "GoToFile",
489490
["<s-cr>"] = "PeekFile",

lua/neogit/buffers/status/actions.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1276,6 +1276,18 @@ M.n_unstage_staged = function(self)
12761276
end)
12771277
end
12781278

1279+
---Opens neogit on the parent repo if if we are in a submodule
1280+
---@param self StatusBuffer
1281+
M.n_goto_parent_repo = function(self)
1282+
return function()
1283+
local parent = self:parent_repo()
1284+
if parent then
1285+
self:close()
1286+
require("neogit").open { cwd = parent }
1287+
end
1288+
end
1289+
end
1290+
12791291
---@param self StatusBuffer
12801292
---@return fun(): nil
12811293
M.n_goto_file = function(self)
@@ -1284,6 +1296,12 @@ M.n_goto_file = function(self)
12841296

12851297
-- Goto FILE
12861298
if item and item.absolute_path then
1299+
if self:has_submodule(item.absolute_path) then
1300+
self:close()
1301+
require("neogit").open { cwd = item.absolute_path }
1302+
return
1303+
end
1304+
12871305
local cursor = translate_cursor_location(self, item)
12881306
self:close()
12891307
vim.schedule_wrap(open)("edit", item.absolute_path, cursor)

lua/neogit/buffers/status/init.lua

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,52 @@ M.__index = M
2222

2323
local instances = {}
2424

25+
---@class SubmoduleInfo
26+
---@field submodules string[] A list with the relative paths to the project's submodules
27+
---@field parent_repo string? If we are in a submodule, cache the abs path to the parent repo
28+
29+
---@type table<string, SubmoduleInfo>
30+
local submodule_info_per_root = {}
31+
32+
---@return string?
33+
function M:parent_repo()
34+
local info = submodule_info_per_root[self.root]
35+
return info and info.parent_repo
36+
end
37+
38+
---@return string[]
39+
function M:submodules()
40+
local info = submodule_info_per_root[self.root]
41+
return info and info.submodules or {}
42+
end
43+
44+
---@param abs_path string
45+
---@return boolean
46+
function M:has_submodule(abs_path)
47+
local dir = require("plenary.path"):new(abs_path)
48+
if not dir:exists() or not dir:is_dir() then
49+
return false
50+
end
51+
local rel_path = dir:make_relative(self.cwd)
52+
for _, submodule in ipairs(self:submodules()) do
53+
if submodule == rel_path then
54+
return true
55+
end
56+
end
57+
return false
58+
end
59+
2560
---@param instance StatusBuffer
2661
---@param dir string
2762
function M.register(instance, dir)
2863
local dir = vim.fs.normalize(dir)
2964
logger.debug("[STATUS] Registering instance for: " .. dir)
3065

3166
instances[dir] = instance
67+
submodule_info_per_root[instance.root] = {
68+
submodules = git.submodule.list(),
69+
parent_repo = git.rev_parse.parent_repo(),
70+
}
3271
end
3372

3473
---@param dir? string
@@ -170,6 +209,7 @@ function M:open(kind)
170209
[mappings["Unstage"]] = self:_action("n_unstage"),
171210
[mappings["UnstageStaged"]] = self:_action("n_unstage_staged"),
172211
[mappings["GoToFile"]] = self:_action("n_goto_file"),
212+
[mappings["GoToParentRepo"]] = self:_action("n_goto_parent_repo"),
173213
[mappings["TabOpen"]] = self:_action("n_tab_open"),
174214
[mappings["SplitOpen"]] = self:_action("n_split_open"),
175215
[mappings["VSplitOpen"]] = self:_action("n_vertical_split_open"),

lua/neogit/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ end
222222
---| "Untrack"
223223
---| "RefreshBuffer"
224224
---| "GoToFile"
225+
---| "GoToParentRepo",
225226
---| "PeekFile"
226227
---| "VSplitOpen"
227228
---| "SplitOpen"
@@ -705,6 +706,7 @@ function M.get_default_values()
705706
["y"] = "ShowRefs",
706707
["$"] = "CommandHistory",
707708
["Y"] = "YankSelected",
709+
["gp"] = "GoToParentRepo",
708710
["<c-r>"] = "RefreshBuffer",
709711
["<cr>"] = "GoToFile",
710712
["<s-cr>"] = "PeekFile",

lua/neogit/lib/git.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
---@field sequencer NeogitGitSequencer
2626
---@field stash NeogitGitStash
2727
---@field status NeogitGitStatus
28+
---@field submodule NeogitGitSubmodule
2829
---@field tag NeogitGitTag
2930
---@field worktree NeogitGitWorktree
3031
---@field hooks NeogitGitHooks

lua/neogit/lib/git/cli.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ end
8484
---@field null_separated self
8585
---@field porcelain fun(string): self
8686

87+
---@class GitCommandSubmodule: GitCommandBuilder
88+
8789
---@class GitCommandLog: GitCommandBuilder
8890
---@field oneline self
8991
---@field branches self
@@ -321,6 +323,7 @@ end
321323
---@field no_flags self
322324
---@field symbolic self
323325
---@field symbolic_full_name self
326+
---@field show_superproject_working_tree self
324327
---@field abbrev_ref fun(ref: string): self
325328

326329
---@class GitCommandCherryPick: GitCommandBuilder
@@ -374,6 +377,7 @@ end
374377
---@field show-ref GitCommandShowRef
375378
---@field stash GitCommandStash
376379
---@field status GitCommandStatus
380+
---@field submodule GitCommandSubmodule
377381
---@field tag GitCommandTag
378382
---@field update-index GitCommandUpdateIndex
379383
---@field update-ref GitCommandUpdateRef
@@ -468,6 +472,8 @@ local configurations = {
468472
},
469473
},
470474

475+
submodule = config {},
476+
471477
log = config {
472478
flags = {
473479
oneline = "--oneline",
@@ -971,6 +977,7 @@ local configurations = {
971977
no_flags = "--no-flags",
972978
symbolic = "--symbolic",
973979
symbolic_full_name = "--symbolic-full-name",
980+
show_superproject_working_tree = "--show-superproject-working-tree",
974981
},
975982
options = {
976983
abbrev_ref = "--abbrev-ref",

lua/neogit/lib/git/rev_parse.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,9 @@ function M.full_name(rev)
3939
.call({ hidden = true, ignore_error = true }).stdout[1]
4040
end
4141

42+
---@return string?
43+
function M.parent_repo()
44+
return git.cli["rev-parse"].show_superproject_working_tree.call({ hidden = true, ignore_error = true }).stdout[1]
45+
end
46+
4247
return M

lua/neogit/lib/git/submodule.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
local git = require("neogit.lib.git")
2+
3+
---@class NeogitGitSubmodule
4+
local M = {}
5+
6+
---@return string[]
7+
function M.list()
8+
local result = git.cli["submodule"].call({ hidden = true, ignore_error = true }).stdout
9+
return vim.tbl_map(function(el)
10+
return vim.split(vim.trim(el), " +", { trimempty = true })[2]
11+
end, result)
12+
end
13+
14+
return M

spec/buffers/status_buffer_spec.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require "spec_helper"
4+
require "fileutils"
45

56
RSpec.describe "Status Buffer", :git, :nvim do
67
it "renders, raising no errors" do
@@ -82,4 +83,82 @@
8283
# context "with tracked file" do
8384
# end
8485
end
86+
87+
describe "submodule navigation" do
88+
let(:submodule_path) { File.join("deps", "nested-submodule") }
89+
let(:submodule_repo_root) { File.expand_path(submodule_path) }
90+
let!(:submodule_source_dir) { Dir.mktmpdir("neogit-submodule-source") }
91+
92+
before do
93+
initialize_submodule_source
94+
95+
git.config("protocol.file.allow", "always")
96+
unless system("git", "-c", "protocol.file.allow=always", "submodule", "add", submodule_source_dir, submodule_path)
97+
raise "Failed to add submodule"
98+
end
99+
100+
git.commit("Add submodule")
101+
102+
File.open(File.join(submodule_path, "file.txt"), "a") { _1.puts("local change") }
103+
nvim.lua(<<~LUA)
104+
local status = require("neogit.buffers.status")
105+
local instance = status.instance()
106+
if instance then
107+
status.register(instance, vim.uv.cwd())
108+
end
109+
LUA
110+
nvim.refresh
111+
end
112+
113+
after do
114+
FileUtils.remove_entry(submodule_source_dir) if File.directory?(submodule_source_dir)
115+
end
116+
117+
it "opens submodule status and returns to the parent repo twice" do
118+
# First jump and back
119+
await do
120+
expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)")
121+
end
122+
123+
nvim.move_to_line(submodule_path)
124+
nvim.keys("<cr>")
125+
126+
await do
127+
expect(nvim.fn("getcwd", [])).to eq(submodule_repo_root)
128+
expect(nvim.screen.join("\n")).to include("modified file.txt")
129+
end
130+
131+
nvim.keys("gp")
132+
133+
await do
134+
expect(nvim.fn("getcwd", [])).to eq(Dir.pwd)
135+
expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)")
136+
end
137+
138+
# Second jump and back
139+
nvim.move_to_line(submodule_path)
140+
nvim.keys("<cr>")
141+
142+
await do
143+
expect(nvim.fn("getcwd", [])).to eq(submodule_repo_root)
144+
expect(nvim.screen.join("\n")).to include("modified file.txt")
145+
end
146+
147+
nvim.keys("gp")
148+
149+
await do
150+
expect(nvim.fn("getcwd", [])).to eq(Dir.pwd)
151+
expect(nvim.screen.join("\n")).to include("#{submodule_path} (modified content)")
152+
end
153+
end
154+
155+
def initialize_submodule_source
156+
repo = Git.init(submodule_source_dir)
157+
repo.config("user.email", "test@example.com")
158+
repo.config("user.name", "tester")
159+
File.write(File.join(submodule_source_dir, "file.txt"), "submodule file\n")
160+
repo.add("file.txt")
161+
repo.commit("Initial submodule commit")
162+
end
163+
end
85164
end

0 commit comments

Comments
 (0)