magit-bisect.el (12398B)
1 ;;; magit-bisect.el --- Bisect support for Magit -*- lexical-binding:t -*- 2 3 ;; Copyright (C) 2008-2024 The Magit Project Contributors 4 5 ;; Author: Jonas Bernoulli <emacs.magit@jonas.bernoulli.dev> 6 ;; Maintainer: Jonas Bernoulli <emacs.magit@jonas.bernoulli.dev> 7 8 ;; SPDX-License-Identifier: GPL-3.0-or-later 9 10 ;; Magit is free software: you can redistribute it and/or modify it 11 ;; under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 ;; 15 ;; Magit is distributed in the hope that it will be useful, but WITHOUT 16 ;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 17 ;; or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 18 ;; License for more details. 19 ;; 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with Magit. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; Use a binary search to find the commit that introduced a bug. 26 27 ;;; Code: 28 29 (require 'magit) 30 31 ;;; Options 32 33 (defcustom magit-bisect-show-graph t 34 "Whether to use `--graph' in the log showing commits yet to be bisected." 35 :package-version '(magit . "2.8.0") 36 :group 'magit-status 37 :type 'boolean) 38 39 (defface magit-bisect-good 40 '((t :foreground "DarkOliveGreen")) 41 "Face for good bisect revisions." 42 :group 'magit-faces) 43 44 (defface magit-bisect-skip 45 '((t :foreground "DarkGoldenrod")) 46 "Face for skipped bisect revisions." 47 :group 'magit-faces) 48 49 (defface magit-bisect-bad 50 '((t :foreground "IndianRed4")) 51 "Face for bad bisect revisions." 52 :group 'magit-faces) 53 54 ;;; Commands 55 56 ;;;###autoload (autoload 'magit-bisect "magit-bisect" nil t) 57 (transient-define-prefix magit-bisect () 58 "Narrow in on the commit that introduced a bug." 59 :man-page "git-bisect" 60 [:class transient-subgroups 61 :if-not magit-bisect-in-progress-p 62 ["Arguments" 63 ("-n" "Don't checkout commits" "--no-checkout") 64 ("-p" "Follow only first parent of a merge" "--first-parent" 65 :if (lambda () (magit-git-version>= "2.29"))) 66 (6 magit-bisect:--term-old 67 :if (lambda () (magit-git-version>= "2.7"))) 68 (6 magit-bisect:--term-new 69 :if (lambda () (magit-git-version>= "2.7")))] 70 ["Actions" 71 ("B" "Start" magit-bisect-start) 72 ("s" "Start script" magit-bisect-run)]] 73 ["Actions" 74 :if magit-bisect-in-progress-p 75 ("B" "Bad" magit-bisect-bad) 76 ("g" "Good" magit-bisect-good) 77 (6 "m" "Mark" magit-bisect-mark 78 :if (lambda () (magit-git-version>= "2.7"))) 79 ("k" "Skip" magit-bisect-skip) 80 ("r" "Reset" magit-bisect-reset) 81 ("s" "Run script" magit-bisect-run)]) 82 83 (transient-define-argument magit-bisect:--term-old () 84 :description "Old/good term" 85 :class 'transient-option 86 :key "=o" 87 :argument "--term-old=") 88 89 (transient-define-argument magit-bisect:--term-new () 90 :description "New/bad term" 91 :class 'transient-option 92 :key "=n" 93 :argument "--term-new=") 94 95 ;;;###autoload 96 (defun magit-bisect-start (bad good args) 97 "Start a bisect session. 98 99 Bisecting a bug means to find the commit that introduced it. 100 This command starts such a bisect session by asking for a known 101 good and a known bad commit. To move the session forward use the 102 other actions from the bisect transient command (\ 103 \\<magit-status-mode-map>\\[magit-bisect])." 104 (interactive (if (magit-bisect-in-progress-p) 105 (user-error "Already bisecting") 106 (magit-bisect-start-read-args))) 107 (magit-bisect-start--assert bad good args) 108 (magit-repository-local-set 'bisect--first-parent 109 (transient-arg-value "--first-parent" args)) 110 (magit-git-bisect "start" (list args bad good) t)) 111 112 (defun magit-bisect-start-read-args () 113 (let* ((args (transient-args 'magit-bisect)) 114 (bad (magit-read-branch-or-commit 115 (format "Start bisect with %s revision" 116 (or (transient-arg-value "--term-new=" args) 117 "bad"))))) 118 (list bad 119 (magit-read-other-branch-or-commit 120 (format "%s revision" (or (transient-arg-value "--term-old=" args) 121 "Good")) 122 bad) 123 args))) 124 125 (defun magit-bisect-start--assert (bad good args) 126 (unless (magit-rev-ancestor-p good bad) 127 (user-error 128 "The %s revision (%s) has to be an ancestor of the %s one (%s)" 129 (or (transient-arg-value "--term-old=" args) "good") 130 good 131 (or (transient-arg-value "--term-new=" args) "bad") 132 bad)) 133 (when (magit-anything-modified-p) 134 (user-error "Cannot bisect with uncommitted changes"))) 135 136 ;;;###autoload 137 (defun magit-bisect-reset () 138 "After bisecting, cleanup bisection state and return to original `HEAD'." 139 (interactive) 140 (magit-confirm 'reset-bisect) 141 (magit-run-git "bisect" "reset") 142 (magit-repository-local-delete 'bisect--first-parent) 143 (ignore-errors 144 (delete-file (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir))))) 145 146 ;;;###autoload 147 (defun magit-bisect-good () 148 "While bisecting, mark the current commit as good. 149 Use this after you have asserted that the commit does not contain 150 the bug in question." 151 (interactive) 152 (magit-git-bisect (or (cadr (magit-bisect-terms)) 153 (user-error "Not bisecting")))) 154 155 ;;;###autoload 156 (defun magit-bisect-bad () 157 "While bisecting, mark the current commit as bad. 158 Use this after you have asserted that the commit does contain the 159 bug in question." 160 (interactive) 161 (magit-git-bisect (or (car (magit-bisect-terms)) 162 (user-error "Not bisecting")))) 163 164 ;;;###autoload 165 (defun magit-bisect-mark () 166 "While bisecting, mark the current commit with a bisect term. 167 During a bisect using alternate terms, commits can still be 168 marked with `magit-bisect-good' and `magit-bisect-bad', as those 169 commands map to the correct term (\"good\" to --term-old's value 170 and \"bad\" to --term-new's). However, in some cases, it can be 171 difficult to keep that mapping straight in your head; this 172 command provides an interface that exposes the underlying terms." 173 (interactive) 174 (magit-git-bisect 175 (pcase-let ((`(,term-new ,term-old) (or (magit-bisect-terms) 176 (user-error "Not bisecting")))) 177 (pcase (read-char-choice 178 (format "Mark HEAD as %s ([n]ew) or %s ([o]ld)" 179 term-new term-old) 180 (list ?n ?o)) 181 (?n term-new) 182 (?o term-old))))) 183 184 ;;;###autoload 185 (defun magit-bisect-skip () 186 "While bisecting, skip the current commit. 187 Use this if for some reason the current commit is not a good one 188 to test. This command lets Git choose a different one." 189 (interactive) 190 (magit-git-bisect "skip")) 191 192 ;;;###autoload 193 (defun magit-bisect-run (cmdline &optional bad good args) 194 "Bisect automatically by running commands after each step. 195 196 Unlike `git bisect run' this can be used before bisecting has 197 begun. In that case it behaves like `git bisect start; git 198 bisect run'." 199 (interactive (let ((args (and (not (magit-bisect-in-progress-p)) 200 (magit-bisect-start-read-args)))) 201 (cons (read-shell-command "Bisect shell command: ") args))) 202 (when (and bad good) 203 (magit-bisect-start--assert bad good args) 204 ;; Avoid `magit-git-bisect' because it's asynchronous, but the 205 ;; next `git bisect run' call requires the bisect to be started. 206 (magit-with-toplevel 207 (magit-process-git 208 (list :file (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir))) 209 "bisect" "start" bad good args) 210 (magit-refresh))) 211 (magit--with-connection-local-variables 212 (magit-git-bisect "run" (list shell-file-name 213 shell-command-switch cmdline)))) 214 215 (defun magit-git-bisect (subcommand &optional args no-assert) 216 (unless (or no-assert (magit-bisect-in-progress-p)) 217 (user-error "Not bisecting")) 218 (message "Bisecting...") 219 (magit-with-toplevel 220 (magit-run-git-async "bisect" subcommand args)) 221 (set-process-sentinel 222 magit-this-process 223 (lambda (process event) 224 (when (memq (process-status process) '(exit signal)) 225 (if (> (process-exit-status process) 0) 226 (magit-process-sentinel process event) 227 (process-put process 'inhibit-refresh t) 228 (magit-process-sentinel process event) 229 (when (buffer-live-p (process-buffer process)) 230 (with-current-buffer (process-buffer process) 231 (when-let* ((section (magit-section-at)) 232 (output (buffer-substring-no-properties 233 (oref section content) 234 (oref section end)))) 235 (with-temp-file 236 (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir)) 237 (insert output))))) 238 (magit-refresh)) 239 (message "Bisecting...done"))))) 240 241 ;;; Sections 242 243 (defun magit-bisect-in-progress-p () 244 (file-exists-p (expand-file-name "BISECT_LOG" (magit-gitdir)))) 245 246 (defun magit-bisect-terms () 247 (magit-file-lines (expand-file-name "BISECT_TERMS" (magit-gitdir)))) 248 249 (defun magit-insert-bisect-output () 250 "While bisecting, insert section with output from `git bisect'." 251 (when (magit-bisect-in-progress-p) 252 (let* ((lines 253 (or (magit-file-lines 254 (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir))) 255 (list "Bisecting: (no saved bisect output)" 256 "It appears you have invoked `git bisect' from a shell." 257 "There is nothing wrong with that, we just cannot display" 258 "anything useful here. Consult the shell output instead."))) 259 (done-re "^\\([a-z0-9]\\{40,\\}\\) is the first bad commit$") 260 (bad-line (or (and (string-match done-re (car lines)) 261 (pop lines)) 262 (--first (string-match done-re it) lines)))) 263 (magit-insert-section ((eval (if bad-line 'commit 'bisect-output)) 264 (and bad-line (match-string 1 bad-line))) 265 (magit-insert-heading 266 (propertize (or bad-line (pop lines)) 267 'font-lock-face 'magit-section-heading)) 268 (dolist (line lines) 269 (insert line "\n")))) 270 (insert "\n"))) 271 272 (defun magit-insert-bisect-rest () 273 "While bisecting, insert section visualizing the bisect state." 274 (when (magit-bisect-in-progress-p) 275 (magit-insert-section (bisect-view) 276 (magit-insert-heading t "Bisect Rest") 277 (magit-git-wash (apply-partially #'magit-log-wash-log 'bisect-vis) 278 "bisect" "visualize" "git" "log" 279 "--format=%h%x00%D%x00%s" "--decorate=full" 280 (and magit-bisect-show-graph "--graph") 281 (and (magit-repository-local-get 'bisect--first-parent) 282 "--first-parent"))))) 283 284 (defun magit-insert-bisect-log () 285 "While bisecting, insert section logging bisect progress." 286 (when (magit-bisect-in-progress-p) 287 (magit-insert-section (bisect-log) 288 (magit-insert-heading t "Bisect Log") 289 (magit-git-wash #'magit-wash-bisect-log "bisect" "log") 290 (insert ?\n)))) 291 292 (defun magit-wash-bisect-log (_args) 293 (let (beg) 294 (while (progn (setq beg (point-marker)) 295 (re-search-forward 296 "^\\(\\(?:git bisect\\|# status:\\) [^\n]+\n\\)" nil t)) 297 (if (string-prefix-p "# status:" (match-string 1)) 298 (magit-delete-match) 299 (magit-bind-match-strings (heading) nil 300 (magit-delete-match) 301 (save-restriction 302 (narrow-to-region beg (point)) 303 (goto-char (point-min)) 304 (magit-insert-section (bisect-item heading t) 305 (insert (propertize heading 'font-lock-face 306 'magit-section-secondary-heading)) 307 (magit-insert-heading) 308 (magit-wash-sequence 309 (apply-partially #'magit-log-wash-rev 'bisect-log 310 (magit-abbrev-length))) 311 (insert ?\n)))))) 312 (when (re-search-forward 313 "# first bad commit: \\[\\([a-z0-9]\\{40,\\}\\)\\] [^\n]+\n" nil t) 314 (magit-bind-match-strings (hash) nil 315 (magit-delete-match) 316 (magit-insert-section (bisect-item) 317 (insert hash " is the first bad commit\n")))))) 318 319 ;;; _ 320 (provide 'magit-bisect) 321 ;;; magit-bisect.el ends here