Lem is an awesome project. It’s an editor buit in Common Lisp, ready to use out of the box for Common Lisp, that supports more languages and modes (Python, Rust, Elixir, Go, JavaScript, TypeScript, Haskell, Java, Nim, Dart, OCaml, Scala, Swift, shell, asm, but also markdown, ascii, JSON, HTML and CSS, SQL…) thanks to, in part, its built-in LSP support.

I took the challenge to add an interactive interface for Git, à la Magit, because you know, despite all its features (good vim mode, project-aware commands, grep, file tree view and directory mode, multiple cursors, tabs…), there’s so much an editor should do to be useful all day long.

Now, for a Git project (and to a lower extent, Fossil and Mercurial ones) we can see its status, stage changes, commit, push & pull, start an interactive rebase

I like the shape it is taking, and frankly, what I have been able to assemble in a continuously successful hack session is a tribute to what @cxxxr and the early contributors built. Lem’s codebase is easily explorable (more so in Lem itself of course, think Emacs in steroïds with greater Common Lisp power), clear, and fun. Come to the Discord or watch the repository and see how new contributors easily add new features.

I didn’t even have to build an UI interface, fortunately. I started with the built-in interactive grep mode, and built from there.

Enough talk, what can we do with Lem/legit as of today? After that, we’ll discuss some implementation details.

Disclaimer: there’s room for collaboration ;)

Table of Contents

Lem/legit - manual

NOTE: you'd better read the latest manual on Lem's repository: https://github.com/lem-project/lem/blob/main/extensions/legit/README.md

legit’s main focus is to support Git operations, but it also has preliminary support for other VCSs (Fossil, Mercurial).

We can currently open a status window, stage and unstage files or diff hunks, commit our changes or again start an interactive rebase.

Its main source of inspiration is, obviously, Magit.

Status

legit is in development. It isn’t finished nor complete nor at feature parity with Magit nor suitable for mission-critical work. Use at your own risk.

However it should run a few operations smoothly.

Load

legit is built into Lem but it isn’t loaded by default. To load it, open a Lisp REPL (M-x start-lisp-repl) or evaluate Lisp code (M-:) and type:

(ql:quickload "lem/legit")

Now you can start it with C-x g or M-x legit-status.

Help

Press ? or C-x ? to call legit-help.

M-x legit-status

The status windows show us, on the left:

  • the current branch
  • the untracked files.
  • the unstaged changes, staged changes,
  • the latest commits.

It also warns us if a rebase is in process.

and the window on the right shows us the file diffs or the commits’ content.

Refresh the status content with g.

We can navigate inside legit windows with n, p, M-n and M-p (go to next/previous section).

To change windows, use the usual M-o key from Lem.

Quit with q or C-x 0 (zero).

Stage or unstage files, diff hunks (s, u)

Stage changes with “s”.

When your cursor is on an Unstaged change file, you can see the file changes on the right, and you can stage the whole file with s.

You can also go to the diff window on the right, navigate the diff hunks with n and p and stage a hunk with s.

Unstage a change with u.

Discard changes to a file

Use k. Be careful, you can loose your changes.

Commit

Pressing c opens a new buffer where you can write your commit message.

Validate with C-c C-c and quit with M-q (or C-c C-k).

Branches, push, pull

Checkout a branch with b b (“b” followed by another “b”).

Create a new branch with b c.

You can push to the current remote branch with P p and pull changes (fetch) with F p.

NOTE: after pressing "P" or "F", you will not see an intermediate window giving you choices. Just press "P p" one after the other.

Interactive rebase

You can start a Git interactive rebase. Place the cursor on a commit you want to rebase from, and press r i.

You will be dropped into the classical Git rebase file, that presents you commits and an action to apply on them: pick the commit, drop it, fixup, edit, reword, squash…

For example:

pick 26b3990f the following commit
pick 499ba39d some commit

# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# .       create a merge commit using the original merge commit's
# .       message (or the oneline, if no original merge commit was
# .       specified). Use -c <commit> to reword the commit message.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out

legit binds keys to the rebase actions:

  • use p to “pick” the commit (the default)
  • f to fixup

and so on.

Validate anytime with C-c C-c and abort with C-c C-k.

NOTE: at the time of writing, "reword" and "edit" are not supported.
NOTE: the interactive rebase is currently Unix only. This is due to the short shell script we use to control the Git process. Come join us if you know how to "trap some-fn SIGTERM" for Windows plateforms.

Abort, continue, skip

In any legit window, type r a to abort a rebase process (if it was started by you inside Lem or by another process), r c to call git rebase --continue and r s to call git rebase --skip.

Fossil

We have basic Fossil support: see current branch, add change, commit.

Mercurial

We have basic Mercurial support.

Customization

In the lem/porcelain package:

  • *git-base-arglist*: the Git program, to be appended command-line options. Defaults to (list "git").

=> you can change the default call to the git binary.

