Skip to content

Commit 0d2fa63

Browse files
cxxxrclaude
andauthored
feat(git-gutter): add directory-mode git status support (#2089)
* feat(git-gutter): add directory-mode git status support Display git status indicators (M/A/D/?) in directory-mode file listings when git-gutter-mode is enabled. Features: - Show file status with colored symbols in directory listings - Aggregate status for directories (shows if any file underneath changed) - Status priority: modified > added > deleted > untracked - Cache git status with configurable TTL (default 5 seconds) - New command: git-gutter-refresh-directory-status Changes: - Export *file-entry-inserters*, item-pathname, item-directory from lem/directory-mode/internal for extensibility - Add parse-git-status-porcelain for parsing git status --porcelain output - Add 20 new test cases for directory status functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: use public lem/directory-mode API instead of internal package - Export extension points from lem/directory-mode public package: *file-entry-inserters*, item-pathname, item-directory, update-buffer - Update git-gutter to use lem/directory-mode instead of lem/directory-mode/internal for better API stability Fixes code-contractor internal_symbol_rule violation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * refactor: replace find-symbol with direct imports from lem/directory-mode - Add lem/directory-mode as explicit dependency in system definition - Import symbols directly via :import-from instead of runtime lookup - Simplify code by removing find-package/find-symbol calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: use lem/core dependency instead of non-existent lem/directory-mode system directory-mode is part of lem/core, not a separate ASDF system. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: use *git-gutter-ref* for directory-mode git status - Replace `git status --porcelain=v1` with `git diff --name-status` - Use *git-gutter-ref* (default: HEAD) as comparison reference - Add parse-git-diff-name-status function for parsing diff output - Include ref in cache key to support different refs - Add 6 new tests for parse-git-diff-name-status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: skip git status mark for '..' parent directory entry Use uiop:pathname-parent-directory-pathname to correctly detect the parent directory entry instead of string comparison. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: update git-gutter when opening files Add *find-file-hook* handler to update git-gutter markers when a file is opened. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * tweak contract.yml --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent b0e0882 commit 0d2fa63

File tree

6 files changed

+460
-8
lines changed

6 files changed

+460
-8
lines changed

contract.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ validation:
1414
max_total_changed_lines: 400
1515
max_delete_ratio: 0.5
1616
max_files_changed: 10
17+
severity: warning
1718

1819
ai:
1920
system_prompt: |

extensions/git-gutter/git-gutter.lisp

Lines changed: 221 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
11
(defpackage :lem-git-gutter
22
(:use :cl :lem)
3-
(:local-nicknames (:diff-parser :lem-git-gutter/diff-parser))
3+
(:local-nicknames (:diff-parser :lem-git-gutter/diff-parser)
4+
(:dir-mode :lem/directory-mode))
5+
(:import-from :lem/directory-mode
6+
:*file-entry-inserters*
7+
:item-pathname
8+
:item-directory
9+
:update-buffer
10+
:directory-mode)
411
(:export :git-gutter-mode
512
:git-gutter-set-ref
613
:git-gutter-refresh
714
:git-gutter-toggle-line-highlight
15+
:git-gutter-refresh-directory-status
816
:*git-gutter-ref*
917
:*git-gutter-highlight-line*
10-
:*git-gutter-update-delay*))
18+
:*git-gutter-update-delay*
19+
:*directory-status-cache-ttl*
20+
;; For testing
21+
:parse-git-status-porcelain
22+
:parse-git-diff-name-status
23+
:status-to-display))
1124
(in-package :lem-git-gutter)
1225

1326
;;; Configuration
@@ -21,6 +34,9 @@
2134
(defvar *git-gutter-update-delay* 300
2235
"Delay in milliseconds before updating gutter after edit.")
2336

37+
(defvar *directory-status-cache-ttl* 5000
38+
"Cache time-to-live in milliseconds for directory git status.")
39+
2440
;;; Attributes
2541

2642
(define-attribute git-gutter-added-attribute
@@ -224,22 +240,51 @@
224240
(when (buffer-filename buffer)
225241
(update-git-gutter-for-buffer buffer)))
226242

