Artifact 484a950a80c563ea650405e945e628b804d79799947ad09d6ee6f12600881100:
- File
conformance/horizon_deepen_test.go
— part of check-in
[d8458ad960]
at
2026-10-22 17:53:21
on branch feat/horizon-cones
— feat(merge): refuse a base below the structural horizon, or deepen to it
Section 5.5 forbids merging against a base the store does not hold. In a structural store the walk can reach a boundary check-in as the base, or find none where both sides' ancestry leaves the store, and the merge then failed on a phantom with an error that never named the horizon. content.MergeBaseCrossing decides the crossing for checkout merges and branch merges alike, and the error names the horizon, the base and the deepen, matching libfossil.ErrBelowHorizon.
Checkout.MergeContext is the remedy zit's merge applies: given a peer it asks the origin for the base, deepens to reach it and merges again over the real base. 'repo merge' takes the recovery flags and contacts the remote only when a merge crosses the horizon. (user: [email protected], size: 12245) [annotate] [blame] [check-ins using]
package conformance import ( "context" "errors" "net/http/httptest" "path/filepath" "strings" "testing" gofossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/internal/cone" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/horizon" "github.com/danmestas/go-libfossil/internal/horizon/seed" "github.com/danmestas/go-libfossil/internal/merge" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/internal/xfer" ) // The merge-base query and deepen (draft-fossil-horizon-00 Appendix A step // 6: §5.5, §5.7, §5.8, §5.9, §6.2.4) over hzStruct: go-libfossil's handler // is the origin, its structural clone the client. func horizonDeepenCases() []Case { return []Case{horizonMergeBaseQuery, horizonMergeBaseBound, horizonDeepen, horizonDeepenPoint, horizonDeepenFailed, horizonMergeCrossing, horizonMergeDeepen} } var ( horizonMergeBaseQuery = Case{ID: "horizon.6.2.4.merge-base", Req: "HZ-6.2-7"} horizonMergeBaseBound = Case{ID: "horizon.8.merge-base-bound", Req: "HZ-8-1"} horizonDeepen = Case{ID: "horizon.5.8.deepen", Req: "HZ-5.8-1"} horizonDeepenPoint = Case{ID: "horizon.5.8.deepen-point", Req: "HZ-5.8-1"} horizonDeepenFailed = Case{ID: "horizon.5.8.deepen-failed", Req: "HZ-5.8-1"} horizonMergeCrossing = Case{ID: "horizon.5.5.merge-crossing", Req: "HZ-5.5-1"} horizonMergeDeepen = Case{ID: "horizon.5.5.merge-deepen", Req: "HZ-5.5-2"} ) func TestHorizonDeepen(t *testing.T) { check(t, horizonMergeBaseQuery, func() error { return hzMergeBaseQuery(t) }) check(t, horizonMergeBaseBound, func() error { return hzMergeBaseBound(t) }) check(t, horizonDeepen, func() error { return hzDeepen(t) }) check(t, horizonDeepenPoint, func() error { return hzDeepenPoint(t) }) check(t, horizonDeepenFailed, func() error { return hzDeepenFailed(t) }) check(t, horizonMergeCrossing, func() error { return hzMergeCrossing(t) }) check(t, horizonMergeDeepen, func() error { return hzMergeDeepen(t) }) } // hzMergeBaseQuery: a structural clone asks its origin for the merge base of // two check-ins, one below its horizon, and the origin answers from its own // graph: the base, or - for check-ins sharing no ancestor. func hzMergeBaseQuery(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } for _, q := range [][3]string{{x.c4, x.feat1, x.c1}, {x.feat2, x.c4, x.c3}} { res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true, MergeBaseOf: [2]string{q[0], q[1]}}) if err != nil || !res.MergeBaseAnswered || res.MergeBase != q[2] { return expectf("base of %.10s and %.10s: %v answered=%v %q, want %.10s", q[0], q[1], err, res.MergeBaseAnswered, res.MergeBase, q[2]) } } root := hzCommit(t, x.s.f, "unrelated", []libfossil.FslID{0}, nil) res, err := sync.Sync(context.Background(), r, x.peer(), sync.SyncOpts{Pull: true, MergeBaseOf: [2]string{x.c4, hzUUID(t, x.s.f.r, root)}}) if err != nil || !res.MergeBaseAnswered || res.MergeBase != "" { return expectf("unrelated: %v answered=%v %q, want -", err, res.MergeBaseAnswered, res.MergeBase) } return nil } // hzMergeBaseBound: the walk behind an answer stops at its visited-node // bound, as the origin's other graph walks do (§8). func hzMergeBaseBound(t *testing.T) error { x := newHZStruct(t) if _, err := merge.FindCommonAncestorWithin(context.Background(), x.s.f.r, x.s.c4, x.s.feat1, 2); !errors.Is(err, merge.ErrBound) { return expectf("a walk past its bound: %v, want merge.ErrBound", err) } base, err := merge.FindCommonAncestorWithin(context.Background(), x.s.f.r, x.s.c4, x.s.feat1, 64) if err != nil || base != x.s.c1 { return expectf("within the bound: %d %v, want c1", base, err) } return nil } // hzDeepen: deepening a structural clone from C = c3 to its parent c2 moves // the stored horizon, discards the seed for c3 before the synchronization // (the request offers none), takes c2's manifest and the seed for c2, and // replaces nothing held: K's check-ins derive densely, only c1 and feat1 // stay below. func hzDeepen(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } before, err := hzHeldNames(r) if err != nil { return err } peer := x.peer() res, err := sync.Deepen(context.Background(), r, peer, x.c2, sync.SyncOpts{}) if err != nil || res.HorizonFallback || res.StructuralServed == 0 { return expectf("deepen to c2: %v %+v", err, res) } if err := hzStoredHorizon(r, x.c2, cone.DepthStructural); err != nil { return err } first := peer.sent()[0] if got := hzPragmas(first, "horizon-seed"); len(got) != 1 || got[0][0] != "-" { return expectf("the deepen's first request offers seed %q, want - (the seed for c3 discarded first)", got) } if got := hzPragmas(first, "horizon"); len(got) != 1 || got[0][0] != x.c2 { return expectf("the deepen's first request names horizon %q, want c2", got) } k, err := horizon.HeldSet(context.Background(), x.s.f.r.DB(), x.s.c2, cone.RuleDescendants) if err != nil { return err } _, want, err := seed.Mint(x.s.f.r.DB(), x.s.c2, k, seedLoginGo) if err != nil { return err } if got := mustHeld(t, r); string(got) != string(want) { return expectf("held seed after the deepen:\n%s\nwant the seed for c2:\n%s", got, want) } after, err := hzHeldNames(r) if err != nil { return err } for name := range before { if !after[name] { return expectf("the deepen dropped held artifact %.10s", name) } } if !after[x.c2] { return expectf("c2's manifest is not held after the deepen") } x.s.k = k if err := x.s.derivesDense(t, r); err != nil { return err } return hzElided(r, map[string]bool{x.c1: true, x.feat1: true}) } // hzDeepenPoint: a deepen toward a check-in that is not an ancestor of C // moves C to their merge base, so K only grows: feat1 lies off C's // ancestry, the clone holds neither its manifest nor its ancestry, so it // asks the origin, and C' is c1. func hzDeepenPoint(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } peer := x.peer() if _, err := sync.Deepen(context.Background(), r, peer, x.feat1, sync.SyncOpts{}); err != nil { return err } if err := hzStoredHorizon(r, x.c1, cone.DepthStructural); err != nil { return err } if got := hzPragmas(peer.sent()[0], "horizon-mergebase"); len(got) != 1 || got[0][0] != x.c3 || got[0][1] != x.feat1 { return expectf("the deepen asked %q, want the base of c3 and feat1", got) } return hzElided(r, nil) } // hzDeepenFailed: a deepen whose synchronization fails leaves the store as it // was, the horizon and the seed for C both, as zit's does. func hzDeepenFailed(t *testing.T) error { x := newHZStruct(t) r, _, err := x.clone(t, x.peer()) if err != nil { return err } held := mustHeld(t, r) if _, err := sync.Deepen(context.Background(), r, hzFailingPeer{}, x.c2, sync.SyncOpts{}); err == nil { return expectf("a deepen through a failing peer succeeded") } if err := hzStoredHorizon(r, x.c3, cone.DepthStructural); err != nil { return err } if got := mustHeld(t, r); string(got) != string(held) { return expectf("the failed deepen left seed %q, want the seed for c3 back", got) } return x.s.derivesDense(t, r) } type hzFailingPeer struct{} func (hzFailingPeer) Exchange(context.Context, *xfer.Message) (*xfer.Message, error) { return nil, errors.New("peer unreachable") } // hzStoredHorizon is nil when r's store cone carries a horizon at c, of the // descendants rule, at depth d. func hzStoredHorizon(r *repo.Repo, c string, d cone.HorizonDepth) error { sc, err := content.StoreCone(r.DB()) if err != nil { return err } if sc == nil { return expectf("the store is dense") } h, ok := sc.Horizon() if !ok || h.Hash() != c || h.Rule() != cone.RuleDescendants || h.Depth() != d { return expectf("stored horizon %v %s %v %v, want %.10s descendants %v", ok, h.Hash(), h.Rule(), h.Depth(), c, d) } return nil } // hzHeldNames is every artifact r holds, by name. func hzHeldNames(r *repo.Repo) (map[string]bool, error) { rows, err := r.DB().Query("SELECT uuid FROM blob WHERE size>=0") if err != nil { return nil, err } defer rows.Close() out := map[string]bool{} for rows.Next() { var u string if err := rows.Scan(&u); err != nil { return nil, err } out[u] = true } return out, rows.Err() } // hzMergeFixture is hzStruct's origin with y, a check-in merging feat1 into // c4, served over HTTP, and a structural clone of it at C = c3 with a // checkout at feat2. The merge base of feat2 and y is feat1, below the // horizon: the store holds y and feat2 but not their base. type hzMergeFixture struct { x *hzStruct y string origin *hzRecorder url string r *gofossil.Repo co *gofossil.Checkout } func newHZMergeFixture(t *testing.T) *hzMergeFixture { t.Helper() x := newHZStruct(t) f := &hzMergeFixture{x: x, origin: &hzRecorder{r: x.s.f.r}} f.y = hzUUID(t, x.s.f.r, hzCommit(t, x.s.f, "y", []libfossil.FslID{x.s.c4, x.s.feat1}, nil)) server := httptest.NewServer(f.origin) t.Cleanup(server.Close) f.url = server.URL hc, err := gofossil.NewHorizonCone(nil, nil, gofossil.Horizon{Checkin: x.c3, Structural: true}) if err != nil { t.Fatal(err) } r, res, err := gofossil.Clone(context.Background(), filepath.Join(t.TempDir(), "structural.fossil"), gofossil.NewHTTPTransport(f.url), gofossil.CloneOpts{StoreCone: hc, PullPhantoms: true}) if err != nil || res.HorizonFallback { t.Fatalf("structural clone: %v %+v", err, res) } t.Cleanup(func() { r.Close() }) f.r = r co, err := r.CreateCheckout(t.TempDir(), gofossil.CheckoutCreateOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { co.Close() }) if err := co.Extract(f.rid(t, x.feat2), gofossil.ExtractOpts{}); err != nil { t.Fatal(err) } f.co = co f.origin.take() return f } func (f *hzMergeFixture) rid(t *testing.T, uuid string) int64 { t.Helper() rid, err := f.r.ResolveVersion(uuid) if err != nil { t.Fatal(err) } return rid } // hzMergeCrossing: a merge whose base lies below the structural horizon is // detected before anything changes, and the diagnostic names the horizon, // the base, and the remedy. func hzMergeCrossing(t *testing.T) error { f := newHZMergeFixture(t) _, err := f.co.Merge(gofossil.CheckoutMergeOpts{Version: f.rid(t, f.y)}) if !errors.Is(err, gofossil.ErrBelowHorizon) { return expectf("merge across the horizon: %v, want ErrBelowHorizon", err) } for _, want := range []string{"below the structural horizon at " + f.x.c3, f.x.feat1, "deepen"} { if !strings.Contains(err.Error(), want) { return expectf("diagnostic %q lacks %q", err, want) } } if v, _, err := f.co.Version(); err != nil || v != f.rid(t, f.x.feat2) { return expectf("the refused merge moved the checkout to %d (%v)", v, err) } changed, err := f.co.HasChanges() if err != nil || changed { return expectf("the refused merge left changes %v (%v)", changed, err) } return nil } // hzMergeDeepen: given a peer, the merge asks the origin for the base, // deepens to a common ancestor of C and the base, and merges over the real // base it then holds. func hzMergeDeepen(t *testing.T) error { f := newHZMergeFixture(t) res, err := f.co.MergeContext(context.Background(), gofossil.CheckoutMergeOpts{Version: f.rid(t, f.y)}, gofossil.RequiredContentOpts{Peer: gofossil.NewHTTPTransport(f.url)}) if err != nil { return expectf("merge with a peer: %v", err) } if res.Ancestor != f.rid(t, f.x.feat1) { return expectf("merged against rid %d, want feat1's", res.Ancestor) } asked := 0 for _, e := range f.origin.take() { asked += len(hzPragmas(e.request, "horizon-mergebase")) } if asked == 0 { return expectf("the merge asked the origin no merge-base query") } sc, err := f.r.StoreCone() if err != nil { return err } if h, ok := sc.Horizon(); !ok || h.Checkin != f.x.c1 || !h.Structural { return expectf("store horizon after the merge %+v %v, want c1 structural", h, ok) } return nil }