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)
}