feat(formula): add untracked status for formulas without .installed.json

When upgrading gt on an existing installation without .installed.json,
formulas that exist but don't match embedded were incorrectly marked as
"modified" (implying user customization). Now they're marked "untracked"
and are safe to update since there's no record of user modification.

This improves the upgrade experience:
- "modified" = tracked file user changed (skip update)
- "untracked" = file exists but not tracked (safe to update)

Adds 3 new tests for untracked scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-07 23:45:22 -08:00
committed by Steve Yegge
parent da2d71c3fe
commit 677a6ed84f
3 changed files with 242 additions and 13 deletions

View File

@@ -38,7 +38,7 @@ func (c *FormulaCheck) Run(ctx *CheckContext) *CheckResult {
} }
// All good // All good
if report.Outdated == 0 && report.Missing == 0 && report.Modified == 0 && report.New == 0 { if report.Outdated == 0 && report.Missing == 0 && report.Modified == 0 && report.New == 0 && report.Untracked == 0 {
return &CheckResult{ return &CheckResult{
Name: c.Name(), Name: c.Name(),
Status: StatusOK, Status: StatusOK,
@@ -63,6 +63,9 @@ func (c *FormulaCheck) Run(ctx *CheckContext) *CheckResult {
case "new": case "new":
details = append(details, fmt.Sprintf(" %s: new formula available", f.Name)) details = append(details, fmt.Sprintf(" %s: new formula available", f.Name))
needsFix = true needsFix = true
case "untracked":
details = append(details, fmt.Sprintf(" %s: untracked (will update)", f.Name))
needsFix = true
} }
} }
@@ -83,6 +86,9 @@ func (c *FormulaCheck) Run(ctx *CheckContext) *CheckResult {
if report.New > 0 { if report.New > 0 {
parts = append(parts, fmt.Sprintf("%d new", report.New)) parts = append(parts, fmt.Sprintf("%d new", report.New))
} }
if report.Untracked > 0 {
parts = append(parts, fmt.Sprintf("%d untracked", report.Untracked))
}
if report.Modified > 0 { if report.Modified > 0 {
parts = append(parts, fmt.Sprintf("%d modified", report.Modified)) parts = append(parts, fmt.Sprintf("%d modified", report.Modified))
} }

View File

@@ -25,7 +25,7 @@ type InstalledRecord struct {
// FormulaStatus represents the status of a single formula during health check. // FormulaStatus represents the status of a single formula during health check.
type FormulaStatus struct { type FormulaStatus struct {
Name string Name string
Status string // "ok", "outdated", "modified", "missing", "new" Status string // "ok", "outdated", "modified", "missing", "new", "untracked"
EmbeddedHash string // hash computed from embedded content EmbeddedHash string // hash computed from embedded content
InstalledHash string // hash we installed (from .installed.json) InstalledHash string // hash we installed (from .installed.json)
CurrentHash string // hash of current file on disk CurrentHash string // hash of current file on disk
@@ -35,11 +35,12 @@ type FormulaStatus struct {
type HealthReport struct { type HealthReport struct {
Formulas []FormulaStatus Formulas []FormulaStatus
// Counts // Counts
OK int OK int
Outdated int // embedded changed, user hasn't modified Outdated int // embedded changed, user hasn't modified
Modified int // user modified the file Modified int // user modified the file (tracked in .installed.json)
Missing int // file was deleted Missing int // file was deleted
New int // new formula not yet installed New int // new formula not yet installed
Untracked int // file exists but not in .installed.json (safe to update)
} }
// computeHash computes SHA256 hash of data. // computeHash computes SHA256 hash of data.
@@ -229,10 +230,15 @@ func CheckFormulaHealth(beadsPath string) (*HealthReport, error) {
// User hasn't modified, safe to update // User hasn't modified, safe to update
status.Status = "outdated" status.Status = "outdated"
report.Outdated++ report.Outdated++
} else { } else if wasInstalled {
// File differs from what we installed - user modified // File was tracked and user modified it - don't overwrite
status.Status = "modified" status.Status = "modified"
report.Modified++ report.Modified++
} else {
// File exists but not tracked (e.g., from older gt version)
// Safe to update since we have no record of user modification
status.Status = "untracked"
report.Untracked++
} }
} }
@@ -242,8 +248,8 @@ func CheckFormulaHealth(beadsPath string) (*HealthReport, error) {
return report, nil return report, nil
} }
// UpdateFormulas updates formulas that are safe to update (outdated or missing). // UpdateFormulas updates formulas that are safe to update (outdated, missing, or untracked).
// Skips user-modified formulas. // Skips user-modified formulas (tracked files that user changed).
// Returns counts of updated, skipped (modified), and reinstalled (missing). // Returns counts of updated, skipped (modified), and reinstalled (missing).
func UpdateFormulas(beadsPath string) (updated, skipped, reinstalled int, err error) { func UpdateFormulas(beadsPath string) (updated, skipped, reinstalled int, err error) {
embedded, err := getEmbeddedFormulas() embedded, err := getEmbeddedFormulas()
@@ -285,9 +291,12 @@ func UpdateFormulas(beadsPath string) (updated, skipped, reinstalled int, err er
} else if wasInstalled && currentHash == installedHash { } else if wasInstalled && currentHash == installedHash {
// User hasn't modified, safe to update // User hasn't modified, safe to update
shouldInstall = true shouldInstall = true
} else { } else if wasInstalled {
// User modified - skip // Tracked file was modified by user - skip
isModified = true isModified = true
} else {
// Untracked file (e.g., from older gt version) - safe to update
shouldInstall = true
} }
if isModified { if isModified {

View File

@@ -532,3 +532,217 @@ func TestCheckFormulaHealth_NewFormula(t *testing.T) {
t.Errorf("OK = %d, want 0", report.OK) t.Errorf("OK = %d, want 0", report.OK)
} }
} }
// TestCheckFormulaHealth_Untracked tests detection of files that exist but aren't
// in .installed.json and don't match embedded (e.g., from older gt version).
func TestCheckFormulaHealth_Untracked(t *testing.T) {
tmpDir := t.TempDir()
// Get embedded formulas
embedded, err := getEmbeddedFormulas()
if err != nil {
t.Fatal(err)
}
// Create formulas directory without .installed.json
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
if err := os.MkdirAll(formulasDir, 0755); err != nil {
t.Fatal(err)
}
// Write formula files with different content (simulating older version)
for name := range embedded {
oldContent := []byte("# old version of " + name + "\n[molecule]\nid = \"test\"\n")
if err := os.WriteFile(filepath.Join(formulasDir, name), oldContent, 0644); err != nil {
t.Fatal(err)
}
}
// Check health - all should be "untracked" (not "modified" since not tracked)
report, err := CheckFormulaHealth(tmpDir)
if err != nil {
t.Fatalf("CheckFormulaHealth() error: %v", err)
}
if report.Untracked != len(embedded) {
t.Errorf("Untracked = %d, want %d", report.Untracked, len(embedded))
}
if report.Modified != 0 {
t.Errorf("Modified = %d, want 0 (untracked files shouldn't be marked as modified)", report.Modified)
}
if report.OK != 0 {
t.Errorf("OK = %d, want 0", report.OK)
}
// Verify all formulas have status "untracked"
for _, f := range report.Formulas {
if f.Status != "untracked" {
t.Errorf("formula %s status = %q, want %q", f.Name, f.Status, "untracked")
}
}
}
// TestUpdateFormulas_UpdatesUntracked tests that untracked files get updated.
func TestUpdateFormulas_UpdatesUntracked(t *testing.T) {
tmpDir := t.TempDir()
// Get embedded formulas
embedded, err := getEmbeddedFormulas()
if err != nil {
t.Fatal(err)
}
// Create formulas directory without .installed.json
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
if err := os.MkdirAll(formulasDir, 0755); err != nil {
t.Fatal(err)
}
// Write formula files with different content (simulating older version)
for name := range embedded {
oldContent := []byte("# old version of " + name + "\n[molecule]\nid = \"test\"\n")
if err := os.WriteFile(filepath.Join(formulasDir, name), oldContent, 0644); err != nil {
t.Fatal(err)
}
}
// Run update - should update all untracked formulas
updated, skipped, reinstalled, err := UpdateFormulas(tmpDir)
if err != nil {
t.Fatalf("UpdateFormulas() error: %v", err)
}
// All untracked files should be updated (counted as "updated", not "reinstalled")
if updated != len(embedded) {
t.Errorf("updated = %d, want %d", updated, len(embedded))
}
if skipped != 0 {
t.Errorf("skipped = %d, want 0", skipped)
}
if reinstalled != 0 {
t.Errorf("reinstalled = %d, want 0", reinstalled)
}
// Verify files now match embedded
for name, expectedHash := range embedded {
content, err := os.ReadFile(filepath.Join(formulasDir, name))
if err != nil {
t.Fatalf("reading %s: %v", name, err)
}
actualHash := computeHash(content)
if actualHash != expectedHash {
t.Errorf("%s hash mismatch after update", name)
}
}
// Verify .installed.json was created with correct hashes
installed, err := loadInstalledRecord(formulasDir)
if err != nil {
t.Fatal(err)
}
for name, expectedHash := range embedded {
if installed.Formulas[name] != expectedHash {
t.Errorf(".installed.json hash for %s = %q, want %q",
name, installed.Formulas[name], expectedHash)
}
}
// Re-run health check - should be all OK now
report, err := CheckFormulaHealth(tmpDir)
if err != nil {
t.Fatal(err)
}
if report.OK != len(embedded) {
t.Errorf("after update, OK = %d, want %d", report.OK, len(embedded))
}
if report.Untracked != 0 {
t.Errorf("after update, Untracked = %d, want 0", report.Untracked)
}
}
// TestCheckFormulaHealth_MixedScenarios tests a mix of OK, untracked, and modified.
func TestCheckFormulaHealth_MixedScenarios(t *testing.T) {
tmpDir := t.TempDir()
// Get embedded formulas
embedded, err := getEmbeddedFormulas()
if err != nil {
t.Fatal(err)
}
if len(embedded) < 3 {
t.Skip("need at least 3 formulas for this test")
}
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
if err := os.MkdirAll(formulasDir, 0755); err != nil {
t.Fatal(err)
}
// Prepare installed record with only some formulas tracked
installed := &InstalledRecord{Formulas: make(map[string]string)}
i := 0
var okFormula, untrackedFormula, modifiedFormula string
for name := range embedded {
switch i {
case 0:
// First formula: write matching content, track it -> should be OK
okFormula = name
content, _ := formulasFS.ReadFile("formulas/" + name)
if err := os.WriteFile(filepath.Join(formulasDir, name), content, 0644); err != nil {
t.Fatal(err)
}
installed.Formulas[name] = computeHash(content)
case 1:
// Second formula: write old content, don't track -> should be untracked
untrackedFormula = name
oldContent := []byte("# untracked old version\n[molecule]\nid = \"test\"\n")
if err := os.WriteFile(filepath.Join(formulasDir, name), oldContent, 0644); err != nil {
t.Fatal(err)
}
// Don't add to installed record
case 2:
// Third formula: write different content, track with original hash -> should be modified
modifiedFormula = name
originalContent, _ := formulasFS.ReadFile("formulas/" + name)
originalHash := computeHash(originalContent)
modifiedContent := []byte("# user modified version\n[molecule]\nid = \"custom\"\n")
if err := os.WriteFile(filepath.Join(formulasDir, name), modifiedContent, 0644); err != nil {
t.Fatal(err)
}
installed.Formulas[name] = originalHash // Track with original hash
}
i++
if i >= 3 {
break
}
}
if err := saveInstalledRecord(formulasDir, installed); err != nil {
t.Fatal(err)
}
// Check health
report, err := CheckFormulaHealth(tmpDir)
if err != nil {
t.Fatal(err)
}
// Find status of each test formula
statusMap := make(map[string]string)
for _, f := range report.Formulas {
statusMap[f.Name] = f.Status
}
if statusMap[okFormula] != "ok" {
t.Errorf("formula %s status = %q, want %q", okFormula, statusMap[okFormula], "ok")
}
if statusMap[untrackedFormula] != "untracked" {
t.Errorf("formula %s status = %q, want %q", untrackedFormula, statusMap[untrackedFormula], "untracked")
}
if statusMap[modifiedFormula] != "modified" {
t.Errorf("formula %s status = %q, want %q", modifiedFormula, statusMap[modifiedFormula], "modified")
}
}