git-board v2: the design of a rewrite¶
- Status: accepted (ADR 0021, 2026-11-05), and built: milestones M0 to M7 (section 5). v2 is git-board since the switch, v0.12.0.
- Asked: "start on a complete rewrite from scratch knowing what you learned this night, with elegance/modularity and speed in mind".
- Builds on every ADR, most of all ADR 0002 (datastores), ADR 0004 (operation logs), ADR 0009 (the index), ADR 0018 (what the core is for) and ADR 0020 (the layers), and on Performance.
The vision does not change: you own your issues. They live in your repository, or a database or folder you choose, and no forge is needed to read or write them. The rewrite is about the code: it rewrites the code and keeps the data. A board written by v1 opens in v2, and the other way round, for as long as both exist.
Reading time: about 20 minutes.
1. What stays¶
The data: one format, every datastore¶
v2 reads and writes the boards v1 wrote, byte for byte. A user never migrates anything. These documents are the contract; where the code and the document disagree, the document wins, and the code that wrote the data decides what the document should have said.
| Contract | Defined in | Holds |
|---|---|---|
| Entities, operations, ids, the four kinds' operations and states | Data model | issue, person, sync, release; every kind and data field; ids; how a state folds |
| The git format | Storage | refs/board/<kind>/<id>, commits, packs, the clock, the order (clock, then pack hash), merges, the compare-and-swap, fetch and push refspecs, refs/board-remotes/ |
| Query semantics | Queries and search | what a filter, free text, a prefix, Labels and a change event answer. Not the index's tables: they are disposable |
| Sync | Sync, Forges | links, last seen, posted, refused, runs; the markers in forge texts; the journal and sync.lock |
| Project numbers | ADR 0016 | refs/board/seq, its numbers file (internal/store/numbers.go, ParseCounter) |
| Check results | ADR 0001 | refs/notes/ci/*, signed lines |
| Tombstones | ADR 0019 | delete hides, history keeps |
| PostgreSQL | core/pgstore/pgstore.go, the schema constant |
git_board.meta (version 1), entities, packs, counter |
| Markdown folder | Where the board is kept, core/mdstore, internal/store/markdown.go and mdimport.go |
<dir>/.board/<kind>/<id>.jsonl, one pack a line; .gitattributes (union, numbers binary); board.lock; the rendered NNN-slug.md |
| Settings | Configuration; board.me, board.datastore, GIT_BOARD_DATASTORE |
.git-board.toml, each table; the clone's git config |
Three of these were only code: the PostgreSQL tables, the Markdown folder and the counter's file. Milestone M0 wrote them into Other datastores and the counter before any v2 code reads them, so the contract is a document and not a reading of v1. The golden boards and v1's outputs on them are in v2: the golden boards and make compat.
Two local files would fight if v1 and v2 shared them, so v2 does not:
- The index. v1 deletes
.git/board/index.dbwhen its version isn't its own. v2 keeps its own,.git/board/v2/view.db, and on PostgreSQL its own schema,git_board_view_v1. Both are disposable. - The sync lock. v2 takes the same
.git/board/sync.lockand reads the same journal, so v1 and v2 never sync one board at once.
The surfaces: what users and the web app call¶
| Contract | Defined in |
|---|---|
| Commands, flags, arguments | Issues from the command line, and cmd/git-board for what the guide leaves out |
| JSON field names | JSON output; the tags in core/model/*.go, core/release, core/cycle |
| Exit codes | Exit codes: 0 to 5 |
| The HTTP API | the routes in internal/server/server.go (about 120), and their types in web/src/api/board.ts (1,741 lines), the only typed record of them |
| The API's error codes | writeCoded in internal/server: {"code": "changed"}, "syncing", "missing", "experimental" and 21 more, which web/src/apierror.ts reads |
| The change events | GET /api/events (internal/server/events.go) |
The HTTP API has no document. M0 records it: a golden transcript per
route, made by v1 on a fixed board. v2 must answer each one the same.
The web app (web/) is not rewritten: it is the HTTP API's first
client, and its e2e tests run against v2 unchanged.
What is not a contract: --help texts, log lines, the TUI's keys, the
index's tables, the Go API of core/ (no one outside imports it yet).
2. What we learned¶
Each point is a mistake in the code's shape, not in the data. Each has its evidence.
2.1 Fold on demand, then caches, then caches of caches¶
v1 folds an entity from its packs when it is asked for, and every speed
fix since added a cache beside it. internal/store.Backend holds four:
issues and persons folded per head (mu), the last listing and its heads
(listMu, 92abf37), the forges' links per sync head (recMu), and the
index's states, which are a fourth copy of each issue (1708f4e). Each is
correct, and keyed by heads (performance.md: "none trusts a time"); none
knows the others. An issue has four shapes: issue.Issue (the fold),
index.State (its JSON), index.Summary (the index's row) and
model.Issue (what surfaces show, folded again in internal/store).
v2: one read model, the view, kept up to date incrementally. It is the only place a state is kept. A kind has two shapes, its state and its row; the surfaces' JSON is made from them in one place.
2.2 A facade that grew into the application¶
internal/store is 7,914 lines and 5,776 of tests. Backend has 125
exported methods; 113 take no context.Context. Its fields include
function hooks (OnWrite, Tokens, Approve) and two atomic pointers
the core needs from the config (SetFlatKinds, SetHeld). It was
gitbugio, the git-bug wrapper (its package comment says so), and kept
growing after git-bug left (ADR 0003). The core doubled its API to add
contexts late: issue.Load and LoadContext, List and ListContext.
v2: no facade. Domains are services with small APIs, every call takes a context, and hooks are observers registered on the board.
2.3 The server is a second implementation¶
internal/server is 9,275 lines and 9,216 of tests, about 120 routes.
Domain rules live in its handlers. handleComment
(internal/server/issues.go) checks a review thread's anchor, refuses a
reply to anything but a thread's first comment, and marks a
```suggestion fence, all in the HTTP layer: the CLI has none of
it. Errors map twice, by hand: writeIssueError sends 404 for not found
and 400 for everything else, an internal error included; the CLI's
exitCode (cmd/git-board/main.go) has its own switch, down to a string
prefix, "unknown command". One writeMu serializes every write, and a
forge sync holds it for minutes, so the page answers 409 syncing to
every edit meanwhile (beginWrite).
v2: one application API. The CLI, the TUI and HTTP are adapters that decode a request, call it, and encode the answer. One error type carries the exit code and the HTTP status. Writes don't wait for a sync: the compare-and-swap already keeps them safe.
2.4 Kinds as special cases¶
The kinds are a global list (datastore.Kinds). The index imports
core/issue and indexes issues and persons only (ADR 0009). Until
tonight a release's fold lived in internal/store/releases.go, next to
the web server (b5a488d). The syncer syncs issues only, and releases
reach GitHub through gh in internal/ship/binaries.go (ADR 0020, Left
open). People and the forges' links are folded again by every command
(performance.md, What's left).
v2: a kind is a value registered on the kernel. The view, the sync engine, fetch, push and change events work on any registered kind.
2.5 One config struct that knows every domain¶
internal/config is 3,347 lines and 2,984 of tests. Its raw struct
decodes every table, so it imports core/cycle, core/fields,
core/proof and core/release. Writing a table back is a hand-written
text editor per table: setsection.go, 751 lines (SetFieldsText,
SetCycleText, SetReleaseText, …). A broken [cycle] broke every
command, ls included, until ADR 0018 special-cased experimental tables.
ADR 0020 left the [release] and [cycle] checks in internal/config.
v2: each domain declares its table: its type, its defaults, its check. The file is read once; a broken table is its own domain's problem. One generic writer replaces a table's text and leaves the rest of the file, comments included, as it was.
2.6 Concurrency found by luck¶
TestGetIssueCacheConcurrent failed 10 times in 400 for months
(fbda0b3, "the same before tonight's perf work"). go-billy's ReadDir
lstat'ed each ref while git update-ref renamed <ref>.lock over it;
the walk took the error for an empty directory, Heads listed no issue,
and the index dropped them all. The same commit found the loose refs
must be read before packed-refs, as git does. The Markdown store could
read a pack half written (12cfe5d, once in 3,000 appends). The datastore
suite tested "read while written" only after (36b431e).
v2: the datastore contract says what a listing guarantees under
concurrent writers, and the conformance suite proves it on every
datastore, with real git update-ref and git pack-refs racing the
reader. And the view never drops an entity because a listing missed it:
it asks for that entity before dropping it.
2.7 Speed was never budgeted¶
Tonight's profile (performance.md) found, on 20,000 issues, ls at 77 s:
packed-refsread line by line for each ref looked up: n² (1136ab6);- the pack file opened 75,000 times for 5,000 issues (85dcb43);
- every
lsread and folded every issue (1708f4e: 3.2 s to 0.4 s on 5,000); - the labels table scanned once per issue on a first build (4dba672);
- a regexp per id, the free text cut again for each word (07b79d0, 829c8d5);
- one
git update-refprocess per ref moved, so a fetch that merges 1,000 issues starts 1,000 processes (core/gitstore/store.go:305).
After the fixes, ls is 1.5 s and a first ls 16 s. On a board of
2,000 issues (cmd/boardgen), v1's first ls takes 1.26 s where git
cat-file reads every object of the board in 0.17 s. The benchmarks and
the synthetic board came last (813006c, tonight).
v2: budgets in section 4, benchmarks from the first milestone, and a CI job that fails when a budget is broken.
2.8 Layers drawn after the fact¶
ADR 0020's four steps moved about ten packages, in 26 commits
of one evening. It names the cost:
core/model instead of core/board because "a package board importing
a package board needs an alias"; internal/branches and
internal/markdown stay outside because moving them would drag a table
along. The guard (core/layers_test.go) came with step 1. The layers are
right; drawing them on 55,000 lines is what is slow.
v2: the layers and the import guard exist before the first line of domain code.
What was right, and stays¶
The format (operation logs, packs hashed as git blobs, the clock order);
cutting the board at the pack (ADR 0002), so git, PostgreSQL, a folder
and the forges alone are one interface; fields as labels (ADR 0007); a
disposable index (ADR 0009); hermetic tests on real repositories
(ADR 0014); one static binary (ADR 0012); the forge adapters and their
conformance suite (core/forge/conformance).
3. The architecture¶
Packages¶
v2 grew in next/ (section 5), and is the module's root since the switch
(M7): the paths below are the root's. Each layer imports only the layers
above it in this table; a test enforces it from day one (layers_test.go).
| Package | Purpose |
|---|---|
kernel |
entities, operations, ids, the order, kinds, the board: open, read, write, watch |
kernel/pack |
the format's bytes: encode a pack, hash it as a git blob, sort by (clock, hash) |
store |
the Datastore interface, its capabilities and errors |
store/git, pg, md, forgecopy, mem |
the datastores; mem for tests |
store/storetest |
the conformance suite every datastore runs |
view |
the materialized state: rows, terms, text, heads; sqlite and PostgreSQL |
conf |
.git-board.toml: tables registered by domains, read once, written table by table |
forge, forge/github, forge/gitlab, forge/fake, forge/forgetest |
the forges' adapters and their conformance suite |
sync |
the sync engine, generic over kinds: plan, pull, push, records, journal, lock |
domain/people |
the person kind; who owns an account; me |
domain/issues |
the issue kind; fields (ADR 0007), filters, search, the inbox, numbers |
domain/releases |
the release kind; versions, overlays, proof (ADR 0001) |
domain/cycle |
an issue's steps; experimental (ADR 0018) |
app |
opens a board, wires the domains, the one API every surface calls, errors |
surface/cli, http, tui |
adapters over app; http serves the web app |
tools/… |
the GitHub Project bridge, the release runner: apps over app (agents left: section 5) |
cmd/git-board |
main: the binary |
domain/* never imports another domain upward: releases read issues,
the cycle reads both, issues read people. A domain imports kernel,
conf, sync and forge; never view or store directly.
The kernel's API¶
package kernel
// ID names an entity: 40 lowercase hex digits.
type ID string
// OpID names an operation: "<pack hash>:<index>".
type OpID string
// Version is a datastore's opaque version of an entity: a commit in git.
type Version string
// Op is one operation, as docs/spec/data-model.md writes it.
type Op struct {
Kind string `json:"kind"`
Author ID `json:"author,omitempty"`
Time int64 `json:"time"`
Origin *Origin `json:"origin,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// Entry is an operation in its entity's order.
type Entry struct {
ID OpID
Clock uint64
Op
}
// Spec defines a kind. S is its state, R its row: what lists show
// and filter on.
type Spec[S, R any] struct {
Name string // the ref segment: refs/board/<Name>/<id>
Given bool // ids are given (sync: SHA-1 of the forge key), not the first pack's hash
Rev int // bumped when Apply or Index change: the view rebuilds this kind
New func(id ID) S
// Apply applies one operation. An unknown kind is an error: the
// kernel never drops an operation (data-model.md, Operations).
Apply func(s *S, e Entry) error
// Index is what the view keeps of a state.
Index func(s *S) Doc[R]
// Row encodes and decodes R; nil is JSON. Issues bring a fast one.
Row Codec[R]
}
// Doc is a state as the view keeps it.
type Doc[R any] struct {
Row R
Terms []string // exact terms, ns:value (ADR 0007): status:open, bug, author:<id>, account:github.com/42
Texts []Text // free text by field: title, body, comments
Created int64
Updated int64
Hidden bool // a tombstone (ADR 0019): kept, never listed
}
type Text struct{ Field, Body string }
// K is a registered kind, typed.
type K[S, R any] struct{ /* … */ }
// Define makes a kind. A domain calls it once, in a package variable,
// and hands it to Open.
func Define[S, R any](s Spec[S, R]) *K[S, R]
// Entity is one entity's state at a version.
type Entity[S any] struct {
ID ID
Version Version
Clock uint64 // its head's clock: the next pack's is Clock+1
State S
}
func (k *K[S, R]) Get(ctx context.Context, b *Board, id ID) (Entity[S], error)
// At is the entity as it was at a time: ls --at, show --at.
func (k *K[S, R]) At(ctx context.Context, b *Board, id ID, at int64) (Entity[S], error)
// Log is every operation in order: log, undo, diff.
func (k *K[S, R]) Log(ctx context.Context, b *Board, id ID) ([]Entry, Version, error)
// Create writes a new entity's first pack. id is "" unless the kind is Given.
func (k *K[S, R]) Create(ctx context.Context, b *Board, id ID, ops ...Op) (Entity[S], error)
// Do reads the entity, asks decide for the operations, and writes them.
// When the entity moved meanwhile it reads again and asks again (5
// tries): a decision always sees the head it is written on.
func (k *K[S, R]) Do(ctx context.Context, b *Board, id ID,
decide func(cur Entity[S]) ([]Op, error)) (Entity[S], error)
func (k *K[S, R]) List(ctx context.Context, b *Board, q Query) ([]Listed[R], error)
func (k *K[S, R]) Resolve(ctx context.Context, b *Board, prefix string) (ID, error)
type Listed[R any] struct {
ID ID
Version Version
Row R
}
// Query is what the view answers; queries.md gives the semantics.
type Query struct {
All []string // every term
Any [][]string // one term of each group
Not []string
Text string
In []string // title, body, comments
Sort string // "created", "updated"
Desc bool
Limit int
}
// Kind is a K without its types, for Open.
type Kind interface{ Name() string }
// Board is an open board: a datastore and its view.
type Board struct{ /* … */ }
func Open(ctx context.Context, ds store.Datastore, v view.View, kinds ...Kind) (*Board, error)
// Fresh brings the view to the datastore's heads. Every Get and List
// calls it; it costs one listing of the heads when nothing moved.
func (b *Board) Fresh(ctx context.Context) error
// Watch tells what changed, whoever changed it (queries.md, Change events).
func (b *Board) Watch(ctx context.Context, every time.Duration) <-chan []Change
// Observe registers f, told of each change this process wrote, once it
// landed: the Markdown render, the forge-only sender.
func (b *Board) Observe(f func(ctx context.Context, c []Change))
func (b *Board) Broken(ctx context.Context) ([]Broken, error)
func (b *Board) Store() store.Datastore
func (b *Board) Close() error
type Change struct {
Kind string
ID ID
Version Version // "" when gone
}
type Broken struct {
Kind string
ID ID
Err error
}
// Errors.
var (
ErrNotFound = errors.New("not found")
ErrAmbiguous = errors.New("ambiguous id prefix")
ErrMoved = store.ErrMoved // after 5 tries
)
A domain is then a few lines of registration and its rules:
package issues
var Kind = kernel.Define(kernel.Spec[Issue, Row]{
Name: "issue", Rev: 1,
New: func(id kernel.ID) Issue { return Issue{ID: id} },
Apply: apply, // the eight operations of data-model.md › Issues
Index: index, // labels as terms, title/body/comments as texts
Row: rowCodec{},
})
person, sync and release are defined the same way, in their
domains. A fifth kind is a new Define, and nothing else in the kernel,
the view or fetch and push changes. The refspecs come from the
registered kinds.
One Datastore, with capabilities¶
package store
type Datastore interface {
// Heads lists every entity of the kinds, each with its version. It
// is snapshot-safe: an entity that exists from the call's start to
// its end is listed, whatever writers do meanwhile. A ref whose name
// isn't an id is skipped (storage.md, Refs).
Heads(ctx context.Context, kinds ...string) ([]Head, error)
// Load streams the packs of each ref, at its version ("" for the
// current one), in any order, as each is read. Load of one ref is a
// point read; of 20,000, a first build.
Load(ctx context.Context, refs []Ref, each func(Ref, [][]byte) error) error
// Add stores one pack if the entity is at prev ("" for a new one),
// and gives the new version; ErrMoved otherwise.
Add(ctx context.Context, kind, id string, pack []byte, prev string) (string, error)
Caps() Caps
// Folder is this machine's own folder for the board: the view, the
// sync lock and its journal. Never shared, never the board.
Folder() string
Close() error
}
type Head struct{ Kind, ID, Version string }
type Ref struct{ Kind, ID, Version string }
// Caps says what a datastore can do beyond the interface. A nil field
// is a capability it lacks; a feature that needs it fails with
// ErrMissing and a sentence the user reads (ADR 0002 §3).
type Caps struct {
Pinned bool // a version names its packs for good (git): no recheck
Shared bool // every machine reads the same store (PostgreSQL)
Counter Counter // project numbers (ADR 0016)
Locker Locker // a lock every machine shares (PostgreSQL)
Remote Remote // fetch, push, start over (git)
SQL SQL // the view may live here (PostgreSQL)
}
type Remote interface {
// Fetch fetches the kinds' refs and merges them (storage.md, Merging
// two heads), all ref moves in one git update-ref --stdin.
Fetch(ctx context.Context, remote string, kinds ...string) ([]Head, error)
Push(ctx context.Context, remote string, kinds ...string) error
Drop(ctx context.Context, kinds ...string) error
}
var (
ErrNotFound = errors.New("store: no such entity")
ErrMoved = errors.New("store: entity moved since it was read")
ErrMissing = errors.New("store: capability missing")
ErrHeld = errors.New("store: lock held")
)
What changes from v1's core/datastore: Heads takes every kind in one
call (ADR 0002, Left open); Load reads many entities at once, at
their versions, so a first build streams (PacksAt was a single read);
capabilities are a struct, not type assertions, so a wrapper (a counting
store, a test fake) forwards them by copying a value.
The git datastore reads refs itself: the loose refs with os.ReadDir
first, skipping an entry gone meanwhile, then packed-refs, parsed once
per call. It reads one entity's objects in process (go-git's storage,
packs kept open). A bulk Load runs one git cat-file --batch --buffer
per CPU, each walking its share of the entities level by level (heads,
then their trees and parents, then the packs). Writes: objects in
process, then git update-ref (ADR 0006); a fetch's merges go in one
git update-ref --stdin transaction.
The read path: the view¶
The view is the read model: every list, filter, search, lookup and
show reads it. It is disposable (ADR 0009) and incremental: it changes
by the entities that moved, never by a rebuild, except when a kind's
Rev or the view's own version changes.
Its tables, in sqlite (.git/board/v2/view.db, WAL) or in PostgreSQL
(git_board_view_v1, when the datastore is Shared and has SQL):
| Table | Holds |
|---|---|
heads |
one row per kind: every (id, version, clock) of the kind, as one sorted blob |
rows |
(kind, id) → version, created, updated, hidden, the row (the kind's codec) |
terms |
(kind, term, id): the terms, indexed by term |
texts |
FTS5 (sqlite) or a tsvector (PostgreSQL), per (kind, id, field) |
states |
(kind, id) → version, the state as JSON; filled by a write or a show, not by a build |
broken |
(kind, id) → version, error |
Freshness, every query:
Headslists the kinds the query needs. Git: about 5 ms on 20,000 refs.- The listing, sorted, is compared with the
headsblob, byte for byte. Equal: the view is fresh, and the query runs. This is the common case. - Else the difference is computed: entities new or moved, and entities missing from the listing.
- The moved ones are loaded at the version listed (
Load), and folded: incrementally when the new packs all sort after the stored head (an append, which is what a write makes), from the start otherwise (a merge). - A missing one is dropped only after a point read answers
ErrNotFound. A listing that missed an entity (2.6) costs a read, never a lost issue. - One transaction writes them, row by row, only where the stored version is still the one step 2 read. A row another process wrote meanwhile is left to it; a version left behind is picked up by the next query.
In one process, a write updates the view itself (write-through, below), so it reads its own writes without a listing. Across processes (the server and a command, two worktrees), step 1 sees what the others wrote. After a fetch, the merged refs are seen the same way. No answer relies on a time: every row is keyed by the version it was folded at.
The server keeps the rows of every kind in memory, keyed by version, and refreshes them by the same steps from one poller (one second), which also feeds the change events. A request still runs step 1 first, so a command's write is never missed between two polls.
show reads the row and the state at the current version; with no
state stored, it loads the entity's packs (one entity: about 30 objects)
and stores its state.
The write path¶
- The surface calls
app, which calls a domain. - The domain validates the request against its config and its rules
(fields, transitions, thread anchors, "changed meanwhile"), inside
Do'sdecide, so the decision sees the head it writes on. - The kernel encodes one pack:
{"clock": head clock + 1, "ops": …}, compact, keys in the spec's order. Its id is its git blob hash. Add(kind, id, pack, prev = version read). OnErrMoved: read again, decide again, up to 5 tries.- Write-through: the new pack sorts last (its clock is the highest), so
the new state is the old state with the new operations applied. The
view's row is replaced where its version is still
prev. - Observers run: the Markdown files render, the forge-only sender queues the issue, the server pushes an event.
No process lock guards writes. Two writers are safe by the compare-and-swap; a sync is a writer like the others.
Sync: one engine, kinds and forges as plug-ins¶
sync runs the Sync algorithm once, for any kind
that has a mapper and any forge that carries that kind. The records stay
operations in sync entities, the journal and the lock stay as the spec
says.
package sync
// Mapper says how one kind meets a forge. Values are compared field by
// field against the last seen value: sync.md › The four cases.
type Mapper interface {
Kind() string
Fields() []string // title, body, status, labels, assignees…
Local(state any) map[string]Value // ours
Remote(it forge.Item) map[string]Value // theirs
Pull(field string, v Value) []kernel.Op // theirs, as our operations
Push(field string, v Value) forge.Change // ours, as a forge write
}
func Run(ctx context.Context, b *kernel.Board, f forge.Forge, ms []Mapper, o Options) (Report, error)
package forge
type Forge interface {
Key() string // github.com/guilbep/git-board
Server() string // github.com
Me(ctx context.Context) (Account, error)
As(ctx context.Context, a Account) (Forge, error)
// Items is the forge's side of a kind: issues, releases. nil: this
// forge doesn't carry it.
Items(kind string) Items
// Pulls are its pull or merge requests, read by the cycle.
Pulls() Pulls
}
type Items interface {
List(ctx context.Context, since int64) ([]Item, error)
Get(ctx context.Context, ref Ref) (Item, error)
Create(ctx context.Context, it Item) (Item, error)
Apply(ctx context.Context, ref Ref, c Change) error
}
Issues are the first mapper, people's accounts come with them, releases
are the second (publish only: ours to the forge). GitHub and GitLab both
carry both, so a release reaches GitLab too (ADR 0020, Left open).
v1's adapters (core/forge/github, gitlab, 3,355 lines) and their
conformance suite are ported, not rewritten: they were right.
Config: each domain owns its table¶
package conf
// Table is one table of .git-board.toml, declared by the domain that
// reads it.
type Table[T any] struct {
Name string // "cycle": [cycle]
Default func() T
Check func(T) []Problem // the domain's own rules
Switch string // "" or its [experimental] key (ADR 0018)
}
// Declare registers a table on the file. Two domains can't declare one
// name.
func Declare[T any](f *File, t Table[T]) *Handle[T]
// Get is the table, checked. A broken table breaks its domain alone:
// the others answer, and app reports this one's problems.
func (h *Handle[T]) Get() (T, []Problem)
// Set rewrites this table's text in the file, and nothing else:
// comments, order and the other tables stay byte for byte.
func (h *Handle[T]) Set(ctx context.Context, v T) error
// Load reads the file once. A key no table declares is a problem of the
// file, reported by `config` and by Settings, and fails no command.
func Load(path string) (*File, error)
[fields], [labels], [sprints], [views], [roles] are the issue
domain's; [release] the release domain's; [cycle] the cycle's;
[branches], [uploads], [github-project] the tools'. Defaults live
with their table. One Set replaces the 751 lines of setsection.go.
The application: one API¶
app opens a board and exposes each domain as a service. Every
surface calls it; none holds a rule.
package app
type Board struct {
Issues *issues.Service
People *people.Service
Releases *releases.Service
Cycle *cycle.Service // nil while the cycle is off (ADR 0018)
Sync *sync.Service
Config *conf.File
}
// Open finds the repository at dir, its datastore (board.datastore),
// its view, its config and who writes (board.me).
func Open(ctx context.Context, dir string, o Options) (*Board, error)
A service method takes a context and one request struct, and returns one
result or an *app.Error:
func (s *issues.Service) List(ctx context.Context, r issues.ListReq) ([]issues.Issue, error)
func (s *issues.Service) Show(ctx context.Context, r issues.ShowReq) (issues.Thread, error)
func (s *issues.Service) Create(ctx context.Context, r issues.CreateReq) (issues.Issue, error)
func (s *issues.Service) Comment(ctx context.Context, r issues.CommentReq) (issues.Thread, error)
func (s *issues.Service) Set(ctx context.Context, r issues.SetReq) (issues.Issue, error)
issues.Issue and issues.Thread carry the JSON tags of today's
core/model: the CLI's --json and the API print the same structs.
The adapters are generic:
// http: a route is a method; path values and the query fill the request
// by its tags (`path:"id"`, `query:"filter"`), the body by JSON.
func Route[Req, Res any](mux *http.ServeMux, pattern string,
f func(context.Context, Req) (Res, error))
Route(mux, "POST /api/issues/{id}/comments", b.Issues.Comment)
The CLI's commands build the same request from flags, and print the
result through one presenter: --json [fields], --jq, --template,
--md, tables. A route or a command that needs more than a call and an
encoding is a sign the rule is in the wrong place.
Errors and exit codes¶
package app
type Class int
const (
Internal Class = iota
Usage // a wrong request: a flag, a value, a config key
NotFound // no such entity, or an ambiguous prefix
NoMe // no one to write as
Changed // the text changed meanwhile
Busy // a sync or a release runs; try again
Missing // the datastore can't (ADR 0002 §3)
Forbidden // the server's host and origin checks (ADR 0013)
Off // an experimental feature is off
)
type Error struct {
Class Class
Code string // the API's stable code: "changed", "syncing", "missing"…
Err error // what the user reads
}
| Class | Exit code | HTTP |
|---|---|---|
Internal |
1 | 500 (v1: 400) |
Usage |
2 | 400 |
NotFound |
3 | 404 |
NoMe |
4 | 409 no-identity (v1: so the inbox; its writes, 400) |
Changed |
5 | 409 changed |
Busy, Missing |
1 | 409, with the code |
Forbidden |
1 | 403 |
Off |
1 | 404 experimental |
One table, in app, read by both adapters. Errors go to stderr prefixed
error:, as today.
Testing¶
storetest, one suite every datastore runs (git, PostgreSQL, Markdown, forge copy, mem): add and read, one writer wins, the counter, and concurrency:Headswhilegit update-refandgit pack-refsrun,Loadwhile packs are appended, two processes writing one entity. v1'sdstestis its seed.forgetest, one suite every forge adapter runs against its recorded fake (ADR 0014: never the network). v1'score/forge/conformanceis ported.viewtest: sqlite and PostgreSQL answer every query alike; a refresh racing a write and another refresh never loses or regresses a row; a missed ref never drops an entity.- The spec's examples are fixtures. Every JSON block of
docs/spec/*.mdis parsed by a test and folded; a spec edit that breaks the code fails the build, and the other way round. - Golden boards.
testdata/golden/boards/holds git bundles: v1'sboardgenboards, and hand-made edge cases (a merge, two first commits of one sync entity, a tombstone, a broken entity, an unknown field, arefs/board/issue/notesref). For each, the output v1 gives forls --json,show --json,log --json,status --jsonand the API's routes is recorded. v2 must give the same, field by field. - Cross-writes.
make compatbuilds both binaries and runs scripts that alternate them on one board: v2 writes, v1 reads, and the other way round, on git, PostgreSQL and Markdown. On Markdown the rendered files too (golden/mdfiles_test.go): after one version's writes the folder is what the other renders from no file; the same hand edits are read in the same (operations, conflicts, files); adatastore copyinto a folder renders the same files. - Budgets. The benchmarks of section 4 run on a 20,000-issue board in CI (nightly), and fail above budget.
- The web app's e2e tests (
web/e2e) run againstgit-board-next serve(git board servesince M7) from M5 on.
4. Speed budget¶
What was measured¶
On the reference machine (2 CPUs, Xeon 2.1 GHz, Linux), a board made by
go run ./cmd/boardgen -n 2000:
| 2,000 issues | ×10: 20,000 | |
|---|---|---|
| packs (commits) | 13,266 | ≈ 133,000 |
| git objects (commit, tree, blob each) | 39,798 | ≈ 400,000 |
| pack JSON | 10 MB | ≈ 100 MB |
packed-refs |
200 KB | ≈ 2 MB |
git cat-file --batch, every object, by id, random order |
0.20 s: 5 µs an object | ≈ 2.0 CPU-s |
v1, first ls (no index) |
1.26 s | 16 s (measured) |
v1, ls |
0.16 s | 1.5 s (measured) |
v1, show |
45 ms | 207 ms (measured) |
Targets¶
On 20,000 issues, a git board, the reference machine (a laptop of 8 cores does better on the first build only).
| Operation | v1 | v2 target |
|---|---|---|
ls, view fresh |
1.5 s | < 100 ms |
ls after a pull that moved 500 issues |
< 300 ms | |
ls, first on a clone (no view) |
16 s | < 3 s |
ls "free text", first on a clone |
< 5 s | |
show <id> |
207 ms | < 30 ms |
new, set, comment |
221–283 ms | < 50 ms |
server: GET /api/issues, all 20,000 |
≈ 170 ms | < 60 ms |
server: GET /api/issues/{id}/thread |
< 10 ms | |
| fetch that merges 1,000 issues | 1,000 processes | one update-ref --stdin |
Why they hold¶
ls, view fresh: about 85 ms.
| Step | Cost |
|---|---|
start the process, read .git-board.toml |
10 ms (--version today: 12 ms) |
Heads: read 2 MB of packed-refs, a few loose refs |
5 ms |
compare with the heads blob (20,000 × 40 bytes) |
2 ms |
| read the rows: 20,000 × 300 bytes from sqlite | 40 ms |
| decode them (a hand-written codec, 1 µs a row, 2 CPUs) | 10 ms |
| filter, sort | 8 ms |
| 10 ms |
A filter with terms (status:ready assignee:dev3) reads fewer rows:
the terms table narrows first. What v1 spends here is decoding 20,000
whole states as JSON (performance.md, What's left); v2's rows hold
what a list shows, and no text.
After a pull that moved 500 issues: about 200 ms. The 85 ms above, plus 500 entities loaded: 500 × 30 objects × 5 µs = 75 ms, folded incrementally where they were appends, and written in one transaction (20 ms).
First ls on a clone: about 2 s.
| Step | CPU | Wall, 2 CPUs |
|---|---|---|
read 400,000 objects, two cat-file processes |
2.0 s | 1.0 s |
| parse commits and trees, decode 100 MB of packs, fold | 1.2 s | 0.6 s |
| write 20,000 rows and 100,000 terms, one transaction | 0.3 s | 0.3 s |
| total | ≈ 1.9 s |
The free-text index is not in it: its 60 MB or so of text are indexed
after the rows, in the server's background, or by the first query that
asks for text (the 5 s line). States are not stored by the build either:
show stores each one the first time. Both choices keep the 100 MB of
pack JSON from being written a second time into the view.
show: about 15 ms. Freshness 6 ms; the row and the state from the
view, or one entity's 30 objects in process (under 1 ms) and a fold;
the people from the view; the HTML of its texts.
A write: about 30 ms. Freshness 6 ms; three objects written in
process; git update-ref, 3 to 5 ms; the view's row replaced, one small
transaction; no fold of the whole entity (step 5 of the write path).
The server's list: about 50 ms. The rows are in memory; freshness 6 ms; the filter 6 ms; encoding 20,000 issues as JSON (8 MB) 40 ms. A paged API would cut the last line; it changes the contract, so it is a question (section 6).
Limits, said now¶
- Markdown boards hash every file on each
Heads(ADR 0002, Left open: 52 ms for 1,000 files). At 20,000 files that is a second a query: a Markdown board is for a few thousand issues. - PostgreSQL's
Headsis one query of 20,000 rows, 15 to 30 ms over a network. The budgets above add it. - The first build's 5 µs an object is git's C, measured on 2,000 issues. M1 measures it at 20,000, and the first build's design is changed or the target moved before anything else is built on it.
5. Migration plan¶
v2 grows in next/, in the same module¶
As planned, and as built until M7: the switch moved next/ to the root
(below).
next/is a tree of this repository and of its Go module,github.com/guilbep/git-board. Onego.mod, onemake ci, one set of dependencies (ADR 0017).- Its binary is
git-board-next, sogit board-next lsworks next togit board ls. Releases go on shipping v1 until the switch. - The guard, written first: nothing in
next/importscore/,internal/orcmd/, and nothing outside importsnext/. Code from v1 that was right (the forge adapters, the search tokenizer, the filter grammar, the cycle's computation, the release versions) is copied in and owned, not imported, so deleting v1 is onegit rm. - v1 takes fixes only. A new feature waits for the milestone that brings its domain to v2, and lands there (a question for Pierre: section 6).
Why not a module v2/: Go reads a /v2 module path as major version 2,
and the tags would have to be v2.x.y, while git-board is at v0.11.
Why not a branch: compatibility must be proven on every commit of v1
too, so both live in one tree.
Compatibility, proven continuously¶
make compat(inmake cifrom M1): both binaries, the golden boards of section 3, every recorded output compared. Allowed differences are listed in one file,testdata/compat/allowed.txt, each with its reason (an internal error that was 400 and is 500).- Nightly, the same on the 20,000-issue board, with the budgets.
- From M2, dogfooding: Pierre's boards (git-board's own, co2-calculator)
are read by both binaries daily, by a scheduled job that diffs
ls --jsonandshow --jsonof every issue, and reports any difference.
Dogfooding, as built (2026-11-05). make dogfood DIR=<repository>
runs golden's TestDogfood: each binary reads its own copy of the
board (a clone of the repository's commit, its working tree's
.git-board.toml, its refs/board/*, no remote; the repository is never
written, nothing is fetched or pushed), and ls --json, ls is:closed
--json and show <id> --json of every issue, deleted ones too, are
compared through make compat's Run, Env and Normalise
(compat.md): exit code and stdout. OLD is v1's
binary, else v1 built from OLD_REF (git archive, no worktree; the
newest v0.11.* tag by default); NEW v2's, else built now. A difference
fails it, and the differing outputs and their diff go to DOGFOOD_OUT
(dogfood-out/dogfood.diff). It reads boards kept in git: a clone whose
board.datastore names PostgreSQL or a folder is refused.
.github/workflows/dogfood.yml runs it daily (and by hand, with an
old_ref) on this repository's board, fetched from origin, and uploads
the diff when it fails; Pierre's other boards (co2-calculator's) are run
by hand, make dogfood DIR=<its checkout>, once their issues are in
refs/board/. Pierre asked to run it after v0.12 rather
than hold the release on it.
Its baseline is a release, and v0.11.6 can't be one: it has no show
command, and its ls --json has neither closed_at nor comments
(added on main since). Against it every case differs (git-board's own
board, 2026-11-05: 42 of 42). v1 built from this tree and v2 agree on that
board, every case. So the job needed a last v1 release cut from main
before the switch (v0.11.7). Pierre decided otherwise (M7, below): no
v0.11.7 is cut, and the baseline is v1 built from its last commit,
7a546cc (the merge base of the switch with main), named in the
Makefile's V1_REF and the job's.
Milestones¶
Sizes are lines of Go, without tests; tests are about as many again.
| What | Done when | Size | |
|---|---|---|---|
| M0 | Contracts frozen: the PostgreSQL, Markdown and counter formats written into the spec; the golden boards and v1's recorded outputs; the API transcripts; make compat; the import guard |
v1 passes its own goldens | 1,500 (tools, tests) |
| M1 | kernel, kernel/pack, store/git, store/mem, storetest, view (sqlite), the issue and person kinds; ls, show, log read-only |
the goldens' ls, show, log match; the budgets of section 4 hold for reads |
5,000 |
| M2 | Writes; conf; domain/issues (fields, filters, inbox, numbers); app; the CLI's issue commands (new, comment, edit, set, move, close, delete, undo, status, follow, labels, config, me) |
cross-writes pass; write budgets hold; dogfooding starts | 7,000 |
| M3 | store/pg (with the view in PostgreSQL), store/md, store/forgecopy; datastore copy |
storetest and viewtest on all; goldens on each |
3,000 |
| M4 | forge (ported), sync engine, issue and release mappers; fetch, pull, push, sync |
forgetest; sync's tests from v1 ported and green; v1 and v2 sync one board alternately |
5,000 |
| M5 | surface/http on app, every route; change events |
every transcript matches; web/e2e green on v2 |
4,000 |
| M6 | domain/releases, proof, domain/cycle, the TUI, the tools (the release runner, the GitHub Project bridge, insights, lint, export and import; agents left, below) |
every command of v1 exists in v2, or is dropped by a decision | 6,000 |
| M7 | The switch: next/ becomes the root, cmd/git-board is v2, v1 is deleted |
done, 2026-11-05 (below) | 0 |
M4, as built (2026-11-05). ls and show read the sync records
(sync): pending.txt is empty. The adapters, the conformance
suite (forge/forgetest, green over the fake, GitHub and GitLab)
and v1's syncer are ported: the kind-free parts (records, texts' forms,
lock, journal) in sync, the issue mapper and the run in
domain/issues/issuesync, v1's 85 sync tests green there. v1 and
v2 sync one board alternately against one fake forge, and pull, merge
and push one board between two clones (golden/v1gen, deleted at M7). forge
pull|push|sync, fetch, pull, push, sync and numbering through
origin's counter run. Left: the Mapper interface (with releases, the
second kind; the run is the issue mapper today), the release mapper, the
whole sync's dry run (sync --check; forge … --check runs), the
switch marks on pull and push (v0.11's, ADR 0021), the quote over a thread
comment and the note after a published body (v1's Quote and
Published, which read the body's sections), and the GitHub Project
step (built in M6, below).
M6, as built (2026-11-05, partial). GitLab replies are posted as
replies (ADR 0021): the wrapper that hears a run's writes forwards
forge.Replier. domain/releases is the release kind, its records
read whole from the datastore, v1's versions, tags and overlays, and
proof (ADR 0001). The sync engine has its Mapper interface
(sync), as a run-level plug-in rather than the field-level shape
above: the issues' run is one mapper (issuesync.Mapper), the releases'
the second (releasesync, publish only, to GitHub and GitLab through
forge.Releaser, run by a forge push on a board that publishes
binaries). The release runner is tools/ship over app.Book; the
forge's token stays in app, which publishes (ship.Publisher). The
cycle is domain/cycle (+ work), its evidence read in app;
tools/code reads git, tools/branches names branches. The
commands release, deploy, verify, branch, sprint and tui are
v1's; a golden board, releases, holds the release reads, and basic the
sprint, branch and verify cases. Left: agents, insights, and
the plan's HTML in the cycle's evidence (the caller renders).
v1's CLI, closed for v0.12 (2026-11-05). init (its templates,
and the start from origin's labels and first GitHub Project, GitLab's
labels too, held to v1's files in app/testdata/init), lint
(its cycle read: lines too, and --create-labels, through the forge
adapters' forge.Labeler, GitLab's as GitHub's), export --md and import --md (the
Markdown datastore's files, mdfiles), reset --hard, push --prune
and the reset guards on pull and push, and datastore drop are v1's,
each held to v1 by a cross test in golden (TestCrossInit,
TestCrossLint, TestCrossExport, TestCrossReset, TestCrossDrop).
git-bug's switch and prune are not ported: v0.11 migrates (ADR
0021).
The GitHub Project bridge, as built (2026-11-05). tools/ghproject
is v1's internal/ghbridge, copied and owned, the sync engine's third
mapper (ghproject.Mapper): a Project's fields (single select,
iteration, number, date, text) and the labels, decided field by field
against the base both last agreed on, kept in the issue's metadata
(git-board:<owner>/<n>:<field>, as v1), with v1's conflict rules and
the assignees merged person by person. A date and a text column are
v2's, by the column's type. v1 read the Project with the gh CLI; v2
reads and writes it through GitHub's GraphQL API
(forge/github.ProjectV2), with the GitHub forge's token (ADR 0010), and
the assignees through REST. project pull|push|sync (--check,
--limit), resolve and configure are v1's; sync runs it between
the forges and the push when [github-project] names a Project (--git
skips it); the TUI's i/o keys pull it. The API's routes 52 and 53 answer
its last run and where each field lives (places); the web app has a
Settings tab for it, its conflicts with their two sides, and Fields'
places mark a column the Project hasn't. v1's ghbridge tests are
ported; projectfake is a Project served by httptest; and
golden/v1gen's TestProjectCross (deleted at M7) had v1's bridge and v2's take
turns on one board and one Project: they planned the same at every turn,
and neither had anything left after the other ran.
About 31,000 lines of Go where v1 has 55,000. One engineer with agents: M0 a week; M1 and M2 three weeks; M3 to M5 four; M6 three; with dogfooding, two to three months.
v1 is deleted when, all at once: every milestone is done; make
compat has been green for two weeks of v1 fixes; the dogfooding job has
reported no difference for two weeks on real boards; the web e2e tests
pass on v2; and a release of v2 as git-board (still v0.x) has run a
week on Pierre's machines. Then one pull request moves next/ to the
root and deletes core/, internal/ and the old cmd/.
M7, as built (2026-11-05). Pierre waived the waiting criteria above
(ADR 0021): v1 is dropped entirely, with no v0.11 support, and v2 ships
as v0.12 now; the dogfooding job runs after. One branch,
rewrite/switch, did the switch:
- v1 deleted:
core/,internal/, v1'scmd/git-board,cmd/boardgenandcmd/smoketui,web/(v1's web app),golden/v1gen, v1's API transcripts, the guard betweennext/and v1, anddocs/embed.go. next/moved to the root, each package keeping its path under it (next/kerneliskernel,next/webisweb), andnext/cmd/git-board-nextiscmd/git-board: the binary isgit-board,--versionsaysgit-board version <v>.cmd/boardgenistools/boardgen's, formake perf.- The layers:
layers_test.goat the root, the table at the new paths; it also checks the table has no cycle and that nothing importscontrib/. - Compatibility without v1: the golden boards and v1's outputs stay,
and
make compatruns git-board against them. The cross tests' v1 side was recorded before the deletion (testdata/golden/cross/), andmake goldennow records from git-board, a change of the contract reviewed as one (compat.md).pending.txtwent: it was empty. - Dogfooding compares git-board with v1 built from
V1_REF(7a546cc), not with a v0.11 tag. contrib/board-agentsbuilds./cmd/git-boardfor its test against the server (GIT_BOARD_BINnames another).- The docs describe v0.12 alone: the README (what git-board is, its
install, a quick start, related work), CONTRIBUTING (the layout, no v1
freeze), the guide and the manual (no v1-only behaviour; git-bug's
switchandpruneare v0.11's, to migrate with first), the spec's paths, ADR 0020 (its layout superseded) and ADR 0021 (done).
Agents and wtx: their own binary¶
v2 has no agents (ADR 0021).
v1's (internal/wtx, internal/ai, the agent and worktree routes) are
ported to git-board-agents, a Go module of its own in
contrib/board-agents/ (module github.com/guilbep/board-agents). It
imports nothing of git-board's: it reads and writes the board through the
git-board CLI's --json outputs and, for what only the server computes
(the cycle, the Work page's rows), through v2's HTTP API, all of it in
one file (internal/board/board.go). Its routes are v2's since
2026-11-05 (/api/board, /api/issues/{id}, /api/issues/{id}/cycle,
/api/cycle?who=team, comments with If-Match); a test runs it against
git-board serve. v2 lacks three things it used from v1, and it
says so where they're missing: a comment's cycle:step (comment meta,
M6), line comments located on the branch's head, and worktrees' paths on
the Work page (it matches rows by branch). Its README says how it leaves this
repository with git subtree split. make agents builds and tests it;
make ci doesn't: it is no longer part of git-board. It stays when v1 is
deleted, until it moves out.
6. Risks and open questions¶
Answered on 2026-11-05: see ADR 0021.
For Pierre to decide:
- Freeze v1? Fixes only, from the start of M1, or until M2? Each feature added to v1 meanwhile is built twice.
next/in the same module (proposed), or a separate module or repository?- Reads through
git cat-file. The first build streams objects fromgit cat-fileprocesses, where ADR 0006 says go-git reads objects. Amend ADR 0006 for bulk reads, or keep go-git and take a slower first build (v1's, about 5 times slower per object)? - The HTTP API: frozen or reshaped? Freezing it lets the web app move unchanged and proves parity. Reshaping it (paged lists, one error shape, fewer routes) is cheaper during the rewrite than after. Proposed: frozen until M5, reshaped after the switch, with the web app.
- What v2 drops. Candidates no core needs and few people use: the
GitHub Project bridge (
internal/ghbridge, 1,241 lines),html,ghlabels,migrate, agents (internal/ai,wtx). Each dropped is a milestone shorter. - The version number. v2 ships as
v0.12(proposed: the data is the same), or asv1.0to mark the rewrite? - Writes during a sync. v2 allows them (the compare-and-swap keeps
them safe), where v1 answers 409
syncing. The web app then never sees that code; is that the behaviour wanted? - One ADR. This design becomes ADR 0021 once accepted, and the decisions it changes (ADR 0006's reads, ADR 0009's tables) are amended there.
Risks:
- Hidden contracts. v1 behaviour that no document states and some board relies on: the Go JSON decoder takes a key in any case and keeps the last of two equal keys (data-model.md marks it a known ceiling). v2 keeps that leniency; the goldens include such packs.
- sqlite in pure Go.
modernc.org/sqliteis slower than C. The 40 ms for 20,000 rows is an estimate; M1 measures it. If it fails, the rows move to one flat file per kind next toview.db, read whole, and sqlite keeps terms and text. - The first build at 20,000. Measured at 2,000 and multiplied by 10. M1 measures it on a real 20,000 board before anything else.
- Two binaries on one board during months: two views (fine, both disposable), one sync lock (shared on purpose), and the counter, which both must hand out by the same rule. The cross-write tests cover numbering.
- Diverging merges (storage.md, Known point): two clones that merge each other at once keep writing merge commits. Not v2's to fix, but v2's batched fetch must not make it worse; a golden board holds such a pair.
- The rewrite stalls half done, and two code bases live on. The
milestones are ordered so that each is usable alone (M1 is a faster
read-only
ls), and v1 is deleted on criteria, not on a date.