diff --git a/sdk/buildrecord/buildrecord.go b/sdk/buildrecord/buildrecord.go new file mode 100644 index 00000000..0c9f7418 --- /dev/null +++ b/sdk/buildrecord/buildrecord.go @@ -0,0 +1,70 @@ +package buildrecord + +import "time" + +// BuildRecord is the layout the ended controller's chain wrote (mesh-controller internal/chain/records.go Build, line 91), +// the fields a reader outside it read (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores +// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100, +// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose +// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller +// writes it. +type BuildRecord struct { + ID string `json:"id"` + State string `json:"state,omitempty"` + Failed string `json:"failed,omitempty"` + Repository string `json:"repository"` + Ref string `json:"ref,omitempty"` + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + Commit string `json:"commit,omitempty"` + At time.Time `json:"at,omitzero"` + SourceFingerprint string `json:"source-fingerprint,omitempty"` + SourceInputs *SourceInputs `json:"source-inputs,omitempty"` + // Made is what the build made (Build.Made, records.go:110); the digest is the tail of an artifact's Reference. + Made []Artifact `json:"made,omitempty"` +} + +// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and +// reference, nothing else (novox/hq ADR 0478 clause 8). +type Artifact struct { + Name string `json:"name"` + Kind string `json:"kind"` + Reference string `json:"reference"` +} + +// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from +// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42). +func BuildKey(module, id string) string { + if module == "" { + return "req." + id + } + return module + ".build." + id +} + +// NewestKey is the key of a module's newest build, of any state: copied from mesh-controller internal/chain/keys.go:44. +// The key holds no record: its value is the newest build's id as raw bytes (internal/inventory/chain_builds.go:210 and +// :546), and the record lives at BuildKey(module, id). The pointer is a hint, checked against that record. +func NewestKey(module string) string { return module + ".newest" } + +// SourceInputs are what the ended controller's source fingerprint (src1) was of, as its build records hold them +// (BuildRecord), read and never computed again (novox/hq ADR 0437 §5): the module, the git tree of +// its directory, its bases (references, of which only the digest counts), the root tree of each repository its +// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build +// when it does not (a build that resolves packages when built). A context is the value the builder records for it: +// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was +// handed (mesh-controller internal/builder/builder.go:1146-1156). +type SourceInputs struct { + Module string + Tree string + Bases []string + Contexts map[string]string + Toolchains map[string]string + Unpinned string +} + +// IsTrunkBuild is whether the record is a successful build of the trunk that recorded its source inputs: an outcome +// (the controller writes no state on one; state asked is a request, records.go:57-59), not failed, and ref "main" or none. A build a delivery plan asked after a merge, or rebuild or build --behind, follows the branch +// without naming it, so it carries no ref; a build of any other branch or of a commit names it (issue 1281). +func (r BuildRecord) IsTrunkBuild() bool { + return r.State == "" && r.Failed == "" && r.SourceInputs != nil && (r.Ref == "" || r.Ref == "main") +} diff --git a/sdk/buildrecord/buildrecord_test.go b/sdk/buildrecord/buildrecord_test.go new file mode 100644 index 00000000..8d992096 --- /dev/null +++ b/sdk/buildrecord/buildrecord_test.go @@ -0,0 +1,179 @@ +package buildrecord + +import ( + "encoding/json" + "os" + "testing" + "time" +) + +// The fixture's path is what the controller writes for a build request: strings.Trim(path, "/") at mesh-controller +// internal/inventory/chain_requests.go:19, the module's directory in its repository (Build.Path, internal/chain/records.go:100). +// The fixture started as a record the controller's own encoder wrote (json.Marshal of a chain.Build, mesh-controller +// internal/chain, a throwaway test run once and deleted) with build-agent's build-1792395447088936520 values, plus a +// manifest-digest and one made artifact (shape: mesh-controller internal/chain/records.go:91-117 Build, json tags, at commit +// 1f708e2d8d72e2c27dbeaba02f14c128d700eada; an outcome has no state key and a build asked after a merge no ref, records.go:57-59), which a BuildRecord does not carry. It was then edited by hand +// to the controller's layout for an outcome: state and ref removed, the repository a clone URL (issue 1281). +func loadBuildFixture(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile("testdata/build-record-build-agent.json") + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestBuildRecordDecodes(t *testing.T) { // 1223/AC1 + var r BuildRecord + if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { + t.Fatal(err) + } + for _, c := range []struct{ name, got, want string }{ + {"id", r.ID, "build-1792395447088936520"}, + {"module", r.Module, "build-agent"}, + {"repository", r.Repository, "http://novox.internal:20000/novox/mesh-controller.git"}, + {"ref", r.Ref, ""}, + {"commit", r.Commit, "7a74cee83f88e6585a5511c3cc53bdf0f5303d20"}, + {"state", r.State, ""}, + {"failed", r.Failed, ""}, + {"source-fingerprint", r.SourceFingerprint, "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7"}, + } { + if c.got != c.want { + t.Errorf("%s = %q, want %q", c.name, c.got, c.want) + } + } + if want := time.Date(2026, 10, 19, 9, 38, 0, 0, time.UTC); !r.At.Equal(want) { + t.Errorf("at = %v, want %v", r.At, want) + } + if !r.IsTrunkBuild() { // 1281/AC7 + t.Error("the fixture, a record as the controller writes it, is not a trunk build") + } + if r.SourceInputs == nil { + t.Fatal("source-inputs missing") + } +} + +func TestBuildRecordPath(t *testing.T) { // 1233/AC1 + var r BuildRecord + if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { + t.Fatal(err) + } + if r.Path != "modules/build-agent" { + t.Errorf("path = %q, want modules/build-agent", r.Path) + } + // mesh-controller's manifest is its repository's root: the controller writes no path (omitempty), so the path is "". + var m map[string]json.RawMessage + if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil { + t.Fatal(err) + } + delete(m, "path") + raw, _ := json.Marshal(m) + var root BuildRecord + if err := json.Unmarshal(raw, &root); err != nil { + t.Fatal(err) + } + if root.Path != "" { + t.Errorf("path of a root module = %q, want empty", root.Path) + } + // Every field 1223/AC1 checks is still equal in both. + if root.ID != r.ID || root.Module != r.Module || root.Repository != r.Repository || root.Ref != r.Ref || + root.Commit != r.Commit || root.State != r.State || root.SourceFingerprint != r.SourceFingerprint || !root.At.Equal(r.At) { + t.Errorf("fields differ: %+v vs %+v", root, r) + } + // And the path survives a round trip through the SDK's own type. + back, _ := json.Marshal(r) + var again BuildRecord + if err := json.Unmarshal(back, &again); err != nil || again.Path != r.Path { + t.Errorf("round trip path = %q (%v)", again.Path, err) + } +} + +func TestBuildRecordWithoutInputs(t *testing.T) { // 1223/AC2 + var m map[string]json.RawMessage + if err := json.Unmarshal(loadBuildFixture(t), &m); err != nil { + t.Fatal(err) + } + delete(m, "source-inputs") + raw, _ := json.Marshal(m) + var r BuildRecord + if err := json.Unmarshal(raw, &r); err != nil { + t.Fatal(err) + } + if r.SourceInputs != nil { + t.Errorf("source-inputs = %+v, want nil", r.SourceInputs) + } + if r.Module != "build-agent" { + t.Errorf("module = %q, want build-agent", r.Module) + } +} + +func TestBuildKeys(t *testing.T) { // 1223/AC3 + // Literal values of mesh-controller internal/broker/controller_buckets.go:101 (BucketName(ControllerSeat, ChainBuilds) + // is "mesh-controller_builds") and internal/chain/keys.go:35-44. + if got := BuildKey("build-agent", "build-1"); got != "build-agent.build.build-1" { + t.Errorf("BuildKey = %q", got) + } + if got := BuildKey("", "build-1"); got != "req.build-1" { + t.Errorf("BuildKey without a module = %q", got) + } + if got := NewestKey("build-agent"); got != "build-agent.newest" { + t.Errorf("NewestKey = %q", got) + } +} + +// The fixture's made artifacts are the controller's Artifact (mesh-controller internal/chain/records.go, json tags name, +// kind, reference; the controller writes no digest key, the digest is the tail of the reference). The kinds and references +// are those of the live record mesh-controller.builds printed for build-agent build-1792540091397811913 (2026-10-21 01:49). +func TestBuildRecordMade(t *testing.T) { // 1588/AC2 + var r BuildRecord + if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil { + t.Fatal(err) + } + want := []Artifact{ + {Name: "server", Kind: "image", Reference: "artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"}, + {Name: "files", Kind: "archive", Reference: "artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}, + } + if len(r.Made) != len(want) { + t.Fatalf("made = %+v, want %+v", r.Made, want) + } + for i := range want { + if r.Made[i] != want[i] { + t.Errorf("made[%d] = %+v, want %+v", i, r.Made[i], want[i]) + } + } + var none BuildRecord + if err := json.Unmarshal([]byte(`{"id":"b","repository":"a/b"}`), &none); err != nil || none.Made != nil { + t.Errorf("a record without made: %+v, %v", none, err) + } +} + +func TestIsTrunkBuild(t *testing.T) { // 1281/AC2 + inputs := &SourceInputs{Module: "zsh", Tree: "t"} + for _, c := range []struct { + name string + r BuildRecord + want bool + }{ + {"built on main", BuildRecord{Ref: "main", SourceInputs: inputs}, true}, + // a build a delivery plan asked after a merge, or rebuild / build --behind, carries no ref (live builds, + // 2026-10-19: zsh build-1792417485811374131, mesh-delivery build-1792435539606203626) + {"built with no ref", BuildRecord{SourceInputs: inputs}, true}, + {"another branch", BuildRecord{Ref: "feat/x", SourceInputs: inputs}, false}, + {"a commit", BuildRecord{Ref: "9efdd87a", SourceInputs: inputs}, false}, + {"asked", BuildRecord{State: "asked", SourceInputs: inputs}, false}, + {"answered, state answered, no source inputs", BuildRecord{State: "answered"}, false}, + {"failed", BuildRecord{Failed: "boom", Ref: "main", SourceInputs: inputs}, false}, + {"no source inputs", BuildRecord{Ref: "main"}, false}, + {"no ref and no source inputs", BuildRecord{}, false}, + } { + if got := c.r.IsTrunkBuild(); got != c.want { + t.Errorf("%s: IsTrunkBuild = %v, want %v", c.name, got, c.want) + } + } + // decoded as the controller writes it: the key "ref" is absent + var r BuildRecord + raw := `{"id":"build-1792417485811374131","repository":"http://novox.internal:20000/novox/mesh-catalog.git","module":"zsh","source-inputs":{"Module":"zsh","Tree":"t"}}` + if err := json.Unmarshal([]byte(raw), &r); err != nil || !r.IsTrunkBuild() { + t.Errorf("a decoded record with no ref: trunk = %v, err %v", r.IsTrunkBuild(), err) + } +} diff --git a/sdk/buildrecord/testdata/build-record-build-agent.json b/sdk/buildrecord/testdata/build-record-build-agent.json new file mode 100644 index 00000000..0e756e00 --- /dev/null +++ b/sdk/buildrecord/testdata/build-record-build-agent.json @@ -0,0 +1 @@ +{"id":"build-1792395447088936520","repository":"http://novox.internal:20000/novox/mesh-controller.git","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},{"name":"files","kind":"archive","reference":"artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}],"at":"2026-10-19T09:38:00Z"} diff --git a/sdk/forgekey/forgekey.go b/sdk/forgekey/forgekey.go new file mode 100644 index 00000000..04020d21 --- /dev/null +++ b/sdk/forgekey/forgekey.go @@ -0,0 +1,107 @@ +// Package forgekey holds the Delivery domain's names beside mesh-sdk's forgekey (novox/hq ADR 0635 §4): the kind of +// key a build module keeps, the runner whose commands get exactly the environment they are given (a fetch holds no +// registry password), and the words a failed command is reported in. mesh-sdk's forgekey keeps what every module's +// key keeper shares: KeyKeeper, KeyPutter, Runner, ExecRunner and Ran. +package forgekey + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + "unicode/utf8" + + sdk "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +// KindBuilder is the kind of key a build module's account keeps (ADR 0466 §2); sdk.KindMachine is a machine's own. +const KindBuilder = "builder" + +// ExecRunnerClean is a runner whose commands get exactly the env argument as their environment, and nothing of this +// process's (issue 1497; novox/hq ADR 0466 §4: no registry password in a fetch's environment). The caller gives PATH +// and HOME itself. A directory is the command's own business (`git -C`, `go -C`): it is started in the HOME it is +// given, or the machine's temporary directory, never in this process's. bound 0 leaves only the step's own context. +func ExecRunnerClean(bound time.Duration) sdk.Runner { + return func(ctx context.Context, env []string, name string, args ...string) sdk.Ran { + if bound > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, bound) + defer cancel() + } + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append([]string{}, env...) // non-nil, so an empty list is an empty environment + cmd.Dir = homeOrTemp(env) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := sdk.Ran{Stdout: out.String(), Stderr: errb.String()} + var exitErr *exec.ExitError + switch { + case errors.Is(ctx.Err(), context.DeadlineExceeded): + r.Status, r.Err = 124, "ended by its step's deadline" + if bound > 0 { + r.Err = fmt.Sprintf("no answer within %d s", int(bound/time.Second)) + } + case errors.Is(err, exec.ErrNotFound): + r.Status, r.Err = 127, "ENOENT" + case errors.As(err, &exitErr): + r.Status = exitErr.ExitCode() + case err != nil: + r.Status, r.Err = 1, err.Error() + } + return r + } +} + +// homeOrTemp is the directory a clean command starts in: the HOME it is given, or the temporary directory (novox/hq +// issue 734: never one that may be deleted under it, as a module's build directory is when a new build replaces it). +func homeOrTemp(env []string) string { + for i := len(env) - 1; i >= 0; i-- { + if home, ok := strings.CutPrefix(env[i], "HOME="); ok { + if info, err := os.Stat(home); err == nil && info.IsDir() { + return home + } + break + } + } + return os.TempDir() +} + +// SaidBound bounds what Said keeps of a command's words, so a reason stays a line of a step's record and of a log. +const SaidBound = 600 + +// Said is what a command said of its failure in its own words, for a reason: every line that says something, trimmed +// and joined with "; ", bounded at SaidBound bytes. A line that is a bare prefix and nothing else (git writes "error:" +// alone before and after the forge's own message, "remote:" or "fatal:" likewise) is left out, so the cause is never +// cut off behind it. +func Said(s string) string { + var keep []string + for _, l := range strings.Split(s, "\n") { + l = strings.TrimSpace(l) + if l == "" || barePrefix(l) { + continue + } + keep = append(keep, l) + } + out := strings.Join(keep, "; ") + if len(out) > SaidBound { + cut := SaidBound + for cut > 0 && !utf8.RuneStart(out[cut]) { + cut-- + } + out = out[:cut] + " …" + } + return out +} + +// barePrefix says whether a line is one word and a colon, such as "error:" or "remote:", with nothing after it. +func barePrefix(l string) bool { + if !strings.HasSuffix(l, ":") { + return false + } + return !strings.ContainsAny(strings.TrimSuffix(l, ":"), " \t:") +} diff --git a/sdk/forgekey/forgekey_test.go b/sdk/forgekey/forgekey_test.go new file mode 100644 index 00000000..6a01d16e --- /dev/null +++ b/sdk/forgekey/forgekey_test.go @@ -0,0 +1,49 @@ +package forgekey + +import ( + "context" + "os" + "strings" + "testing" + + sdk "git.novox.be/novox/mesh-sdk/go/forgekey" +) + +func TestSaidKeepsTheCauseBehindABarePrefix(t *testing.T) { + forge := "error: \nerror: User: 22:nox-builder is not authorized to read novox/x.\nerror: \nfatal: Could not read from remote repository.\n\nPlease make sure you have the correct access rights\n" + for _, c := range []struct{ name, in, want string }{ + {"forge refusal", forge, "error: User: 22:nox-builder is not authorized to read novox/x.; fatal: Could not read from remote repository.; Please make sure you have the correct access rights"}, + {"one line", "fatal: couldn't find remote ref x\n", "fatal: couldn't find remote ref x"}, + {"bare prefixes only", "error:\nremote:\n", ""}, + {"a colon inside stays", " ENOENT \nfatal: bad: thing\n", "ENOENT; fatal: bad: thing"}, + {"empty", "", ""}, + } { + if got := Said(c.in); got != c.want { + t.Errorf("%s: Said = %q, want %q", c.name, got, c.want) + } + } + long := Said(strings.Repeat("é", SaidBound)) + if !strings.HasSuffix(long, " …") || len(long) > SaidBound+len(" …") || !strings.HasPrefix(long, "é") { + t.Errorf("long: %d bytes, ends %q", len(long), long[len(long)-6:]) + } +} + +// ExecRunnerClean gives the command only the env argument, and starts it in the HOME it is given, even when this +// process's directory was deleted under it (issue 1497, issue 734). +func TestExecRunnerCleanGivesTheCommandOnlyTheEnvArgument(t *testing.T) { + t.Setenv("MESH_TEST_SECRET", "x") + var run sdk.Runner = ExecRunnerClean(0) + r := run(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=/h"}, "env") + if r.Status != 0 || !strings.Contains(r.Stdout, "HOME=/h") || strings.Contains(r.Stdout, "MESH_TEST_SECRET") { + t.Errorf("ran = %+v", r) + } + home, gone := t.TempDir(), t.TempDir() + t.Chdir(gone) + if err := os.Remove(gone); err != nil { + t.Fatal(err) + } + r = run(context.Background(), []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home}, "pwd") + if r.Status != 0 || strings.TrimSpace(r.Stdout) != home { + t.Errorf("a clean command run from a deleted directory = %+v, want it started in its HOME %s", r, home) + } +} diff --git a/sdk/module.json b/sdk/module.json new file mode 100644 index 00000000..45609240 --- /dev/null +++ b/sdk/module.json @@ -0,0 +1,18 @@ +{ + "module": "mesh-delivery-sdk", + "version": "1", + "build": { + "artifacts": [ + { + "name": "go-sdk", + "kind": "package", + "language": "go", + "paths": [ + "." + ], + "series": "0.1" + } + ] + }, + "resources": [] +} diff --git a/sdk/trunk/packages.go b/sdk/trunk/packages.go new file mode 100644 index 00000000..442767e0 --- /dev/null +++ b/sdk/trunk/packages.go @@ -0,0 +1,28 @@ +package trunk + +import ( + "strings" +) + +// A mesh package is a Go module of a repository of the mesh's forge (ForgeModulePrefix): mesh-sdk's Go module, and an +// owner module's package (novox/hq ADR 0593 §1). The indexer alone reads which packages a module imports, their content +// digests and their latest published versions, and writes them in its state (PublishedPackages, ADR 0593 §2); a build +// writes the version it is asked into its own go.mod and go.sum (StepAsk.Packages). + +// ForgeModulePrefix is the module path prefix of the Go modules the mesh's forge serves: a Go module whose path starts +// with it is a mesh package, git.novox.be/novox/[/]. +const ForgeModulePrefix = "git.novox.be/novox/" + +// PackageRepository is the repository (/) a mesh package is published from and the package's directory in +// it ("" for its root); false for a path that is not a forge module's. +func PackageRepository(pkg string) (repository, dir string, ok bool) { + rest, found := strings.CutPrefix(pkg, ForgeModulePrefix) + if !found || rest == "" { + return "", "", false + } + name, dir, _ := strings.Cut(rest, "/") + if name == "" { + return "", "", false + } + return "novox/" + name, dir, true +} diff --git a/sdk/trunk/trunk.go b/sdk/trunk/trunk.go new file mode 100644 index 00000000..44402a43 --- /dev/null +++ b/sdk/trunk/trunk.go @@ -0,0 +1,25 @@ +package trunk + +import "strings" + +// RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name +// itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a +// trailing slash). A string of fewer than two path segments is returned as it is, trimmed (issue 1281). +func RepositoryName(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimRight(s, "/") + s = strings.TrimSuffix(s, ".git") + s = strings.TrimRight(s, "/") + parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' }) + if len(parts) < 2 { + return s + } + return parts[len(parts)-2] + "/" + parts[len(parts)-1] +} + +// SameRepository is whether two spellings name one repository: the same owner and name, whichever way each is written +// (RepositoryName), compared without regard to case as the forge does. An empty spelling names none (issue 1281). +func SameRepository(a, b string) bool { + a, b = RepositoryName(a), RepositoryName(b) + return a != "" && strings.EqualFold(a, b) +} diff --git a/sdk/trunk/trunk_test.go b/sdk/trunk/trunk_test.go new file mode 100644 index 00000000..7ea841e8 --- /dev/null +++ b/sdk/trunk/trunk_test.go @@ -0,0 +1,35 @@ +package trunk + +import ( + "testing" +) + +func TestSameRepository(t *testing.T) { // 1281/AC1 + const forge = "novox/mesh-catalog" + for _, c := range []struct { + a, b string + same bool + }{ + // the clone URL as a build record carries it (mesh-controller.command builds, 2026-10-19 ~21:20 local) + {forge, "http://novox.internal:20000/novox/mesh-catalog.git", true}, + {forge, "http://novox.internal:20000/novox/mesh-catalog", true}, + {forge, "http://novox.internal:20000/novox/mesh-catalog.git/", true}, + {"http://novox.internal:20000/novox/mesh-catalog.git/", forge, true}, + {forge, "novox/mesh-catalog", true}, + {forge, "Novox/Mesh-Catalog", true}, + {forge, "http://novox.internal:20000/novox/mesh-catalog-x.git", false}, + {forge, "novox/mesh-catalog-x", false}, + {forge, "http://novox.internal:20000/other/mesh-catalog.git", false}, + {forge, "other/mesh-catalog", false}, + {forge, "http://novox.internal:20000/novox/mesh-media-catalog.git", false}, + {forge, "", false}, + {"", "", false}, + } { + if got := SameRepository(c.a, c.b); got != c.same { + t.Errorf("SameRepository(%q, %q) = %v, want %v", c.a, c.b, got, c.same) + } + } + if got := RepositoryName("http://novox.internal:20000/novox/mesh-catalog.git"); got != "novox/mesh-catalog" { + t.Errorf("RepositoryName = %q", got) + } +}