Indexer (A-326): a branch take-in keeps no revision with a refused cycle

The trunk's take-in refused a cycle with a build edge, but a branch other
than the first kept its revision with one. The branch path now applies the
same rule before keeping its entries and says the cycle, as it says heads it
could not read.

Builds: design 47 G §49, §51, A-326

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-13 02:32:23 +01:00
parent 78cf4e7666
commit 59b46be0d2
2 changed files with 44 additions and 0 deletions
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"maps"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -51,5 +52,10 @@ func (ix *Indexer) ApplyBranchSource(ctx context.Context, branch string, a meshd
if err != nil {
return err
}
// A cycle with a build edge no running version breaks is refused here as at the trunk's take-in (design 47 G §49, A-326).
if cycle := RefusedCycle(WithEdges(ix.withActs(ctx, entries))); cycle != nil {
ix.logf("catalog graph: the branch %s at source revision %d keeps no revision: its catalog graph holds a cycle with a build edge, which no running version breaks (design 47 G §49): %s; taken in again at its next source-moved", branch, a.Revision, strings.Join(cycle, ", "))
return nil
}
return ix.KeepBranchEntries(ctx, branch, a.Revision, entries)
}
@@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
@@ -73,3 +74,40 @@ func TestABranchOtherThanTheFirstIsTakenInIntoItsOwnRevisionAndTheTrunkIsLeftAlo
t.Errorf("the same heads said %d revisions (%v), want one", len(said), err)
}
}
// Design 47 G §49, A-326: a branch take-in refuses a cycle with a build edge as the trunk's does: the branch keeps no revision
// at those heads and says why, naming every artifact on the cycle.
func TestABranchTakeInKeepsNoRevisionWithARefusedCycle(t *testing.T) {
ctx := context.Background()
mods := builderModules(t)
for i, m := range mods {
switch m.name {
case "avahi": // avahi is built on mesh-tools (kind 3) ...
mods[i].manifest = strings.Replace(catalogueManifest(t, "avahi"), `"build": {`, `"build": {
"on": [{"arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "tools"}],`, 1)
case "mesh-tools": // ... and mesh-tools' artifact needs avahi's (kind 2): no build seat breaks the cycle.
mods[i].manifest = strings.Replace(m.manifest, `"name": "tools",`, `"name": "tools", "needs": ["avahi:tools"],`, 1)
}
}
ix, _, merged := newFormIndexerUnmerged(t, mods...)
ix.Revisions = newMemObjects(0)
ix.Graph = newMem()
var logs strings.Builder
ix.Logf = func(format string, args ...any) { fmt.Fprintf(&logs, format+"\n", args...) }
ix.Message = func(context.Context, string, string, int) ([]byte, error) { return json.Marshal(merged) }
var said []meshcatalog.CatalogRevisionEvent
ix.SayCatalogRevision = func(_ context.Context, e meshcatalog.CatalogRevisionEvent) error {
said = append(said, e)
return nil
}
answer := meshdelivery.SourceAnswer{Branch: "beta", Revision: 9, Repositories: []meshdelivery.SourceHead{{Repository: catalogueRepo, Head: merged.Merge, Trunk: "beta"}}}
if err := ix.ApplyBranchSource(ctx, "beta", answer); err != nil {
t.Fatal(err)
}
if _, _, err := ReadRevision(ctx, ix.Revisions, BranchObject("beta", 1)); err == nil || len(said) != 0 {
t.Errorf("the branch kept a revision with a refused cycle (said %+v)", said)
}
if want := "mesh-tools:tools, avahi:tools, mesh-tools:tools"; !strings.Contains(logs.String(), want) {
t.Errorf("the journal %q does not say the cycle %s", logs.String(), want)
}
}