refsync

package
v0.1.4 Latest Latest
Warning

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

Go to latest
Published: Sep 11, 2026 License: MIT Imports: 15 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.

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 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"`

	// 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"`
}

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

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) 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) 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) 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) 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) 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) 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) 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