Learn
Ingot
Introducing a Git engine written in Common Lisp.
Ingot is an attempt at Git itself in Common Lisp. It reads and writes
the repositories that git makes, byte for byte: loose and packed
objects, deltas included, refs and packed-refs, trees, commits and
tags. On them it does what the porcelain does: the log, a file at a
commit, diffs, branches, three-way merges and merge bases, archives of
a tree; and it speaks Git's smart protocol over HTTP on both sides, so
a git clone from it and a git push to it work, and so does a fetch
by it from another server. No subprocess and no git binary anywhere,
and every object id is the one git would compute.
It is early, and in the open. The source is at https://gitlab.common-lisp.net/clo/ingot, where issues and merge requests are welcome; it was written as the Git engine of Anvil, a forge in Common Lisp.
Loading it
Ingot is a clone and two forms away.
From source, in any Lisp with ASDF and Quicklisp (it is known to load
on SBCL and Clozure CL; its only dependencies are chipz, salza2,
babel and bordeaux-threads):
$ git clone https://gitlab.common-lisp.net/clo/ingot.git
(asdf:load-asd "/path/to/ingot/ingot.asd")
(ql:quickload :ingot)
The session, a step at a time
What follows is the session the front page turns through, run as written in Clozure CL. The answers are what it answered. The commit ids carry the clock, so your own run differs there; the blob's id at the end does not, because the bytes are the same.
1. git init --bare notes.git
A bare repository in the current directory, as git init --bare would make it: HEAD, config, objects/, refs/. The identity is the name and address a commit carries; the time is taken when the commit is made.
CL-USER> (ql:quickload "ingot")
("ingot")
CL-USER> (defparameter *repo*
(ingot:init-repository "notes.git/"))
*REPO*
CL-USER> (defparameter *me*
(ingot:make-identity "A Visitor"
"you@example.org"))
*ME*
2. git add README.md lisp/hello.lisp; git commit -m 'First notes'
A commit is the base tree plus a list of actions: :create, :update, :delete and :move, each with a path. There is no work tree and no staging; the tree is built from the actions and written, then the commit, then the branch moves. The answer is the commit's id.
CL-USER> (ingot:make-commit! *repo* :branch "master"
:author *me* :message "First notes"
:actions
'((:action :create :path "README.md"
:content "# Notes, kept with Ingot")
(:action :create :path "lisp/hello.lisp"
:content "(defun hello () 'hello)")))
"aa75a5b51eca0aea9485c445bdace69bdaaa9aa5"
3. git commit -am 'Hello says more'
The second commit updates one file. Its parent is the branch's tip, found by the branch name; the tree it starts from is the tip's.
CL-USER> (ingot:make-commit! *repo* :branch "master"
:author *me* :message "Hello says more"
:actions
'((:action :update :path "lisp/hello.lisp"
:content "(defun hello () '(hello world))")))
"9860f1e2cbfe0ac500580e0ffa266864f40308c9"
4. git log --oneline
log-commits walks the history newest first from any commit, with :limit and a :path filter when wanted. Each commit answers its subject, message, author, committer and parents.
CL-USER> (defparameter *head* (ingot:head-oid *repo*))
*HEAD*
CL-USER> (mapcar #'ingot:commit-subject
(ingot:log-commits *repo* *head*))
("Hello says more" "First notes")
5. git ls-tree -r HEAD
tree-paths walks a tree recursively and answers every path with its entry (mode, name, id). The commit's tree comes from the commit.
CL-USER> (mapcar #'car
(ingot:tree-paths *repo*
(ingot:commit-tree
(ingot:read-commit *repo* *head*))))
("README.md" "lisp/hello.lisp")
6. git show HEAD:lisp/hello.lisp
blob-at finds a path in the tree of a commit and answers the blob's bytes; octets-to-string reads them as UTF-8.
CL-USER> (ingot:octets-to-string
(ingot:blob-at *repo* *head* "lisp/hello.lisp"))
"(defun hello () '(hello world))"
7. git diff HEAD~1 HEAD -- lisp/hello.lisp
A commit's parents are ids. The diff here is a Myers line diff of the two texts, printed as git diff prints it; commit-diff answers the changed paths of a commit, and tree-diff compares two trees.
CL-USER> (defparameter *parent*
(first (ingot:commit-parents
(ingot:read-commit *repo* *head*))))
*PARENT*
CL-USER> (ingot:unified-diff
(ingot:octets-to-string
(ingot:blob-at *repo* *parent* "lisp/hello.lisp"))
(ingot:octets-to-string
(ingot:blob-at *repo* *head* "lisp/hello.lisp")))
"--- a
+++ b
@@ -1 +1 @@
-(defun hello () 'hello)
+(defun hello () '(hello world))
"
8. git checkout -b topic master; git commit; git merge topic
A branch that does not exist yet starts from :start-branch. The merge is a fast-forward, since master has not moved: it answers the topic's own commit, and both branches point at it. When both sides have moved, the merge is a three-way one with a merge commit, and a conflict stops it, saying where.
CL-USER> (ingot:make-commit! *repo* :branch "topic"
:start-branch "master" :author *me*
:message "A to-do list"
:actions '((:action :create :path "TODO.md"
:content "- read the Ingot page")))
"6c8217f9ee00b74bfe52576ead9b9f38f25295d4"
CL-USER> (ingot:merge-branches! *repo* "topic" "master"
:author *me*)
"6c8217f9ee00b74bfe52576ead9b9f38f25295d4"
CL-USER> (ingot:branches *repo*)
(("master" . "6c8217f9ee00b74bfe52576ead9b9f38f25295d4")
("topic" . "6c8217f9ee00b74bfe52576ead9b9f38f25295d4"))
9. echo -n hello | git hash-object --stdin
The id git gives a blob is the SHA-1 of blob <length>\0 followed by the bytes. Ingot computes the same, so this is also the id of the file in any repository that holds it. Try echo -n hello | git hash-object --stdin beside it.
CL-USER> (ingot:hash-object :blob
(ingot:string-to-octets "hello"))
"b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0"
Git commands and their Ingot forms
Ingot is the engine, not the porcelain, but most of what one types at
git has a form here. *repo* is an open repository, ids are
forty-character strings, and a commit is read with read-commit and
asked with the commit- readers.
git | Ingot |
|---|---|
git init --bare notes.git |
(init-repository "notes.git/") |
git rev-parse HEAD, git rev-parse topic |
(head-oid *repo*), (resolve *repo* "topic") |
git branch, git tag |
(branches *repo*), (tags *repo*) |
git log -10 -- src/ |
(log-commits *repo* oid :limit 10 :path "src/") |
git show <commit> |
(read-commit *repo* oid), (commit-diff *repo* oid) |
git ls-tree -r <tree> |
(tree-paths *repo* tree-oid) |
git show <commit>:path/file |
(blob-at *repo* commit-oid "path/file") |
git hash-object file |
(hash-object :blob octets) |
git add and git commit |
(make-commit! *repo* :branch "master" :author who :message "..." :actions '((:action :create :path "f" :content "..."))) |
git checkout -b topic master, then a commit |
(make-commit! *repo* :branch "topic" :start-branch "master" ...) |
git diff a b -- file |
(unified-diff old-text new-text); whole trees: (tree-diff *repo* old-tree new-tree) |
git merge-base a b |
(merge-base *repo* a b) |
git merge topic |
(can-merge? *repo* src dst), then (merge-branches! *repo* "topic" "master" :author who) |
git update-ref, git branch -D |
(update-ref! *repo* "refs/heads/x" oid :old old), (delete-ref! *repo* "refs/heads/x") |
git symbolic-ref HEAD refs/heads/main |
(set-default-branch! *repo* "main") |
git archive --format=tar.gz --prefix=p/ <tree> |
(tar-gz-tree *repo* tree-oid :prefix "p/"), (zip-tree ...) |
git fetch, git clone |
(fetch! *repo* transport), with a transport your HTTP client lends |
serving git-upload-pack and git-receive-pack |
(advertise-refs *repo* :upload-pack), (upload-pack *repo* body), (receive-pack *repo* body :check ...) |
Reading a repository you already have
open-repository takes a checkout or a bare repository and reads it
as git left it, packs and all:
(defparameter *repo* (ingot:open-repository "/path/to/a/checkout/"))
(ingot:default-branch *repo*)
(ingot:branches *repo*)
(mapcar #'ingot:commit-subject
(ingot:log-commits *repo* (ingot:head-oid *repo*) :limit 10))
(ingot:tags *repo*)
A path filter on the log, :path "src/", answers the commits that
touched it, as git log -- src/ does.
Under the hood
An object is found loose first, then in every pack of the repository through its index (the fanout and the sorted ids), a delta resolved against its base by offset or by id. A ref update is written through a temporary file and a rename, compare-and-swap against the old value under the repository's lock, so two writers do not lose each other. A commit is made from a base tree and a list of actions, which is what a web editor or an API would do; there is no work tree and no index. A fetch answered by Ingot copies the entries of its own packs as they lie, deltas included when the base goes too, so a clone of a pushed repository gets a pack much the size of the one that was pushed.
Every function signals ingot:git-error (or not-found or
ref-conflict) rather than answering half a thing.
What is not there
git status, git add and the index: Ingot keeps no work tree.
SHA-256 repositories (the id is twenty bytes throughout). Shallow
clones. Deltas of its own making: a pack it writes reuses the deltas
of the packs it holds and writes the rest whole, and nothing repacks.
Rename detection in diffs. The contents of submodules. Each is a seam
rather than a wall.
Something wrong or missing here? Edit this page on GitLab.