Skip to content

Decisions

The choices this spec starts from, one per line. Each links to the place in the PRD where it was decided.

The decision

  • The core is a Go library inside git-board: git-board's code moves onto it, and git-bug goes. (PRD)
  • Go, for its build times, one static binary per platform, and git-board's code and tests, which stay. (PRD)
  • Sync first: links to forges, and the value git-board and the forge last agreed on, are part of the data model. (PRD)
  • Not compatible with git-bug: the data lives under refs of its own, which git-bug never sees. (PRD)
  • One binary, git board, for people and for CI. (PRD)
  • MIT, once nothing in git-board links GPL code (M6). (PRD)
  • A documented schema: this site, which the code implements. (PRD)

Rules

  • Clean room: the spec is in our own words. Implementers read it, the PRD and the milestone plans, never git-bug's code or its spec. (PRD)
  • core/ never imports github.com/git-bug/git-bug, and a test fails if it does. (PRD) Since v0.12 nothing does: git-bug isn't in go.mod.
  • The library is a library: github.com/guilbep/git-board/core/... is importable, and never imports git-board's internal/. (PRD) Since v0.12 (ADR 0021) the module is the library, in layers: layers_test.go lets each package import only the ones below it.
  • Version 1 is what the problem forces, for GitHub and GitLab. The rest waits until someone asks. (PRD)
  • The fork stays frozen at v0.11.1-sync.10 until the core passes the conformance suite and a live run. (PRD)

The data model

  • One kind of record, the entity: a log of operations in a chain of commits under refs/board/<kind>/<id>, ordered by (clock, pack hash). (PRD)
  • Four kinds: issue, person and sync, and release, whose operations git-board reads (M5). (PRD)
  • An operation pulled from a forge says where it came from, as a field. (PRD)
  • Every field is a label, ns:value, assignees included. The forges get labels, never their own fields. (PRD)
  • One exception: assignee: labels become the forge's own assignees, always. (PRD)
  • Acceptance criteria stay text, a task list in the description. The core doesn't model them. (PRD)
  • Settings shared by the project live in .git-board.toml. Who "I" am is local. (PRD)
  • Tokens stay on each machine: an environment variable, else git's credential helper. (PRD)
  • Writes check the ref they move. No lock files: only a sync run takes a lock. (PRD)
  • Queries and search read a local sqlite index, rebuilt from the refs. It is never the source of truth. (PRD)
  • Fetch and push go through the git binary. (PRD)

Version 1

  • In: issues and people, sync with GitHub and GitLab both ways, and git-board on the core. (PRD)
  • Out until asked: Gitea, review comments mirrored to pull requests, the forges' own fields besides assignees, the link, unlink and take commands, Jira, Launchpad, and any API besides git-board's own. (PRD)
  • No converter: a repository moves to the core by pulling from its forges (M6). (PRD)
  • One sync panel in git-board, no separate git-bug sync. (PRD)

Trial results

Measured on 2026-10-24, after the final review's fixes, lines with wc -l, tests apart from code.

Package Code Tests
gitstore 301 305
oplog 354 499
issue 240 370
forge, forge/fake 92 exercised through syncer
syncer (pull.go 208, state.go 138) 346 377
core (doc, import guard) 4 59
Total 1,337 1,610

Against the targets:

  • gitstore, oplog and issue together: 895 lines of code, target under 1,500. git-bug's operation logs, issues and cache are about 9,200 lines and its git layer about 7,100, so 16,300 against 895. The 895 lines are the trial's part only: issues, their order, merging, and what a pull of current state writes. git-bug's are its whole layers, a cache included. The trial has no people, index, search, change events or push yet, and reads no forge events.
  • The pull: pull.go is 208 lines, target under 300; with the sync state it keeps (state.go) it is 346. git-bug's fork has 910 in syncpull.go, which also reads events, compares conflict times and finds markers: the 208 leave those out.

