Skip to content
quarry Artifact [fe77abbf6c]
Login

Artifact [fe77abbf6c]

Artifact fe77abbf6c5a045502504d6fe911710a4a5ae669e2293a05dc27933129f26b37:


package zitcli

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"

	"craftdesign.group/quarry/internal/lease"
)

// MergeReport is zit's account of merging one check-in into another,
// file by file and region by region (`zit merge-tree -F json`,
// danmestas/zit#231). It comes from the same loop that does the merge,
// so a conflict view built on it shows exactly what `zit merge` leaves
// in a checkout; quarry never merges or parses conflict markers itself.
//
// zit's names are its own: current is the check-in merged INTO (a
// landing's target), other is the one merged (the branch). The forge
// says ours and theirs, and translates in exactly one place.
type MergeReport struct {
	Verdict string      `json:"verdict"`
	Base    string      `json:"base"`
	Current string      `json:"current"`
	Other   string      `json:"other"`
	Files   []MergeFile `json:"files"`
}

// MergeFile is one file the merge could not settle on its own: its
// kind, each side's blob, and, for text, the regions in order.
type MergeFile struct {
	Path    string            `json:"path"`
	Kind    string            `json:"kind"`
	Base    string            `json:"base"`
	Current string            `json:"current"`
	Other   string            `json:"other"`
	Mode    map[string]string `json:"mode"`
	Regions []MergeRegion     `json:"regions"`
}

// MergeRegion is one stretch of a file, as half-open line ranges in
// each side's own lines: same, current (changed only on current's
// side, and merged), other (likewise), or conflict.
type MergeRegion struct {
	Kind    string `json:"kind"`
	Base    [2]int `json:"base"`
	Current [2]int `json:"current"`
	Other   [2]int `json:"other"`
}

// MergeRegions asks zit for the report of merging branchTip into
// targetTip, on the store, writing nothing. A clean merge answers a
// report with no files. Memoised on the pair: a check-in cannot change,
// so neither can its merge with another.
func (c *Client) MergeRegions(ctx context.Context, branchTip, targetTip string) (MergeReport, error) {
	key := "regions\x00" + branchTip + "\x00" + targetTip
	c.mergeMu.Lock()
	hit, ok := c.regionCache[key]
	c.mergeMu.Unlock()
	if ok {
		return hit, nil
	}
	cmd, done := c.command(ctx, "merge-tree", "-R", c.repo, "--into", targetTip, "-F", "json", "--", branchTip)
	defer done()
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr
	if err := cmd.Run(); err != nil {
		// Exit 5 is a conflict, and the report is the answer, not a
		// failure.
		var exit *exec.ExitError
		if !errors.As(err, &exit) || exit.ExitCode() != 5 {
			msg := strings.TrimSpace(stderr.String())
			if missingVersion(msg) {
				return MergeReport{}, fmt.Errorf("%w: %.12s into %.12s: %s", ErrNoVersion, branchTip, targetTip, msg)
			}
			return MergeReport{}, fmt.Errorf("zitcli: merge-tree -F json %.12s into %.12s: %w: %s", branchTip, targetTip, err, msg)
		}
	}
	var rep MergeReport
	if err := json.Unmarshal(stdout.Bytes(), &rep); err != nil {
		return MergeReport{}, fmt.Errorf("zitcli: merge-tree -F json %.12s into %.12s: %w", branchTip, targetTip, err)
	}
	c.mergeMu.Lock()
	if c.regionCache == nil || len(c.regionCache) >= regionCacheLimit {
		c.regionCache = map[string]MergeReport{}
	}
	c.regionCache[key] = rep
	c.mergeMu.Unlock()
	return rep, nil
}

// regionCacheLimit bounds the memo: a report carries every conflicted
// file's regions, and open landings are few.
const regionCacheLimit = 64

// MergeMoved is merge-tree --commit's refusal when the branch or the
// target is no longer the check-in the answers were made against: exit
// 5, nothing written.
var MergeMoved = errors.New("the branch or its target moved since the conflicts were read")

// MergeCommit records the merge of other into the branch current, on
// current's branch, with an answer for every conflicting path: resolved
// holds each path's content, deletes the paths resolved by removing
// them. Every other path is the merge's own. zit refuses unless current
// and other are still expectCurrent and expectOther, the answers are
// exactly the conflicting paths, and no answer holds a conflict marker;
// then nothing is written. Returns zit's commit line.
func (c *Client) MergeCommit(tok lease.Token, other, current string, resolved map[string][]byte, deletes []string,
	message, user, expectCurrent, expectOther string) (string, error) {
	if err := tok.Err(); err != nil {
		return "", err
	}
	if expectCurrent == "" || expectOther == "" {
		return "", errors.New("zitcli: a resolved merge must name the check-ins it was resolved against")
	}
	dir, err := os.MkdirTemp("", "quarry-resolved-")
	if err != nil {
		return "", err
	}
	defer os.RemoveAll(dir)
	for p, body := range resolved {
		dst := filepath.Join(dir, filepath.FromSlash(p))
		if !strings.HasPrefix(dst, dir+string(os.PathSeparator)) {
			return "", fmt.Errorf("zitcli: resolved path %q leaves the answers", p)
		}
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			return "", err
		}
		if err := os.WriteFile(dst, body, 0o644); err != nil {
			return "", err
		}
	}
	args := []string{"merge-tree", "-R", c.repo, "--into", current, "--commit"}
	if len(resolved) > 0 {
		// A clean merge has nothing to answer, and zit refuses answers
		// given for one (zit 0.3.41).
		args = append(args, "--resolved", dir)
	}
	for _, p := range deletes {
		args = append(args, "--delete", p)
	}
	args = append(args, "-m", message, "--user", user, "--expect-current", expectCurrent, "--expect-other", expectOther, "--", other)
	cmd, done := c.writing(tok, args...)
	defer done()
	var stdout, stderr bytes.Buffer
	cmd.Stdout, cmd.Stderr = &stdout, &stderr
	err = cmd.Run()
	msg := strings.TrimSpace(stderr.String())
	switch exitCode(err) {
	case 0:
		return strings.TrimSpace(stdout.String()), nil
	case 5:
		return "", fmt.Errorf("%w: %s", MergeMoved, msg)
	case 1:
		return "", fmt.Errorf("%w: %s", ErrNoEntry, msg)
	}
	if signalled(err) {
		return "", fmt.Errorf("merge-tree --commit: %w", ErrStopped)
	}
	return "", fmt.Errorf("zitcli: merge-tree --commit: %w: %s", err, msg)
}