;; -*- 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 "" #'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