refsync

package
v0.5.8 Latest Latest
Warning

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

Go to latest
Published: Oct 30, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package refsync connects the ticket store to the ref a ticket travels on.

It is the only place that knows all three: core/ticket writes the file, core/gitref carries it on refs/jaira/tickets/<id>, core/outbox holds what has not been sent. Keeping that knowledge here is what lets core/ticket stay unaware of git and core/gitref stay unaware of tickets.

Nothing here pushes on the write path. A ticket write is recorded into the outbox and returns immediately, because the write path runs inside the TUI and inside the git merge driver as well as in the CLI, and none of those may wait for a network round trip. Sending is a separate act (Flush), run where waiting is acceptable.

Index

Constants

View Source
const DefaultFetchEvery = 10 * time.Minute

DefaultFetchEvery is how often the refs are brought up to date in the background.

Ten minutes, and the case it exists for decides the number: somebody who only uses the CLI and only reads — jaira list, next, show — never fetches at all, because reading must never wait for a remote. Without this, a ticket assigned to them does not exist until they happen to type 'jaira fetch'.

View Source
const FetchDisableEnv = "JAIRA_NO_FETCH"

FetchDisableEnv switches the background fetch off, and is also the recursion guard handed to the spawned child.

Variables

View Source
var ErrTaken = errors.New("refsync: the ticket already belongs to someone else")

ErrTaken means the ticket on the ref already belongs to somebody else.

It is a separate refusal from a lost race, and both are needed. The compare-and-swap only rules out two writes landing in the same instant; it cannot say "this ticket is not yours", because a pull re-reads the ref immediately before writing and would therefore always hold a current lease. Ownership is a fact recorded in the ticket, so it is checked as one.

View Source
var ErrUnsent = errors.New("refsync: the ticket has a write here that has not been sent")

ErrUnsent means the file here holds a write that has not reached the remote, so bringing it up to the ref would throw that write away.

Functions

func FetchTask

func FetchTask(stateDir, boardRoot string, every time.Duration) bgrun.Task

FetchTask is the background job: fetch the ticket refs when the last fetch is older than every.

Everything careful about running it in the background lives in core/bgrun, shared with the snapshot, so the stamp-before-run rule and the recursion guard exist once rather than once per task.

Types

type Arrival

type Arrival struct {
	ID       string `json:"id"`
	Title    string `json:"title"`
	Status   string `json:"status"`
	Assignee string `json:"assignee"`
	SHA      string `json:"sha"`

	// Mine says the ticket is assigned to this user. It is the whole reason
	// this exists: being handed a ticket is the event nobody currently learns
	// about.
	Mine bool `json:"mine"`

	// Changed says the ref moved since this clone last looked. A ticket that
	// has been sitting there assigned to me for a week is not news, and
	// announcing it on every fetch would train the user to ignore the thing.
	Changed bool `json:"changed"`

	// Local says a ticket file for this id exists in the working tree. False
	// means the ticket reached this clone only on its ref — the case a
	// teammate's unmerged branch cannot deliver.
	Local bool `json:"local"`
}

Arrival is a ticket as the ref now has it, seen from this clone.

type Departure

type Departure struct {
	ID    string `json:"id"`
	Title string `json:"title"`
	Path  string `json:"path"`
}

Departure is a ticket whose ref is gone while the file is still here.

type MilestonesFetched added in v0.5.0

type MilestonesFetched struct {
	Wrote, Filed, Moved []string
	Renamed             map[string]string
	Conflicts           []milestone.Conflict
}

MilestonesFetched is what one fetch of milestones did on disk.

type Pulled

type Pulled struct {
	ID    string `json:"id"`
	Title string `json:"title"`
	Path  string `json:"path,omitempty"`

	// AlreadyHere means the file was already on this machine and nothing
	// needed doing. A pull is safe to repeat, so this is a success.
	AlreadyHere bool `json:"already-here"`

	// Winner is set when the ref refused the take-over: somebody else got
	// there first, and no file was written.
	Winner *Winner `json:"winner,omitempty"`

	// Was is set when the file was already here but older than its ref, and
	// the pull brought it up: the state the file was in, beside Now, the state
	// it carries since. Nothing on the ref changes in that case, so nobody's
	// claim on the ticket does either.
	Was string `json:"was,omitempty"`
	Now string `json:"now,omitempty"`

	// TakenFrom names who the ticket was taken from, when it was taken with
	// --steal. A silent steal would leave the other person's board saying the
	// ticket is theirs, with nothing anywhere saying why it stopped being.
	TakenFrom string `json:"taken-from,omitempty"`
}

