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>
335 lines
9.4 KiB
Go
335 lines
9.4 KiB
Go
package formula
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// Generate formulas directory from canonical source at .beads/formulas/
|
|
//go:generate sh -c "rm -rf formulas && mkdir -p formulas && cp ../../.beads/formulas/*.formula.toml formulas/"
|
|
|
|
//go:embed formulas/*.formula.toml
|
|
var formulasFS embed.FS
|
|
|
|
// InstalledRecord tracks which formulas were installed and their checksums.
|
|
// Stored in .beads/formulas/.installed.json
|
|
type InstalledRecord struct {
|
|
Formulas map[string]string `json:"formulas"` // filename -> sha256 at install time
|
|
}
|
|
|
|
// FormulaStatus represents the status of a single formula during health check.
|
|
type FormulaStatus struct {
|
|
Name string
|
|
Status string // "ok", "outdated", "modified", "missing", "new", "untracked"
|
|
EmbeddedHash string // hash computed from embedded content
|
|
InstalledHash string // hash we installed (from .installed.json)
|
|
CurrentHash string // hash of current file on disk
|
|
}
|
|
|
|
// HealthReport contains the results of checking formula health.
|
|
type HealthReport struct {
|
|
Formulas []FormulaStatus
|
|
// Counts
|
|
OK int
|
|
Outdated int // embedded changed, user hasn't modified
|
|
Modified int // user modified the file (tracked in .installed.json)
|
|
Missing int // file was deleted
|
|
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.
|
|
func computeHash(data []byte) string {
|
|
hash := sha256.Sum256(data)
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// getEmbeddedFormulas returns a map of filename -> sha256 for all embedded formulas.
|
|
func getEmbeddedFormulas() (map[string]string, error) {
|
|
entries, err := formulasFS.ReadDir("formulas")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading formulas directory: %w", err)
|
|
}
|
|
|
|
result := make(map[string]string)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
content, err := formulasFS.ReadFile("formulas/" + entry.Name())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
|
}
|
|
result[entry.Name()] = computeHash(content)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// loadInstalledRecord loads the installed record from disk.
|
|
func loadInstalledRecord(formulasDir string) (*InstalledRecord, error) {
|
|
path := filepath.Join(formulasDir, ".installed.json")
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return &InstalledRecord{Formulas: make(map[string]string)}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading installed record: %w", err)
|
|
}
|
|
var r InstalledRecord
|
|
if err := json.Unmarshal(data, &r); err != nil {
|
|
return nil, fmt.Errorf("parsing installed record: %w", err)
|
|
}
|
|
if r.Formulas == nil {
|
|
r.Formulas = make(map[string]string)
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// saveInstalledRecord saves the installed record to disk.
|
|
func saveInstalledRecord(formulasDir string, record *InstalledRecord) error {
|
|
path := filepath.Join(formulasDir, ".installed.json")
|
|
data, err := json.MarshalIndent(record, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding installed record: %w", err)
|
|
}
|
|
return os.WriteFile(path, data, 0644)
|
|
}
|
|
|
|
// computeFileHash computes SHA256 hash of a file.
|
|
func computeFileHash(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return computeHash(data), nil
|
|
}
|
|
|
|
// ProvisionFormulas creates the .beads/formulas/ directory with embedded formulas.
|
|
// This is called during gt install for fresh installations.
|
|
// If a formula already exists, it is skipped (no overwrite).
|
|
// Returns the number of formulas provisioned.
|
|
func ProvisionFormulas(beadsPath string) (int, error) {
|
|
embedded, err := getEmbeddedFormulas()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
entries, err := formulasFS.ReadDir("formulas")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("reading formulas directory: %w", err)
|
|
}
|
|
|
|
// Create .beads/formulas/ directory
|
|
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
|
|
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
|
return 0, fmt.Errorf("creating formulas directory: %w", err)
|
|
}
|
|
|
|
// Load existing installed record (or create new)
|
|
installed, err := loadInstalledRecord(formulasDir)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
count := 0
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
destPath := filepath.Join(formulasDir, entry.Name())
|
|
|
|
// Skip if formula already exists (don't overwrite user customizations)
|
|
if _, err := os.Stat(destPath); err == nil {
|
|
continue
|
|
} else if !os.IsNotExist(err) {
|
|
// Log unexpected errors but continue
|
|
continue
|
|
}
|
|
|
|
content, err := formulasFS.ReadFile("formulas/" + entry.Name())
|
|
if err != nil {
|
|
return count, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
|
return count, fmt.Errorf("writing %s: %w", entry.Name(), err)
|
|
}
|
|
|
|
// Record the hash we installed
|
|
if hash, ok := embedded[entry.Name()]; ok {
|
|
installed.Formulas[entry.Name()] = hash
|
|
}
|
|
count++
|
|
}
|
|
|
|
// Save updated installed record
|
|
if err := saveInstalledRecord(formulasDir, installed); err != nil {
|
|
return count, fmt.Errorf("saving installed record: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// CheckFormulaHealth checks the status of all formulas.
|
|
// Returns a report of which formulas are ok, outdated, modified, or missing.
|
|
func CheckFormulaHealth(beadsPath string) (*HealthReport, error) {
|
|
embedded, err := getEmbeddedFormulas()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
|
|
installed, err := loadInstalledRecord(formulasDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
report := &HealthReport{}
|
|
|
|
for filename, embeddedHash := range embedded {
|
|
status := FormulaStatus{
|
|
Name: filename,
|
|
EmbeddedHash: embeddedHash,
|
|
}
|
|
|
|
installedHash, wasInstalled := installed.Formulas[filename]
|
|
status.InstalledHash = installedHash
|
|
|
|
destPath := filepath.Join(formulasDir, filename)
|
|
currentHash, err := computeFileHash(destPath)
|
|
|
|
if os.IsNotExist(err) {
|
|
// File doesn't exist
|
|
if wasInstalled {
|
|
// We installed it before, user deleted it
|
|
status.Status = "missing"
|
|
report.Missing++
|
|
} else {
|
|
// New formula, never installed
|
|
status.Status = "new"
|
|
report.New++
|
|
}
|
|
} else if err != nil {
|
|
// Some other error reading file
|
|
status.Status = "error"
|
|
} else {
|
|
status.CurrentHash = currentHash
|
|
|
|
if currentHash == embeddedHash {
|
|
// File matches embedded - all good
|
|
status.Status = "ok"
|
|
report.OK++
|
|
} else if wasInstalled && currentHash == installedHash {
|
|
// File matches what we installed, but embedded has changed
|
|
// User hasn't modified, safe to update
|
|
status.Status = "outdated"
|
|
report.Outdated++
|
|
} else if wasInstalled {
|
|
// File was tracked and user modified it - don't overwrite
|
|
status.Status = "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++
|
|
}
|
|
}
|
|
|
|
report.Formulas = append(report.Formulas, status)
|
|
}
|
|
|
|
return report, nil
|
|
}
|
|
|
|
// UpdateFormulas updates formulas that are safe to update (outdated, missing, or untracked).
|
|
// Skips user-modified formulas (tracked files that user changed).
|
|
// Returns counts of updated, skipped (modified), and reinstalled (missing).
|
|
func UpdateFormulas(beadsPath string) (updated, skipped, reinstalled int, err error) {
|
|
embedded, err := getEmbeddedFormulas()
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
|
|
formulasDir := filepath.Join(beadsPath, ".beads", "formulas")
|
|
if err := os.MkdirAll(formulasDir, 0755); err != nil {
|
|
return 0, 0, 0, fmt.Errorf("creating formulas directory: %w", err)
|
|
}
|
|
|
|
installed, err := loadInstalledRecord(formulasDir)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
|
|
for filename, embeddedHash := range embedded {
|
|
installedHash, wasInstalled := installed.Formulas[filename]
|
|
destPath := filepath.Join(formulasDir, filename)
|
|
currentHash, fileErr := computeFileHash(destPath)
|
|
|
|
shouldInstall := false
|
|
isMissing := false
|
|
isModified := false
|
|
|
|
if os.IsNotExist(fileErr) {
|
|
// File doesn't exist - install it
|
|
shouldInstall = true
|
|
if wasInstalled {
|
|
isMissing = true
|
|
}
|
|
} else if fileErr != nil {
|
|
// Error reading file, skip
|
|
continue
|
|
} else if currentHash == embeddedHash {
|
|
// Already up to date
|
|
continue
|
|
} else if wasInstalled && currentHash == installedHash {
|
|
// User hasn't modified, safe to update
|
|
shouldInstall = true
|
|
} else if wasInstalled {
|
|
// Tracked file was modified by user - skip
|
|
isModified = true
|
|
} else {
|
|
// Untracked file (e.g., from older gt version) - safe to update
|
|
shouldInstall = true
|
|
}
|
|
|
|
if isModified {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
if shouldInstall {
|
|
content, err := formulasFS.ReadFile("formulas/" + filename)
|
|
if err != nil {
|
|
return updated, skipped, reinstalled, fmt.Errorf("reading %s: %w", filename, err)
|
|
}
|
|
|
|
if err := os.WriteFile(destPath, content, 0644); err != nil {
|
|
return updated, skipped, reinstalled, fmt.Errorf("writing %s: %w", filename, err)
|
|
}
|
|
|
|
// Update installed record
|
|
installed.Formulas[filename] = embeddedHash
|
|
|
|
if isMissing {
|
|
reinstalled++
|
|
} else {
|
|
updated++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save updated installed record
|
|
if err := saveInstalledRecord(formulasDir, installed); err != nil {
|
|
return updated, skipped, reinstalled, fmt.Errorf("saving installed record: %w", err)
|
|
}
|
|
|
|
return updated, skipped, reinstalled, nil
|
|
}
|