Skip to content
quarry Artifact [428e9ab64d]
Login

Artifact [428e9ab64d]

Artifact 428e9ab64da3e366cd9bc836519d96a6695fcda202159c0a49ebde557c762ae0:


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

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