@@ -10,6 +10,7 @@ import (
1010 "io"
1111 "os"
1212 "os/exec"
13+ "path/filepath"
1314 "strings"
1415
1516 "golang.org/x/mod/modfile"
@@ -190,32 +191,30 @@ func validateMainModule(
190191 return fmt .Sprintf ("%s@%s: failed to resolve module origin: %v" , mod .modulePath , version , err )
191192 }
192193
193- expectedRef := "refs/heads/" + origin .defaultBranch
194- if origin .ref != expectedRef {
195- return fmt .Sprintf ("%s@%s: pseudo-version resolved to branch %q but must be on the default branch %q; use a tagged release or a pseudo-version from %s" ,
196- mod .modulePath , version , origin .ref , expectedRef , origin .defaultBranch )
194+ if ! origin .onDefault {
195+ return fmt .Sprintf ("%s@%s: commit %s is not on the default branch (%s) of %s" ,
196+ mod .modulePath , version , origin .hash , origin .defaultBranch , origin .url )
197197 }
198198
199199 _ , _ = fmt .Fprintf (out , " - %s@%s is on %s (ok)\n " , mod .modulePath , version , origin .defaultBranch )
200200 return ""
201201}
202202
203203type moduleOrigin struct {
204- ref string // e.g. "refs/heads/master"
204+ hash string // full commit hash
205+ url string // VCS repository URL
205206 defaultBranch string // e.g. "master"
207+ onDefault bool // whether the commit is on the default branch
206208}
207209
208- // resolveModuleOrigin uses go mod download with GOPROXY=direct to get the
209- // Origin.Ref and Origin.URL from the VCS repository directly, then uses
210- // git ls-remote to determine the default branch.
210+ // resolveModuleOrigin uses go mod download with GOPROXY=direct to clone the
211+ // VCS repository, then checks if the pseudo-version commit is on the default branch.
211212func resolveModuleOrigin (ctx context.Context , modulePath string , version string ) (* moduleOrigin , error ) {
212213 if ! module .IsPseudoVersion (version ) {
213214 return nil , fmt .Errorf ("version %q is not a pseudo-version" , version )
214215 }
215216
216- // Use a temporary module cache so Go fetches directly from VCS.
217- // The Origin.Ref field is only populated on a fresh git fetch;
218- // a warm cache omits it.
217+ // Use a temporary module cache so go mod download clones the VCS repo fresh.
219218 tmpCache , err := os .MkdirTemp ("" , "validate-api-go-version-*" )
220219 if err != nil {
221220 return nil , fmt .Errorf ("failed to create temp cache dir: %w" , err )
@@ -231,31 +230,76 @@ func resolveModuleOrigin(ctx context.Context, modulePath string, version string)
231230
232231 var payload struct {
233232 Origin struct {
234- URL string `json:"URL"`
235- Ref string `json:"Ref "`
233+ URL string `json:"URL"`
234+ Hash string `json:"Hash "`
236235 } `json:"Origin"`
237236 }
238237 if err := json .Unmarshal (out , & payload ); err != nil {
239238 return nil , fmt .Errorf ("failed to decode go mod download output: %w" , err )
240239 }
241- if payload .Origin .Ref == "" {
242- return nil , fmt .Errorf ("go mod download did not return an origin ref for %s@%s" , modulePath , version )
243- }
244240 if payload .Origin .URL == "" {
245241 return nil , fmt .Errorf ("go mod download did not return an origin URL for %s@%s" , modulePath , version )
246242 }
243+ if payload .Origin .Hash == "" {
244+ return nil , fmt .Errorf ("go mod download did not return an origin hash for %s@%s" , modulePath , version )
245+ }
247246
248247 defaultBranch , err := gitDefaultBranch (ctx , payload .Origin .URL )
249248 if err != nil {
250249 return nil , fmt .Errorf ("failed to determine default branch for %s: %w" , payload .Origin .URL , err )
251250 }
252251
252+ // The VCS cache from go mod download is a bare git repo. Find it and
253+ // use git branch --contains to check if the commit is on the default branch.
254+ vcsDir , err := findVCSCache (tmpCache )
255+ if err != nil {
256+ return nil , fmt .Errorf ("failed to find VCS cache: %w" , err )
257+ }
258+
259+ onDefault , err := gitCommitOnBranch (ctx , vcsDir , payload .Origin .Hash , defaultBranch )
260+ if err != nil {
261+ return nil , fmt .Errorf ("failed to check branch containment: %w" , err )
262+ }
263+
253264 return & moduleOrigin {
254- ref : payload .Origin .Ref ,
265+ hash : payload .Origin .Hash ,
266+ url : payload .Origin .URL ,
255267 defaultBranch : defaultBranch ,
268+ onDefault : onDefault ,
256269 }, nil
257270}
258271
272+ // findVCSCache finds the bare git repo directory inside the go module VCS cache.
273+ func findVCSCache (modCache string ) (string , error ) {
274+ vcsRoot := filepath .Join (modCache , "cache" , "vcs" )
275+ entries , err := os .ReadDir (vcsRoot )
276+ if err != nil {
277+ return "" , fmt .Errorf ("failed to read VCS cache dir: %w" , err )
278+ }
279+ for _ , entry := range entries {
280+ if entry .IsDir () {
281+ return filepath .Join (vcsRoot , entry .Name ()), nil
282+ }
283+ }
284+ return "" , errors .New ("no VCS cache directory found" )
285+ }
286+
287+ // gitCommitOnBranch checks if a commit is reachable from a branch in a bare git repo.
288+ func gitCommitOnBranch (ctx context.Context , repoDir string , commitHash string , branch string ) (bool , error ) {
289+ cmd := exec .CommandContext (ctx , "git" , "-C" , repoDir , "branch" , "--contains" , commitHash )
290+ out , err := cmd .Output ()
291+ if err != nil {
292+ return false , fmt .Errorf ("git branch --contains failed: %w" , err )
293+ }
294+ for _ , line := range strings .Split (string (out ), "\n " ) {
295+ name := strings .TrimSpace (strings .TrimPrefix (strings .TrimSpace (line ), "* " ))
296+ if name == branch {
297+ return true , nil
298+ }
299+ }
300+ return false , nil
301+ }
302+
259303// gitDefaultBranch uses git ls-remote to determine the default branch of a
260304// remote repository.
261305func gitDefaultBranch (ctx context.Context , repoURL string ) (string , error ) {
0 commit comments