package zitcli
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"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, ", ")
}
// MergeArchiveTo writes, as a gzip tarball at dst, the tree an accept
// of branchTip onto targetTip WOULD produce, with nothing written to the
// store: `zit merge-tree` (danmestas/zit#223) merges on the store alone
// and writes plain files, the same merge `merge --probe` and `landing
// accept` judge. A pair that does not merge answers a *MergeConflict and
// writes no file.
//
// The tree is packed here, under a top-level <name>/ directory, which is
// the shape the forge's check-in archives have and the runner unpacks.
func (c *Client) MergeArchiveTo(ctx context.Context, branchTip, targetTip, name, dst string, max int64) error {
dir, err := os.MkdirTemp("", "quarry-merge-")
if err != nil {
return err
}
defer os.RemoveAll(dir)
tree := filepath.Join(dir, "tree")
cmd, done := c.command(ctx, "merge-tree", "-R", c.repo, "--into", targetTip, "--out", tree, "--", branchTip)
defer done()
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
var exit *exec.ExitError
switch {
case errors.As(err, &exit) && exit.ExitCode() == 5:
return &MergeConflict{State: "conflict", Paths: strings.Fields(stdout.String())}
case strings.Contains(msg, "unrelated") || strings.Contains(msg, "no common ancestor"):
return &MergeConflict{State: "unrelated"}
case missingVersion(msg):
return fmt.Errorf("%w: %.12s into %.12s: %s", ErrNoVersion, branchTip, targetTip, msg)
}
return fmt.Errorf("zitcli: merge-tree %.12s into %.12s: %w: %s", branchTip, targetTip, err, msg)
}
return tarTree(tree, name, dst, max)
}
// tarTree packs root as <name>/... into a gzip tarball at dst.
// 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
}
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
}