Skip to content
quarry Artifact [6e6947d34e]
Login

Artifact [6e6947d34e]

Artifact 6e6947d34e44dd746ffa0bc6d7e80389f4c565ea355ac8596c4745c6fdd23bd7:


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

// 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) {
	args := append([]string{"diff", "--from", from, "--to", to}, extra...)
	cmd := exec.Command("zit", args...)
	// zit diff rejects -R even for --from/--to (zit#69); until that
	// lands, run inside the repo — which must therefore be a checkout,
	// not a bare .zit file.
	cmd.Dir = c.repo
	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))
		}
	}
}