Neovim sign gutter, designed to be mostly VCS agnostic
1local M = {}
2
3local util = require "vcsigns.util"
4local repo_common = require "vcsigns.repo_def.common"
5
6--- List of VCSs, in priority order.
7---@type VcsInterface[]
8M.vcs = {
9 require "vcsigns.repo_def.jj",
10 require "vcsigns.repo_def.git",
11 require "vcsigns.repo_def.hg",
12}
13
14--- Register a custom VCS implementation.
15--- The VCS will be added at the beginning of the detection priority list.
16---@param vcs Vcs The VCS implementation to register.
17function M.register_vcs(vcs)
18 table.insert(M.vcs, 1, vcs)
19end
20
21--- Get the absolute path of the file in the buffer.
22--- @param bufnr integer The buffer number.
23--- @return string The absolute path of the file.
24local function _get_path(bufnr)
25 return vim.fn.resolve(
26 vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":p")
27 )
28end
29
30local function _target_commit()
31 return vim.g.vcsigns_target_commit or 0
32end
33
34--- Get the target for the current buffer.
35---@param bufnr integer The buffer number.
36---@param vcs Vcs The version control system.
37---@return Target
38local function _get_target(bufnr, vcs)
39 local path = _get_path(bufnr)
40 assert(vcs.root, "VCS root must be set")
41
42 -- Make file path relative to repo root.
43 local root = vcs.root
44 local file
45 if path:sub(1, #root) == root then
46 file = path:sub(#root + 2) -- +2 to skip root and the path separator.
47 else
48 -- File is outside the repo root.
49 error(string.format("File %s is not under repo root %s", path, root))
50 end
51
52 return {
53 commit = _target_commit(),
54 file = file,
55 path = path,
56 }
57end
58
59local function _is_available(vcs)
60 local programs = {
61 vcs.detect.cmd()[1],
62 }
63 for _, program in ipairs(programs) do
64 if vim.fn.executable(program) == 0 then
65 util.verbose("VCS command not executable: " .. program)
66 return false
67 end
68 end
69 return true
70end
71
72---@param bufnr integer The buffer number.
73---@param vcs Vcs The version control system to use.
74---@param target Target The target for the VCS command.
75---@param cb fun(lines: string[]|nil) Callback function to handle the output.
76local function _show_file_impl(bufnr, vcs, target, cb)
77 util.run_with_timeout(vcs.show.cmd(target), { cwd = vcs.root }, function(out)
78 -- If the buffer was deleted, bail.
79 if not vim.api.nvim_buf_is_valid(bufnr) then
80 util.verbose "Buffer no longer valid, skipping diff"
81 return nil
82 end
83 local old_contents = out.stdout
84 if not old_contents then
85 util.verbose "No output from command, skipping diff"
86 return nil
87 end
88 if not vcs.show.check(out) then
89 util.verbose "VCS decided to not produce a file, skipping diff"
90 return nil
91 end
92
93 -- Convert to lines as early as possible.
94 if old_contents == "" then
95 -- Non-existent file.
96 cb {}
97 end
98 if old_contents:sub(-1) == "\n" then
99 -- Trim trailing newline if present.
100 old_contents = old_contents:sub(1, -2)
101 end
102 local old_lines = vim.split(old_contents, "\n", { plain = true })
103 cb(old_lines)
104 end)
105end
106
107--- Get the relevant file contents of the file according to the VCS.
108---@param bufnr integer The buffer number.
109---@param vcs Vcs The version control system to use.
110---@param cb fun(lines: string[]|nil) Callback function to handle the output.
111function M.show_file(bufnr, vcs, cb)
112 local target = _get_target(bufnr, vcs)
113 if vcs.resolve_rename then
114 util.verbose("Resolving rename for " .. target.file)
115 util.run_with_timeout(
116 vcs.resolve_rename.cmd(target),
117 { cwd = vcs.root },
118 function(out)
119 -- If the buffer was deleted, bail.
120 if not vim.api.nvim_buf_is_valid(bufnr) then
121 util.verbose "Buffer no longer valid, skipping"
122 return
123 end
124 local resolved_file = vcs.resolve_rename.extract(out, target)
125 if resolved_file then
126 util.verbose(
127 "Rename found: " .. target.file .. " -> " .. resolved_file
128 )
129 vim.b[bufnr].vcsigns_resolved_rename =
130 { to = target.file, from = resolved_file }
131 target.file = resolved_file
132 end
133 _show_file_impl(bufnr, vcs, target, cb)
134 end
135 )
136 else
137 _show_file_impl(bufnr, vcs, target, cb)
138 end
139end
140
141--- Detect the VCS for the current buffer.
142---@param bufnr integer The buffer number.
143---@return Vcs|nil The detected VCS or nil if no VCS was detected.
144function M.detect_vcs(bufnr)
145 local file_dir = util.file_dir(bufnr)
146 -- If the file dir does not exist, things will end poorly.
147 if vim.fn.isdirectory(file_dir) == 0 then
148 util.verbose("File directory does not exist: " .. file_dir)
149 return nil
150 end
151 for _, vcs in ipairs(M.vcs) do
152 util.verbose("Trying to detect VCS " .. vcs.name)
153 if not _is_available(vcs) then
154 util.verbose("VCS " .. vcs.name .. " is not available")
155 goto continue
156 end
157 local detect_cmd = vcs.detect.cmd()
158 local res = util.run_with_timeout(detect_cmd, { cwd = file_dir }):wait()
159 local detection_result = vcs.detect.check(res)
160 if detection_result.detected then
161 return repo_common.vcs_with_root(vcs, detection_result.root)
162 end
163 ::continue::
164 end
165 return nil
166end
167
168return M