Skip to content
quarry Artifact [aad1287438]
Login

Artifact [aad1287438]

Artifact aad1287438cf4d988ab48ad3e4e810a3d9c23d3f5dc7371044363c6681a7c7a8:


package api

import (
	"context"
	"errors"
	"slices"
	"testing"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/zitcli"
)

// A text file is rebuilt from the merge's regions: each answered region
// takes the reader's lines, every other region the merge's own — the
// shared stretch, the one side that changed it, or the change both made
// — and the final newline is the branch's.
func TestAnswerRegionsBuildsTheFile(t *testing.T) {
	blobs := fakeBlobs{
		"ours":   "a\nB-ours\nc\nshared\nours-only\n",
		"theirs": "a\nB-theirs\nC-theirs\nshared\n",
	}
	f := zitcli.MergeFile{Path: "f.txt", Kind: "text", Other: "ours", Current: "theirs",
		Regions: []zitcli.MergeRegion{
			{Kind: "same", Other: [2]int{0, 1}, Current: [2]int{0, 1}},
			{Kind: "conflict", Other: [2]int{1, 2}, Current: [2]int{1, 2}},
			{Kind: "current", Other: [2]int{2, 3}, Current: [2]int{2, 3}},
			{Kind: "both", Other: [2]int{3, 4}, Current: [2]int{3, 4}},
			{Kind: "other", Other: [2]int{4, 5}, Current: [2]int{4, 4}},
		}}
	body, err := answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 1, Lines: []string{"B-mine"}}})
	if err != nil {
		t.Fatal(err)
	}
	if want := "a\nB-mine\nC-theirs\nshared\nours-only\n"; string(body) != want {
		t.Errorf("FAIL: built %q, want %q", body, want)
	}
	if _, err := answerRegions(context.Background(), blobs, f, nil); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: an unanswered conflict gave %v", err)
	}
	// Overriding a merged region is allowed: an arrow can put a side's
	// old lines back, as in Meld.
	body, _ = answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 1, Lines: nil}, {Index: 2, Lines: []string{"c"}}})
	if want := "a\nc\nshared\nours-only\n"; string(body) != want {
		t.Errorf("FAIL: built %q, want %q", body, want)
	}
	if _, err := answerRegions(context.Background(), blobs, f, []resolveRegion{{Index: 9}}); !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: a region past the file gave %v", err)
	}
}

// Every conflicting path is answered exactly once; a whole-file answer
// takes that side's bytes, and a side with no file removes the path.
func TestAnswerConflictsCoversEveryPath(t *testing.T) {
	blobs := fakeBlobs{"o": "ours bytes", "c": "theirs bytes"}
	rep := zitcli.MergeReport{Files: []zitcli.MergeFile{
		{Path: "logo.png", Kind: "binary", Other: "o", Current: "c"},
		{Path: "gone.txt", Kind: "modify-delete", Other: "", Current: "c"},
		{Path: "kill.txt", Kind: "binary", Other: "o", Current: "c"},
	}}
	files, deletes, err := answerConflicts(context.Background(), blobs, rep, []resolveFile{
		{Path: "logo.png", Take: "theirs"}, {Path: "gone.txt", Take: "ours"}, {Path: "kill.txt", Take: "delete"}})
	if err != nil {
		t.Fatal(err)
	}
	if string(files["logo.png"]) != "theirs bytes" || len(files) != 1 {
		t.Errorf("FAIL: files %v", files)
	}
	slices.Sort(deletes)
	if !slices.Equal(deletes, []string{"gone.txt", "kill.txt"}) {
		t.Errorf("FAIL: deletes %v", deletes)
	}
	for _, bad := range [][]resolveFile{
		{{Path: "logo.png", Take: "ours"}},
		{{Path: "logo.png", Take: "ours"}, {Path: "logo.png", Take: "theirs"}, {Path: "kill.txt", Take: "ours"}},
		{{Path: "logo.png", Take: "ours"}, {Path: "gone.txt", Take: "ours"}, {Path: "other.txt", Take: "ours"}},
		{{Path: "logo.png", Take: "regions"}, {Path: "gone.txt", Take: "ours"}, {Path: "kill.txt", Take: "ours"}},
	} {
		if _, _, err := answerConflicts(context.Background(), blobs, rep, bad); !errors.Is(err, ops.ErrInvalid) {
			t.Errorf("FAIL: answers %+v gave %v", bad, err)
		}
	}
}

