Skip to content
quarry Artifact [17309e5cef]
Login

Artifact [17309e5cef]

Artifact 17309e5cefb0bc701e830cf421555650fd4268d3dc42cc4dd9b808448de8d110:


package zitcli

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"crypto/sha1"
	"crypto/sha3"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"os/exec"
	"strings"
)

// SameTree reports whether a check-in's tree is the one merging
// branchTip into targetTip writes: the same paths, each with the same
// content hash and mode (RFC 0009 §inheritance). The manifest hash is
// no answer: it carries the comment, the date and the user, and two
// check-ins of one tree differ in all three.
//
// The merged tree is `zit merge-tree --tar`, the merge an accept
// writes, read as it streams and never put on disk. Each file is hashed
// the way the check-in's blob was named, SHA1 or SHA3-256 by the
// name's length. A pair that does not merge has no tree, and no tree
// equals it.
func (c *Client) SameTree(ctx context.Context, checkin, branchTip, targetTip string) (bool, error) {
	// The annotated listing: the plain one names no blobs. It is the
	// listing the repository home warms for the default branch's tip.
	entries, err := c.Ls(ctx, checkin)
	if err != nil {
		return false, err
	}
	want := make(map[string]treeEntry, len(entries))
	for _, e := range entries {
		want[e.Name] = treeEntry{blob: e.Blob, exec: e.Executable(), link: e.Symlink()}
	}
	var same bool
	err = c.mergeTree(ctx, branchTip, targetTip, func(r io.Reader) (err error) {
		same, err = treeMatches(r, want)
		return err
	})
	var mc *MergeConflict
	if errors.As(err, &mc) {
		return false, nil
	}
	return same, err
}

// MergedFiles reads the files of the tree merging branchTip into
// targetTip writes whose paths keep accepts, by path, without writing
// anything anywhere: the merged tree's pipelines, which are what a
// premerge pair runs (RFC 0009 §once). A pair that does not merge
// answers a *MergeConflict.
func (c *Client) MergedFiles(ctx context.Context, branchTip, targetTip string, keep func(path string) bool) (map[string][]byte, error) {
	out := map[string][]byte{}
	err := c.mergeTree(ctx, branchTip, targetTip, func(r io.Reader) error {
		gz, err := gzip.NewReader(r)
		if err != nil {
			return err
		}
		tr := tar.NewReader(gz)
		for {
			h, err := tr.Next()
			if errors.Is(err, io.EOF) {
				return nil
			}
			if err != nil {
				return err
			}
			name := strings.TrimPrefix(h.Name, "./")
			if h.Typeflag != tar.TypeReg || !keep(name) {
				continue
			}
			if out[name], err = io.ReadAll(tr); err != nil {
				return err
			}
		}
	})
	if err != nil {
		return nil, err
	}
	return out, nil
}

// mergeTree streams `zit merge-tree --tar` of a pair to read. A pair
// that does not merge answers a *MergeConflict and read sees nothing.
func (c *Client) mergeTree(ctx context.Context, branchTip, targetTip string, read func(io.Reader) error) error {
	cmd, done := c.command(ctx, "merge-tree", "-R", c.repo, "--into", targetTip, "--tar", "--", branchTip)
	defer done()
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	out, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	if err := cmd.Start(); err != nil {
		return err
	}
	rerr := read(out)
	io.Copy(io.Discard, out) // a reader may stop early; let zit finish
	if err := cmd.Wait(); err != nil {
		var exit *exec.ExitError
		msg := strings.TrimSpace(stderr.String())
		switch {
		case errors.As(err, &exit) && exit.ExitCode() == 5:
			return &MergeConflict{State: "conflict"} // the paths went to read, as stdout
		case strings.Contains(msg, "unrelated"), strings.Contains(msg, "no common ancestor"):
			return &MergeConflict{State: "unrelated"}
		}
		return fmt.Errorf("zitcli: merge-tree %.12s into %.12s: %w: %s", branchTip, targetTip, err, msg)
	}
	return rerr
}

// treeEntry is what a tree holds at a path: the content's hash, and
// whether it is executable or a symbolic link, which the content's hash
// does not say and a build can depend on.
type treeEntry struct {
	blob string
	exec bool
	link bool
}

// treeMatches reads a merged tree's gzip tar and reports whether it
// holds exactly want's paths, each with want's hash and mode. Paths are
// counted once each, so a path the tar repeats cannot stand in for one
// it lacks.
func treeMatches(r io.Reader, want map[string]treeEntry) (bool, error) {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return false, err
	}
	tr := tar.NewReader(gz)
	seen := make(map[string]bool, len(want))
	for {
		h, err := tr.Next()
		if errors.Is(err, io.EOF) {
			return len(seen) == len(want), nil
		}
		if err != nil {
			return false, err
		}
		var got treeEntry
		var body []byte
		switch h.Typeflag {
		case tar.TypeReg:
			if body, err = io.ReadAll(tr); err != nil {
				return false, err
			}
			got.exec = h.Mode&0o111 != 0
		case tar.TypeSymlink:
			body, got.link = []byte(h.Linkname), true // a symlink's blob is its target
		default:
			continue
		}
		name := strings.TrimPrefix(h.Name, "./")
		w, ok := want[name]
		got.blob = blobHash(body, len(w.blob))
		if !ok || got != w {
			return false, nil
		}
		seen[name] = true
	}
}

// blobHash names content as a blob of the given name length does.
func blobHash(body []byte, n int) string {
	if n == 2*sha1.Size {
		s := sha1.Sum(body)
		return hex.EncodeToString(s[:])
	}
	s := sha3.Sum256(body)
	return hex.EncodeToString(s[:])
}