From 78364e318bdcc0573405147291433410e652fb43 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 21:21:48 +0200 Subject: [PATCH] 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) --- go/buildstep/trees.go | 67 ++++++++++++++++++++++++++ go/buildstep/trees_test.go | 93 +++++++++++++++++++++++++++++++++++++ go/delivery/checker_test.go | 48 +++++++++++++++++++ go/delivery/preview.go | 17 ++++++- 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 go/buildstep/trees.go create mode 100644 go/buildstep/trees_test.go diff --git a/go/buildstep/trees.go b/go/buildstep/trees.go new file mode 100644 index 0000000..58b2ccd --- /dev/null +++ b/go/buildstep/trees.go @@ -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[:]) } diff --git a/go/buildstep/trees_test.go b/go/buildstep/trees_test.go new file mode 100644 index 0000000..9cbbf0b --- /dev/null +++ b/go/buildstep/trees_test.go @@ -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") + } +} diff --git a/go/delivery/checker_test.go b/go/delivery/checker_test.go index 6b2f438..296da52 100644 --- a/go/delivery/checker_test.go +++ b/go/delivery/checker_test.go @@ -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) + } +} diff --git a/go/delivery/preview.go b/go/delivery/preview.go index 67ce56c..dd90cb7 100644 --- a/go/delivery/preview.go +++ b/go/delivery/preview.go @@ -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") }