feat(version): add stale binary detection with startup warning
Add detection for when the installed gt binary is out of date with the source repository. This helps catch issues where commands fail mysteriously because the installed binary doesn't have recent fixes. Changes: - Add internal/version package with stale binary detection logic - Add startup warning in PersistentPreRunE when binary is stale - Add gt doctor check for stale-binary - Use prefix matching for commit comparison (handles short vs full hash) The warning is non-blocking and only shows once per shell session via the GT_STALE_WARNED environment variable. Resolves: gt-ud912 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ Town root protection:
|
|||||||
- pre-checkout-hook Verify pre-checkout hook prevents branch switches (fixable)
|
- pre-checkout-hook Verify pre-checkout hook prevents branch switches (fixable)
|
||||||
|
|
||||||
Infrastructure checks:
|
Infrastructure checks:
|
||||||
|
- stale-binary Check if gt binary is up to date with repo
|
||||||
- daemon Check if daemon is running (fixable)
|
- daemon Check if daemon is running (fixable)
|
||||||
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
- repo-fingerprint Check database has valid repo fingerprint (fixable)
|
||||||
- boot-health Check Boot watchdog health (vet mode)
|
- boot-health Check Boot watchdog health (vet mode)
|
||||||
@@ -116,6 +117,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
d.Register(doctor.NewGlobalStateCheck())
|
d.Register(doctor.NewGlobalStateCheck())
|
||||||
|
|
||||||
// Register built-in checks
|
// Register built-in checks
|
||||||
|
d.Register(doctor.NewStaleBinaryCheck())
|
||||||
d.Register(doctor.NewTownGitCheck())
|
d.Register(doctor.NewTownGitCheck())
|
||||||
d.Register(doctor.NewTownRootBranchCheck())
|
d.Register(doctor.NewTownRootBranchCheck())
|
||||||
d.Register(doctor.NewPreCheckoutHookCheck())
|
d.Register(doctor.NewPreCheckoutHookCheck())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var infoCmd = &cobra.Command{
|
var infoCmd = &cobra.Command{
|
||||||
@@ -39,7 +40,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if commit := resolveCommitHash(); commit != "" {
|
if commit := resolveCommitHash(); commit != "" {
|
||||||
info["commit"] = shortCommit(commit)
|
info["commit"] = version.ShortCommit(commit)
|
||||||
}
|
}
|
||||||
if branch := resolveBranch(); branch != "" {
|
if branch := resolveBranch(); branch != "" {
|
||||||
info["branch"] = branch
|
info["branch"] = branch
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/version"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,10 +109,52 @@ func checkBeadsDependency(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for stale binary (warning only, doesn't block)
|
||||||
|
checkStaleBinaryWarning()
|
||||||
|
|
||||||
// Check beads version
|
// Check beads version
|
||||||
return CheckBeadsVersion()
|
return CheckBeadsVersion()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// staleBinaryWarned tracks if we've already warned about stale binary in this session.
|
||||||
|
// We use an environment variable since the binary restarts on each command.
|
||||||
|
var staleBinaryWarned = os.Getenv("GT_STALE_WARNED") == "1"
|
||||||
|
|
||||||
|
// checkStaleBinaryWarning checks if the installed binary is stale and prints a warning.
|
||||||
|
// This is a non-blocking check - errors are silently ignored.
|
||||||
|
func checkStaleBinaryWarning() {
|
||||||
|
// Only warn once per shell session
|
||||||
|
if staleBinaryWarned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := version.GetRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
// Can't find repo - silently skip (might be running from non-dev environment)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := version.CheckStaleBinary(repoRoot)
|
||||||
|
if info.Error != nil {
|
||||||
|
// Check failed - silently skip
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsStale {
|
||||||
|
staleBinaryWarned = true
|
||||||
|
os.Setenv("GT_STALE_WARNED", "1")
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("gt binary is stale (built from %s, repo at %s)",
|
||||||
|
version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
|
||||||
|
if info.CommitsBehind > 0 {
|
||||||
|
msg = fmt.Sprintf("gt binary is %d commits behind (built from %s, repo at %s)",
|
||||||
|
info.CommitsBehind, version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "%s %s\n", style.WarningPrefix, msg)
|
||||||
|
fmt.Fprintf(os.Stderr, " %s Run 'gt install' to update\n", style.ArrowPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute runs the root command and returns an exit code.
|
// Execute runs the root command and returns an exit code.
|
||||||
// The caller (main) should call os.Exit with this code.
|
// The caller (main) should call os.Exit with this code.
|
||||||
func Execute() int {
|
func Execute() int {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version information - set at build time via ldflags
|
// Version information - set at build time via ldflags
|
||||||
@@ -28,9 +29,9 @@ var versionCmd = &cobra.Command{
|
|||||||
branch := resolveBranch()
|
branch := resolveBranch()
|
||||||
|
|
||||||
if commit != "" && branch != "" {
|
if commit != "" && branch != "" {
|
||||||
fmt.Printf("gt version %s (%s: %s@%s)\n", Version, Build, branch, shortCommit(commit))
|
fmt.Printf("gt version %s (%s: %s@%s)\n", Version, Build, branch, version.ShortCommit(commit))
|
||||||
} else if commit != "" {
|
} else if commit != "" {
|
||||||
fmt.Printf("gt version %s (%s: %s)\n", Version, Build, shortCommit(commit))
|
fmt.Printf("gt version %s (%s: %s)\n", Version, Build, version.ShortCommit(commit))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("gt version %s (%s)\n", Version, Build)
|
fmt.Printf("gt version %s (%s)\n", Version, Build)
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,11 @@ var versionCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
|
||||||
|
// Pass the build-time commit to the version package for stale binary checks
|
||||||
|
if Commit != "" {
|
||||||
|
version.SetCommit(Commit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveCommitHash() string {
|
func resolveCommitHash() string {
|
||||||
@@ -57,13 +63,6 @@ func resolveCommitHash() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortCommit(hash string) string {
|
|
||||||
if len(hash) > 12 {
|
|
||||||
return hash[:12]
|
|
||||||
}
|
|
||||||
return hash
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveBranch() string {
|
func resolveBranch() string {
|
||||||
if Branch != "" {
|
if Branch != "" {
|
||||||
return Branch
|
return Branch
|
||||||
|
|||||||
83
internal/doctor/stale_binary_check.go
Normal file
83
internal/doctor/stale_binary_check.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaleBinaryCheck verifies the installed gt binary is up to date with the repo.
|
||||||
|
type StaleBinaryCheck struct {
|
||||||
|
FixableCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStaleBinaryCheck creates a new stale binary check.
|
||||||
|
func NewStaleBinaryCheck() *StaleBinaryCheck {
|
||||||
|
return &StaleBinaryCheck{
|
||||||
|
FixableCheck: FixableCheck{
|
||||||
|
BaseCheck: BaseCheck{
|
||||||
|
CheckName: "stale-binary",
|
||||||
|
CheckDescription: "Check if gt binary is up to date with repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run checks if the binary is stale.
|
||||||
|
func (c *StaleBinaryCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
|
repoRoot, err := version.GetRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "Cannot locate gt source repo (not a development environment)",
|
||||||
|
Details: []string{err.Error()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info := version.CheckStaleBinary(repoRoot)
|
||||||
|
if info.Error != nil {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "Cannot determine binary version (dev build?)",
|
||||||
|
Details: []string{info.Error.Error()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsStale {
|
||||||
|
msg := fmt.Sprintf("Binary is stale (built from %s, repo at %s)",
|
||||||
|
version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
|
||||||
|
if info.CommitsBehind > 0 {
|
||||||
|
msg = fmt.Sprintf("Binary is %d commits behind (built from %s, repo at %s)",
|
||||||
|
info.CommitsBehind, version.ShortCommit(info.BinaryCommit), version.ShortCommit(info.RepoCommit))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: msg,
|
||||||
|
FixHint: "Run 'gt install' to rebuild and install",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: fmt.Sprintf("Binary is up to date (%s)", version.ShortCommit(info.BinaryCommit)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix rebuilds and installs gt.
|
||||||
|
func (c *StaleBinaryCheck) Fix(ctx *CheckContext) error {
|
||||||
|
// Note: We don't auto-fix this because:
|
||||||
|
// 1. It requires building and installing, which takes time
|
||||||
|
// 2. It modifies system files outside the workspace
|
||||||
|
// 3. User should explicitly run 'gt install'
|
||||||
|
return fmt.Errorf("run 'gt install' manually to rebuild")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanFix returns false - stale binary should be fixed manually.
|
||||||
|
func (c *StaleBinaryCheck) CanFix() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
164
internal/version/stale.go
Normal file
164
internal/version/stale.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Package version provides version information and staleness checking for gt.
|
||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These variables are set at build time via ldflags in cmd package.
|
||||||
|
// We provide fallback methods to read from build info.
|
||||||
|
var (
|
||||||
|
// Commit can be set from cmd package or read from build info
|
||||||
|
Commit = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaleBinaryInfo contains information about binary staleness.
|
||||||
|
type StaleBinaryInfo struct {
|
||||||
|
IsStale bool // True if binary commit doesn't match repo HEAD
|
||||||
|
BinaryCommit string // Commit hash the binary was built from
|
||||||
|
RepoCommit string // Current repo HEAD commit
|
||||||
|
CommitsBehind int // Number of commits binary is behind (0 if unknown)
|
||||||
|
Error error // Any error encountered during check
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCommitHash gets the commit hash from build info or the Commit variable.
|
||||||
|
func resolveCommitHash() string {
|
||||||
|
if Commit != "" {
|
||||||
|
return Commit
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, setting := range info.Settings {
|
||||||
|
if setting.Key == "vcs.revision" && setting.Value != "" {
|
||||||
|
return setting.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortCommit returns first 12 characters of a hash.
|
||||||
|
func ShortCommit(hash string) string {
|
||||||
|
if len(hash) > 12 {
|
||||||
|
return hash[:12]
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitsMatch compares two commit hashes, handling different lengths.
|
||||||
|
// Returns true if one is a prefix of the other (minimum 7 chars to avoid false positives).
|
||||||
|
func commitsMatch(a, b string) bool {
|
||||||
|
minLen := len(a)
|
||||||
|
if len(b) < minLen {
|
||||||
|
minLen = len(b)
|
||||||
|
}
|
||||||
|
// Need at least 7 chars for a reasonable comparison
|
||||||
|
if minLen < 7 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(a, b[:minLen]) || strings.HasPrefix(b, a[:minLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckStaleBinary compares the binary's embedded commit with the repo HEAD.
|
||||||
|
// It returns staleness info including whether the binary needs rebuilding.
|
||||||
|
// This check is designed to be fast and non-blocking - errors are captured
|
||||||
|
// but don't interrupt normal operation.
|
||||||
|
func CheckStaleBinary(repoDir string) *StaleBinaryInfo {
|
||||||
|
info := &StaleBinaryInfo{}
|
||||||
|
|
||||||
|
// Get binary commit
|
||||||
|
info.BinaryCommit = resolveCommitHash()
|
||||||
|
if info.BinaryCommit == "" {
|
||||||
|
info.Error = fmt.Errorf("cannot determine binary commit (dev build?)")
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get repo HEAD
|
||||||
|
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
info.Error = fmt.Errorf("cannot get repo HEAD: %w", err)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.RepoCommit = strings.TrimSpace(string(output))
|
||||||
|
|
||||||
|
// Compare commits using prefix matching (handles short vs full hash)
|
||||||
|
// Use the shorter of the two commit lengths for comparison
|
||||||
|
if !commitsMatch(info.BinaryCommit, info.RepoCommit) {
|
||||||
|
info.IsStale = true
|
||||||
|
|
||||||
|
// Try to count commits between binary and HEAD
|
||||||
|
countCmd := exec.Command("git", "rev-list", "--count", info.BinaryCommit+"..HEAD")
|
||||||
|
countCmd.Dir = repoDir
|
||||||
|
if countOutput, err := countCmd.Output(); err == nil {
|
||||||
|
if count, parseErr := fmt.Sscanf(strings.TrimSpace(string(countOutput)), "%d", &info.CommitsBehind); parseErr != nil || count != 1 {
|
||||||
|
info.CommitsBehind = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoRoot returns the git repository root for the gt source code.
|
||||||
|
// It looks for the gastown repo by checking known paths.
|
||||||
|
func GetRepoRoot() (string, error) {
|
||||||
|
// First, check if GT_ROOT environment variable is set
|
||||||
|
if gtRoot := os.Getenv("GT_ROOT"); gtRoot != "" {
|
||||||
|
if isGitRepo(gtRoot) && hasGastownMarker(gtRoot) {
|
||||||
|
return gtRoot, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try common development paths relative to home
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home != "" {
|
||||||
|
candidates := []string{
|
||||||
|
home + "/gt/gastown",
|
||||||
|
home + "/gastown",
|
||||||
|
home + "/src/gastown",
|
||||||
|
home + "/dev/gastown",
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if isGitRepo(candidate) && hasGastownMarker(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current directory is in a gastown repo
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||||
|
if output, err := cmd.Output(); err == nil {
|
||||||
|
root := strings.TrimSpace(string(output))
|
||||||
|
if hasGastownMarker(root) {
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("cannot locate gt source repository")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGitRepo checks if a directory is a git repository.
|
||||||
|
func isGitRepo(dir string) bool {
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||||
|
cmd.Dir = dir
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasGastownMarker checks if a directory looks like the gastown repo.
|
||||||
|
func hasGastownMarker(dir string) bool {
|
||||||
|
// Check for cmd/gt directory which is unique to gastown
|
||||||
|
cmd := exec.Command("test", "-d", dir+"/cmd/gt")
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommit allows the cmd package to pass in the build-time commit.
|
||||||
|
func SetCommit(commit string) {
|
||||||
|
Commit = commit
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user