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