Skip to content

Commit f222c40

Browse files
committed
fix(repo): update head_oid when checked-out branch moves
Git can move the checked-out branch without touching .git/HEAD (e.g. `git pull` updating refs/heads/<branch> or packed-refs). When only reacting to HEAD file changes, gitsigns could miss these updates and keep using a stale head OID. - Resolve HEAD OID by reading HEAD and resolving symbolic refs through loose refs + packed-refs, waiting on `<ref>.lock`/`packed-refs.lock` to avoid transient reads; keep a `git rev-parse HEAD` fallback for reftable backends. - Track `commondir` (linked worktrees) and `head_ref`, and add extra fs_event watches (commondir + HEAD ref directory) so nested ref updates are seen on non-recursive backends (e.g. Linux/inotify). - Keep events debounced but recompute `abbrev_head`, `head_ref`, and `head_oid` on every tick for correctness; update watches when HEAD switches refs. - Snapshot `head_oid` per buffer and invalidate cached state when it changes.
1 parent 5dbe0f4 commit f222c40

File tree

7 files changed

+415
-85
lines changed

7 files changed

+415
-85
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
- `make doc` / `make doc-check`: regenerate help from `lua/gitsigns/config.lua` and fail if docs drift.
1515
- `make format-check` or `make format`: lint or autoformat Lua sources.
1616
- `make emmylua-check`: run optional static analysis after fetching the analyzer.
17+
- If you hit `EMFILE` (too many open files) in the sandbox, run commands with a higher fd limit, e.g. `ulimit -n 1024; make test ...` (this does not persist across separate commands).
1718

1819
## Coding Style & Naming Conventions
1920
- Lua code must have emmylua/LuaCATS type annotations
2021
- 2-space indentation, 100-character columns, single quotes for strings.
2122
- Run `make format` and `make emmylua-check` after changing any code
23+
- When creating simple util functions. Use Neovim's `:help` command to see if anything already exists.
2224

2325
## Testing Guidelines
2426
- Every bug fix must include a spec that reproduces the regression and asserts the desired buffer state, co-located with related modules (for example, `hunk_spec.lua` for hunk logic).

lua/gitsigns/attach.lua

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,34 @@ local function repo_update_handler(bufnr)
236236

237237
local was_tracked = git_obj.object_name ~= nil
238238
local old_relpath = git_obj.relpath
239+
local old_object_name = git_obj.object_name
240+
local old_mode_bits = git_obj.mode_bits
239241

240-
bcache:invalidate(true)
241242
git_obj:refresh()
243+
244+
local new_object_name = git_obj.object_name
245+
local new_mode_bits = git_obj.mode_bits
246+
242247
if not bcache:schedule() then
243248
dprint('buffer invalid (1)')
244249
return
245250
end
246251

247-
if config.watch_gitdir.follow_files and was_tracked and not git_obj.object_name then
252+
local head_oid = git_obj.repo.head_oid
253+
254+
if
255+
old_object_name ~= new_object_name
256+
or old_mode_bits ~= new_mode_bits
257+
-- Invalidate when the repo HEAD moves (checkout, pull/rebase, etc). The
258+
-- file object can stay the same while the comparison base changes.
259+
or bcache.head_oid ~= head_oid
260+
then
261+
bcache:invalidate(true)
262+
end
263+
264+
bcache.head_oid = head_oid
265+
266+
if config.watch_gitdir.follow_files and was_tracked and not new_object_name then
248267
-- File was tracked but is no longer tracked. Must of been removed or
249268
-- moved. Check if it was moved and switch to it.
250269
handle_moved(bufnr, old_relpath)

lua/gitsigns/cache.lua

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ local M = {
2020
--- @field compare_text? string[]
2121
--- @field hunks? Gitsigns.Hunk.Hunk[]
2222
--- @field force_next_update? boolean
23+
--- @field head_oid? string
2324
---
2425
--- An update is required for the buffer next time it comes into view
2526
--- @field update_on_view? boolean
@@ -74,6 +75,9 @@ function M.new(bufnr, file, git_obj)
7475
file = file,
7576
git_obj = git_obj,
7677
staged_diffs = {},
78+
-- Snapshot HEAD OID so per-buffer invalidation can detect repo HEAD moves.
79+
-- `git_obj.repo.head_oid` is shared and may change between updates.
80+
head_oid = git_obj.repo.head_oid,
7781
}, { __index = CacheEntry })
7882
end
7983

