fix: store version in gitignored .local_version to prevent notification spam (bd-tok)

Root cause: metadata.json is tracked in git and contains last_bd_version.
When git operations (pull, checkout, merge) reset metadata.json to the
committed version, the upgrade notification would fire repeatedly.

Fix: Store the last used bd version in .beads/.local_version which is
gitignored, so git operations don't affect version tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-02 23:38:20 -08:00
parent 97cfe30bd7
commit dd8c7595ba
4 changed files with 79 additions and 47 deletions

4
.beads/.gitignore vendored
View File

@@ -11,6 +11,10 @@ daemon.log
daemon.pid daemon.pid
bd.sock bd.sock
# Local version tracking (prevents upgrade notification spam after git operations)
# bd-tok: Store version locally instead of in tracked metadata.json
.local_version
# Legacy database files # Legacy database files
db.sqlite db.sqlite
bd.db bd.db

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/beads"
@@ -12,11 +13,19 @@ import (
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
) )
// trackBdVersion checks if bd version has changed since last run and updates metadata.json. // localVersionFile is the gitignored file that stores the last bd version used locally.
// This prevents the upgrade notification from firing repeatedly when git operations
// reset the tracked metadata.json file.
//
// bd-tok: Fix upgrade notification persisting after git operations
const localVersionFile = ".local_version"
// trackBdVersion checks if bd version has changed since last run and updates the local version file.
// This function is best-effort - failures are silent to avoid disrupting commands. // This function is best-effort - failures are silent to avoid disrupting commands.
// Sets global variables versionUpgradeDetected and previousVersion if upgrade detected. // Sets global variables versionUpgradeDetected and previousVersion if upgrade detected.
// //
// bd-loka: Built-in version tracking for upgrade awareness // bd-loka: Built-in version tracking for upgrade awareness
// bd-tok: Use gitignored .local_version file instead of metadata.json
func trackBdVersion() { func trackBdVersion() {
// Find the beads directory // Find the beads directory
beadsDir := beads.FindBeadsDir() beadsDir := beads.FindBeadsDir()
@@ -25,16 +34,32 @@ func trackBdVersion() {
return return
} }
// Load current config // Read last version from local (gitignored) file
localVersionPath := filepath.Join(beadsDir, localVersionFile)
lastVersion := readLocalVersion(localVersionPath)
// Check if version changed
if lastVersion != "" && lastVersion != Version {
// Version upgrade detected!
versionUpgradeDetected = true
previousVersion = lastVersion
}
// Update local version file (best effort)
// Only write if version actually changed to minimize I/O
if lastVersion != Version {
_ = writeLocalVersion(localVersionPath, Version)
}
// Also ensure metadata.json exists with proper defaults (for JSONL export name)
// but don't use it for version tracking anymore
cfg, err := configfile.Load(beadsDir) cfg, err := configfile.Load(beadsDir)
if err != nil { if err != nil {
// Silent failure - config might not exist yet
return return
} }
if cfg == nil { if cfg == nil {
// No config file yet - create one with current version // No config file yet - create one
cfg = configfile.DefaultConfig() cfg = configfile.DefaultConfig()
cfg.LastBdVersion = Version
// bd-afd: Auto-detect actual JSONL file instead of using hardcoded default // bd-afd: Auto-detect actual JSONL file instead of using hardcoded default
// This prevents mismatches when metadata.json gets deleted (git clean, merge conflict, etc.) // This prevents mismatches when metadata.json gets deleted (git clean, merge conflict, etc.)
@@ -43,23 +68,23 @@ func trackBdVersion() {
} }
_ = cfg.Save(beadsDir) // Best effort _ = cfg.Save(beadsDir) // Best effort
return
} }
}
// Check if version changed // readLocalVersion reads the last bd version from the local version file.
if cfg.LastBdVersion != "" && cfg.LastBdVersion != Version { // Returns empty string if file doesn't exist or can't be read.
// Version upgrade detected! func readLocalVersion(path string) string {
versionUpgradeDetected = true // #nosec G304 - path is constructed from beadsDir + constant
previousVersion = cfg.LastBdVersion data, err := os.ReadFile(path)
if err != nil {
return ""
} }
return strings.TrimSpace(string(data))
}
// Update metadata.json with current version (best effort) // writeLocalVersion writes the current version to the local version file.
// Only write if version actually changed to minimize I/O func writeLocalVersion(path, version string) error {
// Also update on first run (when LastBdVersion is empty) to initialize tracking return os.WriteFile(path, []byte(version+"\n"), 0600)
if cfg.LastBdVersion != Version {
cfg.LastBdVersion = Version
_ = cfg.Save(beadsDir) // Silent failure is fine
}
} }
// getVersionsSince returns all version changes since the given version. // getVersionsSince returns all version changes since the given version.

