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