package zitcli
import (
"bufio"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"craftdesign.group/quarry/internal/artifact"
)
// CrosslinkRecord is one check-in's derived record from `zit crosslink`
// (zit#109): the answers an indexer otherwise re-derives by parsing and
// expanding every manifest itself.
type CrosslinkRecord struct {
Hash string
MtimeMillis int64
Parents []string
User string
Comment string
Tags []artifact.Tag
// Changes holds every path the check-in touched (adds, edits and
// deletes alike — quarry's filechange table records "touched").
// FilesKnown false mirrors the stream's "files unknown": the verb
// never claims what it did not compute, and neither do we.
Changes []string
FilesKnown bool
}
// Crosslink streams every check-in's derived record in persisted order.
func (c *Client) Crosslink(fn func(CrosslinkRecord) error) error {
cmd := exec.Command("zit", "crosslink", "-R", c.repo)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
perr := parseCrosslink(bufio.NewReaderSize(stdout, 1<<20), fn)
if perr != nil {
cmd.Process.Kill()
}
werr := cmd.Wait()
if perr != nil {
return fmt.Errorf("zitcli: crosslink: %w", perr)
}
if werr != nil {
return fmt.Errorf("zitcli: crosslink: %w", werr)
}
return nil
}
func parseCrosslink(r *bufio.Reader, fn func(CrosslinkRecord) error) error {
header, err := r.ReadString('\n')
if err != nil {
return fmt.Errorf("reading header: %w", err)
}
if strings.TrimSpace(header) != "crosslink 1" {
return fmt.Errorf("unknown stream format %q", strings.TrimSpace(header))
}
var rec CrosslinkRecord
open := false
for {
line, err := r.ReadString('\n')
if err == io.EOF && line == "" {
if open {
return fmt.Errorf("stream ends inside record %s", rec.Hash)
}
return nil
}
if err != nil {
return err
}
line = strings.TrimSuffix(line, "\n")
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
switch fields[0] {
case "checkin":
if len(fields) < 3 {
return fmt.Errorf("short checkin line %q", line)
}
ms, perr := strconv.ParseInt(fields[2], 10, 64)
if perr != nil {
return fmt.Errorf("bad mtime in %q", line)
}
// The artifact parser's §4.7.17 default applies here too:
// a manifest with no U card is anonymous.
rec = CrosslinkRecord{Hash: fields[1], MtimeMillis: ms,
Parents: fields[3:], User: "anonymous", FilesKnown: true}
open = true
case "user":
if len(fields) == 2 {
rec.User = artifact.DecodeToken(fields[1])
}
case "comment":
if len(fields) != 2 {
return fmt.Errorf("bad comment line %q", line)
}
size, perr := strconv.Atoi(fields[1])
if perr != nil || size < 0 {
return fmt.Errorf("bad comment length %q", line)
}
raw := make([]byte, size)
if _, rerr := io.ReadFull(r, raw); rerr != nil {
return fmt.Errorf("comment body of %s: %w", rec.Hash, rerr)
}
if nl, rerr := r.ReadByte(); rerr != nil || nl != '\n' {
return fmt.Errorf("comment of %s: missing terminator", rec.Hash)
}
rec.Comment = string(raw)
case "tag":
if len(fields) < 3 {
return fmt.Errorf("short tag line %q", line)
}
t := artifact.Tag{Name: artifact.DecodeToken(fields[1]), Target: fields[2]}
if len(fields) > 3 {
t.Value = artifact.DecodeToken(fields[3])
}
rec.Tags = append(rec.Tags, t)
case "file":
if len(fields) != 3 {
return fmt.Errorf("bad file line %q", line)
}
rec.Changes = append(rec.Changes, artifact.DecodeToken(fields[2]))
case "files":
if len(fields) == 2 && fields[1] == "unknown" {
rec.FilesKnown = false
}
case "end":
if !open {
return fmt.Errorf("end outside a record")
}
if err := fn(rec); err != nil {
return err
}
open = false
default:
// Unknown sections from a newer stream are skipped — the
// versioned header is where incompatibility is declared.
}
}
}