Code

git-blame: an Emacs minor mode to view file with git-blame output.
[git.git] / contrib / emacs / git.el
index 2363e48c2998be39c72eebbb3e481d764b27ffd1..24629eb3e211505728c77db820618021203241ae 100644 (file)
 ;;  - hook into file save (after-save-hook)
 ;;  - diff against other branch
 ;;  - renaming files from the status buffer
-;;  - support for appending signed-off-by
 ;;  - creating tags
 ;;  - fetch/pull
 ;;  - switching branches
 ;;  - revlist browser
 ;;  - git-show-branch browser
-;;  - customize support
 ;;  - menus
 ;;
 
 (eval-when-compile (require 'cl))
 (require 'ewoc)
+(require 'log-edit)
 
 
-;;;; Faces
+;;;; Customizations
 ;;;; ------------------------------------------------------------
 
+(defgroup git nil
+  "A user interface for the git versioning system."
+  :group 'tools)
+
+(defcustom git-committer-name nil
+  "User name to use for commits.
+The default is to fall back to the repository config,
+then to `add-log-full-name' and then to `user-full-name'."
+  :group 'git
+  :type '(choice (const :tag "Default" nil)
+                 (string :tag "Name")))
+
+(defcustom git-committer-email nil
+  "Email address to use for commits.
+The default is to fall back to the git repository config,
+then to `add-log-mailing-address' and then to `user-mail-address'."
+  :group 'git
+  :type '(choice (const :tag "Default" nil)
+                 (string :tag "Email")))
+
+(defcustom git-commits-coding-system 'utf-8
+  "Default coding system for the log message of git commits."
+  :group 'git
+  :type 'coding-system)
+
+(defcustom git-append-signed-off-by nil
+  "Whether to append a Signed-off-by line to the commit message before editing."
+  :group 'git
+  :type 'boolean)
+
+(defcustom git-reuse-status-buffer t
+  "Whether `git-status' should try to reuse an existing buffer
+if there is already one that displays the same directory."
+  :group 'git
+  :type 'boolean)
+
+(defcustom git-per-dir-ignore-file ".gitignore"
+  "Name of the per-directory ignore file."
+  :group 'git
+  :type 'string)
+
+
 (defface git-status-face
   '((((class color) (background light)) (:foreground "purple")))
-  "Git mode face used to highlight added and modified files.")
+  "Git mode face used to highlight added and modified files."
+  :group 'git)
 
 (defface git-unmerged-face
   '((((class color) (background light)) (:foreground "red" :bold t)))
-  "Git mode face used to highlight unmerged files.")
+  "Git mode face used to highlight unmerged files."
+  :group 'git)
 
 (defface git-unknown-face
   '((((class color) (background light)) (:foreground "goldenrod" :bold t)))
-  "Git mode face used to highlight unknown files.")
+  "Git mode face used to highlight unknown files."
+  :group 'git)
 
 (defface git-uptodate-face
   '((((class color) (background light)) (:foreground "grey60")))
-  "Git mode face used to highlight up-to-date files.")
+  "Git mode face used to highlight up-to-date files."
+  :group 'git)
 
 (defface git-ignored-face
   '((((class color) (background light)) (:foreground "grey60")))
-  "Git mode face used to highlight ignored files.")
+  "Git mode face used to highlight ignored files."
+  :group 'git)
 
 (defface git-mark-face
   '((((class color) (background light)) (:foreground "red" :bold t)))
-  "Git mode face used for the file marks.")
+  "Git mode face used for the file marks."
+  :group 'git)
 
 (defface git-header-face
   '((((class color) (background light)) (:foreground "blue")))
-  "Git mode face used for commit headers.")
+  "Git mode face used for commit headers."
+  :group 'git)
 
 (defface git-separator-face
   '((((class color) (background light)) (:foreground "brown")))
-  "Git mode face used for commit separator.")
+  "Git mode face used for commit separator."
+  :group 'git)
 
 (defface git-permission-face
   '((((class color) (background light)) (:foreground "green" :bold t)))
-  "Git mode face used for permission changes.")
+  "Git mode face used for permission changes."
+  :group 'git)
 
-(defvar git-committer-name nil
-  "*User name to use for commits.
-If not set, fall back to `add-log-full-name' and then `user-full-name'.")
 
-(defvar git-committer-email nil
-  "*Email address to use for commits.
-If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.")
-
-(defvar git-commits-coding-system 'utf-8
-  "Default coding system for git commits.")
+;;;; Utilities
+;;;; ------------------------------------------------------------
 
 (defconst git-log-msg-separator "--- log message follows this line ---")
 
-(defconst git-per-dir-ignore-file ".gitignore"
-  "Name of the per-directory ignore file.")
-
-
-;;;; Utilities
-;;;; ------------------------------------------------------------
+(defvar git-log-edit-font-lock-keywords
+  `(("^\\(Author:\\|Date:\\|Parent:\\|Signed-off-by:\\)\\(.*\\)$"
+     (1 font-lock-keyword-face)
+     (2 font-lock-function-name-face))
+    (,(concat "^\\(" (regexp-quote git-log-msg-separator) "\\)$")
+     (1 font-lock-comment-face))))
 
 (defun git-get-env-strings (env)
   "Build a list of NAME=VALUE strings from a list of environment strings."
@@ -123,6 +166,13 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
              (append (git-get-env-strings env) (list "git") args))
     (apply #'call-process "git" nil buffer nil args)))
 
+(defun git-call-process-env-string (env &rest args)
+  "Wrapper for call-process that sets environment strings,
+and returns the process output as a string."
+  (with-temp-buffer
+    (and (eq 0 (apply #' git-call-process-env t env args))
+         (buffer-string))))
+
 (defun git-run-process-region (buffer start end program args)
   "Run a git process with a buffer region as input."
   (let ((output-buffer (current-buffer))
@@ -164,13 +214,15 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
 
 (defun git-get-string-sha1 (string)
   "Read a SHA1 from the specified string."
-  (let ((pos (string-match "[0-9a-f]\\{40\\}" string)))
-    (and pos (substring string pos (match-end 0)))))
+  (and string
+       (string-match "[0-9a-f]\\{40\\}" string)
+       (match-string 0 string)))
 
 (defun git-get-committer-name ()
   "Return the name to use as GIT_COMMITTER_NAME."
   ; copied from log-edit
   (or git-committer-name
+      (git-config "user.name")
       (and (boundp 'add-log-full-name) add-log-full-name)
       (and (fboundp 'user-full-name) (user-full-name))
       (and (boundp 'user-full-name) user-full-name)))
@@ -179,6 +231,7 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   "Return the email address to use as GIT_COMMITTER_EMAIL."
   ; copied from log-edit
   (or git-committer-email
+      (git-config "user.email")
       (and (boundp 'add-log-mailing-address) add-log-mailing-address)
       (and (fboundp 'user-mail-address) (user-mail-address))
       (and (boundp 'user-mail-address) user-mail-address)))
@@ -213,15 +266,29 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   "Add a file name to the ignore file in its directory."
   (let* ((fullname (expand-file-name file))
          (dir (file-name-directory fullname))
-         (name (file-name-nondirectory fullname)))
+         (name (file-name-nondirectory fullname))
+         (ignore-name (expand-file-name git-per-dir-ignore-file dir))
+         (created (not (file-exists-p ignore-name))))
   (save-window-excursion
-    (set-buffer (find-file-noselect (expand-file-name git-per-dir-ignore-file dir)))
+    (set-buffer (find-file-noselect ignore-name))
     (goto-char (point-max))
     (unless (zerop (current-column)) (insert "\n"))
-    (insert name "\n")
+    (insert "/" name "\n")
     (sort-lines nil (point-min) (point-max))
-    (save-buffer))))
-
+    (save-buffer))
+  (when created
+    (git-run-command nil nil "update-index" "--info-only" "--add" "--" (file-relative-name ignore-name)))
+  (git-add-status-file (if created 'added 'modified) (file-relative-name ignore-name))))
+
+; propertize definition for XEmacs, stolen from erc-compat
+(eval-when-compile
+  (unless (fboundp 'propertize)
+    (defun propertize (string &rest props)
+      (let ((string (copy-sequence string)))
+        (while props
+          (put-text-property 0 (length string) (nth 0 props) (nth 1 props) string)
+          (setq props (cddr props)))
+        string))))
 
 ;;;; Wrappers for basic git commands
 ;;;; ------------------------------------------------------------
@@ -229,18 +296,17 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
 (defun git-rev-parse (rev)
   "Parse a revision name and return its SHA1."
   (git-get-string-sha1
-   (with-output-to-string
-     (with-current-buffer standard-output
-       (git-call-process-env t nil "rev-parse" rev)))))
+   (git-call-process-env-string nil "rev-parse" rev)))
+
+(defun git-config (key)
+  "Retrieve the value associated to KEY in the git repository config file."
+  (let ((str (git-call-process-env-string nil "config" key)))
+    (and str (car (split-string str "\n")))))
 
 (defun git-symbolic-ref (ref)
   "Wrapper for the git-symbolic-ref command."
-  (car
-   (split-string
-    (with-output-to-string
-      (with-current-buffer standard-output
-        (git-call-process-env t nil "symbolic-ref" ref)))
-    "\n")))
+  (let ((str (git-call-process-env-string nil "symbolic-ref" ref)))
+    (and str (car (split-string str "\n")))))
 
 (defun git-update-ref (ref val &optional oldval)
   "Update a reference by calling git-update-ref."
@@ -255,11 +321,7 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
 (defun git-write-tree (&optional index-file)
   "Call git-write-tree and return the resulting tree SHA1 as a string."
   (git-get-string-sha1
-   (with-output-to-string
-     (with-current-buffer standard-output
-       (git-call-process-env t
-        (if index-file `(("GIT_INDEX_FILE" . ,index-file)) nil)
-        "write-tree")))))
+   (git-call-process-env-string (and index-file `(("GIT_INDEX_FILE" . ,index-file))) "write-tree")))
 
 (defun git-commit-tree (buffer tree head)
   "Call git-commit-tree with buffer as input and return the resulting commit SHA1."
@@ -272,7 +334,7 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
     (with-current-buffer buffer
       (goto-char (point-min))
       (if
-          (setq log-start (re-search-forward (concat "^" git-log-msg-separator "\n") nil t))
+          (setq log-start (re-search-forward (concat "^" (regexp-quote git-log-msg-separator) "\n") nil t))
           (save-restriction
             (narrow-to-region (point-min) log-start)
             (goto-char (point-min))
@@ -377,8 +439,8 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
         (propertize
          (concat "   ("
                  (if (eq state 'copy) "copied from "
-                   (if (eq (git-fileinfo->state info) 'added) "renamed to "
-                     "renamed from "))
+                   (if (eq (git-fileinfo->state info) 'added) "renamed from "
+                     "renamed to "))
                  (git-escape-file-name (git-fileinfo->orig-name info))
                  ")") 'face 'git-status-face)
       "")))
@@ -395,11 +457,10 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
 
 (defun git-fileinfo-prettyprint (info)
   "Pretty-printer for the git-fileinfo structure."
-  (insert (format "   %s %s %s  %s%s"
-                  (if (git-fileinfo->marked info) (propertize "*" 'face 'git-mark-face) " ")
-                  (git-status-code-as-string (git-fileinfo->state info))
-                  (git-permissions-as-string (git-fileinfo->old-perm info) (git-fileinfo->new-perm info))
-                  (git-escape-file-name (git-fileinfo->name info))
+  (insert (concat "   " (if (git-fileinfo->marked info) (propertize "*" 'face 'git-mark-face) " ")
+                  " " (git-status-code-as-string (git-fileinfo->state info))
+                  " " (git-permissions-as-string (git-fileinfo->old-perm info) (git-fileinfo->new-perm info))
+                  "  " (git-escape-file-name (git-fileinfo->name info))
                   (git-rename-as-string info))))
 
 (defun git-parse-status (status)
@@ -544,8 +605,11 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
                           (let ((commit (git-commit-tree buffer tree head)))
                             (git-update-ref "HEAD" commit head)
                             (condition-case nil (delete-file ".git/MERGE_HEAD") (error nil))
+                            (condition-case nil (delete-file ".git/MERGE_MSG") (error nil))
                             (with-current-buffer buffer (erase-buffer))
                             (git-set-files-state files 'uptodate)
+                            (when (file-directory-p ".git/rr-cache")
+                              (git-run-command nil nil "rerere"))
                             (git-refresh-files)
                             (git-refresh-ewoc-hf git-status)
                             (message "Committed %s." commit))
@@ -623,6 +687,32 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   (unless git-status (error "Not in git-status buffer."))
   (ewoc-goto-prev git-status n))
 
+(defun git-next-unmerged-file (&optional n)
+  "Move the selection down N unmerged files."
+  (interactive "p")
+  (unless git-status (error "Not in git-status buffer."))
+  (let* ((last (ewoc-locate git-status))
+         (node (ewoc-next git-status last)))
+    (while (and node (> n 0))
+      (when (eq 'unmerged (git-fileinfo->state (ewoc-data node)))
+        (setq n (1- n))
+        (setq last node))
+      (setq node (ewoc-next git-status node)))
+    (ewoc-goto-node git-status last)))
+
+(defun git-prev-unmerged-file (&optional n)
+  "Move the selection up N unmerged files."
+  (interactive "p")
+  (unless git-status (error "Not in git-status buffer."))
+  (let* ((last (ewoc-locate git-status))
+         (node (ewoc-prev git-status last)))
+    (while (and node (> n 0))
+      (when (eq 'unmerged (git-fileinfo->state (ewoc-data node)))
+        (setq n (1- n))
+        (setq last node))
+      (setq node (ewoc-prev git-status node)))
+    (ewoc-goto-node git-status last)))
+
 (defun git-add-file ()
   "Add marked file(s) to the index cache."
   (interactive)
@@ -703,7 +793,7 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   (interactive)
   (let ((files (git-marked-files-state 'unmerged)))
     (when files
-      (apply #'git-run-command nil nil "update-index" "--info-only" "--" (git-get-filenames files))
+      (apply #'git-run-command nil nil "update-index" "--" (git-get-filenames files))
       (git-set-files-state files 'modified)
       (git-refresh-files))))
 
@@ -733,6 +823,16 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
     (git-setup-diff-buffer
      (apply #'git-run-command-buffer "*git-diff*" "diff-index" "-p" "-M" "HEAD" "--" (git-get-filenames files)))))
 
+(defun git-diff-file-merge-head (arg)
+  "Diff the marked file(s) against the first merge head (or the nth one with a numeric prefix)."
+  (interactive "p")
+  (let ((files (git-marked-files))
+        (merge-heads (git-get-merge-heads)))
+    (unless merge-heads (error "No merge in progress"))
+    (git-setup-diff-buffer
+     (apply #'git-run-command-buffer "*git-diff*" "diff-index" "-p" "-M"
+            (or (nth (1- arg) merge-heads) "HEAD") "--" (git-get-filenames files)))))
+
 (defun git-diff-unmerged-file (stage)
   "Diff the marked unmerged file(s) against the specified stage."
   (let ((files (git-marked-files)))
@@ -787,7 +887,8 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   (unless git-status (error "Not in git-status buffer."))
   (let ((buffer (get-buffer-create "*git-commit*"))
         (merge-heads (git-get-merge-heads))
-        (dir default-directory))
+        (dir default-directory)
+        (sign-off git-append-signed-off-by))
     (with-current-buffer buffer
       (when (eq 0 (buffer-size))
         (cd dir)
@@ -804,15 +905,14 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
           'face 'git-header-face)
          (propertize git-log-msg-separator 'face 'git-separator-face)
          "\n")
-        (when (and merge-heads (file-readable-p ".git/MERGE_MSG"))
-          (insert-file-contents ".git/MERGE_MSG"))))
-    (let ((log-edit-font-lock-keywords
-           `(("^\\(Author:\\|Date:\\|Parent:\\)\\(.*\\)"
-              (1 font-lock-keyword-face)
-              (2 font-lock-function-name-face))
-             (,(concat "^\\(" (regexp-quote git-log-msg-separator) "\\)$")
-              (1 font-lock-comment-face)))))
-      (log-edit #'git-do-commit nil #'git-log-edit-files buffer))))
+        (cond ((file-readable-p ".git/MERGE_MSG")
+               (insert-file-contents ".git/MERGE_MSG"))
+              (sign-off
+               (insert (format "\n\nSigned-off-by: %s <%s>\n"
+                               (git-get-committer-name) (git-get-committer-email)))))))
+    (log-edit #'git-do-commit nil #'git-log-edit-files buffer)
+    (setq font-lock-keywords (font-lock-compile-keywords git-log-edit-font-lock-keywords))
+    (re-search-forward (regexp-quote (concat git-log-msg-separator "\n")) nil t)))
 
 (defun git-find-file ()
   "Visit the current file in its own buffer."
@@ -823,6 +923,15 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
     (when (eq 'unmerged (git-fileinfo->state info))
       (smerge-mode))))
 
+(defun git-find-file-other-window ()
+  "Visit the current file in its own buffer in another window."
+  (interactive)
+  (unless git-status (error "Not in git-status buffer."))
+  (let ((info (ewoc-data (ewoc-locate git-status))))
+    (find-file-other-window (git-fileinfo->name info))
+    (when (eq 'unmerged (git-fileinfo->state info))
+      (smerge-mode))))
+
 (defun git-find-file-imerge ()
   "Visit the current file in interactive merge mode."
   (interactive)
@@ -891,6 +1000,8 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
   (let ((map (make-keymap))
         (diff-map (make-sparse-keymap)))
     (suppress-keymap map)
+    (define-key map "?"   'git-help)
+    (define-key map "h"   'git-help)
     (define-key map " "   'git-next-file)
     (define-key map "a"   'git-add-file)
     (define-key map "c"   'git-commit-file)
@@ -904,7 +1015,10 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
     (define-key map "m"   'git-mark-file)
     (define-key map "M"   'git-mark-all)
     (define-key map "n"   'git-next-file)
+    (define-key map "N"   'git-next-unmerged-file)
+    (define-key map "o"   'git-find-file-other-window)
     (define-key map "p"   'git-prev-file)
+    (define-key map "P"   'git-prev-unmerged-file)
     (define-key map "q"   'git-status-quit)
     (define-key map "r"   'git-remove-file)
     (define-key map "R"   'git-resolve-file)
@@ -921,6 +1035,7 @@ If not set, fall back to `add-log-mailing-address' and then `user-mail-address'.
     (define-key diff-map "=" 'git-diff-file)
     (define-key diff-map "e" 'git-diff-file-idiff)
     (define-key diff-map "E" 'git-find-file-imerge)
+    (define-key diff-map "h" 'git-diff-file-merge-head)
     (define-key diff-map "m" 'git-diff-file-mine)
     (define-key diff-map "o" 'git-diff-file-other)
     (setq git-status-mode-map map)))
@@ -946,12 +1061,28 @@ Commands:
   (set (make-local-variable 'list-buffers-directory) default-directory)
   (run-hooks 'git-status-mode-hook)))
 
+(defun git-find-status-buffer (dir)
+  "Find the git status buffer handling a specified directory."
+  (let ((list (buffer-list))
+        (fulldir (expand-file-name dir))
+        found)
+    (while (and list (not found))
+      (let ((buffer (car list)))
+        (with-current-buffer buffer
+          (when (and list-buffers-directory
+                     (string-equal fulldir (expand-file-name list-buffers-directory))
+                     (string-match "\\*git-status\\*$" (buffer-name buffer)))
+            (setq found buffer))))
+      (setq list (cdr list)))
+    found))
+
 (defun git-status (dir)
   "Entry point into git-status mode."
   (interactive "DSelect directory: ")
   (setq dir (git-get-top-dir dir))
   (if (file-directory-p (concat (file-name-as-directory dir) ".git"))
-      (let ((buffer (create-file-buffer (expand-file-name "*git-status*" dir))))
+      (let ((buffer (or (and git-reuse-status-buffer (git-find-status-buffer dir))
+                        (create-file-buffer (expand-file-name "*git-status*" dir)))))
         (switch-to-buffer buffer)
         (cd dir)
         (git-status-mode)
@@ -959,5 +1090,10 @@ Commands:
         (goto-char (point-min)))
     (message "%s is not a git working tree." dir)))
 
+(defun git-help ()
+  "Display help for Git mode."
+  (interactive)
+  (describe-function 'git-status-mode))
+
 (provide 'git)
 ;;; git.el ends here