- tools.json now has both "version" (tag) and "hash" (commit) fields - Output format: toolname@version [hash] (in duration) - Update tool fetches and stores both version and hash - Fix .gitignore to not exclude cmd/update directory
311 lines
7.7 KiB
Go
311 lines
7.7 KiB
Go
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: <hash>\t<ref>
|
|
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
|
|
}
|