Pulled reports what a pull did.

type Reconciled

type Reconciled struct {
	ID string `json:"id"`

	// Content is what to show. On a clean merge it is the two sides merged; a
	// ticket that exists on only one side is that side unchanged.
	Content []byte `json:"-"`

	// Conflicts are the prose fields the merge could not settle. They are the
	// existing conflict path, not a new one: the same fields 'jaira resolve'
	// handles after a git merge.
	Conflicts []merge.Conflict `json:"conflicts,omitempty"`

	// RefOnly says the ticket reached this clone on its ref alone — it is in
	// no branch here. This is the case the whole feature exists for.
	RefOnly bool `json:"ref-only"`

	// Unsent says this clone holds a write for the ticket that has not reached
	// the remote yet.
	Unsent bool `json:"unsent"`
}

Reconciled is one ticket as the board should show it: the local file and the ref brought together, with the two state markers the user needs.

type Released

type Released struct {
	ID      string `json:"id"`
	Title   string `json:"title"`
	Removed string `json:"removed,omitempty"`

	// NotYours is set when the ticket is not this user's to release. Handing
	// somebody else's work back is not a thing one person decides for another.
	NotYours *Winner `json:"not-yours,omitempty"`
}

Released reports what a release did.

type Report

type Report struct {
	outbox.Result
	Winner *Winner `json:"winner,omitempty"`
	// Merged is set on a refused milestone write whose edit was merged into
	// the version that won, and queued again; Conflicts are the fields both
	// sides changed, where the winner's value was kept.
	Merged    bool                 `json:"merged,omitempty"`
	Conflicts []milestone.Conflict `json:"conflicts,omitempty"`
}

Report is one queued write's fate, with the other side named when we lost.

type StaleError added in v0.3.7

type StaleError struct {
	ID        string
	File, Ref *ticket.Ticket
}

StaleError is the refusal for a ticket file that is older than its ref. It names both states, because "your file is stale" alone does not tell anyone what writing it would have thrown away.

func (*StaleError) Error added in v0.3.7

func (e *StaleError) Error() string

func (*StaleError) Unwrap added in v0.3.7

func (e *StaleError) Unwrap() error

type Stranded

type Stranded struct {
	ID    string        `json:"id"`
	Title string        `json:"title"`
	Path  string        `json:"path"`
	Since time.Duration `json:"-"`
	Days  int           `json:"days"`
}

Stranded is a ticket that was finished here and has not arrived anywhere everybody can see it.

type Syncer

type Syncer struct {
	Repo *gitref.Repo
	Box  *outbox.Box

	// Actor is who the queued write is from, so a flush can say whose write it
	// is sending and a rejection can name both sides.
	Actor string

	// Store is the working tree's tickets, used only to tell a ticket that
	// arrived on a ref alone from one this clone already has a file for.
	Store *ticket.Store

	// IsMine decides whether an assignee is this user. It is a function rather
	// than a name because "me" includes the aliases a person has recorded, and
	// core/identity already knows how to answer it — this package should not
	// hold a second opinion.
	IsMine func(assignee string) bool

	// SeenPath records which ref SHA was last reported for each ticket, so
	// being handed a ticket is announced once instead of on every fetch.
	SeenPath string
	// contains filtered or unexported fields
}

Syncer records ticket writes for the remote and sends them.

func New

func New(s *ticket.Store, remote, actor string) *Syncer

New builds a syncer for a store, or returns nil when this board has no business talking to a remote: no git, no repository, or no remote configured. A nil *Syncer is a valid recorder that records nothing, so callers do not need to branch.

func (*Syncer) Ahead added in v0.5.7

func (y *Syncer) Ahead(files []*ticket.Ticket) map[string]*ticket.Ticket

Ahead reports, for each of the given ticket files, the ticket its ref carries when the ref is ahead of the file — "ahead" exactly as pull and CheckFresh judge it (isStale). It satisfies ticket.AheadSource.

It runs on the board's two-second refresh, so it costs one 'git for-each-ref' and reads only refs whose hash it has not decoded before, sharing Extra's cache: a hash is a content address.

func (*Syncer) CheckFresh added in v0.3.7

func (y *Syncer) CheckFresh(t *ticket.Ticket) error

CheckFresh refuses a write whose file is older than the ticket's ref. It satisfies ticket.FreshnessChecker, so every write through the store asks it.

The local ref is the yardstick, not the remote: it is exactly the state a queued write leases against, so a file older than it is a file whose write the compare-and-swap would accept — and that is the whole problem. A file read from an older branch carries a perfectly current lease. Reading the local ref also keeps a write off the network.

