git-related.el 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. ;;; git-related.el --- Find related files through commit history analysis -*- lexical-binding: t -*-
  2. ;; Copyright (C) 2023 Nthcdr
  3. ;; Author: Nthcdr <nthcdr@macroexpand.net>
  4. ;; Maintainer: Nthcdr <nthcdr@macroexpand.net>
  5. ;; URL: https://macroexpand.net/el/git-related.el
  6. ;; Version: 1.0
  7. ;; Package-Requires: ((emacs "28.1"))
  8. ;; This program is free software; you can redistribute it and/or modify
  9. ;; it under the terms of the GNU General Public License as published by
  10. ;; the Free Software Foundation, either version 3 of the License, or
  11. ;; (at your option) any later version.
  12. ;; This program is distributed in the hope that it will be useful,
  13. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. ;; GNU General Public License for more details.
  16. ;; You should have received a copy of the GNU General Public License
  17. ;; along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. ;;; Commentary:
  19. ;; Find files by recommendation based on git commit history.
  20. ;; Usage: Visiting a git versioned file run once (and then only when
  21. ;; you feel the need to refresh) `git-related-update` than you will get
  22. ;; suggestions based on the current file through invocations to
  23. ;; `git-related-find-file`
  24. ;;; Code:
  25. (require 'cl-lib)
  26. (require 'subr-x)
  27. (require 'project)
  28. (require 'vc-git)
  29. (defface git-related-score
  30. '((t (:foreground "#f1fa8c")))
  31. "Face used for git related score."
  32. :group 'git-related)
  33. (defface git-related-file
  34. '((t (:foreground "#ff79c6")))
  35. "Face used for git related file name."
  36. :group 'git-related)
  37. (defvar git-related--graphs nil)
  38. (cl-defstruct git-related--graph files commits)
  39. (cl-defstruct git-related--file (name "" :type string) (commits nil :type list))
  40. (cl-defstruct git-related--commit (sha "" :type string) (files nil :type list))
  41. (defun git-related--new-graph ()
  42. "Create an empty graph."
  43. (make-git-related--graph
  44. :files (make-hash-table :test 'equal :size 2500)
  45. :commits (make-hash-table :test 'equal :size 2500)))
  46. (defun git-related--record-commit (graph sha filenames)
  47. "Record in the GRAPH the relation between SHA and FILENAMES."
  48. (let ((commit (make-git-related--commit :sha sha)))
  49. (dolist (filename filenames)
  50. (let* ((seen-file (gethash filename (git-related--graph-files graph)))
  51. (file-found (not (null seen-file)))
  52. (file (or seen-file (make-git-related--file :name filename))))
  53. (cl-pushnew commit (git-related--file-commits file))
  54. (cl-pushnew file (git-related--commit-files commit))
  55. (unless file-found
  56. (setf (gethash filename (git-related--graph-files graph)) file))))
  57. (setf (gethash sha (git-related--graph-commits graph)) commit)))
  58. (defun git-related--replay (&optional graph)
  59. "Replay git commit history into optional GRAPH."
  60. (let ((graph (or graph (git-related--new-graph))))
  61. (with-temp-buffer
  62. (process-file vc-git-program nil t nil "log" "--name-only" "--format=%x00%H")
  63. (let* ((commits (split-string (buffer-string) "\0" t))
  64. (replay-count 0)
  65. (progress-reporter (make-progress-reporter "Building commit-file graph..." 0 (length commits))))
  66. (dolist (commit commits)
  67. (let* ((sha-and-paths (split-string commit "\n\n" t (rx whitespace)))
  68. (sha (car sha-and-paths))
  69. (paths (when (cadr sha-and-paths)
  70. (split-string (cadr sha-and-paths) "\n" t (rx whitespace)))))
  71. (git-related--record-commit graph sha paths)
  72. (progress-reporter-update progress-reporter (cl-incf replay-count))))
  73. (progress-reporter-done progress-reporter)))
  74. graph))
  75. (defun git-related--similar-files (graph filename)
  76. "Return files in GRAPH that are similar to FILENAME."
  77. (unless (git-related--graph-p graph)
  78. (user-error "You need to index this project first"))
  79. (let ((file (gethash filename (git-related--graph-files graph))))
  80. (when file
  81. (let ((file-sqrt (sqrt (length (git-related--file-commits file))))
  82. (neighbor-sqrts (make-hash-table :test 'equal :size 100))
  83. (hits (make-hash-table :test 'equal :size 100)))
  84. (dolist (commit (git-related--file-commits file))
  85. (dolist (neighbor (remove file (git-related--commit-files commit)))
  86. (let ((count (cl-incf (gethash (git-related--file-name neighbor) hits 0))))
  87. (when (= count 1)
  88. (setf (gethash (git-related--file-name neighbor) neighbor-sqrts)
  89. (sqrt (length (git-related--file-commits neighbor))))))))
  90. (let (ranked-neighbors)
  91. (maphash
  92. (lambda (neighbor-name neighbor-sqrt)
  93. (let ((axb (* file-sqrt neighbor-sqrt))
  94. (n (gethash neighbor-name hits)))
  95. (push (list (if (cl-plusp axb) (/ n axb) 0.0) neighbor-name) ranked-neighbors)))
  96. neighbor-sqrts)
  97. (cl-sort
  98. (cl-remove-if-not #'git-related--file-exists-p ranked-neighbors :key #'cadr)
  99. #'> :key #'car))))))
  100. (defun git-related--file-exists-p (relative-filename)
  101. "Determine if RELATIVE-FILENAME currently exists."
  102. (file-exists-p
  103. (expand-file-name relative-filename
  104. (project-root (project-current)))))
  105. (defun git-related--propertize (hit)
  106. "Given the cons HIT return a rendered representation for completion."
  107. (propertize
  108. (concat
  109. (propertize (format "%2.2f" (car hit)) 'face 'git-related-score)
  110. " ---> "
  111. (propertize (cadr hit) 'face 'git-related-file))
  112. 'path (cadr hit)))
  113. ;;;###autoload
  114. (defun git-related-update ()
  115. "Update graph for the current project."
  116. (interactive)
  117. (let* ((default-directory (project-root (project-current)))
  118. (project-symbol (intern (project-name (project-current))))
  119. (graph (cl-getf git-related--graphs project-symbol)))
  120. (setf (cl-getf git-related--graphs project-symbol)
  121. (git-related--replay graph))))
  122. ;;;###autoload
  123. (defun git-related-find-file ()
  124. "Find files related through commit history."
  125. (interactive)
  126. (if (buffer-file-name)
  127. (let ((default-directory (project-root (project-current))))
  128. (find-file
  129. (let* ((selection
  130. (completing-read "Related files: "
  131. (mapcar #'git-related--propertize
  132. (git-related--similar-files
  133. (cl-getf git-related--graphs (intern (project-name (project-current))))
  134. (file-relative-name (buffer-file-name) (project-root (project-current)))))
  135. nil t)))
  136. (when selection
  137. (let ((filename (get-text-property 0 'path selection)))
  138. (find-file filename))))))
  139. (message "Current buffer has no file")))
  140. (provide 'git-related)
  141. ;;; git-related.el ends here