Skip to content
quarry Artifact [c0f5a5788f]
Login

Artifact [c0f5a5788f]

Artifact c0f5a5788fb9eef593c8c613b5cb6a504176859627062f988a61dea818576546:


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

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