Skip to content

Latest commit

 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vex

Minimal version control built from scratch in Go to learn how Git works. No git libraries. Just SHA-256, zlib, and files under .vex/.

vex demo

Learning project, not production.

What it does

vex is a working version control system. You can init a repo, add files, commit snapshots, create branches, checkout between them, and inspect history with log, status, and diff. Every commit is immutable and content-addressed. Branches are just pointers. It looks and feels like Git because it reimplements Git's core ideas in about a thousand lines of readable Go.

Why it exists

Git is powerful but opaque. Its internals are hidden behind C, packfiles, and decades of optimization. vex was built to answer one question: how does version control actually work? By rebuilding the essential pieces from zero, every decision is visible. You can open .vex/objects and see blobs. You can read .vex/index and see staging. There is no magic.

Why not just use Git

Use Git for real work. vex is not a replacement.

Git vex
Purpose production VCS for millions of repos teaching tool to understand VCS
Implementation C, highly optimized, packfiles, delta compression Go, ~1k lines, one object per file, no packfiles
Storage packfiles, GC, reflog, remotes loose objects only, local branches only
Safety handles merges, conflicts, hooks, fsync no merge, no push/pull, no conflict resolution

If you want to ship software, use Git. If you want to understand why git add and git commit are separate, or what a tree object actually contains, read vex.

Features

What works today:

  • vex init - create a repo with full .vex/ layout, safe to re-run
  • vex config - store user name and email, with interactive prompt or flags
  • vex add - stage files and directories, respects .vexignore
  • vex commit - snapshot the index into a tree and create a commit
  • vex branch - list, create, and delete branches (supports a/b nesting)
  • vex checkout - switch branches or detached commits by comparing trees
  • vex status - staged, unstaged, and untracked, with branch awareness
  • vex diff / vex diff --staged - line-level LCS diff
  • vex log - walk parent chain from HEAD

Quick start

Build and install with the included script. It compiles vex and symlinks it to ~/.local/bin/vex.

git clone https://github.com/codetesla51/go-git.git
cd go-git
./install.sh        # or: go build -o vex
vex --help

This builds the binary from main.go through cmd/root.go:32 and links it so vex is on your PATH.

Create a repo and make your first commit. vex init scaffolds .vex/, vex config records who you are, vex add stages, and vex commit snapshots.

mkdir demo && cd demo
vex init
# Created .vex/ with HEAD -> refs/heads/main

vex config --name "Ada" --email "ada@example.com"
# writes .vex/config; omit flags for an interactive form on a TTY

echo "hello vex" > file.txt
vex add file.txt
vex commit -m "first commit"
# Committed successfully!

vex log
# commit <hash>
# Author: Ada <ada@example.com> ...
#     first commit

vex status
# On branch main
# nothing to commit, working tree clean

Branch and checkout. Branches are cheap pointers, checkout rebuilds only what changed.

vex branch feature              # create branch at current HEAD
vex branch feature main         # create at another branch or commit hash
vex branch                      # list, * marks current

echo "new feature" > feat.txt
vex add feat.txt
vex commit -m "add feature"

vex checkout main               # tree diff: deletes feat.txt, restores main
ls                              # feat.txt is gone, untracked files stay
vex checkout feature            # tree diff: recreates feat.txt

Inspect changes. vex diff is working dir vs index, vex diff --staged is index vs HEAD.

echo "edit" >> file.txt
vex diff                        # shows unstaged change
vex add file.txt
vex diff --staged               # now staged, shows index vs HEAD
vex status                      # staged / unstaged / untracked breakdown

The mental model

Git and vex share the same four layers. Understand these and everything else follows.

Working directory  -- files you edit on disk
       |
       |  vex add  (hash -> blob, record in index)
       v
Index (.vex/index) -- proposed next commit (path -> hash)
       |
       |  vex commit  (index -> tree -> commit -> move branch)
       v
Object store (.vex/objects) -- immutable, content-addressed history
       |
       |  vex branch / checkout  (pointer moves, working dir rebuilt)
       v
