Emacs vc-mode tutorial

Published 2024-08-15 - Last edited 2024-08-28

tag(s): #emacs #tutorial

NOTE: I received some email about this article! Added some notes at the very end of the tutorial.

A couple days ago I found a video (probably via reddit) titled "A fair trial of Emacs' vc-mode". And probably the video is great (I understand the author is well regarded). But it is also 2 hours long (!) and there's no way it takes 2 hours to cover the vc-mode basics.

This made me think, that I could try to write a tutorial post. And it's not the first time that I think of writing something about vc-mode. As a Windows user[1], I tried it early in my Emacs journey, even before the current trend on minimalism and built-in-itis set in. It was a necessity: Magit is really slow on Windows (not Magit's fault!). It wasn't an easy transition, there's no neat and discoverable interface like Magit has. Maybe I can help others?

Let's make sure that expectations are set: vc-mode is not as powerful as Magit. It is not tailored 100% to git.
But it is simple, fast, and consistent. Also, after using it for some time, the way it is designed has grown on me.

Basic clone, commit, pull and push flow

Clone

You decide you want to hack on a repo. There's no vc equivalent to git clone.... So you drop to the command line to do it. OR you write a command, this one is mine:


(defvar hoagie-vc-git-emails
  '("code@sebasmonia.com"
    "seb.monia@workdomain.com"
    "another@email.com")
  "List of email addresses that can be associated with a repository")

(defun hoagie-vc-git-clone (repository-url local-dir)
  "Run \"git clone REPOSITORY-URL\" to LOCAL-DIR.
It also prompts what email to use in the directory, from the
values in `hoagie-vc-git-emails'.
Executes `vc-dir' in the newly cloned directory."
  (interactive
   (let* ((url (read-string "Repository URL: "))
          (dir (file-name-base url)))
     (list url (read-string "Target directory: " dir))))
  (vc-git-command nil 0 nil "clone" repository-url local-dir)
  (let ((default-directory (file-name-concat default-directory local-dir)))
    (vc-git-command nil 0 nil "config" "user.email"
                    (completing-read "Email for this repo: "
                                     hoagie-vc-git-emails))
    (vc-dir default-directory)))
    

You can see the command does some additional setup and opens vc-dir, which is more or less equivalent to Magit's status window. It also uses plumbing from vc-git, the package to which most commands dispatch their work for git repositories.

Commands in VC are under the prefix C-x v[2]. For Example, you can invoke vc-dir at any point using C-x v d (personally, I always use project-vc-dir, because I use project.el. The binding is C-x p v).

vc-dir

We'll focus first on this window, which gives an overview of the repository status. Here is how the vc-dir buffer for my website repo looks for this post:


VC backend : Git
Working dir: ~/sourcehut/site.sebasmonia/
Branch     : main
Tracking   : origin/main
Remote     : git@git.sr.ht:~sebasmonia/site.sebasmonia.com
Stash      : Hide all stashes (1)
             {0}: On main: temp files

                         ./
                         content/
     edited              content/index.html
     edited              content/postslist.html
                         content/posts/
     unregistered        content/posts/2024-08-15-emacs-vc-mode-tutorial.html
    

Things to note in the header:

The "unregistered" file is this post. The next logical action for a file happens with the key v, which in this case, is to add it to the repo. So I move point to the file and press the key:


     added               content/posts/2024-08-15-emacs-vc-mode-tutorial.html
      

You can also mark several files to operate on them at the same time, using m. Pressing M will mark all files with the same status as the current one, for example if I put point in any of the files I edited:


                         ./
                         content/
  *  edited              content/index.html
  *  edited              content/postslist.html
                         content/posts/
     added               content/posts/2024-08-15-emacs-vc-mode-tutorial.html
      

And then with u or U you can unmark individual files or remove all marks, respectively.

There are many commands that depend on selected files, or point position. For example vc-revert (not bound by default, I put it in k), shows a diff of the changes and asks for confirmation to revert. If files are selected, it will operate only on those. If there's no selection and point is over any file, it will instead offer to revert that single file. With point in the header, we can revert all changes in one go.
Other bindings I use often and behave similarly are vc-diff and vc-print-log. Try this last one, by default bound to l, in the header and over a single file, and see the difference :) very cool.

Commit

