package api
import (
"context"
"errors"
"fmt"
"strings"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
type landingResolveIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,min=1,max=64"`
// The tips the answers were made against, as landing.conflicts gave
// them. Either having moved refuses the commit, here and in zit.
BranchTip string `json:"branchTip" validate:"required,max=64"`
TargetTip string `json:"targetTip" validate:"required,max=64"`
Message string `json:"message" validate:"max=4096"`
// Files answers every conflicting path, and no other.
Files []resolveFile `json:"files" validate:"max=50"`
}
// resolveFile is one conflicting path's answer. Take is "regions" for a
// text file settled region by region, or "ours", "theirs" or "delete"
// for the whole file.
type resolveFile struct {
Path string `json:"path" validate:"required,max=4096"`
Take string `json:"take" validate:"oneof=regions|ours|theirs|delete"`
Regions []resolveRegion `json:"regions"`
}
// resolveRegion replaces one region of the merge with the reader's
// lines: its index in landing.conflicts' regions for the file.
type resolveRegion struct {
Index int `json:"index" validate:"min=0"`
Lines []string `json:"lines"`
}
type landingResolveOut struct {
// Commit is zit's line for the merge check-in it wrote.
Commit string `json:"commit"`
Branch string `json:"branch"`
}
// registerLandingResolve commits a conflicting landing's resolution as
// the merge of its target into its branch, on the branch, as the
// caller (#343). The answers come from the page: a choice per conflict
// region, or a whole-file choice. quarry builds each answered file from
// the merge's own regions and the three versions' blobs, and zit
// records the merge (`zit merge-tree --commit`): every other path is the
// merge's own, and zit refuses answers that are not exactly the
// conflicting paths or still hold a conflict marker.
//
// It writes to the branch, so it is gated as a push to it: the check-in
// capability, and a protected branch's own rule. Merge CI re-runs on the
// new tip like any push.
func (d Deps) registerLandingResolve(r *ops.Registry) error {
return ops.Register(r, ops.Op[landingResolveIn, landingResolveOut]{
Name: "landing.resolve",
Caps: "i",
Doc: "Commit a conflicting landing's resolution: the merge of its target into its branch, on the branch, as the caller. " +
"`files` answers every conflicting path from landing.conflicts: region by region for text (`take: regions`, each answered region by index), or `ours`, `theirs` or `delete` for the whole file. " +
"Refused, with nothing written, when either tip moved since `branchTip`/`targetTip`, when the answers are not exactly the conflicting paths, when an open conflict is unanswered, or when an answer still holds a conflict marker.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/landing/{id}/resolve",
Run: func(ctx context.Context, in landingResolveIn) (landingResolveOut, error) {
if err := d.refuseUnlessWritable(in.Repo); err != nil {
return landingResolveOut{}, err
}
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return landingResolveOut{}, err
}
l, ok, err := repo.Index.LandingByPrefix(d.landingRef(in.Repo, repo, in.ID))
if err != nil {
return landingResolveOut{}, err
}
if !ok {
return landingResolveOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
}
if l.State != "open" {
return landingResolveOut{}, fmt.Errorf("the landing is %s; there is nothing to resolve: %w", l.State, ops.ErrInvalid)
}
return d.pairResolve(ctx, in.Repo, repo, l.Branch, l.Target, resolveAnswers{BranchTip: in.BranchTip,
TargetTip: in.TargetTip, Message: in.Message, Files: in.Files}, "the landing page")
},
})
}
// resolveAnswers is a resolution as the page sends it: the tips it was
// made against, an optional message, and an answer per conflicting
// path.
type resolveAnswers struct {
BranchTip, TargetTip, Message string
Files []resolveFile
}
// pairResolve commits a resolution of branch onto target: the merge of
// the target into the branch, on the branch, as the caller. It is a push
// to the branch, so the branch's protection decides it. where names the
// page in the default message.
func (d Deps) pairResolve(ctx context.Context, repoName string, repo *Repo, branch, onto string, a resolveAnswers, where string) (landingResolveOut, error) {
return d.pairCommit(ctx, repoName, repo, branch, onto, a, false, where)
}
// pairCommit records the merge of a pair, on the branch (intoOnto
// false: the target brought into the branch) or on the target (intoOnto
// true: the branch merged in, as `fossil merge` on the target would).
// Either way it is a push to the side written, so that side's
// protection decides it. Onto the target, the source may be any
// check-in, not only a branch.
func (d Deps) pairCommit(ctx context.Context, repoName string, repo *Repo, branch, onto string, a resolveAnswers, intoOnto bool, where string) (landingResolveOut, error) {
dest := branch
if intoOnto {
dest = onto
}
if d.Protect != nil && !d.Protect.MayJoin(repoName, ops.User(ctx), dest) {
return landingResolveOut{}, fmt.Errorf("%s is protected, and you may not push to it: %w", dest, ops.ErrForbidden)
}
if !intoOnto && branchTipOf(repo, branch) == "" {
return landingResolveOut{}, fmt.Errorf("%s is not a branch; only a branch takes a merge: %w", branch, ops.ErrInvalid)
}
if repo.Zit == nil {
return landingResolveOut{}, fmt.Errorf("this repository's store cannot be asked: %w", ops.ErrUnavailable)
}
bt, tt := sourceTipOf(repo, branch), branchTipOf(repo, onto)
if bt != a.BranchTip || tt != a.TargetTip {
return landingResolveOut{}, resolveMoved(branch, onto)
}
rep, err := repo.Zit.MergeRegions(ctx, bt, tt)
if err != nil {
return landingResolveOut{}, err
}
switch rep.Verdict {
case "up-to-date":
return landingResolveOut{}, fmt.Errorf("%s is already in %s; there is nothing to merge: %w", branch, onto, ops.ErrInvalid)
case "unrelated":
return landingResolveOut{}, fmt.Errorf("%s and %s share no history: %w", branch, onto, ops.ErrInvalid)
case "clean":
// Nothing to settle: zit records the merge as `fossil merge`
// then a commit would (zit 0.3.42, danmestas/zit#235, #236). Bringing
// the target into a branch that already merges cleanly is the
// same act, and answers given for it are refused below by zit.
}
resolved, deletes, err := answerConflicts(ctx, repo.Zit, rep, a.Files)
if err != nil {
return landingResolveOut{}, err
}
msg := strings.TrimSpace(a.Message)
switch {
case msg != "":
case intoOnto:
msg = "Merge " + branch + " into " + onto
default:
msg = "merge " + onto + " into " + branch + ", resolved on " + where
}
var line string
if err := repo.write(func(tok lease.Token) (werr error) {
if intoOnto {
line, werr = repo.Zit.MergeCommit(tok, bt, onto, resolved, deletes, msg, author(ctx), tt, bt)
} else {
line, werr = repo.Zit.MergeCommit(tok, onto, branch, resolved, deletes, msg, author(ctx), bt, tt)
}
return werr
}); err != nil {
switch {
case errors.Is(err, zitcli.MergeMoved):
return landingResolveOut{}, resolveMoved(branch, onto)
case errors.Is(err, zitcli.ErrNoEntry):
return landingResolveOut{}, fmt.Errorf("%s: %w", refusalText(err), ops.ErrInvalid)
}
return landingResolveOut{}, err
}
if err := repo.synced(); err != nil {
return landingResolveOut{}, err
}
return landingResolveOut{Commit: line, Branch: dest}, nil
}
func resolveMoved(branch, target string) error {
return fmt.Errorf("%s or %s moved since these conflicts were read; reload to see the merge as it is now: %w",
branch, target, ops.ErrInvalid)
}
// conflictBytes is what answering a conflict needs from a store.
type conflictBytes interface {
ArtifactSize(ctx context.Context, hash string) (int, error)
ArtifactRaw(ctx context.Context, hash string, max int) ([]byte, error)
}
// answerConflicts turns the page's answers into the files zit is given:
// content for each answered path, and the paths answered by removal.
// The answers must cover exactly the conflicting paths.
func answerConflicts(ctx context.Context, z conflictBytes, rep zitcli.MergeReport, answers []resolveFile) (map[string][]byte, []string, error) {
byPath := map[string]resolveFile{}
for _, a := range answers {
if _, dup := byPath[a.Path]; dup {
return nil, nil, fmt.Errorf("%s is answered twice: %w", a.Path, ops.ErrInvalid)
}
byPath[a.Path] = a
}
if len(byPath) != len(rep.Files) {
return nil, nil, fmt.Errorf("%d of %d conflicting files are answered: %w", len(byPath), len(rep.Files), ops.ErrInvalid)
}
resolved := map[string][]byte{}
var deletes []string
for _, f := range rep.Files {
a, ok := byPath[f.Path]
if !ok {
return nil, nil, fmt.Errorf("%s conflicts and has no answer: %w", f.Path, ops.ErrInvalid)
}
// Ours is the branch (zit's other), theirs the target (current).
var blob string
switch a.Take {
case "delete":
deletes = append(deletes, f.Path)
continue
case "ours":
blob = f.Other
case "theirs":
blob = f.Current
case "regions":
body, err := answerRegions(ctx, z, f, a.Regions)
if err != nil {
return nil, nil, err
}
resolved[f.Path] = body
continue
}
if blob == "" {
// That side has no file here: taking it removes the path.
deletes = append(deletes, f.Path)
continue
}
body, err := readBlob(ctx, z, blob)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", f.Path, err)
}
resolved[f.Path] = body
}
return resolved, deletes, nil
}
func readBlob(ctx context.Context, z conflictBytes, blob string) ([]byte, error) {
if n, err := z.ArtifactSize(ctx, blob); err == nil && n > maxConflictBytes {
return nil, fmt.Errorf("too large to resolve here (%d bytes): %w", n, ops.ErrInvalid)
}
return z.ArtifactRaw(ctx, blob, maxConflictBytes)
}
// answerRegions builds a text file from the merge's regions: each
// answered region takes the reader's lines, and every other region the
// merge's own — a stretch both sides share, or the one side that changed
// it. An open conflict left unanswered refuses the whole commit.
func answerRegions(ctx context.Context, z conflictBytes, f zitcli.MergeFile, answers []resolveRegion) ([]byte, error) {
if f.Kind != "text" || len(f.Regions) == 0 {
return nil, fmt.Errorf("%s is %s and is answered whole, not by region: %w", f.Path, f.Kind, ops.ErrInvalid)
}
given := map[int][]string{}
for _, a := range answers {
if a.Index >= len(f.Regions) {
return nil, fmt.Errorf("%s has no region %d: %w", f.Path, a.Index, ops.ErrInvalid)
}
given[a.Index] = a.Lines
}
lines := map[string][]string{}
trailing := true
for side, blob := range map[string]string{"ours": f.Other, "theirs": f.Current} {
if blob == "" {
continue
}
b, err := readBlob(ctx, z, blob)
if err != nil {
return nil, fmt.Errorf("%s: %w", f.Path, err)
}
lines[side] = splitLines(string(b))
if side == "ours" && len(b) > 0 {
trailing = b[len(b)-1] == '\n'
}
}
span := func(side string, r [2]int) []string {
ls := lines[side]
return ls[min(r[0], len(ls)):min(r[1], len(ls))]
}
var out []string
for i, r := range f.Regions {
if ls, ok := given[i]; ok {
out = append(out, ls...)
continue
}
switch r.Kind {
case "same", "other", "both":
out = append(out, span("ours", r.Other)...)
case "current":
out = append(out, span("theirs", r.Current)...)
default:
return nil, fmt.Errorf("%s has an unanswered conflict at line %d: %w", f.Path, r.Other[0]+1, ops.ErrInvalid)
}
}
body := strings.Join(out, "\n")
if trailing && len(out) > 0 {
body += "\n"
}
return []byte(body), nil
}