package zitcli
import (
"bufio"
"context"
"craftdesign.group/quarry/internal/obs"
"fmt"
"io"
"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
// FilesWhy is the word zit gives after "files unknown" since 0.14.7:
// "missing" (an artifact the walk needs is absent and may arrive) or
// "malformed" (never retried); "" for any other cause or an older zit.
FilesWhy string
// Lines are each changed path's line counts against the primary
// parent (zit 0.3.40, zit#232), for the paths zit could count. A
// path missing here is uncounted, not unchanged. Since 0.3.41 zit
// counts them only when asked, with --lines.
Lines map[string]LineCount
// Cherrypicks are the record's Q cards (zit 0.3.37, zit#228): a
// check-in cherry-picked into this one (+) or backed out of it (-).
Cherrypicks []artifact.Cherrypick
}
// Crosslink streams every check-in's derived record in persisted order.
// Cancelling ctx kills the child, which matters because the verb can run
// a long while before its first record: fn is not called in that time,
// so an error from fn alone cannot stop it.
func (c *Client) Crosslink(ctx context.Context, fn func(CrosslinkRecord) error) error {
cmd, untrack, _ := zitChild(ctx, repoName(c.repo), "crosslink", obs.Cause(ctx), "crosslink", "--lines", "-R", c.repo)
defer untrack()
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 "lines":
// Counts for the file line just before it; "- -" is a
// binary file.
if len(fields) != 3 || len(rec.Changes) == 0 {
return fmt.Errorf("bad lines line %q", line)
}
lc, ok := parseLineCount(fields[1], fields[2])
if !ok {
return fmt.Errorf("bad lines line %q", line)
}
if rec.Lines == nil {
rec.Lines = map[string]LineCount{}
}
rec.Lines[rec.Changes[len(rec.Changes)-1]] = lc
case "files":
// A prefix: zit 0.14.7 says why after the two words
// ("files unknown missing HASH", "files unknown malformed";
// zit#344, #345). Matching the whole line would read those
// as known, with no changes.
if len(fields) >= 2 && fields[1] == "unknown" {
rec.FilesKnown = false
if len(fields) >= 3 {
rec.FilesWhy = fields[2]
}
}
case "cherrypick":
if len(fields) != 2 || len(fields[1]) < 2 || (fields[1][0] != '+' && fields[1][0] != '-') {
return fmt.Errorf("bad cherrypick line %q", line)
}
rec.Cherrypicks = append(rec.Cherrypicks, artifact.Cherrypick{Sign: fields[1][0], Hash: fields[1][1:]})
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.
}
}
}
// LineCount is one file's lines added and removed in a check-in, as
// zit diff --numstat counts them. A binary file is -1 on both sides: a
// file that changed, with no lines to count.
type LineCount struct {
Added, Removed int
}
func parseLineCount(added, removed string) (LineCount, bool) {
if added == "-" && removed == "-" {
return LineCount{-1, -1}, true
}
a, err1 := strconv.Atoi(added)
r, err2 := strconv.Atoi(removed)
if err1 != nil || err2 != nil || a < 0 || r < 0 {
return LineCount{}, false
}
return LineCount{a, r}, true
}