| | | 1 | | package git |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "context" |
| | | 5 | | "fmt" |
| | | 6 | | "go/parser" |
| | | 7 | | "go/token" |
| | | 8 | | "io/fs" |
| | | 9 | | "path/filepath" |
| | | 10 | | "slices" |
| | | 11 | | "strings" |
| | | 12 | | |
| | | 13 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 14 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 15 | | ) |
| | | 16 | | |
| | | 17 | | // CmdDiscoverer discovers projects in a single-module Go monorepo that follows |
| | | 18 | | // the cmd/<service>/main.go layout. It is only activated when: |
| | | 19 | | // - A go.mod file exists at the repository root (single module). |
| | | 20 | | // - No go.work file is present (not a Go workspace — use WorkspaceDiscoverer for those). |
| | | 21 | | // - A cmd/ directory exists at the repository root. |
| | | 22 | | // |
| | | 23 | | // For each immediate subdirectory of cmd/ that contains a main.go, a |
| | | 24 | | // ProjectTypeCmdService project is created. Additionally, any pkg/<name> import |
| | | 25 | | // path that is used by more than one service becomes a ProjectTypeCmdLibrary |
| | | 26 | | // project and is listed as a dependency of all consuming services. |
| | | 27 | | type CmdDiscoverer struct { |
| | | 28 | | fs ports.FileSystem |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | // NewCmdDiscoverer creates a CmdDiscoverer that reads from the given filesystem. |
| | | 32 | | func NewCmdDiscoverer(fsys ports.FileSystem) *CmdDiscoverer { |
| | | 33 | | return &CmdDiscoverer{fs: fsys} |
| | | 34 | | } |
| | | 35 | | |
| | | 36 | | // Discover returns discovered projects. Returns (nil, nil) when the repository |
| | | 37 | | // does not match the cmd/ monorepo pattern, or when the cmd/ directory exists |
| | | 38 | | // but contains no subdirectory with a main.go. |
| | | 39 | | // |
| | | 40 | | // Output order: services (sorted by name) followed by shared libraries (sorted |
| | | 41 | | // by name). This ordering is stable but is an implementation detail — callers |
| | | 42 | | // should not depend on the relative position of a specific project. |
| | 13 | 43 | | func (d *CmdDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) { |
| | 13 | 44 | | // Guard 1: single-module — go.mod must exist at root. |
| | 1 | 45 | | if !d.fs.Exists(filepath.Join(rootPath, "go.mod")) { |
| | 1 | 46 | | return nil, nil |
| | 1 | 47 | | } |
| | | 48 | | // Guard 2: not a workspace — go.work must NOT exist. |
| | 1 | 49 | | if d.fs.Exists(filepath.Join(rootPath, "go.work")) { |
| | 1 | 50 | | return nil, nil |
| | 1 | 51 | | } |
| | | 52 | | // Guard 3: cmd/ directory must exist. |
| | 11 | 53 | | cmdDir := filepath.Join(rootPath, "cmd") |
| | 1 | 54 | | if !d.fs.Exists(cmdDir) { |
| | 1 | 55 | | return nil, nil |
| | 1 | 56 | | } |
| | | 57 | | |
| | 10 | 58 | | moduleName, err := readModuleName(d.fs, filepath.Join(rootPath, "go.mod")) |
| | 1 | 59 | | if err != nil { |
| | 1 | 60 | | return nil, fmt.Errorf("cmd discoverer: reading module name: %w", err) |
| | 1 | 61 | | } |
| | | 62 | | |
| | | 63 | | // Walk cmd/ to find immediate subdirectories that contain a main.go file. |
| | | 64 | | // We collect (serviceName → importedPkgNames) per service. |
| | 9 | 65 | | type serviceInfo struct { |
| | 9 | 66 | | name string // e.g. "api" |
| | 9 | 67 | | pkgs []string // pkg/ sub-package names imported (e.g. "queue") |
| | 9 | 68 | | } |
| | 9 | 69 | | |
| | 9 | 70 | | var services []serviceInfo |
| | 9 | 71 | | |
| | 9 | 72 | | walkErr := d.fs.Walk(cmdDir, func(path string, de fs.DirEntry, err error) error { |
| | 0 | 73 | | if err != nil { |
| | 0 | 74 | | return err |
| | 0 | 75 | | } |
| | 1 | 76 | | if ctxErr := ctx.Err(); ctxErr != nil { |
| | 1 | 77 | | return ctxErr |
| | 1 | 78 | | } |
| | | 79 | | |
| | 14 | 80 | | rel, relErr := filepath.Rel(cmdDir, path) |
| | 0 | 81 | | if relErr != nil { |
| | 0 | 82 | | return relErr |
| | 0 | 83 | | } |
| | 14 | 84 | | parts := strings.Split(rel, string(filepath.Separator)) |
| | 14 | 85 | | |
| | 14 | 86 | | // Only interested in main.go files exactly one level below cmd/: |
| | 14 | 87 | | // rel = "<service>/main.go" → parts = ["<service>", "main.go"] |
| | 14 | 88 | | // Service name is always derived from parts[0] so that discovery does |
| | 14 | 89 | | // not depend on the directory entry arriving before its children. |
| | 7 | 90 | | if !de.IsDir() && de.Name() == "main.go" && len(parts) == 2 { |
| | 7 | 91 | | svcName := parts[0] |
| | 7 | 92 | | pkgs, parseErr := d.parsePkgImports(path, moduleName) |
| | 1 | 93 | | if parseErr != nil { |
| | 1 | 94 | | return parseErr |
| | 1 | 95 | | } |
| | 6 | 96 | | services = append(services, serviceInfo{name: svcName, pkgs: pkgs}) |
| | | 97 | | } |
| | 13 | 98 | | return nil |
| | | 99 | | }) |
| | 4 | 100 | | if walkErr != nil { |
| | 4 | 101 | | return nil, fmt.Errorf("cmd discoverer: walking %s: %w", cmdDir, walkErr) |
| | 4 | 102 | | } |
| | | 103 | | |
| | 0 | 104 | | if len(services) == 0 { |
| | 0 | 105 | | return nil, nil |
| | 0 | 106 | | } |
| | | 107 | | |
| | | 108 | | // Count how many services use each pkg/ package. |
| | | 109 | | // parsePkgImports already deduplicates within a file, so each entry in |
| | | 110 | | // services[i].pkgs is unique — no additional per-service dedup is needed. |
| | 5 | 111 | | pkgUsage := make(map[string]int) // pkgName → usage count |
| | 5 | 112 | | for i := range services { |
| | 4 | 113 | | for _, pkg := range services[i].pkgs { |
| | 4 | 114 | | pkgUsage[pkg]++ |
| | 4 | 115 | | } |
| | | 116 | | } |
| | | 117 | | |
| | | 118 | | // Packages used by more than one service become library projects. |
| | 5 | 119 | | sharedPkgs := make(map[string]bool) |
| | 3 | 120 | | for pkg, count := range pkgUsage { |
| | 1 | 121 | | if count > 1 { |
| | 1 | 122 | | sharedPkgs[pkg] = true |
| | 1 | 123 | | } |
| | | 124 | | } |
| | | 125 | | |
| | 5 | 126 | | var projects []domain.Project |
| | 5 | 127 | | |
| | 5 | 128 | | // Service projects first, sorted by name for determinism. |
| | 1 | 129 | | slices.SortFunc(services, func(a, b serviceInfo) int { return strings.Compare(a.name, b.name) }) |
| | 5 | 130 | | for i := range services { |
| | 6 | 131 | | svc := &services[i] |
| | 6 | 132 | | var deps []string |
| | 4 | 133 | | for _, pkg := range svc.pkgs { |
| | 2 | 134 | | if sharedPkgs[pkg] { |
| | 2 | 135 | | deps = append(deps, pkg) |
| | 2 | 136 | | } |
| | | 137 | | } |
| | 6 | 138 | | slices.Sort(deps) |
| | 6 | 139 | | |
| | 6 | 140 | | projects = append(projects, domain.Project{ |
| | 6 | 141 | | Name: svc.name, |
| | 6 | 142 | | Path: filepath.Join("cmd", svc.name), |
| | 6 | 143 | | Type: domain.ProjectTypeCmdService, |
| | 6 | 144 | | ModulePath: moduleName, |
| | 6 | 145 | | TagPrefix: svc.name + "/", |
| | 6 | 146 | | Dependencies: deps, |
| | 6 | 147 | | }) |
| | | 148 | | } |
| | | 149 | | |
| | | 150 | | // Library projects, sorted by name. |
| | 5 | 151 | | var libNames []string |
| | 1 | 152 | | for name := range sharedPkgs { |
| | 1 | 153 | | libNames = append(libNames, name) |
| | 1 | 154 | | } |
| | 5 | 155 | | slices.Sort(libNames) |
| | 5 | 156 | | |
| | 1 | 157 | | for _, name := range libNames { |
| | 1 | 158 | | projects = append(projects, domain.Project{ |
| | 1 | 159 | | Name: name, |
| | 1 | 160 | | Path: filepath.Join("pkg", name), |
| | 1 | 161 | | Type: domain.ProjectTypeCmdLibrary, |
| | 1 | 162 | | ModulePath: moduleName, |
| | 1 | 163 | | TagPrefix: name + "/", |
| | 1 | 164 | | }) |
| | 1 | 165 | | } |
| | | 166 | | |
| | 5 | 167 | | return projects, nil |
| | | 168 | | } |
| | | 169 | | |
| | | 170 | | // parsePkgImports reads a Go source file and returns the names of any |
| | | 171 | | // pkg/<name> sub-packages it imports within the given module. |
| | 7 | 172 | | func (d *CmdDiscoverer) parsePkgImports(filePath, moduleName string) ([]string, error) { |
| | 7 | 173 | | data, err := d.fs.ReadFile(filePath) |
| | 0 | 174 | | if err != nil { |
| | 0 | 175 | | return nil, fmt.Errorf("reading %s: %w", filePath, err) |
| | 0 | 176 | | } |
| | | 177 | | |
| | 7 | 178 | | fset := token.NewFileSet() |
| | 7 | 179 | | f, err := parser.ParseFile(fset, filePath, data, parser.ImportsOnly) |
| | 1 | 180 | | if err != nil { |
| | 1 | 181 | | return nil, fmt.Errorf("parsing imports in %s: %w", filePath, err) |
| | 1 | 182 | | } |
| | | 183 | | |
| | 6 | 184 | | pkgPrefix := moduleName + "/pkg/" |
| | 6 | 185 | | seen := make(map[string]bool) |
| | 6 | 186 | | var pkgs []string |
| | 6 | 187 | | for _, imp := range f.Imports { |
| | 8 | 188 | | path := strings.Trim(imp.Path.Value, `"`) |
| | 5 | 189 | | if rest, ok := strings.CutPrefix(path, pkgPrefix); ok { |
| | 5 | 190 | | // Extract the immediate sub-package name: "module/pkg/queue/sub" → "queue". |
| | 5 | 191 | | name := strings.SplitN(rest, "/", 2)[0] |
| | 4 | 192 | | if name != "" && !seen[name] { |
| | 4 | 193 | | seen[name] = true |
| | 4 | 194 | | pkgs = append(pkgs, name) |
| | 4 | 195 | | } |
| | | 196 | | } |
| | | 197 | | } |
| | 6 | 198 | | return pkgs, nil |
| | | 199 | | } |