xaizek / vifm (License: GPLv2+) (since 2018-12-07)
Vifm is a file manager with curses interface, which provides Vi[m]-like environment for managing objects within file systems, extended with some useful ideas from mutt.
Commit ce8e5bd1440008ad761086fdc28b9cb21db76c62

git: add GitStatus column
The current implementation is synchronous which makes it unsuitable for
huge repositories.
Author: xaizek
Author date (UTC): 2026-03-28 14:54
Committer name: xaizek
Committer date (UTC): 2026-03-28 15:00
Parent(s): a149d45200c8d8bff9a213dae71c0f4c6a71f769
Signing key: 99DC5E4DB05F6BE2
Tree: 2295aed4bac89cb6a25e7463b2077ee6395f7b2a
File Lines added Lines deleted
data/plugins/git/README.md 59 0
data/plugins/git/init.lua 31 0
data/plugins/git/statuses.lua 168 0
File data/plugins/git/README.md changed (mode: 100644) (index d126291f7..fe36a9c0e)
... ... dialog.
25 25 1. URL to clone from (required). 1. URL to clone from (required).
26 26 2. Destination (optional). Derived from the last component of the URL removing 2. Destination (optional). Derived from the last component of the URL removing
27 27 `.git` suffix. `.git` suffix.
28
29 ### `GitStatus` view column
30
31 When inside a Git repository, displays status of files and directories in a way
32 similar to how `git status --short` does it.
33
34 **Possible column values for files:**
35
36 | Value | Meaning
37 | ----- | -------
38 | ` ` | Ignored or unchanged.
39 | ` M` | Modified in worktree.
40 | `MM` | Modified in index and in worktree.
41 | `AM` | Added in index, modified in worktree.
42 | `RM` | Renamed in index, modified in worktree.
43 | `M ` | Modified in index.
44 | `A ` | Added in Index.
45 | `R ` | Renamed in index.
46 | `D ` | Deleted in index.
47 | ` D` | Deleted in worktree.
48 | `MD` | Modified in index, deleted in worktree.
49 | `AD` | Added in index, deleted in worktree.
50 | `RD` | Renamed in index, deleted in worktree.
51 | `UD` | Updated here, but deleted in merged changes.
52 | `DU` | Deleted here, but updated in merged changes.
53 | `AA` | Added here and in merged changes.
54 | `UU` | Modified here and in merged changes.
55 | `??` | Untracked.
56
57 **Possible column values for directories:**
58
59 * Any of the values for files.
60 * If a subtree has multiple files modified, each character of the status is
61 merged like this:
62 * space + space = space
63 * space + non-space = non-space
64 * non-space + identical non-space = non-space
65 * non-space + different non-space = 'X'
66 * 'X' + anything = 'X'
67
68 **Example:**
69
70 * `:set viewcolumns=-3{GitStatus},*{name}..,6{}.`
71
72 **Issues:**
73
74 * Has a noticeable initial delay for large Git repositories due to fetching
75 statuses synchronously.
76
77 **TODO:**
78
79 * Run `git status -z` asynchronously to not block directory traversal.
80 * Make cache update faster if directory modification is detected.
81 * Check behaviour with submodules.
82 * Check if `!!` status can ever be seen.
83 * Consider using `git status --porcelain=v2 -z` as it provides more
84 information.
85 * Consider marking ignored files as such.
86 * Using `libgit2` could improve performance.
File data/plugins/git/init.lua changed (mode: 100644) (index 116d8bb33..81b5da0f4)
1 local statuses = vifm.plugin.require('statuses')
2
1 3 local M = {} local M = {}
2 4
3 5 local function clone(info) local function clone(info)
 
