├── .gitignore ├── Makefile ├── NEWS ├── README.md ├── magit-tbdiff.el └── static └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | /config.mk 2 | magit-tbdiff-autoloads.el 3 | *.elc 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | -include config.mk 3 | 4 | COMPAT_DIR ?= /dev/null 5 | LLAMA_DIR ?= /dev/null 6 | WITH_EDITOR_DIR ?= /dev/null 7 | TRANSIENT_DIR ?= /dev/null 8 | MAGIT_DIR ?= /dev/null 9 | 10 | LOAD_PATH = -L $(COMPAT_DIR) -L $(LLAMA_DIR) -L $(WITH_EDITOR_DIR) \ 11 | -L $(TRANSIENT_DIR) -L $(MAGIT_DIR) 12 | BATCH = emacs -Q --batch $(LOAD_PATH) 13 | 14 | all: magit-tbdiff.elc magit-tbdiff-autoloads.el 15 | 16 | .PHONY: clean 17 | clean: 18 | $(RM) *.elc magit-tbdiff-autoloads.el 19 | 20 | %.elc: %.el 21 | @$(BATCH) -f batch-byte-compile $< 22 | 23 | %-autoloads.el: %.el 24 | @$(BATCH) --eval \ 25 | "(let ((make-backup-files nil)) \ 26 | (if (fboundp 'loaddefs-generate) \ 27 | (loaddefs-generate default-directory \"$@\") \ 28 | (update-file-autoloads \"$(CURDIR)/$<\" t \"$(CURDIR)/$@\")))" 29 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | Magit-tbdiff NEWS -- history of user-visible changes -*- mode: org; -*- 2 | 3 | * Version 1.2.0 4 | 5 | Magit-tbdiff now requires Magit version 4.0.0 or later. 6 | 7 | * Version 1.1.1 8 | 9 | Adapt ansi-color usage to work with Emacs 28 while retaining 10 | compatibility with older Emacs versions. 11 | 12 | * Version 1.1.0 13 | 14 | Magit-tbdiff has been updated for the latest version of Magit and now 15 | requires version 3.0.0 or later. 16 | 17 | - To be compatible with the latest Magit, ~magit-tbdiff-popup~ has 18 | been rewritten as a transient command, ~magit-tbdiff~. 19 | 20 | The key bindings for ~--creation-weight~ has changed from ~=w~ to 21 | ~-w~ because, unlike magit-popup.el, transient.el doesn't require 22 | that options start with '='. 23 | 24 | - The ~magit-tbdiff~ transient now supports range-diff's analogs of 25 | tbdiff's ~--creation-weight~ and ~--no-patches~: ~creation-factor~ 26 | and ~--no-patch~. 27 | 28 | - The ~--dual-color~ switch for ~range-diff~ is now supported. 29 | 30 | - The ~--(left|right)-only~ (new in Git 2.31) and ~--no-notes~ (new in 31 | Git 2.25) switches for ~range-diff~ are now supported but are 32 | disabled by default. 33 | 34 | - New command ~magit-tbdiff-save~ (inspired by ~magit-patch-save~) 35 | writes the range-diff output to a file. 36 | 37 | * Version 0.3.0 38 | 39 | - Git v2.19.0 ships with a built-in analog of tbdiff, ~range-diff~! 40 | The new option ~magit-tbdiff-subcommand~ will be set to "range-diff" 41 | instead of "tbdiff" if ~git-range-diff~ is detected. 42 | 43 | - ~magit-tbdiff-mode~ buffers can now be locked with 44 | ~magit-toggle-buffer-lock~. 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License GPL 3][badge-license]](https://www.gnu.org/licenses/gpl-3.0.txt) 2 | [![MELPA](https://melpa.org/packages/magit-tbdiff-badge.svg)](https://melpa.org/#/magit-tbdiff) 3 | 4 | Magit-tbdiff is a Magit interface to [git-tbdiff] and 5 | [git-range-diff], subcommands for comparing two versions of a topic 6 | branch (or more generally two ranges). When Magit-tbdiff was created, 7 | only git-tbdiff, a third-party extension, existed. However, recent 8 | versions of Git include a tbdiff-inspired range-diff subcommand. 9 | Using range-diff is recommended, and it will be used by default if 10 | detected. 11 | 12 | The [commentary] in magit-tbdiff.el provides an overview of 13 | Magit-tbdiff commands. 14 | 15 | ![Magit-tbdiff screenshot](static/screenshot.png) 16 | 17 | [badge-license]: https://img.shields.io/badge/license-GPL_3-green.svg 18 | [commentary]: https://github.com/magit/magit-tbdiff/blob/master/magit-tbdiff.el#L24 19 | [announcement]: https://public-inbox.org/git/87ip2pfs19.fsf@linux-k42r.v.cablecom.net/ 20 | [git-tbdiff]: https://github.com/trast/tbdiff 21 | [git-range-diff]: https://git-scm.com/docs/git-range-diff 22 | -------------------------------------------------------------------------------- /magit-tbdiff.el: -------------------------------------------------------------------------------- 1 | ;;; magit-tbdiff.el --- Magit extension for range diffs -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2017-2024 all Magit-tbdiff contributors 4 | 5 | ;; Author: Kyle Meyer 6 | ;; URL: https://github.com/magit/magit-tbdiff 7 | ;; Keywords: vc, tools 8 | ;; Version: 1.2.0 9 | ;; Package-Requires: ((emacs "26.1") (magit "4.0.0")) 10 | 11 | ;; This program is free software; you can redistribute it and/or modify 12 | ;; it under the terms of the GNU General Public License as published by 13 | ;; the Free Software Foundation, either version 3 of the License, or 14 | ;; (at your option) any later version. 15 | ;; 16 | ;; This program is distributed in the hope that it will be useful, 17 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | ;; GNU General Public License for more details. 20 | ;; 21 | ;; You should have received a copy of the GNU General Public License 22 | ;; along with this program. If not, see . 23 | 24 | ;;; Commentary: 25 | ;; 26 | ;; Magit-tbdiff provides a Magit interface to git-tbdiff [1] and 27 | ;; git-range-diff, subcommands for comparing two versions of a topic 28 | ;; branch. 29 | ;; 30 | ;; There are three commands for creating range diffs: 31 | ;; 32 | ;; * `magit-tbdiff-ranges' is the most generic of the three 33 | ;; commands. It reads two ranges that represent the two series to 34 | ;; be compared. 35 | ;; 36 | ;; * `magit-tbdiff-revs' reads two revisions. From these (say, "A" 37 | ;; and "B"), it constructs the two series as B..A and A..B. 38 | ;; 39 | ;; * `magit-tbdiff-revs-with-base' is like the previous command, but 40 | ;; it also reads a base revision, constructing the range as 41 | ;; ..A and ..B. 42 | ;; 43 | ;; These commands are available in the transient `magit-tbdiff', which 44 | ;; in turn is available in the Magit diff transient, bound by default 45 | ;; to "i" (for "interdiff" [2]). So, with the default keybindings, 46 | ;; you can invoke the tbdiff transient with "di". 47 | ;; 48 | ;; As of v2.19.0, Git comes with the "range-diff" subcommand, an 49 | ;; analog of tbdiff. The option `magit-tbdiff-subcommand' controls 50 | ;; which subcommand is used. 51 | ;; 52 | ;; When Magit-tbdiff is installed from MELPA, no additional setup is 53 | ;; needed beyond installing git-tbdiff [1]. The tbdiff transient will 54 | ;; be added under the Magit diff transient, and Magit-tbdiff will be 55 | ;; loaded the first time that the tbdiff transient is invoked. 56 | ;; 57 | ;; [1] https://github.com/trast/tbdiff 58 | ;; 59 | ;; [2] When I selected that key, I didn't know what an interdiff was 60 | ;; and that what tbdiff refers to as an "interdiff" isn't 61 | ;; technically one. Sorry. 62 | ;; 63 | ;; https://lore.kernel.org/git/nycvar.QRO.7.76.6.1805062155120.77@tvgsbejvaqbjf.bet/#t 64 | 65 | ;;; Code: 66 | 67 | (require 'ansi-color) 68 | (require 'cl-lib) 69 | (require 'magit) 70 | (require 'transient) 71 | 72 | 73 | ;;; Options 74 | 75 | (defgroup magit-tbdiff nil 76 | "Magit extension for git-tbdiff and git-range-diff" 77 | :prefix "magit-tbdiff" 78 | :group 'magit-extensions) 79 | 80 | (defface magit-tbdiff-marker-equivalent 81 | '((t (:inherit magit-cherry-equivalent))) 82 | "Face for '=' marker in assignment line." 83 | :group 'magit-tbdiff) 84 | 85 | (defface magit-tbdiff-marker-different 86 | '((t (:inherit magit-cherry-unmatched))) 87 | "Face for '!' marker in assignment line." 88 | :group 'magit-tbdiff) 89 | 90 | (defface magit-tbdiff-marker-unmatched 91 | '((t (:inherit magit-cherry-unmatched))) 92 | "Face for '<' and '>' markers in assignment line." 93 | :group 'magit-tbdiff) 94 | 95 | (defcustom magit-tbdiff-subcommand 96 | (or (and (ignore-errors 97 | (file-exists-p 98 | (concat (file-name-as-directory (magit-git-str "--exec-path")) 99 | "git-range-diff"))) 100 | "range-diff") 101 | "tbdiff") 102 | "Subcommand used to create range diff. 103 | Translates to \"git [global options] ...\". The 104 | default is set to \"range-diff\" if git-range-diff (introduced in 105 | Git v2.19.0) is detected on your system and to \"tbdiff\" 106 | otherwise." 107 | :package-version '(magit-tbdiff . "0.3.0") 108 | :type 'string) 109 | 110 | 111 | ;;; Internals 112 | 113 | (defvar-local magit-tbdiff-buffer-range-a nil) 114 | (defvar-local magit-tbdiff-buffer-range-b nil) 115 | 116 | (define-derived-mode magit-tbdiff-mode magit-mode "Magit-tbdiff" 117 | "Mode for viewing range diffs. 118 | 119 | \\{magit-tbdiff-mode-map}" 120 | :group 'magit-tbdiff 121 | (setq-local magit-diff-highlight-trailing nil) 122 | (hack-dir-local-variables-non-file-buffer)) 123 | 124 | (defvar magit-tbdiff-assignment-re 125 | (eval-when-compile 126 | (let ((digit-re '(and (zero-or-more " ") ; Retain left padding. 127 | (or (one-or-more digit) 128 | (one-or-more "-")))) 129 | (hash-re '(or (repeat 4 40 (char digit (?a . ?f))) 130 | (repeat 4 40 "-")))) 131 | (rx-to-string `(and line-start 132 | (group ,digit-re) 133 | ":" (zero-or-more " ") 134 | (group ,hash-re) " " 135 | (group (any "<>!=")) " " 136 | (group ,digit-re) 137 | ":" (zero-or-more " ") 138 | (group ,hash-re) " " 139 | (group (zero-or-more not-newline))) 140 | t)))) 141 | 142 | ;; range-diff's --dual-color inverts the color of the outer diff 143 | ;; markers. Map this to boxed text. 144 | (face-spec-set 'magit-tbdiff-dual-color '((t (:box t)))) 145 | (defvar magit-tbdiff--color-vector 146 | (let ((new (copy-sequence 147 | (cond ((boundp 'ansi-color-basic-faces-vector) 148 | ansi-color-basic-faces-vector) 149 | ;; Emacs <28. 150 | ((boundp 'ansi-color-faces-vector) 151 | ansi-color-faces-vector) 152 | (t (error "This should be unreachable")))))) 153 | (aset new 7 'magit-tbdiff-dual-color) 154 | new)) 155 | 156 | (defvar magit-tbdiff-ansi-color-map 157 | (cond 158 | ((boundp 'ansi-color-basic-faces-vector) nil) 159 | ;; Emacs <28. 160 | ((and (fboundp 'ansi-color-make-color-map) 161 | (boundp 'ansi-color-faces-vector)) 162 | (let ((ansi-color-faces-vector magit-tbdiff--color-vector)) 163 | (ansi-color-make-color-map))) 164 | (t (error "This should be unreachable")))) 165 | 166 | (defmacro magit-tbdiff--with-ansi-colors (&rest body) 167 | (declare (indent 0) (debug (body))) 168 | (if (boundp 'ansi-color-basic-faces-vector) 169 | `(let ((ansi-color-basic-faces-vector magit-tbdiff--color-vector)) 170 | ,@body) 171 | ;; Emacs <28. 172 | `(let ((ansi-color-map magit-tbdiff-ansi-color-map)) 173 | ,@body))) 174 | 175 | (defun magit-tbdiff-wash-hunk (&optional dual-color) 176 | ;; Note: This hunk matching is less strict than what is in 177 | ;; `magit-diff-wash-hunk' to accommodate range-diff output changes 178 | ;; introduced by Git 2.23.0. 179 | (when (looking-at "^@@\\(?: \\(.*\\)\\)?") 180 | (let ((heading (match-string 0)) 181 | (value (match-string 1))) 182 | (magit-delete-line) 183 | (magit-insert-section section ((eval (if dual-color 'tbdiff-hunk 'hunk)) 184 | value) 185 | (insert (propertize (concat heading "\n") 186 | 'font-lock-face 'magit-diff-hunk-heading)) 187 | (when dual-color 188 | (remove-overlays (line-beginning-position 0) (line-end-position 0))) 189 | (magit-insert-heading) 190 | (while (not (or (eobp) (looking-at "^[^-+\s\\]"))) 191 | (forward-line)) 192 | (oset section end (point)) 193 | (unless dual-color 194 | (oset section washer 'magit-diff-paint-hunk)))) 195 | t)) 196 | 197 | (defun magit-tbdiff-wash (args) 198 | (let* ((dual-color (member "--dual-color" args)) 199 | (hunk-wash-fn (lambda () (magit-tbdiff-wash-hunk dual-color)))) 200 | (when dual-color 201 | (let (end-pt) 202 | (goto-char (point-max)) 203 | (goto-char (line-beginning-position)) 204 | ;; Paint ANSI escape sequences in hunks while removing them from 205 | ;; the assignment lines. 206 | (magit-tbdiff--with-ansi-colors 207 | (while (zerop (forward-line -1)) 208 | (if (looking-at-p "^ ") 209 | (unless end-pt 210 | (setq end-pt (line-end-position))) 211 | (when end-pt 212 | (ansi-color-apply-on-region (1+ (line-end-position)) end-pt) 213 | (setq end-pt nil)) 214 | (ansi-color-filter-region (point) (line-end-position))))))) 215 | (while (not (eobp)) 216 | (if (looking-at magit-tbdiff-assignment-re) 217 | (magit-bind-match-strings 218 | (num-a hash-a marker num-b hash-b subject) nil 219 | (magit-delete-line) 220 | (when (string-match-p "-" hash-a) (setq hash-a nil)) 221 | (when (string-match-p "-" hash-b) (setq hash-b nil)) 222 | (magit-insert-section (commit (or hash-b hash-a)) 223 | (insert 224 | (mapconcat 225 | #'identity 226 | (list num-a 227 | (if hash-a 228 | (propertize hash-a 'face 'magit-hash) 229 | (make-string (length hash-b) ?-)) 230 | (propertize marker 231 | 'face 232 | (pcase marker 233 | ("=" 'magit-tbdiff-marker-equivalent) 234 | ("!" 'magit-tbdiff-marker-different) 235 | ((or "<" ">") 'magit-tbdiff-marker-different) 236 | (_ 237 | (error "Unrecognized marker")))) 238 | num-b 239 | (if hash-b 240 | (propertize hash-b 'face 'magit-hash) 241 | (make-string (length hash-a) ?-)) 242 | subject) 243 | " ") 244 | ?\n) 245 | (magit-insert-heading) 246 | (when (not (looking-at-p magit-tbdiff-assignment-re)) 247 | (let ((beg (point)) 248 | end) 249 | (while (looking-at "^ ") 250 | (magit-delete-match) 251 | (forward-line 1)) 252 | (setq end (point)) 253 | (goto-char beg) 254 | (save-restriction 255 | (narrow-to-region beg end) 256 | (magit-wash-sequence hunk-wash-fn)))))) 257 | (error "Unexpected tbdiff output"))))) 258 | 259 | (defun magit-tbdiff-insert () 260 | "Insert range diff into a `magit-tbdiff-mode' buffer." 261 | (let ((magit-git-global-arguments 262 | (append (list "-c" "color.diff.context=normal" 263 | "-c" "color.diff.whitespace=normal") 264 | magit-git-global-arguments))) 265 | (apply #'magit-git-wash 266 | #'magit-tbdiff-wash 267 | magit-tbdiff-subcommand 268 | (if (member "--dual-color" magit-buffer-arguments) 269 | "--color" 270 | "--no-color") 271 | magit-tbdiff-buffer-range-a magit-tbdiff-buffer-range-b 272 | magit-buffer-arguments))) 273 | 274 | (defun magit-tbdiff-setup-buffer (range-a range-b args) 275 | (magit-setup-buffer #'magit-tbdiff-mode nil 276 | (magit-tbdiff-buffer-range-a range-a) 277 | (magit-tbdiff-buffer-range-b range-b) 278 | (magit-buffer-arguments args))) 279 | 280 | (defun magit-tbdiff-refresh-buffer () 281 | (setq header-line-format 282 | (propertize (format " Range diff: %s vs %s" 283 | magit-tbdiff-buffer-range-a 284 | magit-tbdiff-buffer-range-b) 285 | 'face 'magit-header-line)) 286 | (magit-insert-section (tbdiff-buf) 287 | (magit-tbdiff-insert))) 288 | 289 | (cl-defmethod magit-buffer-value (&context (major-mode magit-tbdiff-mode)) 290 | (list magit-tbdiff-buffer-range-a magit-tbdiff-buffer-range-b)) 291 | 292 | (defun magit-tbdiff-apply-error (&rest _args) 293 | (when (derived-mode-p 'magit-tbdiff-mode) 294 | (user-error "Cannot apply changes from range diff hunk"))) 295 | (advice-add 'magit-apply :before #'magit-tbdiff-apply-error) 296 | (advice-add 'magit-reverse :before #'magit-tbdiff-apply-error) 297 | 298 | 299 | ;;; Commands 300 | 301 | ;;;###autoload 302 | (defun magit-tbdiff-ranges (range-a range-b &optional args) 303 | "Compare commits in RANGE-A with those in RANGE-B. 304 | $ git range-diff [ARGS...] RANGE-A RANGE-B" 305 | (interactive (list (magit-read-range "Range A") 306 | (magit-read-range "Range B") 307 | (transient-args 'magit-tbdiff))) 308 | (magit-tbdiff-setup-buffer range-a range-b args)) 309 | 310 | ;;;###autoload 311 | (defun magit-tbdiff-revs (rev-a rev-b &optional args) 312 | "Compare commits in REV-B..REV-A with those in REV-A..REV-B. 313 | $ git range-diff [ARGS...] REV-B..REV-A REV-A..REV-B" 314 | (interactive 315 | (let ((rev-a (magit-read-branch-or-commit "Revision A"))) 316 | (list rev-a 317 | (magit-read-other-branch-or-commit "Revision B" rev-a) 318 | (transient-args 'magit-tbdiff)))) 319 | (magit-tbdiff-ranges (concat rev-b ".." rev-a) 320 | (concat rev-a ".." rev-b) 321 | args)) 322 | 323 | ;;;###autoload 324 | (defun magit-tbdiff-revs-with-base (rev-a rev-b base &optional args) 325 | "Compare commits in BASE..REV-A with those in BASE..REV-B. 326 | $ git range-diff [ARGS...] BASE..REV-A BASE..REV-B" 327 | (interactive 328 | (let* ((rev-a (magit-read-branch-or-commit "Revision A")) 329 | (rev-b (magit-read-other-branch-or-commit "Revision B" rev-a))) 330 | (list rev-a rev-b 331 | (magit-read-branch-or-commit "Base" 332 | (or (magit-get-upstream-branch rev-b) 333 | (magit-get-upstream-branch rev-a))) 334 | (transient-args 'magit-tbdiff)))) 335 | (magit-tbdiff-ranges (concat base ".." rev-a) 336 | (concat base ".." rev-b) 337 | args)) 338 | 339 | (defun magit-tbdiff-save (file) 340 | "Write current range-diff to FILE." 341 | (interactive "FWrite range-diff to file: ") 342 | (unless (derived-mode-p 'magit-tbdiff-mode) 343 | (user-error "Current buffer is not a `magit-tbdiff-mode' buffer")) 344 | (let ((range-a magit-tbdiff-buffer-range-a) 345 | (range-b magit-tbdiff-buffer-range-b) 346 | (args magit-buffer-arguments)) 347 | (with-temp-file file 348 | (magit-git-insert magit-tbdiff-subcommand 349 | range-a range-b 350 | (remove "--dual-color" args)))) 351 | (magit-refresh)) 352 | 353 | ;;;###autoload (autoload 'magit-tbdiff "magit-tbdiff" nil t) 354 | (transient-define-prefix magit-tbdiff () 355 | "Invoke tbdiff (or range-diff)." 356 | :incompatible '(("--left-only" "--right-only")) 357 | ["Arguments" 358 | :if (lambda () (equal magit-tbdiff-subcommand "tbdiff")) 359 | ("-s" "Suppress diffs" "--no-patches") 360 | ;; TODO: Define custom reader. 361 | ("-w" "Creation weight [0-1, default: 0.6]" "--creation-weight=")] 362 | ["Arguments" 363 | :if-not (lambda () (equal magit-tbdiff-subcommand "tbdiff")) 364 | ("-d" "Dual color" "--dual-color") 365 | ("-s" "Suppress diffs" ("-s" "--no-patch")) 366 | (5 "-N" "Suppress notes diff" "--no-notes") 367 | (5 "-l" "Exclude commits not in left range" "--left-only" 368 | :if (lambda () (version<= "2.31" (magit-git-version)))) 369 | (5 "-r" "Exclude commits not in right range" "--right-only" 370 | :if (lambda () (version<= "2.31" (magit-git-version)))) 371 | ;; TODO: Define custom reader. 372 | ("-c" "Creation factor [0-100, default: 60] " "--creation-factor=")] 373 | ["Actions" 374 | ("b" "Compare revs using common base" magit-tbdiff-revs-with-base) 375 | ("i" "Compare revs" magit-tbdiff-revs) 376 | ("r" "Compare ranges" magit-tbdiff-ranges)]) 377 | 378 | ;;;###autoload 379 | (eval-after-load 'magit 380 | '(progn 381 | (transient-append-suffix 'magit-diff "p" 382 | '("i" "Interdiffs" magit-tbdiff)))) 383 | 384 | (provide 'magit-tbdiff) 385 | ;;; magit-tbdiff.el ends here 386 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magit/magit-tbdiff/1cb315269df2df2382edc3db21ed52418f13a0d6/static/screenshot.png --------------------------------------------------------------------------------