Skip to content
quarry Artifact [75d3fcf8ec]
Login

Artifact [75d3fcf8ec]

Artifact 75d3fcf8ecc8dbeffb71236f05ac7d7af63ef8046820d8565f35d3496fa3edd5:


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

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

func New(repo string) *Client {
	return &Client{repo: repo}
}

// exit codes documented by `zit help frames`.
const (
	exitNoNew         = 3
	exitCursorInvalid = 4
)

// Frames streams artifacts appended after c. A current cursor returns an
// empty Batch with the same cursor; a dead cursor returns ErrCursorInvalid.
func (c *Client) Frames(cur Cursor) (Batch, error) {
	args := []string{"frames", "-R", c.repo}
	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 Batch{}, err
	}
	if err := cmd.Start(); err != nil {
		return Batch{}, err
	}
	batch, parseErr := parseFrames(bufio.NewReader(stdout))
	waitErr := cmd.Wait()

	switch code := exitCode(waitErr); {
	case code == exitCursorInvalid:
		return Batch{}, ErrCursorInvalid
	case code == exitNoNew:
		// Trailer-only output; the parsed cursor is authoritative.
	case waitErr != nil:
		return Batch{}, fmt.Errorf("zitcli: frames: %w: %s", waitErr, strings.TrimSpace(stderr.String()))
	}
	if parseErr != nil {
		return Batch{}, fmt.Errorf("zitcli: frames: %w", parseErr)
	}
	return batch, nil
}

// LsEntry is one file of a check-in as listed by `zit ls`.
type LsEntry struct {
	Blob string `json:"blob"`
	Name string `json:"name"`
}

// 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(version string) ([]LsEntry, error) {
	cmd := exec.Command("zit", "ls", "-R", c.repo, version)
	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
		}
		blob, name, found := strings.Cut(line, " ")
		if !found {
			return nil, fmt.Errorf("zitcli: ls: unparseable line %q", line)
		}
		files = append(files, LsEntry{Blob: blob, Name: name})
	}
	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(path, version string) ([]byte, error) {
	cmd := exec.Command("zit", "cat", "-R", c.repo, path, version)
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.Output()
	if exitCode(err) == 1 {
		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()))
	}
	return out, nil
}

// DiffEntry is one file in a name-status diff.
type DiffEntry struct {
	Status string `json:"status"` // A, M, D
	Name   string `json:"name"`
}

// 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(from, to string) (string, []DiffEntry, error) {
	unified, err := c.diffOut(from, to)
	if err != nil {
		return "", nil, err
	}
	brief, err := c.diffOut(from, to, "--brief")
	if err != nil {
		return "", nil, err
	}
	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 unified, entries, nil
}

func (c *Client) diffOut(from, to string, extra ...string) (string, error) {
	// -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 := exec.Command("zit", args...)
	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()))
	}
	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) (Batch, error) {
	var b Batch
	for {
		line, err := r.ReadString('\n')
		if err != nil {
			return b, 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 b, fmt.Errorf("bad size in %q", strings.TrimSpace(line))
			}
			raw := make([]byte, size)
			if _, err := io.ReadFull(r, raw); err != nil {
				return b, fmt.Errorf("artifact %s: %w", fields[1], err)
			}
			if nl, err := r.ReadByte(); err != nil || nl != '\n' {
				return b, fmt.Errorf("artifact %s: missing frame terminator", fields[1])
			}
			b.Artifacts = append(b.Artifacts, Artifact{Hash: fields[1], Bytes: raw})
		case len(fields) == 3 && fields[0] == "cursor":
			b.Cursor = Cursor{Since: fields[1], Verify: fields[2]}
			return b, nil
		default:
			return b, 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.
// 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(hash string, max int) ([]byte, error) {
	cmd := exec.Command("zit", "artifact", hash, "--raw", "-R", c.repo)
	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
}

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