Skip to content
quarry Artifact [9cdf9b5837]
Login

Artifact [9cdf9b5837]

Artifact 9cdf9b5837b904fa4b8d9bf6c69ddfe31f3051b2e71ed13792ab0b557730a69c:


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 (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]string, len(entries))
	for _, e := range entries {
		want[e.Name] = e.Blob
	}
	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 false, err
	}
	if err := cmd.Start(); err != nil {
		return false, err
	}
	same, rerr := treeMatches(out, want)
	io.Copy(io.Discard, out) // a mismatch stops reading; 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,
			strings.Contains(msg, "unrelated"), strings.Contains(msg, "no common ancestor"):
			return false, nil
		}
		return false, fmt.Errorf("zitcli: merge-tree %.12s into %.12s: %w: %s", branchTip, targetTip, err, msg)
	}
	return same, rerr
}

// treeMatches reads a merged tree's gzip tar and reports whether it
// holds exactly want's paths with want's hashes.
func treeMatches(r io.Reader, want map[string]string) (bool, error) {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return false, err
	}
	tr := tar.NewReader(gz)
	seen := 0
	for {
		h, err := tr.Next()
		if errors.Is(err, io.EOF) {
			return seen == len(want), nil
		}
		if err != nil {
			return false, err
		}
		var body []byte
		switch h.Typeflag {
		case tar.TypeReg:
			if body, err = io.ReadAll(tr); err != nil {
				return false, err
			}
		case tar.TypeSymlink:
			body = []byte(h.Linkname) // a symlink's blob is its target
		default:
			continue
		}
		blob, ok := want[strings.TrimPrefix(h.Name, "./")]
		if !ok || blobHash(body, len(blob)) != blob {
			return false, nil
		}
		seen++
	}
}

// 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[:])
}