243+
(defun add-directory-mode-inserter ()
244+
"Add git status inserter to directory-mode."
245+
(unless (member #'insert-git-status *file-entry-inserters*)
246+
(push #'insert-git-status *file-entry-inserters*)))
247+
248+
(defun remove-directory-mode-inserter ()
249+
"Remove git status inserter from directory-mode."
250+
(setf *file-entry-inserters*
251+
(remove #'insert-git-status *file-entry-inserters*)))
252+
253+
(defun update-all-directory-buffers ()
254+
"Update all directory-mode buffers to reflect git status."
255+
(dolist (buffer (buffer-list))
256+
(when (eq (buffer-major-mode buffer) 'lem/directory-mode:directory-mode)
257+
(update-buffer buffer))))
258+
259+
(defun git-gutter-find-file (buffer)
260+
"Hook function called when a file is opened."
261+
(when (buffer-filename buffer)
262+
(update-git-gutter-for-buffer buffer)))
263+
227264
(defun enable-hook ()
228265
"Called when git-gutter-mode is enabled."
229266
(update-all-buffers)
267+
(add-hook *find-file-hook* 'git-gutter-find-file)
230268
(add-hook (variable-value 'after-save-hook :global t)
231269
'git-gutter-after-save)
232270
(add-hook (variable-value 'after-change-functions :global t)
233-
'git-gutter-on-change))
271+
'git-gutter-on-change)
272+
;; Add directory-mode support
273+
(add-directory-mode-inserter)
274+
(update-all-directory-buffers))
234275

235276
(defun disable-hook ()
236277
"Called when git-gutter-mode is disabled."
278+
(remove-hook *find-file-hook* 'git-gutter-find-file)
237279
(remove-hook (variable-value 'after-save-hook :global t)
238280
'git-gutter-after-save)
239281
(remove-hook (variable-value 'after-change-functions :global t)
240282
'git-gutter-on-change)
241283
(cancel-all-git-gutter-timers)
242-
(clear-all-buffers))
284+
(clear-all-buffers)
285+
;; Remove directory-mode support and clear cache
286+
(remove-directory-mode-inserter)
287+
(clear-directory-status-cache))
243288

244289
(define-minor-mode git-gutter-mode
245290
(:name "GitGutter"
@@ -295,3 +340,175 @@
295340
(setf *git-gutter-highlight-line* (not *git-gutter-highlight-line*))
296341
(update-all-buffers)
297342
(message "Git gutter line highlight: ~A" (if *git-gutter-highlight-line* "on" "off")))
343+
344+
;;; Directory Mode Git Status Support
345+
346+
(defvar *directory-git-status-cache* (make-hash-table :test #'equal)
347+
"Cache for directory git status. Maps directory path to (timestamp . status-hash).")
348+
349+
(define-attribute git-status-modified-attribute
350+
(t :foreground "yellow"))
351+
352+
(define-attribute git-status-added-attribute
353+
(t :foreground "green"))
354+
355+
(define-attribute git-status-deleted-attribute
356+
(t :foreground "red"))
357+
358+
(define-attribute git-status-untracked-attribute
359+
(t :foreground "cyan"))
360+
361+
(define-attribute git-status-staged-attribute
362+
(t :foreground "green" :bold t))
363+
364+
(defun parse-git-status-porcelain (output)
365+
"Parse git status --porcelain=v1 output into a hash-table.
366+
Returns hash-table mapping relative-path -> status-keyword."
367+
(let ((status (make-hash-table :test #'equal)))
368+
(dolist (line (uiop:split-string output :separator '(#\Newline)))
369+
(when (>= (length line) 3)
370+
(let ((x (char line 0))
371+
(y (char line 1))
372+
(file (string-trim " " (subseq line 3))))
373+
(when (plusp (length file))
374+
;; Handle renamed files: "R old -> new"
375+
(when (find #\> file)
376+
(setf file (string-trim " " (subseq file (1+ (position #\> file))))))
377+
(cond
378+
;; Untracked
379+
((and (char= x #\?) (char= y #\?))
380+
(setf (gethash file status) :untracked))
381+
;; Staged changes (index)
382+
((char= x #\M) (setf (gethash file status) :staged-modified))
383+
((char= x #\A) (setf (gethash file status) :staged-added))
384+
((char= x #\D) (setf (gethash file status) :staged-deleted))
385+
((char= x #\R) (setf (gethash file status) :staged-added))
386+
;; Unstaged changes (work tree)
387+
((char= y #\M) (setf (gethash file status) :modified))
388+
((char= y #\D) (setf (gethash file status) :deleted)))))))
389+
status))
390+
391+
(defun parse-git-diff-name-status (output)
392+
"Parse git diff --name-status output into a hash-table.
393+
Returns hash-table mapping relative-path -> status-keyword."
394+
(let ((status (make-hash-table :test #'equal)))
395+
(dolist (line (uiop:split-string output :separator '(#\Newline)))
396+
(when (>= (length line) 2)
397+
(let* ((status-char (char line 0))
398+
(rest (string-trim '(#\Space #\Tab) (subseq line 1)))
399+
(file rest))
400+
;; Handle renamed files: "R100\told\tnew" format
401+
;; After stripping status char, rest is "100\told\tnew"
402+
(when (char= status-char #\R)
403+
(let* ((parts (uiop:split-string rest :separator '(#\Tab)))
404+
(new-name (third parts)))
405+
(when new-name
406+
(setf file new-name))))
407+
(when (plusp (length file))
408+
(case status-char
409+
(#\M (setf (gethash file status) :modified))
410+
(#\A (setf (gethash file status) :added))
411+
(#\D (setf (gethash file status) :deleted))
412+
(#\R (setf (gethash file status) :added)))))))
413+
status))
414+
415+
(defun get-directory-git-status (directory)
416+
"Get git status for all files in directory. Uses cache when available.
417+
Compares against *git-gutter-ref* (default: HEAD)."
418+
(let* ((cache-key (format nil "~A:~A" (namestring directory) *git-gutter-ref*))
419+
(cached (gethash cache-key *directory-git-status-cache*))
420+
(now (get-internal-real-time))
421+
(ttl-ticks (* *directory-status-cache-ttl*
422+
(/ internal-time-units-per-second 1000))))
423+
(if (and cached
424+
(< (- now (car cached)) ttl-ticks))
425+
(cdr cached)
426+
;; Refresh cache
427+
(alexandria:when-let ((git-root (find-git-root directory)))
428+
(uiop:with-current-directory (git-root)
429+
(let* ((output (run-git (list "diff" "--name-status" *git-gutter-ref*)))
430+
(status-table (parse-git-diff-name-status output)))
431+
(setf (gethash cache-key *directory-git-status-cache*)
432+
(cons now status-table))
433+
status-table))))))
434+
435+
(defun find-status-in-directory (dir-prefix status-table)
436+
"Find if any file under dir-prefix has a git status.
437+
Returns the most significant status found (:modified > :added > :deleted > :untracked)."
438+
(let ((found-status nil))
439+
(maphash (lambda (path status)
440+
(when (and (> (length path) (length dir-prefix))
441+
(string= dir-prefix (subseq path 0 (length dir-prefix))))
442+
;; Found a file under this directory
443+
(case status
444+
((:modified :staged-modified)
445+
(setf found-status :modified))
446+
((:added :staged-added)
447+
(unless (eq found-status :modified)
448+
(setf found-status :added)))
449+
((:deleted :staged-deleted)
450+
(unless (member found-status '(:modified :added))
451+
(setf found-status :deleted)))
452+
(:untracked
453+
(unless found-status
454+
(setf found-status :untracked))))))
455+
status-table)
456+
found-status))
457+
458+
(defun get-file-git-status (pathname directory)
459+
"Get git status symbol for a specific file or directory.
460+
For directories, checks if any file underneath has changes."
461+
(alexandria:when-let ((status-table (get-directory-git-status directory)))
462+
(alexandria:when-let ((git-root (find-git-root directory)))
463+
(let ((relative-path (namestring (enough-namestring pathname git-root))))
464+
(if (uiop:directory-pathname-p pathname)
465+
;; For directories, check if any files underneath have changes
466+
(let ((dir-prefix (if (alexandria:ends-with #\/ relative-path)
467+
relative-path
468+
(concatenate 'string relative-path "/"))))
469+
(find-status-in-directory dir-prefix status-table))
470+
;; For files, direct lookup
471+
(gethash relative-path status-table))))))
472+
473+
(defun status-to-display (status)
474+
"Convert status keyword to display character and attribute."
475+
(case status
476+
(:modified (values "M" 'git-status-modified-attribute))
477+
(:staged-modified (values "M" 'git-status-staged-attribute))
478+
(:added (values "A" 'git-status-added-attribute))
479+
(:staged-added (values "A" 'git-status-staged-attribute))
480+
(:deleted (values "D" 'git-status-deleted-attribute))
481+
(:staged-deleted (values "D" 'git-status-staged-attribute))
482+
(:untracked (values "?" 'git-status-untracked-attribute))
483+
(otherwise (values " " nil))))
484+
485+
(defun insert-git-status (point item)
486+
"Inserter function for directory-mode to show git status.
487+
Skips the '..' (parent directory) entry."
488+
(let* ((pathname (item-pathname item))
489+
(directory (item-directory item)))
490+
;; Skip ".." entry (when pathname is parent of directory)
491+
(if (and pathname directory
492+
(equal pathname (uiop:pathname-parent-directory-pathname directory)))
493+
(insert-string point " ")
494+
(let ((status (when (and pathname directory)
495+
(get-file-git-status pathname directory))))
496+
(multiple-value-bind (char attr)
497+
(status-to-display status)
498+
(if attr
499+
(insert-string point (format nil "~A " char) :attribute attr)
500+
(insert-string point " ")))))))
501+
502+
(defun clear-directory-status-cache ()
503+
"Clear the directory git status cache."
504+
(clrhash *directory-git-status-cache*))
505+
506+
(define-command git-gutter-refresh-directory-status () ()
507+
"Refresh git status cache for current directory buffer."
508+
(let ((directory (buffer-directory (current-buffer))))
509+
(when directory
510+
(remhash (namestring directory) *directory-git-status-cache*)
511+
;; Force redisplay of directory buffer if in directory-mode
512+
(when (eq (buffer-major-mode (current-buffer)) 'lem/directory-mode:directory-mode)
513+
(update-buffer (current-buffer)))
514+
(message "Git status refreshed"))))

extensions/git-gutter/lem-git-gutter.asd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
:in-order-to ((test-op (test-op "lem-git-gutter/tests"))))
1515

1616
(defsystem "lem-git-gutter/tests"
17-
:depends-on ("lem-git-gutter/diff-parser" "rove")
17+
:depends-on ("lem-git-gutter" "rove")
1818
:components ((:module "tests"
1919
:components ((:file "git-gutter-tests"))))
2020
:perform (test-op (op c) (symbol-call :rove '#:run c)))

0 commit comments

Comments
 (0)