package releaseidentity import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestDeriveUsesModuleDeclarationAndPhysicalLayout(t *testing.T) { tests := []struct { name string subdir string modulePath string version string wantTag string wantNote string }{ {name: "root v1", subdir: ".", modulePath: "example.com/root", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"}, {name: "local main module", subdir: ".", modulePath: "example", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"}, {name: "local major module", subdir: "apps/local/v2", modulePath: "example/v2", version: "v2.3.4", wantTag: "apps/local/v2.3.4", wantNote: "apps/local/v2/docs/releases/v2.3.4.md"}, {name: "nested", subdir: "integrations/check", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/check/v1.2.3", wantNote: "integrations/check/docs/releases/v1.2.3.md"}, {name: "root major path", subdir: ".", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "docs/releases/v2.3.4.md"}, {name: "root physical major directory", subdir: "v2", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "v2/docs/releases/v2.3.4.md"}, {name: "physical major directory", subdir: "integrations/check/v2", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/v2/docs/releases/v2.3.4.md"}, {name: "logical major without physical directory", subdir: "integrations/check", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/docs/releases/v2.3.4.md"}, {name: "ordinary directory named v2", subdir: "integrations/v2", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/v2/v1.2.3", wantNote: "integrations/v2/docs/releases/v1.2.3.md"}, {name: "gopkg special major", subdir: "integrations/example.v2", modulePath: "gopkg.in/example.v2", version: "v2.3.4", wantTag: "integrations/example.v2/v2.3.4", wantNote: "integrations/example.v2/docs/releases/v2.3.4.md"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { selection := Selection{RepositoryDir: "/repo", ModuleDir: filepath.Join("/repo", filepath.FromSlash(tt.subdir)), ModuleSubdir: tt.subdir} if tt.subdir == "." { selection.ModuleDir = "/repo" } got, err := Derive(selection, []byte("module "+tt.modulePath+"\n"), tt.version) if err != nil { t.Fatal(err) } if got.Tag != tt.wantTag || got.NotePath != tt.wantNote { t.Fatalf("identity tag/note = %q, %q; want %q, %q", got.Tag, got.NotePath, tt.wantTag, tt.wantNote) } }) } } func TestDeriveRejectsInvalidModuleAndVersionIdentity(t *testing.T) { selection := Selection{RepositoryDir: "/repo", ModuleDir: "/repo", ModuleSubdir: "."} for _, tt := range []struct { name, declaration, version string }{ {name: "missing module directive", declaration: "go 1.26\n", version: "v1.2.3"}, {name: "malformed module path", declaration: "module example.com/../bad\n", version: "v1.2.3"}, {name: "incompatible major", declaration: "module example.com/service/v2\n", version: "v1.2.3"}, {name: "incompatible local major", declaration: "module example/v2\n", version: "v1.2.3"}, {name: "invalid local module path", declaration: "module example/../bad\n", version: "v1.2.3"}, {name: "unstable version", declaration: "module example.com/service\n", version: "v1.2.3-rc.1"}, } { t.Run(tt.name, func(t *testing.T) { if _, err := Derive(selection, []byte(tt.declaration), tt.version); err == nil { t.Fatal("Derive() succeeded for invalid identity") } }) } } func TestResolveSelectionNormalizesAndRejectsEscape(t *testing.T) { repository := t.TempDir() moduleDir := filepath.Join(repository, "integrations", "one") if err := os.MkdirAll(moduleDir, 0o755); err != nil { t.Fatal(err) } selection, err := ResolveSelection(repository, "integrations/one/../one") if err != nil { t.Fatal(err) } if selection.ModuleSubdir != "integrations/one" || selection.ModuleDir != moduleDir { t.Fatalf("normalized selection = %#v", selection) } root, err := ResolveSelection(repository, "") if err != nil || root.ModuleSubdir != "." || root.ModuleDir != repository { t.Fatalf("default root selection = %#v, error = %v", root, err) } if _, err := ResolveSelection(repository, "../outside"); err == nil { t.Fatal("ResolveSelection() accepted a path outside the repository") } if _, err := ResolveSelection(repository, moduleDir); err == nil { t.Fatal("ResolveSelection() accepted an absolute module path") } outside := t.TempDir() link := filepath.Join(repository, "linked") if err := os.Symlink(outside, link); err != nil { t.Fatal(err) } if _, err := ResolveSelection(repository, "linked"); err == nil { t.Fatal("ResolveSelection() accepted a symlink that resolves outside the repository") } } func TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources(t *testing.T) { repository := t.TempDir() gitIdentityRun(t, repository, "init", "-q") gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test") gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid") moduleDir := filepath.Join(repository, "nested", "mod") if err := os.MkdirAll(moduleDir, 0o755); err != nil { t.Fatal(err) } declaration := filepath.Join(moduleDir, "go.mod") if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil { t.Fatal(err) } gitIdentityRun(t, repository, "add", ".") gitIdentityRun(t, repository, "commit", "-qm", "add module") commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD")) if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil { t.Fatal(err) } committed, err := FromCommitted(context.Background(), repository, "nested/mod", "v1.2.3", commit) if err != nil { t.Fatal(err) } working, err := FromWorkingTree(context.Background(), repository, "nested/mod", "v1.2.3") if err != nil { t.Fatal(err) } if committed.ModulePath != "example.com/committed" || committed.Tag != "nested/mod/v1.2.3" { t.Fatalf("committed identity = %#v", committed) } if working.ModulePath != "example.com/working" || working.Tag != "nested/mod/v1.2.3" { t.Fatalf("working identity = %#v", working) } } func TestCommittedIdentityFollowsCapturedDeclarationAndDirectoryLinks(t *testing.T) { repository := t.TempDir() gitIdentityRun(t, repository, "init", "-q") gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test") gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid") moduleDir := filepath.Join(repository, "nested") if err := os.Mkdir(moduleDir, 0o755); err != nil { t.Fatal(err) } declaration := filepath.Join(repository, "module.txt") if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink("../module.txt", filepath.Join(moduleDir, "go.mod")); err != nil { t.Fatal(err) } if err := os.Symlink("nested", filepath.Join(repository, "selected")); err != nil { t.Fatal(err) } gitIdentityRun(t, repository, "add", ".") gitIdentityRun(t, repository, "commit", "-qm", "Add linked declaration") commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD")) if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil { t.Fatal(err) } committed, err := FromCommitted(t.Context(), repository, "selected", "v1.2.3", commit) if err != nil || committed.ModulePath != "example.com/committed" || committed.ModuleDir != moduleDir || committed.Tag != "nested/v1.2.3" { t.Fatalf("committed linked identity = %#v, error = %v", committed, err) } working, err := FromWorkingTree(t.Context(), repository, "selected", "v1.2.3") if err != nil || working.ModulePath != "example.com/working" || working.ModuleDir != moduleDir { t.Fatalf("working linked identity = %#v, error = %v", working, err) } ctx, cancel := context.WithCancel(t.Context()) cancel() if _, err := FromCommitted(ctx, repository, "selected", "v1.2.3", commit); !errors.Is(err, context.Canceled) { t.Fatalf("canceled committed identity error = %v", err) } } func TestCommittedIdentityPreservesReferenceFailures(t *testing.T) { for _, mode := range []string{"cycle", "outside", "missing", "unavailable blob"} { t.Run(mode, func(t *testing.T) { repository := t.TempDir() gitIdentityRun(t, repository, "init", "-q") gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test") gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid") target, wantCause := "go.mod", "symlink-cycle" switch mode { case "outside": target, wantCause = "../module.txt", "outside-checkout" case "missing": target, wantCause = "missing.mod", "missing" case "unavailable blob": target, wantCause = "module.txt", "read selected module declaration" if err := os.WriteFile(filepath.Join(repository, target), []byte("module example.com/linked\n"), 0o644); err != nil { t.Fatal(err) } } if err := os.Symlink(target, filepath.Join(repository, "go.mod")); err != nil { t.Fatal(err) } gitIdentityRun(t, repository, "add", ".") gitIdentityRun(t, repository, "commit", "-qm", "Add reference fixture") commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD")) if mode == "unavailable blob" { object := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD:module.txt")) if err := os.Remove(filepath.Join(repository, ".git", "objects", object[:2], object[2:])); err != nil { t.Fatal(err) } } identity, err := FromCommitted(t.Context(), repository, ".", "v1.2.3", commit) if err == nil || !strings.Contains(err.Error(), wantCause) || identity.RepositoryDir != repository || identity.ModuleDir != repository { t.Fatalf("identity = %#v, error = %v; want %s", identity, err, wantCause) } }) } } func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) { repository := t.TempDir() moduleDir := filepath.Join(repository, "nested") if err := os.MkdirAll(moduleDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module example.com/nested\n"), 0o644); err != nil { t.Fatal(err) } identity, err := FromWorkingTree(context.Background(), repository, "nested", "v1.2.3") if err != nil || identity.ModulePath != "example.com/nested" { t.Fatalf("nested identity = %#v, error = %v", identity, err) } if _, err := FromWorkingTree(context.Background(), repository, ".", "v1.2.3"); err == nil { t.Fatal("root selection silently fell back to a nested module declaration") } } func TestReadersPreserveCancellation(t *testing.T) { repository := t.TempDir() ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := FromWorkingTree(ctx, repository, ".", "v1.2.3"); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { t.Fatalf("working-tree error = %v, want cancellation", err) } } func gitIdentityRun(t *testing.T, dir string, args ...string) { t.Helper() command := exec.Command("git", args...) command.Dir = dir if output, err := command.CombinedOutput(); err != nil { t.Fatalf("git %v: %v\n%s", args, err, output) } } func gitIdentityOutput(t *testing.T, dir string, args ...string) string { t.Helper() command := exec.Command("git", args...) command.Dir = dir output, err := command.Output() if err != nil { t.Fatalf("git %v: %v", args, err) } return string(output) }