Artifact 428e9ab64da3e366cd9bc836519d96a6695fcda202159c0a49ebde557c762ae0:
- File
internal/zitcli/zitcli.go
— part of check-in
[d0e80a9156]
at
2026-08-15 12:05:39
on branch trunk
— checkin: detail op + page, hash resolution, zit ls on demand
CheckinByPrefix (unique-only); file set via zit subprocess at request time; timeline links hashes; harness snapshots every check-in page. (user: dmestas, size: 4838) [annotate] [blame] [check-ins using]
// 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)) } } }