# GIT tutorial

# Under construction

The main reference for this tutorial is the Pro Git book section on GIT internals.

This tutorial uses three libraries:

- CodeMirror, released under the MIT license,
- sha1.js, released under the MIT license,
- pako 2.0.3, released under the MIT and Zlib licenses, see the project page for details,
- Viz.js (v1.8.2 which has a synchronous API), released under the MIT license.

# Introduction

GIT is based on a simple model, with a lot of shorthands for common use cases. This model is sometimes hard to guess just from the everyday commands. To illustrate how GIT works, we'll implement a stripped down clone of GIT in a few lines of JavaScript. * empty lines and single closing braces excluded, a few more in total.

# The Operating System's filesystem

# Model of the filesystem

The Operating System's filesystem will be simulated by a very simple key-value store. In this very simple filesystem, directories are entries mapped to `null` and files are entries mapped to strings. The path to the current directory is stored in a separate variable.

var filesystem = {}; var current_directory = '';

# Filesystem access functions (`read`, `write`, `mkdir`, `exists`, `remove`, `cd`)

The filesystem exposes functions to read an entire file, create or replace an entire file, create a directory, test the existence of a filesystem entry, and change the current directory.

function read(filename) { return filesystem[filename]; } function write(filename, data) { filesystem[filename] = String(data); } function exists(filename) { return typeof(filesystem[filename]) !== 'undefined'; } function mkdir(dirname) { filesystem[dirname] = null; } function cd(dirname) { current_directory = dirname; } function remove(path, recursive) { if (recursive && filesystem[path] === null) { var children = listdir(path); for (var i = 0; i < children.length; i++) { remove(path + '/' + children[i], true); } } delete filesystem[path]; }

# Filesystem access functions (`listdir`)

It will be handy for some operations to list the contents of a directory.

