Skip to content
quarry Artifact [3449a3c56b]
Login

Artifact [3449a3c56b]

Artifact 3449a3c56b07e5cd5304f9366dfca803911f94c463e0c43b863f9c449ccf86d2:


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
	}
	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
}

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