Refs (.vex/refs/heads/*, HEAD) -- human names for commit hashes

There are always three trees to compare: HEAD (last commit), index (proposed commit), and working directory (disk). status and diff are just different pairwise comparisons of these three.

Content addressing is the key idea. The hash of a file's content is its name. Same content, same hash, stored once. Change one byte and you get a new object. This is why vex can deduplicate and why history is immutable.

How it works

1. Objects - everything is hashed

Every object is stored as <type> <size>\0<content> compressed with zlib at .vex/objects/ab/cdef... where ab are the first two hex characters of the SHA-256 hash. This is defined in internal/object/hash.go:19.

There are three types:

Blob (internal/object/blob.go:20 WriteBlobWithMeta) - a file snapshot. Header is blob <size>\0, body is raw file bytes. Built with append, not Sprintf, so binary files pass through untouched. Returns size and mtime so the index does not need a second stat.

header := fmt.Sprintf("blob %d\x00", len(content))
blob := append([]byte(header), content...)
hash, _ := Hash(blob) // SHA-256 -> hex -> .vex/objects/xx/yyyy

Tree (internal/object/tree.go:16 WriteTree) - a directory snapshot. Entries are sorted by name and encoded as <mode> <name>\0<32 raw hash bytes>. Subtrees use mode 040000, files use 100644. Hashes are stored as 32 raw bytes, not 64 hex characters. Storing hex would double the size and break traversal.

Trees are built deepest-first. A parent cannot be hashed until its children exist, because the parent embeds child hashes. Directories are sorted by depth (strings.Count("/")) then length, so a/b/c is stored before a/b before a before root "". Root is synthesized if missing.

Commit (internal/object/commit.go:16 CommitTree) - a snapshot with metadata:

tree <hash>
parent <hash>          # omitted for the first commit
author Ada <ada@example.com> 1710000000 +0000
committer Ada <ada@example.com> 1710000000 +0000

commit message

Header is commit <size>\0, then SHA-256. Commit() at internal/object/commit.go:42 snapshots the index via WriteTree, reads the parent from repo.HeadCommitHash() (works for both branches and detached HEAD), creates the commit, and moves either the current branch ref or HEAD directly if detached.

Reading any object (internal/object/read.go:18 Read) reverses the process: decompress zlib, split at \0, return type and content. CommitTreeHash and ListTree walk the tree recursively to produce path -> hash maps.

2. The index - the staging area

The index at .vex/index is the bridge between working directory and history. It records what the next commit will contain: each path mapped to its blob hash, mode, size, and mtime.

Format is binary VEXI (internal/repo/index.go:25):

4 bytes  magic    "VEXI"
4 bytes  version  1 (big-endian uint32)
4 bytes  count    number of entries (big-endian uint32)
N * {
  4 bytes  mode      (e.g. 0100644 octal)
  4 bytes  mtime sec
  4 bytes  mtime nsec
  4 bytes  size
  32 bytes hash      (raw SHA-256)
  2 bytes  flags     (low 12 bits = path length)
  path + \0 + pad to 8-byte boundary
}
32 bytes checksum  SHA-256 of everything before it

Sorted by path for deterministic output. Written atomically: write to .vex/index.tmp, then rename (internal/repo/index.go:174). Loaded with checksum verification (internal/repo/index.go:54). Non-VEXI content is rejected as corrupt, there is no text fallback. An empty index is 44 bytes (12 header + 32 checksum).

internal/index/index.go:52 Add batches everything. One Load, walk all requested paths, hash each file in memory via WriteBlobWithMeta, collect entries, one Save. Directory walks respect ignore rules and skip .vex entirely. Explicit file arguments bypass ignore (vex add ignored.log stages even if ignored).

3. Branches - just pointers

A branch is a file at .vex/refs/heads/<name> containing one line: a commit hash (internal/repo/branch.go:32 BranchRefPath). That is all. Creating a branch copies a hash. Deleting one removes a file. No data is copied.

vex branch (internal/repo/branch.go:48) handles all cases:

  • vex branch - walk refs/heads recursively (supports feature/sub), sort, mark current with *
  • vex branch <name> - create at HeadCommitHash() (current branch or detached)
  • vex branch <name> <start> - create at another branch name or raw 64-char hash
  • vex branch -d <name> - refuses to delete the current branch, cleans empty parent dirs

HEAD at .vex/HEAD is either ref: refs/heads/main (attached) or a raw hash (detached). Helpers CurrentBranch, IsDetached, and HeadCommitHash (internal/repo/branch.go:14) abstract this so commit, log, status, and diff all follow HEAD correctly whether attached or detached.

Re-running vex init never clobbers HEAD, branch refs, or config (internal/repo/init.go:55 writeIfMissing).

4. Checkout - tree-to-tree diff

vex checkout does not wipe and restore. It compares the current tree against the target tree and touches only what changed (internal/checkout/checkout.go:19).

currentFiles = ListTree(HEAD)          # path -> hash
targetFiles  = ListTree(target branch) # path -> hash

toDelete = current - target            # in current, not in target
toUpdate = target - current + changed  # new or hash differs

Both lists are sorted for determinism. Deletes remove files and prune empty parent directories (but stop if a target file needs that directory). Updates read blobs via object.Read and write them to disk. The index is then rebuilt to exactly match the target tree, and HEAD is moved.

Untracked files (not in either tree) are never touched. This is verified: create untracked.txt, switch branches, it survives. Tracked files like b.txt or lib/x.txt correctly appear and disappear and their contents flip between versions.

Detached checkout (vex checkout <64-char-hash>) writes the hash directly to HEAD. vex checkout <branch> from detached state works because headFiles() resolves via HeadCommitHash regardless of mode.


Deep dive: diff, status, branch, checkout

This section teaches the four subsystems that make vex feel like Git. Each one is a small, isolated Go package you can read top to bottom.

Diff - longest common subsequence

vex diff shows you what changed line by line. vex diff (no flags) compares working directory vs index. vex diff --staged (alias --cached) compares index vs HEAD. Both are built on the same LCS engine in internal/diff/diff.go:46.

What diff actually does

A diff takes two lists of lines and produces a third list where each line is tagged. internal/diff/diff.go:14 defines three tags: Unchanged, Inserted, Deleted. The engine guarantees two properties, tested in internal/diff/diff_test.go:92:

  • Drop all Inserted lines and you get back the old file exactly.
  • Drop all Deleted lines and you get back the new file exactly.
  • All Unchanged lines appear in order in both files and form a common subsequence.

That is, the diff is lossless and the unchanged lines are the longest sequence that did not move.

Why LCS

The classic way to diff is to find the longest common subsequence. A subsequence keeps order but not necessarily contiguity. For lines, that models edits well: unchanged lines stay in order, inserted and deleted lines are everything else.

vex uses LCS deliberately. It costs O(n*m) time and memory (n = len(old), m = len(new)). That sounds expensive, but vex versions small text files. The code comment at internal/diff/diff.go:1 says it directly: LCS is fine for small files, a Myers implementation would win on large inputs but would obscure the algorithm. For teaching, clarity beats speed.

How the table is built

internal/diff/diff.go:50 builds a grid where grid[i][j] is the LCS length of old[:i] and new[:j].

grid := make([][]int, n+1)
for i := range grid { grid[i] = make([]int, m+1) }
for i := 1; i <= n; i++ {
    for j := 1; j <= m; j++ {
        if oldLines[i-1] == newLines[j-1] {
            grid[i][j] = grid[i-1][j-1] + 1
        } else if grid[i-1][j] > grid[i][j-1] {
            grid[i][j] = grid[i-1][j]
        } else {
            grid[i][j] = grid[i][j-1]
        }
    }
}

Three cases. If the lines match, the LCS grows by one from the diagonal. If they differ, the LCS is the better of dropping a line from old or dropping a line from new. Ties go to grid[i][j-1] (left), which biases toward insertions. That bias is what makes output stable.

Concrete example, old = [a, b, c], new = [a, B, c] from internal/diff/diff_test.go:42:

       ""  a   B   c
  "" [ 0,  0,  0,  0 ]
   a [ 0,  1,  1,  1 ]
   b [ 0,  1,  1,  1 ]
   c [ 0,  1,  1,  2 ]

grid[3][3] = 2, so the LCS is [a, c], length 2. Those are the unchanged lines.

How the diff is reconstructed

internal/diff/diff.go:70 walks back from the bottom right (n, m) to (0, 0), emitting one DiffLine per step, then reverses.

i, j := n, m
for i > 0 || j > 0 {
    switch {
    case i > 0 && j > 0 && oldLines[i-1] == newLines[j-1]:
        result = append(result, DiffLine{Unchanged, oldLines[i-1]})
        i--; j--
    case j > 0 && (i == 0 || grid[i][j-1] >= grid[i-1][j]):
        result = append(result, DiffLine{Inserted, newLines[j-1]})
        j--
    default:
        result = append(result, DiffLine{Deleted, oldLines[i-1]})
        i--
    }
}

Equal lines are kept as Unchanged. Otherwise the larger neighbor decides: left means the new line is exclusive (Inserted), above means the old line is exclusive (Deleted). When equal, >= prefers insertion, so the test at internal/diff/diff_test.go:60 for old=[a,b] new=[b,a] reliably produces [Deleted a, Unchanged b, Inserted a] rather than flipping.

For the example above the walk is:

  • i=3 j=3: c == c -> Unchanged c, i=2 j=2
  • i=2 j=2: b != B, grid[2][1]=1 >= grid[1][2]=1 -> Inserted B, j=1
  • i=2 j=1: b != a, grid[2][0]=0 < grid[1][1]=1 -> Deleted b, i=1
  • i=1 j=1: a == a -> Unchanged a

Reverse gives [Unchanged a, Deleted b, Inserted B, Unchanged c], which is exactly what vex diff prints as (plus line numbers and color on a terminal):

    1    1 a
-   2      b
+        2 B
    3    3 c

How the viewer renders it

The engine returns data only. All presentation lives in internal/tui/diff.go, keeping internal/diff UI-free:

  • diff.FileDiff (internal/diff/diff.go) bundles one file's path, old/new hashes, []DiffLine, and IsNew / IsDeleted flags. cmd/root.go runDiff builds one per changed file after choosing which pair of trees to compare.
  • diff.Stats counts + / - lines per file and for the summary header (1 file(s) changed +2 -1).
  • diff.Hunks(lines, 3) groups changes with 3 context lines, git-style. Unchanged runs longer than 2*context split the file into separate hunks with @@ -old,count +new,count @@ headers and a … separator. Small files stay one hunk with no header.
  • tui.RenderFile prints the file header (diff -- vex <path> (+a -d) plus (new file) / (deleted) tags), the index <short>..<short> line when both hashes are known (0000000 for the missing side), --- a/ / +++ b/, then each hunk with old/new line numbers. Unstaged diffs skip the index line because working-dir content has no stored hash.
  • tui.RenderAll prepends the summary and concatenates files. One render path serves both pipes and terminals: lipgloss styles degrade to plain text when piped.
  • tui.DiffModel is a bubbles/viewport pager with a scroll-percent indicator. Keys: j/k or arrows scroll, space/b page, g/G top/bottom, q quits. It opens on every vex diff on a terminal (like git paging through less); piped output prints directly, and vex diff --no-pager forces print.

How vex uses the engine

runDiff is not line-diffing raw files blindly. It reuses the three-tree model:

  • Unstaged (default): for each path, hash the working file vs index[path].Hash. If different, read the blob for the indexed hash and the working content, split both on \n via splitLines, call diff.Diff.
  • Staged (--staged): for each staged path from status.Collect, read committed[path] hash vs index[path].Hash, split, diff. Staged deletions (in HEAD, removed from index) are collected separately, sorted for determinism.

Filtering (vex diff file.txt) is a simple map[string]bool set in runDiff. The engine itself has no I/O, which is why diff_test.go:126 can fuzz it with go test -fuzz.

Note: diff is line based, not word or char based. Changing one character on a line shows the whole line as deleted and inserted. That matches Git's default.

Status - the three-tree comparison

vex status answers one question: where does each path differ across HEAD, index, and working directory? internal/status/status.go:47 Collect does it in one pass with no writes.

The three maps

committed := ListTree(HeadCommitHash)  // HEAD: path -> hash, empty if no commits
indexed   := LoadIndex()               // index: path -> hash
disk      := WalkDir(".") + HashBlob   // working dir: path -> hash (HashBlob is pure, no store)

disk is built with a single filepath.WalkDir at internal/status/status.go:82. It skips .vex and any IgnoredDir, skips Ignored files via internal/ignore/ignore.go:28, hashes everything else with object.HashBlob so status never creates objects. Keys are normalized with filepath.ToSlash.

The classification

For each indexed path, let indexHash, commitHash, diskHash be the hashes (or absence) from the three maps. internal/status/status.go:115 is the whole decision table:

for path, indexHash := range indexed {
    diskHash, onDisk := disk[path]
    commitHash, inCommit := committed[path]
    switch {
    case !onDisk:
        r.Deleted = append(r.Deleted, path)                          // in index, not on disk
    case !inCommit || indexHash != commitHash:
        r.Staged = append(r.Staged, StagedFile{Path: path, New: !inCommit}) // index differs from HEAD
        if diskHash != indexHash {
            r.Modified = append(r.Modified, path)                    // and working dir differs from index
        }
    case diskHash != indexHash:
        r.Modified = append(r.Modified, path)                        // working dir differs from index
    }
}
for path := range disk {
    if _, ok := indexed[path]; !ok {
        r.Untracked = append(r.Untracked, path)                      // on disk, not in index
    }
}

A path can be both staged and modified. Edit a file, vex add it, edit it again, status shows it in both sections, like Git. Deleted paths (in index but not on disk) are separate from modified. Untracked paths are everything on disk that the index has never seen.

All four lists are sorted (sort.Strings, sort.Slice for staged) so output is deterministic.

The branch name comes from internal/status/status.go:145 branch(). It reads .vex/HEAD directly. If HEAD is ref: refs/heads/main it returns main. If HEAD is a raw hash (detached) it returns HEAD detached at <7 chars>. If HEAD is empty (fresh repo) it returns main. No error, just a string for display. HasCommits is set from whether HeadCommitHash was non-empty, so status can print No commits yet on a fresh repo.

Rendering is internal/status/status.go:168 Fprint (plain, no colors, so go test sees exact bytes) and internal/tui/status.go:12 FprintStatus (styled for terminals, lipgloss degrades to plain when piped).

Example trace. You edit a.txt after committing:

  • committed[a.txt] = abc, indexed[a.txt] = abc, disk[a.txt] = xyz -> disk != index -> Modified: a.txt
  • You vex add a.txt: indexed[a.txt] = xyz -> index != commit -> Staged: a.txt, disk == index so not modified
  • You edit again: disk[a.txt] = 999 -> Staged: a.txt and Modified: a.txt

Branch - pointers in the filesystem

A branch is the simplest possible thing that can work. It is a file whose content is a commit hash. internal/repo/branch.go:75 BranchRefPath is filepath.Join(RefsHeadsDir(), filepath.FromSlash(name)), so feature/sub becomes .vex/refs/heads/feature/sub.

Naming rules

internal/repo/branch.go:82 ValidateBranchName enforces a minimal subset of Git's rules: not empty, not HEAD, no leading -, no leading or trailing /, no //, no .., no ~^:?*[]\ or control chars, no .lock suffix, no trailing . or /.. This prevents directory traversal and clashes with Git's ref format while staying readable.

Listing

internal/repo/branch.go:121 ListBranches reads HEAD directly to find current (empty string if detached), then filepath.WalkDir over refs/heads recursively so nested branches are found. Relative paths are converted with filepath.ToSlash, collected, sorted. Missing refs/heads is treated as empty, not an error. cmd/root.go prints * next to current.

Creating

internal/repo/branch.go:160 CreateBranch(name, startPoint):

  1. Validate name, fail if BranchRefPath(name) already exists.
  2. Resolve startPoint:
    • Empty -> HeadCommitHash() (current HEAD, branch or detached). Fresh repo with no commits creates an empty branch file (empty tree).
    • Branch name that exists on disk -> read its hash.
    • 64-char hex hash -> use directly, but verify .vex/objects/ab/cdef... exists or fail commit not found.
    • Otherwise -> start point is not a branch or commit.
  3. MkdirAll for nested dirs, write hash + "\n" or empty string if no commits yet.

So vex branch feature, vex branch feature main, and vex branch feature <hash> all go through the same path.

Deleting

internal/repo/branch.go:225 DeleteBranch:

  1. Validate, then CurrentBranch(). Only refuses if HEAD is attached and equals the target. Detached HEAD never blocks deletion.
  2. Check file exists, os.Remove, then walk up cleaning empty parent dirs under refs/heads (so deleting a/b removes a/ if now empty) but never beyond refs/heads itself.

Checkout - rebuilding the working directory

vex checkout is the most involved command because it must not lose untracked work. It never wipes. It computes a minimal edit between two trees and applies it file by file. internal/checkout/checkout.go:19 Branch and internal/checkout/checkout.go:48 Commit both funnel into switchTrees.

Resolving trees

currentFiles = headFiles()            // HeadCommitHash -> CommitTreeHash -> ListTree
targetFiles  = filesForBranch(target)  // read .vex/refs/heads/<name> -> tree -> ListTree

Both return map[string]string of path -> hash. Empty branch or empty HEAD (no commits yet) returns an empty map. That naturally models creation of a repo: checking out an empty branch deletes everything tracked.

Branch validates the name, checks the target ref exists, rejects already on branch if CurrentBranch() == target. Commit validates 64-char hex and that the object file exists. Detached branch switches are rejected earlier, so Commit is the only way to get detached.

Computing the edit

internal/checkout/checkout.go:79 switchTrees:

for path := range currentFiles {
    if _, ok := targetFiles[path]; !ok {
        toDelete = append(toDelete, path)  // in current, not in target
    }
}
for path, targetHash := range targetFiles {
    if curHash, ok := currentFiles[path]; !ok || curHash != targetHash {
        toUpdate = append(toUpdate, path)  // new path or same path with different hash
    }
}
sort.Strings(toDelete)
sort.Strings(toUpdate)

Same content at same path with same hash is not touched, even if the file was manually edited to the same content. Only hash matters, which is how content addressing makes checkout safe.

Applying deletes

for _, path := range toDelete {
    os.Remove(path) // ignore ErrNotExist
    dir := filepath.Dir(path)
    for dir != "." && dir != "/" {
        hasTargetChild := false
        for p := range targetFiles {
            if strings.HasPrefix(p, dir+"/") { hasTargetChild = true; break }
        }
        if hasTargetChild { break }
        entries, _ := os.ReadDir(dir)
        if len(entries) != 0 { break }
        os.Remove(dir)
        dir = filepath.Dir(dir)
    }
}

Delete the file, then try to prune empty parents. But never prune a directory that the target tree needs (has a file under it) and never prune a non-empty directory (has untracked files or target files). So switching from main with lib/x.txt and b.txt to feature without them deletes both files and removes lib/ if now empty. Untracked notes.txt sitting in lib/ keeps lib/ alive, and notes.txt itself is never deleted because it was never in currentFiles.

Applying updates

for _, path := range toUpdate {
    typ, content, _ := object.Read(targetFiles[path])
    // typ must be "blob"
    os.MkdirAll(filepath.Dir(path), 0755)
    os.WriteFile(path, content, 0644)
}

Create parent dirs, write blob content. Content comes from the object store, not from the index, so checkout works even with a corrupt or missing index. Mode is 0644 for now because ListTree returns only hashes, not modes. Rebuilding a file with the same content naturally deduplicates via the same blob hash.

Rebuilding the index

newIndex := make(map[string]IndexEntry, len(targetFiles))
for path, hash := range targetFiles {
    info, _ := os.Stat(path)
    newIndex[path] = IndexEntry{Mode: "100644", Hash: hash, Path: path,
        Size: uint32(info.Size()), ModSec: ..., ModNsec: ...}
}
repo.SaveIndex(newIndex)
os.WriteFile(repo.HeadPath(), []byte(newHead), 0644)

The index is discarded and recreated to exactly match the target tree. Sizes and mtimes are taken from the files just written, so the next vex status will see disk == index. Only after the working dir and index match does HEAD move (ref: refs/heads/<branch>\n or <hash>\n). If any write fails, HEAD does not move, so the repo is not left half-switched.

Detached checkout (vex checkout <hash>) writes the hash directly. vex checkout <branch> from detached works because headFiles() resolves via HeadCommitHash regardless of mode. vex checkout from an empty branch to a populated one creates all files. vex checkout between branches that share most files touches only the differing files, which is why vex checkout main after editing one file on feature is instant for large repos.

Ignore

.vexignore at the repo root and .vex/info/exclude (same syntax) are respected on every directory walk (internal/ignore/ignore.go:28).

# .vexignore
*.log
build/
!important.log
a/**/b.txt

Rules: # comments and empty lines are skipped, ! negates, trailing / matches directories only, no slash matches basename at any depth (*.log matches a/b.log), with slash matches full path with ** doublestar support (a/**/b.txt). .vex is always ignored. Explicit vex add bypasses ignore.

.vex layout

vex init (internal/repo/init.go:21 InitAt) creates:

.vex/
  HEAD                # ref: refs/heads/main  or  <hash> when detached
  config              # [user] name = ... / email = ...
  index               # binary VEXI staging area
  objects/ab/cdef...  # zlib-compressed blobs, trees, commits
  refs/heads/<branch> # one line = tip commit hash
  refs/tags/          # reserved
  logs/refs/heads/    # reserved
  info/exclude        # per-repo ignore patterns
  hooks/              # reserved

internal/repo/paths.go:7 defines VexDir = ".vex" as the single source of truth.

Project structure

Core has zero UI dependencies. go list -deps on any core package shows no bubbletea, bubbles, lipgloss, or go-isatty. UI lives entirely in internal/tui, internal/ui, and cmd (the composition root).

cmd/root.go              # cobra CLI, wires core + tui + ui (no tea directly)
internal/repo/           # init, paths, branch, index format (VEXI) — core
internal/object/         # hash, blob, tree, commit, read — core
internal/index/          # Load/Save/Add/Remove for .vex/index — core
internal/ignore/         # .vexignore matching with doublestar — core
internal/diff/           # LCS engine + FileDiff/Stats/Hunks (no I/O) — core
internal/status/         # Collect + plain Fprint — core, no ui import
internal/checkout/       # tree-to-tree checkout — core
internal/history/        # Commits + plain Fprint — core, no viewport
internal/config/         # Read/Write only — core, no prompts
internal/tui/            # all Bubble Tea: add, commit, log, diff viewer,
                         # config form, RunAdd/RunCommit/RunLog/Pager,
                         # RenderFile/RenderAll, styled status printer
                         # — imports core + ui
internal/ui/             # lipgloss styles + TTY detection only, imports nothing internal
main.go                  # entry: cmd.Execute()
install.sh               # build + symlink to ~/.local/bin/vex

Read in this order to follow a commit end-to-end: cmd/root.go -> internal/index/index.go:52 Add -> internal/object/tree.go:16 WriteTree -> internal/object/commit.go:42 Commit -> internal/checkout/checkout.go:79 switchTrees -> internal/status/status.go:47 Collect.

Limitations

Warning: vex is a learning tool. Do not use it for real projects.

  • No merge, rebase, or conflict resolution
  • No remotes, push, pull, or fetch
  • No packfiles or garbage collection, every object is a loose file
  • Binary index only, old plain-text indexes are rejected (delete .vex/index to reset)
  • Single parent commits only, history is linear
  • No hooks execution, tags, or reflog beyond directory scaffolding

Note: vex add without arguments opens an interactive picker on a TTY. In scripts and pipes it does nothing. Always pass explicit paths in CI.

Stack

Go 1.25, cobra for CLI. Core VCS logic is stdlib only with no UI imports. bubbletea / bubbles / lipgloss / go-isatty live only in internal/tui, internal/ui, and cmd.

License

MIT. See LICENSE if present. Built to learn, free to fork.

About

A Git implementation built from first principles in Go to understand how distributed version control actually works.

Resources

Stars

13 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages