From d32743499caed1b6c1d2abd7ae306cc724f7683f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 17:29:17 +0200 Subject: [PATCH] =?UTF-8?q?Narrow=20the=20merge=20preview's=20tree=20of=20?= =?UTF-8?q?a=20module=20that=20names=20its=20paths=20(issue=201758,=20S4,?= =?UTF-8?q?=20ADR=200499=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModuleTrees reads each named path's object with one git ls-tree and answers their named tree, so the preview gives the value the indexer gives; a named path absent at the commit fails by name rather than pinning a source that does not exist. --- go/buildstep/trees.go | 55 ++++++++++++++++++++++- go/buildstep/trees_named_test.go | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 go/buildstep/trees_named_test.go diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go index 372e46f..fc31c62 100644 --- a/go/buildstep/trees.go +++ b/go/buildstep/trees.go @@ -6,9 +6,12 @@ import ( "encoding/hex" "fmt" "os" + "path" "path/filepath" "strings" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/forgekey" ) @@ -27,7 +30,8 @@ type ModuleTree struct { 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 repository's root is "."); for a module whose every artifact names its paths, the named tree of those paths and +// their objects (novox/hq ADR 0499 clause 2). 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") @@ -54,6 +58,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co return nil, err } mt.Manifest = sha256Hex(raw) + if err := narrow(ctx, run, env, dir, commit, mdir, raw, &mt); err != nil { + return nil, err + } for _, lock := range lockFiles { if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil { if mt.Locks == nil { @@ -67,4 +74,50 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co return trees, nil } +// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499 +// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as +// the indexer does, and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse +// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path. +func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error { + m, err := catalogue.ParseManifest(raw) + if err != nil { + return nil + } + paths, every := m.NamedPaths() + if !every { + return nil + } + full := make([]string, len(paths)) + for i, p := range paths { + full[i] = path.Join(mdir, p) + } + r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...) + if r.Status != 0 || r.Err != "" { + return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr)) + } + objects := map[string]string{} + for _, l := range strings.Split(r.Stdout, "\x00") { + meta, name, ok := strings.Cut(l, "\t") + fields := strings.Fields(meta) + if !ok || len(fields) != 3 { + continue + } + objects[name] = fields[2] + } + mt.Paths = map[string]string{} + for i, p := range paths { + object := objects[full[i]] + if object == "" { + return fmt.Errorf("the module %s names the path %q, which is not in %s", mdir, p, commit) + } + mt.Paths[p] = object + } + named, err := delivery.NamedTree(mt.Manifest, mt.Paths) + if err != nil { + return fmt.Errorf("the module %s: %w", mdir, err) + } + mt.Tree = named + return nil +} + func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } diff --git a/go/buildstep/trees_named_test.go b/go/buildstep/trees_named_test.go new file mode 100644 index 0000000..914f9d2 --- /dev/null +++ b/go/buildstep/trees_named_test.go @@ -0,0 +1,75 @@ +package buildstep + +import ( + "context" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// A module whose every artifact names its paths takes its tree from them (novox/hq ADR 0499 clause 2): ModuleTrees asks +// git for each named path's object at the commit, keeps them in Paths and answers their delivery.NamedTree as Tree; a +// module naming none keeps its directory's tree, as before. +func TestModuleTreesNarrowsAModuleWhoseArtifactsNameTheirPaths(t *testing.T) { + root := t.TempDir() + sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}` + writeTreeFile(t, root, "module.json", sdk) + writeTreeFile(t, root, "modules/b/module.json", `{"module":"b","version":"1"}`) + merge := strings.Repeat("c", 40) + var lsTree []string + run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + switch args[2] { + case "ls-files": + return forgekey.Ran{Stdout: "module.json\nmodules/b/module.json\n"} + case "rev-parse": + return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"} + case "ls-tree": + lsTree = append(lsTree, strings.Join(args[4:], " ")) + return forgekey.Ran{Stdout: "100644 blob " + strings.Repeat("b", 40) + "\tpackage.json\x00" + + "040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"} + } + return forgekey.Ran{Status: 1, Stderr: "unexpected " + args[2]} + } + got, err := ModuleTrees(context.Background(), run, nil, root, merge) + if err != nil { + t.Fatal(err) + } + paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)} + want, _ := delivery.NamedTree(hexOf(sdk), paths) + if g := got["."]; g.Tree != want || len(g.Paths) != 2 || g.Paths["src"] != paths["src"] || g.Paths["package.json"] != paths["package.json"] { + t.Fatalf("the root module = %+v, want tree %s and paths %v", g, want, paths) + } + if len(lsTree) != 1 || lsTree[0] != merge+" -- package.json src" { + t.Fatalf("git ls-tree ran as %v", lsTree) + } + if g := got["modules/b"]; g.Tree != "tree-of-"+merge+":modules/b" || g.Paths != nil { + t.Fatalf("a module naming no path was narrowed: %+v", g) + } +} + +// A named path absent at the commit fails the answer, naming the module and the path: a tree computed without it would +// pin a build source that does not exist. +func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) { + root := t.TempDir() + writeTreeFile(t, root, "modules/a/module.json", `{"module":"a","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","gone"]}]}}`) + run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { + switch args[2] { + case "ls-files": + return forgekey.Ran{Stdout: "modules/a/module.json\n"} + case "rev-parse": + return forgekey.Ran{Stdout: "t\n"} + case "ls-tree": + if strings.Join(args[4:], " ") != strings.Repeat("c", 40)+" -- modules/a/gone modules/a/src" { + return forgekey.Ran{Status: 1, Stderr: "wrong arguments " + strings.Join(args[3:], " ")} + } + return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tmodules/a/src\x00"} + } + return forgekey.Ran{Status: 1} + } + _, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40)) + if err == nil || !strings.Contains(err.Error(), `"gone"`) || !strings.Contains(err.Error(), "modules/a") { + t.Fatalf("an absent named path gave %v", err) + } +}