Move a module on a merge only when its build source holds a changed file (hq ADR 0267, issue 363)

Every merge to the controller's repository planned the controller, the
build seat's holder and the route proxy in three gated tiers, whatever it
changed (issue 338). The planner now maps a merge's files onto the build
source each module's newest trunk build said: a README moves nothing, the
controller's command the controller alone, the proxy's program the proxy
alone. A module with none said, or one an open plan has yet to build, is
read whole as before. Sharing a repository draws no packages edge any more,
and one recorded before neither widens nor orders a plan.
This commit is contained in:
jochen
2026-10-10 03:20:36 +02:00
parent de55c63e12
commit 6c616838a5
18 changed files with 543 additions and 140 deletions
+127 -19
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
@@ -313,7 +314,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
if err != nil {
return notNow(err)
}
read, err := inv.ReadRepositories(ctx)
read, err := readForAMerge(ctx, inv)
if err != nil {
return notNow(err)
}
@@ -360,7 +361,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base)
}
}
touched, added, _ := touchedBy(from, entries, m)
touched, added, _ := touchedBy(from, entries, m, read)
// **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build
// seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with
// every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise.
@@ -587,7 +588,7 @@ func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository) []inventory.Entry {
from, _, _ := mergeCandidates(m, entries, read)
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m), m)
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
return touched
}
@@ -672,18 +673,112 @@ func sameRepository(repository string, m link.SourceMoved) bool {
(m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git")))
}
// readsFrom is whether a module's build read the repository a merge names: the second repository its
// recipe packages source from. Its ref must be the branch that moved, or unset — the same rule a
// module's own source follows.
// readsFrom is whether a merge changed what a module's build read in another repository: the second
// repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same
// rule a module's own source follows.
//
// **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's
// newest trunk build said its build source in that repository, a merge touching none of it is no change
// to the module (issue 338: every merge to the controller's repository moved the route proxy and the
// build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is
// read, as before.
func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool {
for _, r := range read {
if sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) {
if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) {
continue
}
if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated {
return true
}
for _, p := range m.Paths {
if builder.SourceHolds(r.Paths, p) {
return true
}
}
}
return false
}
// ownSource is the build source a module's newest trunk build said it read in its own repository; nil
// when it said none, and the module's own directory — or, built from the root, its whole repository — is
// its build source, as before (novox/hq ADR 0267).
func ownSource(read []inventory.ReadRepository) []string {
for _, r := range read {
if r.Own && len(r.Paths) > 0 {
return r.Paths
}
}
return nil
}
// readsFile is whether a module built from the merged repository reads one of its changed files: in its
// build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all
// for a module built from the repository's root.
func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool {
if own := ownSource(read); own != nil {
return builder.SourceHolds(own, p)
}
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
// pendingIn is the modules an open plan has yet to build (novox/hq ADR 0267): what such a module was built
// from is about to change, so the build source its last build said is not the one a merge now meets — an
// earlier merge that added an import to it would otherwise hide a change to what that import names. Each
// is read whole, as before, and planned again with the merge; the newer plan supersedes the older.
func pendingIn(plans []inventory.Plan) map[string]bool {
pending := map[string]bool{}
for _, p := range plans {
if !p.Open() {
continue
}
for name, s := range p.Modules {
if s == nil || (s.State != "built" && s.State != planDeleted) {
pending[name] = true
}
}
}
return pending
}
// readForAMerge is what each module's build read, as a merge acting now maps its files onto: the build
// sources the newest trunk builds said, but for the modules an open plan has yet to build (pendingIn).
func readForAMerge(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
read, err := inv.ReadRepositories(ctx)
if err != nil {
return nil, err
}
open, err := inv.OpenPlans(ctx)
if err != nil {
return nil, err
}
return unnarrowed(read, pendingIn(open)), nil
}
// unnarrowed is what modules read, with the build sources of the pending ones set aside: each of them is
// read whole.
func unnarrowed(read map[string][]inventory.ReadRepository, pending map[string]bool) map[string][]inventory.ReadRepository {
if len(pending) == 0 {
return read
}
out := make(map[string][]inventory.ReadRepository, len(read))
for name, rs := range read {
if !pending[name] {
out[name] = rs
continue
}
var whole []inventory.ReadRepository
for _, r := range rs {
if r.Own {
continue
}
r.Paths = nil
whole = append(whole, r)
}
out[name] = whole
}
return out
}
// lastLookAt is the most recent look at this repository by anything built from it.
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
var newest time.Time
@@ -698,8 +793,9 @@ func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
touched, _, _ := touchedBy(candidates, known, m)
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved,
read map[string][]inventory.ReadRepository) []inventory.Entry {
touched, _, _ := touchedBy(candidates, known, m, read)
return touched
}
@@ -707,8 +803,11 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
// ADR 0238), so planning and gating cannot disagree about what a change touches.
//
// **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
// **A changed file touches exactly the modules whose build reads it.** What a build reads is its build
// source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure,
// an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is
// built from its root, or another program's package beside it, touches nothing. Where none was said, it is
// the module's own directory — the builder clones the repository and builds within that directory alone: the manifest,
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
@@ -728,7 +827,8 @@ func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved
//
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
// be narrowed.
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved,
read map[string][]inventory.ReadRepository) (touched []inventory.Entry, added, unread []string) {
knownDirs := map[string]bool{}
for _, e := range known {
if !e.Provided && sameRepository(e.Source.Repository, m) {
@@ -763,19 +863,27 @@ func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched
return candidates, added, nil
}
for _, e := range candidates {
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
touched = append(touched, e)
for _, p := range m.Paths {
if readsFile(e, read[e.Manifest.Module], p) {
touched = append(touched, e)
break
}
}
}
for _, p := range m.Paths {
read := newDir["."]
isRead := newDir["."]
for _, e := range candidates {
read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
isRead = isRead || readsFile(e, read[e.Manifest.Module], p)
}
for d := range newDir {
read = read || inside(p, d)
isRead = isRead || inside(p, d)
}
if !read {
// A file a module packages from this repository is read too, by that module's build.
for _, e := range known {
isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo,
Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}})
}
if !isRead {
unread = append(unread, p)
}
}
@@ -834,7 +942,7 @@ func (r mergeReach) Dependents() []string {
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) mergeReach {
from, packaging, already := mergeCandidates(m, entries, read)
touched, added, unread := touchedBy(from, entries, m)
touched, added, unread := touchedBy(from, entries, m, read)
kept, deleted := splitDeleted(touched, m)
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
var building []string