... ... local function clone(info)
32 34 end end
33 35 end end
34 36
37 local function status_column(info)
38 local e = info.entry
39
40 local node = statuses.get(e.location)
41 if not node.in_git then
42 return { text = '' }
43 end
44
45 local status = node.items[e.name]
46 if status ~= nil then
47 return { text = status }
48 end
49
50 local sub = node.subs[e.name]
51 if sub ~= nil and sub.status ~= nil then
52 return { text = sub.status }
53 end
54
55 return { text = '' }
56 end
57
35 58 -- this does NOT overwrite pre-existing user command -- this does NOT overwrite pre-existing user command
36 59 local added = vifm.cmds.add { local added = vifm.cmds.add {
37 60 name = "Gclone", name = "Gclone",
 
... ... if not added then
44 67 vifm.sb.error("Failed to register :Gclone") vifm.sb.error("Failed to register :Gclone")
45 68 end end
46 69
70 local added = vifm.addcolumntype {
71 name = "GitStatus",
72 handler = status_column
73 }
74 if not added then
75 vifm.sb.error("Failed to add view column GitStatus")
76 end
77
47 78 return M return M
File data/plugins/git/statuses.lua added (mode: 100644) (index 000000000..045d05c6b)
1 local M = { }
2
3 -- a user may be making changes in a repository quite frequently
4 local GIT_DIR_TTL = 5
5 -- repositories are created infrequently
6 local NON_GIT_DIR_TTL = 60
7
8 local cache = {
9 in_git = false, -- whether current path is covered by Git
10 status = nil, -- status derived from nested files
11 subs = { }, -- subdirectory name -> cache node
12 items = { }, -- file name -> status
13 expires = 0 -- when the cache entry expires
14 }
15
16 local function find_node(path)
17 local current = cache
18
19 for entry in string.gmatch(path, '[^/]+') do
20 local next = current.subs[entry]
21 if next == nil then
22 return nil
23 end
24
25 current = next
26 end
27
28 if os.time() > current.expires then
29 return nil
30 end
31
32 return current
33 end
34
35 local function init_node(node, expires)
36 node.subs = {}
37 node.items = {}
38 node.expires = expires
39 node.status = nil
40 return node
41 end
42
43 local function make_node(node, path)
44 local current = node
45
46 for entry in string.gmatch(path, '[^/]+') do
47 local next = current.subs[entry]
48 if next == nil then
49 next = init_node({}, 0)
50 current.subs[entry] = next
51 end
52
53 current = next
54 end
55
56 return current
57 end
58
59 local function combine_status(a, b)
60 if a == b then
61 return a
62 end
63 if a == ' ' then
64 return b
65 end
66 if b == ' ' then
67 return a
68 end
69 return 'X'
70 end
71
72 local function update_dir_status(node, status)
73 if node.status == nil then
74 node.status = status
75 else
76 local staged = combine_status(node.status:sub(1, 1), status:sub(1, 1))
77 local index = combine_status(node.status:sub(2, 2), status:sub(2, 2))
78 node.status = staged..index
79 end
80 end
81
82 local function set_file_status(node, path, status, expires)
83 local slash = path:find('/')
84 if slash == nil then
85 node.items[path] = status
86 update_dir_status(node, status)
87 return node.status
88 end
89
90 local entry = path:sub(1, slash - 1)
91 path = path:sub(slash + 1)
92
93 local next = node.subs[entry]
94 if next == nil then
95 next = init_node({}, expires)
96 next.in_git = true
97 node.subs[entry] = next
98 end
99
100 status = set_file_status(next, path, status, expires)
101 update_dir_status(node, status)
102
103 return node.status
104 end
105
106 local function exec(cmd)
107 local job = vifm.startjob { cmd = cmd }
108 local result = job:stdout():read('a')
109 if result:sub(#result) == '\n' then
110 result = result:sub(1, #result - 1)
111 end
112 return result, job:exitcode()
113 end
114
115 function M.get(at)
116 at = vifm.fnamemodify(at, ':p')
117 if vifm.fnamemodify(at, ':t') == '.' then
118 at = vifm.fnamemodify(at, ':h')
119 end
120
121 local cached = find_node(at)
122 if cached ~= nil then
123 return cached
124 end
125
126 local ts = os.time()
127 local root, exit_code = exec(string.format('git -C %s rev-parse --show-toplevel', vifm.escape(at)))
128 if exit_code ~= 0 then
129 -- Handle directories outside of git repositories and avoid clearing
130 -- accumulated cache of its children.
131 local node = make_node(cache, at)
132 node.expires = ts + NON_GIT_DIR_TTL
133 node.in_git = false
134 return node
135 end
136
137 local expires = ts + GIT_DIR_TTL
138 local node = init_node(make_node(cache, at), expires)
139 node.in_git = true
140
141 local subdirs = exec(string.format('git -C %s ls-tree -r -d --name-only -z HEAD .', vifm.escape(at)))
142 for subdir in string.gmatch(subdirs, '[^\0]+') do
143 if subdir == '../' then
144 node.status = ''
145 -- no need to call `git status`
146 return node
147 end
148
149 if subdir ~= './' then
150 local dir = make_node(node, subdir)
151 dir.expires = expires
152 dir.in_git = true
153 dir.status = ' '
154 end
155 end
156
157 local status = exec(string.format('git -C %s status -z .', vifm.escape(at)))
158 for entry in string.gmatch(status, '[^\0]+') do
159 local status = entry:sub(1, 2)
160 local abs_path = root..'/'..entry:sub(4)
161 local rel_path = abs_path:sub(1 + #at + 1)
162 set_file_status(node, rel_path, status, expires)
163 end
164
165 return node
166 end
167
168 return M
Hints

Before first commit, do not forget to setup your git environment:
git config --global user.name "your_name_here"
git config --global user.email "your@email_here"

Clone this repository using HTTP(S):
git clone https://code.reversed.top/user/xaizek/vifm

Clone this repository using ssh (do not forget to upload a key first):
git clone ssh://rocketgit@code.reversed.top/user/xaizek/vifm

You are allowed to anonymously push to this repository.
This means that your pushed commits will automatically be transformed into a pull request:
... clone the repository ...
... make some changes and some commits ...
git push origin master