Skip to content
quarry Artifact [fc1217e402]
Login

Artifact [fc1217e402]

Artifact fc1217e4028f510729aa0ab372cfb8e151af2bef5c3052ee27c2b6a560394f00:


// 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"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"craftdesign.group/quarry/internal/timing"
)

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

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

	// 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.
	diffMu    sync.Mutex
	diffCache map[string]diffResult

	// 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{},
		blobs:   newBlobCache(blobCacheBytes),
	}
}

// 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()
	}
	start := time.Now()
	return exec.CommandContext(ctx, "zit", args...),
		func() { timing.Record(ctx, timing.Zit, time.Since(start)) }
}

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

// 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 := exec.Command("zit", args...)
	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)
	if parseErr != nil {
		// Stop the child rather than draining a stream nobody will read;
		// on a 13GB census the difference is minutes.
		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{}, fmt.Errorf("zitcli: frames: %w: %s", waitErr, strings.TrimSpace(stderr.String()))
	}
	if parseErr != nil {
		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, "")
}

// 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)
	}
	key := version + "\x00" + under
	c.lsMu.Lock()
	if hit, ok := c.lsCache[key]; ok {
		c.lsMu.Unlock()
		return hit, nil
	}
	c.lsMu.Unlock()

	entries, err := c.lsUncached(ctx, version, under)
	if err != nil {
		return nil, err
	}
	c.lsMu.Lock()
	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] = entries
	c.lsMu.Unlock()
	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, fmt.Errorf("zitcli: ls %s: %w: %s", version, err, strings.TrimSpace(stderr.String()))
	}
	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")

// 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, fmt.Errorf("zitcli: cat %s@%s: %w: %s", path, version, err, strings.TrimSpace(stderr.String()))
	}
	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", ref, "-R", c.repo)
	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
}

// diffCacheLimit bounds the memo. Diffs are larger than listings, so
// this is smaller than lsCacheLimit: enough for a browsing session,
// not enough to hold a repository.
const diffCacheLimit = 32

// 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 {
		c.diffMu.Lock()
		hit, ok := c.diffCache[key]
		c.diffMu.Unlock()
		if ok {
			return hit.unified, hit.entries, nil
		}
	}
	unified, entries, err := c.diffUncached(ctx, from, to)
	if err == nil && cacheable {
		c.diffMu.Lock()
		if c.diffCache == nil {
			c.diffCache = map[string]diffResult{}
		}
		if len(c.diffCache) >= diffCacheLimit {
			for k := range c.diffCache {
				delete(c.diffCache, k)
				break
			}
		}
		c.diffCache[key] = diffResult{unified, entries}
		c.diffMu.Unlock()
	}
	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 "", fmt.Errorf("zitcli: diff %s..%s: %w: %s", from, to, err, strings.TrimSpace(stderr.String()))
	}
	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(title, user, mimetype string, body []byte) (string, error) {
	args := []string{"wiki", "commit", title, "-R", c.repo, "--user", user}
	if mimetype != "" {
		args = append(args, "--mimetype", mimetype)
	}
	cmd := exec.Command("zit", args...)
	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(user string, fields map[string]string) (id, hash string, err error) {
	out, err := c.ticketRun(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(id, user string, fields map[string]string) (string, error) {
	out, err := c.ticketRun(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(args []string) (string, error) {
	cmd := exec.Command("zit", args...)
	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 "", fmt.Errorf("zitcli: %s: %w: %s", strings.Join(args[:2], " "), err, strings.TrimSpace(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(title, user, mimetype string, body []byte) (string, error) {
	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(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(postHash, user, mimetype string, body []byte) (string, error) {
	args := []string{"forum", "reply", postHash, "-R", c.repo, "--user", user}
	if mimetype != "" {
		args = append(args, "--mimetype", mimetype)
	}
	return c.stdinHash(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).
func (c *Client) TechnoteAdd(user, event, mimetype, comment string, body []byte) (id, hash string, err error) {
	args := []string{"technote", "add", "-R", c.repo, "--user", user}
	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(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
}

// 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(args []string, body []byte) (string, error) {
	cmd := exec.Command("zit", args...)
	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 "", fmt.Errorf("zitcli: %s %s: %w: %s", args[0], args[1], err, strings.TrimSpace(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", hash, "--raw", "-R", c.repo)
	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(url string) (bool, error) {
	cmd := exec.Command("zit", "pull", url)
	cmd.Dir = c.repo
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if err != nil {
		return false, fmt.Errorf("zitcli: pull %s: %w: %s", url, err,
			strings.TrimSpace(stderr.String()))
	}
	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"):
		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(name, checkin, value string, propagate bool) error {
	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 := exec.Command("zit", args...)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if _, err := cmd.Output(); err != nil {
		return fmt.Errorf("zitcli: tag add %s %s: %w: %s", name, checkin, err,
			strings.TrimSpace(stderr.String()))
	}
	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)
	}
	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 {
		return fmt.Errorf("zitcli: %s %s: %w: %s", cmdName, version, err,
			strings.TrimSpace(stderr.String()))
	}
	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.
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") {
		// "* <hash>  <branch>" — the star marks the checkout's own leaf.
		f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "* "))
		if len(f) == 0 || len(f[0]) < 40 {
			continue
		}
		set[f[0]] = true
	}
	return set, nil
}

// 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(user, branch, target, message string) (string, error) {
	out, err := c.landingRun("open", "-R", c.repo, "--branch", branch,
		"--to", target, "--user", user, "-m", message)
	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.
//
// 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(verb, id, user, message string) (string, error) {
	args := []string{verb, id, "-R", c.repo, "--user", user}
	if message != "" {
		args = append(args, "-m", message)
	}
	out, err := c.landingRun(args...)
	if err != nil {
		return "", err
	}
	return firstField(out), nil
}

func (c *Client) landingRun(args ...string) (string, error) {
	cmd := exec.Command("zit", append([]string{"landing"}, args...)...)
	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 1:
		return "", fmt.Errorf("%w: %s", ErrNoEntry, msg)
	}
	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

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

// LandingShow asks the STORE whether a landing would merge, without writing
// anything (zit#131 phase 1). 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", or "conflict" with the paths.
func (c *Client) LandingShow(id string) (state string, conflicts []string, err error) {
	out, err := c.landingRun("show", id, "-R", c.repo)
	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"
			}
		}
	}
	return state, conflictPaths(out), 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]
}