forked from nvim-mini/mini.nvim
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit.lua
More file actions
1728 lines (1499 loc) · 71.1 KB
/
git.lua
File metadata and controls
1728 lines (1499 loc) · 71.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--- *mini.git* Git integration
---
--- MIT License Copyright (c) 2024 Evgeni Chasnovski
--- Features:
---
--- - Automated tracking of Git related data: root path, status, HEAD, etc.
--- Exposes buffer-local variables for convenient use in statusline.
--- See |MiniGit.enable()| and |MiniGit.get_buf_data()| for more information.
---
--- - |:Git| command for executing any `git` call inside file's repository root with
--- deeper current instance integration (show output as notification/buffer,
--- use to edit commit messages, etc.).
---
--- - Helper functions to inspect Git history:
--- - |MiniGit.show_range_history()| shows how certain line range evolved.
--- - |MiniGit.show_diff_source()| shows file state as it was at diff entry.
--- - |MiniGit.show_at_cursor()| shows Git related data depending on context.
---
--- What it doesn't do:
---
--- - Replace fully featured Git client. Rule of thumb: if feature does not rely
--- on a state of current Neovim (opened buffers, etc.), it is out of scope.
--- For more functionality, use either |mini.diff| or fully featured Git client.
---
--- Sources with more details:
--- - |:Git|
--- - |MiniGit-examples|
--- - |MiniGit.enable()|
--- - |MiniGit.get_buf_data()|
---
--- # Setup ~
---
--- This module needs a setup with `require('mini.git').setup({})` (replace `{}` with
--- your `config` table). It will create global Lua table `MiniGit` which you can use
--- for scripting or manually (with `:lua MiniGit.*`).
---
--- See |MiniGit.config| for `config` structure and default values.
---
--- # Comparisons ~
---
--- - [tpope/vim-fugitive](https://github.com/tpope/vim-fugitive):
--- - Mostly a dedicated Git client, while this module is not (by design).
--- - Provides buffer-local Git data only through fixed statusline component,
--- while this module has richer data in the form of a Lua table.
--- - Both provide |:Git| command with 'vim-fugitive' treating some cases
--- extra specially (like `:Git blame`, etc.), while this module mostly
--- treats all cases the same. See |MiniGit-examples| for how they can be
--- manually customized.
--- Also this module provides slightly different (usually richer)
--- completion suggestions.
---
--- - [NeogitOrg/neogit](https://github.com/NeogitOrg/neogit):
--- - Similar to 'tpope/vim-fugitive', but without `:Git` command.
---
--- - [lewis6991/gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim):
--- - Provides buffer-local Git data with emphasis on granular diff status,
--- while this module is more oriented towards repository and file level
--- data (root, HEAD, file status, etc.). Use |mini.diff| for diff tracking.
---
--- # Disabling ~
---
--- To prevent buffer(s) from being tracked, set `vim.g.minigit_disable` (globally)
--- or `vim.b.minigit_disable` (for a buffer) to `true`. Considering high number of
--- different scenarios and customization intentions, writing exact rules for
--- disabling module's functionality is left to user.
--- See |mini.nvim-disabling-recipes| for common recipes.
---@tag MiniGit
--- # Statusline component ~
---
--- Tracked buffer data can be used in statusline via `vim.b.minigit_summary_string`
--- buffer-local variable. It is expected to be used as is. To show another info,
--- tweak buffer-local variable directly inside `MiniGitUpdated` `User` event: >lua
---
--- -- Use only HEAD name as summary string
--- local format_summary = function(data)
--- -- Utilize buffer-local table summary
--- local summary = vim.b[data.buf].minigit_summary
--- vim.b[data.buf].minigit_summary_string = summary.head_name or ''
--- end
---
--- local au_opts = { pattern = 'MiniGitUpdated', callback = format_summary }
--- vim.api.nvim_create_autocmd('User', au_opts)
--- <
--- # Tweaking command output ~
---
--- Buffer output of |:Git| command can be tweaked inside autocommand for
--- `MiniGitCommandSplit` `User` event (see |MiniGit-command-events|).
--- For example, to make `:vertical Git blame -- %` align blame output with the
--- current window state, use the following code: >lua
---
--- local align_blame = function(au_data)
--- if au_data.data.git_subcommand ~= 'blame' then return end
---
--- -- Align blame output with source
--- local win_src = au_data.data.win_source
--- vim.wo.wrap = false
--- vim.fn.winrestview({ topline = vim.fn.line('w0', win_src) })
--- vim.api.nvim_win_set_cursor(0, { vim.fn.line('.', win_src), 0 })
---
--- -- Bind both windows so that they scroll together
--- vim.wo[win_src].scrollbind, vim.wo.scrollbind = true, true
--- end
---
--- local au_opts = { pattern = 'MiniGitCommandSplit', callback = align_blame }
--- vim.api.nvim_create_autocmd('User', au_opts)
--- <
--- # History navigation ~
---
--- Function |MiniGit.show_at_cursor()| is specifically exported to make Git
--- history navigation easier. Here are some different ways it can be used:
---
--- - Call inside buffer for already committed file to show the evolution of
--- the current line (or visually selected range) through history.
--- It is essentially a `:Git log HEAD` with proper `-L` flag.
--- This also works inside output of |MiniGit.show_diff_source()|.
---
--- - Call with cursor on commit hash to inspect that commit in full.
--- This is usually helpful in the output of `:Git log`.
---
--- - Call with cursor inside diff entry to inspect its file in the state how it
--- was at certain commit. By default it shows state after commit, unless cursor
--- is on the "deleted" line (i.e. line starting with "-") in which case
--- state before commit is shown.
---
--- This workflow can be made more interactive when used with mapping, like this: >lua
---
--- local rhs = '<Cmd>lua MiniGit.show_at_cursor()<CR>'
--- vim.keymap.set({ 'n', 'x' }, '<Leader>gs', rhs, { desc = 'Show at cursor' })
--- <
---@tag MiniGit-examples
--- The `:Git` user command runs `git` CLI call with extra integration for currently
--- opened Neovim process:
--- - Command is executed inside repository root of the currently active file
--- (or |current-directory| if file is not tracked by this module).
---
--- - Command output is shown either in dedicated buffer in window split or as
--- notification via |vim.notify()|. Which method is used depends on whether
--- particular Git subcommand is supposed to show data for user to inspect
--- (like `log`, `status`, etc.) or not (like `commit`, `push`, etc.). This is
--- determined automatically based on the data Git itself provides.
--- Split window is made current after command execution.
---
--- Use split-related |:command-modifiers| (|:vertical|, |:horizontal|, or |:tab|)
--- to force output in a particular type of split. Default split direction is
--- controlled by `command.split` in |MiniGit.config|.
---
--- Use |:silent| command modifier to not show any output.
---
--- Errors and warnings are always shown as notifications.
---
--- See |MiniGit-examples| for the example of tweaking command output.
---
--- - Editor for tasks that require interactive user input (like `:Git commit` or
--- `:Git rebase --interactive`) is opened inside current session in a separate
--- split. Make modifications as in regular buffer, |:write| changes followed by
--- |:close| / |:quit| for Git CLI command to resume.
---
--- Examples of usage:
--- - `:Git log --oneline` - show compact log of current repository.
--- - `:vert Git blame -- %` - show latest commits per line in vertical split.
--- - `:Git help rebase` - show help page for `rebase` subcommand.
--- - `:Git -C <cwd> status` - execute `git status` inside |current-directory|.
---
--- There is also a context aware completion which can be invoked with `<Tab>`:
--- - If completed word starts with "-", options for the current Git subcommand
--- are shown. Like completion at `:Git log -` will suggest `-L`, `--oneline`, etc.
--- - If there is an explicit " -- " to the cursor's left, incremental path
--- suggestions will be shown.
--- - If there is no recognized Git subcommand yet, show list of subcommands.
--- Otherwise for some common subcommands list of its targets will be suggested:
--- like for `:Git branch` it will be list of branches, etc.
---
--- Notes:
--- - Paths are always treated as relative to command's execution directory
--- (file's repository root or |current-directory| if absent).
--- - Don't use quotes for entries containing space, escape it with `\` directly.
--- Like `:Git commit -m Hello\ world` and not `:Git commit -m 'Hello world'`
--- (which treats `'Hello` and `world'` as separate arguments).
---
--- # Events ~
--- *MiniGit-command-events*
---
--- There are several `User` events triggered during command execution:
---
--- - `MiniGitCommandDone` - after command is done executing. For Lua callbacks it
--- provides a special `data` table with the following fields:
--- - <cmd_input> `(table)` - structured data about executed command.
--- Has same structure as Lua function input in |nvim_create_user_command()|.
--- - <cwd> `(string)` - directory path inside which Git command was executed.
--- - <exit_code> `(number)` - exit code of CLI process.
--- - <git_command> `(table)` - array with arguments of full executed command.
--- - <git_subcommand> `(string)` - detected Git subcommand (like "log", etc.).
--- - <stderr> `(string)` - `stderr` process output.
--- - <stdout> `(string)` - `stdout` process output.
---
--- - `MiniGitCommandSplit` - after command showed its output in a split. Triggered
--- after `MiniGitCommandDone` and provides similar `data` table with extra fields:
--- - <win_source> `(number)` - window identifier of "source" window (current at
--- the moment before command execution).
--- - <win_stdout> `(number)` - window identifier of command output.
---@tag :Git
---@alias __git_buf_id number Target buffer identifier. Default: 0 for current buffer.
---@alias __git_split_field <split> `(string)` - split direction. One of "horizontal", "vertical",
--- "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git'
--- buffers are shown in the tabpage and |:tab| otherwise.
---@diagnostic disable:undefined-field
---@diagnostic disable:discard-returns
---@diagnostic disable:unused-local
---@diagnostic disable:cast-local-type
---@diagnostic disable:undefined-doc-name
---@diagnostic disable:luadoc-miss-type-name
-- Module definition ==========================================================
local MiniGit = {}
local H = {}
--- Module setup
---
--- Besides general side effects (see |mini.nvim|), it also:
--- - Sets up auto enabling in every normal buffer for an actual file on disk.
--- - Creates |:Git| command.
---
---@param config table|nil Module config table. See |MiniGit.config|.
---
---@usage >lua
--- require('mini.git').setup() -- use default config
--- -- OR
--- require('mini.git').setup({}) -- replace {} with your config table
--- <
MiniGit.setup = function(config)
-- Export module
_G.MiniGit = MiniGit
-- Setup config
config = H.setup_config(config)
-- Apply config
H.apply_config(config)
-- Ensure proper Git executable
local exec = config.job.git_executable
H.has_git = vim.fn.executable(exec) == 1
if not H.has_git then H.notify('There is no `' .. exec .. '` executable', 'WARN') end
-- Define behavior
H.create_autocommands()
for _, buf_id in ipairs(vim.api.nvim_list_bufs()) do
H.auto_enable({ buf = buf_id })
end
-- Create user commands
H.create_user_commands()
end
--stylua: ignore
--- Defaults ~
---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section)
---@text # Job ~
---
--- `config.job` contains options for customizing CLI executions.
---
--- `job.git_executable` defines a full path to Git executable. Default: "git".
---
--- `job.timeout` is a duration (in ms) from job start until it is forced to stop.
--- Default: 30000.
---
--- # Command ~
---
--- `config.command` contains options for customizing |:Git| command.
---
--- `command.split` defines default split direction for |:Git| command output. Can be
--- one of "horizontal", "vertical", "tab", or "auto". Value "auto" uses |:vertical|
--- if only 'mini.git' buffers are shown in the tabpage and |:tab| otherwise.
--- Default: "auto".
MiniGit.config = {
-- General CLI execution
job = {
-- Path to Git executable
git_executable = 'git',
-- Timeout (in ms) for each job before force quit
timeout = 30000,
},
-- Options for `:Git` command
command = {
-- Default split direction
split = 'auto',
},
}
--minidoc_afterlines_end
--- Show Git related data at cursor
---
--- - If inside |mini.deps| confirmation buffer, show in split relevant commit data.
--- - If there is a commit-like |<cword>|, show it in split.
--- - If possible, show diff source via |MiniGit.show_diff_source()|.
--- - If possible, show range history via |MiniGit.show_range_history()|.
--- - Otherwise throw an error.
---
---@param opts table|nil Options. Possible values:
--- - __git_split_field
--- - Fields appropriate for forwarding to other functions.
MiniGit.show_at_cursor = function(opts)
-- Try showing commit data at cursor
local commit, cwd
if vim.bo.filetype == 'minideps-confirm' then
commit, cwd = H.deps_pos_to_source()
else
local cword = vim.fn.expand('<cword>')
local is_commit = string.find(cword, '^%x%x%x%x%x%x%x+$') ~= nil and string.lower(cword) == cword
commit = is_commit and cword or nil
cwd = is_commit and H.get_git_cwd() or nil
end
if commit ~= nil and cwd ~= nil then
local split = H.normalize_split_opt((opts or {}).split or 'auto', 'opts.split')
local args = { 'show', '--stat', '--patch', commit }
local lines = H.git_cli_output(args, cwd)
if #lines == 0 then return H.notify('Can not show commit ' .. commit .. ' in repo ' .. cwd, 'WARN') end
H.show_in_split(split, lines, 'show', table.concat(args, ' '))
vim.bo.filetype = 'git'
return
end
-- Try showing diff source
if H.diff_pos_to_source() ~= nil then return MiniGit.show_diff_source(opts) end
-- Try showing range history if possible: either in Git repo (tracked or not;
-- after resolving symlinks) or diff source output.
local buf_id, buf_name = vim.api.nvim_get_current_buf(), vim.api.nvim_buf_get_name(0)
local path = vim.loop.fs_realpath(buf_name)
local path_dir = path == nil and '' or vim.fn.fnamemodify(path, ':h')
local is_in_git = H.is_buf_enabled(buf_id) or #H.git_cli_output({ 'rev-parse', '--show-toplevel' }, path_dir) > 0
local is_diff_source_output = H.parse_diff_source_buf_name(buf_name) ~= nil
if is_in_git or is_diff_source_output then return MiniGit.show_range_history(opts) end
H.notify('Nothing Git-related to show at cursor', 'WARN')
end
--- Show diff source
---
--- When buffer contains text formatted as unified patch (like after
--- `:Git log --patch`, `:Git diff`, or |MiniGit.show_range_history()|),
--- show state of the file at the particular state. Target commit/state, path,
--- and line number are deduced from cursor position.
---
--- Notes:
--- - Needs |current-directory| to be the Git root for relative paths to work.
--- - Needs cursor to be inside hunk lines or on "---" / "+++" lines with paths.
--- - Only basic forms of `:Git diff` output is supported: `:Git diff`,
--- `:Git diff --cached`, and `:Git diff <commit>`.
---
---@param opts table|nil Options. Possible values:
--- - __git_split_field
--- - <target> `(string)` - which file state to show. One of "before", "after",
--- "both" (both states in vertical split), "auto" (default). Value "auto"
--- shows "before" state if cursor line starts with "-", otherwise - "after".
MiniGit.show_diff_source = function(opts)
opts = vim.tbl_deep_extend('force', { split = 'auto', target = 'auto' }, opts or {})
local split = H.normalize_split_opt(opts.split, 'opts.split')
local target = opts.target
if not (target == 'auto' or target == 'before' or target == 'after' or target == 'both') then
H.error('`opts.target` should be one of "auto", "before", "after", "both".')
end
local src = H.diff_pos_to_source()
if src == nil then
return H.notify('Could not find diff source. Ensure that cursor is inside a valid diff lines of git log.', 'WARN')
end
if target == 'auto' then target = src.init_prefix == '-' and 'before' or 'after' end
local cwd = H.get_git_cwd()
local show = function(commit, path, mods)
local is_worktree, args, lines = commit == true, nil, nil
if is_worktree then
args, lines = { 'edit', vim.fn.fnameescape(path) }, vim.fn.readfile(path)
else
args = { 'show', commit .. ':' .. path }
lines = H.git_cli_output(args, cwd)
end
if #lines == 0 and not is_worktree then
return H.notify('Can not show ' .. path .. ' at commit ' .. commit, 'WARN')
end
H.show_in_split(mods, lines, 'show', table.concat(args, ' '))
end
local has_before_shown = false
if target ~= 'after' then
if src.path_before == nil then
H.notify('No "before" as file was created', 'WARN')
else
show(src.commit_before, src.path_before, split)
vim.api.nvim_win_set_cursor(0, { src.lnum_before, 0 })
has_before_shown = true
end
end
if target ~= 'before' then
if src.path_after == nil then
H.notify('No "after" as file was deleted', 'WARN')
else
local mods_after = has_before_shown and 'belowright vertical' or split
show(src.commit_after, src.path_after, mods_after)
vim.api.nvim_win_set_cursor(0, { src.lnum_after, 0 })
end
end
end
--- Show range history
---
--- Compute and show in split data about how particular line range in current
--- buffer evolved through Git history. Essentially a `git log` with `-L` flag.
---
--- Notes:
--- - Works well with |MiniGit.diff_foldexpr()|.
--- - Does not work if there are uncommited changes, as there is no easy way to
--- compute effective range line numbers.
---
---@param opts table|nil Options. Possible fields:
--- - <line_start> `(number)` - range start line.
--- - <line_end> `(number)` - range end line.
--- If both <line_start> and <line_end> are not supplied, they default to
--- current line in Normal mode and visual selection in Visual mode.
--- - <log_args> `(table)` - array of options to append to `git log` call.
--- - __git_split_field
MiniGit.show_range_history = function(opts)
local default_opts = { line_start = nil, line_end = nil, log_args = nil, split = 'auto' }
opts = vim.tbl_deep_extend('force', default_opts, opts or {})
local line_start, line_end = H.normalize_range_lines(opts.line_start, opts.line_end)
local log_args = opts.log_args or {}
if not H.islist(log_args) then H.error('`opts.log_args` should be an array.') end
local split = H.normalize_split_opt(opts.split, 'opts.split')
-- Construct `:Git log` command that works both with regular files and
-- buffers from `show_diff_source()`
local buf_name, cwd = vim.api.nvim_buf_get_name(0), H.get_git_cwd()
local commit, rel_path = H.parse_diff_source_buf_name(buf_name)
if commit == nil then
commit = 'HEAD'
local cwd_pattern = '^' .. vim.pesc(cwd:gsub('\\', '/')) .. '/'
rel_path = H.get_buf_realpath(0):gsub('\\', '/'):gsub(cwd_pattern, '')
end
-- Ensure no uncommitted changes as they might result into improper `-L` arg
local diff = commit == 'HEAD' and H.git_cli_output({ 'diff', '-U0', 'HEAD', '--', rel_path }, cwd) or {}
if #diff ~= 0 then
return H.notify('Current file has uncommitted lines. Commit or stash before exploring history.', 'WARN')
end
-- Show log in split
local range_flag = string.format('-L%d,%d:%s', line_start, line_end, rel_path)
local args = { 'log', range_flag, commit, unpack(log_args) }
local history = H.git_cli_output(args, cwd)
if #history == 0 then return H.notify('Could not get range history', 'WARN') end
H.show_in_split(split, history, 'log', table.concat(args, ' '))
end
--- Fold expression for Git logs
---
--- Folds contents of hunks, file patches, and log entries in unified diff.
--- Useful for filetypes "diff" (like after `:Git diff`) and "git" (like after
--- `:Git log --patch` or `:Git show` for commit).
--- Works well with |MiniGit.show_range_history()|.
---
--- General idea of folding levels (use |zr| and |zm| to adjust interactively):
--- - At level 0 there is one line per whole patch or log entry.
--- - At level 1 there is one line per patched file.
--- - At level 2 there is one line per hunk.
--- - At level 3 there is no folds.
---
--- For automated setup, set the following for "git" and "diff" filetypes (either
--- inside |FileType| autocommand or |ftplugin|): >vim
---
--- setlocal foldmethod=expr foldexpr=v:lua.MiniGit.diff_foldexpr()
--- <
---@param lnum number|nil Line number for which fold level is computed.
--- Default: |v:lnum|.
---
---@return number|string Line fold level. See |fold-expr|.
MiniGit.diff_foldexpr = function(lnum)
lnum = lnum or vim.v.lnum
if H.is_log_entry_header(lnum + 1) or H.is_log_entry_header(lnum) then return 0 end
if H.is_file_entry_header(lnum) then return 1 end
if H.is_hunk_header(lnum) then return 2 end
if H.is_hunk_header(lnum - 1) then return 3 end
return '='
end
--- Enable Git tracking in a file buffer
---
--- Tracking is done by reacting to changes in file content or file's repository
--- in the form of keeping buffer data up to date. The data can be used via:
--- - |MiniGit.get_buf_data()|. See its help for a list of actually tracked data.
--- - `vim.b.minigit_summary` (table) and `vim.b.minigit_summary_string` (string)
--- buffer-local variables which are more suitable for statusline.
--- `vim.b.minigit_summary_string` contains information about HEAD, file status,
--- and in progress action (see |MiniGit.get_buf_data()| for more details).
--- See |MiniGit-examples| for how it can be tweaked and used in statusline.
---
--- Note: this function is called automatically for all new normal buffers.
--- Use it explicitly if buffer was disabled.
---
--- `User` event `MiniGitUpdated` is triggered whenever tracking data is updated.
--- Note that not all data listed in |MiniGit.get_buf_data()| can be present (yet)
--- at the point of event being triggered.
---
---@param buf_id __git_buf_id
MiniGit.enable = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
-- Don't enable more than once
if H.is_buf_enabled(buf_id) or H.is_disabled(buf_id) or not H.has_git then return end
-- Enable only in buffers which *can* be part of Git repo
local path = H.get_buf_realpath(buf_id)
if path == '' or vim.fn.filereadable(path) ~= 1 then return end
-- Start tracking
H.cache[buf_id] = {}
H.setup_buf_behavior(buf_id)
H.start_tracking(buf_id, path)
end
--- Disable Git tracking in buffer
---
---@param buf_id __git_buf_id
MiniGit.disable = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
local buf_cache = H.cache[buf_id]
if buf_cache == nil then return end
H.cache[buf_id] = nil
-- Cleanup
pcall(vim.api.nvim_del_augroup_by_id, buf_cache.augroup)
vim.b[buf_id].minigit_summary, vim.b[buf_id].minigit_summary_string = nil, nil
-- - Unregister buffer from repo watching with possibly more cleanup
local repo = buf_cache.repo
if H.repos[repo] == nil then return end
H.repos[repo].buffers[buf_id] = nil
if vim.tbl_count(H.repos[repo].buffers) == 0 then
H.teardown_repo_watch(repo)
H.repos[repo] = nil
end
end
--- Toggle Git tracking in buffer
---
--- Enable if disabled, disable if enabled.
---
---@param buf_id __git_buf_id
MiniGit.toggle = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
if H.is_buf_enabled(buf_id) then return MiniGit.disable(buf_id) end
return MiniGit.enable(buf_id)
end
--- Get buffer data
---
---@param buf_id __git_buf_id
---
---@return table|nil Table with buffer Git data or `nil` if buffer is not enabled.
--- If the file is not part of Git repo, table will be empty.
--- Table has the following fields:
--- - <repo> `(string)` - full path to '.git' directory.
--- - <root> `(string)` - full path to worktree root.
--- - <head> `(string)` - full commit of current HEAD.
--- - <head_name> `(string)` - short name of current HEAD (like "master").
--- For detached HEAD it is "HEAD".
--- - <status> `(string)` - two character file status as returned by `git status`.
--- - <in_progress> `(string)` - name of action(s) currently in progress
--- (bisect, merge, etc.). Can be a combination of those separated by ",".
MiniGit.get_buf_data = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
local buf_cache = H.cache[buf_id]
if buf_cache == nil then return nil end
--stylua: ignore
return {
repo = buf_cache.repo, root = buf_cache.root,
head = buf_cache.head, head_name = buf_cache.head_name,
status = buf_cache.status, in_progress = buf_cache.in_progress,
}
end
-- Helper data ================================================================
-- Module default config
H.default_config = MiniGit.config
-- Cache per enabled buffer. Values are tables with fields:
-- - <augroup> - identifier of augroup defining buffer behavior.
-- - <repo> - path to buffer's repo ('.git' directory).
-- - <root> - path to worktree root.
-- - <head> - full commit of `HEAD`.
-- - <head_name> - short name of `HEAD` (`'HEAD'` for detached head).
-- - <status> - current file status.
-- - <in_progress> - string name of action in progress (bisect, merge, etc.)
H.cache = {}
-- Cache per repo (git directory) path. Values are tables with fields:
-- - <fs_event> - `vim.loop` event for watching repo dir.
-- - <timer> - timer to debounce repo changes.
-- - <buffers> - map of buffers which should are part of repo.
H.repos = {}
-- Termporary file used as config for `GIT_EDITOR`
H.git_editor_config = nil
-- Data about supported Git subcommands. Initialized lazily. Fields:
-- - <supported> - array of supported one word commands.
-- - <complete> - array of commands to complete directly after `:Git`.
-- - <info> - map with fields as commands which show something to user.
-- - <options> - map of cached options per command; initialized lazily.
-- - <alias> - map of alias command name to command it implements.
H.git_subcommands = nil
-- Whether to temporarily skip some checks (like when inside `GIT_EDITOR`)
H.skip_timeout = false
H.skip_sync = false
-- Helper functionality =======================================================
-- Settings -------------------------------------------------------------------
H.setup_config = function(config)
H.check_type('config', config, 'table', true)
config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {})
H.check_type('job', config.job, 'table')
H.check_type('command', config.command, 'table')
H.check_type('job.git_executable', config.job.git_executable, 'string')
H.check_type('job.timeout', config.job.timeout, 'number')
if not pcall(H.normalize_split_opt, config.command.split) then
H.error('`command.split` should be one of "auto", "horizontal", "vertical", "tab"')
end
return config
end
H.apply_config = function(config) MiniGit.config = config end
H.create_autocommands = function()
local gr = vim.api.nvim_create_augroup('MiniGit', {})
local au = function(event, pattern, callback, desc)
vim.api.nvim_create_autocmd(event, { group = gr, pattern = pattern, callback = callback, desc = desc })
end
-- NOTE: Try auto enabling buffer on every `BufEnter` to not have `:edit`
-- disabling buffer, as it calls `on_detach()` from buffer watcher
au('BufEnter', '*', H.auto_enable, 'Enable Git tracking')
end
H.is_disabled = function(buf_id) return vim.g.minigit_disable == true or vim.b[buf_id or 0].minigit_disable == true end
H.create_user_commands = function()
local opts = { bang = true, nargs = '+', complete = H.command_complete, desc = 'Execute Git command' }
vim.api.nvim_create_user_command('Git', H.command_impl, opts)
end
-- Autocommands ---------------------------------------------------------------
H.auto_enable = vim.schedule_wrap(function(data)
local buf = data.buf
if not (vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == '' and vim.bo[buf].buflisted) then return end
MiniGit.enable(data.buf)
end)
-- Command --------------------------------------------------------------------
H.command_impl = function(input)
if not H.has_git then
return H.notify('There is no `' .. MiniGit.config.job.git_executable .. '` executable', 'ERROR')
end
H.ensure_git_subcommands()
-- Define Git editor to be used if needed. The way it works is: execute
-- command, wait for it to exit, use content of edited file. So to properly
-- wait for user to finish edit, start fresh headless process which opens
-- file in current session/process. It exits after the user is done editing
-- (deletes the buffer or closes the window).
H.ensure_git_editor(input.mods)
-- NOTE: use `vim.v.progpath` to have same runtime
local editor = H.cli_escape(vim.v.progpath) .. ' --clean --headless -u ' .. H.cli_escape(H.git_editor_config)
-- Setup custom environment variables for better reproducibility
local env_vars = {}
-- - Use Git related variables to use instance for editing
env_vars.GIT_EDITOR, env_vars.GIT_SEQUENCE_EDITOR, env_vars.GIT_PAGER = editor, editor, ''
-- - Make output as much machine readable as possible
env_vars.NO_COLOR, env_vars.TERM = 1, 'dumb'
local env = H.make_spawn_env(env_vars)
-- Setup spawn arguments
local args = vim.tbl_map(H.expandcmd, input.fargs)
local command = { MiniGit.config.job.git_executable, unpack(args) }
local cwd = H.get_git_cwd()
local cmd_data = { cmd_input = input, git_command = command, cwd = cwd }
local is_done_track = { done = false }
local on_done = H.command_make_on_done(cmd_data, is_done_track)
H.cli_run(command, cwd, on_done, { env = env })
-- If needed, synchronously wait for job to finish
local sync_check = function() return H.skip_sync or is_done_track.done end
if not input.bang then vim.wait(MiniGit.config.job.timeout + 10, sync_check, 1) end
end
--stylua: ignore
H.ensure_git_subcommands = function()
if H.git_subcommands ~= nil then return end
local git_subcommands = {}
-- Compute all supported commands. All 'list-' are taken from Git source
-- 'command-list.txt' file. Be so granular and not just `main,nohelpers` in
-- order to not include purely man-page worthy items (like "remote-ext").
local lists_all = {
'list-mainporcelain',
'list-ancillarymanipulators', 'list-ancillaryinterrogators',
'list-foreignscminterface',
'list-plumbingmanipulators', 'list-plumbinginterrogators',
'others', 'alias',
}
local supported = H.git_cli_output({ '--list-cmds=' .. table.concat(lists_all, ',') })
if #supported == 0 then
-- Fall back only on basics if previous one failed for some reason
supported = {
'add', 'bisect', 'branch', 'clone', 'commit', 'diff', 'fetch', 'grep', 'init', 'log', 'merge',
'mv', 'pull', 'push', 'rebase', 'reset', 'restore', 'rm', 'show', 'status', 'switch', 'tag',
}
end
table.sort(supported)
git_subcommands.supported = supported
-- Compute complete list for commands by enhancing with two word commands.
-- Keep those lists manual as there is no good way to compute lazily.
local complete = vim.deepcopy(supported)
local add_twoword = function(prefix, suffixes)
if not vim.tbl_contains(supported, prefix) then return end
for _, suf in ipairs(suffixes) do table.insert(complete, prefix .. ' ' .. suf) end
end
add_twoword('bundle', { 'create', 'list-heads', 'unbundle', 'verify' })
add_twoword('bisect', { 'bad', 'good', 'log', 'replay', 'reset', 'run', 'skip', 'start', 'terms', 'view', 'visualize' })
add_twoword('commit-graph', { 'verify', 'write' })
add_twoword('maintenance', { 'run', 'start', 'stop', 'register', 'unregister' })
add_twoword('multi-pack-index', { 'expire', 'repack', 'verify', 'write' })
add_twoword('notes', { 'add', 'append', 'copy', 'edit', 'get-ref', 'list', 'merge', 'prune', 'remove', 'show' })
add_twoword('p4', { 'clone', 'rebase', 'submit', 'sync' })
add_twoword('reflog', { 'delete', 'exists', 'expire', 'show' })
add_twoword('remote', { 'add', 'get-url', 'prune', 'remove', 'rename', 'rm', 'set-branches', 'set-head', 'set-url', 'show', 'update' })
add_twoword('rerere', { 'clear', 'diff', 'forget', 'gc', 'remaining', 'status' })
add_twoword('sparse-checkout', { 'add', 'check-rules', 'disable', 'init', 'list', 'reapply', 'set' })
add_twoword('stash', { 'apply', 'branch', 'clear', 'create', 'drop', 'list', 'pop', 'save', 'show', 'store' })
add_twoword('submodule', { 'absorbgitdirs', 'add', 'deinit', 'foreach', 'init', 'set-branch', 'set-url', 'status', 'summary', 'sync', 'update' })
add_twoword('subtree', { 'add', 'merge', 'pull', 'push', 'split' })
add_twoword('worktree', { 'add', 'list', 'lock', 'move', 'prune', 'remove', 'repair', 'unlock' })
git_subcommands.complete = complete
-- Compute commands which are meant to show information. These will show CLI
-- output in separate buffer opposed to `vim.notify`.
local info_args = { '--list-cmds=list-info,list-ancillaryinterrogators,list-plumbinginterrogators' }
local info_commands = H.git_cli_output(info_args)
if #info_commands == 0 then info_commands = { 'bisect', 'diff', 'grep', 'log', 'show', 'status' } end
local info = {}
for _, cmd in ipairs(info_commands) do
info[cmd] = true
end
git_subcommands.info = info
-- Compute commands which aliases rely on
local alias_data = H.git_cli_output({ 'config', '--get-regexp', 'alias.*' })
local alias = {}
for _, l in ipairs(alias_data) do
-- Assume simple alias of the form `alias.xxx subcommand ...`
local alias_cmd, cmd = string.match(l, '^alias%.(%S+) (%S+)')
if vim.tbl_contains(supported, cmd) then alias[alias_cmd] = cmd end
end
git_subcommands.alias = alias
-- Initialize cache for command options. Initialize with `false` so that
-- actual values are computed lazily when needed for a command.
local options = { git = false }
for _, command in ipairs(supported) do
options[command] = false
end
git_subcommands.options = options
-- Cache results
H.git_subcommands = git_subcommands
end
H.ensure_git_editor = function(mods)
if H.git_editor_config == nil or not vim.fn.filereadable(H.git_editor_config) == 0 then
H.git_editor_config = vim.fn.tempname()
end
-- Create a private function responsible for editing Git file
MiniGit._edit = function(path, servername)
-- Define editor state before and after editing path
H.skip_timeout, H.skip_sync = true, true
local cleanup = function()
local _, channel = pcall(vim.fn.sockconnect, 'pipe', servername, { rpc = true })
pcall(vim.rpcnotify, channel, 'nvim_exec2', 'quitall!', {})
H.skip_timeout, H.skip_sync = false, false
end
-- Start file edit with proper modifiers in a special window
mods = H.ensure_mods_is_split(mods)
vim.cmd(mods .. ' split ' .. vim.fn.fnameescape(path))
H.define_minigit_window(cleanup)
end
-- Start editing file from first argument (as how `GIT_EDITOR` works) in
-- current instance and don't close until explicitly closed later from this
-- instance as set up in `MiniGit._edit()`
local lines = {
'lua << EOF',
string.format('local channel = vim.fn.sockconnect("pipe", %s, { rpc = true })', vim.inspect(vim.v.servername)),
'local ins = vim.inspect',
'local lua_cmd = string.format("MiniGit._edit(%s, %s)", ins(vim.fn.argv(0)), ins(vim.v.servername))',
'vim.rpcrequest(channel, "nvim_exec_lua", lua_cmd, {})',
'EOF',
}
vim.fn.writefile(lines, H.git_editor_config)
end
H.get_git_cwd = function()
local buf_cache = H.cache[vim.api.nvim_get_current_buf()] or {}
return buf_cache.root or vim.fn.getcwd()
end
H.command_make_on_done = function(cmd_data, is_done_track)
return vim.schedule_wrap(function(code, out, err)
-- Register that command is done executing (to enable sync execution)
is_done_track.done = true
-- Trigger "done" event
cmd_data.git_subcommand = H.command_parse_subcommand(cmd_data.git_command)
cmd_data.exit_code, cmd_data.stdout, cmd_data.stderr = code, out, err
H.trigger_event('MiniGitCommandDone', cmd_data)
-- Show stderr and stdout
if H.cli_err_notify(code, out, err) then return end
H.command_show_stdout(cmd_data)
-- Ensure that all buffers are up to date (avoids "The file has been
-- changed since reading it" warning)
vim.tbl_map(function(buf_id) vim.cmd('checktime ' .. buf_id) end, vim.api.nvim_list_bufs())
end)
end
H.command_show_stdout = function(cmd_data)
local stdout, mods, subcommand = cmd_data.stdout, cmd_data.cmd_input.mods, cmd_data.git_subcommand
if stdout == '' or (mods:find('silent') ~= nil and mods:find('unsilent') == nil) then return end
-- Show in split if explicitly forced or the command shows info.
-- Use `vim.notify` otherwise.
local should_split = H.mods_is_split(mods) or H.git_subcommands.info[subcommand]
if not should_split then return H.notify(stdout, 'INFO') end
local lines = vim.split(stdout, '\n')
local name = table.concat(cmd_data.git_command, ' ')
cmd_data.win_source, cmd_data.win_stdout = H.show_in_split(mods, lines, subcommand, name)
-- Trigger "split" event
H.trigger_event('MiniGitCommandSplit', cmd_data)
end
H.command_parse_subcommand = function(command)
local res
for _, cmd in ipairs(command) do
if res == nil and vim.tbl_contains(H.git_subcommands.supported, cmd) then res = cmd end
end
return H.git_subcommands.alias[res] or res
end
H.command_complete = function(_, line, col)
-- Compute completion base manually to be "at cursor" and respect `\ `
local base = H.get_complete_base(line:sub(1, col))
local candidates, compl_type = H.command_get_complete_candidates(line, col, base)
-- Allow several "//" at the end for path completion for easier "chaining"
if compl_type == 'path' then base = base:gsub('/+$', '/') end
return vim.tbl_filter(function(x) return vim.startswith(x, base) end, candidates)
end
H.get_complete_base = function(line)
local from, _, res = line:find('(%S*)$')
while from ~= nil do
local cur_from, _, cur_res = line:sub(1, from - 1):find('(%S*\\ )$')
if cur_res ~= nil then res = cur_res .. res end
from = cur_from
end
return (res:gsub([[\ ]], ' '))
end
H.command_get_complete_candidates = function(line, col, base)
H.ensure_git_subcommands()
-- Determine current Git subcommand as the earliest present supported one
local subcmd, subcmd_end = nil, math.huge
for _, cmd in pairs(H.git_subcommands.supported) do
local _, ind = line:find(' ' .. cmd .. ' ', 1, true)
if ind ~= nil and ind < subcmd_end then
subcmd, subcmd_end = cmd, ind
end
end
subcmd = subcmd or 'git'
local cwd = H.get_git_cwd()
-- Determine command candidates:
-- - Commannd options if complete base starts with "-".
-- - Paths if after explicit "--".
-- - Git commands if there is none fully formed yet or cursor is at the end
-- of the command (to also suggest subcommands).
-- - Command targets specific for each command (if present).
if vim.startswith(base, '-') then return H.command_complete_option(subcmd) end
if line:sub(1, col):find(' -- ') ~= nil then return H.command_complete_path(cwd, base) end
if subcmd_end == math.huge or (subcmd_end - 1) == col then return H.git_subcommands.complete, 'subcommand' end
subcmd = H.git_subcommands.alias[subcmd] or subcmd
local complete_targets = H.command_complete_subcommand_targets[subcmd]
if complete_targets == nil then return {}, nil end
return complete_targets(cwd, base, line)
end
H.command_complete_option = function(command)
local cached_candidates = H.git_subcommands.options[command]
if cached_candidates == nil then return {} end
if type(cached_candidates) == 'table' then return cached_candidates end
-- Use alias's command to compute the options but store cache for alias
local orig_command = command
command = H.git_subcommands.alias[command] or command
-- Find command's flag options by parsing its help page. Needs a bit
-- heuristic approach and ensuring proper `git help` output (as it is done
-- through `man`), but seems to work good enough.
-- Alternative is to call command with `--git-completion-helper-all` flag (as
-- is done in bash and vim-fugitive completion). This has both pros and cons:
-- - Pros: faster; more targeted suggestions (like for two word subcommands);
-- presumably more reliable.
-- - Cons: works on smaller number of commands (for example, `rev-parse` or
-- pure `git` do not work); does not provide single dash suggestions;
-- does not work when not inside Git repo; needs recognizing two word
-- commands before asking for completion.
local env = H.make_spawn_env({ MANPAGER = 'cat', NO_COLOR = 1, PAGER = 'cat' })
local lines = H.git_cli_output({ 'help', '--man', command }, nil, env)
-- - Exit early before caching to try again later
if #lines == 0 then return {} end
-- - On some systems (like Mac), output still might contain formatting
-- sequences, like "a\ba" and "_\ba" meaning bold and italic.
-- See https://github.com/nvim-mini/mini.nvim/issues/918
lines = vim.tbl_map(function(l) return l:gsub('.\b', '') end, lines)
-- Construct non-duplicating candidates by parsing lines of help page
local candidates_map = {}
-- Options are assumed to be listed inside "OPTIONS" or "XXX OPTIONS" (like
-- "MODE OPTIONS" of `git rebase`) section on dedicated lines. Whether a line
-- contains only options is determined heuristically: it is assumed to start
-- exactly with " -" indicating proper indent for subsection start.
-- Known not parsable options:
-- - `git reset <mode>` (--soft, --hard, etc.): not listed in "OPTIONS".
-- - All -<number> options, as they are not really completeable.
local is_in_options_section = false
for _, l in ipairs(lines) do
if is_in_options_section and l:find('^%u[%u ]+$') ~= nil then is_in_options_section = false end
if not is_in_options_section and l:find('^%u?[%u ]*OPTIONS$') ~= nil then is_in_options_section = true end
if is_in_options_section and l:find('^ %-') ~= nil then H.parse_options(candidates_map, l) end
end
-- Finalize candidates. Should not contain "almost duplicates".
-- Should also be sorted by relevance: short flags before regular flags.
-- Inside groups sort alphabetically ignoring case.
candidates_map['--'] = nil
for cmd, _ in pairs(candidates_map) do
-- There can be two explicitly documented options "--xxx" and "--xxx=".
-- Use only one of them (without "=").
if cmd:sub(-1, -1) == '=' and candidates_map[cmd:sub(1, -2)] ~= nil then candidates_map[cmd] = nil end
end
local res = vim.tbl_keys(candidates_map)
table.sort(res, function(a, b)
local a2, b2 = a:sub(2, 2) == '-', b:sub(2, 2) == '-'
if a2 and not b2 then return false end
if not a2 and b2 then return true end
local a_low, b_low = a:lower(), b:lower()
return a_low < b_low or (a_low == b_low and a < b)
end)
-- Cache and return
H.git_subcommands.options[orig_command] = res
return res, 'option'