Skip to content
quarry Artifact [00ef920213]
Login

Artifact [00ef920213]

Artifact 00ef920213471d02ad50403c12cc62fd28063ba40d6f6e90f802ecd720713e23:


package zitcli

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// MergeConflict is a pair that does not merge: the answer a merged tree
// has instead of a tree. State is zit's word for it ("conflict" or
// "unrelated") and Paths the files both sides changed irreconcilably.
type MergeConflict struct {
	State string
	Paths []string
}

func (e *MergeConflict) Error() string {
	if len(e.Paths) == 0 {
		return "the merge does not apply: " + e.State
	}
	return "the merge does not apply: " + e.State + " in " + strings.Join(e.Paths, ", ")
}

// checkoutState is what a checkout adds beside the tree. None of it is
// the merged tree, and a tarball carrying it would hand a pipeline a
// checkout pointing at the forge's own store.
var checkoutState = map[string]bool{
	".zit": true, ".zit-checkout": true, ".zit-cache": true,
	"_FOSSIL_": true, ".fslckout": true,
}

// MergeArchiveTo writes, as a gzip tarball at dst, the tree an accept
// of branchTip onto targetTip WOULD produce (RFC 0009 §premerge), with
// nothing written to the store. A pair that does not merge answers a
// *MergeConflict and writes no file.
//
// The probe answers first and writes nothing, so a conflict never opens
// a checkout. A clean pair is merged in a scratch checkout opened
// against this store, the way CommitFile opens one, and the tree is
// packed from there; the merge is --no-sync and nothing ever commits,
// so the store is only read. When zit can answer this itself
// (danmestas/zit#223, `zit merge-tree`), this becomes one call.
func (c *Client) MergeArchiveTo(ctx context.Context, branchTip, targetTip, name, dst string, max int64) error {
	state, paths, err := c.MergeProbe(branchTip, targetTip, "")
	if err != nil {
		return err
	}
	switch state {
	case "clean":
	case "up-to-date":
		// The branch is already in the target: the merge is the
		// target's own tree.
		return c.ArchiveTo(ctx, "tar", targetTip, name, dst, max)
	case "conflict", "unrelated":
		return &MergeConflict{State: state, Paths: paths}
	default:
		return fmt.Errorf("zitcli: merge probe of %.12s into %.12s answered %q", branchTip, targetTip, state)
	}
	dir, err := os.MkdirTemp("", "quarry-merge-")
	if err != nil {
		return err
	}
	defer os.RemoveAll(dir)
	run := func(args ...string) error {
		cmd, done := c.command(ctx, args...)
		defer done()
		cmd.Dir = dir
		var out bytes.Buffer
		cmd.Stdout, cmd.Stderr = &out, &out
		if err := cmd.Run(); err != nil {
			return fmt.Errorf("zit %s: %w: %s", args[0], err, strings.TrimSpace(out.String()))
		}
		return nil
	}
	if err := run("open", targetTip, "--store="+filepath.Join(c.repo, ".zit")); err != nil {
		return err
	}
	if err := run("merge", "--no-sync", branchTip); err != nil {
		// The probe said clean; a merge that disagrees is a fault to
		// report, not a verdict to hand a pipeline.
		return err
	}
	return tarTree(dir, name, dst, max)
}

// tarTree packs root as <name>/... into a gzip tarball at dst, leaving
// out checkout state. Timestamps are fixed so the same tree packs to the
// same bytes.
func tarTree(root, name, dst string, max int64) error {
	f, err := os.Create(dst)
	if err != nil {
		return err
	}
	gz := gzip.NewWriter(f)
	tw := tar.NewWriter(gz)
	epoch := time.Unix(0, 0).UTC()
	var total int64
	werr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(root, p)
		if err != nil || rel == "." {
			return err
		}
		if checkoutState[filepath.ToSlash(rel)] {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		h := &tar.Header{Name: name + "/" + filepath.ToSlash(rel), ModTime: epoch, Format: tar.FormatPAX}
		switch {
		case d.IsDir():
			h.Typeflag, h.Name, h.Mode = tar.TypeDir, h.Name+"/", 0o755
			return tw.WriteHeader(h)
		case info.Mode()&fs.ModeSymlink != 0:
			link, err := os.Readlink(p)
			if err != nil {
				return err
			}
			h.Typeflag, h.Linkname, h.Mode = tar.TypeSymlink, link, 0o777
			return tw.WriteHeader(h)
		case info.Mode().IsRegular():
			total += info.Size()
			if total > max {
				return fmt.Errorf("zitcli: merged tree exceeds the %d-byte limit", max)
			}
			h.Typeflag, h.Size, h.Mode = tar.TypeReg, info.Size(), 0o644
			if info.Mode().Perm()&0o111 != 0 {
				h.Mode = 0o755
			}
			if err := tw.WriteHeader(h); err != nil {
				return err
			}
			src, err := os.Open(p)
			if err != nil {
				return err
			}
			_, err = io.Copy(tw, src)
			src.Close()
			return err
		}
		return nil
	})
	for _, closer := range []io.Closer{tw, gz, f} {
		if err := closer.Close(); err != nil && werr == nil {
			werr = err
		}
	}
	if werr != nil {
		os.Remove(dst)
	}
	return werr
}