Same with *fossil-base-args* and *hg-base-arglist* (oops, a name mismatch).

  • *nb-latest-commits*: defaults to 10
  • *branch-sort-by*: when listing branches, sort by this field name. Prefix with “-” to sort in descending order. Defaults to “-creatordate”, to list the latest used branches first.
  • *file-diff-args*: defaults to (list "diff" "--no-color"). Arguments to display the file diffs. Will be surrounded by the git binary and the file path. For staged files, –cached is added by the command.

If a project is managed by more than one VCS, legit takes the first VCS defined in *vcs-existence-order*:

(defvar *vcs-existence-order*
  '(
    git-project-p
    fossil-project-p
    hg-project-p
    ))

where these symbols are functions with no arguments that return two values: a truthy value if the current project is considered a Git/Fossil/Mercurial project, and a keyword representing the VCS: :git, :fossil, :hg.

For example:

(defun hg-project-p ()
  "Return t if we find a .hg/ directory in the current directory (which should be the project root. Use `lem/legit::with-current-project`)."
  (values (uiop:directory-exists-p ".hg")
          :hg))

Variables and parameters in the lem/legit package. They might not be exported.

  • *legit-verbose*: If non nil, print some logs on standard output (terminal) and create the hunk patch file on disk at (lem-home)/lem-hunk-latest.patch.

=> to help debugging

see sources in /extensions/legit/

Implementation details

Calls

Repository data is retrieved with calls to the VCS binary. We have a POC to read some data directly from the Git objects (proactively looking for best efficiency) using cl-git.

Basically, we get Git status data with git status --porcelain=v1. This outputs something like:

 A project/settings.lisp
 M project/api.lisp
?? project/search/datasources

we output this a to a string and we parse it.

Interactive rebase

The interactive rebase currently uses a Unix-only shell script.

When you run git rebase --interactive, the Git program creates a special file in .git/rebase-merge/git-rebase-merge-todo, opens it with your $EDITOR in the terminal, lets you edit it (change a “pick” to “fixup”, “reword” etc), and on exit it interprets the file and runs the required Git operations. What we want is to not use Git’s default program, edit the file with Lem and our special Legit mode that binds keys for quick actions (press “f” for “fixup” etc). So we bind the shell’s $EDITOR to a dummy editor, this shell script:

function ok {
    exit 0
}

trap ok SIGTERM
echo "dumbrebaseeditor_pid:$$"

while :
do
        sleep 0.1
done

This script doesn’t simulate an editor, it waits, so we can edit the rebase file with Lem, but this script catches a SIGTERM signal and exits successfully, so git-rebase is happy and terminates the rebase and all is well.

But that’s Unix only.

On that matter Magit seems to be doing black magic.

The basic function to write content to a buffer is

(insert-string point s :read-only t)

And this is how you make actionable links:

(put-text-property start end :visit-file-function function))

where :visit-file-function is any keyword you want, and function is any lambda function you want. So, how to make any link useful? Create a lambda, make it close over any variables you want, “store” it in a link, and later on read the attribute at point with

(text-property-at point :visit-file-function)

where point can be (buffer-point (window-buffer *my-window*)) for instance.

Now create a mode, add keybindings and you’re ready to go.

;; Legit diff mode: view diffs.
;; We use the existing patch-mode and supercharge it with our keys.
(define-major-mode legit-diff-mode lem-patch-mode:patch-mode
    (:name "legit-diff"
     :syntax-table lem-patch-mode::*patch-syntax-table*
     :keymap *legit-diff-mode-keymap*)
  (setf (variable-value 'enable-syntax-highlight) t))

;; git commands.
;; Some are defined on peek-legit too.
(define-key *global-keymap* "C-x g" 'legit-status)

or a minor mode:

(define-minor-mode peek-legit-mode
    (:name "Peek"
     :keymap *peek-legit-keymap*)
  (setf (not-switchable-buffer-p (current-buffer)) t))

;; Git commands
;; Some are defined on legit.lisp for this keymap too.
(define-key *peek-legit-keymap* "s" 'peek-legit-stage-file)
(define-key *peek-legit-keymap* "u" 'peek-legit-unstage-file)
(define-key *peek-legit-keymap* "k" 'peek-legit-discard-file)

TODOs

Much needs to be done, if only to have a better discoverable UX.

First:

  • interactive rebase: support reword, edit.
  • show renamed files

and then:

  • visual submenu to pick subcommands
  • view log
  • stage only selected region (more precise than hunks)
  • unstage/stage/discard multiple files
  • many, many more commands, settings and switches
  • mouse context menus

Closing words

You’ll be surprised by all Lem’s features and how easy it is to add features.

I believe it doesn’t make much sense to “port Magit to Lem”. The UIs are different, the text displaying mechanism is different, etc. It’s faster to re-implement the required functionality, without the cruft. And look, I started, it’s possible.

But, sad me, I didn’t plan to be involved in yet another side project, as cool and motivating as it might be :S