function listdir(dirname) { var depth = dirname.split('/').length; // Get all paths in the filesystem var paths = Object.keys(filesystem); // Filter to keep only the paths starting with the given dirname var prefix = dirname + '/'; var descendents = paths .filter(function (filename) { return filename.startsWith(prefix) && (filename.length > prefix.length); }); // Keep only the next path component var children = descendents .map(function (filename) { return filename.split('/')[depth]; }); // remove duplicates, listdir('a') with paths a/b/c and a/b/d and a/x // should only return ['b', 'x'], not 'b', 'b', x. return Array.from(new Set(children)); }

# Example working tree

Our imaginary user will create a `proj` directory, and start filling in some files.

A working tree designates the directory (and the subdirectories and files within) in which the user will normally view and edit the files. GIT has commands to save the state of the working tree (git commit), in order to be able to go back in time later on, and view older versions of the files. The command `git worktree` allows the user to create multiple working trees using the same local repository. This effectively allows the user to easily have two or more versions of the project side-by-side. GIT commands can be invoked in either copy. It is worth noting that the `.git/` directory exists only in the original working tree; while it is safe to remove other worktrees (followed by an invocation of `git worktree prune` from one of the remaining working tree to let GIT detect the deletion), the removal of the original working tree will discard ths `.git/` directory, and all versions of the project that have not been published elsewhere (usually via `git push`) will be lost.

mkdir('proj'); cd('proj'); write('proj/README', 'This is my Scheme project.\n'); mkdir('proj/src'); write('proj/src/main.scm', '(map (lambda (x) (+ x 1)) (list 1 2 3))\n');

# `git init` (creating `.git`)

The first thing to do is to initialize the GIT directory. For now, only the `.git` folder is needed, The rest of the function implementing `git init` will be written later.

function join_paths(a, b) { return (a == "") ? b : (a + "/" + b); } // git init (partial implementation: create the .git directory) function git_init_mkdir() { mkdir(join_paths(current_directory, '.git')); } git_init_mkdir();

Click on the eval button to see the files and directories that were created so far.

# `git hash-object` (storing a copy of a file in `.git`)

The most basic element of a GIT repository is an object. Objects have a type which can be `blob` (individual files), `tree` (directories), `commit` (pointers to a specific version of the root directory, with a description and some metadata) and `tag` (named pointers to a specific commit, with a description and some metadata). When a file is added to the git repostitory, a compressed copy is stored in GIT's database, in the `.git/objects/` folder. This copy is a blob object.

The compressed copy is given a unique filename, which is obtained by hashing the contents of the original file. Some filesystems have poor performance when a single directory contains a large number of files, and some filesystems have a limit on the number of files that a directory may contain. To circumvent these issues, the first two characters of the hash are used as the name of an intermediate directory: if a file's hash is `0a1bd…`, its compressed copy will be stored in `.git/objects/0a/1bd…`

This function creates a file that looks like this:

The objects stored in the GIT database are compressed with zlib (using the "deflate" compression method). The filesystem view shows the marker deflated: followed by the uncompressed data. Click on the (un)compressed data to toggle between this pretty-printed view and the raw compressed data.

When creating some `blob` objects, the result could be, for example:

This function reproduces faithfully the behaviour of (a subset of the options of) the `git hash-object` command which can be called on a real git command-line.

// git hash-object [-w] -t <type> [--stdin] [path] function hash_object(must_write, type, is_data, path_or_data) { var data = is_data ? path_or_data : read(join_paths(current_directory, path_or_data)); object_contents = type + ' ' + data.length + '\0' + data; var hash = sha1_from_bytes_returns_hex(object_contents); if (must_write) { mkdir(join_paths(current_directory, '.git/objects')); mkdir(join_paths(current_directory, '.git/objects/' + hash.substring(0,2))); var object_path = join_paths(current_directory, '.git/objects/' + hash.substring(0,2) + '/' + hash.substring(2)); // deflate() compresses using zlib write(object_path, deflate(object_contents)); } return hash; }

# Adding a file to the GIT database

So far, our GIT database does not know about any of the user's files. In order to add the contents of the `README` file in the database, we use `git hash-object -w -t blob README`, where `-w` tells GIT to write the object in its database, and `-t blob` indicates that we want to create a blob object, i.e. the contents of a file.

// git hash-object -w -t blob README hash_object(true, 'blob', false, 'README');

Click on the eval button to see the file that was created by this call.

You can notice that the database does not contain the name of the original file, only its content, stored under a unique identifier which is derived by hashing that content. Let's add the second user file to the database.

// git hash-object -w -t blob src/main.scm hash_object(true, 'blob', false, 'src/main.scm');

# `zlib` compression

GIT compresses objects with zlib. The `deflate()` function used in the script above comes from the pako 2.0.3 library. To view a zlib-compressed object in your *nix terminal, simply write this declaration in your shell.

    unzlib() {
      python -c \
        "import sys,zlib; \
         sys.stdout.buffer.write(zlib.decompress(open(sys.argv[1], 'rb').read()));" \
        "$1"
    }

You can then inspect git objects as follows, using `hexdump` to view the null bytes and other non-printable bytes.

    unzlib .git/objects/95/d318ae78cee607a77c453ead4db344fc1221b7 | hexdump -Cv

# Storing trees (list of hashed files and subtrees)

At this point GIT knows about the contents of both of the user's files, but it would be nice to also store the filenames. This is done by creating a tree object

A tree object can contain files (by associating the blob's hash to its name), or directories (by associating the hash of other subtrees to their name). The mode (`100644` for the file and `40000` for the folder) indicates the permissions, and is given in octal using the values used by *nix

In the contents of a tree, subdirectories (trees) are listed before files (blobs); within each group the entries are ordered alphabetically.

// base_directory is a string // filenames is a list of strings // subtrees is a list of {name, hash} objects. function store_tree(base_directory, filenames, subtrees) { function get_file_hash(filename) { var path = join_paths(base_directory, filename); var hash = hash_object(true, 'blob', false, path) return hex_to_raw_bytes(hash); } var blobs = filenames.map(function (filename) { return "100644 " + filename + "\0" + get_file_hash(filename); }); var trees = subtrees.map(function (subtree) { return "40000 " + subtree.name + "\0" + hex_to_raw_bytes(subtree.hash); }); // blobs are listed before subtrees var tree_content = blobs.join('') + trees.join(''); // cat tree_content | git hash-object -w -t tree --stdin return hash_object(true, 'tree', true, tree_content); }

This function needs a small utility to convert hashes encoded in hexadecimal to raw bytes.

function hex_to_raw_bytes(hex) { var hex = String(hex); var str = "" for (var i = 0; i < hex.length; i+=2) { str += String.fromCharCode(parseInt(hex.substring(i, i + 2), 16)); } return str; }

# Example use of `store_tree()`

The following code, once uncommented, stores into the GIT database the trees for `src` and for the root directory of the GIT project.

//hash_src_tree = store_tree("src", ["main.scm"], []); //hash_root_tree = store_tree("", ["README"], [{name:"src", hash:hash_src_tree}]);

The `store_tree()` function needs to be called for the contents of subdirectories first, and that result can be used to store the trees of upper directories. In the next section, we will write a function which takes a list of paths, constructs an internal representation of the hierarchy, and stores the corresponding trees bottom-up.

# Storing a tree from a list of paths

Making trees out of the subfolders one by one is cumbersome. The following utility function takes a list of paths, and builds a tree from those.

function paths_to_tree(paths) { // This temporary mutable object will store a hierarchy of // subfolders and files, e.g. // { // subfolders: { src: { subfolders: [], files: ['main.scm'] } } // files: ['README'] // } var hierarchy = { subfolders: {}, files: [] }; // This splits the input paths on occurrences of "/", // and inserts them into the "hierarchy" object. for (var i = 0; i < paths.length; i++) { var path_components = paths[i].split('/'); var h = hierarchy; for (var j = 0; j < path_components.length - 1; j++) { if (! h.subfolders.hasOwnProperty(path_components[j])) { h.subfolders[path_components[j]] = { subfolders: {}, files: [] }; } h = h.subfolders[path_components[j]]; } h.files[h.files.length] = path_components[path_components.length - 1]; } // This function takes the path to a directory, e.g. "src", // and a hierarchy object e.g. { subfolders: [], files: ['main.scm'] }. // It recursively stores the tree object for that directory into // GIT's database. var to_tree = function(base_directory, hierarchy) { var subtrees = []; for (var i in hierarchy.subfolders) { if (hierarchy.subfolders.hasOwnProperty(i)) { subtrees[subtrees.length] = { name: i, hash: to_tree(join_paths(base_directory, i), hierarchy.subfolders[i]) }; } } return store_tree(base_directory, hierarchy.files, subtrees); } // Store the trees for the whole hierarchy, starting from the // root directory of the GIT repository (which is represented // as an empty path "") return to_tree("", hierarchy); } // git add README src/main.scm paths_to_tree(["README", "src/main.scm"]);

# Storing a commit in the GIT database

Now that the GIT database contains the entire tree for the current version, a commit can be created. A commit contains

- the hash of the tree object,
- the hash of the previous commit, which is dubbed the `parent` (merge commits have two or more parents, and the initial commit has no parent commit),
- information about the author (the person who initially wrote the code),
- information about the committer (the person who adds the code to the GIT database, often the same person as the author, but it can be a different person e.g. when someone else rewrites the history with a rebase or applies a patch recieved by e-mail),
- and a description.

The author and committer information contain

- the person's name,
- the person's email,
- the *nix timestamp at which the version was authored or committed,
- and the timezone for that timestamp.

function store_commit(tree, parents, author, committer, message) { var commit_contents = ''; commit_contents += 'tree ' + tree + '\n'; for (var i = 0; i < parents.length; i++) { commit_contents += 'parent ' + parents[i] + '\n'; } commit_contents += 'author ' + author.name + ' <' + author.email + '> ' + format_date(author.date) + ' ' + format_timezone(author.timezoneMinutes) + '\n'; commit_contents += 'committer ' + committer.name + ' <' + committer.email + '> ' + format_date(committer.date) + ' ' + format_timezone(committer.timezoneMinutes) + '\n'; commit_contents += '\n'; commit_contents += '' + message + (message[message.length-1] == '\n' ? '' : '\n'); // cat commit_contents | git hash-object -w -t commit --stdin return hash_object(true, 'commit', true, commit_contents); } function format_date(d) { return Math.floor((+d) / 1000); } function left_pad(s, char, len) { while ((''+s).length < len) { s = '' + char + s; } return s; } function format_timezone(tm) { var h = Math.floor(Math.abs(+tm)/60); var m = Math.abs(+tm)%60; return (tm >= 0 ? '+' : '-') + left_pad(h, '0', 2) + left_pad(m, '0', 2); }

# Storing an example commit

It is now possible to store a commit in the database. This saves a copy of the tree along with some metadata about this version. The first commit has no parent, which is represented by passing the empty list.

initial_commit = store_commit( paths_to_tree(["README", "src/main.scm"]), [], {name:'Ada Lovelace', email:'ada@analyti.cal', date:new Date(1617120803000), timezoneMinutes: +60}, {name:'Ada Lovelace', email:'ada@analyti.cal', date:new Date(1617120803000), timezoneMinutes: +60}, 'Initial commit');

# resolving references

The next few subsections will introduce symbolic references and other references like branch names, the special name `HEAD` or tag names.

Most GIT commands accept as an argument a commit hash or a named reference to a hash. In order to implement those, we need to be able to resolve these references first.

Symbolic references are nothing more than regular files containing a hexadecimal hash or a string of the form `ref: path/to/other/symbolic/reference`. The `HEAD` reference is stored in `.git/HEAD`, and can point directly to a commit hash like 0123456789abcdef0123456789abcdef01234567, or can point to another symbolic reference, in which case the `.git/HEAD` file will contain e.g. `refs/heads/main`.

Branches are simple files stored in `.git/refs/heads/name-of-the-branch` and usually contain a hash like 0123456789abcdef0123456789abcdef01234567.

Tags are identical to branches in terms of representation. It seems that the only difference between tags and branches is the behaviour of `git checkout` and similar commands. These commands, as explained in the section about `git checkout` below, normally write `ref: refs/heads/name-of-branch` in `.git/HEAD` when checking out a branch, but write the hash of the target commit when checking out a tag or any other non-branch reference.

We'll start with a small utility to remove the newline at the end of a string. GIT references are usually files containing a hexadecimal hash, and following *NIX tradition these files finish with a newline byte. When reading these references, we need to get rid of the newline first.

// Removes the newline at the end of a string, if present. function trim_newline(s) { return (s[s.length-1] == '\n') ? s.substring(0, s.length-1) : s; }

# `git symbolic-ref`

`git symbolic-ref` is a low-level command which reads (and in the official GIT implementation also writes and updates) symbolic references given a path relative to `.git/`. For example, `git symbolic-ref HEAD` will read the contents of the file `.git/HEAD`, and if that file starts with `ref:` , the rest of the line will be returned.

function git_symbolic_ref(ref) { var ref_file = join_paths(current_directory, '.git/' + ref); if (exists(ref_file) && read(ref_file).startsWith('ref: ')) { var result = trim_newline(read(ref_file)).substring('ref: '.length); var recursive = git_symbolic_ref(result); return recursive || result; } else { return false; } }

The official implementation of GIT follows references recursively and returns the `path/to/file` of the last file of the form `ref: path/to/file`. In the example below, `git symbolic-ref HEAD` would

- read the file `proj/.git/HEAD` which contains `ref: refs/heads/main`,
- follow that indirection and read the file `proj/.git/refs/heads/main` which contains `ref: refs/heads/other`
- follow that indirection and read the file `proj/.git/refs/heads/other` which contains a hash
- return the last file path that contained a `ref:`, i.e. return the string `refs/heads/other`

# `git rev-parse`

`git rev-parse` is another low-level command. It takes a symbolic reference or other reference, and returns the hash. The difference with `git symbolic-ref` is that `symbolic-ref` follows indirections to other references, and returns the last named reference in the chain of indirections, whereas `rev-parse` goes one step further and returns the hash pointed to by the last named reference.

function git_rev_parse(ref) { var symbolic_ref_target = git_symbolic_ref(ref); if (symbolic_ref_target) { // symbolic ref like "ref: refs/heads/main" return git_rev_parse(symbolic_ref_target); } else if (/[0-9a-f]{40}/.test(ref)) { // hash like "0123456789abcdef0123456789abcdef01234567" return ref; } else if (ref == 'HEAD') { // user-friendly reference like "HEAD" return git_rev_parse(trim_newline(read(join_paths(current_directory, '.git/' + ref)))); } else if (ref.startsWith('refs/') && exists(join_paths(current_directory, '.git/' + ref))) { // user-friendly reference like "refs/heads/main" return git_rev_parse(trim_newline(read(join_paths(current_directory, '.git/' + ref)))); } else if (exists(join_paths(current_directory, '.git/refs/heads/' + ref))) { // user-friendly reference like "main" (a branch) return git_rev_parse(trim_newline(read(join_paths(current_directory, '.git/refs/heads/' + ref)))); } else if (exists(join_paths(current_directory, '.git/refs/tags/' + ref))) { // user-friendly reference like "v1.0" (a branch) return git_rev_parse(trim_newline(read(join_paths(current_directory, '.git/refs/tags/' + ref)))); } else { // unknown ref return false; } }

# `git branch`

A branch is a pointer to a commit, stored in a file in `.git/refs/heads/name_of_the_branch`. The branch can be overwritten with `git branch -f`. Also, as will be explained later, `git commit` can update the pointer of a branch.

function git_branch(branch_name, commit_ref, force) { var commit_hash = git_rev_parse(commit_ref); mkdir(join_paths(current_directory, '.git/refs')); mkdir(join_paths(current_directory, '.git/refs/heads')); if (!force && exists(join_paths(current_directory, '.git/refs/heads/' + branch_name))) { alert("branch already exists"); return false; } else { write(join_paths(current_directory, '.git/refs/heads/' + branch_name), commit_hash + '\n'); return true; } }

When we call `git branch main HEAD` or equivalently `git branch main 0123456789012345678901234567890123456789`, a file containing that hash is created in `.git/refs/heads/main`. This file acts as a pointer to the branch, and this pointer can be read e.g. by `git rev-parse`.

After creating the branch, we show how the file `.git/refs/heads/main` can be overwritten using `git branch -f`

// git branch main 0123456789012345678901234567890123456789 git_branch('main', initial_commit, false); // git branch -f main 0123456789012345678901234567890123456789 git_branch('main', initial_commit, true);

# `HEAD`

The `HEAD` indicates the "current" commit. It is set at first as part of the `git init` routine.

function git_init_head() { write(join_paths(current_directory, '.git/HEAD'), 'ref: refs/heads/main\n'); } git_init_head();

Usually, the `HEAD` is a symbolic reference to a branch, i.e. the file `.git/HEAD` contains `ref: refs/heads/name-of-branch`. When checking out a commit by specifying its hash directly, or when checking out a non-branch reference, the file `.git/HEAD` contains the hash of the commit instead.

The state in which `.git/HEAD` contains a commit hash is called "detached HEAD", and often sounds alarming to people who have not encountered this before. As we will see in the following sections, the only difference between detached HEAD and the normal state is that `git commit` updates the branch to point to the new commit in the normal mode of operation. When the `HEAD` is detached, it does not point to a specific branch, and `git commit` updates the HEAD directly instead, overwriting it with the new commit hash.

Since the HEAD is supposed to be a transient pointer, it is easy to lose track of the hash of an important commit. For example, the following sequence of operations:

    git checkout 0123456789abcdef0123456789abcdef01234567

    touch new_file
    git add new_file
    git commit -m 'This is a commit adding a new file'

    git checkout branch-of-feature-foobar

roughly means:

    HEAD = 0123456789abcdef0123456789abcdef01234567
    // overwrite the contents of the working tree with
    // the contents of commit 0123456789abcdef0123456789abcdef01234567
    checkout(0123456789abcdef0123456789abcdef01234567)

    // create commit with the new file:
    HEAD = commit(…)

    // Checkout other branch
    HEAD = git_rev_parse('branch-of-feature-foobar')

The hash of the new commit which is stored in HEAD on the second step is overwritten in the third step. In order to later retrieve that specific version with the precious new_file, one needs that hash. It would be possible to note down these hashes in a simple text file, but GIT offers a mechanism for that: branches. After all, branches are merely named text files containing the hash of the latest commit in that line of work.

The hash of a commit created with `git commit` does not only exist in the HEAD file (when in detached HEAD) or in the current branch file (normal mode). The official implementation of GIT keeps a log of the changes being made to the various references. `.git/logs/HEAD` contains a log of the hashes pointed to by `.git/HEAD`, and `.git/logs/refs/heads/main` contains a log of the hashes pointed to by `.git/refs/heads/main`, and the commands `git reflog` and `git reflog main` pretty-print these files.

There are a few more ways to find a lost commit hash, including a careful invocation of `git fsck` which checks that the files stored in `.git/` are not corrupted, and that no reference (to another reference or a commit, tree or blob) points to a non-existing file. The `git fsck --unreachable` option tells this command to print all object hashes which are not pointed to indirectly by any named reference (so-called unreachable objects, which are well-formed but are not indirectly linked to from a branch or other kind of named pointer).

The reflog can be used to recover a lost hash but handling hashes manually like this is somewhat error-prone, and most new users are not aware of those features; for this reason GIT commands tend to display a warning when switching to a detached HEAD state.

# git config

The official implementation of GIT stores the settings in various files (`.git/config` within a repository, `~/.gitconfig` in the user's home folder, and several other places).

var gitconfig = { user: { name: 'Ada Lovelace', email: 'ada@analyti.cal', } }; var $EDITOR = function() { return window.prompt('Commit message:'); }

These files use a `.ini` syntax with `key = value` lines grouped under some `[section]` headings. The configuration above could be stored in `~/.gitconfig` or `.git/config` using the following syntax:

    [user]
    name = Ada Lovelace
    email = ada@analyti.cal

The `$EDITOR` variable is a traditional *NIX environment variable, and could e.g. be declared with `EDITOR=nano` in `~/.profile` or `~/.bashrc`.

# `git commit`

The `git commit` command stores a commit (metadata and a pointer to a tree containing the files given on the command-line), and updates the `HEAD` or current branch to point to the new commit.

function git_commit(file_paths, message) { var now = new Date(); var timestamp = (+now)/1000; var timezoneMinutes = -(now.getTimezoneOffset()); var parent = git_rev_parse('HEAD'); var parents = parent ? [parent] : [] var new_commit_hash = store_commit( paths_to_tree(file_paths), parents, {name:gitconfig.user.name, email:gitconfig.user.email, date:now, timezoneMinutes:timezoneMinutes }, {name:gitconfig.user.name, email:gitconfig.user.email, date:now, timezoneMinutes:timezoneMinutes }, message || $EDITOR()); advance_head_or_branch(new_commit_hash); return new_commit_hash; }

If the `HEAD` points to a commit hash, then `git commit` updates the `HEAD` to point to the new commit. Otherwise, when the `HEAD` points to a branch, then the target branch (represented by a file named `.git/refs/heads/the_branch_name`) is updated.

function advance_head_or_branch(new_commit_hash) { var referenced_branch = git_symbolic_ref('HEAD'); if (referenced_branch) { // Update the target of the ref: write(join_paths(current_directory, '.git/' + referenced_branch), new_commit_hash + '\n'); } else { // Detached HEAD, update .git/HEAD directly. write(join_paths(current_directory, '.git/HEAD'), new_commit_hash + '\n'); } }

The official implementation of `git commit` makes use of the index. When a file is scheduled for the next commit using `git add path/to/file`, it is added to the index. The index is a representation of a collection of copies of files, which can efficiently be compared to the working tree. It uses a different representation, but its role is very similar to that of a tree object along with the subtrees and blob objects of individual files. When `git commit` is called without specifying any files, it creates a commit containing the version of the files stored in the index.

In this simplified implementation, we only support creating commits by specifying all the files that must be present in the commit (including unchanged files). This contrasts with the official implementation which would create a tree containing the files from the current HEAD, as well as the added, modified or deleted files specified by `git add` or specified directly on the `git commit` command-line.

write('proj/README', 'This is my Scheme project -- with updates!'); var second_commit = git_commit(['README', 'src/main.scm'], 'Some updates');

# `git tag`

Tags behave like branches, but are stored in `.git/refs/tags/the_tag_name` and a tag is not normally modified. Once created, it's supposed to always point to the same version.

GIT does offer a `git tag -f existing-tag new-hash` command, but using it should be a rare occurrence.

function git_tag(tag_name, commit_hash, force) { mkdir(join_paths(current_directory, '.git/refs')); mkdir(join_paths(current_directory, '.git/refs/tags')); if (!force && exists(join_paths(current_directory, '.git/refs/tags/' + tag_name))) { alert("tag already exists"); return false; } else { write(join_paths(current_directory, '.git/refs/tags/' + tag_name), commit_hash + '\n'); return true; } }

Intuitively, tags differ from branches in the following way: when checking out a branch, and a subsequent commit is made, the branch is updated to point to the new commit's hash. As we've seen in the implementation of `git commit`, the difference is actually in the contents of the `.git/HEAD` file. If it is a symbolic reference (generally a pointer to a branch), then the target of that reference is updated every time a new commit is created. If the `.git/HEAD` file contains the hash of a commit, then the `.git/HEAD` file itself is updated every time a new commit is created.

Therefore, tags and branches differ only in their usage and in the path under which they are stored (`.git/refs/heads/name-of-the-branch` vs. `.git/refs/tags/name-of-the-tag`). The file `.git/HEAD` is overwritten by `git commit` and `git checkout`. It is the latter command which will behave differently for tags and branches; `git checkout branch-name` turns the HEAD into a symbolic reference, whereas `git checkout tag-name` resolves the tag name to a commit hash, and writes that hash directly into `.git/HEAD`.

// git tag v1.0 0123456789012345678901234567890123456789 git_tag('v1.0', second_commit);

# `git checkout`

The `git checkout commit-hash-or-reference` command modifies the HEAD to point to the given commit, and modifies the working tree to match the contents of the tree object pointed to by that commit.

function git_checkout(tag_or_branch_or_hash) { if (exists(join_paths(current_directory, '.git/refs/heads/' + tag_or_branch_or_hash))) { // Normal (attached) HEAD, points to 'ref: refs/heads/the_branch_name' write(join_paths(current_directory, '.git/HEAD'), 'ref: refs/heads/' + tag_or_branch_or_hash + '\n'); } else { // Detached HEAD, points directly to commit hash write(join_paths(current_directory, '.git/HEAD'), git_rev_parse(tag_or_branch_or_hash) + '\n'); } checkout_files(git_rev_parse('HEAD')); }

# Checkout, branches and other references

The HEAD does not normally point to a tag. Although nothing actually prevents writing `ref: refs/tags/v1.0` into `.git/HEAD`, the GIT commands will not automatically do this. For example, `git checkout tag-or-branch-or-hash` will put a symbolic `ref:` in `.git/HEAD` only if the argument is a branch.

# Checking out files

In order to replace the contents of the working tree with those of the given commit, we recursively compare the subtrees, deleting from the working tree the files or directories that are not present in the tree object, and overwriting the others.

The official implementation of GIT will record the diff between the current working tree and the current commit, and will re-apply these changes on top of the freshly checked-out commit. The official `git checkout` command will print warnings and refuse to proceed when these changes cannot be re-applied without conflict, encouraging the user to create a commit containing this updated version or to stash the changes (effectively creating a temporary commit containing this version, pointed to by `.git/refs/stash`). Our simple implementation will always overwrite the changes.

function checkout_files(hash) { var commit = parse_commit(hash); checkout_tree(current_directory, commit.tree); } function checkout_tree(path_prefix, hash) { var entries = parse_tree(hash); var entries_names = entries.map(function (entry) { return entry.name; }); var working_directory_contents = listdir(path_prefix); for (var i = 0; i < working_directory_contents.length; i++) { if (entries_names.indexOf(working_directory_contents[i]) == -1 && working_directory_contents[i] != '.git') { // The file or directory exists in the working tree, but // not in the commit that is being checked out, remove it recursively. remove(join_paths(path_prefix, working_directory_contents[i]), true); } } for (var i = 0; i < entries.length; i++) { var o = parse_object(entries[i].hash); var entry_path = join_paths(path_prefix, entries[i].name); if (o.type == 'blob') { write(entry_path, o.contents); } else { checkout_tree(entry_path, entries[i].hash) } } }

# Assert

The `checkout_tree()` function needs to read the commit, tree and blob objects from the `.git/` folder. The following sections will introduce some parsers for these objects. The parsers will check that their input looks reasonably well-formed, using `assert()`.

function assert(boolean, text) { if (! boolean) { alert("GIT: assertion failed: " + text); throw new Error("GIT: assertion failed: " + text); } }

# Reading compressed objects

The GIT objects which are stored in `.git/objects` are compressed with `zlib`, and need to be uncompressed before they can be parsed. The actual implementation of GIT also stores some objects in packs. Packs contain a large number of objects, and used a form of delta compression, which effectively stores objects as the diff with another similar object, in order to optimize the disk space usage.

Our simplified implementation only deals with zlib-compressed objects, and cannot read from pack files. The function below extracts the type and length, which form the header present in all objects, and returns those along with the contents of the object.

function parse_object(hash) { var compressed = read(join_paths(current_directory, '.git/objects/' + hash.substring(0,2) + '/' + hash.substring(2))); var inflated = inflate(compressed); var split = inflated.match(/^([\s\S]*?) ([\s\S]*?)\0([\s\S]*)$/); assert(split, "ill-formed object"); var type = split[1]; var length = split[2]; var contents = split[3]; assert(contents.length == length, "object has incorrect length"); return { type: type, length: length, contents: contents }; }

# Parsing tree objects

We will start by parsing tree objects. As a reminder, a tree object has the following form:

After the object header, we have a mode, a filename, a null byte and a hash consisting of 20 bytes. The null byte cannot appear in the mode or filename, so we use this null + hash as a delimiter (the non-greedy match ensures the null byte terminator will not match with a `00` byte in the hash)

function parse_tree(hash) { var tree = parse_object(hash); var i = 0; var entries = []; while (i < tree.contents.length) { // skip to the null terminator var space_offset = tree.contents.indexOf(' ', i); var null_offset = tree.contents.indexOf('\0', i); // add 20 bytes for the hash that follows, and check the object isn't shorter than that if (space_offset < null_offset && null_offset + 20 < tree.contents.length) { var mode = tree.contents.substring(i, space_offset); var name = tree.contents.substring(space_offset+1, null_offset); var hash = to_hex(tree.contents.substring(null_offset + 1, null_offset + 1 + 20)); entries.push({ mode: mode, name: name, hash: hash }); } else { assert(false, 'invalid contents of tree object'); } i = null_offset + 20 + 1; } return entries; }

The `parse_tree` function above needs a small utility to convert hashes represented using raw bytes to a hexadecimal representation.

function to_hex(bin) { var bin = String(bin); var hex = ""; for (var i = 0; i < bin.length; i++) { hex += left_pad(bin.charCodeAt(i).toString(16), '0', 2); } return hex; }

# Parsing commit objects

The following function is fairly long, but only parses lines of the form `header-name header-value` (with some restrictions depending on the header), followed by a blenk line, and a free-form description.

function parse_commit(hash) { var commit = parse_object(hash); var lines = commit.contents.split('\n'); var tree = null; var parents = []; var author = null; var committer = null; var i; // A blank line separates the headers from the message. for (i = 0; i < lines.length && lines[i] != ''; i++) { var split = lines[i].match(/^(.*?) (.*)$/); assert(split, "ill-formed commit header: " + lines[i]); var header = split[1]; var value = split[2]; switch (header) { case 'tree': assert(!tree, 'duplicate tree header in commit'); assert(/^[0-9a-f]{40}$/.test(value), "invalid tree header in commit"); tree = value; break; case 'parent': assert(/^[0-9a-f]{40}$/.test(value), "invalid parent header in commit"); parents.push(value); break; case 'author': assert(!author, 'duplicate author header in commit'); author = parse_author(value, 'author'); break; case 'committer': assert(!committer, 'duplicate committer header in commit'); committer = parse_author(value, 'committer'); break; default: /* unknown field, skipping */ break; } } // The message is everything after the blank line. message = lines.splice(i+1).join('\n'); assert(tree, 'commit lacks tree header'); assert(author, 'commit lacks author header'); assert(committer, 'commit lacks committer header'); return { tree: tree, parents: parents, author: author, committer: committer, message: message }; }

# Parsing author and committer metadata

The author and committer metadata has the form `Name <email@domain.tld> timestamp +timezone`, for example `Ada Lovelace <ada@analyti.cal> 1617120803 +0100`

function parse_author(value, field) { var split = value.match(/^(.*?) <(.*?)> ([0-9]+) ([+-])([0-9][0-9])([0-9][0-9])$/); assert(split, 'ill-formed ' + field) var name = split[1]; var email = split[2]; var date = new Date(parseInt(split[3], 10) * 1000); var timezone_sign = (split[4] == '+' ? 1 : -1); var timezone_hours = parseInt(split[5], 10); var timezone_minutes = parseInt(split[6], 10); var timezone = timezone_sign * (timezone_hours * 60 + timezone_minutes); return { name: name, email: email, date: date, timzeone: timezone }; }

# Example checkout

Now that we can parse blobs objects, trees, and commits, it is now possible to checkout a given commit. The following operation will revert the working tree to the state that was copied in the initial commit.

git_checkout(initial_commit);

# `git init`

The `git init` command creates the `.git` directory and points `.git/HEAD` to the default branch (a file which does not exist yet, as this branch does not contain any commit at this point).

function git_init() { git_init_mkdir(); git_init_head(); }

# The index

When adding files with `git add`, GIT does not immediately create a commit object. Instead, it adds the files to the index, which uses a binary format with lots of metadata. The mock filesystem used here lacks most of these pieces of information, so the value `0` will be used for most fields. See this blog post for a more in-depth study of the index.

function raw_bytes(val, bytes) { return hex_to_raw_bytes(left_pad(val.toString(16), '0', bytes*2)); } function raw_bytes16(val) { return raw_bytes(val, 2); } function raw_bytes32(val) { return raw_bytes(val, 4); } function raw_bytes64(val) { return raw_bytes(val, 8); } function store_index(paths) { var magic = 'DIRC' // DIRectory Cache var version = raw_bytes32(2); var entries = raw_bytes32(paths.length); var header = magic + version + entries; index = header; for (var i = 0; i < paths.length; i++) { var ctime = raw_bytes64(0); var mtime = raw_bytes64(0); var device = raw_bytes32(0); var inode = raw_bytes32(0); // default permissions for files, in octal. var mode = raw_bytes32(0100644); var uid = raw_bytes32(0); var gid = raw_bytes32(0); var size = raw_bytes32(read(join_paths(current_directory, paths[i])).length); var hash = hex_to_raw_bytes(hash_object(true, 'blob', false, paths[i])); // for this simple index, the flags (the 4 higher bits) are 0. assert(paths[i].length < 0xfff) var flags_and_file_path_length = raw_bytes16(paths[i].length) var file_path = paths[i] + '\0'; entry = ctime + mtime + device + inode + mode + uid + gid + size + hash + flags_and_file_path_length + file_path; while (entry.length % 8 != 0) { // pad with null bytes to a multiple of 8 bytes (64-bits). entry += '\0'; } index += entry; } index += hex_to_raw_bytes(sha1_from_bytes_returns_hex(index)); write(join_paths(current_directory, '.git/index'), index) }

# Playground

The implementation is now sufficiently complete to create a small repository.

// Reset the filesystem to its initial state filesystem = {}; current_directory = ''; mkdir('proj'); cd('proj'); write('proj/README', 'This is my implementation of GIT.\n'); mkdir('proj/src'); write('proj/src/main.scm', "(define filesystem '())\n...\n"); git_init(); git_commit(['README', 'src/main.scm'], 'A well-understood initial commit.'); git_branch('dev', 'HEAD'); git_checkout('dev'); write('proj/src/main.scm', "(define filesystem '())\n(define current_directory \"\")\n"); git_commit(['README', 'src/main.scm'], 'What an update!'); git_checkout('main'); // update the cache of the working tree. Without this, // GIT finds an empty cache, and thinks all files are scheduled // for deletion, until "git add ." allows it to realize that // the working tree matches the contents of HEAD. store_index(['README', 'src/main.scm']);

By clicking on "Copy commands to recreate in *nix terminal.", it is possible to copy a series of `mkdir …` and `printf … > …` commands that, when executed, will recreate the virtual filesystem on a real system. The resulting folder is bit-compatible with the official `git log`, `git status`, `git checkout` etc. commands.

# Suggested exercises

The reader willing to improve their grasp of GIT's mental model, and reduce their reliance on a few learned recipies, might be interested in the following warm-up exercises:

# Inspection using `git cat-file`

Inspect an existing repository, starting with `cat .git/HEAD` and using `git cat-file -p some-hash` to pretty-print an object given its hash.

This will help sink in the points explained in this tutorial, and give a better understanding of the internals of GIT. This knowledge is helpful for day-to-day tasks, as the GIT commands usually perform simple changes to this internal representation. Understanding the representation better can demistify the semantics of the daily GIT commands. Furthermore, equipped with a better understanding of GIT's implementation, the dreamy reader will be tempted to compare this lack of intrinsic complexity with the apparent complexity, and be entitled to expect a better, less arcane user interface for a tool with such a simple implementation.

# Inspection of the files in `.git/`

Inspect a small existing repository, starting with `cat .git/HEAD` and using the `zlib` decompression tool from the `zlib` compression section. Larger repositories will make use of GIT packs, which are compressed archives containing a number of objects. GIT packs only matter as an optimization of the disk space used by large repositories, but other tools would be necessary to inspect those.

This should help understand the internal representation of GIT commits and branches, and should help having a instinctive idea of how the data store is modified by the various commands. This in turn could come in handy in case of apparent data loss (a lost stash or a checkout leaving an unreferenced commit on a detached HEAD), as this would help understand the work done by the various disaster-recovery one-liners that a quick panicked online search provides.

# Creating a repository from scratch

Run `git init new-directory` in a terminal, and create an initial single-file commit from scratch, using only `git hash-object`, `printf` and overwriting `.git/HEAD` and/or `.git/refs/heads/name-of-a-branch`. This will involve retracing the steps in this tutorial to create a blob object for the file, a tree object to be the directory containing just that file, and a commit object.

This exercise should help sink in the feeling that the internal representation of GIT commits is not very complex, and that many commands with convoluted options have very simple semantics. For example, `git reset --soft other-commit` is little more than writing that other commit's hash in `.git/refs/heads/name-of-the-current-branch` or `.git/HEAD`. Furthermore, equipped with an even better understanding of GIT's implementation, the dreamy reader will be tempted to compare this lack of intrinsic complexity with the sheer complexity of the systems they are working with on a day-to-day basis, and be entitled to expect better features in a versioning tool. After all, writing those few lines of code to reimplement the core of a versioning tool shouldn't take more than a couple of afternoons, surely our community can do better?

# Using only basic GIT commands

For a couple of weeks, only use the GIT commands `commit`, `diff`, `checkout`, `merge`, `cherry-pick`, `log`, `clone`, `fetch` and `push remote hash-of-commit:refs/heads/name-of-the-branch`. In particular, don't use `rebase` which is just a wrapper around a sequence of `cherry-pick` commands, don't use `pull` which is just a wrapper around `fetch` and `merge`, don't use `git push` as-is and instead explicitly give the name (origin) or URL of the remote, the hash of the commit to push, and the path that should be updated on the remote (`git push` while the `main` branch is checked out locally is equivalent to `git push origin HEAD:refs/heads/main`, where `HEAD` can be replaced by the actual hash of the commit).

This should help sink in the feeling that the internals of GIT are very simple (most of these commands are implemented in this tutorial, and the other ones are merely wrappers around enhanced versions of the *NIX commands `diff`, `patch` and `scp`), and that the rest of the GIT toolkit consists mostly of convenience wrappers to help seasoned users perform common tasks more efficiently.

# Understanding commits as copies of the root directory

Try not even using `git cherry-pick` or `git diff` a few times, instead make two copies the git directoy, check out the two different commits in each copy, and use the traditional *NIX commands `diff` and `patch`.

This should help sink in the feeling that commits are not diffs, but are actual (deduplicated) copies of the entire project directory. GIT commits are quite similar to the age-old manual versioning technique of copying the entire directory under a new name at each version, except that the metadata keeps track of which version was the previous one (or which versions were merged together to obtain the new one), and the deduplication avoids excessive space usage, as would be the case with `cp --reflink` on a filesystem supporting Copy-On-Write (COW).

# Branches as pointers: living without branches

For a couple of weeks, don't use any local branch, and stay in detached HEAD state all the time. When checking out a colleague's work, use `git fetch && git checkout origin/remote-branch`, and use the reflog and a text file outside of the repository to keep track of the latest commit in a current "branch" instead of relying on GIT.

This should help sink in the feeling that branches are not containers in which commits pile up, but are merely pointers to the latest commit that are automatically updated.

# Conclusion

This article shows that a large part of the core of GIT can be re-implemented in a few source lines of code* (copy all the code). * empty lines and single closing braces excluded, a few more in total.

- Some of the features which may appear mysterious at first sight (e.g. detached HEAD) should be clearer with the knowledge of how GIT works behind the scenes.
- Furthermore, branches are often associated with an intuition (containers into which commits are added) which does not match the implementation (mutable pointers to commits).
- Finally, it is tempting to think of commits as patches. While `darcs` tries to expose an interface which matches this intuition, it is clear that the implementation of GIT considers commits as copies of the entire repository, and are linked to the previous version solely by the `parent` metadata in the commit headers.

A few core commands like `git diff` and `git apply` are not described in this tutorial. They are little more than improved versions of the classical *nix commands `diff` and `patch`.

Most other commands provided by GIT are merely convenience wrappers around these commands. For example, `git cherry-pick` is simply a combination of `git diff` between the tree of a commit and the tree of its parent, followed by `git apply` to apply the patch and `git commit` to create a new commit whose diff is equivalent to the diff of the original commit. As an other example, the command `git rebase` performs as succession of `cherry-pick` operations.

By keeping in mind the internal model of GIT, it becomes easier to understand the usual commands and their quirks. By undersanding the design philosophy behind the implementation, the day-to-day usage can become, hopefully, less surprising.