@@ -3,6 +3,7 @@ local git_command = require('gitsigns.git.cmd')
33local config = require (' gitsigns.config' ).config
44local log = require (' gitsigns.debug.log' )
55local util = require (' gitsigns.util' )
6+ local Path = util .Path
67local errors = require (' gitsigns.git.errors' )
78local 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
2529local M = {}
2630
2731--- @param gitdir string
2832--- @return boolean
2933local 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' ))
3236end
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
68119end
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