The comparison trusts clocks, and skew between machines can refuse a write that is in fact current: a teammate whose clock runs ahead writes the ref, this clone then writes offline with an earlier stamp, and since the local ref only moves after a push, the next write here reads as stale — and pull refuses too, over the unsent write — until the remote is reachable again.

func (*Syncer) Departed

func (y *Syncer) Departed() []Departure

Departed reports tickets somebody else has taken off the board while a file for them is still sitting here.

The signal is a ref that this clone has seen before and cannot see now. Deleting the ref is exactly what logging or archiving a ticket does, so its absence is the board saying "this is finished elsewhere". Without noticing it, the next merge keeps both paths and the ticket lives twice — closed for its owner, open here.

Nothing is moved. Which of the two is right is a question for the person, and a tool that guessed would sometimes reopen finished work; the caller reports this and names the command that settles it.

func (*Syncer) Dirty

func (y *Syncer) Dirty() bool

Dirty reports whether this process queued a write. A caller uses it to decide whether flushing is worth a round trip at all.

func (*Syncer) EpicContent added in v0.5.1

func (y *Syncer) EpicContent(name string) ([]byte, error)

EpicContent reads one epic. An unsent write wins over the ref: it is newer, and with no file on disk it is the only copy of that edit.

func (*Syncer) EpicNames added in v0.5.1

func (y *Syncer) EpicNames() ([]string, error)

EpicNames lists every epic this clone knows: the local refs, and the epics written here that have not reached the remote yet.

func (*Syncer) Extra

func (y *Syncer) Extra(have map[string]bool) ([]*ticket.Ticket, error)

Extra hands the store the tickets this board can see but has no file for — the ones still travelling on their refs, unpulled. It satisfies ticket.TicketSource.

This is what keeps 'jaira list', 'jaira next', the board and the task mirror showing the whole board once an unworked ticket lives only on its ref. They all read through the store, so none of them has to know that refs exist.

func (*Syncer) FetchMilestones added in v0.5.0

func (y *Syncer) FetchMilestones(s *ticket.Store) (MilestonesFetched, error)

FetchMilestones writes the milestone files the refs carry, then moves old files to their ID names (milestone.Migrate), under the milestone lock every other milestone writer takes. It is what 'jaira fetch' and the board's background fetch both run: the lock is only held here, never across the network round trip the caller made before.

The error of Migrate comes back as it is, a *milestone.RecordError included: the caller decides how loud a ref that could not be queued is.

func (*Syncer) FetchRefs added in v0.5.0

func (y *Syncer) FetchRefs() bool

FetchRefs brings the refs here and reports whether it did. An unreachable remote is not an error: whoever asks carries on without it.

func (*Syncer) Flush

func (y *Syncer) Flush() ([]Report, error)

Flush sends what the outbox holds and reports each entry, naming who was quicker for every write the remote refused.

The fetch comes first, and deliberately: a queued write leases a SHA this clone has seen, so without fetching, the very first push would be refused for a race that has in fact already been resolved on the remote. Fetching also makes the winner readable, which is the difference between "you lost" and "Grace moved it to review".

func (*Syncer) ForgetDeparted

func (y *Syncer) ForgetDeparted(id string)

ForgetDeparted drops a ticket from the seen record, so it stops being reported once the person has dealt with it.

func (*Syncer) HasMilestoneRef added in v0.5.0

func (y *Syncer) HasMilestoneRef(id string) bool

HasMilestoneRef reports whether this tree has the ref of the milestone with this ID. A syncer that is nil or without a remote has no refs.

func (*Syncer) Incoming

func (y *Syncer) Incoming() ([]Arrival, error)

Incoming fetches the ticket refs and reports what they now say.

