Put the module trees of a merge into go/buildstep for both fetch steps, and let a preview name the conflicts of a head that does not merge (issues 1675, 1676, 1445)
mesh/repo-check head 78364e318bdc merged into trunk 07a65927c362 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery-group group feat/the-checker rejected: composed together: 1 problem(s) the change brings; the first: mesh-delivery-checker reads "mesh-controller…
mesh/merge-gate head 78364e318bdc merged into trunk 07a65927c362 — fail: group feat/the-checker composed: 1 problem(s) the change brings; the first: mesh-d…
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
2026-10-21 21:21:48 +02:00
parent afbe371003
commit 78364e318b
4 changed files with 223 additions and 2 deletions
+67
View File
@@ -0,0 +1,67 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// ModuleTree is what a merge holds of one module directory: its tree id, the sha256 of its module.json and the sha256
// of each lock file it has (go.sum, package-lock.json), from which the checker and the indexer take the module's inputs
// (novox/hq ADR 0484 clause 3).
type ModuleTree struct {
Tree string `json:"tree"`
Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"`
}
var lockFiles = []string{"go.sum", "package-lock.json"}
// ModuleTrees are the trees, in commit, of every directory of the workspace dir that holds a module.json, by directory
// (the repository's root is "."). The workspace is at commit, so the manifests and the lock files are read from it. env is
// the environment git runs with (nil for none). Both build modules' fetch steps answer it for a merge.
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
}
trees := map[string]ModuleTree{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" {
continue
}
mdir := filepath.ToSlash(filepath.Dir(l))
spec := commit + ":" + mdir
if mdir == "." {
spec = commit + "^{tree}"
}
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
if t.Status != 0 || t.Err != "" {
return nil, fmt.Errorf("git rev-parse %s: %s", spec, firstLine(t.Err+" "+t.Stderr))
}
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
if err != nil {
return nil, err
}
mt.Manifest = sha256Hex(raw)
for _, lock := range lockFiles {
if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil {
if mt.Locks == nil {
mt.Locks = map[string]string{}
}
mt.Locks[lock] = sha256Hex(raw)
}
}
trees[mdir] = mt
}
return trees, nil
}
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
+93
View File
@@ -0,0 +1,93 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func hexOf(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func writeTreeFile(t *testing.T, root, name, content string) {
t.Helper()
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// ModuleTrees answers, for each directory holding a module.json (the root as "."), the tree id git gives, the sha256 of
// module.json and of each lock file present, and reads them from the workspace.
func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
writeTreeFile(t, root, "modules/a/go.sum", "asum")
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40)
var calls []string
var envs [][]string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
calls = append(calls, name+" "+strings.Join(args[2:], " "))
envs = append(envs, env)
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/a/module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
got, err := ModuleTrees(context.Background(), run, []string{"X=1"}, root, merge)
if err != nil {
t.Fatal(err)
}
want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("trees = %+v\nwant %+v", got, want)
}
if got["modules/b"].Locks != nil {
t.Error("a module with no lock file carries a lock map")
}
for _, e := range envs {
if len(e) != 1 || e[0] != "X=1" {
t.Errorf("git ran with env %v, want the one given", e)
}
}
}
func TestModuleTreesFailsNamingTheGitCommandOrTheUnreadableManifest(t *testing.T) {
root := t.TempDir()
fail := func(on string) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if args[2] == on {
return forgekey.Ran{Status: 128, Stderr: "fatal: no\n"}
}
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
}
}
for _, on := range []string{"ls-files", "rev-parse"} {
if _, err := ModuleTrees(context.Background(), fail(on), nil, root, sha); err == nil || !strings.Contains(err.Error(), on) || !strings.Contains(err.Error(), "fatal: no") {
t.Errorf("%s failing: %v", on, err)
}
}
if _, err := ModuleTrees(context.Background(), fail("none"), nil, root, sha); err == nil {
t.Error("a module.json that is not in the workspace was read")
}
}
+48
View File
@@ -542,3 +542,51 @@ func TestTheGatesRecheckStatusRoundTripsAndTheCheckersBucketRefusesIt(t *testing
t.Error("a checker owed key of the recheck status accepted")
}
}
// 1445/AC5 (layout): a head that does not merge is a preview naming its conflicting files, with no merge commit and no
// modules; a preview with conflicts and a merge or modules is refused; a preview of today's shape (no conflicts)
// decodes with none, and a reader of today's shape reads one with conflicts.
func TestPreviewOfAHeadInConflict(t *testing.T) {
c := Preview{Repository: "novox/mesh-delivery", Head: "aaaa", Trunk: "bbbb", Conflicts: []string{"a.txt", "modules/m/module.json"}}
data, err := EncodePreview(c)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"conflicts":["a.txt","modules/m/module.json"]`) || strings.Contains(string(data), `"merge"`) {
t.Errorf("at %s", data)
}
got, err := DecodePreview(data)
c.V = Version
if err != nil || !SamePreview(got, c) {
t.Errorf("round trip = %+v, %v", got, err)
}
withMerge := c
withMerge.Merge = "cccc"
withModules := c
withModules.Modules = samplePreview().Modules
emptyPath := c
emptyPath.Conflicts = []string{""}
for name, p := range map[string]Preview{"a merge commit": withMerge, "modules": withModules, "an empty conflicting path": emptyPath} {
if _, err := EncodePreview(p); err == nil || !strings.Contains(err.Error(), "onflict") {
t.Errorf("a preview in conflict with %s: %v", name, err)
}
}
// today's bytes: a clean preview has no conflicts and decodes with none.
clean, _ := EncodePreview(samplePreview())
if strings.Contains(string(clean), "conflicts") {
t.Errorf("a clean preview carries conflicts: %s", clean)
}
if p, err := DecodePreview(clean); err != nil || p.Conflicts != nil {
t.Errorf("a clean preview decodes with conflicts %v, %v", p.Conflicts, err)
}
var old struct {
V int `json:"v"`
Repository string `json:"repository"`
Head string `json:"head"`
Trunk string `json:"trunk"`
Merge string `json:"merge"`
}
if err := json.Unmarshal(data, &old); err != nil || old.Head != "aaaa" || old.Merge != "" {
t.Errorf("the old reader: %+v %v", old, err)
}
}
+15 -2
View File
@@ -107,13 +107,15 @@ type PreviewModule struct {
}
// Preview is the value of a preview key: the repository, the head and the trunk commit it was merged onto, the merge
// commit and, for each module directory of the merge, a PreviewModule.
// commit and, for each module directory of the merge, a PreviewModule. A head that does not merge is a preview too: it
// names the files in Conflicts and has no merge commit and no modules (issue 1445).
type Preview struct {
V int `json:"v"`
Repository string `json:"repository"`
Head string `json:"head"`
Trunk string `json:"trunk"`
Merge string `json:"merge"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Modules map[string]PreviewModule `json:"modules,omitempty"`
}
@@ -121,6 +123,17 @@ func (p Preview) check() error {
if _, err := PreviewKey(p.Repository, p.Head, p.Trunk); err != nil {
return err
}
for _, f := range p.Conflicts {
if f == "" {
return fmt.Errorf("delivery: a preview names a conflicting file with an empty path")
}
}
if len(p.Conflicts) > 0 {
if p.Merge != "" || len(p.Modules) > 0 {
return fmt.Errorf("delivery: a preview of a head in conflict has no merge commit and no modules")
}
return nil
}
if p.Merge == "" {
return fmt.Errorf("delivery: a preview names no merge commit")
}