Some checks failed
CI / Check for .beads changes (push) Has been skipped
CI / Check embedded formulas (push) Failing after 23s
CI / Test (push) Failing after 1m47s
CI / Lint (push) Failing after 25s
CI / Integration Tests (push) Successful in 1m19s
CI / Coverage Report (push) Has been skipped
Windows CI / Windows Build and Unit Tests (push) Has been cancelled
Under high concurrency (17+ agents), the bd version check spawns multiple git subprocesses per invocation, causing timeouts when 85-120+ git processes compete for resources. This fix: - Caches successful version checks to ~/.cache/gastown/beads-version.json - Uses cached results for 24 hours to avoid subprocess spawning - On timeout, uses stale cache if available or gracefully degrades - Prints warning when using cached/degraded path Fixes: https://github.com/steveyegge/gastown/issues/503 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
242 lines
6.6 KiB
Go
242 lines
6.6 KiB
Go
// Package cmd provides CLI commands for the gt tool.
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/state"
|
|
)
|
|
|
|
// MinBeadsVersion is the minimum required beads version for Gas Town.
|
|
// This version must include custom type support (bd-i54l).
|
|
const MinBeadsVersion = "0.44.0"
|
|
|
|
// beadsVersion represents a parsed semantic version.
|
|
type beadsVersion struct {
|
|
major int
|
|
minor int
|
|
patch int
|
|
}
|
|
|
|
// parseBeadsVersion parses a version string like "0.44.0" into components.
|
|
func parseBeadsVersion(v string) (beadsVersion, error) {
|
|
// Strip leading 'v' if present
|
|
v = strings.TrimPrefix(v, "v")
|
|
|
|
// Split on dots
|
|
parts := strings.Split(v, ".")
|
|
if len(parts) < 2 {
|
|
return beadsVersion{}, fmt.Errorf("invalid version format: %s", v)
|
|
}
|
|
|
|
major, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return beadsVersion{}, fmt.Errorf("invalid major version: %s", parts[0])
|
|
}
|
|
|
|
minor, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return beadsVersion{}, fmt.Errorf("invalid minor version: %s", parts[1])
|
|
}
|
|
|
|
patch := 0
|
|
if len(parts) >= 3 {
|
|
// Handle versions like "0.44.0-dev" - take only numeric prefix
|
|
patchStr := parts[2]
|
|
if idx := strings.IndexFunc(patchStr, func(r rune) bool {
|
|
return r < '0' || r > '9'
|
|
}); idx != -1 {
|
|
patchStr = patchStr[:idx]
|
|
}
|
|
if patchStr != "" {
|
|
patch, err = strconv.Atoi(patchStr)
|
|
if err != nil {
|
|
return beadsVersion{}, fmt.Errorf("invalid patch version: %s", parts[2])
|
|
}
|
|
}
|
|
}
|
|
|
|
return beadsVersion{major: major, minor: minor, patch: patch}, nil
|
|
}
|
|
|
|
// compare returns -1 if v < other, 0 if equal, 1 if v > other.
|
|
func (v beadsVersion) compare(other beadsVersion) int {
|
|
if v.major != other.major {
|
|
if v.major < other.major {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if v.minor != other.minor {
|
|
if v.minor < other.minor {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if v.patch != other.patch {
|
|
if v.patch < other.patch {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// Pre-compiled regex for beads version parsing
|
|
var beadsVersionRe = regexp.MustCompile(`bd version (\d+\.\d+(?:\.\d+)?(?:-\w+)?)`)
|
|
|
|
// versionCacheTTL is how long a cached version check remains valid.
|
|
// 24 hours is reasonable since version upgrades are infrequent.
|
|
const versionCacheTTL = 24 * time.Hour
|
|
|
|
// versionCache stores the result of a beads version check.
|
|
type versionCache struct {
|
|
Version string `json:"version"`
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
Valid bool `json:"valid"` // true if version meets minimum requirement
|
|
}
|
|
|
|
// versionCachePath returns the path to the version cache file.
|
|
func versionCachePath() string {
|
|
return filepath.Join(state.CacheDir(), "beads-version.json")
|
|
}
|
|
|
|
// loadVersionCache reads the cached version check result.
|
|
func loadVersionCache() (*versionCache, error) {
|
|
data, err := os.ReadFile(versionCachePath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cache versionCache
|
|
if err := json.Unmarshal(data, &cache); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cache, nil
|
|
}
|
|
|
|
// saveVersionCache writes the version check result to cache.
|
|
func saveVersionCache(c *versionCache) error {
|
|
dir := state.CacheDir()
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Atomic write via temp file
|
|
tmp := versionCachePath() + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, versionCachePath())
|
|
}
|
|
|
|
// isCacheFresh returns true if the cache is within the TTL.
|
|
func (c *versionCache) isCacheFresh() bool {
|
|
return time.Since(c.CheckedAt) < versionCacheTTL
|
|
}
|
|
|
|
func getBeadsVersion() (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "bd", "version")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return "", fmt.Errorf("bd version check timed out")
|
|
}
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
return "", fmt.Errorf("bd version failed: %s", string(exitErr.Stderr))
|
|
}
|
|
return "", fmt.Errorf("failed to run bd: %w (is beads installed?)", err)
|
|
}
|
|
|
|
// Parse output like "bd version 0.44.0 (dev)"
|
|
// or "bd version 0.44.0"
|
|
matches := beadsVersionRe.FindStringSubmatch(string(output))
|
|
if len(matches) < 2 {
|
|
return "", fmt.Errorf("could not parse beads version from: %s", strings.TrimSpace(string(output)))
|
|
}
|
|
|
|
return matches[1], nil
|
|
}
|
|
|
|
var (
|
|
cachedVersionCheckResult error
|
|
versionCheckOnce sync.Once
|
|
)
|
|
|
|
// CheckBeadsVersion verifies that the installed beads version meets the minimum requirement.
|
|
// Returns nil if the version is sufficient, or an error with details if not.
|
|
// The check is performed only once per process execution.
|
|
func CheckBeadsVersion() error {
|
|
versionCheckOnce.Do(func() {
|
|
cachedVersionCheckResult = checkBeadsVersionInternal()
|
|
})
|
|
return cachedVersionCheckResult
|
|
}
|
|
|
|
func checkBeadsVersionInternal() error {
|
|
// Try to use cached result first to avoid subprocess spawning
|
|
if cache, err := loadVersionCache(); err == nil && cache.isCacheFresh() {
|
|
if cache.Valid {
|
|
return nil // Cached successful check
|
|
}
|
|
// Cached failure - still need to check (version might have been upgraded)
|
|
}
|
|
|
|
installedStr, err := getBeadsVersion()
|
|
if err != nil {
|
|
// On timeout, try to use stale cache or gracefully degrade
|
|
if strings.Contains(err.Error(), "timed out") {
|
|
if cache, cacheErr := loadVersionCache(); cacheErr == nil && cache.Valid {
|
|
// Use stale cache but warn
|
|
fmt.Fprintf(os.Stderr, "Warning: bd version check timed out, using cached result (v%s)\n", cache.Version)
|
|
return nil
|
|
}
|
|
// No cache available - gracefully degrade with warning
|
|
fmt.Fprintf(os.Stderr, "Warning: bd version check timed out (high system load?), proceeding anyway\n")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("cannot verify beads version: %w", err)
|
|
}
|
|
|
|
installed, err := parseBeadsVersion(installedStr)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot parse installed beads version %q: %w", installedStr, err)
|
|
}
|
|
|
|
required, err := parseBeadsVersion(MinBeadsVersion)
|
|
if err != nil {
|
|
// This would be a bug in our code
|
|
return fmt.Errorf("cannot parse required beads version %q: %w", MinBeadsVersion, err)
|
|
}
|
|
|
|
valid := installed.compare(required) >= 0
|
|
|
|
// Cache the result
|
|
_ = saveVersionCache(&versionCache{
|
|
Version: installedStr,
|
|
CheckedAt: time.Now(),
|
|
Valid: valid,
|
|
})
|
|
|
|
if !valid {
|
|
return fmt.Errorf("beads version %s is required, but %s is installed\n\nPlease upgrade beads: go install github.com/steveyegge/beads/cmd/bd@latest", MinBeadsVersion, installedStr)
|
|
}
|
|
|
|
return nil
|
|
}
|