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:
@@ -7,10 +7,16 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// FormulaExt is the file extension for formula files.
|
||||
const FormulaExt = ".formula.json"
|
||||
// Formula file extensions. TOML is preferred, JSON is legacy fallback.
|
||||
const (
|
||||
FormulaExtTOML = ".formula.toml"
|
||||
FormulaExtJSON = ".formula.json"
|
||||
FormulaExt = FormulaExtJSON // Legacy alias for backwards compatibility
|
||||
)
|
||||
|
||||
// Parser handles loading and resolving formulas.
|
||||
//
|
||||
@@ -68,6 +74,7 @@ func defaultSearchPaths() []string {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Check cache first
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
@@ -122,6 +135,24 @@ func (p *Parser) Parse(data []byte) (*Formula, error) {
|
||||
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.
|
||||
// Returns a new formula with all inheritance applied.
|
||||
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.
|
||||
// Tries TOML first (.formula.toml), then falls back to JSON (.formula.json).
|
||||
func (p *Parser) loadFormula(name string) (*Formula, error) {
|
||||
// Check cache first
|
||||
if cached, ok := p.cache[name]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Search for the formula file
|
||||
filename := name + FormulaExt
|
||||
// Search for the formula file - try TOML first, then JSON
|
||||
extensions := []string{FormulaExtTOML, FormulaExtJSON}
|
||||
for _, dir := range p.searchPaths {
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return p.ParseFile(path)
|
||||
for _, ext := range extensions {
|
||||
path := filepath.Join(dir, name+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return p.ParseFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// GetGitDir returns the actual .git directory path for the current repository.
|
||||
@@ -52,25 +53,43 @@ func GetGitHeadPath() (string, error) {
|
||||
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.
|
||||
// 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 {
|
||||
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")
|
||||
if gitDir == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
commonDir := getGitDirNoError("--git-common-dir")
|
||||
if commonDir == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
absGit, err1 := filepath.Abs(gitDir)
|
||||
absCommon, err2 := filepath.Abs(commonDir)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return absGit != absCommon
|
||||
}
|
||||
|
||||
@@ -103,13 +122,28 @@ func GetMainRepoRoot() (string, error) {
|
||||
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.
|
||||
// Returns empty string if not in a git repository.
|
||||
//
|
||||
// This function is worktree-aware and handles Windows path normalization
|
||||
// (Git on Windows may return paths like /c/Users/... or C:/Users/...).
|
||||
// It also resolves symlinks to get the canonical path.
|
||||
// The result is cached after the first call.
|
||||
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")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user