View File

@@ -6,7 +6,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
) )
@@ -156,7 +155,7 @@ func TestTrackBdVersion_FirstRun(t *testing.T) {
versionUpgradeDetected = false versionUpgradeDetected = false
previousVersion = "" previousVersion = ""
// trackBdVersion should create metadata.json // trackBdVersion should create .local_version
trackBdVersion() trackBdVersion()
// Should not detect upgrade on first run // Should not detect upgrade on first run
@@ -164,13 +163,11 @@ func TestTrackBdVersion_FirstRun(t *testing.T) {
t.Error("Expected no upgrade detection on first run") t.Error("Expected no upgrade detection on first run")
} }
// Should have created metadata.json with current version // Should have created .local_version with current version
cfg, err := configfile.Load(beadsDir) localVersionPath := filepath.Join(beadsDir, localVersionFile)
if err != nil { localVersion := readLocalVersion(localVersionPath)
t.Fatalf("Failed to load config after tracking: %v", err) if localVersion != Version {
} t.Errorf(".local_version = %q, want %q", localVersion, Version)
if cfg.LastBdVersion != Version {
t.Errorf("LastBdVersion = %q, want %q", cfg.LastBdVersion, Version)
} }
} }
@@ -189,11 +186,16 @@ func TestTrackBdVersion_UpgradeDetection(t *testing.T) {
t.Fatalf("Failed to change to temp dir: %v", err) t.Fatalf("Failed to change to temp dir: %v", err)
} }
// Create metadata.json with old version // Create minimal metadata.json so FindBeadsDir can find the directory (bd-420)
cfg := configfile.DefaultConfig() metadataPath := filepath.Join(beadsDir, "metadata.json")
cfg.LastBdVersion = "0.22.0" if err := os.WriteFile(metadataPath, []byte(`{"database":"beads.db"}`), 0600); err != nil {
if err := cfg.Save(beadsDir); err != nil { t.Fatalf("Failed to create metadata.json: %v", err)
t.Fatalf("Failed to save config: %v", err) }
// Create .local_version with old version (simulating previous bd run)
localVersionPath := filepath.Join(beadsDir, localVersionFile)
if err := writeLocalVersion(localVersionPath, "0.22.0"); err != nil {
t.Fatalf("Failed to write local version: %v", err)
} }
// Save original state // Save original state
@@ -220,13 +222,10 @@ func TestTrackBdVersion_UpgradeDetection(t *testing.T) {
t.Errorf("previousVersion = %q, want %q", previousVersion, "0.22.0") t.Errorf("previousVersion = %q, want %q", previousVersion, "0.22.0")
} }
// Should have updated metadata.json to current version // Should have updated .local_version to current version
cfg, err := configfile.Load(beadsDir) localVersion := readLocalVersion(localVersionPath)
if err != nil { if localVersion != Version {
t.Fatalf("Failed to load config after tracking: %v", err) t.Errorf(".local_version = %q, want %q", localVersion, Version)
}
if cfg.LastBdVersion != Version {
t.Errorf("LastBdVersion = %q, want %q", cfg.LastBdVersion, Version)
} }
} }
@@ -245,11 +244,10 @@ func TestTrackBdVersion_SameVersion(t *testing.T) {
t.Fatalf("Failed to change to temp dir: %v", err) t.Fatalf("Failed to change to temp dir: %v", err)
} }
// Create metadata.json with current version // Create .local_version with current version
cfg := configfile.DefaultConfig() localVersionPath := filepath.Join(beadsDir, localVersionFile)
cfg.LastBdVersion = Version if err := writeLocalVersion(localVersionPath, Version); err != nil {
if err := cfg.Save(beadsDir); err != nil { t.Fatalf("Failed to write local version: %v", err)
t.Fatalf("Failed to save config: %v", err)
} }
// Save original state // Save original state

View File

@@ -10,12 +10,17 @@ import (
const ConfigFileName = "metadata.json" const ConfigFileName = "metadata.json"
type Config struct { type Config struct {
Database string `json:"database"` Database string `json:"database"`
JSONLExport string `json:"jsonl_export,omitempty"` JSONLExport string `json:"jsonl_export,omitempty"`
LastBdVersion string `json:"last_bd_version,omitempty"`
// Deletions configuration // Deletions configuration
DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (7 days) DeletionsRetentionDays int `json:"deletions_retention_days,omitempty"` // 0 means use default (7 days)
// Deprecated: LastBdVersion is no longer used for version tracking.
// Version is now stored in .local_version (gitignored) to prevent
// upgrade notifications firing after git operations reset metadata.json.
// bd-tok: This field is kept for backwards compatibility when reading old configs.
LastBdVersion string `json:"last_bd_version,omitempty"`
} }
func DefaultConfig() *Config { func DefaultConfig() *Config {