package main import ( "encoding/json" "flag" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "time" ) type Tool struct { Name string `json:"name"` Package string `json:"package"` Version string `json:"version"` Hash string `json:"hash"` Date string `json:"date"` } type ToolsFile struct { Tools []Tool `json:"tools"` } type ModuleInfo struct { Version string `json:"Version"` Time string `json:"Time"` Origin struct { VCS string `json:"VCS"` URL string `json:"URL"` Hash string `json:"Hash"` } `json:"Origin"` } func main() { outputPath := flag.String("o", "tools.json", "output file path") parallel := flag.Int("j", 8, "number of parallel lookups") flag.Parse() // Read existing tools.json to get the package list data, err := os.ReadFile(*outputPath) if err != nil { fmt.Fprintf(os.Stderr, "error reading %s: %v\n", *outputPath, err) os.Exit(1) } var tf ToolsFile if err := json.Unmarshal(data, &tf); err != nil { fmt.Fprintf(os.Stderr, "error parsing %s: %v\n", *outputPath, err) os.Exit(1) } fmt.Printf("Updating %d tools...\n\n", len(tf.Tools)) var wg sync.WaitGroup sem := make(chan struct{}, *parallel) results := make(chan result, len(tf.Tools)) for i, t := range tf.Tools { wg.Add(1) go func(idx int, tool Tool) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() updated, err := fetchLatestVersion(tool) results <- result{idx: idx, tool: tool, updated: updated, err: err} }(i, t) } go func() { wg.Wait() close(results) }() updatedTools := make([]Tool, len(tf.Tools)) var succeeded, failed, changed int for r := range results { if r.err != nil { fmt.Printf("✗ %s: %v\n", r.tool.Name, r.err) updatedTools[r.idx] = r.tool // keep old version failed++ } else { updatedTools[r.idx] = r.updated oldHash := r.tool.Hash if oldHash == "" { oldHash = r.tool.Version // migration from old format } if oldHash != r.updated.Hash { fmt.Printf("↑ %s: %s [%s] -> %s [%s]\n", r.tool.Name, r.tool.Version, shortHash(oldHash), r.updated.Version, r.updated.Hash[:12]) changed++ } else { fmt.Printf("✓ %s: %s [%s] (unchanged)\n", r.tool.Name, r.updated.Version, r.updated.Hash[:12]) } succeeded++ } } tf.Tools = updatedTools // Write updated tools.json output, err := json.MarshalIndent(tf, "", " ") if err != nil { fmt.Fprintf(os.Stderr, "error marshaling JSON: %v\n", err) os.Exit(1) } output = append(output, '\n') absPath, _ := filepath.Abs(*outputPath) if err := os.WriteFile(*outputPath, output, 0o644); err != nil { fmt.Fprintf(os.Stderr, "error writing %s: %v\n", *outputPath, err) os.Exit(1) } fmt.Printf("\nDone: %d succeeded, %d failed, %d updated\n", succeeded, failed, changed) fmt.Printf("Written to %s\n", absPath) if failed > 0 { os.Exit(1) } } // getModulePath extracts the module path from a package path func getModulePath(pkg string) string { if strings.Contains(pkg, "/cmd/") { parts := strings.Split(pkg, "/cmd/") return parts[0] } if strings.Contains(pkg, "/gojson") && strings.Contains(pkg, "ChimeraCoder") { return "github.com/ChimeraCoder/gojson" } if strings.HasSuffix(pkg, "/gotests") { return strings.TrimSuffix(pkg, "/gotests") } // gopls is a submodule of golang.org/x/tools if strings.HasSuffix(pkg, "/gopls") { return pkg } return pkg } // getRepoPath extracts the git repository path from a module path func getRepoPath(modPath string) string { // golang.org/x/tools/gopls -> golang.org/x/tools if strings.HasPrefix(modPath, "golang.org/x/") { parts := strings.Split(modPath, "/") if len(parts) >= 3 { return strings.Join(parts[:3], "/") } } return modPath } // getGitURL returns the git URL for a module path func getGitURL(modPath string) string { // Get the repo path first repoPath := getRepoPath(modPath) // Handle vanity imports switch { case strings.HasPrefix(repoPath, "golang.org/x/"): name := strings.TrimPrefix(repoPath, "golang.org/x/") return "https://github.com/golang/" + name + ".git" case strings.HasPrefix(repoPath, "honnef.co/go/tools"): return "https://github.com/dominikh/go-tools.git" case strings.HasPrefix(repoPath, "mvdan.cc/"): name := strings.TrimPrefix(repoPath, "mvdan.cc/") return "https://github.com/mvdan/" + name + ".git" default: // Assume github.com/user/repo format parts := strings.Split(repoPath, "/") if len(parts) >= 3 { return "https://" + strings.Join(parts[:3], "/") + ".git" } return "https://" + repoPath + ".git" } } // getTagPattern returns the git tag pattern for a module func getTagPattern(pkg, version string) string { // gopls has tags like "gopls/v0.21.0" if strings.Contains(pkg, "/gopls") { return "gopls/" + version } // godoc has tags like "cmd/godoc/v0.1.0-deprecated" if strings.HasSuffix(pkg, "/cmd/godoc") { return "cmd/godoc/" + version } return version } // pseudoVersionRe matches pseudo-versions like v0.0.0-20250907133731-34b10582faa4 var pseudoVersionRe = regexp.MustCompile(`^v\d+\.\d+\.\d+-\d{14}-([a-f0-9]{12})$`) func fetchLatestVersion(tool Tool) (Tool, error) { modPath := getModulePath(tool.Package) // First get the latest version info cmd := exec.Command("go", "list", "-m", "-json", modPath+"@latest") output, err := cmd.Output() if err != nil { return Tool{}, fmt.Errorf("go list failed: %w", err) } var info ModuleInfo if err := json.Unmarshal(output, &info); err != nil { return Tool{}, fmt.Errorf("parse error: %w", err) } // Parse date date := "" if info.Time != "" { if t, err := time.Parse(time.RFC3339, info.Time); err == nil { date = t.Format("2006-01-02") } } // Check if it's already a pseudo-version with commit hash if matches := pseudoVersionRe.FindStringSubmatch(info.Version); matches != nil { // Extract the 12-char hash from pseudo-version, we need the full hash shortHash := matches[1] gitURL := getGitURL(modPath) fullHash, err := resolveShortHash(gitURL, shortHash) if err != nil { return Tool{}, fmt.Errorf("resolve hash failed: %w", err) } return Tool{ Name: tool.Name, Package: tool.Package, Version: info.Version, Hash: fullHash, Date: date, }, nil } // It's a tagged version, resolve to commit hash gitURL := getGitURL(modPath) tagPattern := getTagPattern(tool.Package, info.Version) hash, err := resolveTagToHash(gitURL, tagPattern) if err != nil { return Tool{}, fmt.Errorf("resolve tag failed: %w", err) } return Tool{ Name: tool.Name, Package: tool.Package, Version: info.Version, Hash: hash, Date: date, }, nil } func resolveTagToHash(gitURL, tag string) (string, error) { cmd := exec.Command("git", "ls-remote", "--tags", gitURL, tag) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("git ls-remote failed: %w", err) } lines := strings.TrimSpace(string(output)) if lines == "" { return "", fmt.Errorf("tag %s not found", tag) } // Format: \t parts := strings.Fields(lines) if len(parts) < 1 { return "", fmt.Errorf("unexpected git ls-remote output") } return parts[0], nil } func resolveShortHash(gitURL, shortHash string) (string, error) { // Use git ls-remote with the short hash to find full hash cmd := exec.Command("git", "ls-remote", gitURL) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("git ls-remote failed: %w", err) } lines := strings.Split(string(output), "\n") for _, line := range lines { parts := strings.Fields(line) if len(parts) >= 1 && strings.HasPrefix(parts[0], shortHash) { return parts[0], nil } } return "", fmt.Errorf("hash %s not found", shortHash) } type result struct { idx int tool Tool updated Tool err error } func shortHash(h string) string { if len(h) >= 12 { return h[:12] } return h }