Artifact 61e15a83fd0abc2d7e691ab85e50cea25014c90b6fc2a906c3d33248d1ed77c9:
- File
conformance/horizon_mergebase_zit_test.go
— part of check-in
[78483726d9]
at
2026-10-23 01:41:07
on branch feat/horizon-cones
— feat(sync): ask the list-form merge base whenever a merge crosses
The re-pinned draft's 6.2.4 query is a primary and one or two secondaries, `pragma horizon-mergebase <primary> <secondary>...`, and the answer repeats the query in order and appends Fossil's pivot over the set. A two-hash query was answered as the base of a pair, so a merge into a checkout with a merge already pending could be told a base Fossil's merge would not choose. The origin now takes the list (sync.MergeBaseQuery), answers nothing when it lacks any named check-in or is asked more secondaries than a six-token card can repeat, and the client takes only an answer that repeats its query.
5.5 now has the clone ask whenever either side's ancestry crosses the horizon, not only when its own walk does: over part of the graph the common ancestor found need not be the nearest. MergeContext, given a peer, checks the ancestry of the merged-in version, the checkout and its pending plain merges (content.AncestryCrosses), asks the pivot of the merged-in version over the checkout and those merges, and merges over the answer, falling back to Merge when the origin does not answer, as zit's merge does. With two merges already pending the query cannot carry them, and asking without them would name another base, so the merge is refused before anything changes.
The deepen point's query names the target over C, as zit asks it. (user: [email protected], size: 5586) [annotate] [blame] [check-ins using] [more...]
package conformance import ( "context" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "github.com/danmestas/go-libfossil/internal/sync" ) // TestZitHorizonMergeBaseInterop runs the merge-base query // (draft-fossil-horizon-00 §6.2.4) between go-libfossil and zit both ways // over hzStruct, in its list form: the primary first, then one or two // secondaries, the answer repeating them and appending the pivot. // go-libfossil asks `zit serve` with one secondary and with two, and zit's // answer must be go-libfossil's origin's; zit, deepening its structural // checkout to c1, below its horizon, asks go-libfossil's handler for the // pivot of c1 over C and deepens to the answer, and merging feat2 into a // checkout of c4, whose ancestry crosses its horizon, asks the pivot of // feat2 over c4 although its own walk finds one. zit's merge refuses a // second merge while one is pending, so it never asks two secondaries. func TestZitHorizonMergeBaseInterop(t *testing.T) { bin := zitHorizonBinary(t) x := newHZStruct(t) dir := t.TempDir() home := filepath.Join(dir, "home") if err := os.Mkdir(home, 0o755); err != nil { t.Fatal(err) } origin := &hzRecorder{r: x.s.f.r} server := httptest.NewServer(origin) defer server.Close() // go-libfossil asks zit. zd := filepath.Join(dir, "zdense") zitRun(t, bin, home, dir, "clone", server.URL, zd, "--no-open", "--once") peer := &hzRecordingTransport{inner: &sync.HTTPTransport{URL: zitServe(t, bin, home, zd)}} r, _, err := sync.Clone(context.Background(), filepath.Join(dir, "dense.fossil"), peer, sync.CloneOpts{}) if err != nil { t.Fatal(err) } defer r.Close() for _, tc := range []struct { hashes []string // the primary, then its secondaries want string // "" for go-libfossil's origin's answer }{ {[]string{x.c4, x.feat2}, x.c3}, {[]string{x.feat2, x.c1}, x.c1}, {[]string{x.c4, x.feat2, x.c1}, ""}, {[]string{x.feat2, x.c4, x.c2}, ""}, {[]string{x.c1, x.c4, x.feat2}, ""}, } { query := sync.MergeBaseQuery{Primary: tc.hashes[0], Secondaries: tc.hashes[1:]} want := tc.want if want == "" { own, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true, MergeBaseOf: query}) if err != nil || !own.MergeBaseAnswered || own.MergeBase == "" { t.Fatalf("go-libfossil's answer for %.10s over %.10s: %v %+v", tc.hashes[0], tc.hashes[1:], err, own) } want = own.MergeBase } peer.replies = nil res, err := sync.Sync(context.Background(), r, peer, sync.SyncOpts{Pull: true, MergeBaseOf: query}) if err != nil || !res.MergeBaseAnswered || res.MergeBase != want { t.Fatalf("zit's answer for %.10s over %.10s: %v answered=%v base=%q, want %s", tc.hashes[0], tc.hashes[1:], err, res.MergeBaseAnswered, res.MergeBase, want) } card := append(slices.Clone(tc.hashes), want) if got := hzPragmas(peer.replies[0], "horizon-mergebase"); !slices.EqualFunc(got, [][]string{card}, slices.Equal) { t.Fatalf("zit's answer card %q, want %q", got, card) } } // zit answers nothing, not -, when it lacks a named check-in. peer.replies = nil res, err := sync.Sync(context.Background(), r, peer, sync.SyncOpts{Pull: true, MergeBaseOf: sync.MergeBaseQuery{Primary: x.c4, Secondaries: []string{x.feat2, strings.Repeat("ab", 20)}}}) if err != nil || res.MergeBaseAnswered { t.Fatalf("zit answered a query naming a check-in it lacks: %v %+v", err, res) } // zit asks go-libfossil. zs := filepath.Join(dir, "zstructural") if out, err := zitCommand(bin, home, dir, "clone", server.URL, zs, "--horizon="+x.c3, "--structural"); err != nil { t.Fatalf("zit's structural clone: %v\n%s", err, out) } origin.take() if out, err := zitCommand(bin, home, zs, "update", x.c1); err != nil || !strings.Contains(out, "deepening") { t.Fatalf("zit's deepen to c1: %v\n%s", err, out) } asked := false for _, e := range origin.take() { if q := hzPragmas(e.request, "horizon-mergebase"); len(q) != 0 { asked = true if !slices.EqualFunc(q, [][]string{{x.c1, x.c3}}, slices.Equal) { t.Fatalf("zit asked %q, want the pivot of c1 over C", q) } if got := hzPragmas(e.reply, "horizon-mergebase"); !slices.EqualFunc(got, [][]string{{x.c1, x.c3, x.c1}}, slices.Equal) { t.Fatalf("go-libfossil answered zit %q", got) } } } if !asked { t.Fatal("zit's deepen asked go-libfossil no merge-base query") } if got := zitRun(t, bin, home, zs, "info", x.c1); !strings.Contains(got, x.c1) { t.Fatalf("zit's deepened checkout does not hold c1:\n%s", got) } // zit's merge asks whenever its ancestry crosses the horizon (§5.5). zm := filepath.Join(dir, "zmerge") if out, err := zitCommand(bin, home, dir, "clone", server.URL, zm, "--horizon="+x.c3, "--structural"); err != nil { t.Fatalf("zit's structural clone: %v\n%s", err, out) } zitRun(t, bin, home, zm, "update", x.c4) origin.take() if out, err := zitCommand(bin, home, zm, "merge", x.feat2); err != nil { t.Fatalf("zit's merge of feat2: %v\n%s", err, out) } asked = false for _, e := range origin.take() { if q := hzPragmas(e.request, "horizon-mergebase"); len(q) != 0 { asked = true if !slices.EqualFunc(q, [][]string{{x.feat2, x.c4}}, slices.Equal) { t.Fatalf("zit's merge asked %q, want the pivot of feat2 over c4", q) } if got := hzPragmas(e.reply, "horizon-mergebase"); !slices.EqualFunc(got, [][]string{{x.feat2, x.c4, x.c3}}, slices.Equal) { t.Fatalf("go-libfossil answered zit's merge %q", got) } } } if !asked { t.Fatal("zit's merge across the horizon asked go-libfossil no merge-base query") } }