Minimal version control built from scratch in Go to learn how Git works. No git libraries. Just SHA-256, zlib, and files under .vex/.
Learning project, not production.
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.
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.
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.
What works today:
vex init- create a repo with full.vex/layout, safe to re-runvex config- store user name and email, with interactive prompt or flagsvex add- stage files and directories, respects.vexignorevex commit- snapshot the index into a tree and create a commitvex branch- list, create, and delete branches (supportsa/bnesting)vex checkout- switch branches or detached commits by comparing treesvex status- staged, unstaged, and untracked, with branch awarenessvex diff/vex diff --staged- line-level LCS diffvex log- walk parent chain from HEAD
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 --helpThis 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 cleanBranch 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.txtInspect 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 breakdownGit 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.
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/yyyyTree (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.
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).
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- walkrefs/headsrecursively (supportsfeature/sub), sort, mark current with*vex branch <name>- create atHeadCommitHash()(current branch or detached)vex branch <name> <start>- create at another branch name or raw 64-char hashvex 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).
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.
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.
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.
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
Insertedlines and you get back the old file exactly. - Drop all
Deletedlines and you get back the new file exactly. - All
Unchangedlines 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.
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.
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.
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=2i=2 j=2:b != B,grid[2][1]=1 >= grid[1][2]=1->Inserted B,j=1i=2 j=1:b != a,grid[2][0]=0 < grid[1][1]=1->Deleted b,i=1i=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
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, andIsNew/IsDeletedflags.cmd/root.gorunDiffbuilds one per changed file after choosing which pair of trees to compare.diff.Statscounts+/-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 than2*contextsplit the file into separate hunks with@@ -old,count +new,count @@headers and a…separator. Small files stay one hunk with no header.tui.RenderFileprints the file header (diff -- vex <path> (+a -d)plus(new file)/(deleted)tags), theindex <short>..<short>line when both hashes are known (0000000for 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.RenderAllprepends the summary and concatenates files. One render path serves both pipes and terminals: lipgloss styles degrade to plain text when piped.tui.DiffModelis abubbles/viewportpager with a scroll-percent indicator. Keys:j/kor arrows scroll,space/bpage,g/Gtop/bottom,qquits. It opens on everyvex diffon a terminal (like git paging through less); piped output prints directly, andvex diff --no-pagerforces print.
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\nviasplitLines, calldiff.Diff. - Staged (
--staged): for each staged path fromstatus.Collect, readcommitted[path]hash vsindex[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.
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.
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.
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 == indexso not modified - You edit again:
disk[a.txt] = 999->Staged: a.txtandModified: a.txt
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.
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.
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.
internal/repo/branch.go:160 CreateBranch(name, startPoint):
- Validate
name, fail ifBranchRefPath(name)already exists. - 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 failcommit not found. - Otherwise ->
start point is not a branch or commit.
- Empty ->
MkdirAllfor nested dirs, writehash + "\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.
internal/repo/branch.go:225 DeleteBranch:
- Validate, then
CurrentBranch(). Only refuses if HEAD is attached and equals the target. Detached HEAD never blocks deletion. - Check file exists,
os.Remove, then walk up cleaning empty parent dirs underrefs/heads(so deletinga/bremovesa/if now empty) but never beyondrefs/headsitself.
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.
currentFiles = headFiles() // HeadCommitHash -> CommitTreeHash -> ListTree
targetFiles = filesForBranch(target) // read .vex/refs/heads/<name> -> tree -> ListTreeBoth 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.
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.
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.
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.
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.
.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 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.
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.
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/indexto reset) - Single parent commits only, history is linear
- No hooks execution, tags, or reflog beyond directory scaffolding
Note:
vex addwithout arguments opens an interactive picker on a TTY. In scripts and pipes it does nothing. Always pass explicit paths in CI.
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.
MIT. See LICENSE if present. Built to learn, free to fork.
