summaryrefslogtreecommitdiff
path: root/emacs/scripts/jj-review.el
blob: fc9567e3304a12cd73a7df36e6f42c7f545ce4c7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
;; -*- lexical-binding: t; -*-

;;; jj-review.el --- PR-style review of Jujutsu changes  -*- lexical-binding: t; -*-

;; Author: tslil clingman
;; Package-Requires: ((emacs "29.1"))

(require 'diff-mode)
(require 'outline)
(require 'ansi-color)

(defgroup jj-review nil
  "PR-style review of jj changes."
  :group 'vc)

(defcustom jj-review-executable "jj"
  "Name or path of the jj executable."
  :type 'string)

(defcustom jj-review-open-collapsed t
  "If non-nil, open review buffers with all file sections folded."
  :type 'boolean)

(defvar-local jj-review--root nil
  "Repository root of this review buffer.")

(defvar-local jj-review--revset nil
  "Revset this buffer is reviewing.")

(defvar jj-review--revset-history nil)

;;;; jj plumbing

(defun jj-review--find-root ()
  "Return the jj repository root containing `default-directory'."
  (or (locate-dominating-file default-directory ".jj")
      (user-error "Not inside a jj repository")))

(defun jj-review--run (root &rest args)
  "Run jj with ARGS in ROOT and return stdout as a string.
stderr is kept separate (jj prints hints and warnings there) and
included in the `user-error' signalled on nonzero exit."
  (let ((err-file (make-temp-file "jj-review-stderr")))
    (unwind-protect
        (with-temp-buffer
          (let* ((default-directory root)
                 (exit (apply #'call-process jj-review-executable nil
                              (list t err-file) nil
                              "--color=never" args)))
            (unless (eql exit 0)
              (user-error "jj %s: %s"
                          (string-join args " ")
                          (string-trim
                           (with-temp-buffer
                             (insert-file-contents err-file)
                             (buffer-string)))))
            (buffer-string)))
      (delete-file err-file))))

;;;; Locating the file at point

(defun jj-review--paths ()
  "Return (BEFORE-PATH . AFTER-PATH) for the file section at point.
Paths are repository-relative, taken from the nearest preceding
\"diff --git a/... b/...\" header."
  (save-excursion
    (end-of-line)
    (if (re-search-backward "^diff --git a/\\(.*\\) b/\\(.*\\)$" nil t)
        (cons (match-string-no-properties 1)
              (match-string-no-properties 2))
      (user-error "No file at or before point"))))

;;;; jj's own diff formatter (difftastic etc.), ANSI-rendered

(defun jj-review--formatted-diff-buffer (root revset &optional path)
  "Return a buffer with `jj diff -r REVSET' for ROOT, colors rendered.
Restrict to PATH if non-nil.  Uses whatever diff formatter jj is
configured with; DFT_COLOR/DFT_WIDTH are set so difftastic emits
color at the current window's width despite writing to a pipe."
  (let ((buf (get-buffer-create
              (format "*jj diff: %s*" (or path revset))))
        (process-environment
         (append (list "DFT_COLOR=always"
                       (format "DFT_WIDTH=%d" (window-width)))
                 process-environment)))
    (with-current-buffer buf
      (let ((inhibit-read-only t)
            (default-directory root))
        (erase-buffer)
        (let ((exit (apply #'call-process jj-review-executable nil t nil
                           "--color=always" "diff" "-r" revset
                           (and path (list "--" path)))))
          (unless (eql exit 0)
            (user-error "jj diff: %s" (string-trim (buffer-string)))))
        (ansi-color-apply-on-region (point-min) (point-max)))
      (setq default-directory root)
      (goto-char (point-min))
      (special-mode))
    buf))

;;;; Commands

(defun jj-review-diff-file ()
  "Show `jj diff' for the file at point in jj's configured formatter."
  (interactive)
  (pop-to-buffer
   (jj-review--formatted-diff-buffer jj-review--root jj-review--revset
                                     (cdr (jj-review--paths)))))

(defun jj-review-diff-revision ()
  "Show `jj diff' for the whole revision in jj's configured formatter."
  (interactive)
  (pop-to-buffer
   (jj-review--formatted-diff-buffer jj-review--root jj-review--revset)))

(defun jj-review-collapse-all ()
  "Fold every file section, leaving the file list."
  (interactive)
  (outline-hide-sublevels 1))

(defun jj-review-expand-all ()
  "Unfold everything."
  (interactive)
  (outline-show-all))

;;;; Staying up to date (auto-revert wiring)

(defvar-local jj-review--commit-ids nil
  "Commit ids the revset denoted when this buffer was last filled.")

(defun jj-review--query-commit-ids (root revset)
  "Return the commit ids REVSET currently denotes in ROOT, as one string.
Running jj snapshots the working copy, so on-disk edits are
reflected in the result."
  (string-trim
   (jj-review--run root "log" "--no-graph" "-r" revset "-T" "commit_id")))

(defun jj-review--stale-p (&optional _noconfirm)
  "Non-nil if the revset's commit ids changed since the last fill.
Errors (e.g. the revset no longer resolving) count as not stale,
so a broken state doesn't spam; refresh manually with \\[revert-buffer]."
  (and jj-review--root jj-review--revset
       (condition-case nil
           (not (equal (jj-review--query-commit-ids jj-review--root
                                                    jj-review--revset)
                       jj-review--commit-ids))
         (error nil))))

(defun jj-review--expanded-files ()
  "Return after-paths of file sections whose bodies are visible."
  (let (res)
    (save-excursion
      (goto-char (point-min))
      (while (re-search-forward "^diff --git a/.* b/\\(.*\\)$" nil t)
        (unless (invisible-p (min (line-end-position) (point-max)))
          (push (match-string-no-properties 1) res))))
    (nreverse res)))

(defun jj-review--expand-files (paths)
  "Reveal the file sections whose after-path is in PATHS."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward "^diff --git a/.* b/\\(.*\\)$" nil t)
      (when (member (match-string-no-properties 1) paths)
        (beginning-of-line)
        (outline-show-subtree)
        (end-of-line)))))

(defun jj-review--revert (&optional _ignore-auto _noconfirm)
  "Refill the review buffer, preserving point and per-file fold state.
Installed as `revert-buffer-function', so both \\[revert-buffer]
and `auto-revert-mode' use it."
  (let ((expanded (jj-review--expanded-files))
        (pos (point)))
    (jj-review--fill (current-buffer) jj-review--root jj-review--revset)
    (jj-review--expand-files expanded)
    (goto-char (min pos (point-max)))))

;;;; Mode and buffer construction

(defvar-keymap jj-review-mode-map
  :doc "Keymap for `jj-review-mode'."
  "n" #'diff-hunk-next
  "p" #'diff-hunk-prev
  "N" #'diff-file-next
  "P" #'diff-file-prev
  "RET" #'diff-goto-source
  "TAB" #'outline-toggle-children
  "<backtab>" #'outline-cycle-buffer
  "d" #'jj-review-diff-file
  "D" #'jj-review-diff-revision
  "c" #'jj-review-collapse-all
  "x" #'jj-review-expand-all
  "g" #'revert-buffer
  "q" #'quit-window)

(define-derived-mode jj-review-mode diff-mode "jj-review"
  "PR-style review of a jj revision.
An ordinary read-only `diff-mode' buffer with per-file folding
and access to the before/after version of each file.

\\{jj-review-mode-map}"
  (setq buffer-read-only t)
  (setq-local revert-buffer-function #'jj-review--revert
              buffer-stale-function #'jj-review--stale-p)
  (outline-minor-mode 1))

(defun jj-review--fill (buf root revset)
  "Fill BUF with the review of REVSET in ROOT."
  (let ((ids (jj-review--query-commit-ids root revset))
        (stat (jj-review--run root "diff" "--stat" "-r" revset))
        (diff (jj-review--run root "diff" "--git" "-r" revset)))
    (when (string-empty-p (string-trim diff))
      (user-error "Revset %s introduces no changes" revset))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (erase-buffer)
        (insert "Reviewing: " revset "\n\n" stat "\n" diff))
      (setq default-directory root)
      (unless (derived-mode-p 'jj-review-mode)
        (jj-review-mode))
      (setq jj-review--root root
            jj-review--revset revset
            jj-review--commit-ids ids)
      (when jj-review-open-collapsed
        (jj-review-collapse-all))
      (goto-char (point-min)))
    buf))

(defun jj-review-buffer (revset &optional dir)
  "Return a review buffer for REVSET in the repo containing DIR."
  (let* ((default-directory (or dir default-directory))
         (root (jj-review--find-root))
         (buf (get-buffer-create (format "*jj-review: %s*" revset))))
    (jj-review--fill buf root revset)))

;;;###autoload
(defun jj-review (revset)
  "Review the changes introduced by REVSET (default @), PR-style."
  (interactive
   (list (read-string "Revset (default @): " nil 'jj-review--revset-history "@")))
  (pop-to-buffer (jj-review-buffer revset)))

(provide 'jj-review)
;;; jj-review.el ends here