;;; magit-bisect.el --- Bisect support for Magit  -*- lexical-binding:t -*-

;; Copyright (C) 2008-2024 The Magit Project Contributors

;; Author: Jonas Bernoulli <jonas@bernoul.li>
;; Maintainer: Jonas Bernoulli <jonas@bernoul.li>

;; SPDX-License-Identifier: GPL-3.0-or-later

;; Magit is free software: you can redistribute it and/or modify it
;; under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;
;; Magit is distributed in the hope that it will be useful, but WITHOUT
;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
;; or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
;; License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with Magit.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; Use a binary search to find the commit that introduced a bug.

;;; Code:

(require 'magit)

;;; Options

(defcustom magit-bisect-show-graph t
  "Whether to use `--graph' in the log showing commits yet to be bisected."
  :package-version '(magit . "2.8.0")
  :group 'magit-status
  :type 'boolean)

(defface magit-bisect-good
  '((t :foreground "DarkOliveGreen"))
  "Face for good bisect revisions."
  :group 'magit-faces)

(defface magit-bisect-skip
  '((t :foreground "DarkGoldenrod"))
  "Face for skipped bisect revisions."
  :group 'magit-faces)

(defface magit-bisect-bad
  '((t :foreground "IndianRed4"))
  "Face for bad bisect revisions."
  :group 'magit-faces)

;;; Commands

;;;###autoload (autoload 'magit-bisect "magit-bisect" nil t)
(transient-define-prefix magit-bisect ()
  "Narrow in on the commit that introduced a bug."
  :man-page "git-bisect"
  [:class transient-subgroups
   :if-not magit-bisect-in-progress-p
   ["Arguments"
    ("-n" "Don't checkout commits"              "--no-checkout")
    ("-p" "Follow only first parent of a merge" "--first-parent"
     :if (lambda () (magit-git-version>= "2.29")))
    (6 magit-bisect:--term-old
       :if (lambda () (magit-git-version>= "2.7")))
    (6 magit-bisect:--term-new
       :if (lambda () (magit-git-version>= "2.7")))]
   ["Actions"
    ("B" "Start"        magit-bisect-start)
    ("s" "Start script" magit-bisect-run)]]
  ["Actions"
   :if magit-bisect-in-progress-p
   ("B" "Bad"          magit-bisect-bad)
   ("g" "Good"         magit-bisect-good)
   (6 "m" "Mark"       magit-bisect-mark
      :if (lambda () (magit-git-version>= "2.7")))
   ("k" "Skip"         magit-bisect-skip)
   ("r" "Reset"        magit-bisect-reset)
   ("s" "Run script"   magit-bisect-run)])

(transient-define-argument magit-bisect:--term-old ()
  :description "Old/good term"
  :class 'transient-option
  :key "=o"
  :argument "--term-old=")

(transient-define-argument magit-bisect:--term-new ()
  :description "New/bad term"
  :class 'transient-option
  :key "=n"
  :argument "--term-new=")

;;;###autoload
(defun magit-bisect-start (bad good args)
  "Start a bisect session.

Bisecting a bug means to find the commit that introduced it.
This command starts such a bisect session by asking for a known
good and a known bad commit.  To move the session forward use the
other actions from the bisect transient command (\
\\<magit-status-mode-map>\\[magit-bisect])."
  (interactive (if (magit-bisect-in-progress-p)
                   (user-error "Already bisecting")
                 (magit-bisect-start-read-args)))
  (magit-bisect-start--assert bad good args)
  (magit-repository-local-set 'bisect--first-parent
                              (transient-arg-value "--first-parent" args))
  (magit-git-bisect "start" (list args bad good) t))

(defun magit-bisect-start-read-args ()
  (let* ((args (transient-args 'magit-bisect))
         (bad (magit-read-branch-or-commit
               (format "Start bisect with %s revision"
                       (or (transient-arg-value "--term-new=" args)
                           "bad")))))
    (list bad
          (magit-read-other-branch-or-commit
           (format "%s revision" (or (transient-arg-value "--term-old=" args)
                                     "Good"))
           bad)
          args)))

(defun magit-bisect-start--assert (bad good args)
  (unless (magit-rev-ancestor-p good bad)
    (user-error
     "The %s revision (%s) has to be an ancestor of the %s one (%s)"
     (or (transient-arg-value "--term-old=" args) "good")
     good
     (or (transient-arg-value "--term-new=" args) "bad")
     bad))
  (when (magit-anything-modified-p)
    (user-error "Cannot bisect with uncommitted changes")))

;;;###autoload
(defun magit-bisect-reset ()
  "After bisecting, cleanup bisection state and return to original `HEAD'."
  (interactive)
  (magit-confirm 'reset-bisect)
  (magit-run-git "bisect" "reset")
  (magit-repository-local-delete 'bisect--first-parent)
  (ignore-errors
    (delete-file (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir)))))

;;;###autoload
(defun magit-bisect-good ()
  "While bisecting, mark the current commit as good.
Use this after you have asserted that the commit does not contain
the bug in question."
  (interactive)
  (magit-git-bisect (or (cadr (magit-bisect-terms))
                        (user-error "Not bisecting"))))

;;;###autoload
(defun magit-bisect-bad ()
  "While bisecting, mark the current commit as bad.
Use this after you have asserted that the commit does contain the
bug in question."
  (interactive)
  (magit-git-bisect (or (car (magit-bisect-terms))
                        (user-error "Not bisecting"))))

;;;###autoload
(defun magit-bisect-mark ()
  "While bisecting, mark the current commit with a bisect term.
During a bisect using alternate terms, commits can still be
marked with `magit-bisect-good' and `magit-bisect-bad', as those
commands map to the correct term (\"good\" to --term-old's value
and \"bad\" to --term-new's).  However, in some cases, it can be
difficult to keep that mapping straight in your head; this
command provides an interface that exposes the underlying terms."
  (interactive)
  (magit-git-bisect
   (pcase-let ((`(,term-new ,term-old) (or (magit-bisect-terms)
                                           (user-error "Not bisecting"))))
     (pcase (read-char-choice
             (format "Mark HEAD as %s ([n]ew) or %s ([o]ld)"
                     term-new term-old)
             (list ?n ?o))
       (?n term-new)
       (?o term-old)))))

;;;###autoload
(defun magit-bisect-skip ()
  "While bisecting, skip the current commit.
Use this if for some reason the current commit is not a good one
to test.  This command lets Git choose a different one."
  (interactive)
  (magit-git-bisect "skip"))

;;;###autoload
(defun magit-bisect-run (cmdline &optional bad good args)
  "Bisect automatically by running commands after each step.

Unlike `git bisect run' this can be used before bisecting has
begun.  In that case it behaves like `git bisect start; git
bisect run'."
  (interactive (let ((args (and (not (magit-bisect-in-progress-p))
                                (magit-bisect-start-read-args))))
                 (cons (read-shell-command "Bisect shell command: ") args)))
  (when (and bad good)
    (magit-bisect-start--assert bad good args)
    ;; Avoid `magit-git-bisect' because it's asynchronous, but the
    ;; next `git bisect run' call requires the bisect to be started.
    (magit-with-toplevel
      (magit-process-git
       (list :file (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir)))
       (magit-process-git-arguments
        (list "bisect" "start" bad good args)))
      (magit-refresh)))
  (magit--with-connection-local-variables
    (magit-git-bisect "run" (list shell-file-name
                                  shell-command-switch cmdline))))

(defun magit-git-bisect (subcommand &optional args no-assert)
  (unless (or no-assert (magit-bisect-in-progress-p))
    (user-error "Not bisecting"))
  (message "Bisecting...")
  (magit-with-toplevel
    (magit-run-git-async "bisect" subcommand args))
  (set-process-sentinel
   magit-this-process
   (lambda (process event)
     (when (memq (process-status process) '(exit signal))
       (if (> (process-exit-status process) 0)
           (magit-process-sentinel process event)
         (process-put process 'inhibit-refresh t)
         (magit-process-sentinel process event)
         (when (buffer-live-p (process-buffer process))
           (with-current-buffer (process-buffer process)
             (when-let* ((section (magit-section-at))
                         (output (buffer-substring-no-properties
                                  (oref section content)
                                  (oref section end))))
               (with-temp-file
                   (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir))
                 (insert output)))))
         (magit-refresh))
       (message "Bisecting...done")))))

;;; Sections

(defun magit-bisect-in-progress-p ()
  (file-exists-p (expand-file-name "BISECT_LOG" (magit-gitdir))))

(defun magit-bisect-terms ()
  (magit-file-lines (expand-file-name "BISECT_TERMS" (magit-gitdir))))

(defun magit-insert-bisect-output ()
  "While bisecting, insert section with output from `git bisect'."
  (when (magit-bisect-in-progress-p)
    (let* ((lines
            (or (magit-file-lines
                 (expand-file-name "BISECT_CMD_OUTPUT" (magit-gitdir)))
                (list "Bisecting: (no saved bisect output)"
                      "It appears you have invoked `git bisect' from a shell."
                      "There is nothing wrong with that, we just cannot display"
                      "anything useful here.  Consult the shell output instead.")))
           (done-re "^\\([a-z0-9]\\{40,\\}\\) is the first bad commit$")
           (bad-line (or (and (string-match done-re (car lines))
                              (pop lines))
                         (--first (string-match done-re it) lines))))
      (magit-insert-section ((eval (if bad-line 'commit 'bisect-output))
                             (and bad-line (match-string 1 bad-line)))
        (magit-insert-heading
          (propertize (or bad-line (pop lines))
                      'font-lock-face 'magit-section-heading))
        (dolist (line lines)
          (insert line "\n"))))
    (insert "\n")))

(defun magit-insert-bisect-rest ()
  "While bisecting, insert section visualizing the bisect state."
  (when (magit-bisect-in-progress-p)
    (magit-insert-section (bisect-view)
      (magit-insert-heading "Bisect Rest:")
      (magit-git-wash (apply-partially #'magit-log-wash-log 'bisect-vis)
        "bisect" "visualize" "git" "log"
        "--format=%h%x00%D%x00%s" "--decorate=full"
        (and magit-bisect-show-graph "--graph")
        (and (magit-repository-local-get 'bisect--first-parent)
             "--first-parent")))))

(defun magit-insert-bisect-log ()
  "While bisecting, insert section logging bisect progress."
  (when (magit-bisect-in-progress-p)
    (magit-insert-section (bisect-log)
      (magit-insert-heading "Bisect Log:")
      (magit-git-wash #'magit-wash-bisect-log "bisect" "log")
      (insert ?\n))))

(defun magit-wash-bisect-log (_args)
  (let (beg)
    (while (progn (setq beg (point-marker))
                  (re-search-forward
                   "^\\(\\(?:git bisect\\|# status:\\) [^\n]+\n\\)" nil t))
      (if (string-prefix-p "# status:" (match-string 1))
          (magit-delete-match)
        (magit-bind-match-strings (heading) nil
          (magit-delete-match)
          (save-restriction
            (narrow-to-region beg (point))
            (goto-char (point-min))
            (magit-insert-section (bisect-item heading t)
              (insert (propertize heading 'font-lock-face
                                  'magit-section-secondary-heading))
              (magit-insert-heading)
              (magit-wash-sequence
               (apply-partially #'magit-log-wash-rev 'bisect-log
                                (magit-abbrev-length)))
              (insert ?\n))))))
    (when (re-search-forward
           "# first bad commit: \\[\\([a-z0-9]\\{40,\\}\\)\\] [^\n]+\n" nil t)
      (magit-bind-match-strings (hash) nil
        (magit-delete-match)
        (magit-insert-section (bisect-item)
          (insert hash " is the first bad commit\n"))))))

;;; _
(provide 'magit-bisect)
;;; magit-bisect.el ends here
