Skip to content
quarry Artifact [0371fe2d60]
Login

Artifact [0371fe2d60]

Artifact 0371fe2d6037fad0ac02474ba4834805c21908feb3bf26c905557e665ee544f5:


package zitcli

import (
	"bufio"
	"context"
	"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
	// 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.
	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.
func (c *Client) Crosslink(fn func(CrosslinkRecord) error) error {
	cmd, untrack, _ := zitChild(context.Background(), repoName(c.repo), "crosslink", "crosslink", "-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":
			if len(fields) == 2 && fields[1] == "unknown" {
				rec.FilesKnown = false
			}
		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
}