Artifact 0d78a968f20351620385b132702eaae8f058c433b0fdd09a1bcf5003e0a255b1:
- File
internal/sync/horizon_mergebase_test.go
— part of check-in
[5612ac9771]
at
2026-10-22 17:28:17
on branch feat/horizon-cones
— feat(sync): answer and ask the horizon merge-base query
A structural clone cannot compute a merge base whose ancestry lies below its horizon, so draft-fossil-horizon-00 section 6.2.4 lets it ask the origin with pragma horizon-mergebase A B. The origin answers in its pull reply, ahead of the igots where zit places it, with the base or - for no common ancestor, and only for check-ins it holds. Section 8 bounds the walk: merge.FindCommonAncestorWithin carries the held-set walk's visited-node bound and the query goes unanswered past it.
The client asks once, in the first request after the cursor, through SyncOpts.MergeBaseOf and reports the answer in the result. Interop runs both ways against zit 9bf7ebfe0f. (user: [email protected], size: 5692) [annotate] [blame] [check-ins using] [more...]
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) } }