Skip to content
quarry Artifact [815d37098b]
Login

Artifact [815d37098b]

Artifact 815d37098b361302894b020477911e9f6dac64b089d91ed9086c86a0af36771c:


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
}