1;;; git-blame.el --- Minor mode for incremental blame for Git -*- coding: utf-8 -*-
2;;
3;; Copyright (C) 2007 David Kågedal
4;;
5;; Authors: David Kågedal <davidk@lysator.liu.se>
6;; Created: 31 Jan 2007
7;; Message-ID: <87iren2vqx.fsf@morpheus.local>
8;; License: GPL
9;; Keywords: git, version control, release management
10;;
11;; Compatibility: Emacs21, Emacs22 and EmacsCVS
12;; Git 1.5 and up
13
14;; This file is *NOT* part of GNU Emacs.
15;; This file is distributed under the same terms as GNU Emacs.
16
17;; This program is free software; you can redistribute it and/or
18;; modify it under the terms of the GNU General Public License as
19;; published by the Free Software Foundation; either version 2 of
20;; the License, or (at your option) any later version.
21
22;; This program is distributed in the hope that it will be
23;; useful, but WITHOUT ANY WARRANTY; without even the implied
24;; warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
25;; PURPOSE. See the GNU General Public License for more details.
26
27;; You should have received a copy of the GNU General Public
28;; License along with this program; if not, write to the Free
29;; Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
30;; MA 02111-1307 USA
31
32;; http://www.fsf.org/copyleft/gpl.html
33
34
35;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
36;;
37;;; Commentary:
38;;
39;; Here is an Emacs implementation of incremental git-blame. When you
40;; turn it on while viewing a file, the editor buffer will be updated by
41;; setting the background of individual lines to a color that reflects
42;; which commit it comes from. And when you move around the buffer, a
43;; one-line summary will be shown in the echo area.
44
45;;; Installation:
46;;
47;; To use this package, put it somewhere in `load-path' (or add
48;; directory with git-blame.el to `load-path'), and add the following
49;; line to your .emacs:
50;;
51;; (require 'git-blame)
52;;
53;; If you do not want to load this package before it is necessary, you
54;; can make use of the `autoload' feature, e.g. by adding to your .emacs
55;; the following lines
56;;
57;; (autoload 'git-blame-mode "git-blame"
58;; "Minor mode for incremental blame for Git." t)
59;;
60;; Then first use of `M-x git-blame-mode' would load the package.
61
62;;; Compatibility:
63;;
64;; It requires GNU Emacs 21 or later and Git 1.5.0 and up
65;;
66;; If you'are using Emacs 20, try changing this:
67;;
68;; (overlay-put ovl 'face (list :background
69;; (cdr (assq 'color (cddddr info)))))
70;;
71;; to
72;;
73;; (overlay-put ovl 'face (cons 'background-color
74;; (cdr (assq 'color (cddddr info)))))
75
76
77;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
78;;
79;;; Code:
80
81(eval-when-compile (require 'cl)) ; to use `push', `pop'
82
83
84(defun git-blame-color-scale (&rest elements)
85 "Given a list, returns a list of triples formed with each
86elements of the list.
87
88a b => bbb bba bab baa abb aba aaa aab"
89 (let (result)
90 (dolist (a elements)
91 (dolist (b elements)
92 (dolist (c elements)
93 (setq result (cons (format "#%s%s%s" a b c) result)))))
94 result))
95
96;; (git-blame-color-scale "0c" "04" "24" "1c" "2c" "34" "14" "3c") =>
97;; ("#3c3c3c" "#3c3c14" "#3c3c34" "#3c3c2c" "#3c3c1c" "#3c3c24"
98;; "#3c3c04" "#3c3c0c" "#3c143c" "#3c1414" "#3c1434" "#3c142c" ...)
99
100(defmacro git-blame-random-pop (l)
101 "Select a random element from L and returns it. Also remove
102selected element from l."
103 ;; only works on lists with unique elements
104 `(let ((e (elt ,l (random (length ,l)))))
105 (setq ,l (remove e ,l))
106 e))
107
108(defvar git-blame-dark-colors
109 (git-blame-color-scale "0c" "04" "24" "1c" "2c" "34" "14" "3c")
110 "*List of colors (format #RGB) to use in a dark environment.
111
112To check out the list, evaluate (list-colors-display git-blame-dark-colors).")
113
114(defvar git-blame-light-colors
115 (git-blame-color-scale "c4" "d4" "cc" "dc" "f4" "e4" "fc" "ec")
116 "*List of colors (format #RGB) to use in a light environment.
117
118To check out the list, evaluate (list-colors-display git-blame-light-colors).")
119
120(defvar git-blame-colors '()
121 "Colors used by git-blame. The list is built once when activating git-blame
122minor mode.")
123
124(defvar git-blame-ancient-color "dark green"
125 "*Color to be used for ancient commit.")
126
127(defvar git-blame-autoupdate t
128 "*Automatically update the blame display while editing")
129
130(defvar git-blame-proc nil
131 "The running git-blame process")
132(make-variable-buffer-local 'git-blame-proc)
133
134(defvar git-blame-overlays nil
135 "The git-blame overlays used in the current buffer.")
136(make-variable-buffer-local 'git-blame-overlays)
137
138(defvar git-blame-cache nil
139 "A cache of git-blame information for the current buffer")
140(make-variable-buffer-local 'git-blame-cache)
141
142(defvar git-blame-idle-timer nil
143 "An idle timer that updates the blame")
144(make-variable-buffer-local 'git-blame-cache)
145
146(defvar git-blame-update-queue nil
147 "A queue of update requests")
148(make-variable-buffer-local 'git-blame-update-queue)
149
150;; FIXME: docstrings
151(defvar git-blame-file nil)
152(defvar git-blame-current nil)
153
154(defvar git-blame-mode nil)
155(make-variable-buffer-local 'git-blame-mode)
156
157(defvar git-blame-mode-line-string " blame"
158 "String to display on the mode line when git-blame is active.")
159
160(or (assq 'git-blame-mode minor-mode-alist)
161 (setq minor-mode-alist
162 (cons '(git-blame-mode git-blame-mode-line-string) minor-mode-alist)))
163
164;;;###autoload
165(defun git-blame-mode (&optional arg)
166 "Toggle minor mode for displaying Git blame
167
168With prefix ARG, turn the mode on if ARG is positive."
169 (interactive "P")
170 (cond
171 ((null arg)
172 (if git-blame-mode (git-blame-mode-off) (git-blame-mode-on)))
173 ((> (prefix-numeric-value arg) 0) (git-blame-mode-on))
174 (t (git-blame-mode-off))))
175
176(defun git-blame-mode-on ()
177 "Turn on git-blame mode.
178
179See also function `git-blame-mode'."
180 (make-local-variable 'git-blame-colors)
181 (if git-blame-autoupdate
182 (add-hook 'after-change-functions 'git-blame-after-change nil t)
183 (remove-hook 'after-change-functions 'git-blame-after-change t))
184 (git-blame-cleanup)
185 (let ((bgmode (cdr (assoc 'background-mode (frame-parameters)))))
186 (if (eq bgmode 'dark)
187 (setq git-blame-colors git-blame-dark-colors)
188 (setq git-blame-colors git-blame-light-colors)))
189 (setq git-blame-cache (make-hash-table :test 'equal))
190 (setq git-blame-mode t)
191 (git-blame-run))
192
193(defun git-blame-mode-off ()
194 "Turn off git-blame mode.
195
196See also function `git-blame-mode'."
197 (git-blame-cleanup)
198 (if git-blame-idle-timer (cancel-timer git-blame-idle-timer))
199 (setq git-blame-mode nil))
200
201;;;###autoload
202(defun git-reblame ()
203 "Recalculate all blame information in the current buffer"
204 (interactive)
205 (unless git-blame-mode
206 (error "Git-blame is not active"))
207
208 (git-blame-cleanup)
209 (git-blame-run))
210
211(defun git-blame-run (&optional startline endline)
212 (if git-blame-proc
213 ;; Should maybe queue up a new run here
214 (message "Already running git blame")
215 (let ((display-buf (current-buffer))
216 (blame-buf (get-buffer-create
217 (concat " git blame for " (buffer-name))))
218 (args '("--incremental" "--contents" "-")))
219 (if startline
220 (setq args (append args
221 (list "-L" (format "%d,%d" startline endline)))))
222 (setq args (append args
223 (list (file-name-nondirectory buffer-file-name))))
224 (setq git-blame-proc
225 (apply 'start-process
226 "git-blame" blame-buf
227 "git" "blame"
228 args))
229 (with-current-buffer blame-buf
230 (erase-buffer)
231 (make-local-variable 'git-blame-file)
232 (make-local-variable 'git-blame-current)
233 (setq git-blame-file display-buf)
234 (setq git-blame-current nil))
235 (set-process-filter git-blame-proc 'git-blame-filter)
236 (set-process-sentinel git-blame-proc 'git-blame-sentinel)
237 (process-send-region git-blame-proc (point-min) (point-max))
238 (process-send-eof git-blame-proc))))
239
240(defun remove-git-blame-text-properties (start end)
241 (let ((modified (buffer-modified-p))
242 (inhibit-read-only t))
243 (remove-text-properties start end '(point-entered nil))
244 (set-buffer-modified-p modified)))
245
246(defun git-blame-cleanup ()
247 "Remove all blame properties"
248 (mapcar 'delete-overlay git-blame-overlays)
249 (setq git-blame-overlays nil)
250 (remove-git-blame-text-properties (point-min) (point-max)))
251
252(defun git-blame-update-region (start end)
253 "Rerun blame to get updates between START and END"
254 (let ((overlays (overlays-in start end)))
255 (while overlays
256 (let ((overlay (pop overlays)))
257 (if (< (overlay-start overlay) start)
258 (setq start (overlay-start overlay)))
259 (if (> (overlay-end overlay) end)
260 (setq end (overlay-end overlay)))
261 (setq git-blame-overlays (delete overlay git-blame-overlays))
262 (delete-overlay overlay))))
263 (remove-git-blame-text-properties start end)
264 ;; We can be sure that start and end are at line breaks
265 (git-blame-run (1+ (count-lines (point-min) start))
266 (count-lines (point-min) end)))
267
268(defun git-blame-sentinel (proc status)
269 (with-current-buffer (process-buffer proc)
270 (with-current-buffer git-blame-file
271 (setq git-blame-proc nil)
272 (if git-blame-update-queue
273 (git-blame-delayed-update))))
274 ;;(kill-buffer (process-buffer proc))
275 ;;(message "git blame finished")
276 )
277
278(defvar in-blame-filter nil)
279
280(defun git-blame-filter (proc str)
281 (save-excursion
282 (set-buffer (process-buffer proc))
283 (goto-char (process-mark proc))
284 (insert-before-markers str)
285 (goto-char 0)
286 (unless in-blame-filter
287 (let ((more t)
288 (in-blame-filter t))
289 (while more
290 (setq more (git-blame-parse)))))))
291
292(defun git-blame-parse ()
293 (cond ((looking-at "\\([0-9a-f]\\{40\\}\\) \\([0-9]+\\) \\([0-9]+\\) \\([0-9]+\\)\n")
294 (let ((hash (match-string 1))
295 (src-line (string-to-number (match-string 2)))
296 (res-line (string-to-number (match-string 3)))
297 (num-lines (string-to-number (match-string 4))))
298 (setq git-blame-current
299 (if (string= hash "0000000000000000000000000000000000000000")
300 nil
301 (git-blame-new-commit
302 hash src-line res-line num-lines))))
303 (delete-region (point) (match-end 0))
304 t)
305 ((looking-at "filename \\(.+\\)\n")
306 (let ((filename (match-string 1)))
307 (git-blame-add-info "filename" filename))
308 (delete-region (point) (match-end 0))
309 t)
310 ((looking-at "\\([a-z-]+\\) \\(.+\\)\n")
311 (let ((key (match-string 1))
312 (value (match-string 2)))
313 (git-blame-add-info key value))
314 (delete-region (point) (match-end 0))
315 t)
316 ((looking-at "boundary\n")
317 (setq git-blame-current nil)
318 (delete-region (point) (match-end 0))
319 t)
320 (t
321 nil)))
322
323(defun git-blame-new-commit (hash src-line res-line num-lines)
324 (save-excursion
325 (set-buffer git-blame-file)
326 (let ((info (gethash hash git-blame-cache))
327 (inhibit-point-motion-hooks t)
328 (inhibit-modification-hooks t))
329 (when (not info)
330 ;; Assign a random color to each new commit info
331 ;; Take care not to select the same color multiple times
332 (let ((color (if git-blame-colors
333 (git-blame-random-pop git-blame-colors)
334 git-blame-ancient-color)))
335 (setq info (list hash src-line res-line num-lines
336 (git-describe-commit hash)
337 (cons 'color color))))
338 (puthash hash info git-blame-cache))
339 (goto-line res-line)
340 (while (> num-lines 0)
341 (if (get-text-property (point) 'git-blame)
342 (forward-line)
343 (let* ((start (point))
344 (end (progn (forward-line 1) (point)))
345 (ovl (make-overlay start end)))
346 (push ovl git-blame-overlays)
347 (overlay-put ovl 'git-blame info)
348 (overlay-put ovl 'help-echo hash)
349 (overlay-put ovl 'face (list :background
350 (cdr (assq 'color (nthcdr 5 info)))))
351 ;; the point-entered property doesn't seem to work in overlays
352 ;;(overlay-put ovl 'point-entered
353 ;; `(lambda (x y) (git-blame-identify ,hash)))
354 (let ((modified (buffer-modified-p)))
355 (put-text-property (if (= start 1) start (1- start)) (1- end)
356 'point-entered
357 `(lambda (x y) (git-blame-identify ,hash)))
358 (set-buffer-modified-p modified))))
359 (setq num-lines (1- num-lines))))))
360
361(defun git-blame-add-info (key value)
362 (if git-blame-current
363 (nconc git-blame-current (list (cons (intern key) value)))))
364
365(defun git-blame-current-commit ()
366 (let ((info (get-char-property (point) 'git-blame)))
367 (if info
368 (car info)
369 (error "No commit info"))))
370
371(defun git-describe-commit (hash)
372 (with-temp-buffer
373 (call-process "git" nil t nil
374 "log" "-1" "--pretty=oneline"
375 hash)
376 (buffer-substring (point-min) (1- (point-max)))))
377
378(defvar git-blame-last-identification nil)
379(make-variable-buffer-local 'git-blame-last-identification)
380(defun git-blame-identify (&optional hash)
381 (interactive)
382 (let ((info (gethash (or hash (git-blame-current-commit)) git-blame-cache)))
383 (when (and info (not (eq info git-blame-last-identification)))
384 (message "%s" (nth 4 info))
385 (setq git-blame-last-identification info))))
386
387;; (defun git-blame-after-save ()
388;; (when git-blame-mode
389;; (git-blame-cleanup)
390;; (git-blame-run)))
391;; (add-hook 'after-save-hook 'git-blame-after-save)
392
393(defun git-blame-after-change (start end length)
394 (when git-blame-mode
395 (git-blame-enq-update start end)))
396
397(defvar git-blame-last-update nil)
398(make-variable-buffer-local 'git-blame-last-update)
399(defun git-blame-enq-update (start end)
400 "Mark the region between START and END as needing blame update"
401 ;; Try to be smart and avoid multiple callouts for sequential
402 ;; editing
403 (cond ((and git-blame-last-update
404 (= start (cdr git-blame-last-update)))
405 (setcdr git-blame-last-update end))
406 ((and git-blame-last-update
407 (= end (car git-blame-last-update)))
408 (setcar git-blame-last-update start))
409 (t
410 (setq git-blame-last-update (cons start end))
411 (setq git-blame-update-queue (nconc git-blame-update-queue
412 (list git-blame-last-update)))))
413 (unless (or git-blame-proc git-blame-idle-timer)
414 (setq git-blame-idle-timer
415 (run-with-idle-timer 0.5 nil 'git-blame-delayed-update))))
416
417(defun git-blame-delayed-update ()
418 (setq git-blame-idle-timer nil)
419 (if git-blame-update-queue
420 (let ((first (pop git-blame-update-queue))
421 (inhibit-point-motion-hooks t))
422 (git-blame-update-region (car first) (cdr first)))))
423
424(provide 'git-blame)
425
426;;; git-blame.el ends here