Files
beads/internal/config/repos.go
Steve Yegge 8f8e9516df fix: bd repo commands write to YAML and cleanup on remove (#683)
- bd repo add/remove now writes to .beads/config.yaml instead of database
- bd repo remove deletes hydrated issues from the removed repo
- Added internal/config/repos.go for YAML config manipulation
- Added DeleteIssuesBySourceRepo for cleanup on remove

Fixes config disconnect where bd repo add wrote to DB but hydration read from YAML.

Breaking change: bd repo add no longer accepts optional alias argument.

Co-authored-by: Dylan Conlin <dylan.conlin@gmail.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 01:26:45 -08:00

270 lines
7.3 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// ReposConfig represents the repos section of config.yaml
type ReposConfig struct {
Primary string `yaml:"primary,omitempty"`
Additional []string `yaml:"additional,omitempty,flow"`
}
// configFile represents the structure for reading/writing config.yaml
// We use yaml.Node to preserve comments and formatting
type configFile struct {
root yaml.Node
}
// FindConfigYAMLPath finds the config.yaml file in .beads directory
// Walks up from CWD to find .beads/config.yaml
func FindConfigYAMLPath() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
beadsDir := filepath.Join(dir, ".beads")
configPath := filepath.Join(beadsDir, "config.yaml")
if _, err := os.Stat(configPath); err == nil {
return configPath, nil
}
}
return "", fmt.Errorf("no .beads/config.yaml found in current directory or parents")
}
// GetReposFromYAML reads the repos configuration from config.yaml
// Returns an empty ReposConfig if repos section doesn't exist
func GetReposFromYAML(configPath string) (*ReposConfig, error) {
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from caller
if err != nil {
if os.IsNotExist(err) {
return &ReposConfig{}, nil
}
return nil, fmt.Errorf("failed to read config.yaml: %w", err)
}
// Parse into a generic map to extract repos section
var cfg map[string]interface{}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config.yaml: %w", err)
}
repos := &ReposConfig{}
if reposRaw, ok := cfg["repos"]; ok && reposRaw != nil {
reposMap, ok := reposRaw.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("repos section is not a map")
}
if primary, ok := reposMap["primary"].(string); ok {
repos.Primary = primary
}
if additional, ok := reposMap["additional"]; ok && additional != nil {
switch v := additional.(type) {
case []interface{}:
for _, item := range v {
if str, ok := item.(string); ok {
repos.Additional = append(repos.Additional, str)
}
}
}
}
}
return repos, nil
}
// SetReposInYAML writes the repos configuration to config.yaml
// It preserves other config sections and comments where possible
func SetReposInYAML(configPath string, repos *ReposConfig) error {
// Read existing config or create new
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from caller
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read config.yaml: %w", err)
}
// Parse existing config into yaml.Node to preserve structure
var root yaml.Node
if len(data) > 0 {
if err := yaml.Unmarshal(data, &root); err != nil {
return fmt.Errorf("failed to parse config.yaml: %w", err)
}
}
// Handle empty or comment-only files by creating a valid document structure
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
root = yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{
{Kind: yaml.MappingNode},
},
}
}
// Get the mapping node (first content of document)
mapping := root.Content[0]
if mapping.Kind != yaml.MappingNode {
// If the document content isn't a mapping, replace it with one
root.Content[0] = &yaml.Node{Kind: yaml.MappingNode}
mapping = root.Content[0]
}
// Find or create repos section
reposIndex := -1
for i := 0; i < len(mapping.Content); i += 2 {
if mapping.Content[i].Value == "repos" {
reposIndex = i
break
}
}
// Build the repos node
reposNode := buildReposNode(repos)
if reposIndex >= 0 {
// Update existing repos section
if reposNode == nil {
// Remove repos section entirely if empty
mapping.Content = append(mapping.Content[:reposIndex], mapping.Content[reposIndex+2:]...)
} else {
mapping.Content[reposIndex+1] = reposNode
}
} else if reposNode != nil {
// Add new repos section at the end
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "repos"},
reposNode,
)
}
// Marshal back to YAML
var buf strings.Builder
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(&root); err != nil {
return fmt.Errorf("failed to encode config.yaml: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("failed to close encoder: %w", err)
}
// Write back to file
if err := os.WriteFile(configPath, []byte(buf.String()), 0600); err != nil {
return fmt.Errorf("failed to write config.yaml: %w", err)
}
// Reload viper config so changes take effect immediately
if v != nil {
if err := v.ReadInConfig(); err != nil {
// Not fatal - config is on disk, will be picked up on next command
_ = err
}
}
return nil
}
// buildReposNode creates a yaml.Node for the repos configuration
// Returns nil if repos is empty (no primary and no additional)
func buildReposNode(repos *ReposConfig) *yaml.Node {
if repos == nil || (repos.Primary == "" && len(repos.Additional) == 0) {
return nil
}
node := &yaml.Node{Kind: yaml.MappingNode}
if repos.Primary != "" {
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "primary"},
&yaml.Node{Kind: yaml.ScalarNode, Value: repos.Primary, Style: yaml.DoubleQuotedStyle},
)
}
if len(repos.Additional) > 0 {
additionalNode := &yaml.Node{Kind: yaml.SequenceNode}
for _, path := range repos.Additional {
additionalNode.Content = append(additionalNode.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: path, Style: yaml.DoubleQuotedStyle},
)
}
node.Content = append(node.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "additional"},
additionalNode,
)
}
return node
}
// AddRepo adds a repository to the repos.additional list in config.yaml
// If primary is not set, it defaults to "."
func AddRepo(configPath, repoPath string) error {
repos, err := GetReposFromYAML(configPath)
if err != nil {
return fmt.Errorf("failed to get repos config: %w", err)
}
// Set primary to "." if not already set (standard multi-repo convention)
if repos.Primary == "" {
repos.Primary = "."
}
// Check if repo already exists
for _, existing := range repos.Additional {
if existing == repoPath {
return fmt.Errorf("repository already configured: %s", repoPath)
}
}
// Add the new repo
repos.Additional = append(repos.Additional, repoPath)
return SetReposInYAML(configPath, repos)
}
// RemoveRepo removes a repository from the repos.additional list in config.yaml
func RemoveRepo(configPath, repoPath string) error {
repos, err := GetReposFromYAML(configPath)
if err != nil {
return fmt.Errorf("failed to get repos config: %w", err)
}
// Find and remove the repo
found := false
newAdditional := make([]string, 0, len(repos.Additional))
for _, existing := range repos.Additional {
if existing == repoPath {
found = true
continue
}
newAdditional = append(newAdditional, existing)
}
if !found {
return fmt.Errorf("repository not found: %s", repoPath)
}
repos.Additional = newAdditional
// If no repos left, clear primary too
if len(repos.Additional) == 0 {
repos.Primary = ""
}
return SetReposInYAML(configPath, repos)
}
// ListRepos returns the current repos configuration from YAML
func ListRepos(configPath string) (*ReposConfig, error) {
return GetReposFromYAML(configPath)
}