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
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
.
Navigation
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
.
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
.
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.
Displaying data, actionable links
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