lua/gitsigns/git/repo.lua

Lines changed: 253 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ local git_command = require('gitsigns.git.cmd')
33
local config = require('gitsigns.config').config
44
local log = require('gitsigns.debug.log')
55
local util = require('gitsigns.util')
6+
local Path = util.Path
67
local errors = require('gitsigns.git.errors')
78
local Watcher = require('gitsigns.git.repo.watcher')
89

@@ -22,35 +23,85 @@ local uv = vim.uv or vim.loop ---@diagnostic disable-line: deprecated
2223
--- Needed for to determine "You" in current line blame.
2324
--- @field username string
2425
--- @field private _watcher? Gitsigns.Repo.Watcher
26+
--- @field head_oid? string
27+
--- @field head_ref? string
28+
--- @field commondir string
2529
local M = {}
2630

2731
--- @param gitdir string
2832
--- @return boolean
2933
local function is_rebasing(gitdir)
30-
return util.Path.exists(util.Path.join(gitdir, 'rebase-merge'))
31-
or util.Path.exists(util.Path.join(gitdir, 'rebase-apply'))
34+
return Path.exists(Path.join(gitdir, 'rebase-merge'))
35+
or Path.exists(Path.join(gitdir, 'rebase-apply'))
3236
end
3337

34-
--- Return the abbreviated ref for HEAD (or short SHA if detached).
35-
--- Equivalent to `git rev-parse --abbrev-ref HEAD`
36-
--- @param gitdir string Must be an absolute path to the .git directory
37-
--- @return string abbrev_head
38-
local function abbrev_head(gitdir)
39-
local head_path = util.Path.join(gitdir, 'HEAD')
38+
--- @param value string?
39+
--- @return string?
40+
local function trim(value)
41+
if not value then
42+
-- Preserve nil to signal "no value".
43+
return
44+
end
45+
-- Normalize line endings/whitespace from ref files.
46+
local trimmed = vim.trim(value)
47+
-- Treat whitespace-only lines as absent.
48+
return trimmed ~= '' and trimmed or nil
49+
end
50+
51+
--- @param path string
52+
--- @return string?
53+
local function read_first_line(path)
54+
local f = io.open(path, 'r')
55+
if not f then
56+
return
57+
end
58+
local line = f:read('*l')
59+
f:close()
60+
return trim(line)
61+
end
4062

63+
--- @param path string
64+
local function wait_for_unlock(path)
65+
-- Git updates refs by taking `<ref>.lock` and then renaming into place.
66+
-- Wait briefly so we don't read transient state when reacting to fs events.
67+
--
4168
-- TODO(lewis6991): should this be async?
4269
vim.wait(1000, function()
43-
return not util.Path.exists(head_path .. '.lock')
70+
return not Path.exists(path .. '.lock')
4471
end, 10, true)
72+
end
4573

46-
local f = assert(io.open(head_path, 'r'))
47-
local head = f:read('*l')
48-
f:close()
74+
--- Wait for `<path>.lock` to clear then read the first line of a file.
75+
--- @param path string
76+
--- @return string?
77+
local function read_first_line_wait(path)
78+
wait_for_unlock(path)
79+
return read_first_line(path)
80+
end
4981

