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>
This commit is contained in:
269
internal/config/repos.go
Normal file
269
internal/config/repos.go
Normal file
@@ -0,0 +1,269 @@
|
||||
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)
|
||||
}
|
||||
286
internal/config/repos_test.go
Normal file
286
internal/config/repos_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetReposFromYAML_Empty(t *testing.T) {
|
||||
// Create temp dir with empty config.yaml
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("# empty config\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos, err := GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReposFromYAML failed: %v", err)
|
||||
}
|
||||
|
||||
if repos.Primary != "" {
|
||||
t.Errorf("expected empty primary, got %q", repos.Primary)
|
||||
}
|
||||
if len(repos.Additional) != 0 {
|
||||
t.Errorf("expected empty additional, got %v", repos.Additional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReposFromYAML_WithRepos(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
config := `repos:
|
||||
primary: "."
|
||||
additional:
|
||||
- ~/beads-planning
|
||||
- /path/to/other
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos, err := GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReposFromYAML failed: %v", err)
|
||||
}
|
||||
|
||||
if repos.Primary != "." {
|
||||
t.Errorf("expected primary='.', got %q", repos.Primary)
|
||||
}
|
||||
if len(repos.Additional) != 2 {
|
||||
t.Fatalf("expected 2 additional repos, got %d", len(repos.Additional))
|
||||
}
|
||||
if repos.Additional[0] != "~/beads-planning" {
|
||||
t.Errorf("expected first additional='~/beads-planning', got %q", repos.Additional[0])
|
||||
}
|
||||
if repos.Additional[1] != "/path/to/other" {
|
||||
t.Errorf("expected second additional='/path/to/other', got %q", repos.Additional[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetReposInYAML_NewFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
repos := &ReposConfig{
|
||||
Primary: ".",
|
||||
Additional: []string{"~/test-repo"},
|
||||
}
|
||||
|
||||
if err := SetReposInYAML(configPath, repos); err != nil {
|
||||
t.Fatalf("SetReposInYAML failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify by reading back
|
||||
readRepos, err := GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReposFromYAML failed: %v", err)
|
||||
}
|
||||
|
||||
if readRepos.Primary != "." {
|
||||
t.Errorf("expected primary='.', got %q", readRepos.Primary)
|
||||
}
|
||||
if len(readRepos.Additional) != 1 || readRepos.Additional[0] != "~/test-repo" {
|
||||
t.Errorf("expected additional=['~/test-repo'], got %v", readRepos.Additional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetReposInYAML_PreservesOtherConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
// Write initial config with other settings
|
||||
initial := `issue-prefix: "test"
|
||||
sync-branch: "beads-sync"
|
||||
json: false
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(initial), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add repos
|
||||
repos := &ReposConfig{
|
||||
Primary: ".",
|
||||
Additional: []string{"~/test-repo"},
|
||||
}
|
||||
if err := SetReposInYAML(configPath, repos); err != nil {
|
||||
t.Fatalf("SetReposInYAML failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify content still has other settings
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
// Check that original settings are preserved
|
||||
if !contains(content, "issue-prefix") {
|
||||
t.Error("issue-prefix setting was lost")
|
||||
}
|
||||
if !contains(content, "sync-branch") {
|
||||
t.Error("sync-branch setting was lost")
|
||||
}
|
||||
if !contains(content, "json") {
|
||||
t.Error("json setting was lost")
|
||||
}
|
||||
|
||||
// Check that repos section was added
|
||||
if !contains(content, "repos:") {
|
||||
t.Error("repos section not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRepo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("# config\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add first repo
|
||||
if err := AddRepo(configPath, "~/first-repo"); err != nil {
|
||||
t.Fatalf("AddRepo failed: %v", err)
|
||||
}
|
||||
|
||||
repos, err := GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repos.Primary != "." {
|
||||
t.Errorf("expected primary='.', got %q", repos.Primary)
|
||||
}
|
||||
if len(repos.Additional) != 1 || repos.Additional[0] != "~/first-repo" {
|
||||
t.Errorf("unexpected additional: %v", repos.Additional)
|
||||
}
|
||||
|
||||
// Add second repo
|
||||
if err := AddRepo(configPath, "/path/to/second"); err != nil {
|
||||
t.Fatalf("AddRepo failed: %v", err)
|
||||
}
|
||||
|
||||
repos, err = GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repos.Additional) != 2 {
|
||||
t.Fatalf("expected 2 additional repos, got %d", len(repos.Additional))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRepo_Duplicate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("# config\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add repo
|
||||
if err := AddRepo(configPath, "~/test-repo"); err != nil {
|
||||
t.Fatalf("AddRepo failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to add same repo again - should fail
|
||||
err := AddRepo(configPath, "~/test-repo")
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRepo(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
config := `repos:
|
||||
primary: "."
|
||||
additional:
|
||||
- ~/first
|
||||
- ~/second
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(config), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove first repo
|
||||
if err := RemoveRepo(configPath, "~/first"); err != nil {
|
||||
t.Fatalf("RemoveRepo failed: %v", err)
|
||||
}
|
||||
|
||||
repos, err := GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repos.Additional) != 1 || repos.Additional[0] != "~/second" {
|
||||
t.Errorf("unexpected additional after remove: %v", repos.Additional)
|
||||
}
|
||||
|
||||
// Remove last repo - should clear primary too
|
||||
if err := RemoveRepo(configPath, "~/second"); err != nil {
|
||||
t.Fatalf("RemoveRepo failed: %v", err)
|
||||
}
|
||||
|
||||
repos, err = GetReposFromYAML(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repos.Primary != "" {
|
||||
t.Errorf("expected empty primary after removing all repos, got %q", repos.Primary)
|
||||
}
|
||||
if len(repos.Additional) != 0 {
|
||||
t.Errorf("expected empty additional after removing all repos, got %v", repos.Additional)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRepo_NotFound(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("# config\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := RemoveRepo(configPath, "~/nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent repo, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigYAMLPath(t *testing.T) {
|
||||
// Create temp dir with .beads/config.yaml
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configPath := filepath.Join(beadsDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("# config\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Change to the temp dir
|
||||
oldWd, _ := os.Getwd()
|
||||
defer func() {
|
||||
if err := os.Chdir(oldWd); err != nil {
|
||||
t.Logf("warning: failed to restore working directory: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := FindConfigYAMLPath()
|
||||
if err != nil {
|
||||
t.Fatalf("FindConfigYAMLPath failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify path ends with .beads/config.yaml
|
||||
if filepath.Base(found) != "config.yaml" {
|
||||
t.Errorf("expected path ending with config.yaml, got %s", found)
|
||||
}
|
||||
if filepath.Base(filepath.Dir(found)) != ".beads" {
|
||||
t.Errorf("expected path in .beads dir, got %s", found)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && (s[0:len(substr)] == substr || contains(s[1:], substr)))
|
||||
}
|
||||
Reference in New Issue
Block a user