package api
import (
"context"
"fmt"
"strings"
"sync"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/zitcli"
)
type landingConflictsIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// ID names the landing as landing.get takes it: hash, prefix of at
// least four, or number.
ID string `json:"id" validate:"required,min=1,max=64"`
}
// conflictSide is one version of a conflicted file: its blob and, for a
// text file within the limit, its lines.
type conflictSide struct {
Blob string `json:"blob,omitempty"`
Mode string `json:"mode,omitempty"`
Lines []string `json:"lines,omitempty"`
}
// conflictRegion is a stretch of the file in each version's own lines,
// as half-open ranges counted from 0: same, ours (changed only on the branch, and
// merged), theirs (changed only on the target, and merged), or
// conflict.
type conflictRegion struct {
Kind string `json:"kind"`
Base lineSpan `json:"base"`
Ours lineSpan `json:"ours"`
Theirs lineSpan `json:"theirs"`
}
// lineSpan is a half-open range of a version's lines, counted from 0.
type lineSpan struct {
Start int `json:"start"`
End int `json:"end"`
}
func spanOf(r [2]int) lineSpan { return lineSpan{Start: r[0], End: r[1]} }
type conflictFile struct {
Path string `json:"path"`
// Kind is zit's: text, binary, or the kind of a file that is not
// content (a mode or type change).
Kind string `json:"kind"`
Ours conflictSide `json:"ours"`
Theirs conflictSide `json:"theirs"`
Base conflictSide `json:"base"`
Regions []conflictRegion `json:"regions,omitempty"`
// Omitted says why a file's lines are not here: binary, too large,
// or past the file limit. Its blobs and links still are.
Omitted string `json:"omitted,omitempty"`
}
type landingConflictsOut struct {
Branch string `json:"branch"`
Target string `json:"target"`
// The pair the report is about, resolved once: the view names both
// and a tip that moves makes it a different report.
BranchTip string `json:"branchTip"`
TargetTip string `json:"targetTip"`
// Verdict is zit's: conflict, clean, or up-to-date.
Verdict string `json:"verdict"`
Files []conflictFile `json:"files"`
}
// Limits on what one view carries. A file past them is listed by name,
// with its blobs, rather than drawn.
const (
maxConflictFiles = 20
maxConflictBytes = 1 << 20
)
// registerLandingConflicts serves a conflicting landing's merge region
// by region, from zit's own report (`zit merge-tree -F json`,
// danmestas/zit#231): the view shows what `zit merge` would leave in
// the author's checkout, and quarry never merges or reads conflict
// markers itself.
//
// Ours is the branch being landed and theirs is its target: whoever
// resolves is committing to the branch, and that is what they see
// running `zit merge <target>` there. zit names the sides the other way
// round (current is the target), and this is the one place the forge
// translates.
func (d Deps) registerLandingConflicts(r *ops.Registry) error {
return ops.Register(r, ops.Op[landingConflictsIn, landingConflictsOut]{
Name: "landing.conflicts",
Caps: "o",
Doc: "A landing's merge with its target, file by file and region by region, from zit's merge report. " +
"Ours is the branch, theirs the target. Each region is same, ours, theirs or conflict, with half-open line ranges in each version's own lines. " +
"Text files within the limits carry each version's lines; binary and oversized files carry their blobs and say why.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/landing/{id}/conflicts",
Run: func(ctx context.Context, in landingConflictsIn) (landingConflictsOut, error) {
repo, err := d.repoFor(ctx, in.Repo)
if err != nil {
return landingConflictsOut{}, err
}
id := d.landingRef(in.Repo, repo, in.ID)
l, ok, err := repo.Index.Landing(id)
if err != nil {
return landingConflictsOut{}, err
}
if !ok && len(id) >= 4 {
if full, found, rerr := repo.Index.LandingByPrefix(id); rerr == nil && found {
l, ok = full, true
}
}
if !ok {
return landingConflictsOut{}, fmt.Errorf("no landing %q here: %w", in.ID, ops.ErrNotFound)
}
if repo.Zit == nil {
return landingConflictsOut{}, fmt.Errorf("this repository's store cannot be asked: %w", ops.ErrUnavailable)
}
out := landingConflictsOut{Branch: l.Branch, Target: l.Target,
BranchTip: branchTipOf(repo, l.Branch), TargetTip: branchTipOf(repo, l.Target), Files: []conflictFile{}}
if out.BranchTip == "" || out.TargetTip == "" {
return landingConflictsOut{}, fmt.Errorf("the branch or its target has no check-in: %w", ops.ErrNotFound)
}
key := in.Repo + "\x00" + out.BranchTip + "\x00" + out.TargetTip
if hit, ok := conflictsCache.get(key); ok {
return hit, nil
}
rep, err := repo.Zit.MergeRegions(ctx, out.BranchTip, out.TargetTip)
if err != nil {
return landingConflictsOut{}, err
}
out.Verdict = rep.Verdict
for i, f := range rep.Files {
cf := conflictFileOf(f)
switch {
case i >= maxConflictFiles:
cf.Omitted = "past the file limit"
case f.Kind != "text":
cf.Omitted = f.Kind
default:
if why := readConflictSides(ctx, repo.Zit, &cf); why != "" {
cf.Omitted = why
}
}
if cf.Omitted != "" {
cf.Regions = nil
}
out.Files = append(out.Files, cf)
}
conflictsCache.put(key, out)
return out, nil
},
})
}
// conflictFileOf translates zit's file into the forge's sides: other is
// ours, current is theirs.
func conflictFileOf(f zitcli.MergeFile) conflictFile {
cf := conflictFile{Path: f.Path, Kind: f.Kind,
Ours: conflictSide{Blob: f.Other, Mode: f.Mode["other"]},
Theirs: conflictSide{Blob: f.Current, Mode: f.Mode["current"]},
Base: conflictSide{Blob: f.Base, Mode: f.Mode["base"]}}
for _, r := range f.Regions {
kind := r.Kind
switch kind {
case "other":
kind = "ours"
case "current":
kind = "theirs"
}
cf.Regions = append(cf.Regions, conflictRegion{Kind: kind, Base: spanOf(r.Base), Ours: spanOf(r.Other), Theirs: spanOf(r.Current)})
}
return cf
}
// conflictBlobs is what reading a conflict's sides needs from a store.
type conflictBlobs interface {
ArtifactSize(ctx context.Context, hash string) (int, error)
ArtifactRaw(ctx context.Context, hash string, max int) ([]byte, error)
}
// readConflictSides fills each side's lines by its blob hash, never by
// path at a tip, so the lines are the ones the regions index. A side
// with no blob (a file the other side added) has no lines. Returns why
// the file cannot be drawn, or "".
func readConflictSides(ctx context.Context, z conflictBlobs, cf *conflictFile) string {
for _, s := range []*conflictSide{&cf.Ours, &cf.Theirs, &cf.Base} {
if s.Blob == "" {
continue
}
if n, err := z.ArtifactSize(ctx, s.Blob); err == nil && n > maxConflictBytes {
return "too large"
}
b, err := z.ArtifactRaw(ctx, s.Blob, maxConflictBytes)
if err != nil {
return "unreadable"
}
s.Lines = splitLines(string(b))
}
return ""
}
// splitLines is a file's lines as zit counts them: a final newline ends
// the last line rather than starting another.
func splitLines(s string) []string {
if s == "" {
return []string{}
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}
// conflictsCache keeps answers by repository and tip pair. Two
// check-ins' merge cannot change, and drawing it reads three blobs per
// file, so a page that re-renders — the live stream does on every
// event — must not read them again. Bounded, and dropped whole when
// full: an open landing's pair is asked for again at once.
var conflictsCache = &pairCache{m: map[string]landingConflictsOut{}}
type pairCache struct {
mu sync.Mutex
m map[string]landingConflictsOut
}
func (c *pairCache) get(k string) (landingConflictsOut, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[k]
return v, ok
}
func (c *pairCache) put(k string, v landingConflictsOut) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.m) >= 32 {
c.m = map[string]landingConflictsOut{}
}
c.m[k] = v
}