Files
gastown/internal/version/stale.go
slit be35b3eaab 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>
2026-01-09 18:14:17 -08:00

165 lines
4.6 KiB
Go

// 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
}