// Package zitcli is the subprocess boundary to zit (0001 §2): the zit
// binary is the only store interface — never linked code, never direct
// file reads. This package is a data plane; the arch gate restricts who
// may import it.
package zitcli
import (
"bufio"
"bytes"
"context"
"craftdesign.group/quarry/internal/ops"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"craftdesign.group/quarry/internal/children"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/obs"
"craftdesign.group/quarry/internal/timing"
"craftdesign.group/quarry/internal/fault"
)
// ErrCursorInvalid reports that the store was rewritten (pack, shun
// reclaim) under a consumer's cursor. The remedy is a re-read from zero;
// derived indexes rebuild, so the loss is detectable, never silent.
var ErrCursorInvalid = errors.New("zitcli: cursor invalid: store was rewritten; re-read from zero")
// ErrBusy is what a store command answers when the forge is already
// running as many as it allows. It wraps ops.ErrUnavailable so every
// surface that already knows how to say "try again shortly" does.
var ErrBusy = fmt.Errorf("store busy: %w", ops.ErrUnavailable)
// ErrUnsafeArgument is an operand that would have reached zit as an
// OPTION. ErrInvalid rather than an internal fault: the request named
// something the forge will not pass on, and saying so is a 400.
var ErrUnsafeArgument = fmt.Errorf("unsafe argument: %w", ops.ErrInvalid)
// ErrStopped is a child that did not fail — it was KILLED, because the
// forge itself was stopping. systemd SIGTERMs the whole cgroup on a
// restart, so a deploy landing mid-operation takes the zit subprocess
// with it.
//
// It reported as a generic 500 ("Something went wrong serving this
// page"), which is the wrong answer twice over: nothing went wrong
// with the request, and the reader is left unable to tell a broken
// accept from a half-finished one. ExitCode() is -1 for a signalled
// process, which falls through every exit-status switch; the signal
// has to be asked about directly.
//
// Unavailable, not invalid: the honest reading is "ask again in a
// moment", which is exactly what ops.ErrUnavailable already means
// everywhere else in the forge.
var ErrStopped = fmt.Errorf("the subprocess was killed: %w", ops.ErrStopping)
// signalled reports that a child was terminated by a signal rather
// than exiting with a status of its own.
func signalled(err error) bool {
var ee *exec.ExitError
if errors.As(err, &ee) {
if ws, ok := ee.Sys().(syscall.WaitStatus); ok {
return ws.Signaled()
}
}
return false
}
// busyWait is how long a caller waits for a subprocess slot before it
// is told busy. Long enough to ride out a burst, short enough that a
// person sees a page and not a spinner.
const busyWait = 5 * time.Second
// admission is a counting semaphore over store subprocesses, with the
// counters an operator needs to explain a busy answer after the fact.
type admission struct {
mu sync.Mutex
n int
slots chan struct{}
// Counters, under mu.
admitted, refused int
waited time.Duration
}
// Lane is one admission lane as the ops page shows it.
type Lane struct {
Name string `json:"name"`
Bound int `json:"bound"`
Running int `json:"running"`
Admitted int `json:"admitted"`
Refused int `json:"refused"`
WaitedMs int64 `json:"waitedMs"`
// Waiting and Queue are the DEPTH: how many are queued behind the
// running ones, and how many may be. A lane that is refusing is
// either at its queue cap or holding slots too long, and running
// and refused alone cannot tell those apart — which is what made
// ten thousand "a store could not be ingested" rows unactionable.
// Zero Queue means the lane does not queue at all.
Waiting int64 `json:"waiting"`
Queue int `json:"queue"`
// Items is what the lane is holding right now, oldest first.
// Counters say a lane refused 9,871 callers; these say the three
// it is holding are pkgsrc, tcl and sqlite, and for how long.
Items []LaneItem `json:"items,omitempty"`
}
// LaneItem is one queued or running unit, as a reader needs it.
type LaneItem struct {
Verb string `json:"verb"`
Subject string `json:"subject,omitempty"`
AgeMs int64 `json:"ageMs"`
Running bool `json:"running"`
}
// StoreLane reports the store-subprocess lane since the process started.
func StoreLane() Lane {
gate.mu.Lock()
defer gate.mu.Unlock()
return Lane{Name: "store subprocesses", Bound: gate.n, Running: len(gate.slots),
Admitted: gate.admitted, Refused: gate.refused, WaitedMs: gate.waited.Milliseconds()}
}
var gate = newAdmission(max(2, runtime.NumCPU()))
func newAdmission(n int) *admission {
return &admission{n: n, slots: make(chan struct{}, n)}
}
// SetParallel bounds how many store subprocesses may run at once,
// process-wide. Called once at startup, from the --zit-parallel flag.
func SetParallel(n int) {
if n < 1 {
n = 1
}
gate.mu.Lock()
defer gate.mu.Unlock()
gate.n, gate.slots = n, make(chan struct{}, n)
}
// Parallel reports the current bound.
func Parallel() int {
gate.mu.Lock()
defer gate.mu.Unlock()
return gate.n
}
func (a *admission) acquire(ctx context.Context) bool {
a.mu.Lock()
slots := a.slots
a.mu.Unlock()
select {
case slots <- struct{}{}:
a.count(true, 0)
return true
default:
}
start := time.Now()
t := time.NewTimer(busyWait)
defer t.Stop()
select {
case slots <- struct{}{}:
a.count(true, time.Since(start))
return true
case <-ctx.Done():
a.count(false, time.Since(start))
return false
case <-t.C:
a.count(false, time.Since(start))
return false
}
}
func (a *admission) count(admitted bool, waited time.Duration) {
a.mu.Lock()
defer a.mu.Unlock()
if admitted {
a.admitted++
} else {
a.refused++
}
a.waited += waited
}
func (a *admission) release() {
a.mu.Lock()
slots := a.slots
a.mu.Unlock()
select {
case <-slots:
default:
}
}
// storeFault is what a failed zit invocation looks like in the journal:
// one sentence per verb, with the store, the exit status and what the
// command said on stderr as fields.
//
// Built here rather than at each call site because the journal groups
// on the message, and a message carrying a URL or a check-in hash — as
// "zitcli: tag add ci-fail <hash>" did — is a new row per hash, which
// is how one broken verb becomes a page of noise.
func (c *Client) storeFault(err error, verb, stderr string, kv ...any) error {
// The store PATH describes the forge's filesystem, not the request,
// so it goes to the log and not to the client.
//
// It used to go to both: a 404 body carried
// "command=zit ls store=/srv/quarry/repos/quarry version=-trunk",
// readable by anyone who could provoke the error, anonymously. That
// is how the argument injection was found — the leaked command line
// is what showed a URL segment sitting in argv. Closing the
// injection does not close this: the path is still in every store
// fault a stranger can trigger.
//
// The VERB stays public. "zit cat" tells a caller which operation
// failed and reveals nothing they could not infer from the product.
f := fault.Wrap(err, "a store command failed").
Cost("whatever needed this store read or write did not complete").
With("command", "zit "+verb).
WithPrivate("store", c.repo)
f.With(kv...)
if s := strings.TrimSpace(stderr); s != "" {
f.With("stderr", firstLine(s))
}
return f
}
// firstLine keeps the line a command failed on. zit's stderr can run to
// a usage block; the first line is where it says what it objected to.
func firstLine(s string) string {
line, _, _ := strings.Cut(s, "\n")
if len(line) > 300 {
line = line[:300] + "…"
}
return line
}
// Cursor is an opaque resume position from a prior Frames call. The zero
// Cursor reads from the beginning (bootstrap). Only round-tripping the
// values is supported — their content belongs to zit.
type Cursor struct {
Since string
Verify string
}
// Artifact is one whole artifact as streamed by zit — delta storage is
// resolved before emission, so Bytes hash to Hash.
type Artifact struct {
Hash string
Bytes []byte
}
// Batch is one Frames result: the artifacts appended since the request
// cursor, and the cursor to pass next time.
type Batch struct {
Artifacts []Artifact
Cursor Cursor
}
// Client runs zit against one repository path (a checkout directory or
// the .zit file itself, per zit -R).
type Client struct {
repo string
// lsCache memoises file listings. A check-in's file set is IMMUTABLE
// — the same version always yields the same listing — so a cached
// entry can never go stale, which is what makes caching safe here
// rather than merely convenient.
//
// It exists because `ls --long` costs roughly 4ms per file: it reads
// each blob for size and the binary flag, so tcl's 2,198-path tip
// takes 8.5 seconds. Fossil never pays that — its directory listing
// comes from its own indexed tables and shows no size at all — but
// quarry reads through the CLI by design (0001 §2), so the choice is
// to compute once rather than to index separately.
lsMu sync.Mutex
lsCache map[string][]LsEntry
// lsFlight is the listing currently being computed for a key, so
// concurrent callers wait for it rather than each spawning their
// own zit. Without it a cold cache turns N simultaneous readers of
// one page into N subprocesses of the same expensive command.
lsFlight map[string]*lsCall
// diffCache memoises check-in diffs under the same contract as
// lsCache: a pair of immutable check-ins always diffs the same, so
// an entry cannot go stale. It exists because the check-in page
// costs TWO subprocesses (unified plus --brief) at ~75ms each even
// when the unified half is then discarded for blowing the render
// budget — and a reader walking a timeline revisits the same
// check-ins constantly.
diffs *diffCache
// mergeCache memoises the landing mergeability probe under the same
// immutability contract, keyed on the PAIR OF TIPS rather than on
// the landing: whether a branch merges is a function of the two
// check-ins and nothing else, so two landings between the same tips
// share an answer and a moved tip is a different key.
//
// It existed because the probe used to be a full-store scan: `landing
// show` had to FIND the record before answering about it, which measured
// 2.0s on this forge's own store and made the landing page the slowest
// surface here by tenfold. zit#134 removed that cost — the probe now
// names the two tips and skips the walk — so the cache is no longer
// load-bearing for latency.
//
// It is kept because the merge computation itself is still real work
// proportional to the changed file set, and because the key's contract
// (a moved tip is a different key) is worth more than the microseconds
// it saves: it is what stops a stale "merges clean" outliving the push
// that falsified it.
mergeMu sync.Mutex
mergeCache map[string]mergeResult
stackCache map[string][]RungProbe
// blobs memoises file CONTENT, which is what the file page pays for:
// zit cat costs ~147ms even for a small file, against a 3ms process
// floor, because resolving a path at a version reads the manifest.
// Byte-bounded because the values are arbitrary file contents.
blobs *blobCache
}
func New(repo string) *Client {
return &Client{
repo: repo,
lsCache: map[string][]LsEntry{},
lsFlight: map[string]*lsCall{},
blobs: newBlobCache(blobCacheBytes),
diffs: newDiffCache(diffCacheBytes),
}
}
// blobCacheBytes is one repository's content budget. 64 MiB holds a
// browsing session's worth of source files with room to spare, and
// eleven repositories at that budget is a ceiling the process can
// actually afford — the whole point of bounding by bytes is that the
// worst case is a number someone chose rather than a number the corpus
// chose.
const blobCacheBytes = 64 << 20
// command builds a zit invocation bound to the request: cancelling the
// request kills the child, and the call is attributed to the zit
// category so the page can report what the store cost it.
//
// Cancellation matters more than it looks. Without it an abandoned
// request leaves its subprocess running to completion — and `ls --long`
// on tk runs for 2.6 seconds, so a reader who navigates away still costs
// the machine the full amount.
func (c *Client) command(ctx context.Context, args ...string) (*exec.Cmd, func()) {
if ctx == nil {
ctx = context.Background()
}
// Every invocation passes here, which is why the guard lives here
// rather than in the dozen typed methods that take a user-supplied
// path, version or ref: a new call site is covered the day it is
// written.
if err := checkArgs(args); err != nil {
cmd := exec.CommandContext(ctx, "zit", args...)
cmd.Err = err
return cmd, func() {}
}
start := time.Now()
// Admission (ticket 8152617b99): at most gate.n store subprocesses
// run at once, process-wide. A caller that cannot get a slot within
// busyWait gets a command that fails with ErrBusy — an honest 503
// upstream, with Retry-After — rather than a queue that grows with
// the internet until the machine falls over. The wait honours the
// request's own cancellation.
if !gate.acquire(ctx) {
cmd := exec.CommandContext(ctx, "zit", args...)
cmd.Err = ErrBusy
return cmd, func() {}
}
released := false
release := func() {
if !released {
released = true
gate.release()
}
}
verb := ""
if len(args) > 0 {
verb = args[0]
}
cmd, untrack, err := zitChild(ctx, repoName(c.repo), verb, args...)
if err != nil {
release()
return cmd, func() {}
}
// Counted as LIVE from here, not from completion. The number that
// matters for a store subprocess is how many are running at once —
// one slow listing is a performance note, and the same listing
// thirteen times over is an incident — and a counter that only
// moves when work finishes cannot show the second.
obs.Default.Spawned()
// Which request this call is serving, empty for background work —
// and then which background activity, which is the only thing that
// tells an ingest round from a warm pass.
rid, reason := obs.RequestID(ctx), obs.Reason(ctx)
return cmd,
func() {
untrack()
release()
timing.Record(ctx, timing.Zit, start)
c.recordSpawn(rid, reason, verb, start)
}
}
// unattended builds a zit invocation for a call that has no request
// context to belong to: the write paths, which are deliberately NOT
// cancellable, and the streaming feeds that outlive any one render.
//
// It exists because "no context" was being spelled as "no bookkeeping".
// Ten call sites reached for exec.Command directly — every write plus
// the frames feed — and a subprocess started that way is invisible
// twice over: absent from the timing context, which a detached call
// could never fill anyway, and absent from the obs recorder, which is
// what the footer's store-busy figure and the /fleet/ops subprocess
// tally are both computed from. The accept route showed the result:
// 2.5 seconds, "of which zit: 0ms", on a request that spends half its
// time in a zit merge.
//
// Deliberately UNGATED, unlike command(). Admission refuses a caller
// that cannot get a slot, and a landing merge failing because a listing
// was busy would be a worse bug than the one this fixes; bringing
// writes under the cap is a separate decision with its own risk. What
// this changes is only whether the work is SEEN.
func (c *Client) unattended(args ...string) (*exec.Cmd, func()) {
return unattendedIn(repoName(c.repo), args...)
}
// unattendedIn is unattended for a caller that holds no Client — the
// scratch-checkout commit path, which opens its own tree against a
// store it is given rather than one it belongs to.
func unattendedIn(repo string, args ...string) (*exec.Cmd, func()) {
return spawnUnattended(context.Background(), repo, "", args...)
}
// writing builds a store write under tok: unattended by any request, as
// before, but killed the moment the lease tok was granted under is lost,
// so the write cannot go on committing while another node holds the
// store (#245). An Unguarded token has no lease to lose and never
// cancels.
func (c *Client) writing(tok lease.Token, args ...string) (*exec.Cmd, func()) {
return spawnUnattended(tok.Context(), repoName(c.repo), "", args...)
}
// underLease is ctx, also ended when the lease tok was granted under is
// lost: for a write that runs under a request and must stop at either.
func underLease(ctx context.Context, tok lease.Token) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
unhook := context.AfterFunc(tok.Context(), cancel)
return ctx, func() { unhook(); cancel() }
}
// writingFor is writing with the activity named. Passed as an argument
// rather than stashed somewhere ambient: several of these run at once,
// and a package-level "the reason for the next spawn" would attribute
// whichever finished first.
func (c *Client) writingFor(tok lease.Token, reason string, args ...string) (*exec.Cmd, func()) {
return spawnUnattended(tok.Context(), repoName(c.repo), reason, args...)
}
// writingIn is writing for a caller that holds no Client.
func writingIn(tok lease.Token, repo string, args ...string) (*exec.Cmd, func()) {
return spawnUnattended(tok.Context(), repo, "", args...)
}
func spawnUnattended(ctx context.Context, repo, reason string, args ...string) (*exec.Cmd, func()) {
start := time.Now()
verb := ""
if len(args) > 0 {
verb = args[0]
}
// No request cancels these: a write is not abandoned because its
// reader went away. ctx is the lease's, for a write, and a stop's
// expired wait still ends any of them.
cmd, untrack, err := zitChild(ctx, repo, verb, args...)
if err != nil {
return cmd, func() {}
}
obs.Default.Spawned()
return cmd, func() {
untrack()
recordSpawn(repo, "", reason, verb, start)
}
}
// zitChild builds a zit invocation as one of the process's children,
// so a stop can wait for it and log how it ended (children.Set). ctx
// cancels it with a kill, as exec.CommandContext does. Once a stop is
// waiting it refuses: the command then carries an error that fails its
// Run, Output or Start before anything is spawned, and reads upstream
// as the forge restarting.
func zitChild(ctx context.Context, repo, verb string, args ...string) (*exec.Cmd, func(), error) {
cmd, done, err := children.Default.Command(ctx, repo, verb, "zit", args...)
if err != nil {
cmd.Err = fmt.Errorf("%w: %w", err, ops.ErrStopping)
}
return cmd, done, err
}
// recordSpawn files one finished subprocess with the recorder. Shared
// so a caller cannot record half of it, which is how the two paths
// drifted apart in the first place.
func (c *Client) recordSpawn(rid, reason, verb string, start time.Time) {
recordSpawn(repoName(c.repo), rid, reason, verb, start)
}
func recordSpawn(repo, rid, reason, verb string, start time.Time) {
obs.Default.RecordSubprocess(obs.Subprocess{
RenderID: rid, Reason: reason, At: start, Verb: verb, Repo: repo,
Duration: time.Since(start),
})
}
// repoName is the store path's last element — the name a reader knows
// the repository by. The full path is the operator's own directory
// layout and says nothing a panel needs.
func repoName(path string) string {
return filepath.Base(strings.TrimSuffix(path, string(filepath.Separator)))
}
// lsCacheLimit bounds the memo. Listings are keyed by version, and a
// browsing session touches few; the cap is only so a long-lived process
// cannot accumulate one entry per check-in ever viewed.
const lsCacheLimit = 64
// lsCall is one in-flight listing. Waiters block on done and then read
// entries and err, which the owner writes before closing it.
type lsCall struct {
done chan struct{}
entries []LsEntry
err error
}
// lsShared memoises an immutable listing and collapses concurrent
// callers for the same key onto ONE subprocess.
//
// The cache alone was not enough: it was consulted, released, and only
// then did the caller shell out, so N readers arriving together on a
// cold key all missed and all spawned. On a store where the command
// takes 40 seconds that is 40 seconds multiplied by however many people
// opened the page, for a listing that is identical every time.
//
// A mutable version is never cached — "trunk" names whatever is newest,
// so memoising it would serve a stale tree forever — and is therefore
// never shared either: two readers of a moving target may legitimately
// get different answers.
func (c *Client) lsShared(ctx context.Context, key string, compute func() ([]LsEntry, error)) ([]LsEntry, error) {
c.lsMu.Lock()
if hit, ok := c.lsCache[key]; ok {
c.lsMu.Unlock()
return hit, nil
}
if call, ok := c.lsFlight[key]; ok {
c.lsMu.Unlock()
select {
case <-call.done:
return call.entries, call.err
case <-ctx.Done():
// This reader gave up; the owner carries on for the rest.
return nil, ctx.Err()
}
}
call := &lsCall{done: make(chan struct{})}
c.lsFlight[key] = call
c.lsMu.Unlock()
call.entries, call.err = compute()
c.lsMu.Lock()
delete(c.lsFlight, key)
if call.err == nil {
if len(c.lsCache) >= lsCacheLimit {
// Whole-map reset rather than an LRU: the cache is an
// optimisation, the cost of a miss is bounded, and an
// eviction policy here would be more machinery than the
// problem needs.
c.lsCache = map[string][]LsEntry{}
}
c.lsCache[key] = call.entries
}
c.lsMu.Unlock()
close(call.done)
return call.entries, call.err
}
// exit codes documented by `zit help frames`.
const (
exitNoNew = 3
exitCursorInvalid = 4
)
// Frames reads every artifact appended after c into memory and returns
// them together.
//
// PREFER FramesEach for anything that might face a real store: this
// materialises the whole census, which is 13.2GB of stream for sqlite
// and larger for tcl. It remains for tests and small fixtures, where
// having the slice is simpler than a callback.
func (c *Client) Frames(cur Cursor) (Batch, error) {
var b Batch
cursor, err := c.FramesEach(cur, func(a Artifact) error {
b.Artifacts = append(b.Artifacts, a)
return nil
})
b.Cursor = cursor
return b, err
}
// FramesEach calls fn for each artifact AS IT IS READ, returning the
// cursor to pass next time. Nothing accumulates: peak memory is one
// artifact plus whatever fn retains.
//
// The cursor arrives in a trailer after the last artifact, so it is
// returned rather than yielded — a caller saves it once the stream ends.
// If fn returns an error the read stops, the child is killed, and no
// cursor is saved; the artifacts already handed over will simply be
// seen again next round, which idempotent folds absorb.
//
// A current cursor yields nothing and returns the same cursor; a dead
// cursor returns ErrCursorInvalid.
func (c *Client) FramesEach(cur Cursor, fn func(Artifact) error) (Cursor, error) {
return c.framesEach(cur, false, fn)
}
// FramesEachStructural is FramesEach with content blobs elided at the
// source (zit#108): the census's bytes are mostly file contents the
// index discards after the framing gate — 13.2GB vs 3.87GB on sqlite,
// 6GB vs 682MB on tcl. Cursor semantics are identical.
func (c *Client) FramesEachStructural(cur Cursor, fn func(Artifact) error) (Cursor, error) {
return c.framesEach(cur, true, fn)
}
func (c *Client) framesEach(cur Cursor, structural bool, fn func(Artifact) error) (Cursor, error) {
args := []string{"frames", "-R", c.repo}
if structural {
args = append(args, "--structural")
}
if cur.Since != "" {
args = append(args, "--since="+cur.Since, "--verify="+cur.Verify)
}
cmd, done := c.unattended(args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return Cursor{}, err
}
if err := cmd.Start(); err != nil {
return Cursor{}, err
}
next, parseErr := parseFrames(bufio.NewReader(stdout), fn)
// An EOF means the child has ALREADY closed its output, so there is
// nothing to stop and its exit status is its own. Any other parse
// error means we are walking away from a stream still being written,
// and draining it costs minutes on a 13GB census.
abandoned := parseErr != nil && !errors.Is(parseErr, io.EOF)
if abandoned {
cmd.Process.Kill()
}
waitErr := cmd.Wait()
switch code := exitCode(waitErr); {
case code == exitCursorInvalid:
return Cursor{}, ErrCursorInvalid
case code == exitNoNew:
// Trailer-only output; the parsed cursor is authoritative.
case waitErr != nil && parseErr == nil:
return Cursor{}, c.storeFault(waitErr, "frames", stderr.String())
}
if parseErr != nil {
// A stream that stops early stops BECAUSE the child stopped, and
// the child said why on stderr. Reporting the parse symptom by
// itself — "reading frame header: EOF" — describes the shape of
// the silence and names no cause, which is how one store failing
// to ingest became ten thousand identical journal rows that told
// an operator nothing about which store or why.
//
// Only for an EOF we did not cause: when we killed the child,
// its status is our own signal and the parse error is the real
// story.
if !abandoned && (waitErr != nil || strings.TrimSpace(stderr.String()) != "") {
return Cursor{}, c.storeFault(waitErr, "frames", stderr.String(),
"stream", "ended before the cursor trailer")
}
return Cursor{}, fmt.Errorf("zitcli: frames: %w", parseErr)
}
return next, nil
}
// LsEntry is one file of a check-in as listed by `zit ls`.
//
// Size, Perm and Binary come from --long (zit#96). Before it existed a
// listing could show none of them: size and binary cost a subprocess
// per file, and the permission was unreachable at any price, because it
// lives in the manifest rather than the blob.
type LsEntry struct {
Blob string `json:"blob"`
Name string `json:"name"`
Size int64 `json:"size"`
// Perm is the raw F-card token: "-" for a regular file, "x"
// executable, "l" symbolic link.
Perm string `json:"perm"`
Binary bool `json:"binary"`
}
// Executable reports the x permission.
func (e LsEntry) Executable() bool { return strings.Contains(e.Perm, "x") }
// Symlink reports the l permission. A symlink's blob content is its
// TARGET path, so a viewer must not render it as file content.
func (e LsEntry) Symlink() bool {
return !e.Executable() && strings.Contains(e.Perm, "l")
}
// Ls lists the effective file set of a check-in (an on-demand read,
// 0001 §2: ~ms subprocess cost, off the hot path, cache above here).
func (c *Client) Ls(ctx context.Context, version string) ([]LsEntry, error) {
return c.ls(ctx, version, "")
}
// LsNames lists a check-in's filenames and nothing else.
//
// The plain two-field listing reads no content, and on a large store
// that is the whole difference: measured on tcl (2,198 files, 94,140
// artifacts) the plain listing is 0.20s where --size is 38s and --long
// is 41s — the per-file cost of the annotated forms scales with the
// STORE rather than the listing (zit#148). On a small store all three
// are comparable, which is why this only shows up on the repositories
// that matter.
//
// So a caller that only reads .Name — a marker scan, a doc index, a
// file list for CI, the file set of a root check-in — must not ask for
// size, permission and text/binary it will throw away.
func (c *Client) LsNames(ctx context.Context, version string) ([]LsEntry, error) {
return c.lsNames(ctx, version)
}
// LsUnder lists only the paths beneath a directory (zit#96's --under),
// so drawing one directory does not materialise the whole tree — 2,198
// paths to show 146 of them, on a repository the size of tcl.
func (c *Client) LsUnder(ctx context.Context, version, under string) ([]LsEntry, error) {
return c.ls(ctx, version, under)
}
func (c *Client) ls(ctx context.Context, version, under string) ([]LsEntry, error) {
// ONLY an immutable version may be cached. "trunk" names whatever is
// newest, so its listing changes on every commit — memoising it
// would serve a stale tree forever, which is a correctness bug
// wearing a performance fix's clothes.
if !immutableVersion(version) {
return c.lsUncached(ctx, version, under)
}
return c.lsShared(ctx, version+"\x00"+under, func() ([]LsEntry, error) {
return c.lsUncached(ctx, version, under)
})
}
// lsNames runs the plain listing and caches it beside the annotated one.
// Keyed apart, because the two return different shapes: an entry here
// has a name and a blob and zero for everything else.
func (c *Client) lsNames(ctx context.Context, version string) ([]LsEntry, error) {
if !immutableVersion(version) {
return c.lsNamesUncached(ctx, version)
}
// Keyed apart from the annotated listing: the two return different
// shapes, and an entry here has a name and a blob and zero for
// everything else.
return c.lsShared(ctx, "names\x00"+version, func() ([]LsEntry, error) {
return c.lsNamesUncached(ctx, version)
})
}
func (c *Client) lsNamesUncached(ctx context.Context, version string) ([]LsEntry, error) {
cmd, done := c.command(ctx, "ls", "-R", c.repo, "--", version)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("zitcli: ls: %w: %s", err, strings.TrimSpace(stderr.String()))
}
var entries []LsEntry
for _, line := range strings.Split(string(out), "\n") {
if line == "" {
continue
}
// zit ≤0.2 printed `<blob-id> <filename>`; 0.3 prints the name
// alone. Read both: a first token that is a 64-hex blob is the
// old form, anything else is a name that may contain spaces.
if blob, name, ok := strings.Cut(line, " "); ok && len(blob) == 64 && isHex(blob) {
entries = append(entries, LsEntry{Blob: blob, Name: name})
continue
}
entries = append(entries, LsEntry{Name: line})
}
return entries, nil
}
func (c *Client) lsUncached(ctx context.Context, version, under string) ([]LsEntry, error) {
args := []string{"ls", "--long", "-R", c.repo}
if under != "" {
args = append(args, "--under="+under)
}
args = append(args, "--", version)
cmd, done := c.command(ctx, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, c.storeFault(err, "ls", stderr.String(), "version", version)
}
var files []LsEntry
for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") {
if line == "" {
continue
}
// --long: <blob> <size> <perm> <text|binary> <name>. The name is
// last and may contain spaces, so the split is bounded rather
// than greedy.
fields := strings.SplitN(line, " ", 5)
if len(fields) != 5 {
return nil, fmt.Errorf("zitcli: ls: unparseable line %q", line)
}
size, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("zitcli: ls: bad size in %q", line)
}
files = append(files, LsEntry{
Blob: fields[0], Size: size, Perm: fields[2],
Binary: fields[3] == "binary", Name: fields[4],
})
}
return files, nil
}
// ErrNoEntry reports a read of a file that is not available at the
// requested check-in — absent from its file set, or content shunned.
var ErrNoEntry = errors.New("file not available at this check-in")
// ErrNoVersion reports that the VERSION itself does not exist: no
// check-in, branch or tag answers to it.
//
// Separate from ErrNoEntry because they are different absences with
// different sentences — a file missing from a real check-in versus a
// check-in that was never written — and they arrive from different verbs.
// Only this one explains a request for something that will never appear,
// which is why the caller can answer NOT FOUND rather than "try again".
var ErrNoVersion = errors.New("no such version")
// missingVersion recognises zit's sentence for ErrNoVersion.
//
// ONE place, and prose — which is why the guard against it drifting is the
// test beside it rather than the shape of this match. Anchored on both
// halves so a message that merely mentions a check-in alongside a miss (a
// log line quoting one) is not read as this.
func missingVersion(msg string) bool {
return strings.Contains(msg, "check-in") && strings.Contains(msg, "not found")
}
// Cat emits a file's exact bytes at a check-in (an on-demand read,
// 0001 §2). The emitted bytes are hash-verified by zit itself. A file
// zit cannot serve (untracked at that check-in, shunned content) comes
// back wrapping ErrNoEntry.
func (c *Client) Cat(ctx context.Context, path, version string) ([]byte, error) {
// Only an immutable version is cacheable, for the same reason the
// listing memo says so: a name resolves to different content over
// time. Ops resolve names to hashes before calling (resolveVersion),
// so this hits in practice rather than in theory.
key := ""
if immutableVersion(version) {
key = version + "\x00" + path
if hit, ok := c.blobs.get(key); ok {
return hit, nil
}
if _, ok := c.blobs.get("miss\x00" + key); ok {
return nil, fmt.Errorf("%w (%s@%.12s): cached absence", ErrNoEntry, path, version)
}
}
cmd, done := c.command(ctx, "cat", "-R", c.repo, "--", path, version)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if exitCode(err) == 1 {
// Absence is as immutable as content: a path missing at this
// check-in is missing forever, and the About panel probes
// .quarry/repo.json on every render — without this, each render
// of a repository that has no config pays a subprocess to
// relearn that, indefinitely.
if key != "" {
c.blobs.put("miss\x00"+key, nil)
}
return nil, fmt.Errorf("%w (%s@%.12s): %s", ErrNoEntry, path, version, strings.TrimSpace(stderr.String()))
}
if err != nil {
return nil, c.storeFault(err, "cat", stderr.String(), "path", path, "version", version)
}
if key != "" {
c.blobs.put(key, out)
}
return out, nil
}
// InfoBranch is the branch NAME a symbolic reference resolves to —
// `zit info trunk` on a repository that renamed its default answers
// "main", because fossil's "trunk" means the default branch, not the
// literal tag. zit is the authority on that resolution (0001 §2).
func (c *Client) InfoBranch(ctx context.Context, ref string) (string, error) {
_, branch, err := c.Info(ctx, ref)
return branch, err
}
// Info resolves a reference to its check-in hash and branch name.
// The first output line is the full hash; "branch:" names the branch.
func (c *Client) Info(ctx context.Context, ref string) (hash, branch string, err error) {
cmd, done := c.command(ctx, "info", "-R", c.repo, "--", ref)
defer done()
out, cerr := cmd.Output()
if cerr != nil {
return "", "", fmt.Errorf("zitcli: info %s: %w", ref, cerr)
}
lines := strings.Split(string(out), "\n")
if len(lines) > 0 {
hash = strings.TrimSpace(lines[0])
}
for _, line := range lines {
if rest, ok := strings.CutPrefix(line, "branch:"); ok {
branch = strings.TrimSpace(rest)
}
}
if hash == "" || branch == "" {
return "", "", fmt.Errorf("zitcli: info %s: unparsed output", ref)
}
return hash, branch, nil
}
// DiffEntry is one file in a name-status diff.
type DiffEntry struct {
Status string `json:"status"` // A, M, D
Name string `json:"name"`
}
// diffResult is one memoised diff.
type diffResult struct {
unified string
entries []DiffEntry
}
// diffCacheBytes is one repository's diff budget.
//
// Counted in BYTES rather than entries, because that is the only bound
// that means anything here: a one-line fix and a thousand-file merge
// are both one entry and nothing like one cost. The old cap of 32
// entries could hold 32 multi-megabyte merges — the exact failure the
// blob cache's own note describes.
//
// 32 MiB is half the blob budget: a diff is derived text that can be
// recomputed, where a blob is the only copy in memory of something a
// reader asked for by name.
const diffCacheBytes = 32 << 20
// Diff compares two check-ins: unified hunks plus the name-status
// summary (two zit invocations; both on-demand reads, 0001 §2).
func (c *Client) Diff(ctx context.Context, from, to string) (string, []DiffEntry, error) {
// Only an immutable PAIR may be cached, for the same reason the
// listing memo says so.
cacheable := immutableVersion(from) && immutableVersion(to)
key := from + "\x00" + to
if cacheable {
if hit, ok := c.diffs.get(key); ok {
return hit.unified, hit.entries, nil
}
}
unified, entries, err := c.diffUncached(ctx, from, to)
if err == nil && cacheable {
c.diffs.put(key, diffResult{unified, entries})
}
return unified, entries, err
}
func (c *Client) diffUncached(ctx context.Context, from, to string) (string, []DiffEntry, error) {
// Two independent subprocesses over the same immutable pair; on a
// repository the size of tcl each takes ~450ms, so running them
// back-to-back doubles what the reader waits for a check-in page.
var (
unified, brief string
unifiedErr, briefErr error
wg sync.WaitGroup
)
wg.Add(1)
go func() {
defer wg.Done()
brief, briefErr = c.diffOut(ctx, from, to, "--brief")
}()
unified, unifiedErr = c.diffOut(ctx, from, to)
wg.Wait()
if unifiedErr != nil {
return "", nil, unifiedErr
}
if briefErr != nil {
return "", nil, briefErr
}
entries, err := parseBrief(brief)
return unified, entries, err
}
func parseBrief(brief string) ([]DiffEntry, error) {
var entries []DiffEntry
for _, line := range strings.Split(strings.TrimSuffix(brief, "\n"), "\n") {
if line == "" {
continue
}
status, name, found := strings.Cut(line, " ")
if !found {
return nil, fmt.Errorf("zitcli: diff --brief: unparseable line %q", line)
}
entries = append(entries, DiffEntry{Status: status, Name: name})
}
return entries, nil
}
func (c *Client) diffOut(ctx context.Context, from, to string, extra ...string) (string, error) {
// Two immutable hashes name one diff forever, so the answer is
// memoisable under the same contract as the blob cache: it may make
// the answer faster, never different. The diff page paid two
// subprocesses (~30ms each) on every view of the same check-in.
key := ""
if immutableVersion(from) && immutableVersion(to) {
key = "diff\x00" + from + "\x00" + to + "\x00" + strings.Join(extra, "\x00")
if hit, ok := c.blobs.get(key); ok {
return string(hit), nil
}
}
// -R diffs from --from/--to work against a bare store now (zit#69),
// so quarry never needs a checkout on disk.
args := append([]string{"diff", "-R", c.repo, "--from", from, "--to", to}, extra...)
cmd, done := c.command(ctx, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return "", c.storeFault(err, "diff", stderr.String(), "from", from, "to", to)
}
if key != "" {
c.blobs.put(key, out)
}
return string(out), nil
}
func exitCode(err error) int {
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode()
}
return 0
}
// parseFrames reads the frames wire format: zero or more
// "artifact HASH SIZE\n" + SIZE raw bytes + "\n", then "cursor SINCE TOKEN\n".
func parseFrames(r *bufio.Reader, fn func(Artifact) error) (Cursor, error) {
for {
line, err := r.ReadString('\n')
if err != nil {
return Cursor{}, fmt.Errorf("reading frame header: %w", err)
}
fields := strings.Fields(line)
switch {
case len(fields) == 3 && fields[0] == "artifact":
size, err := strconv.Atoi(fields[2])
if err != nil {
return Cursor{}, fmt.Errorf("bad size in %q", strings.TrimSpace(line))
}
raw := make([]byte, size)
if _, err := io.ReadFull(r, raw); err != nil {
return Cursor{}, fmt.Errorf("artifact %s: %w", fields[1], err)
}
if nl, err := r.ReadByte(); err != nil || nl != '\n' {
return Cursor{}, fmt.Errorf("artifact %s: missing frame terminator", fields[1])
}
if err := fn(Artifact{Hash: fields[1], Bytes: raw}); err != nil {
return Cursor{}, err
}
case len(fields) == 3 && fields[0] == "cursor":
return Cursor{Since: fields[1], Verify: fields[2]}, nil
default:
return Cursor{}, fmt.Errorf("unexpected frame line %q", strings.TrimSpace(line))
}
}
}
// WikiCommit records a wiki artifact for title with body, returning the
// new artifact hash (zit#71: a machine verb, hash-only stdout).
func (c *Client) WikiCommit(tok lease.Token, title, user, mimetype string, body []byte) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
args := []string{"wiki", "commit", title, "-R", c.repo, "--user", user}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
cmd, done := c.writing(tok, args...)
defer done()
cmd.Stdin = bytes.NewReader(body)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("zitcli: wiki commit %q: %w: %s", title, err, strings.TrimSpace(stderr.String()))
}
return strings.TrimSpace(string(out)), nil
}
// TicketNew records a fresh ticket's first change and returns its
// minted ticket id and the artifact hash.
func (c *Client) TicketNew(tok lease.Token, user string, fields map[string]string) (id, hash string, err error) {
if err := tok.Err(); err != nil {
return "", "", err
}
out, err := c.ticketRun(tok, append([]string{"ticket", "new", "-R", c.repo, "--user", user}, fieldArgs(fields)...))
if err != nil {
return "", "", err
}
id, hash, found := strings.Cut(strings.TrimSpace(out), " ")
if !found {
return "", "", fmt.Errorf("zitcli: ticket new: unparseable result %q", out)
}
return id, hash, nil
}
// TicketSet records a change to an existing ticket, returning the
// artifact hash. ErrNoEntry reports an unknown ticket id.
func (c *Client) TicketSet(tok lease.Token, id, user string, fields map[string]string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
out, err := c.ticketRun(tok, append([]string{"ticket", "set", id, "-R", c.repo, "--user", user}, fieldArgs(fields)...))
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}
func (c *Client) ticketRun(tok lease.Token, args []string) (string, error) {
cmd, done := c.writing(tok, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if exitCode(err) == 1 {
return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String()))
}
if err != nil {
return "", c.storeFault(err, strings.Join(args[:2], " "), stderr.String())
}
return string(out), nil
}
func fieldArgs(fields map[string]string) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
args := make([]string, 0, len(keys))
for _, k := range keys {
args = append(args, k+"="+fields[k])
}
return args
}
// ForumPost opens a new forum thread (zit#76): title is the H card,
// body the W card, mimetype the N card (zit#77); returns the root
// post's artifact hash. An empty mimetype omits the card, which Fossil
// reads as text/x-fossil-wiki — see markup's dispatch.
func (c *Client) ForumPost(tok lease.Token, title, user, mimetype string, body []byte) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
args := []string{"forum", "post", "-R", c.repo, "--user", user}
if title != "" {
args = append(args, "--title", title)
}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
return c.stdinHash(tok, args, body)
}
// ForumReply replies to postHash within its thread (zit resolves the
// thread root transitively); returns the reply's artifact hash.
// User is one row of the store's user table (zit#124/#125): the
// logins and capabilities a fossil repository carries, brought across
// by `zit config import` or `zit config pull user`.
type User struct {
Login string
Caps string
PWHash string
Info string
}
// Users reads the store's user table. `zit users --raw` prints one row
// per line, TAB-separated: LOGIN CAPS PWHASH MTIME INFO. A store that
// never imported a user table prints nothing, which is not an error —
// it is a repository with no fossil people to carry.
func (c *Client) Users(ctx context.Context) ([]User, error) {
cmd, done := c.command(ctx, "users", "--raw", "-R", c.repo)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("zitcli: users: %w: %s", err, strings.TrimSpace(stderr.String()))
}
var users []User
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(line, "\t")
if len(f) < 2 {
continue
}
u := User{Login: f[0], Caps: f[1]}
if len(f) > 2 {
u.PWHash = f[2]
}
if len(f) > 4 {
u.Info = f[4]
}
users = append(users, u)
}
return users, nil
}
// ErrNoEntry reports an unknown or non-forum post.
func (c *Client) ForumReply(tok lease.Token, postHash, user, mimetype string, body []byte) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
args := []string{"forum", "reply", postHash, "-R", c.repo, "--user", user}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
return c.stdinHash(tok, args, body)
}
// TechnoteAdd records a technote (zit#76). event reuses an id to record
// an edit (latest wins); empty mints a fresh one. Returns the event id
// and the artifact hash.
//
// comment is the C card — the title a timeline renders for the note.
// It can only be written here: zit documents that no later verb can
// attach the card, so a note recorded without one shows its event id
// forever (zit#77).
// when is the E card — the moment the note is FILED under, which the
// author chooses. Empty means now, which is right for a new note and
// wrong for an edit: without it every edit re-files the note at the
// moment it was edited, silently moving it in the timeline.
func (c *Client) TechnoteAdd(tok lease.Token, user, event, mimetype, comment, when string, body []byte) (id, hash string, err error) {
if err := tok.Err(); err != nil {
return "", "", err
}
args := []string{"technote", "add", "-R", c.repo, "--user", user}
if when != "" {
args = append(args, "--time", when)
}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
if comment != "" {
args = append(args, "--comment", comment)
}
if event != "" {
args = append(args, "--event", event)
}
out, err := c.stdinHash(tok, args, body)
if err != nil {
return "", "", err
}
id, hash, found := strings.Cut(out, " ")
if !found {
return "", "", fmt.Errorf("zitcli: technote add: unparseable result %q", out)
}
return id, hash, nil
}
// AttachAdd attaches body to target under name (zit 0.3.13: `attach
// add TARGET - --name NAME`), which writes the blob and then the A card
// naming it. Returns both hashes, the A-card artifact first. mimetype is
// the A card's N card; comment its C card, omitted when empty.
//
// target is whatever the A card carries — a ticket id, a technote event
// id, a forum post hash or a wiki page NAME — and zit does not check it
// exists, so the caller must. quarry's only body here is a stub (RFC
// 0016): a real file is never written through this seam, which is why
// zit's attachment-size-limit is never exercised by the forge.
func (c *Client) AttachAdd(tok lease.Token, ctx context.Context, target, name, mimetype, user, comment string, body []byte) (artifact, blob string, err error) {
if err := tok.Err(); err != nil {
return "", "", err
}
args := []string{"attach", "add", target, "-", "-R", c.repo, "--name", name, "--user", user}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
if comment != "" {
args = append(args, "--comment", comment)
}
out, err := c.stdinHash(tok, args, body)
if err != nil {
return "", "", err
}
artifact, blob, found := strings.Cut(out, "\n")
if !found {
return "", "", fmt.Errorf("zitcli: attach add: unparseable result %q", out)
}
return strings.TrimSpace(artifact), strings.TrimSpace(blob), nil
}
// AttachEntry is one live attachment as the store reports it.
type AttachEntry struct {
Name string
User string
}
// AttachList asks the STORE what is attached to target, which is a
// different question from what the index has folded.
//
// The index is the right source for every ordinary read: it is fast,
// it carries the holds and the external-stub detail, and it is what
// every surface renders from. This exists for the ONE case where the
// two disagree and the disagreement matters — an attachment that has
// reached the store but not yet the index. A removal in that window
// used to answer "nothing named X is attached", which is a lie about a
// file the store is holding, and the ownership gate above it found no
// row and so decided nothing (jva, measured from a Developer seat).
//
// Costs a subprocess, so it is called on the miss path only.
//
// No end-of-options marker: `attach list` refuses one (zit 0.3.26),
// unlike the verbs that take a path. A target is a hash or a page name
// and neither starts with a dash, so there is nothing to disambiguate.
func (c *Client) AttachList(ctx context.Context, target string) ([]AttachEntry, error) {
cmd, done := c.command(ctx, "attach", "list", target, "-R", c.repo)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, c.storeFault(err, "attach list", stderr.String())
}
var rows []AttachEntry
for _, line := range strings.Split(string(out), "\n") {
// NAME SIZE MIMETYPE BLOB DATE USER, and a name may contain a
// space, so the five fixed fields are taken from the RIGHT and
// whatever is left is the name.
f := strings.Fields(line)
if len(f) < 6 {
continue
}
tail := len(f) - 5
rows = append(rows, AttachEntry{Name: strings.Join(f[:tail], " "), User: f[len(f)-1]})
}
return rows, nil
}
// AttachRm records that name is no longer attached to target: an A
// card with no source, fossil's deletion form. Nothing leaves the
// store. Returns the A-card artifact's hash.
func (c *Client) AttachRm(tok lease.Token, ctx context.Context, target, name, user string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
return c.stdinHash(tok, []string{"attach", "rm", target, name, "-R", c.repo, "--user", user}, nil)
}
// stdinHash runs a machine verb with body on stdin, returning trimmed
// stdout (the hash line). Exit 1 is a user error (unknown target, empty
// body/author) and maps to ErrNoEntry.
func (c *Client) stdinHash(tok lease.Token, args []string, body []byte) (string, error) {
cmd, done := c.writing(tok, args...)
defer done()
cmd.Stdin = bytes.NewReader(body)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if exitCode(err) == 1 {
return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String()))
}
if err != nil {
return "", c.storeFault(err, args[0]+" "+args[1], stderr.String())
}
return strings.TrimSpace(string(out)), nil
}
// ArtifactRaw returns one stored artifact's exact bytes, addressed by
// hash. This is how attachment content reaches a reader: an attachment's
// source is a plain blob, not a file within a check-in, so Cat (which
// resolves a path at a version) cannot fetch it.
//
// max caps the response — an attachment is arbitrary user-supplied
// content, and quarry buffers it to hand to the op layer, so an
// unbounded read would be a memory denial of service. Exceeding the cap
// is an error, never a truncated artifact: silently serving a prefix of
// a binary is worse than refusing it.
func (c *Client) ArtifactRaw(ctx context.Context, hash string, max int) ([]byte, error) {
cmd, done := c.command(ctx, "artifact", "--raw", "-R", c.repo, "--", hash)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if exitCode(err) == 1 {
return nil, fmt.Errorf("zitcli: artifact %s: %w", hash, ErrNoEntry)
}
return nil, fmt.Errorf("zitcli: artifact %s: %w: %s", hash, err, strings.TrimSpace(stderr.String()))
}
if len(out) > max {
return nil, fmt.Errorf("zitcli: artifact %s: %d bytes exceeds the %d-byte limit", hash, len(out), max)
}
return out, nil
}
// Pull fetches new artifacts from a peer into this store — the mirror
// verb. It reports whether anything arrived, so a scheduler can stay
// quiet on the overwhelmingly common no-op.
//
// This is the ONE call in this package that does not pass -R. `zit pull`
// refuses the flag outright ("pull works in a checkout, and -R names a
// repository with no working tree"), so the store is named by working
// directory instead. That is fine for the stores quarry serves — they
// carry a .zit-checkout — but it means a genuinely bare store cannot be
// mirrored, which is a zit-side limitation, not a quarry choice.
//
// The URL is passed explicitly rather than relying on `zit remote`'s
// saved value: quarry keeps mirror configuration in its own database, so
// having a second, per-checkout copy of the same fact is a way for the
// two to disagree about where a store mirrors from.
func (c *Client) Pull(tok lease.Token, url string) (bool, error) {
if err := tok.Err(); err != nil {
return false, err
}
cmd, done := c.writingFor(tok, obs.ReasonSync, "pull", url)
defer done()
cmd.Dir = c.repo
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return false, c.storeFault(err, "pull", stderr.String(), "upstream", url)
}
changed, perr := pullChanged(out)
if perr != nil {
return false, fmt.Errorf("zitcli: pull %s: %w", url, perr)
}
return changed, nil
}
// pullChanged reads whether a pull brought anything in.
//
// It recognizes zit's two outcomes POSITIVELY and errors on anything
// else, rather than defaulting to "nothing changed". The tempting
// one-liner — `!contains("already up to date")` — inverts the risk: if
// zit ever rewords the success line, a mirror keeps reporting no-change
// forever, the index is never told to re-fold, and the store silently
// stops tracking its upstream while the page shows it healthy. A loud
// error on an unrecognized line is a bad afternoon; a silent one is a
// stale mirror nobody notices.
func pullChanged(out []byte) (bool, error) {
s := strings.TrimSpace(string(out))
switch {
case strings.Contains(s, "already up to date"),
strings.Contains(s, "already in sync"): // zit 0.3.0's wording
return false, nil
case strings.Contains(s, "received"):
return true, nil
default:
return false, fmt.Errorf("unrecognized pull result %q "+
"(zit's wording changed; teach pullChanged the new form)", s)
}
}
// TagAdd attaches a raw-namespace tag with a value to a check-in
// (zit#80). Raw is not optional for a verdict: without --raw the name
// enters the symbolic namespace, where it resolves to a check-in like a
// release name does — and the SECOND check-in to pass CI would make
// "ci-pass" an ambiguous name for both.
//
// propagate carries the tag to descendants; a CI verdict does not
// propagate, because passing says something about one check-in.
func (c *Client) TagAdd(tok lease.Token, name, checkin, value string, propagate bool) error {
if err := tok.Err(); err != nil {
return err
}
args := []string{"tag", "add", "--raw"}
if propagate {
args = append(args, "--propagate")
}
args = append(args, name, checkin)
if value != "" {
args = append(args, value)
}
args = append(args, "-R", c.repo)
cmd, done := c.writing(tok, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if _, err := cmd.Output(); err != nil {
return c.storeFault(err, "tag add", stderr.String(), "tag", name, "check-in", checkin)
}
return nil
}
// ActiveTags is every tag standing on a check-in, raw name to value,
// read from the store: the index can lag a tag just written, and a
// caller deciding whether to write one again must not ask the index.
func (c *Client) ActiveTags(ctx context.Context, checkin string) (map[string]string, error) {
cmd, done := c.command(ctx, "tag", "list", "--raw", "-R", c.repo, checkin)
defer done()
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("zitcli: tag list %s: %w", checkin, err)
}
tags := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
name, value, _ := strings.Cut(strings.TrimSpace(line), " ")
if name != "" {
tags[name] = value
}
}
return tags, nil
}
// TagCancel retracts a tag from a check-in. Fossil's tag log is
// append-only, so this writes a CANCELLING artifact rather than
// removing anything: the history keeps both rows and the newest one
// counts, which is what the tag-history page shows.
//
// --raw matches TagAdd: cancelling a raw property and cancelling a
// symbolic name are different tags, and the add that created ours
// was raw.
func (c *Client) TagCancel(tok lease.Token, name, checkin string) error {
if err := tok.Err(); err != nil {
return err
}
cmd, done := c.writing(tok, "tag", "cancel", "--raw", name, checkin, "-R", c.repo)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if _, err := cmd.Output(); err != nil {
return c.storeFault(err, "tag cancel", stderr.String(), "tag", name, "check-in", checkin)
}
return nil
}
// FileChange is one check-in that changed a file, from `zit log PATH`.
type FileChange struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
}
// FileHistory returns the check-ins that changed path, newest first
// (zit#93). A path that never changed yields nothing, which is not an
// error — it is the honest answer for a file that only ever existed in
// one state.
func (c *Client) FileHistory(ctx context.Context, version, path string, limit int) ([]FileChange, error) {
if limit <= 0 {
limit = 50
}
cmd, done := c.command(ctx, "log", "--long", "-n", strconv.Itoa(limit),
"-R", c.repo, "--", version, path)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if exitCode(err) == 1 {
return nil, nil // no such path at this version: an empty history
}
return nil, fmt.Errorf("zitcli: log %s: %w: %s", path, err,
strings.TrimSpace(stderr.String()))
}
var hist []FileChange
for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") {
rest, ok := strings.CutPrefix(line, "checkin ")
if !ok {
continue // continuation lines of a multi-line comment
}
// checkin <id> <timestamp> <user> <comment…>
// A weekday token may precede the timestamp if zit#111's
// annotation ever reaches this verb ambiently; tolerated here
// as insurance, per the signoff on that issue.
f := strings.SplitN(rest, " ", 5)
if len(f) >= 2 && weekdayToken[f[1]] {
f = append(f[:1], f[2:]...)
} else if len(f) == 5 {
f[3] = f[3] + " " + f[4]
f = f[:4]
}
if len(f) < 3 {
continue
}
fc := FileChange{Hash: f[0], Date: f[1], User: f[2]}
if len(f) >= 4 {
fc.Comment = f[3]
}
hist = append(hist, fc)
}
return hist, nil
}
// weekdayToken recognises zit#111's weekday annotation.
var weekdayToken = map[string]bool{
"Sun": true, "Mon": true, "Tue": true, "Wed": true,
"Thu": true, "Fri": true, "Sat": true,
}
// immutableVersion reports whether a version string names one fixed
// check-in — a full artifact hash. Branch names, tags, "tip" and
// timestamps all resolve differently as history grows, so a listing
// taken under them may not be cached.
func immutableVersion(v string) bool {
if len(v) != 40 && len(v) != 64 {
return false
}
for i := 0; i < len(v); i++ {
c := v[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// ArchiveTo writes a check-in's files to dst as a reproducible archive
// (zit#95). format is "tar" for gzip tar or "zip"; the two commands are
// identical but for the container, so they share one implementation.
//
// No working tree is involved, so this works against a bare store, and
// an F card's permission travels with each member — an executable
// unpacks executable, a symlink stays a link, which is what a
// checkout-then-tar workaround loses.
//
// max caps the archive quarry will produce. A repository whose tree
// exceeds the cap is refused rather than half-served, and the partial
// file is removed rather than left for a caller to mistake for one.
func (c *Client) ArchiveTo(ctx context.Context, format, version, name, dst string, max int64) error {
cmdName, ok := archiveCommand(format)
if !ok {
return fmt.Errorf("zitcli: unknown archive format %q", format)
}
args := []string{cmdName, "-R", c.repo}
if name != "" {
args = append(args, "--name", name)
}
// The marker, like every other call site — but it was absent here
// for one release. zit 0.3.25 gated twenty-five verbs behind the
// marker and these two counted it as an OPERAND, so "-- VERSION
// DEST" was three operands to a verb that takes two and every
// archive download on the forge answered 503. zit 0.3.26 makes the
// marker branch end the iteration, which is what the other verbs
// already did (danmestas/zit#208), so the version is a version
// again even when it begins with a dash.
args = append(args, "--", version, dst)
cmd, done := c.command(ctx, args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
// A VERSION nobody wrote is not a failure to retry.
//
// zit says `check-in 'X' not found`, and every caller upstream
// treated an archive error as "busy" — so asking for a version
// that will never exist answered "the server is at capacity,
// retry shortly", with a Retry-After. A client retried forever
// for something that was never going to appear, which is the
// worst pair this forge can produce: a wrong answer carrying an
// instruction.
//
// Matched on zit's words rather than an exit status because zit
// spends the same status on every user error. The phrase is NOT
// taken on trust: TestArchiveOfAVersionNobodyWroteIsNoVersion
// runs the real binary and fails if the wording changes, so a
// reworded zit breaks a test instead of silently answering busy.
if missingVersion(msg) {
return fmt.Errorf("%w: %s: %s", ErrNoVersion, version, msg)
}
return fmt.Errorf("zitcli: %s %s: %w: %s", cmdName, version, err, msg)
}
fi, err := os.Stat(dst)
if err != nil {
return err
}
if fi.Size() > max {
os.Remove(dst)
return fmt.Errorf("zitcli: %s %s: %d bytes exceeds the %d-byte limit",
cmdName, version, fi.Size(), max)
}
return nil
}
// archiveCommand maps a format to zit's subcommand. Only these two
// exist; anything else is a caller's mistake and is refused by name
// rather than defaulted, which would silently hand back the wrong
// container.
func archiveCommand(format string) (string, bool) {
switch format {
case "tar":
return "tarball", true
case "zip":
return "zip", true
}
return "", false
}
// SeenSubmanifest reports whether this repository has ever held a
// sub-manifest artifact. It is the fossil-compatibility question: an
// S-bearing artifact is refused by peers below Fossil 2.29, and zit
// documents that turning emission back off "cannot retire artifacts
// already written" — so the setting is not the answer, the history is.
// zit reports exactly that as "seen-submanifest".
//
// The command reads settings from the checkout rather than accepting
// -R, so it runs with the repository as its working directory. About
// 250ms on a large store, which is why callers cache it: the answer
// changes only when an S-bearing artifact first arrives.
func (c *Client) SeenSubmanifest(ctx context.Context) (bool, error) {
cmd, done := commandIn(ctx, c.repo, "submanifest", "show")
defer done()
var out, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &stderr
if err := cmd.Run(); err != nil {
return false, fmt.Errorf("zitcli: submanifest show: %w: %s", err,
strings.TrimSpace(stderr.String()))
}
for _, line := range strings.Split(out.String(), "\n") {
rest, ok := strings.CutPrefix(line, "seen-submanifest:")
if !ok {
continue
}
return strings.TrimSpace(rest) == "yes", nil
}
// A field that is not there is not a "no": a zit that stopped
// reporting it would silently mark every repository compatible.
return false, fmt.Errorf("zitcli: submanifest show: no seen-submanifest line")
}
// Leaves returns the open leaves of the check-in DAG, as zit defines
// them: check-ins with no child on the same branch that carry no active
// closed tag (repo-state RFC §7.2, §7.4).
//
// quarry asks rather than derives. Its own SQL answered "no child of
// ANY kind", which is the phrasing §7.2 explicitly warns is not the
// canonical test — on fossil that marked 490 check-ins as leaves where
// the true set is 1432, and a different set, since no-child-at-all is a
// strict subset of no-same-branch-child. Reproducing the rest (tag
// propagation, blocking conditions, re-evaluation triggers) is several
// more sections of the RFC, and landing near-but-not-equal is how a
// forge shows false data about a store that is perfectly correct.
//
// It is cheap because zit's derivation cache already holds it: on tcl,
// 103k artifacts, this is ~10ms warm against ~79s with --no-cache.
// Leaves reports the open leaves, keyed by the TWELVE-CHARACTER hash
// prefix zit prints. Callers test membership with LeafKey rather than a
// full hash.
func (c *Client) Leaves(ctx context.Context) (map[string]bool, error) {
// -R names the repository. command() does not add it, so a call
// without it silently answers about whatever store the process
// happens to be standing in — which is the server's own checkout,
// giving every repository quarry's leaves.
cmd, done := c.command(ctx, "leaves", "-R", c.repo)
defer done()
var out, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("zitcli: leaves: %w: %s", err, strings.TrimSpace(stderr.String()))
}
set := map[string]bool{}
for _, line := range strings.Split(out.String(), "\n") {
// zit ≤0.2 printed "* <hash> <branch>"; 0.3 prints Fossil's
// timeline line — `(N) time [hash12] comment …` — with a
// twelve-character hash. Read both; a short hash is resolved
// to the full one, because every consumer keys by it.
s := strings.TrimSpace(line)
if f := strings.Fields(strings.TrimPrefix(s, "* ")); len(f) > 0 && len(f[0]) >= 40 {
set[f[0]] = true
continue
}
open := strings.IndexByte(s, '[')
close := strings.IndexByte(s, ']')
if open < 0 || close <= open+1 {
continue
}
prefix := s[open+1 : close]
if len(prefix) < 8 || !isHex(prefix) {
continue
}
// Keyed by the PREFIX zit printed, not expanded to a full hash.
// Expanding meant one `zit info` subprocess per leaf: sqlite has
// 473 of them, which was 357 spawns and 6.1 seconds in a single
// page-load window. The only consumer asks "is this check-in a
// leaf", so a twelve-hex-character key answers it — 48 bits,
// where a collision inside one repository is not a real risk.
set[prefix] = true
}
return set, nil
}
// CommitFile writes one file as a check-in on a fresh branch, through
// a scratch checkout opened against the same store — the serving tree
// is never touched, and nothing needs a network. The scratch tree is
// removed either way; the check-in, if made, is in the store.
func CommitFile(tok lease.Token, store, baseline, branch, user, message, path string, body []byte) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
dir, err := os.MkdirTemp("", "quarry-edit-")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
run := func(args ...string) (string, error) {
cmd, done := writingIn(tok, repoName(store), args...)
defer done()
cmd.Dir = dir
out, rerr := cmd.CombinedOutput()
if rerr != nil {
return "", fmt.Errorf("zit %s: %v: %s", args[0], rerr, strings.TrimSpace(string(out)))
}
return string(out), nil
}
if _, err := run("open", baseline, "--store="+store); err != nil {
return "", err
}
// The store may have a saved remote; this commit must land HERE.
if _, err := run("settings", "autosync", "off"); err != nil {
return "", err
}
full := filepath.Join(dir, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(full, body, 0o644); err != nil {
return "", err
}
if _, err := run("add", path); err != nil {
return "", err
}
out, err := run("commit", "--branch", branch, "--user", user, "-m", message, path)
if err != nil {
return "", err
}
for _, f := range strings.Fields(out) {
if len(f) == 64 && isHex(f) {
return f, nil
}
}
return "", fmt.Errorf("zit commit: no check-in hash in %q", strings.TrimSpace(out))
}
// isHex reports a lowercase hex string.
func isHex(s string) bool {
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
// LandingOpen proposes a branch to land on a target, returning the record's
// id. The BODY ENCODING IS NOT OURS: a landing is a forum thread whose root
// carries a marker and headers, and zit's landing verb owns that format
// deliberately —
//
// THE INTERFACE HIDES THAT ENCODING ... If a thread hash leaks into the
// interface, or accept starts asking callers to name a tag, this has
// become a pass-through wrapper and the complexity it was built to
// absorb has been pushed back up to quarry and to every human.
//
// So quarry mints landings by asking for a landing, never by writing a forum
// post that looks like one. Two encoders of one format is how the forge and
// the store come to disagree about what a record says.
func (c *Client) LandingOpen(tok lease.Token, user, branch, target, message, mimetype string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
args := []string{"open", "-R", c.repo, "--branch", branch,
"--to", target, "--user", user, "-m", message}
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
out, err := c.landingRun(tok, args...)
if err != nil {
return "", err
}
return firstField(out), nil
}
// LandingSay appends to a record: a remark, or the transition that settles it.
// verb is comment, reject or accept. replyTo aims a remark at an earlier post
// in the same thread; empty answers the record itself.
//
// accept is the one that can fail for a reason a reader must see rather than a
// bug: it merges, and a merge can conflict. zit today also refuses accept
// against a bare store (zit#131), which is the shape quarry serves — until
// that lands, the error travels up and the page says so.
func (c *Client) LandingSay(tok lease.Token, verb, id, user, message, mimetype, replyTo string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
if verb == "accept" {
return "", errors.New("zitcli: accept names the tip it checked; use LandingAccept")
}
return c.landingSay(tok, verb, []string{id, "-R", c.repo, "--user", user}, message, mimetype, replyTo)
}
// LandingAccept merges a landing's branch onto its target, but only if
// the branch still ends at expectTip: the check-in the caller's checks
// judged. zit reads the tip once and merges exactly that check-in, so a
// push landing after the check is refused as *BranchMoved with nothing
// written, rather than merged unchecked.
func (c *Client) LandingAccept(tok lease.Token, id, user, message, mimetype, expectTip string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
if expectTip == "" {
return "", errors.New("zitcli: an accept must name the tip it checked")
}
return c.landingSay(tok, "accept", []string{id, "-R", c.repo, "--user", user, "--expect-tip", expectTip},
message, mimetype, "")
}
func (c *Client) landingSay(tok lease.Token, verb string, lead []string, message, mimetype, replyTo string) (string, error) {
args := append([]string{verb}, lead...)
// zit#149: a remark may answer an earlier post rather than the
// record's opener. zit refuses a target outside this landing's
// thread, so the flag cannot reach another thread.
if replyTo != "" {
args = append(args, "--reply-to", replyTo)
}
if message != "" {
args = append(args, "-m", message)
// Say what the text IS. zit#136: the verb defaults to plain, which
// is right for a message typed at a shell prompt and wrong for one
// written in an editor that previewed it as Markdown. A composer
// that renders a mention and then stores something that will not
// has lied to the person writing it.
if mimetype != "" {
args = append(args, "--mimetype", mimetype)
}
}
out, err := c.landingRun(tok, args...)
if err != nil {
return "", err
}
// The first word is a hash only when it IS one. comment and reject
// print the post they wrote; accept prints a sentence — "accepted
// feat/x -> trunk", with zit's own e2e pinning that no check-in line
// appears — and taking its first word made the verb travel as a
// hash: landing.accept answered {"hash":"accepted"} (G-44), and every
// ticket a landing closed had its note end "(accepted)" where the
// merge belongs. Asking the word rather than the verb keeps the two
// posts zit does name, and hands back nothing for the one it does
// not; the merge is a fact the index holds, and the caller reads it
// there.
if h := firstField(out); isArtifactName(h) {
return h, nil
}
return "", nil
}
// isArtifactName reports whether s is a full artifact hash: 40 hex for
// SHA1, 64 for SHA3-256, lowercase as zit prints them.
func isArtifactName(s string) bool {
if len(s) != 40 && len(s) != 64 {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
// LandingRetarget points an open record at a different target branch.
//
// The recovery for a stack whose base landed on its own: zit refuses to
// merge onto a target whose work is already in the destination and says
// to retarget, which until now could only be done from a shell.
func (c *Client) LandingRetarget(tok lease.Token, id, user, to string) (string, error) {
if err := tok.Err(); err != nil {
return "", err
}
out, err := c.landingRun(tok, "retarget", id, "-R", c.repo, "--to", to, "--user", user)
if err != nil {
return "", err
}
return firstField(out), nil
}
func (c *Client) landingRun(tok lease.Token, args ...string) (string, error) {
cmd, done := c.writing(tok, append([]string{"landing"}, args...)...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
msg := strings.TrimSpace(stderr.String())
switch exitCode(err) {
case 0:
return string(out), nil
case landingConflictExit:
// zit#131: exit 5 means the merge would conflict and NOTHING was
// written. That is a state to render, not a failure to report —
// the difference this error exists to carry.
return "", &LandingConflict{Paths: conflictPaths(msg), Message: msg}
case landingMovedExit:
if moved := parseBranchMoved(msg); moved != nil {
return "", moved
}
return "", fmt.Errorf("zitcli: landing %s: exit 6 not in the expected shape: %s", args[0], msg)
case 1:
return "", fmt.Errorf("%w: %s", ErrNoEntry, msg)
case 2:
if strings.Contains(msg, "'--expect-tip'") {
WarnTooOld("")
return "", fmt.Errorf("zit on PATH does not know --expect-tip, so this accept cannot be guarded and was not tried: %w", ErrZitTooOld)
}
}
if signalled(err) {
return "", fmt.Errorf("landing %s: %w", args[0], ErrStopped)
}
return "", fmt.Errorf("zitcli: landing %s: %w: %s", args[0], err, msg)
}
// landingConflictExit is zit's status for "refused, conflicts" (zit#131).
// Distinct from 1 on purpose: 1 is a user error worth reporting as a fault,
// 5 is an ordinary state of a long-lived branch worth rendering as one.
const landingConflictExit = 5
// landingMovedExit is zit's status for "refused, the branch is not at
// --expect-tip" (zit 0.3.31). Nothing was written.
const landingMovedExit = 6
// BranchMoved is an accept refused because the branch moved after the
// caller checked it. Current is where the branch is now; Expected is
// the tip the caller named.
type BranchMoved struct {
Branch, Current, Expected string
}
func (e *BranchMoved) Error() string {
return fmt.Sprintf("%s is at %s, not the checked %s", e.Branch, e.Current, e.Expected)
}
// parseBranchMoved reads zit's exit-6 sentence:
// "zit landing: branch B is at CURRENT, not EXPECTED; nothing was written".
func parseBranchMoved(msg string) *BranchMoved {
line := strings.TrimSpace(strings.SplitN(msg, "\n", 2)[0])
line = strings.TrimSpace(strings.TrimPrefix(line, "zit landing:"))
rest, ok := strings.CutPrefix(line, "branch ")
if !ok {
return nil
}
branch, rest, ok := strings.Cut(rest, " is at ")
if !ok {
return nil
}
current, rest, ok := strings.Cut(rest, ", not ")
if !ok {
return nil
}
expected, _, _ := strings.Cut(rest, ";")
if branch == "" || current == "" || expected == "" {
return nil
}
return &BranchMoved{Branch: branch, Current: current, Expected: strings.TrimSpace(expected)}
}
// MinVersion is the oldest zit this forge runs correctly against:
// 0.3.31 gave `landing accept` --expect-tip, without which an accept
// cannot promise it merges the tip it checked.
const MinVersion = "0.3.31"
// ErrZitTooOld is an operation refused because the zit on PATH is older
// than MinVersion and lacks what the operation needs.
var ErrZitTooOld = errors.New("zit is older than " + MinVersion + "; upgrade zit")
// ProbeVersion asks the zit on PATH what it is. "zit 0.3.4 (targets
// Fossil RFC rev 00)" becomes "0.3.4"; a missing or unreadable zit is
// "", never an error here — every real use of zit reports its own.
func ProbeVersion() string {
out, err := exec.Command("zit", "version").Output()
if err != nil {
return ""
}
line := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
line = strings.TrimPrefix(line, "zit ")
if i := strings.IndexByte(line, ' '); i > 0 {
line = line[:i]
}
return line
}
var tooOldOnce sync.Once
// WarnTooOld logs, once per process however many callers find it, that
// the zit on PATH is older than MinVersion. found is the version seen,
// "" when unknown. The refusals themselves travel to every caller; the
// log only needs to say it once.
func WarnTooOld(found string) {
tooOldOnce.Do(func() {
log.Printf("zit: quarry needs zit %s or newer and found %q; every landing accept is refused until zit is upgraded",
MinVersion, found)
})
}
// VersionAtLeast reports whether dotted version have is at least want,
// comparing numerically field by field. An unparseable have is not.
func VersionAtLeast(have, want string) bool {
h, w := versionFields(have), versionFields(want)
if h == nil || w == nil {
return false
}
for i := 0; i < max(len(h), len(w)); i++ {
var a, b int
if i < len(h) {
a = h[i]
}
if i < len(w) {
b = w[i]
}
if a != b {
return a > b
}
}
return true
}
func versionFields(v string) []int {
if v == "" {
return nil
}
parts := strings.Split(v, ".")
out := make([]int, len(parts))
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return nil
}
out[i] = n
}
return out
}
// LandingConflict is a refusal that changed nothing. It names the paths so a
// page can say which files stand in the way rather than "it failed".
type LandingConflict struct {
Paths []string
Message string
}
func (e *LandingConflict) Error() string {
if len(e.Paths) == 1 {
return "the branch conflicts with its target in " + e.Paths[0]
}
return fmt.Sprintf("the branch conflicts with its target in %d files", len(e.Paths))
}
// conflictPaths reads the "conflict PATH" lines zit emits, on stdout for the
// probe and on stderr for a refused accept. Anything else on the line is
// prose for a human and deliberately ignored.
func conflictPaths(text string) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "zit landing:"))
if rest, ok := strings.CutPrefix(line, "conflict "); ok {
if p := strings.TrimSpace(rest); p != "" {
out = append(out, p)
}
}
}
return out
}
// mergeResult is one memoised probe answer.
type mergeResult struct {
state string
conflicts []string
}
// mergeProbeRun invokes the read-only probe. Exit status is 0 whatever the
// answer — zit#134 puts "has conflicts" on stdout as data, because it is an
// answer and not a failure — so any non-zero here is a real error.
func (c *Client) mergeProbeRun(branchTip, targetTip string) (string, error) {
cmd, done := c.unattended("merge", "--probe", branchTip, "--into", targetTip, "-R", c.repo)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("zitcli: merge --probe: %w: %s", err, strings.TrimSpace(stderr.String()))
}
return string(out), nil
}
// MergeProbe asks the STORE whether one check-in would merge into another,
// without writing anything (zit#131). This is the only way a forge may learn
// it: discovering mergeability by attempting an accept is the one thing a
// server must not do, because the attempt is the damage.
//
// Returns "clean", "up-to-date", "unrelated", or "conflict" with the paths.
//
// It names the two TIPS rather than the landing (zit#134). `landing show`
// answers the same question, but has to walk the whole store to FIND the
// record first — a cost proportional to store bytes, paid to rediscover
// something quarry already folded out of its own index. Naming the pair skips
// that walk entirely.
//
// Passing tips rather than branch names also closes a race the old call had:
// the cache key was built from resolved tips while the probe re-resolved the
// branch, so a push landing between the two produced an answer cached under
// the wrong key. Probing exactly what the key names cannot disagree with it.
func (c *Client) MergeProbe(branchTip, targetTip, key string) (state string, conflicts []string, err error) {
if key != "" {
c.mergeMu.Lock()
hit, ok := c.mergeCache[key]
c.mergeMu.Unlock()
if ok {
return hit.state, hit.conflicts, nil
}
}
out, err := c.mergeProbeRun(branchTip, targetTip)
if err != nil {
return "", nil, err
}
for _, line := range strings.Split(out, "\n") {
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "merges"); ok {
state = strings.TrimSpace(rest)
if i := strings.IndexByte(state, ' '); i >= 0 {
state = state[:i] // "conflict (3)" -> "conflict"
}
}
}
conflicts = conflictPaths(out)
if key != "" {
c.mergeMu.Lock()
if c.mergeCache == nil {
c.mergeCache = map[string]mergeResult{}
}
c.mergeCache[key] = mergeResult{state: state, conflicts: conflicts}
c.mergeMu.Unlock()
}
return state, conflicts, nil
}
// RungProbe is one rung's answer from StackProbe.
type RungProbe struct {
// State is clean, up-to-date, unrelated, conflict, or not-reached
// for a rung above the one that stopped.
State string
Conflicts []string
}
// StackProbe asks the STORE whether a stack of check-ins would land on
// a target one after another, each with the ones before it already
// merged, in memory, writing nothing (zit#211). It answers the question
// a strata walk actually asks, which probing each rung against the one
// below cannot: that says nothing about what the target did meanwhile.
// The answers come back in the order of tips; the first rung that will
// not merge carries its conflict paths and every rung after it is
// not-reached. Memoised on the tips and the target, like MergeProbe.
func (c *Client) StackProbe(tips []string, targetTip string) ([]RungProbe, error) {
if len(tips) == 0 {
return nil, nil
}
key := "stack:" + strings.Join(tips, ",") + ":" + targetTip
c.mergeMu.Lock()
hit, ok := c.stackCache[key]
c.mergeMu.Unlock()
if ok {
return hit, nil
}
args := append([]string{"merge", "--probe"}, tips...)
args = append(args, "--into", targetTip, "-R", c.repo)
cmd, done := c.unattended(args...)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("zitcli: merge --probe (stack of %d): %w: %s", len(tips), err, strings.TrimSpace(stderr.String()))
}
rungs, err := parseStackProbe(string(out), len(tips))
if err != nil {
return nil, err
}
c.mergeMu.Lock()
if c.stackCache == nil {
c.stackCache = map[string][]RungProbe{}
}
c.stackCache[key] = rungs
c.mergeMu.Unlock()
return rungs, nil
}
// parseStackProbe reads `merges [NAME] VERDICT [(N)]` lines, one per
// tip in order, with `conflict PATH` lines under the one that stopped.
// zit omits NAME when there is one tip, so the verdict is the field
// after the name when there are several and the first when there is
// one. A count that disagrees with the tips is an error, not a guess.
func parseStackProbe(out string, n int) ([]RungProbe, error) {
var rungs []RungProbe
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(line, "merges"); ok {
f := strings.Fields(rest)
at := 0
if n > 1 {
at = 1
}
if len(f) <= at {
return nil, fmt.Errorf("zitcli: merge --probe: unreadable line %q", line)
}
rungs = append(rungs, RungProbe{State: f[at]})
continue
}
if p, ok := strings.CutPrefix(line, "conflict "); ok && len(rungs) > 0 {
if p = strings.TrimSpace(p); p != "" {
rungs[len(rungs)-1].Conflicts = append(rungs[len(rungs)-1].Conflicts, p)
}
}
}
if len(rungs) != n {
return nil, fmt.Errorf("zitcli: merge --probe answered %d rungs for %d tips", len(rungs), n)
}
return rungs, nil
}
// firstField takes the identifier off a machine verb's one result line.
func firstField(out string) string {
f := strings.Fields(strings.TrimSpace(out))
if len(f) == 0 {
return ""
}
return f[0]
}
// Setting is one fossil setting and the value in force for a store.
// Versionable marks the settings fossil lets a repository carry in a
// tracked .fossil-settings/<name> file — the tier a bare store cannot
// reach — so the editor can flag them.
type Setting struct {
Name string
Value string
Versionable bool
}
// ErrStoreNoCheckout reports that a store subprocess refused -R because
// the verb wants a working tree the served bare store does not have.
// The fossil settings and configuration areas live in the store, not
// the tree, so this is a zit limitation, tracked upstream (zit#153),
// not a fact about the data.
var ErrStoreNoCheckout = errors.New("zitcli: store has no working tree; needs zit settings/config -R on a bare store (zit#153)")
// versionableSettings is fossil's fixed set of settings a repository
// may carry in the tree (fossil's "versionable settings"). The value
// is intrinsic to fossil, so it is a constant rather than parsed.
var versionableSettings = map[string]bool{
"allow-symlinks": true, "binary-glob": true, "clean-glob": true,
"crlf-glob": true, "crnl-glob": true, "dotfiles": true, "empty-dirs": true,
"encoding-glob": true, "ignore-glob": true, "keep-glob": true, "manifest": true,
}
// noCheckout reports whether a store subprocess failed because it
// refused -R for want of a working tree.
func noCheckout(stderr string) bool {
return strings.Contains(stderr, "is not supported here")
}
// Settings lists the fossil settings in force for the store. It runs
// `zit settings -R`; on a bare store today that is refused, and the
// call returns ErrStoreNoCheckout so the surface can say why rather
// than show an empty editor. When zit#153 lands, the same call returns
// the rows with no change here.
func (c *Client) Settings(ctx context.Context) ([]Setting, error) {
cmd, done := c.command(ctx, "settings", "-R", c.repo)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
if noCheckout(stderr.String()) {
return nil, ErrStoreNoCheckout
}
return nil, fmt.Errorf("zitcli: settings: %w: %s", err, strings.TrimSpace(stderr.String()))
}
var settings []Setting
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
name, value, _ := strings.Cut(strings.TrimRight(line, "\n"), " ")
name = strings.TrimSpace(name)
if name == "" {
continue
}
settings = append(settings, Setting{
Name: name, Value: strings.TrimSpace(value), Versionable: versionableSettings[name],
})
}
return settings, nil
}
// SetSetting writes one repository-tier setting. Same store-tree
// limitation as Settings; ErrStoreNoCheckout until zit#153.
func (c *Client) SetSetting(tok lease.Token, ctx context.Context, name, value string) error {
if err := tok.Err(); err != nil {
return err
}
ctx, stop := underLease(ctx, tok)
defer stop()
cmd, done := c.command(ctx, "settings", "-R", c.repo, name, value)
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if noCheckout(stderr.String()) {
return ErrStoreNoCheckout
}
return fmt.Errorf("zitcli: settings %s: %w: %s", name, err, strings.TrimSpace(stderr.String()))
}
return nil
}
// ImportConfig applies a fossil configuration-area file (the output of
// `fossil configuration export AREA`) to the store, merging or
// replacing as zit's `config import`/`config merge` decide by the
// file's cards. Same store-tree limitation until zit#153.
func (c *Client) ImportConfig(tok lease.Token, ctx context.Context, merge bool, file []byte) error {
if err := tok.Err(); err != nil {
return err
}
verb := "import"
if merge {
verb = "merge"
}
// zit config import/merge names a FILE, not stdin, so the upload is
// spilled to a temp file for the length of the call.
tmp, err := os.CreateTemp("", "quarry-config-*.txt")
if err != nil {
return fmt.Errorf("zitcli: config %s: %w", verb, err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(file); err != nil {
tmp.Close()
return fmt.Errorf("zitcli: config %s: %w", verb, err)
}
tmp.Close()
ctx, stop := underLease(ctx, tok)
defer stop()
cmd, done := c.command(ctx, "config", "-R", c.repo, verb, tmp.Name())
defer done()
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if noCheckout(stderr.String()) {
return ErrStoreNoCheckout
}
return fmt.Errorf("zitcli: config %s: %w: %s", verb, err, strings.TrimSpace(stderr.String()))
}
return nil
}
// ExportConfig writes one configuration area out in fossil's own
// export format — what `fossil configuration export AREA` writes and
// what ImportConfig reads back (zit#210). zit knows two areas, user and
// shun. Restoring is exact for user; for shun zit only ever adds, so a
// shun export restores what it holds and cannot remove a later shun.
func (c *Client) ExportConfig(ctx context.Context, area string) ([]byte, error) {
if area != "user" && area != "shun" {
return nil, fmt.Errorf("zitcli: config export: unknown area %q (known: user, shun)", area)
}
cmd, done := c.command(ctx, "config", "-R", c.repo, "export", area)
defer done()
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if noCheckout(stderr.String()) {
return nil, ErrStoreNoCheckout
}
return nil, fmt.Errorf("zitcli: config export %s: %w: %s", area, err, strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), nil
}
// LeafKey is how a check-in hash is looked up in a Leaves set: the
// twelve-character prefix zit prints. A function rather than a slice
// expression at each call site, so the width lives in one place.
func LeafKey(hash string) string {
if len(hash) <= leafKeyWidth {
return hash
}
return hash[:leafKeyWidth]
}
// leafKeyWidth is zit's own abbreviation in `zit leaves` output.
const leafKeyWidth = 12