consult-vc

an Emacs package to navigate vc-annotate revisions via Consult
git clone https://git.trogloxene.org/consult-vc.git
Log | Files | Refs

commit 517dca2e29dfd0b666e7b1ef190d1c46ec12e039
Author: andsy10 <andsy1016@gmail.com>
Date:   Tue, 22 Sep 2026 17:57:42 +0800

Initial commit

Diffstat:
Aconsult-vc.el | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 282 insertions(+), 0 deletions(-)

diff --git a/consult-vc.el b/consult-vc.el @@ -0,0 +1,282 @@ +;;; consult-vc.el --- Consult commands for VC -*- lexical-binding: t -*- + +;;; Commentary: + +;; This package provides Consult commands enhancing `vc-annotate'. +;; +;; Use `consult-vc-annotate-revision' to select a revision of the file +;; in the current annotate buffer and warp the buffer to the selected +;; revision with `vc-annotate-warp-revision'. A suggested binding is: +;; +;; (keymap-set vc-annotate-mode-map "r" #'consult-vc-annotate-revision) +;; +;; Candidates are annotated with the commit author and date and can be +;; narrowed by author. Additionally the narrowing keys +;; `consult-vc-annotate-revision-narrow-key-forward' and +;; `consult-vc-annotate-revision-narrow-key-backward' select the +;; commits newer and older than the currently annotated revision. +;; +;; For Git backends the full history of the annotated file is offered. +;; For other backends only the revisions visible in the annotate +;; buffer are offered, without commit metadata. + +;;; Code: + +(require 'consult) +(require 'vc-annotate) +(require 'cl-lib) + +(declare-function vc-git--out-ok "vc-git") + +(defgroup consult-vc nil + "Consult commands for VC." + :group 'consult + :prefix "consult-vc-") + +(defcustom consult-vc-annotate-revision-narrow-key-forward ?> + "Narrowing key for commits newer than the annotated revision." + :type 'character) + +(defcustom consult-vc-annotate-revision-narrow-key-backward ?< + "Narrowing key for commits older than the annotated revision." + :type 'character) + +(defcustom consult-vc-annotate-revision-max-width 80 + "Maximum display width of the candidates of `consult-vc-annotate-revision'. +Set to nil to disable truncation." + :type '(choice natnum (const nil))) + +(defvar consult-vc--annotate-revision-history nil + "History for `consult-vc-annotate-revision'.") + +(defun consult-vc--annotate-revisions () + "Collect the revisions occurring in the current `vc-annotate-mode' buffer. +Return the revisions in order of first appearance. This is used for +non-Git backends, see `consult-vc--annotate-revision-items-fallback'." + (save-excursion + (save-restriction + (widen) + (goto-char (point-min)) + (let (revs) + (while (not (eobp)) + (when-let* ((rev (car (vc-annotate-extract-revision-at-line)))) + (cl-pushnew rev revs :test #'equal)) + (forward-line 1)) + (nreverse revs))))) + +(defun consult-vc--git-file-revisions (file) + "Return the revisions touching FILE as a list of (FULL SHORT), oldest first." + (with-temp-buffer + (unless (vc-git--out-ok "log" "--follow" "--format=%H%x09%h" "--" file) + (user-error "Cannot get log for %s" file)) + (goto-char (point-min)) + (nreverse + (cl-loop while (re-search-forward "^\\([0-9a-f]+\\)\t\\([0-9a-f]+\\)$" nil t) + collect (list (match-string 1) (match-string 2)))))) + +(defun consult-vc--git-revision-info (revs) + "Return a hash table mapping each revision of REVS to (DATE AUTHOR SUBJECT). +REVS is a list of full Git commit hashes. The metadata is fetched in a +single `git log' call; revisions missing from the batch result are +fetched individually. Failures are ignored and the corresponding +entries are absent from the table." + (let (entries) + (with-temp-buffer + (when (apply #'vc-git--out-ok "log" "--no-walk" "--date=short" + "--format=%H%x09%ad%x09%an%x09%s" revs) + (goto-char (point-min)) + (while (re-search-forward + "^\\([0-9a-f]+\\)\t\\([^\t]*\\)\t\\([^\t]*\\)\t\\(.*\\)$" nil t) + (push (list (match-string 1) (match-string 2) + (match-string 3) (match-string 4)) + entries)))) + (let ((result (make-hash-table :test #'equal))) + ;; Map each rev to its info, matching against the full hashes + ;; returned by `git log' by prefix, such that abbreviated + ;; revisions are also supported. + (dolist (rev revs) + (when-let* ((entry (cl-find rev entries :key #'car + :test #'string-prefix-p))) + (puthash rev (cdr entry) result))) + ;; Fallback for revisions missing from the batch result: fetch + ;; them one by one, ignoring failures. + (dolist (rev revs) + (unless (gethash rev result) + (with-temp-buffer + (when (vc-git--out-ok "show" "--no-patch" "--date=short" + "--format=%ad%x09%an%x09%s" rev) + (goto-char (point-min)) + (when (re-search-forward "^\\([^\t]*\\)\t\\([^\t]*\\)\t\\(.*\\)$" nil t) + (puthash rev (list (match-string 1) (match-string 2) + (match-string 3)) + result)))))) + result))) + +(defun consult-vc--git-annotate-revision-items (file) + "Return revision items for FILE. + +An item is a list (REV SHORT DATE AUTHOR SUBJECT IDX). REV is the full +revision used for warping, SHORT the abbreviated revision used for +display, DATE the commit date as a zero-padded ISO string, AUTHOR and +SUBJECT the commit metadata and IDX the position in the chronologically +sorted item list. + +The items are sorted by commit date, oldest revision first." + (pcase-let* ((default-directory (file-name-directory file)) + (revs (consult-vc--git-file-revisions file)) + (info (consult-vc--git-revision-info (mapcar #'car revs))) + (items (sort + (cl-loop for (full short) in revs + for (date author subject) = (gethash full info) + collect (list full short date author subject nil)) + (lambda (a b) (string< (or (nth 2 a) "") + (or (nth 2 b) "")))))) + (cl-loop for item in items + for idx from 0 + do (setf (nth 5 item) idx)) + items)) + +(defun consult-vc--annotate-revision-items-fallback () + "Return revision items extracted from the annotate buffer. +This is used for non-Git backends without full history information." + (cl-loop for rev in (consult-vc--annotate-revisions) + for idx from 0 + collect (list rev rev nil nil nil idx))) + +(defun consult-vc--annotate-revision-candidates (items) + "Return candidate strings for revision ITEMS. +Each candidate is propertized with `consult-vc-rev', +`consult-vc-author', `consult-vc-date' and `consult-vc-idx'. The +visible part is truncated to `consult-vc-annotate-revision-max-width'." + (mapcar + (pcase-lambda (`(,rev ,short ,date ,author ,subject ,idx)) + (let ((visible (concat (propertize short 'face 'consult-key) + (and subject " ") (or subject "")))) + (propertize + (concat + (if (and consult-vc-annotate-revision-max-width + (> (consult--display-width visible) + consult-vc-annotate-revision-max-width)) + (truncate-string-to-width + visible consult-vc-annotate-revision-max-width nil nil "…") + visible) + (and (or date author subject) + (propertize (concat "\t" (or date "") " " (or author "") + " " (or subject "")) + 'invisible t))) + 'consult-vc-rev rev + 'consult-vc-author author + 'consult-vc-date date + 'consult-vc-idx idx))) + items)) + +(defun consult-vc--annotate-revision-annotate (cand) + "Annotate revision candidate CAND with author and date." + (consult--annotate-align + cand + (let ((author (get-text-property 0 'consult-vc-author cand)) + (date (get-text-property 0 'consult-vc-date cand))) + (concat + (and author (propertize author 'face 'completions-annotations)) + (and author date " ") + (and date (propertize date 'face 'completions-annotations)))))) + +(defun consult-vc--annotate-revision-narrow (candidates anchor-idx) + "Return narrowing configuration for revision CANDIDATES. +ANCHOR-IDX is the index of the annotated revision, see `consult-vc-idx'. +Authors are assigned keys in order of their number of commits in the +history of the annotated file. If ANCHOR-IDX is non-nil, two additional +narrowing keys select the commits newer and older than the annotated +revision, see `consult-vc-annotate-revision-narrow-key-forward' and +`consult-vc-annotate-revision-narrow-key-backward'." + (let ((counts (make-hash-table :test #'equal))) + (dolist (cand candidates) + (when-let* ((author (get-text-property 0 'consult-vc-author cand))) + (cl-incf (gethash author counts 0)))) + (let* ((authors nil) + (authors-alist + (progn + (maphash (lambda (author n) (push (cons author n) authors)) counts) + (cl-loop for (author . _) + in (sort authors + (lambda (a b) + (or (> (cdr a) (cdr b)) + (and (= (cdr a) (cdr b)) + (string< (car a) (car b)))))) + for i across "123456789abcdefghijklmnopqrstuvwxyz" + collect (cons i author))))) + (list :keys (append (mapcar (lambda (x) (cons (car x) (cdr x))) authors-alist) + (and anchor-idx + (list (cons consult-vc-annotate-revision-narrow-key-forward "Newer") + (cons consult-vc-annotate-revision-narrow-key-backward "Older")))) + :predicate + (lambda (cand) + (let ((key consult--narrow)) + (cond ((cdr (assq key authors-alist)) + (equal (get-text-property 0 'consult-vc-author cand) + (cdr (assq key authors-alist)))) + ((eq key consult-vc-annotate-revision-narrow-key-forward) + (> (get-text-property 0 'consult-vc-idx cand) anchor-idx)) + ((eq key consult-vc-annotate-revision-narrow-key-backward) + (< (get-text-property 0 'consult-vc-idx cand) anchor-idx))))))))) + +;;;; Commands + +;;;###autoload +(defun consult-vc-annotate-revision () + "Select a revision of the file in the current `vc-annotate-mode' buffer. + +The annotate buffer is warped to the selected revision with +`vc-annotate-warp-revision', preserving the current line. + +Candidates are annotated with the commit author and date and can be +narrowed by author. Additionally the narrowing keys +`consult-vc-annotate-revision-narrow-key-forward' and +`consult-vc-annotate-revision-narrow-key-backward' select the commits +newer and older than the currently annotated revision. + +For Git backends the full history of the annotated file is offered. +For other backends only the revisions visible in the annotate buffer +are offered, without commit metadata." + (interactive) + (unless (derived-mode-p 'vc-annotate-mode) + (user-error "Not in a vc-annotate buffer")) + (consult--forbid-minibuffer) + (pcase-let* ((`(,backend (,file)) vc-buffer-overriding-fileset) + (items (consult--slow-operation "Collecting revisions..." + (if (eq backend 'Git) + (consult-vc--git-annotate-revision-items file) + (consult-vc--annotate-revision-items-fallback)))) + (anchor vc-buffer-revision) + ;; Match the anchor by prefix for Git, where revisions + ;; may be abbreviated; use exact match for other + ;; backends. + (anchor-idx (and anchor + (cl-position anchor items + :key #'car + :test (if (eq backend 'Git) + #'string-prefix-p + #'equal))))) + ;; Sort by commit date, most recent revision first. The index is + ;; chronological, such that descending order is strictly + ;; chronological, also for commits with equal dates. + (setq items (sort items (lambda (a b) (> (nth 5 a) (nth 5 b))))) + (when anchor-idx + (setq items (cl-remove-if (lambda (item) (= (nth 5 item) anchor-idx)) + items))) + (let* ((candidates (consult-vc--annotate-revision-candidates items)) + (selected (consult--read + candidates + :prompt "Annotate revision: " + :category 'consult-vc-revision + :history 'consult-vc--annotate-revision-history + :require-match t + :sort nil + :annotate #'consult-vc--annotate-revision-annotate + :narrow (consult-vc--annotate-revision-narrow candidates anchor-idx) + :lookup #'consult--lookup-member))) + (vc-annotate-warp-revision + (get-text-property 0 'consult-vc-rev selected))))) + +(provide 'consult-vc) +;;; consult-vc.el ends here