>From a593ab35656c89e2850bbff283963c00a595a74b Mon Sep 17 00:00:00 2001 From: Noam Postavsky Date: Thu, 4 Jul 2019 20:32:39 -0400 Subject: [PATCH] Improved ChangeLog generation for vc log (Bug#16301) * lisp/vc/diff-mode.el (diff-find-source-location): Fix docstring. * lisp/vc/add-log.el (change-log-unindented-file-names-re) (change-log-read-entries, change-log-read-defuns) (change-log-insert-entries): * lisp/vc/diff-mode.el (diff-add-log-current-defuns): * lisp/vc/log-edit.el (log-edit--insert-filled-defuns) (log-edit-fill-entry): New functions. (log-edit-mode): Set `log-edit-fill-entry' as `fill-paragraph-function'. (log-edit-generate-changelog-from-diff): New command. (log-edit-mode-map): Bind it to C-c C-w. * doc/emacs/maintaining.texi (Types of Log File, Log Buffer): * CONTRIBUTE: Document it. * etc/NEWS: Announce it. * test/lisp/vc/log-edit-tests.el (log-edit-fill-entry) (log-edit-fill-entry-joining): New tests. --- CONTRIBUTE | 20 ++++--- doc/emacs/maintaining.texi | 13 +++- etc/NEWS | 4 ++ lisp/vc/add-log.el | 39 ++++++++++++ lisp/vc/diff-mode.el | 131 +++++++++++++++++++++++++++++++++++++++-- lisp/vc/log-edit.el | 75 +++++++++++++++++++++++ test/lisp/vc/log-edit-tests.el | 113 +++++++++++++++++++++++++++++++++++ 7 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 test/lisp/vc/log-edit-tests.el diff --git a/CONTRIBUTE b/CONTRIBUTE index f257fc57f0..f480ffec9b 100644 --- a/CONTRIBUTE +++ b/CONTRIBUTE @@ -263,18 +263,22 @@ them right the first time, so here are guidelines for formatting them: ** Generating ChangeLog entries -- You can use Emacs functions to write ChangeLog entries; see +- If you use Emacs VC, you can use 'C-c C-w' to generate formatted + blank ChangeLog entries from the diff being committed, then use + 'M-q' to combine and fill them. See 'info "(emacs) Log Buffer"'. + +- Alternatively, you can use Emacs functions for ChangeLog files; see https://www.gnu.org/software/emacs/manual/html_node/emacs/Change-Log-Commands.html or run 'info "(emacs)Change Log Commands"'. -- If you use Emacs VC, one way to format ChangeLog entries is to create - a top-level ChangeLog file manually, and update it with 'C-x 4 a' as - usual. Do not register the ChangeLog file under git; instead, use - 'C-c C-a' to insert its contents into your *vc-log* buffer. - Or if 'log-edit-hook' includes 'log-edit-insert-changelog' (which it - does by default), they will be filled in for you automatically. + To format ChangeLog entries with Emacs VC, create a top-level + ChangeLog file manually, and update it with 'C-x 4 a' as usual. Do + not register the ChangeLog file under git; instead, use 'C-c C-a' to + insert its contents into your *vc-log* buffer. Or if + 'log-edit-hook' includes 'log-edit-insert-changelog' (which it does + by default), they will be filled in for you automatically. -- Alternatively, you can use the vc-dwim command to maintain commit +- Instead of Emacs VC, you can use the vc-dwim command to maintain commit messages. When you create a source directory, run the shell command 'git-changelog-symlink-init' to create a symbolic link from ChangeLog to .git/c/ChangeLog. Edit this ChangeLog via its symlink diff --git a/doc/emacs/maintaining.texi b/doc/emacs/maintaining.texi index c3895bffb5..c6fe29ed27 100644 --- a/doc/emacs/maintaining.texi +++ b/doc/emacs/maintaining.texi @@ -396,8 +396,9 @@ Types of Log File for each change just once, then put it into both logs. You can write the entry in @file{ChangeLog}, then copy it to the log buffer with @kbd{C-c C-a} when committing the change (@pxref{Log Buffer}). Or you -can write the entry in the log buffer while committing the change, and -later use the @kbd{C-x v a} command to copy it to @file{ChangeLog} +can write the entry in the log buffer while committing the change +(with the help of @kbd{C-c C-w}), and later use the @kbd{C-x v a} +command to copy it to @file{ChangeLog} @iftex (@pxref{Change Logs and VC,,,emacs-xtra, Specialized Emacs Features}). @end iftex @@ -677,6 +678,14 @@ Log Buffer started editing (@pxref{Old Revisions}), type @kbd{C-c C-d} (@code{log-edit-show-diff}). +@kindex C-c C-w @r{(Log Edit mode)} +@findex log-edit-generate-changelog + To help generate ChangeLog entries, type @kbd{C-c C-w} +(@code{log-edit-generate-changelog}), to generate skeleton ChangeLog +entries, listing all changed file and function names based on the diff +of the VC fileset. Consecutive entries left empty will be combined by +@kbd{C-q} (@code{fill-paragraph}). + @kindex C-c C-a @r{(Log Edit mode)} @findex log-edit-insert-changelog If the VC fileset includes one or more @file{ChangeLog} files diff --git a/etc/NEWS b/etc/NEWS index 13de6bb0f8..f5e13ea88c 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -653,6 +653,10 @@ The default value is 'find-dired-sort-by-filename'. ** Change Logs and VC ++++ +*** New command 'log-edit-generate-changelog', bound to C-c C-w. +This generates ChangeLog entries from the VC fileset diff. + *** Recording ChangeLog entries doesn't require an actual file. If a ChangeLog file doesn't exist, and if the new variable 'add-log-dont-create-changelog-file' is non-nil (which is the diff --git a/lisp/vc/add-log.el b/lisp/vc/add-log.el index f9efd44c5c..47a68167fb 100644 --- a/lisp/vc/add-log.el +++ b/lisp/vc/add-log.el @@ -36,6 +36,8 @@ ;;; Code: +(eval-when-compile (require 'cl-lib)) + (defgroup change-log nil "Change log maintenance." :group 'tools @@ -309,6 +311,43 @@ change-log-search-file-name (re-search-forward change-log-file-names-re nil t) (match-string-no-properties 2)))))) +(defconst change-log-unindented-file-names-re "^[*] \\([^ ,:([\n]+\\)") + +(defun change-log-read-entries (&optional end) + "Read ChangeLog entries at point until END. +Move point to the end of entries that were read. Return a list +in the same form as `diff-add-log-current-defuns'." + (cl-loop while (and (or (not end) (< (point) end)) + (looking-at change-log-unindented-file-names-re)) + do (goto-char (match-end 0)) + collect (cons (match-string-no-properties 1) + (change-log-read-defuns end)))) + +(defvar change-log-tag-re) ; add-log.el +(defun change-log-read-defuns (&optional end) + "Read ChangeLog formatted function names at point until END. +Move point to the end of names read and return the function names +as a list of strings." + (cl-loop while (and (skip-chars-forward ":\n[:blank:]" end) + (or (not end) (< (point) end)) + (looking-at change-log-tag-re)) + do (goto-char (match-end 0)) + nconc (split-string (match-string-no-properties 1) + ",[[:blank:]]*" t) + finally do (skip-chars-backward "\n[:blank:]"))) + +(defun change-log-insert-entries (changelogs) + "Format and insert CHANGELOGS into current buffer. +CHANGELOGS is a list in the form returned by +`diff-add-log-current-defuns'." + (cl-loop for (file . defuns) in changelogs do + (insert "* " file) + (if (not defuns) + (insert ":\n") + (insert " ") + (cl-loop for def in defuns + do (insert "(" def "):\n"))))) + (defun change-log-find-file () "Visit the file for the change under point." (interactive) diff --git a/lisp/vc/diff-mode.el b/lisp/vc/diff-mode.el index 0d5dc0e1c0..81662cafed 100644 --- a/lisp/vc/diff-mode.el +++ b/lisp/vc/diff-mode.el @@ -54,6 +54,7 @@ ;;; Code: (eval-when-compile (require 'cl-lib)) +(eval-when-compile (require 'subr-x)) (autoload 'vc-find-revision "vc") (autoload 'vc-find-revision-no-save "vc") @@ -1773,15 +1774,22 @@ diff-find-approx-text (defsubst diff-xor (a b) (if a (if (not b) a) b)) (defun diff-find-source-location (&optional other-file reverse noprompt) - "Find out (BUF LINE-OFFSET POS SRC DST SWITCHED). + "Find current diff location within the source file. +OTHER-FILE, if non-nil, means to look at the diff's name and line + numbers for the old file. Furthermore, use `diff-vc-revisions' + if it's available. If `diff-jump-to-old-file' is non-nil, the + sense of this parameter is reversed. If the prefix argument is + 8 or more, `diff-jump-to-old-file' is set to OTHER-FILE. +REVERSE, if non-nil, switches the sense of SRC and DST (see below). +NOPROMPT, if non-nil, means not to prompt the user. +Return a list (BUF LINE-OFFSET (BEG . END) SRC DST SWITCHED). BUF is the buffer corresponding to the source file. LINE-OFFSET is the offset between the expected and actual positions of the text of the hunk or nil if the text was not found. -POS is a pair (BEG . END) indicating the position of the text in the buffer. +\(BEG . END) is a pair indicating the position of the text in the buffer. SRC and DST are the two variants of text as returned by `diff-hunk-text'. SRC is the variant that was found in the buffer. -SWITCHED is non-nil if the patch is already applied. -NOPROMPT, if non-nil, means not to prompt the user." +SWITCHED is non-nil if the patch is already applied." (save-excursion (let* ((other (diff-xor other-file diff-jump-to-old-file)) (char-offset (- (point) (diff-beginning-of-hunk t))) @@ -2210,6 +2218,121 @@ diff-undo (let ((inhibit-read-only t)) (undo arg))) +(defun diff-add-log-current-defuns () + "Return an alist of defun names for the current diff. +The elements of the alist are of the form (FILE . (DEFUN...)), +where DEFUN... is a list of function names found in FILE." + (save-excursion + (goto-char (point-min)) + (let ((defuns nil) + (hunk-end nil) + (hunk-mismatch-files nil) + (make-defun-context-follower + (lambda (goline) + (let ((eodefun nil) + (defname nil)) + (list + (lambda () ;; Check for end of current defun. + (when (and eodefun + (funcall goline) + (>= (point) eodefun)) + (setq defname nil) + (setq eodefun nil))) + (lambda (&optional get-current) ;; Check for new defun. + (if get-current + defname + (when-let* ((def (and (not eodefun) + (funcall goline) + (add-log-current-defun))) + (eof (save-excursion (end-of-defun) (point)))) + (setq eodefun eof) + (setq defname def))))))))) + (while + ;; Might need to skip over file headers between diff + ;; hunks (e.g., "diff --git ..." etc). + (re-search-forward diff-hunk-header-re nil t) + (setq hunk-end (save-excursion (diff-end-of-hunk))) + (pcase-let* ((filename (substring-no-properties (diff-find-file-name))) + (=lines 0) + (+lines 0) + (-lines 0) + (`(,buf ,line-offset (,beg . ,_end) + (,old-text . ,_old-offset) + (,new-text . ,_new-offset) + ,applied) + ;; Try to use the vc integration of + ;; `diff-find-source-location', unless it + ;; would look for non-existent files like + ;; /dev/null. + (diff-find-source-location + (not (equal "/dev/null" + (car (diff-hunk-file-names t)))))) + (other-buf nil) + (goto-otherbuf + ;; If APPLIED, we have NEW-TEXT in BUF, so we + ;; need to a buffer with OLD-TEXT to follow + ;; -lines. + (lambda () + (if other-buf (set-buffer other-buf) + (set-buffer (generate-new-buffer " *diff-other-text*")) + (insert (if applied old-text new-text)) + (funcall (buffer-local-value 'major-mode buf)) + (setq other-buf (current-buffer))) + (goto-char (point-min)) + (forward-line (+ =lines -1 + (if applied -lines +lines))))) + (gotobuf (lambda () + (set-buffer buf) + (goto-char beg) + (forward-line (+ =lines -1 + (if applied +lines -lines))))) + (`(,=ck-eodefun ,=ck-defun) + (funcall make-defun-context-follower gotobuf)) + (`(,-ck-eodefun ,-ck-defun) + (funcall make-defun-context-follower + (if applied goto-otherbuf gotobuf))) + (`(,+ck-eodefun ,+ck-defun) + (funcall make-defun-context-follower + (if applied gotobuf goto-otherbuf)))) + (unless (eql line-offset 0) + (cl-pushnew filename hunk-mismatch-files :test #'equal)) + ;; Some modes always return nil for `add-log-current-defun', + ;; make sure at least the filename is included. + (unless (assoc filename defuns) + (push (cons filename nil) defuns)) + (unwind-protect + (while (progn (forward-line) + (< (point) hunk-end)) + (let ((patch-char (char-after))) + (pcase patch-char + (?+ (cl-incf +lines)) + (?- (cl-incf -lines)) + (?\s (cl-incf =lines))) + (save-current-buffer + (funcall =ck-eodefun) + (funcall +ck-eodefun) + (funcall -ck-eodefun) + (when-let* ((def (cond + ((eq patch-char ?\s) + ;; Just updating context defun. + (ignore (funcall =ck-defun))) + ;; + or - in existing defun. + ((funcall =ck-defun t)) + ;; Check added or removed defun. + (t (funcall (if (eq ?+ patch-char) + +ck-defun -ck-defun)))))) + (cl-pushnew def (alist-get filename defuns + nil nil #'equal) + :test #'equal))))) + (when (buffer-live-p other-buf) + (kill-buffer other-buf))))) + (when hunk-mismatch-files + (message "Diff didn't match for %s." + (mapconcat #'identity hunk-mismatch-files ", "))) + (dolist (file-defuns defuns) + (cl-callf nreverse (cdr file-defuns))) + (nreverse defuns)))) + (defun diff-add-change-log-entries-other-window () "Iterate through the current diff and create ChangeLog entries. I.e. like `add-change-log-entry-other-window' but applied to all hunks." diff --git a/lisp/vc/log-edit.el b/lisp/vc/log-edit.el index 91e18c1ec5..8d47d66ac3 100644 --- a/lisp/vc/log-edit.el +++ b/lisp/vc/log-edit.el @@ -54,6 +54,7 @@ cvs-buffer (easy-mmode-defmap log-edit-mode-map '(("\C-c\C-c" . log-edit-done) ("\C-c\C-a" . log-edit-insert-changelog) + ("\C-c\C-w" . log-edit-generate-changelog-from-diff) ("\C-c\C-d" . log-edit-show-diff) ("\C-c\C-f" . log-edit-show-files) ("\C-c\C-k" . log-edit-kill-buffer) @@ -488,10 +489,63 @@ log-edit-mode (set (make-local-variable 'font-lock-defaults) '(log-edit-font-lock-keywords t)) (setq-local jit-lock-contextually t) ;For the "first line is summary". + (setq-local fill-paragraph-function #'log-edit-fill-entry) (make-local-variable 'log-edit-comment-ring-index) (add-hook 'kill-buffer-hook 'log-edit-remember-comment nil t) (hack-dir-local-variables-non-file-buffer)) +(defun log-edit--insert-filled-defuns (func-names) + "Insert FUNC-NAMES, following ChangeLog formatting." + (if (not func-names) + (insert ":") + (unless (or (memq (char-before) '(?\n ?\s)) + (> (current-column) fill-column)) + (insert " ")) + (cl-loop for first-fun = t then nil + for def in func-names do + (when (> (+ (current-column) (string-width def)) fill-column) + (unless first-fun + (insert ")")) + (insert "\n")) + (insert (if (memq (char-before) '(?\n ?\s)) + "(" ", ") + def)) + (insert "):"))) + +(defun log-edit-fill-entry (&optional justify) + "Like \\[fill-paragraph], but handle ChangeLog entries. +Consecutive function entries without prose (i.e., lines of the +form \"(FUNCTION):\") will be combined into \"(FUNC1, FUNC2):\" +according to `fill-column'." + (save-excursion + (pcase-let ((`(,beg ,end) (log-edit-changelog-paragraph))) + (if (= beg end) + ;; Not a ChangeLog entry, fill as normal. + nil + (cl-callf copy-marker end) + (goto-char beg) + (cl-loop + for defuns-beg = + (and (< beg end) + (re-search-forward + (concat "\\(?1:" change-log-unindented-file-names-re + "\\)\\|^\\(?1:\\)(") + end t) + (copy-marker (match-end 1))) + ;; Fill prose between log entries. + do (let ((fill-indent-according-to-mode t) + (end (if defuns-beg (match-beginning 0) end)) + (beg (progn (goto-char beg) (line-beginning-position)))) + (when (<= (line-end-position) end) + (fill-region beg end justify))) + while defuns-beg + for defuns = (progn (goto-char defuns-beg) + (change-log-read-defuns end)) + do (progn (delete-region defuns-beg (point)) + (log-edit--insert-filled-defuns defuns) + (setq beg (point)))) + t)))) + (defun log-edit-hide-buf (&optional buf where) (when (setq buf (get-buffer (or buf log-edit-files-buf))) ;; FIXME: Should use something like `quit-windows-on' here, but @@ -726,6 +780,27 @@ log-edit-add-field (replace-match (concat " " value) t t nil 1) (insert field ": " value "\n" (if (looking-at "\n") "" "\n")))) +(declare-function diff-add-log-current-defuns "diff-mode" ()) + +(defun log-edit-generate-changelog-from-diff () + "Insert a log message by looking at the current diff. +This command will generate a ChangeLog entries listing the +functions. You can then add a description where needed, and use +\\[fill-paragraph] to join consecutive function names." + (interactive) + (let* ((diff-buf nil) + ;; Unfortunately, `log-edit-show-diff' doesn't have a NO-SHOW + ;; option, so we try to work around it via display-buffer + ;; machinery. + (display-buffer-overriding-action + `(,(lambda (buf alist) + (setq diff-buf buf) + (display-buffer-no-window buf alist)) + . ((allow-no-window . t))))) + (change-log-insert-entries + (with-current-buffer (progn (log-edit-show-diff) diff-buf) + (diff-add-log-current-defuns))))) + (defun log-edit-insert-changelog (&optional use-first) "Insert a log message by looking at the ChangeLog. The idea is to write your ChangeLog entries first, and then use this diff --git a/test/lisp/vc/log-edit-tests.el b/test/lisp/vc/log-edit-tests.el new file mode 100644 index 0000000000..7d77eca87d --- /dev/null +++ b/test/lisp/vc/log-edit-tests.el @@ -0,0 +1,113 @@ +;;; log-edit-tests.el --- Unit tests for log-edit.el -*- lexical-binding: t; -*- + +;; Copyright (C) 2019 Free Software Foundation, Inc. + +;; This file is part of GNU Emacs. + +;; GNU Emacs 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. + +;; GNU Emacs 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 GNU Emacs. If not, see . + +;;; Commentary: + +;; Unit tests for lisp/vc/log-edit.el. + +;;; Code: + +(require 'log-edit) +(require 'ert) + +(ert-deftest log-edit-fill-entry () + (with-temp-buffer + (insert "\ +* dir/file.ext (fun1): +\(fun2): +\(fun3): +* file2.txt (fun4): +\(fun5): +\(fun6): +\(fun7): Some prose. +\(fun8): A longer description of a complicated change.\ + Spread over a couple of sentencences.\ + Long enough to be filled for several lines. +\(fun9): Etc.") + (goto-char (point-min)) + (let ((fill-column 72)) (log-edit-fill-entry)) + (should (equal (buffer-string) "\ +* dir/file.ext (fun1, fun2, fun3): +* file2.txt (fun4, fun5, fun6, fun7): Some prose. +\(fun8): A longer description of a complicated change. Spread over a +couple of sentencences. Long enough to be filled for several lines. +\(fun9): Etc.")) + (let ((fill-column 20)) (log-edit-fill-entry)) + (should (equal (buffer-string) "\ +* dir/file.ext (fun1) +\(fun2, fun3): +* file2.txt (fun4) +\(fun5, fun6, fun7): +Some prose. +\(fun8): A longer +description of a +complicated change. +Spread over a couple +of sentencences. +Long enough to be +filled for several +lines. +\(fun9): Etc.")) + (let ((fill-column 40)) (log-edit-fill-entry)) + (should (equal (buffer-string) "\ +* dir/file.ext (fun1, fun2, fun3): +* file2.txt (fun4, fun5, fun6, fun7): +Some prose. +\(fun8): A longer description of a +complicated change. Spread over a +couple of sentencences. Long enough to +be filled for several lines. +\(fun9): Etc.")))) + +(ert-deftest log-edit-fill-entry-trailing-prose () + (with-temp-buffer + (insert "\ +* dir/file.ext (fun1): A longer description of a complicated change.\ + Spread over a couple of sentencences.\ + Long enough to be filled for several lines.") + (let ((fill-column 72)) (log-edit-fill-entry)) + (should (equal (buffer-string) "\ +* dir/file.ext (fun1): A longer description of a complicated change. +Spread over a couple of sentencences. Long enough to be filled for +several lines.")))) + +(ert-deftest log-edit-fill-entry-joining () + ;; Join short enough function names on the same line. + (with-temp-buffer + (insert "* dir/file.ext (fun1):\n(fun2):") + (let ((fill-column 72)) (log-edit-fill-entry)) + (should (equal (buffer-string) "* dir/file.ext (fun1, fun2):"))) + ;; Don't combine them if they're too long. + (with-temp-buffer + (insert "* dir/long-file-name.ext (a-really-long-function-name): +\(another-very-long-function-name):") + (let ((fill-column 72)) (log-edit-fill-entry)) + (should (equal (buffer-string) "* dir/long-file-name.ext (a-really-long-function-name) +\(another-very-long-function-name):"))) + ;; Put function name on next line, if the file name is too long. + (with-temp-buffer + (insert "\ +* a-very-long-directory-name/another-long-directory-name/and-a-long-file-name.ext\ + (a-really-long-function-name):") + (let ((fill-column 72)) (log-edit-fill-entry)) + (should (equal (buffer-string) "\ +* a-very-long-directory-name/another-long-directory-name/and-a-long-file-name.ext +\(a-really-long-function-name):")))) + +;;; log-edit-tests.el ends here -- 2.11.0