Let's say I am ready to commit, with my two files edited and one added. I press v with point in the header of vc-dir and two windows pop up: one where I can write the commit message, and at the bottom the list of files included. There are a few important bindings in this window, other than C-c C-c to commit:

Push and pull

I have my changes committed, and I feel ready to push them. From my vc-dir window, I press P. It will run git push. Similarly + will run git pull.
In both cases, you can invoke the commands with prefix argument to modify the parameters passed to git, for example if you would like to pull from a different branch (by editing the default command to git pull origin other-branch).

Branches

Speaking of branches, somewhat recently (very recently, in Emacs years) a new prefix C-x v b was added for git branches, also available in vc-dir under b:

Notoriously missing is a command to simply list branches, which I added to b b:


(defun hoagie-vc-git-show-branches (&optional arg)
    "Show in a buffer the list of branches in the current repository.
With prefix ARG show the remote branches."
    (interactive "P")
    ;; TODO: this is a mix of vc-git stuff and project.el stuff...
    (let* ((default-directory (project-root (project-current t)))
           (buffer-name (project-prefixed-buffer-name (if arg
                                                          "git remote branches"
                                                        "git local branches"))))
      (vc-git-command buffer-name
                      0
                      nil
                      "branch"
                      (when arg "-r"))
      (pop-to-buffer buffer-name)
      (goto-char (point-min))
      (special-mode)))
    

"C-x v" prefix

Most of the commands described above can be invoked in any file you are working on. For example if I am modifying index.html, I save changes and press C-x v = to see the diff compared to the last commit. I can also commit that single file individually, revert it[3], see a log of its commits, etc.

Conflicts, ediff, reset

When there's a conflict, open the relevant file and invoke vc-resolve-conflicts to navigate the problem areas. Once you save the file, the conflict should be marked as resolved.
There's a vc-ediff command that I bound in a bunch of places, and it does exactly what the name makes you think it does. I love ediff :)

And finally, the last custom command of this post:


(defun hoagie-vc-dir-reset (&optional arg)
    "Runs \"git reset\" to unstage all changes.
With prefix arg, does a hard reset (thus it asks for confirmation)."
    (interactive "P")
    (if arg
        (when (y-or-n-p "Perform a hard reset? ")
          (vc-git-command nil 0 nil "reset" "--hard")
          (message "Completed. All pending changes are lost."))
      (vc-git-command nil 0 nil "reset")
      (message "All changes are unstaged."))
    (vc-dir-refresh))
    

I bound this one to r in my vc-dir configuration.

Partial commits

This was the last thing that I missed from Magit, and is also a somewhat recent addition.
From a diff buffer (for example, one invoked in the current file via C-x v =), you can drop a hunk using k, or split it using C-c C-s. There are a few other bindings, check out the major mode help.

Once satisfied, in the diff buffer, press C-x v v to create a commit with only the contents that you didn't drop.
I don't use this often, but it was very convenient the few times I needed it.

Closing remarks

There are many useful commands I didn't mention (to examine commit details, retrieve a specific version of a file, or search all the commits for a word, for example).
But hopefully this was enough to get someone started with the basics.

If any experienced vc-mode users find errors in the text, I will be happy to receive a message and update the post.

I mentioned in the intro built-in-itis, vc-mode was my own gateway to start shedding third party packages for more "Emacs core". In its own messy way, built-ins integrate and work together better than it seems. Even if they are more idiosyncratic and have more surprising behaviours (to the modern eye) than newer packages.

Correspondence

I am happy that I received some email about this article.
The first one is from Philip K., who pointed out that there is a vc-clone function, added in recent Emacs versions. It is not interactive, but can be used to build a command that is simpler than the one I wrote and shared here.

James T. suggested I make a follow up post, and graciously shared some of his tips. A few of them I have since started using! Very much appreciated.
Regarding a follow up post, maybe? There's a lot more to vc-mode, some really powerful commands. But the main trigger to write this tutorial was to get someone "up and running", and I figure for more advanced tips there's always the manual or online help.
Then again, before James' email, I hadn't discovered some of these commands myself... :)

Footnotes
  1. At work. Except on the few places that allowed me to dual boot.
  2. M-x describe-keymap ==> vc-prefix-map to see all bindings.
  3. By default, reverting is bound to C-x v u, in my config I add a second binding to C-x v k to make it consistent the one I added to vc-dir.

Back to top

Back to homepage