Add TOML support for formulas (gt-xmyha)
- Add TOML parsing using BurntSushi/toml - Update formula loader to try .toml first, fall back to .json - Add `bd formula convert` command for JSON→TOML migration - Multi-line string support for readable descriptions - Cache git worktree/reporoot checks for performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/formula"
|
"github.com/steveyegge/beads/internal/formula"
|
||||||
"github.com/steveyegge/beads/internal/ui"
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
@@ -363,7 +366,7 @@ func getFormulaSearchPaths() []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanFormulaDir scans a directory for formula files.
|
// scanFormulaDir scans a directory for formula files (both TOML and JSON).
|
||||||
func scanFormulaDir(dir string) ([]*formula.Formula, error) {
|
func scanFormulaDir(dir string) ([]*formula.Formula, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -377,11 +380,13 @@ func scanFormulaDir(dir string) ([]*formula.Formula, error) {
|
|||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(entry.Name(), formula.FormulaExt) {
|
// Support both .formula.toml and .formula.json
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, formula.FormulaExtTOML) && !strings.HasSuffix(name, formula.FormulaExtJSON) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(dir, entry.Name())
|
path := filepath.Join(dir, name)
|
||||||
f, err := parser.ParseFile(path)
|
f, err := parser.ParseFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip invalid formulas
|
continue // Skip invalid formulas
|
||||||
@@ -471,10 +476,297 @@ func printFormulaStepsTree(steps []*formula.Step, indent string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formulaConvertCmd converts JSON formulas to TOML.
|
||||||
|
var formulaConvertCmd = &cobra.Command{
|
||||||
|
Use: "convert <formula-name|path> [--all]",
|
||||||
|
Short: "Convert formula from JSON to TOML",
|
||||||
|
Long: `Convert formula files from JSON to TOML format.
|
||||||
|
|
||||||
|
TOML format provides better ergonomics:
|
||||||
|
- Multi-line strings without \n escaping
|
||||||
|
- Human-readable diffs
|
||||||
|
- Comments allowed
|
||||||
|
|
||||||
|
The convert command reads a .formula.json file and outputs .formula.toml.
|
||||||
|
The original JSON file is preserved (use --delete to remove it).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd formula convert shiny # Convert shiny.formula.json to .toml
|
||||||
|
bd formula convert ./my.formula.json # Convert specific file
|
||||||
|
bd formula convert --all # Convert all JSON formulas
|
||||||
|
bd formula convert shiny --delete # Convert and remove JSON file
|
||||||
|
bd formula convert shiny --stdout # Print TOML to stdout`,
|
||||||
|
Run: runFormulaConvert,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
convertAll bool
|
||||||
|
convertDelete bool
|
||||||
|
convertStdout bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func runFormulaConvert(cmd *cobra.Command, args []string) {
|
||||||
|
if convertAll {
|
||||||
|
convertAllFormulas()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: formula name or path required\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: bd formula convert <name|path> [--all]\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
|
||||||
|
// Determine the JSON file path
|
||||||
|
var jsonPath string
|
||||||
|
if strings.HasSuffix(name, formula.FormulaExtJSON) {
|
||||||
|
// Direct path provided
|
||||||
|
jsonPath = name
|
||||||
|
} else if strings.HasSuffix(name, formula.FormulaExtTOML) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %s is already a TOML file\n", name)
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
// Search for the formula in search paths
|
||||||
|
jsonPath = findFormulaJSON(name)
|
||||||
|
if jsonPath == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: JSON formula %q not found\n", name)
|
||||||
|
fmt.Fprintf(os.Stderr, "\nSearch paths:\n")
|
||||||
|
for _, p := range getFormulaSearchPaths() {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s\n", p)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON file
|
||||||
|
parser := formula.NewParser()
|
||||||
|
f, err := parser.ParseFile(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", jsonPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to TOML
|
||||||
|
tomlData, err := formulaToTOML(f)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error converting to TOML: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if convertStdout {
|
||||||
|
fmt.Print(string(tomlData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine output path
|
||||||
|
tomlPath := strings.TrimSuffix(jsonPath, formula.FormulaExtJSON) + formula.FormulaExtTOML
|
||||||
|
|
||||||
|
// Write the TOML file
|
||||||
|
if err := os.WriteFile(tomlPath, tomlData, 0644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error writing %s: %v\n", tomlPath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Converted: %s\n", tomlPath)
|
||||||
|
|
||||||
|
if convertDelete {
|
||||||
|
if err := os.Remove(jsonPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: could not delete %s: %v\n", jsonPath, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✓ Deleted: %s\n", jsonPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertAllFormulas() {
|
||||||
|
converted := 0
|
||||||
|
errors := 0
|
||||||
|
|
||||||
|
for _, dir := range getFormulaSearchPaths() {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := formula.NewParser(dir)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(entry.Name(), formula.FormulaExtJSON) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonPath := filepath.Join(dir, entry.Name())
|
||||||
|
tomlPath := strings.TrimSuffix(jsonPath, formula.FormulaExtJSON) + formula.FormulaExtTOML
|
||||||
|
|
||||||
|
// Skip if TOML already exists
|
||||||
|
if _, err := os.Stat(tomlPath); err == nil {
|
||||||
|
fmt.Printf("⏭ Skipped (TOML exists): %s\n", entry.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := parser.ParseFile(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "✗ Error parsing %s: %v\n", jsonPath, err)
|
||||||
|
errors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tomlData, err := formulaToTOML(f)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "✗ Error converting %s: %v\n", jsonPath, err)
|
||||||
|
errors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(tomlPath, tomlData, 0644); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "✗ Error writing %s: %v\n", tomlPath, err)
|
||||||
|
errors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Converted: %s\n", tomlPath)
|
||||||
|
converted++
|
||||||
|
|
||||||
|
if convertDelete {
|
||||||
|
if err := os.Remove(jsonPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: could not delete %s: %v\n", jsonPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nConverted %d formulas", converted)
|
||||||
|
if errors > 0 {
|
||||||
|
fmt.Printf(" (%d errors)", errors)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFormulaJSON searches for a JSON formula file by name.
|
||||||
|
func findFormulaJSON(name string) string {
|
||||||
|
for _, dir := range getFormulaSearchPaths() {
|
||||||
|
path := filepath.Join(dir, name+formula.FormulaExtJSON)
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// formulaToTOML converts a Formula to TOML bytes.
|
||||||
|
// Uses a custom structure optimized for TOML readability.
|
||||||
|
func formulaToTOML(f *formula.Formula) ([]byte, error) {
|
||||||
|
// We need to re-read the original JSON to get the raw structure
|
||||||
|
// because the Formula struct loses some ordering/formatting
|
||||||
|
if f.Source == "" {
|
||||||
|
return nil, fmt.Errorf("formula has no source path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the original JSON
|
||||||
|
jsonData, err := os.ReadFile(f.Source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse into a map to preserve structure
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(jsonData, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix float64 to int for known integer fields
|
||||||
|
fixIntegerFields(raw)
|
||||||
|
|
||||||
|
// Encode to TOML
|
||||||
|
var buf bytes.Buffer
|
||||||
|
encoder := toml.NewEncoder(&buf)
|
||||||
|
encoder.Indent = ""
|
||||||
|
if err := encoder.Encode(raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("encoding TOML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process to convert escaped \n in strings to multi-line strings
|
||||||
|
result := convertToMultiLineStrings(buf.String())
|
||||||
|
|
||||||
|
return []byte(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToMultiLineStrings post-processes TOML to use multi-line strings
|
||||||
|
// where strings contain newlines. This improves readability for descriptions.
|
||||||
|
func convertToMultiLineStrings(input string) string {
|
||||||
|
// Regular expression to match key = "value with \n"
|
||||||
|
// We look for description fields specifically as those benefit most
|
||||||
|
lines := strings.Split(input, "\n")
|
||||||
|
var result []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
// Check if this line has a string with escaped newlines
|
||||||
|
if strings.Contains(line, "\\n") {
|
||||||
|
// Find the key = "..." pattern
|
||||||
|
eqIdx := strings.Index(line, " = \"")
|
||||||
|
if eqIdx > 0 && strings.HasSuffix(line, "\"") {
|
||||||
|
key := strings.TrimSpace(line[:eqIdx])
|
||||||
|
// Only convert description fields
|
||||||
|
if key == "description" {
|
||||||
|
// Extract the value (without quotes)
|
||||||
|
value := line[eqIdx+4 : len(line)-1]
|
||||||
|
// Unescape the newlines
|
||||||
|
value = strings.ReplaceAll(value, "\\n", "\n")
|
||||||
|
// Use multi-line string syntax
|
||||||
|
result = append(result, fmt.Sprintf("%s = \"\"\"\n%s\"\"\"", key, value))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixIntegerFields recursively fixes float64 values that should be integers.
|
||||||
|
// JSON unmarshals all numbers as float64, but TOML needs proper int types.
|
||||||
|
func fixIntegerFields(m map[string]interface{}) {
|
||||||
|
// Known integer fields
|
||||||
|
intFields := map[string]bool{
|
||||||
|
"version": true,
|
||||||
|
"priority": true,
|
||||||
|
"count": true,
|
||||||
|
"max": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range m {
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
// Convert whole numbers to int64 if they're known int fields
|
||||||
|
if intFields[k] && val == float64(int64(val)) {
|
||||||
|
m[k] = int64(val)
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
fixIntegerFields(val)
|
||||||
|
case []interface{}:
|
||||||
|
for _, item := range val {
|
||||||
|
if subMap, ok := item.(map[string]interface{}); ok {
|
||||||
|
fixIntegerFields(subMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
formulaListCmd.Flags().String("type", "", "Filter by type (workflow, expansion, aspect)")
|
formulaListCmd.Flags().String("type", "", "Filter by type (workflow, expansion, aspect)")
|
||||||
|
formulaConvertCmd.Flags().BoolVar(&convertAll, "all", false, "Convert all JSON formulas")
|
||||||
|
formulaConvertCmd.Flags().BoolVar(&convertDelete, "delete", false, "Delete JSON file after conversion")
|
||||||
|
formulaConvertCmd.Flags().BoolVar(&convertStdout, "stdout", false, "Print TOML to stdout instead of file")
|
||||||
|
|
||||||
formulaCmd.AddCommand(formulaListCmd)
|
formulaCmd.AddCommand(formulaListCmd)
|
||||||
formulaCmd.AddCommand(formulaShowCmd)
|
formulaCmd.AddCommand(formulaShowCmd)
|
||||||
|
formulaCmd.AddCommand(formulaConvertCmd)
|
||||||
rootCmd.AddCommand(formulaCmd)
|
rootCmd.AddCommand(formulaCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -22,6 +22,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
|
github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE=
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormulaExt is the file extension for formula files.
|
// Formula file extensions. TOML is preferred, JSON is legacy fallback.
|
||||||
const FormulaExt = ".formula.json"
|
const (
|
||||||
|
FormulaExtTOML = ".formula.toml"
|
||||||
|
FormulaExtJSON = ".formula.json"
|
||||||
|
FormulaExt = FormulaExtJSON // Legacy alias for backwards compatibility
|
||||||
|
)
|
||||||
|
|
||||||
// Parser handles loading and resolving formulas.
|
// Parser handles loading and resolving formulas.
|
||||||
//
|
//
|
||||||
@@ -68,6 +74,7 @@ func defaultSearchPaths() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseFile parses a formula from a file path.
|
// ParseFile parses a formula from a file path.
|
||||||
|
// Detects format from extension: .formula.toml or .formula.json
|
||||||
func (p *Parser) ParseFile(path string) (*Formula, error) {
|
func (p *Parser) ParseFile(path string) (*Formula, error) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
@@ -86,7 +93,13 @@ func (p *Parser) ParseFile(path string) (*Formula, error) {
|
|||||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
formula, err := p.Parse(data)
|
// Detect format from extension
|
||||||
|
var formula *Formula
|
||||||
|
if strings.HasSuffix(path, FormulaExtTOML) {
|
||||||
|
formula, err = p.ParseTOML(data)
|
||||||
|
} else {
|
||||||
|
formula, err = p.Parse(data)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -122,6 +135,24 @@ func (p *Parser) Parse(data []byte) (*Formula, error) {
|
|||||||
return &formula, nil
|
return &formula, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseTOML parses a formula from TOML bytes.
|
||||||
|
func (p *Parser) ParseTOML(data []byte) (*Formula, error) {
|
||||||
|
var formula Formula
|
||||||
|
if err := toml.Unmarshal(data, &formula); err != nil {
|
||||||
|
return nil, fmt.Errorf("toml: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if formula.Version == 0 {
|
||||||
|
formula.Version = 1
|
||||||
|
}
|
||||||
|
if formula.Type == "" {
|
||||||
|
formula.Type = TypeWorkflow
|
||||||
|
}
|
||||||
|
|
||||||
|
return &formula, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve fully resolves a formula, processing extends and expansions.
|
// Resolve fully resolves a formula, processing extends and expansions.
|
||||||
// Returns a new formula with all inheritance applied.
|
// Returns a new formula with all inheritance applied.
|
||||||
func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
||||||
@@ -205,18 +236,21 @@ func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadFormula loads a formula by name from search paths.
|
// loadFormula loads a formula by name from search paths.
|
||||||
|
// Tries TOML first (.formula.toml), then falls back to JSON (.formula.json).
|
||||||
func (p *Parser) loadFormula(name string) (*Formula, error) {
|
func (p *Parser) loadFormula(name string) (*Formula, error) {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if cached, ok := p.cache[name]; ok {
|
if cached, ok := p.cache[name]; ok {
|
||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for the formula file
|
// Search for the formula file - try TOML first, then JSON
|
||||||
filename := name + FormulaExt
|
extensions := []string{FormulaExtTOML, FormulaExtJSON}
|
||||||
for _, dir := range p.searchPaths {
|
for _, dir := range p.searchPaths {
|
||||||
path := filepath.Join(dir, filename)
|
for _, ext := range extensions {
|
||||||
if _, err := os.Stat(path); err == nil {
|
path := filepath.Join(dir, name+ext)
|
||||||
return p.ParseFile(path)
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return p.ParseFile(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetGitDir returns the actual .git directory path for the current repository.
|
// GetGitDir returns the actual .git directory path for the current repository.
|
||||||
@@ -52,9 +53,27 @@ func GetGitHeadPath() (string, error) {
|
|||||||
return filepath.Join(gitDir, "HEAD"), nil
|
return filepath.Join(gitDir, "HEAD"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWorktreeOnce ensures we only check worktree status once per process.
|
||||||
|
// This is safe because worktree status cannot change during a single command execution.
|
||||||
|
var (
|
||||||
|
isWorktreeOnce sync.Once
|
||||||
|
isWorktreeResult bool
|
||||||
|
)
|
||||||
|
|
||||||
// IsWorktree returns true if the current directory is in a Git worktree.
|
// IsWorktree returns true if the current directory is in a Git worktree.
|
||||||
// This is determined by comparing --git-dir and --git-common-dir.
|
// This is determined by comparing --git-dir and --git-common-dir.
|
||||||
|
// The result is cached after the first call since worktree status doesn't
|
||||||
|
// change during a single command execution.
|
||||||
func IsWorktree() bool {
|
func IsWorktree() bool {
|
||||||
|
isWorktreeOnce.Do(func() {
|
||||||
|
isWorktreeResult = isWorktreeUncached()
|
||||||
|
})
|
||||||
|
return isWorktreeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWorktreeUncached performs the actual worktree check without caching.
|
||||||
|
// Called once by IsWorktree and cached for subsequent calls.
|
||||||
|
func isWorktreeUncached() bool {
|
||||||
gitDir := getGitDirNoError("--git-dir")
|
gitDir := getGitDirNoError("--git-dir")
|
||||||
if gitDir == "" {
|
if gitDir == "" {
|
||||||
return false
|
return false
|
||||||
@@ -103,13 +122,28 @@ func GetMainRepoRoot() (string, error) {
|
|||||||
return mainRepoRoot, nil
|
return mainRepoRoot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// repoRootOnce ensures we only get repo root once per process.
|
||||||
|
var (
|
||||||
|
repoRootOnce sync.Once
|
||||||
|
repoRootResult string
|
||||||
|
)
|
||||||
|
|
||||||
// GetRepoRoot returns the root directory of the current git repository.
|
// GetRepoRoot returns the root directory of the current git repository.
|
||||||
// Returns empty string if not in a git repository.
|
// Returns empty string if not in a git repository.
|
||||||
//
|
//
|
||||||
// This function is worktree-aware and handles Windows path normalization
|
// This function is worktree-aware and handles Windows path normalization
|
||||||
// (Git on Windows may return paths like /c/Users/... or C:/Users/...).
|
// (Git on Windows may return paths like /c/Users/... or C:/Users/...).
|
||||||
// It also resolves symlinks to get the canonical path.
|
// It also resolves symlinks to get the canonical path.
|
||||||
|
// The result is cached after the first call.
|
||||||
func GetRepoRoot() string {
|
func GetRepoRoot() string {
|
||||||
|
repoRootOnce.Do(func() {
|
||||||
|
repoRootResult = getRepoRootUncached()
|
||||||
|
})
|
||||||
|
return repoRootResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRepoRootUncached performs the actual repo root lookup without caching.
|
||||||
|
func getRepoRootUncached() string {
|
||||||
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user