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
}