82+
--- @param gitdir string
83+
--- @return string?
84+
local function read_head(gitdir)
85+
return read_first_line_wait(Path.join(gitdir, 'HEAD'))
86+
end
87+
88+
--- @param head string?
89+
--- @return string?
90+
local function parse_head_ref(head)
91+
return head and head:match('^ref:%s*(.+)$') or nil
92+
end
93+
94+
--- Return the abbreviated ref for HEAD (or short SHA if detached).
95+
--- Equivalent to `git rev-parse --abbrev-ref HEAD`
96+
--- @param gitdir string Must be an absolute path to the .git directory
97+
--- @param head? string
98+
--- @return string abbrev_head
99+
local function get_abbrev_head(gitdir, head)
100+
head = head or assert(read_head(gitdir))
50101
-- HEAD content is either:
51102
-- "ref: refs/heads/<branch>"
52103
-- "<commitsha>" (detached HEAD)
53-
local refpath = head:match('^ref:%s*(.+)$')
104+
local refpath = parse_head_ref(head)
54105
if refpath then
55106
-- Extract last path component (branch name)
56107
return refpath:match('([^/]+)$') or refpath
@@ -66,6 +117,164 @@ local function abbrev_head(gitdir)
66117
end
67118
return short_sha
68119
end
120+
121+
--- @param gitdir string
122+
--- @return string
123+
local function get_commondir(gitdir)
124+
-- In linked worktrees, `gitdir` points at `.git/worktrees/<name>` while most
125+
-- refs live under the main `.git` directory (the "commondir").
126+
local commondir = read_first_line(Path.join(gitdir, 'commondir'))
127+
if not commondir then
128+
return gitdir
129+
end
130+
local abs = Path.join(gitdir, commondir)
131+
return uv.fs_realpath(abs) or abs
132+
end
133+
134+
--- @param commondir string
135+
--- @param refname string
136+
--- @return string?
137+
local function read_packed_ref(commondir, refname)
138+
local packed_refs_path = Path.join(commondir, 'packed-refs')
139+
wait_for_unlock(packed_refs_path)
140+
-- `packed-refs` is a flat map from refname to OID (with optional peeled
141+
-- entries). Read it linearly as this is only used on debounced fs events.
142+
local f = io.open(packed_refs_path, 'r')
143+
if not f then
144+
return
145+
end
146+
for line in f:lines() do
147+
--- @cast line string
148+
if line:sub(1, 1) ~= '#' and line:sub(1, 1) ~= '^' then
149+
local oid, name = line:match('^(%x+)%s+(.+)$')
150+
if name == refname then
151+
f:close()
152+
return oid
153+
end
154+
end
155+
end
156+
f:close()
157+
end
158+
159+
--- @param gitdir string
160+
--- @param commondir? string
161+
--- @param refname string
162+
--- @return string?
163+
local function resolve_ref(gitdir, commondir, refname)
164+
-- Resolve a refname to an OID by following symbolic refs and checking:
165+
-- - worktree-local loose refs in `gitdir/`
166+
-- - shared loose refs in `commondir/`
167+
-- - `commondir/packed-refs`
168+
local seen = {} --- @type table<string, true>
169+
local current = refname
170+
171+
while current and current ~= '' do
172+
if seen[current] then
173+
log.dprintf('cycle detected in symbolic refs: %s', vim.inspect(vim.tbl_keys(seen)))
174+
return
175+
end
176+
seen[current] = true
177+
178+
local line = read_first_line_wait(Path.join(gitdir, current))
179+
180+
if not line and commondir and commondir ~= gitdir then
181+
line = read_first_line_wait(Path.join(commondir, current))
182+
end
183+
184+
if not line then
185+
log.dprintf('Ref %s not found as loose ref; checking packed-refs', current)
186+
break
187+
elseif line:match('^%x+$') then
188+
return line
189+
end
190+
191+
local symref = line:match('^ref:%s*(.+)$')
192+
if symref then
193+
current = symref
194+
else
195+
log.dprintf('Ref %s has invalid contents (%s); checking packed-refs', current, line)
196+
break
197+
end
198+
end
199+
200+
if commondir and current then
201+
-- Some refs are only stored in packed-refs.
202+
local packed = read_packed_ref(commondir, current)
203+
if packed and packed:match('^%x+$') then
204+
return packed
205+
end
206+
end
207+
end
208+
209+
--- Manual implementation of `git rev-parse HEAD`.
210+
--- @param gitdir string
211+
--- @param commondir string
212+
--- @return string? oid
213+
--- @return string? err
214+
local function get_head_oid0(gitdir, commondir)
215+
-- `.git/HEAD` can remain unchanged while its target ref moves (e.g. `git pull`
216+
-- updating the checked-out branch). Resolve `HEAD` through loose refs and
217+
-- packed-refs so we can detect branch moves without spawning `git`.
218+
local head = read_head(gitdir)
219+
if not head then
220+
-- Unable to read HEAD.
221+
return nil, 'unable to read HEAD file'
222+
end
223+
224+
if head:match('^%x+$') then
225+
-- Detached HEAD contains an OID directly.
226+
return head
227+
end
228+
229+
local ref = parse_head_ref(head)
230+
if not ref then
231+
-- Unrecognized HEAD format.
232+
return nil, ('unrecognized HEAD contents: %s'):format(head)
233+
end
234+
235+
local oid = resolve_ref(gitdir, commondir, ref)
236+
if oid then
237+
-- Resolved via loose refs or packed-refs.
238+
return oid
239+
end
240+
241+
-- Reftable stores refs in a different backend (no loose/packed refs).
242+
if Path.exists(Path.join(commondir, 'reftable')) then
243+
return nil, 'reftable'
244+
end
245+
246+
-- Reftable cannot be parsed via loose refs/packed-refs. Keep a synchronous
247+
-- fallback for correctness (rare setup). Some other backends or transient
248+
-- states can also cause resolution to fail, so keep this as a general
249+
-- fallback.
250+
return nil, ('unable to resolve %s via loose refs/packed-refs'):format(ref)
251+
end
252+
253+
--- Manual implementation of `git rev-parse HEAD` with command fallback.
254+
--- @param gitdir string
255+
--- @param commondir string
256+
--- @return string? oid
257+
local function get_head_oid(gitdir, commondir)
258+
local oid0, err = get_head_oid0(gitdir, commondir)
259+
if oid0 then
260+
return oid0
261+
end
262+
263+
log.dprintf('Falling back to `git rev-parse HEAD`: %s', err)
264+
265+
local stdout, stderr, code = async
266+
.run(git_command, { '--git-dir', gitdir, 'rev-parse', 'HEAD' }, { ignore_error = true })
267+
:wait()
268+
269+
local oid = stdout[1]
270+
271+
if code ~= 0 or not oid or not oid:match('^%x+$') then
272+
log.dprintf('Fallback `git rev-parse HEAD` failed: code=%s oid=%s stderr=%s', code, oid, stderr)
273+
return
274+
end
275+
return oid
276+
end
277+
69278
--- Registers a callback to be invoked on update events.
70279
---
71280
--- The provided callback function `cb` will be stored and called when an update
@@ -170,11 +379,38 @@ function M._new(info)
170379
--- @type Gitsigns.Repo
171380
local self = setmetatable(info, { __index = M })
172381
self.username = self:command({ 'config', 'user.name' }, { ignore_error = true })[1]
382+
383+
self.commondir = get_commondir(self.gitdir)
384+
173385
if config.watch_gitdir.enable then
174-
self._watcher = Watcher.new(self.gitdir)
175-
self._watcher:on_head_update(function()
176-
self.abbrev_head = abbrev_head(self.gitdir)
177-
log.dprintf('HEAD changed, updating abbrev_head to %s', self.abbrev_head)
386+
local head = read_head(self.gitdir)
387+
self.head_ref = parse_head_ref(head)
388+
self.head_oid = get_head_oid(self.gitdir, self.commondir)
389+
self._watcher = Watcher.new(self.gitdir, self.commondir)
390+
self._watcher:set_head_ref(self.head_ref)
391+
self._watcher:on_update(function()
392+
-- Recompute on every debounced tick. The checked-out branch can move
393+
-- without `HEAD` changing (e.g. `refs/heads/main` update).
394+
local head2 = read_head(self.gitdir)
395+
if not head2 then
396+
return
397+
end
398+
399+
self.head_oid = get_head_oid(self.gitdir, self.commondir)
400+
-- Set abbrev_head to empty string if head_oid is unavailable (.e.g repo
401+
-- with no commits). This is consistent with `git rev-parse --abrev-ref
402+
-- HEAD` which returns "HEAD" in this case.
403+
local abbrev_head = self.head_oid and get_abbrev_head(self.gitdir, head2) or ''
404+
if self.abbrev_head ~= abbrev_head then
405+
self.abbrev_head = abbrev_head
406+
log.dprintf('HEAD changed, updating abbrev_head to %s', self.abbrev_head)
407+
end
408+
409+
local head_ref = parse_head_ref(head2)
410+
if self.head_ref ~= head_ref then
411+
self.head_ref = head_ref
412+
self._watcher:set_head_ref(self.head_ref)
413+
end
178414
end)
179415
end
180416

0 commit comments

Comments
 (0)