A git status Emacs modeline

| categories: git, emacs | tags:

I am using git more and more in Emacs, and I would like a way to know the status of the git repo I am working in by looking at the modeline. I know about magit , and other git modes, but none of them provide something as easy as useful as say bash-git-prompt in the bash shell, which is to say I do not want to run a command to see the status (I might as well be in the shell then). Part of this need comes from a project with hundreds of git repos in it, and I want convenient status when I open any one of them.

Here, I want to emulate the bash-git-prompt feature in the Emacs modeline where it will show you when you are in a git repo, and then some basic information like what branch you are on, the number of untracked, modified files, and the commit status with respect to a remote. First, we only want this when we are in a git repo. We can check for that like this. The command in this block returns a string that starts with fatal when not in a git repo.

(not (string-match "^fatal" (shell-command-to-string "git rev-parse --git-dir")))
t

Let us wrap that in a nice function so we can use it later..

(defun in-git-p ()
  (not (string-match "^fatal" (shell-command-to-string "git rev-parse --git-dir"))))

(in-git-p)
t

Next, we would like to know how many untracked, modified and other (e.g. unmerged, deleted, etc…) files we have. We can get this from git status --porcelain. I am going to set these to be red if they are not zero, so they stand out, and be green otherwise. We will also store a list of each file type so we can make a tooltip on the counter to see what is there.

(defun git-parse-status ()
  (interactive)
  (let ((U 0)   ; untracked files
        (M 0)   ; modified files
        (O 0)   ; other files
        (U-files "")
        (M-files "")
        (O-files ""))
    (dolist (line (split-string
                   (shell-command-to-string "git status --porcelain")
                   "\n"))
      (cond

       ;; ignore empty line at end
       ((string= "" line) nil)

       ((string-match "^\\?\\?" line)
        (setq U (+ 1 U))
        (setq U-files (concat U-files "\n" line)))

       ((string-match "^ M" line)
        (setq M (+ 1 M))
        (setq M-files (concat M-files "\n" line))
        )

       (t
        (message "detected other in %s" line)
        (setq O (+ 1 O))
        (setq O-files (concat O-files "\n" line)))))
      
    ;; construct propertized string
    (concat
     "("
     (propertize 
      (format "M:%d" M)
      'face (list ':foreground (if (> M 0)
                                   "red"
                                 "forest green"))
      'help-echo M-files)
     "|"
     (propertize 
      (format "U:%d" U)
      'face (list ':foreground (if (> U 0)
                                   "red"
                                 "forest green"))
      'help-echo U-files)
     "|"
     (propertize 
      (format "O:%d" O)
      'face (list ':foreground (if (> O 0)
                                   "red"
                                 "forest green"))
      'help-echo O-files)                   
      ") ")))

(git-parse-status)
(M:1|U:2|O:0) 

Finally, let us get the branch we are on, and the commits with respect to a remote. We can do that like this. We use some unicode characters to indicate what direction things go, e.g. an up arrow to indicate you need to push, and a down arrow to indicate you should pull.

(defun git-remote-status ()
  (interactive)
  (let* (;; get the branch we are on.
         (branch (s-trim
                  (shell-command-to-string
                   "git rev-parse --abbrev-ref HEAD")))
         ;; get the remote the branch points to.
         (remote (s-trim
                  (shell-command-to-string
                   (format "git config branch.%s.remote" branch))))
         (remote-branch (s-trim
                         (shell-command-to-string
                          "git for-each-ref --format='%(upstream:short)' $(git symbolic-ref -q HEAD)")))
         (commits (split-string
                   (s-trim
                    (shell-command-to-string
                     (format
                      "git rev-list --count --left-right HEAD...%s"
                      remote-branch)))))
         (local (nth 0 commits))
         (remotes (nth 1 commits)))
    (concat
     "["
     (propertize
      (format "%s" branch)
      'face (list :foreground "magenta"))
     "|"
     (format "↑%s|↓%s" local remotes)
     "]"))) 

(git-remote-status)
[source|↑0|↓0]

Now, we can finally put this together in a little minor mode. We add an element to the mode-line-format variable that evaluates those functions. When we turn off the minor mode, we remove the element from the modeline.

(define-minor-mode git-mode
  "minor mode to put git repo status in modeline"
  nil nil nil
  (let ((git-modeline '(:eval (if (not (in-git-p))
                                  ""
                                (concat 
                                 (git-remote-status)
                                 (git-parse-status))))))
    (if git-mode
        ;; put in modeline
        (push git-modeline mode-line-format)
      ;; remove from modeline
      (setq mode-line-format
            (-remove (lambda (x)
                       (equal x git-modeline))                                  
                     mode-line-format)))))

This leads to a modeline that looks like this (when my mouse is hovered over the M):

This seems to have some performance issue, since pretty much everytime I type a key, it updates the modeline, and runs git. That is too often. Let us redefine the mode here so we have a minimum time between updates, say 15 seconds. We will store the last time updated, and the last value of the mode-line. Then each time the modeline updates, if the time since the last update is greater than our interval, then we will run the git commands. Otherwise, we just use the old modeline value.

(defvar git-modeline-last-update (float-time) "Last time we updated")
(defvar git-modeline-update-interval 15 "Minimum time between update in seconds")
(defvar git-modeline "" "Last value of the modeline")

(define-minor-mode git-mode
  "minor mode to put git repo status in modeline"
  nil nil nil
  (let ((git-modeline '(:eval (if
                                  (> (- (float-time) git-modeline-last-update)
                                     git-modeline-update-interval)
                                  ;; we are updating                              
                                  (setq git-modeline
                                        (if (not (in-git-p))
                                            ""                                   
                                          (setq  git-modeline-last-update (float-time))
                                          (concat 
                                           (git-remote-status)
                                           (git-parse-status))))
                                
                              ;; use last value of the modeline
                              git-modeline))))
    (if git-mode
        ;; put in modeline
        (push git-modeline mode-line-format)
      ;; remove from modeline
      (setq mode-line-format
            (-remove (lambda (x)
                       (equal x git-modeline))                                  
                     mode-line-format)))))

That does it I think. I don't have any performance issues here now. I have not tested this super thoroughly on many git repos, but it seems to be pretty consistent and correct so far. The remote status code is where there is the most probability for issues. I still do not know that part of git very well. I wonder if there is a more elegant solution than this, perhaps an idle timer. I notice a little lag in updating the data when I switch to another git repo. That might be a little confusing one day.

Otherwise, this seems like a pretty nice solution so far. There are still some things that would be nice to see on here. For example, a pop-up menu on the modeline to switch branches, push or pull, and with actions for the files, e.g. add/commit, etc… Those do not seem to hard to

Copyright (C) 2014 by John Kitchin. See the License for information about copying.

org-mode source

Org-mode version = 8.2.7c

Discuss on Twitter