// Resolving writes to the branch, so a protected branch's rule decides
// it as it decides a push: refused before the store is asked.
func TestResolveOnAProtectedBranchIsRefused(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos:   NewRepos(map[string]*Repo{"q": {Index: landingIndex(t, "alice", "feat", "trunk", "")}}),
		Protect: stubProtect{rules: []oltp.Protection{{Branch: "feat", Owners: []string{"alice"}}}}}); err != nil {
		t.Fatal(err)
	}
	_, err := r.Invoke(ops.WithUser(context.Background(), "mallory"), "landing.resolve",
		landingResolveIn{Repo: "q", ID: landingHash, BranchTip: "b", TargetTip: "t"})
	if !errors.Is(err, ops.ErrForbidden) {
		t.Errorf("FAIL: a stranger resolving onto a protected branch gave %v", err)
	}
}

// A merge names two branches the index knows, and two different ones:
// a typo is not found rather than read as a clean merge.
func TestMergePairRefusesWhatIsNotAPair(t *testing.T) {
	r := ops.NewRegistry()
	if err := RegisterAll(r, Deps{Version: "v",
		Repos: NewRepos(map[string]*Repo{"q": {Index: landingIndex(t, "alice", "feat", "trunk", "")}})}); err != nil {
		t.Fatal(err)
	}
	if _, err := r.Invoke(context.Background(), "merge.conflicts", mergePairIn{Repo: "q", Branch: "nope", Onto: "trunk"}); !errors.Is(err, ops.ErrNotFound) {
		t.Errorf("FAIL: an unknown branch gave %v", err)
	}
}

// Merging onto the target is a push to the target, and resolving onto
// the branch a push to the branch: each side's own protection decides,
// before the store is asked.
func TestMergeCommitIsAPushToTheTarget(t *testing.T) {
	ix, _, _ := closeIndex(t)
	for _, tc := range []struct {
		protect, op string
		refused     bool
	}{
		{"trunk", "merge.commit", true},
		{"dev", "merge.commit", false},
		{"dev", "merge.resolve", true},
		{"trunk", "merge.resolve", false},
	} {
		r := closeRegistry(t, ix, []oltp.Protection{{Branch: tc.protect, Owners: []string{"alice"}}})
		_, err := r.Invoke(ops.WithUser(context.Background(), "mallory"), tc.op,
			mergeResolveIn{Repo: "test", Branch: "dev", Onto: "trunk", BranchTip: "b", TargetTip: "t"})
		if errors.Is(err, ops.ErrForbidden) != tc.refused {
			t.Errorf("FAIL: %s with %s protected gave %v", tc.op, tc.protect, err)
		}
	}
}

// A merge's source may be a check-in, as `fossil merge` takes one, but
// only the target can take such a merge: a check-in is no branch.
func TestMergeSourceMayBeACheckin(t *testing.T) {
	ix, _, devTip := closeIndex(t)
	repo := &Repo{Index: ix}
	if got := sourceTipOf(repo, devTip[:10]); got != devTip {
		t.Errorf("FAIL: a check-in prefix resolved to %q", got)
	}
	if got := sourceTipOf(repo, "dev"); got != devTip {
		t.Errorf("FAIL: a branch resolved to %q", got)
	}
	if got := sourceTipOf(repo, "zzzz"); got != "" {
		t.Errorf("FAIL: a name that is neither resolved to %q", got)
	}
	r := closeRegistry(t, ix, nil)
	_, err := r.Invoke(ops.WithUser(context.Background(), "alice"), "merge.resolve",
		mergeResolveIn{Repo: "test", Branch: devTip[:12], Onto: "trunk", BranchTip: devTip, TargetTip: "t"})
	if !errors.Is(err, ops.ErrInvalid) {
		t.Errorf("FAIL: resolving onto a check-in gave %v", err)
	}
}