Skip to content
go-libfossil Artifact [0d78a968f2]
Login

Artifact [0d78a968f2]

Artifact 0d78a968f20351620385b132702eaae8f058c433b0fdd09a1bcf5003e0a255b1:


package sync

import (
	"context"
	"errors"
	"slices"
	"strings"
	"testing"
	"time"

	libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
	"github.com/danmestas/go-libfossil/internal/manifest"
	"github.com/danmestas/go-libfossil/internal/merge"
	"github.com/danmestas/go-libfossil/internal/repo"
	"github.com/danmestas/go-libfossil/internal/xfer"
)

// mbCommit checks in one file on parent (0 for a root) and returns its rid
// and hash.
func mbCommit(t *testing.T, r *repo.Repo, name string, parent libfossil.FslID, when time.Time) (libfossil.FslID, string) {
	t.Helper()
	rid, uuid, err := manifest.Checkin(r, manifest.CheckinOpts{
		Files: []manifest.File{{Name: name, Content: []byte(name + "\n")}}, Comment: name, User: "u",
		Parent: parent, Time: when,
	})
	if err != nil {
		t.Fatal(err)
	}
	return rid, uuid
}

// mbFork is root -> a and root -> b, plus an unrelated root, at the origin.
type mbFork struct {
	r                   *repo.Repo
	root, a, b, unrelat string
	rootRID, aRID       libfossil.FslID
}

func newMBFork(t *testing.T) *mbFork {
	t.Helper()
	f := &mbFork{r: setupPushableTestRepo(t)}
	when := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
	f.rootRID, f.root = mbCommit(t, f.r, "root", 0, when)
	f.aRID, f.a = mbCommit(t, f.r, "a", f.rootRID, when.Add(time.Hour))
	_, f.b = mbCommit(t, f.r, "b", f.rootRID, when.Add(2*time.Hour))
	_, f.unrelat = mbCommit(t, f.r, "unrelated", 0, when.Add(3*time.Hour))
	return f
}

func mbAsk(t *testing.T, r *repo.Repo, a, b string, pull bool) []string {
	t.Helper()
	cards := []xfer.Card{&xfer.PragmaCard{Name: horizonMergeBasePragma, Values: []string{a, b}}}
	if pull {
		cards = append(cards, &xfer.PullCard{ProjectCode: testPC, ServerCode: "s"})
	}
	resp, err := HandleSync(context.Background(), r, &xfer.Message{Cards: cards})
	if err != nil {
		t.Fatal(err)
	}
	var out []string
	for _, line := range pragmaLines(t, resp.Cards) {
		if strings.HasPrefix(line, "pragma "+horizonMergeBasePragma) {
			out = append(out, line)
		}
	}
	return out
}

// draft-fossil-horizon-00 §6.2.4: an origin answers a merge-base query in
// its pull reply with the base, or - when the two share no ancestor, spelled
// as zit spells it.
func TestHorizonMergeBaseAnswered(t *testing.T) {
	f := newMBFork(t)
	if got, want := mbAsk(t, f.r, f.a, f.b, true), "pragma horizon-mergebase "+f.a+" "+f.b+" "+f.root; !slices.Equal(got, []string{want}) {
		t.Fatalf("answer = %q, want %q", got, want)
	}
	if got, want := mbAsk(t, f.r, f.a, f.unrelat, true), "pragma horizon-mergebase "+f.a+" "+f.unrelat+" -"; !slices.Equal(got, []string{want}) {
		t.Fatalf("answer = %q, want %q", got, want)
	}
}

// A query the origin cannot answer -- a check-in it does not hold, a
// malformed hash, or no pull to answer in -- draws no answer, which the
// client reads as unanswered.
func TestHorizonMergeBaseUnanswered(t *testing.T) {
	f := newMBFork(t)
	missing := strings.Repeat("ab", 20)
	for _, tc := range []struct {
		a, b string
		pull bool
	}{{f.a, missing, true}, {f.a, "abc", true}, {f.a, f.b, false}} {
		if got := mbAsk(t, f.r, tc.a, tc.b, tc.pull); len(got) != 0 {
			t.Fatalf("query %.8s %.8s pull=%v answered %q", tc.a, tc.b, tc.pull, got)
		}
	}
}

// §8: the ancestry walk behind the answer carries the visited-node bound of
// the origin's other graph walks; past it the query goes unanswered rather
// than walking on.
func TestHorizonMergeBaseBounded(t *testing.T) {
	f := newMBFork(t)
	tip := f.aRID
	when := time.Date(2026, 10, 2, 0, 0, 0, 0, time.UTC)
	for i := range 8 {
		tip, _ = mbCommit(t, f.r, "chain", tip, when.Add(time.Duration(i)*time.Minute))
	}
	tipUUID := mbUUID(t, f.r, tip)
	if _, ok, err := originMergeBase(context.Background(), f.r, tipUUID, f.b, 4); ok || !errors.Is(err, merge.ErrBound) {
		t.Fatalf("a walk past the bound answered=%v err=%v, want ErrBound", ok, err)
	}
	base, ok, err := originMergeBase(context.Background(), f.r, tipUUID, f.b, 64)
	if err != nil || !ok || base != f.root {
		t.Fatalf("within the bound: %q %v %v, want %s", base, ok, err, f.root)
	}
}

func mbUUID(t *testing.T, r *repo.Repo, rid libfossil.FslID) string {
	t.Helper()
	var uuid string
	if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", rid).Scan(&uuid); err != nil {
		t.Fatal(err)
	}
	return uuid
}

// The client asks once, in the first request after the cursor, as zit asks,
// and reports the answer.
func TestHorizonMergeBaseClient(t *testing.T) {
	f := newMBFork(t)
	client := setupSyncTestRepo(t)
	var sent []*xfer.Message
	transport := &MockTransport{Handler: func(req *xfer.Message) *xfer.Message {
		sent = append(sent, req)
		resp, err := HandleSync(context.Background(), f.r, req)
		if err != nil {
			t.Fatal(err)
		}
		return resp
	}}
	res, err := Sync(context.Background(), client, transport, SyncOpts{Pull: true, ProjectCode: testPC,
		MergeBaseOf: [2]string{f.a, f.b}})
	if err != nil {
		t.Fatal(err)
	}
	if !res.MergeBaseAnswered || res.MergeBase != f.root {
		t.Fatalf("result answered=%v base=%q, want %s", res.MergeBaseAnswered, res.MergeBase, f.root)
	}
	asked := 0
	for i, req := range sent {
		for _, line := range pragmaLines(t, req.Cards) {
			if line == "pragma horizon-mergebase "+f.a+" "+f.b {
				asked++
				if i != 0 {
					t.Fatalf("asked in request %d, want the first only", i)
				}
			}
		}
	}
	if asked != 1 {
		t.Fatalf("asked %d times, want once", asked)
	}
	res, err = Sync(context.Background(), client, transport, SyncOpts{Pull: true, ProjectCode: testPC,
		MergeBaseOf: [2]string{f.a, f.unrelat}})
	if err != nil || !res.MergeBaseAnswered || res.MergeBase != "" {
		t.Fatalf("no common ancestor: %v answered=%v base=%q", err, res.MergeBaseAnswered, res.MergeBase)
	}
}