Files
gastown/internal/formula/embed.go
julianknutsen 677a6ed84f 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>
2026-01-08 22:43:44 -08:00

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
}