It is the read half of the feature: a fetch of refs/jaira/tickets/* costs one round trip and needs no branch, no checkout and no merge, so a clone can be told "this is yours now" without anyone having pushed a branch.

func (*Syncer) IncomingMilestones added in v0.3.0

func (y *Syncer) IncomingMilestones(root string) (wrote, filed []string, renamed map[string]string, conflicts []milestone.Conflict, err error)

IncomingMilestones writes to disk every milestone file the refs carry that this working tree does not already have in the same state, and reports the names it wrote.

It writes rather than merely reporting because a milestone has no local editor in jaira the way a ticket has a lane: the file IS the interface, and a group somebody planned is of no use as a ref nobody has checked out. The ref is the newer state by construction — it is what the remote accepted — so a local file that differs is overwritten, and the previous content is in git if it was ever committed. The one exception is a milestone with a write still queued in the outbox: that file is newer than the ref and is left alone until the write has been sent or rejected.

A milestone's ref is named after its ID and carries the file under its current name, so a rename arrives as the same ref with another file name. That is the one thing a fetch removes: every other file on this disk with the ref's ID is the milestone under its old name — or a copy git merged in beside it — and the ref decides which is right, before the file is compared. A file with another ID is another milestone, whatever it is called, and is never touched. The names removed this way come back as renamed, old name to new.

A ref named after an old name — written before IDs existed — is read as the ID LegacyID gives that name. It is skipped once a milestone has moved on from it (the ID ref is here, or is on this disk); the mark a rename of that era left removes the old file this disk may still have and is reported as a rename; otherwise it is taken like an old file and migrated by the next write.

A ref whose file says it has been filed is written over a file this tree already has: the marked line is what keeps a milestone off the board, so writing it is how a clone that already has the file learns it was filed at all — which is the one thing that ref exists to carry. Skipping it would leave that clone showing a round of work everybody else has closed. Those names come back separately, because "this group left the board" is different news from "this group changed".

A filed milestone this tree does NOT have is not written: there is nothing here to correct, and putting the file on disk would leave a second copy beside the one in the logbook of the tree that filed it — which is the tree 'jaira restore' would then refuse, because the milestone is back on the board.

func (*Syncer) MigrationBase added in v0.5.0

func (y *Syncer) MigrationBase(id string) (milestone.Base, bool)

MigrationBase is the base the ID ref's migration commit gives: the migrated content and the old ref's tip it came from. A ref without one was created with its ID, or moved there by a build of this change from before the migration commit existed; milestone.AdoptOld merges an old side of such a milestone against an empty base.

func (*Syncer) OldRefHad added in v0.5.0

func (y *Syncer) OldRefHad(name string, content []byte) bool

OldRefHad reports whether content is a state the ref of an old milestone name has carried. Such an old file is a stale copy, not an edit.

func (*Syncer) OldRefSHA added in v0.5.0

func (y *Syncer) OldRefSHA(name string) string

OldRefSHA is the tip of the ref an old milestone name had before IDs, "" when this tree has none. Migrate records it with the base.

func (*Syncer) Pending

func (y *Syncer) Pending(id string) (outbox.Entry, bool)

Pending reports an unsent write for one ticket, for the marker on a board card.

func (*Syncer) PendingAge

func (y *Syncer) PendingAge(id string) time.Duration

PendingAge is how long this ticket's write has been waiting, or zero.

func (*Syncer) Pull

func (y *Syncer) Pull(id string, steal bool) (*Pulled, error)

Pull takes a ticket that lives on its ref and makes it this clone's to work on: it claims the ticket on the ref and only then writes the file here.

Two things gate it, and neither is enough alone:

  • The ref's assignee. A ticket somebody else has already pulled is refused with ErrTaken, naming them. This is the check that makes "one clone has the file" true, because a pull re-reads the ref right before writing and so would otherwise always hold a current lease.
  • The compare-and-swap on the push. That covers the remaining case the first check cannot see: two clones pulling in the same instant, each having read an unassigned ticket.

The order is the other half of the mechanism. The push goes first. Writing the file first would leave the loser holding a ticket file that belongs to somebody else — the duplicate this design exists to make impossible rather than to resolve.

This is also the one write in jaira that is synchronous and does not go through the outbox. That is not an exception to the offline rule but a consequence of it: with no route to the remote there is no ref to read, so there is nothing to take over in the first place.

func (*Syncer) Reconcile

func (y *Syncer) Reconcile(id string, lanes *lane.Set) (*Reconciled, error)

Reconcile brings the local ticket file and the ref together for one ticket.

It is deliberately not "show whichever is newer". The two sides are merged field by field by core/merge, which is the same resolver the git merge driver uses: status by progress along the lane chain (never backwards), lists by union, other scalars by updated-at, prose as a real conflict. A newer local edit must not drag a ticket back out of review because someone touched it after the reviewer did.

The base for that merge is the ref's parent blob — the state both sides started from — which is available precisely because a ref commit keeps its leased SHA as parent.

func (*Syncer) Record

func (y *Syncer) Record(id string, content []byte) error

Record queues the ticket's current bytes for its ref. It satisfies ticket.WriteRecorder.

The lease is the local ref's SHA, which is only ever moved after the remote has accepted a push — so it is exactly "the state the remote last confirmed to us", which is what a compare-and-swap has to be checked against. A ticket with no ref yet leases the empty string, meaning "I expect this ref not to exist".

func (*Syncer) RecordDelete

func (y *Syncer) RecordDelete(id string) error

RecordDelete queues the removal of a ticket's ref, for a ticket that has been logged or archived. It goes through the same queue as a write so that taking a ticket off the board works offline too — otherwise the last act of a finished ticket would be the one act that needs a network.

func (*Syncer) RecordEpic added in v0.5.1

func (y *Syncer) RecordEpic(name string, content []byte) error

RecordEpic queues an epic's whole content for its ref, leased against the SHA the remote last confirmed — the same rule a ticket write follows.

func (*Syncer) RecordFiled

func (y *Syncer) RecordFiled(id string, content []byte) error

RecordFiled queues a ticket's final state for its ref, for a ticket that has been logged or archived here.

Filing does not remove the ref, and that is the point. At this moment the ticket file exists only in this clone's branch; until that branch is merged, nobody else can see the ticket. Taking the ref down as well would open a window as long as a review, in which the ticket is invisible to everybody — and somebody who notices the same problem writes it down a second time, with the same solution.

The ref goes later, when the ticket has arrived where everybody can see it. That is the snapshot run's job (core/snapshot), which removes a ref only after writing the ticket into the snapshot branch, so at the moment of deletion the ticket exists in two places.

func (*Syncer) RecordMigration added in v0.5.0

func (y *Syncer) RecordMigration(key, file string, migrated []byte, from string) error

RecordMigration queues a milestone's move to its ID as the commit of its own the ref's base is derived from: the migrated content, and from, the old name ref's tip it came from.

func (*Syncer) RecordMilestone added in v0.3.0

func (y *Syncer) RecordMilestone(key, file string, content []byte) error

RecordMilestone queues a milestone file's current bytes for its ref, under the same rules as a ticket: the whole file, leased against the SHA the remote last confirmed, sent later.

A milestone travels on a ref for the reason it exists at all. It is the one file everybody plans from, and the whole point of planning in a file is that twenty tickets are grouped in one edit — if that edit then waited for a branch to be merged, the file everybody is supposed to read would be the file nobody has.

func (*Syncer) Release

func (y *Syncer) Release(id string, force bool) (*Released, error)

Release hands a ticket back: it clears the assignee on the ref and removes the file from this clone, in that order.

It is the other half of Pull, and without it an assignment is a reservation nobody can give back. The order matters for the same reason: the ref is cleared first, so a failure leaves the ticket still yours rather than unowned-but-still-on-your-disk.

The local file is removed rather than kept, because keeping it would put a second copy of a ticket somebody else may now pull into a branch — the duplicate this whole design exists to make impossible. Nothing is lost: the ref carries the bytes, and the work already committed stays in the history.

func (*Syncer) SpawnFetch

func (y *Syncer) SpawnFetch(stateDir, boardRoot string, every time.Duration)

SpawnFetch starts a detached fetch if one is due, and never waits for it.

boardRoot is not optional: the child inherits this process's working directory and knows nothing of a -C flag, so without it a fetch spawned from 'jaira -C somewhere list' would refresh whatever board the shell is standing in — or none at all.

func (*Syncer) Stranded

func (y *Syncer) Stranded(landing []string, grace time.Duration) []Stranded

Stranded reports tickets filed away here whose ref is still standing because they have not landed in any landing branch.

This is the cost of not deleting a ref at filing time, and it has to be visible: a branch that is never merged would otherwise keep its ref for ever, and the board would carry a finished ticket nobody can account for. Naming it is the whole fix — 'jaira snapshot --drop' is the way out, and it is deliberately a person's decision.

func (*Syncer) Usable

func (y *Syncer) Usable() error

Usable reports whether this board carries tickets on refs. A board with no repository or no remote is not an error — it is jaira used the way it also works, and the answer is simply no.

type Winner

type Winner struct {
	ID        string `json:"id"`
	Assignee  string `json:"assignee,omitempty"`
	Status    string `json:"status,omitempty"`
	UpdatedBy string `json:"updated-by,omitempty"`
	UpdatedAt string `json:"updated-at,omitempty"`
}

Winner describes the state that beat a rejected write, read from the ref itself. A rejection that only says "you lost" leaves the user to go and find out who and what — which they cannot do without knowing the ref exists.

func (Winner) Describe

func (w Winner) Describe() string

Describe renders the winner as the one line a command can print.

func (Winner) Holds

func (w Winner) Holds() string

Holds renders the same state as a statement of ownership rather than of a race. The two refusals are different facts and must not borrow each other's wording: "grace wrote it first" is about who won a push, "grace has it" is about who the ticket belongs to.

Jump to

Keyboard shortcuts

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