Concepts a reader must hold: entity, operation, pack, clock, order, merge, ref update, link and seen. Six crept in:

  • the nonce, in an entity's first pack, so that two entities created with the same content have different ids;
  • the forge, a 35-line package that the pull reads through, so that the fake and the real adapters look the same; the interface has two methods;
  • the effective link: of two links for one issue, or for one forge issue, the first in the order wins and the other is ignored;
  • identical packs: two commits whose packs are the same blob count as one pack;
  • assignee labels, assignee: in any case, which are labels in git-board but a field of their own to sync;
  • duplicate-origin comments: a comment with an earlier comment's origin is that comment, as when two clones pull one forge comment.

seen also changed meaning: it is now the base, the value both sides last agreed on. It is still one concept, but a reader must hold the new meaning.

What was harder than planned: git, not the model. Fetch and push had to name the three entity kinds, because refs/board/* also matched git-board's own refs/board/seq. go-git caches its list of pack files, so packs written by git fetch (over 100 objects) or git gc were invisible; gitstore now re-reads the index after a fetch and once on a missing object. Its index isn't safe across goroutines either, so one lock serializes access. git's refusals are matched by their English text, so gitstore runs git with LC_ALL=C. An issue must validate before it writes: a rejected operation once stayed in the log and made the issue unloadable on every clone. The base kept a conflict visible, where seen hid it, and labels merge one by one so they can't conflict. Two clones that exchange directly and merge at the same time each write a merge commit, so their refs keep diverging while their states agree; through one origin they converge (a known point, not settled yet: see Merging two heads). The spec got wrong: the refspecs (refs/board/*), what a reader rejects, the sync state's edge cases (first link wins), and the merge-commit divergence. All were fixed in the spec as the tasks found them. The final review found more, fixed the same way: go-git ignored a linked worktree's common directory, so a worktree saw no issue; fetched heads sat in refs/remotes/, where branch lists and git fetch --prune reach them; a forge comment pulled on two clones became two comments; and labels had no check.

M2 results

Measured on 2026-10-24, at the end of M2, lines with wc -l, tests apart from code. M1's are the trial's, above.

Package Code Tests M1 code M1 tests
gitstore 413 512 301 305
oplog 454 770 354 499
issue 246 444 240 370
people 248 267
index 468 399
watch 108 164
forge, forge/fake 105 59 92
syncer (pull.go 272, state.go 148) 420 599 346 377
core (doc, import guard) 4 59 4 59
Total 2,466 3,273 1,337 1,610

gitstore, oplog and issue together are 1,113 lines of code, still under M1's target of 1,500. People, the index and change events add 824.

Concepts M2 added:

  • the person, who authors every operation, with its accounts: a user on a forge server, named by (server, id), whose login can change;
  • the first pack, the pack whose hash is a person's id: only its operations may have an empty author, since none can name the person;
  • the account owner: of two persons holding one account, the one whose id sorts first;
  • me, the local board.me, never shared;
  • the index, a sqlite file every worktree shares, never the source of truth;
  • the refresh, which brings the index up to the refs before every query, and the broken entity it keeps out and lists;
  • the change event: a poll's batch of the entities that appeared, moved or went, after a baseline that reports nothing.

What was harder than planned: again git and its neighbours, not the model. go-git lists a <ref>.lock as a ref, so an empty one from another process's git update-ref failed every listing, and so every query; gitstore now reads loose and packed refs itself. A cancelled git was killed and left its lock behind, so it now gets SIGINT first (Windows has none: there it is still killed). Two processes on one sqlite file needed WAL, a busy timeout and transactions that take the write lock at once, and a path with # or ? opened another file until it was escaped. A person's id comes from its first pack, so nothing in that pack can name it: the author rule had to allow empty authors there, and a pulled person became one write instead of two. One broken entity failed every query until the index kept it out and listed it. A person that won't load first got skipped by the pull, which made duplicates, so the pull now stops on it. The old id pattern let in names git refuses, like a..b and x.lock, so ids are now 40 lowercase hex digits.

What the core still lacks for M5, against git-board's internal/gitbugio. Its issue methods take an id prefix; the core takes whole ids, so M5 needs a prefix lookup, which the index can answer.

gitbugio The core
Open, OpenCwd, NewForRepo, Close, ErrNoRepo gitstore.Open, index.Open, Close; NewForRepo goes with git-bug
NewIssue, GetIssue, ListIssues, SetTitle, SetStatus, CloseBug, ApplyLabelChange, GetLabels, AddComment, Issue issue.Create, Load, Do and its operations; index.Issues
EditComment, ErrNoSuchComment issue.EditText. Not its base check, ErrChangedMeanwhile
SearchText index.Issues with a text
Identities, NewIdentity, Me, Adopt, Person people.List, Load, Create, Me, SetMe
Logins, MyLogin partly: a person's accounts. Which login a person is assigned as waits for M3's assignees
RefsFingerprint watch.Watch, for issues and persons. Not releases
Fetch, Push, Pull gitstore.Fetch, Push, oplog.MergeFetched
Thread, Threads, Event, Version, History partly: the issue's state, and its log through oplog.Load. No events, edit history or participants as such
Labels, Mentioning, Mentions none: every label in use, and issues mentioning a login
AddCommentMeta, CommentWith, SetCommentMetadata none: operations hold an origin, no other metadata
CommentReview, Review, StampPR none: review comments
ProjectBases, SetProjectBases none: a GitHub Project's last agreed values
Counter, ParseCounter, CheckNumbers, InitNumbers, NumberIssues, Numbering, FetchNumbers, NumbersRemote, ErrNumbered, ErrNumbersMoved none: issue numbers, refs/board/seq, stay git-board's
CreateRelease, Releases, ReleaseFor, CommentRelease, SetReleaseNotes, YankRelease, PullReleases, PushReleases, ReleasesFingerprint, ReleasesNamespace, ReleaseRecord, ReleaseComment, Artifact, Asset, ChartArtifact none: release records
AddBridge, Bridges, RemoveBridge, SetBridgeSync, SyncBridges, Bridge, SyncBridge, ErrNoBridge, Credentials, Credential, ForgeHosts, ForgeToken, GitHubToken, NoCredentials, Forge, SetAssignees M3 and M4: forge settings and tokens
GetForges, SendForges, SyncForges, SyncIssue M3: only the trial's pull, syncer.Pull, exists
LinkIssue, UnlinkIssue, TakeIssue none: out of version 1
Branches, Branch, Ancestors, ChangedFiles, ChangesIn, DiffFile, DiffStat, FileStat, FileLines, FirstCommitAdding, GrepFiles, HasCommit, LineAt, Log, Commit, MergeBase, ReadCommit, ReadFile, RevParse, RemoteHead, Remotes, Remote, Worktrees, Worktree, Ahead, GitHubRepo, GitHubRepoOf none: reading the code's own git history isn't the core's job, and stays in git-board
SetFlatKinds, Storage, TestCache none: git-bug's cache and storage go with it

M3 results

Measured on 2026-10-24, at the end of M3, lines with wc -l, tests apart from code. The fake and the suite are code other packages import, for their tests.

Package Code Tests M2 code M2 tests
gitstore 413 512 413 512
oplog 454 770 454 770
issue 265 470 246 444
people 248 267 248 267
index 468 399 468 399
watch 108 164 108 164
forge 86 38
forge/fake 449 102 67 59
forge/conformance (the suite) 693
syncer (sync.go 572, plan.go 532, state.go 253, pull.go 218, text.go 109) 1,684 848 420 599
core (doc, import guard) 4 59 4 59
Total 4,179 4,284 2,466 3,273

The sync engine is 1,684 lines, against the fork's 910 for its pull alone. Its interface to a forge is one 86-line package of 15 methods, which the GitHub and GitLab adapters implement in M4. The suite runs 16 scenarios: the PRD's M3 list, plus a refusal warned once and assignees.

Concepts M3 added:

  • the push, and its order: new issues first, then each linked issue's title, body, comments, status, labels and assignees;
  • the forge's form of a text: translated, with a header for the posting account, and a marker, both kept from last seen;
  • what git-board took from a forge: a field whose last change came from that forge counts as last seen there. It is the echo rule, settles two pulls of one comment, and makes an unlinked pulled issue safe to link;
  • the later change, by time, the forge's at the same second, against the order, between clones;
  • the records: posted, refused and run;
  • sync's accounts, whose markers count;
  • the duplicate, an issue whose origin is linked to another one, which sync leaves alone;
  • the run's lock, a file in git's common directory.

What was harder than planned:

  • Stability. A run after a run must write nothing. The header and the marker had to come from last seen, not be built again, and a run records itself only when it wrote, or every sync would grow the sync entity.
  • Forward references. A text naming an issue the run hadn't created yet would have needed a second run. The push now creates every issue first, then writes the rest.
  • Pushing alone. Last seen for labels after a push alone is last seen plus the diff sent, not the forge's set, or a label the forge gained would be removed by the next push.
  • The carried disagreement between the first copy of a comment and the last seen needed a rule, not a fix: what git-board took from a forge is last seen there. The same rule then gave the echo and the crash recovery by origin.
  • Assignees need the set's diff applied to the forge's set, the whole of it sent, and what the forge kept read back, so that GitHub's dropped assignee and GitLab's single one are the same refusal.

Left out, and carried in the PRD: rate limits and their pacing (the adapters'), a stale lock after a kill, a snapshot of the sync entity, and pulled operations' times, which are the pull's.

The M2 table above, for M3's rows:

gitbugio After M3
Logins, MyLogin an assignee label names a person by their login on the forge's server, else by their name
AddBridge ... SetAssignees forge.Forge, with As for each token owner, and syncer.Options; settings and tokens come from git-board (M4, M5)
GetForges, SendForges, SyncForges, SyncIssue syncer.Pull, Push, Sync, and Options.Issue for one issue alone

M6 results

2026-10-25, at the end of M6's code. The switch itself waits for guilbep.

The licence scan. TestLicences (make licences, and go test) reads the licence of each module the binary and its tests build, go list -deps -test ./..., from the module cache, and fails on a GPL, LGPL or AGPL. A licence is named by its SPDX identifier, else by the title or grant at the start of its file: MPL-2.0 names the GPL further down, as a secondary licence. Modules only in the module graph (go list -m all) are never built, so not scanned. 57 modules, git-board's own included, and none GPL:

Licence Modules
MIT 29: git-board, BurntSushi/toml, yuin/goldmark, go.yaml.in/yaml/v3, the Charm TUI libraries (bubbletea, bubbles, lipgloss, x/ansi, x/term, x/cellbuf, colorprofile), muesli/termenv, muesli/ansi, muesli/cancelreader, mattn/go-isatty, mattn/go-runewidth, rivo/uniseg, clipperhouse/uax29, clipperhouse/displaywidth, lucasb-eyer/go-colorful, xo/terminfo, aymanbagabas/go-osc52, sahilm/fuzzy, aymerick/douceur, dustin/go-humanize, ncruces/go-strftime, kevinburke/ssh_config, sergi/go-diff, jbenet/go-context
BSD 20: golang.org/x/{crypto,net,sys,text}, ProtonMail/go-crypto, cloudflare/circl, go-git/gcfg, gopkg.in/warnings.v0, dario.cat/mergo, emirpasic/gods, google/uuid, remyoudompheng/bigfft, gorilla/css, microcosm-cc/bluemonday, atotto/clipboard, spf13/pflag, modernc.org/{libc,mathutil,memory,sqlite}
Apache-2.0 7: go-git/go-git, go-git/go-billy, pjbgf/sha1cd, golang/groupcache, skeema/knownhosts, xanzy/ssh-agent, spf13/cobra
BSD-3-Clause and MPL-2.0 1: cyphar/filepath-securejoin

modernc.org/sqlite also carries SQLite's public-domain dedication, an MIT file for sqlite-vec, and a third-party notice; modernc.org/memory a logo's link. LICENSE is now MIT, copyright guilbep, 2026, and the web package's license field with it. The web app's npm packages, built into web/dist and embedded, are scanned too, by TestLicencesOfTheWebApp: each entry of web/package-lock.json not marked dev, its license checked against an allow-list, the licences above plus ISC and 0BSD, with no network.

The switch (git-board switch): a dry run on a mirror of the repository by default, --apply to write refs/board/* here. It reads the fork's data with its binary, read-only, on the mirror; pulls only; joins the fork's mirrors to their originals (Switching from the fork); keys the numbers again; carries the release records; and runs the next sync's push on a copy, its writes answered in the process, to show what it would create.

  • The fork's form, seen on guilbep/git-bug-sync-test and, in a dry run of the switch on a copy of the wiki, on its GitLab: its marker, <!-- git-bug:<64 hex> -->, alone on a text's first line; for a text it posted for someone else, <!-- git-bug:<id> attributed --> and the header *Originally posted by <name> on <date>, [view on GitHub](<url>)*, whose URL names the original issue, or the original comment by #issuecomment-<id>. The fork trimmed its copies' ends.
  • The fork's links are its issues' metadata, from git bug bug --format json: sync:<key>:number and :url, and the older github-url and gitlab-url. A GitLab URL names its project by path, the settings maybe by id: it goes to a forge of its host named by id only when that project's path_with_namespace, read once per switch through the adapter's project read, is the URL's, and the issue has no sync:<key>: metadata for that forge. A forge whose path can't be read leaves the issue unplaced, and the report says so.
  • The release records carry over. Each is a chain of commits under refs/releases/<id>, each tree an ops file of git-board's own four operations (types 1 to 4: create, set-notes, comment, yank) and an edit clock. The switch copies them in edit-clock order into the release kind, their authors the persons with their login, and skips a commit that has a record already.
  • Who you are carries over. git-bug's identity is the clone's git config git-bug.identity, a 64-hex id. The switch finds its person as it finds a release author's: the fork's user of that id (git bug user --format json), by login among the persons' accounts, else by name. The report says "You: ", and --apply sets board.me to that person, unless it is set already.
  • The switch's mark is refs/board/switched, which --apply writes last: a commit of nothing, at the switch's time. A repository with refs/bugs and no mark is still git-bug's: a forge sync refuses it, and so does git-board sync, which says to run git-board switch. A sync entity, as the trials left, doesn't count. The mark goes to the remote with the entities, and a clone without one takes the remote's when it pulls.
  • The reset's mark is refs/board/reset, which git board reset --hard writes: a commit of a nonce at the reset's time, so that no two resets write the same one. git board push --prune sends it, git board reset --hard <remote> takes the remote's, and each fetch brings the remote's to refs/board-remotes/<remote>/reset. A pull or a push between two boards whose marks differ refuses, naming the side that started over and the command that settles it: merged, the old board and the refilled one would hold every issue twice, both linked to the same forge issue. A clone with no issue, no person and no mark takes the remote's (docs/prd/sync-sides.md).
  • Comment metadata isn't carried. The pull rebuilds the comments from the forges, which hold none, and git bug bug show --format json gives a comment's author, human_id, id and message only, so the switch can't read it either: the report says so, once. Neither real repository holds any. Every comment no forge has is listed, review comments among them: the switch reads each pulled issue's comments, so a comment only the forge has can't hide one. refs/bugs stays, until git board prune removes git-bug's data, once every clone has switched.
  • The numbers are keyed again on origin's refs/board/seq, which --apply makes this clone's, the re-key on top, unpushed. A fetch of the numbers (git-board sync, numbers, the web app's Sync) takes origin's only when this clone's is behind it or the same, and otherwise refuses: taking it back would drop the re-key, and the next numbering would give every switched issue a new number. The whole sync's git step sends a counter strictly ahead of origin's first (a fast-forward, which git refuses otherwise), so after --apply the next git-board sync carries the re-key; one apart from origin's is refused: --apply again.
  • The sync entity needs no snapshot yet. A pull of 3,000 fake issues, two comments each, makes 21,000 operations: loadState reads them in 0.25 s, Records in 0.20 s, and a second pull takes 2.3 s (M4 Pro).