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:
217
cmd/bd/repo.go
217
cmd/bd/repo.go
@@ -1,119 +1,157 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
var repoCmd = &cobra.Command{
|
var repoCmd = &cobra.Command{
|
||||||
Use: "repo",
|
Use: "repo",
|
||||||
GroupID: "advanced",
|
GroupID: "advanced",
|
||||||
Short: "Manage multiple repository configuration",
|
Short: "Manage multiple repository configuration",
|
||||||
Long: `Configure and manage multiple repository support for multi-clone sync.
|
Long: `Configure and manage multiple repository support for multi-repo hydration.
|
||||||
|
|
||||||
|
Multi-repo support allows hydrating issues from multiple beads repositories
|
||||||
|
into a single database for unified cross-repo issue tracking.
|
||||||
|
|
||||||
|
Configuration is stored in .beads/config.yaml under the 'repos' section:
|
||||||
|
|
||||||
|
repos:
|
||||||
|
primary: "."
|
||||||
|
additional:
|
||||||
|
- ~/beads-planning
|
||||||
|
- ~/work-repo
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd repo add ~/.beads-planning # Add planning repo
|
bd repo add ~/beads-planning # Add planning repo
|
||||||
bd repo add ../other-repo "notes" # Add with alias
|
bd repo add ../other-repo # Add relative path repo
|
||||||
bd repo list # Show all configured repos
|
bd repo list # Show all configured repos
|
||||||
bd repo remove notes # Remove by alias
|
bd repo remove ~/beads-planning # Remove by path
|
||||||
bd repo remove ~/.beads-planning # Remove by path`,
|
bd repo sync # Sync from all configured repos`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var repoAddCmd = &cobra.Command{
|
var repoAddCmd = &cobra.Command{
|
||||||
Use: "add <path> [alias]",
|
Use: "add <path>",
|
||||||
Short: "Add an additional repository to sync",
|
Short: "Add an additional repository to sync",
|
||||||
Args: cobra.RangeArgs(1, 2),
|
Long: `Add a repository path to the repos.additional list in config.yaml.
|
||||||
|
|
||||||
|
The path should point to a directory containing a .beads folder.
|
||||||
|
Paths can be absolute or relative (they are stored as-is).
|
||||||
|
|
||||||
|
This modifies .beads/config.yaml, which is version-controlled and
|
||||||
|
shared across all clones of this repository.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := ensureDirectMode("repo add requires direct database access"); err != nil {
|
repoPath := args[0]
|
||||||
return err
|
|
||||||
|
// Expand ~ to home directory for validation and display
|
||||||
|
expandedPath := repoPath
|
||||||
|
if len(repoPath) > 0 && repoPath[0] == '~' {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err == nil {
|
||||||
|
expandedPath = filepath.Join(home, repoPath[1:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := rootCtx
|
// Validate the repo path exists and has .beads
|
||||||
path := args[0]
|
beadsDir := filepath.Join(expandedPath, ".beads")
|
||||||
var alias string
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
if len(args) > 1 {
|
return fmt.Errorf("no .beads directory found at %s - is this a beads repository?", expandedPath)
|
||||||
alias = args[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use path as key if no alias provided
|
// Find config.yaml
|
||||||
key := alias
|
configPath, err := config.FindConfigYAMLPath()
|
||||||
if key == "" {
|
|
||||||
key = path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing repos
|
|
||||||
existing, err := getRepoConfig(ctx, store)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get existing repos: %w", err)
|
return fmt.Errorf("failed to find config.yaml: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
existing[key] = path
|
// Add the repo (use original path to preserve ~ etc.)
|
||||||
|
if err := config.AddRepo(configPath, repoPath); err != nil {
|
||||||
// Save back
|
return fmt.Errorf("failed to add repository: %w", err)
|
||||||
if err := setRepoConfig(ctx, store, existing); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"added": true,
|
"added": true,
|
||||||
"key": key,
|
"path": repoPath,
|
||||||
"path": path,
|
|
||||||
}
|
}
|
||||||
return json.NewEncoder(os.Stdout).Encode(result)
|
return json.NewEncoder(os.Stdout).Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Added repository: %s → %s\n", key, path)
|
fmt.Printf("Added repository: %s\n", repoPath)
|
||||||
|
fmt.Printf("Run 'bd repo sync' to hydrate issues from this repository.\n")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var repoRemoveCmd = &cobra.Command{
|
var repoRemoveCmd = &cobra.Command{
|
||||||
Use: "remove <key>",
|
Use: "remove <path>",
|
||||||
Short: "Remove a repository from sync configuration",
|
Short: "Remove a repository from sync configuration",
|
||||||
Args: cobra.ExactArgs(1),
|
Long: `Remove a repository path from the repos.additional list in config.yaml.
|
||||||
|
|
||||||
|
The path must exactly match what was added (e.g., if you added "~/foo",
|
||||||
|
you must remove "~/foo", not "/home/user/foo").
|
||||||
|
|
||||||
|
This command also removes any previously-hydrated issues from the database
|
||||||
|
that came from the removed repository.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
repoPath := args[0]
|
||||||
|
|
||||||
|
// Ensure we have direct database access for cleanup
|
||||||
if err := ensureDirectMode("repo remove requires direct database access"); err != nil {
|
if err := ensureDirectMode("repo remove requires direct database access"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
key := args[0]
|
|
||||||
|
|
||||||
// Get existing repos
|
// Delete issues from the removed repo before removing from config
|
||||||
existing, err := getRepoConfig(ctx, store)
|
// The source_repo field uses the original path (e.g., "~/foo")
|
||||||
|
deletedCount := 0
|
||||||
|
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
|
||||||
|
count, err := sqliteStore.DeleteIssuesBySourceRepo(ctx, repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete issues from repo: %w", err)
|
||||||
|
}
|
||||||
|
deletedCount = count
|
||||||
|
|
||||||
|
// Also clear the mtime cache entry
|
||||||
|
if err := sqliteStore.ClearRepoMtime(ctx, repoPath); err != nil {
|
||||||
|
// Non-fatal: just log a warning
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to clear mtime cache: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find config.yaml
|
||||||
|
configPath, err := config.FindConfigYAMLPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get existing repos: %w", err)
|
return fmt.Errorf("failed to find config.yaml: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
path, exists := existing[key]
|
// Remove the repo from config
|
||||||
if !exists {
|
if err := config.RemoveRepo(configPath, repoPath); err != nil {
|
||||||
return fmt.Errorf("repository not found: %s", key)
|
return fmt.Errorf("failed to remove repository: %w", err)
|
||||||
}
|
|
||||||
|
|
||||||
delete(existing, key)
|
|
||||||
|
|
||||||
// Save back
|
|
||||||
if err := setRepoConfig(ctx, store, existing); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"removed": true,
|
"removed": true,
|
||||||
"key": key,
|
"path": repoPath,
|
||||||
"path": path,
|
"issues_deleted": deletedCount,
|
||||||
}
|
}
|
||||||
return json.NewEncoder(os.Stdout).Encode(result)
|
return json.NewEncoder(os.Stdout).Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Removed repository: %s → %s\n", key, path)
|
fmt.Printf("Removed repository: %s\n", repoPath)
|
||||||
|
if deletedCount > 0 {
|
||||||
|
fmt.Printf("Deleted %d issue(s) from the database\n", deletedCount)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -121,32 +159,46 @@ var repoRemoveCmd = &cobra.Command{
|
|||||||
var repoListCmd = &cobra.Command{
|
var repoListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all configured repositories",
|
Short: "List all configured repositories",
|
||||||
|
Long: `List all repositories configured in .beads/config.yaml.
|
||||||
|
|
||||||
|
Shows the primary repository (always ".") and any additional
|
||||||
|
repositories configured for hydration.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := ensureDirectMode("repo list requires direct database access"); err != nil {
|
// Find config.yaml
|
||||||
return err
|
configPath, err := config.FindConfigYAMLPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find config.yaml: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := rootCtx
|
// Get repos from YAML
|
||||||
repos, err := getRepoConfig(ctx, store)
|
repos, err := config.ListRepos(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
|
primary := repos.Primary
|
||||||
|
if primary == "" {
|
||||||
|
primary = "."
|
||||||
|
}
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"primary": ".",
|
"primary": primary,
|
||||||
"additional": repos,
|
"additional": repos.Additional,
|
||||||
}
|
}
|
||||||
return json.NewEncoder(os.Stdout).Encode(result)
|
return json.NewEncoder(os.Stdout).Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Primary repository: .")
|
primary := repos.Primary
|
||||||
if len(repos) == 0 {
|
if primary == "" {
|
||||||
|
primary = "."
|
||||||
|
}
|
||||||
|
fmt.Printf("Primary repository: %s\n", primary)
|
||||||
|
if len(repos.Additional) == 0 {
|
||||||
fmt.Println("No additional repositories configured")
|
fmt.Println("No additional repositories configured")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("\nAdditional repositories:")
|
fmt.Println("\nAdditional repositories:")
|
||||||
for key, path := range repos {
|
for _, path := range repos.Additional {
|
||||||
fmt.Printf(" %s → %s\n", key, path)
|
fmt.Printf(" - %s\n", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -156,6 +208,10 @@ var repoListCmd = &cobra.Command{
|
|||||||
var repoSyncCmd = &cobra.Command{
|
var repoSyncCmd = &cobra.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Manually trigger multi-repo sync",
|
Short: "Manually trigger multi-repo sync",
|
||||||
|
Long: `Trigger synchronization from all configured repositories.
|
||||||
|
|
||||||
|
This hydrates issues from all repos in repos.additional into the
|
||||||
|
local database, then exports any local changes back to JSONL.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := ensureDirectMode("repo sync requires direct database access"); err != nil {
|
if err := ensureDirectMode("repo sync requires direct database access"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -186,39 +242,6 @@ var repoSyncCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for repo config management
|
|
||||||
func getRepoConfig(ctx context.Context, store storage.Storage) (map[string]string, error) {
|
|
||||||
value, err := store.GetConfig(ctx, "repos.additional")
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "not found") {
|
|
||||||
return make(map[string]string), nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty value (config key exists but no value set)
|
|
||||||
if value == "" {
|
|
||||||
return make(map[string]string), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON map
|
|
||||||
repos := make(map[string]string)
|
|
||||||
if err := json.Unmarshal([]byte(value), &repos); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse repos config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return repos, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func setRepoConfig(ctx context.Context, store storage.Storage, repos map[string]string) error {
|
|
||||||
data, err := json.Marshal(repos)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to serialize repos: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return store.SetConfig(ctx, "repos.additional", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
repoCmd.AddCommand(repoAddCmd)
|
repoCmd.AddCommand(repoAddCmd)
|
||||||
repoCmd.AddCommand(repoRemoveCmd)
|
repoCmd.AddCommand(repoRemoveCmd)
|
||||||
|
|||||||
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)))
|
||||||
|
}
|
||||||
@@ -388,6 +388,124 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteIssuesBySourceRepo permanently removes all issues from a specific source repository.
|
||||||
|
// This is used when a repo is removed from the multi-repo configuration.
|
||||||
|
// It also cleans up related data: dependencies, labels, comments, events, and dirty markers.
|
||||||
|
// Returns the number of issues deleted.
|
||||||
|
func (s *SQLiteStorage) DeleteIssuesBySourceRepo(ctx context.Context, sourceRepo string) (int, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// Get the list of issue IDs to delete
|
||||||
|
rows, err := tx.QueryContext(ctx, `SELECT id FROM issues WHERE source_repo = ?`, sourceRepo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to query issues: %w", err)
|
||||||
|
}
|
||||||
|
var issueIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, fmt.Errorf("failed to scan issue ID: %w", err)
|
||||||
|
}
|
||||||
|
issueIDs = append(issueIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to iterate issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issueIDs) == 0 {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to commit empty transaction: %w", err)
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete dependencies (both directions) for all affected issues
|
||||||
|
for _, id := range issueIDs {
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, id, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete dependencies for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete events for all affected issues
|
||||||
|
for _, id := range issueIDs {
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM events WHERE issue_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete events for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete comments for all affected issues
|
||||||
|
for _, id := range issueIDs {
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM comments WHERE issue_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete comments for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete labels for all affected issues
|
||||||
|
for _, id := range issueIDs {
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM labels WHERE issue_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete labels for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete dirty markers for all affected issues
|
||||||
|
for _, id := range issueIDs {
|
||||||
|
_, err = tx.ExecContext(ctx, `DELETE FROM dirty_issues WHERE issue_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete dirty marker for %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the issues themselves
|
||||||
|
result, err := tx.ExecContext(ctx, `DELETE FROM issues WHERE source_repo = ?`, sourceRepo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to delete issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to check rows affected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(rowsAffected), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRepoMtime removes the mtime cache entry for a repository.
|
||||||
|
// This is used when a repo is removed from the multi-repo configuration.
|
||||||
|
func (s *SQLiteStorage) ClearRepoMtime(ctx context.Context, repoPath string) error {
|
||||||
|
// Expand tilde in path to match how it's stored
|
||||||
|
expandedPath, err := expandTilde(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to expand path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get absolute path to match how it's stored in repo_mtimes
|
||||||
|
absRepoPath, err := filepath.Abs(expandedPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get absolute path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.ExecContext(ctx, `DELETE FROM repo_mtimes WHERE repo_path = ?`, absRepoPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete mtime cache: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// expandTilde expands ~ in a file path to the user's home directory.
|
// expandTilde expands ~ in a file path to the user's home directory.
|
||||||
func expandTilde(path string) (string, error) {
|
func expandTilde(path string) (string, error) {
|
||||||
if !strings.HasPrefix(path, "~") {
|
if !strings.HasPrefix(path, "~") {
|
||||||
|
|||||||
@@ -500,6 +500,263 @@ func TestImportJSONLFileOutOfOrderDeps(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteIssuesBySourceRepo(t *testing.T) {
|
||||||
|
t.Run("deletes all issues from specified repo", func(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create issues with different source_repos
|
||||||
|
issue1 := &types.Issue{
|
||||||
|
ID: "bd-repo1-1",
|
||||||
|
Title: "Repo1 Issue 1",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SourceRepo: "~/test-repo",
|
||||||
|
}
|
||||||
|
issue1.ContentHash = issue1.ComputeContentHash()
|
||||||
|
|
||||||
|
issue2 := &types.Issue{
|
||||||
|
ID: "bd-repo1-2",
|
||||||
|
Title: "Repo1 Issue 2",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SourceRepo: "~/test-repo",
|
||||||
|
}
|
||||||
|
issue2.ContentHash = issue2.ComputeContentHash()
|
||||||
|
|
||||||
|
issue3 := &types.Issue{
|
||||||
|
ID: "bd-primary-1",
|
||||||
|
Title: "Primary Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SourceRepo: ".",
|
||||||
|
}
|
||||||
|
issue3.ContentHash = issue3.ComputeContentHash()
|
||||||
|
|
||||||
|
// Insert all issues
|
||||||
|
if err := store.CreateIssue(ctx, issue1, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create issue1: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue2, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create issue2: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.CreateIssue(ctx, issue3, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create issue3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete issues from ~/test-repo
|
||||||
|
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/test-repo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
if deletedCount != 2 {
|
||||||
|
t.Errorf("expected 2 issues deleted, got %d", deletedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ~/test-repo issues are gone
|
||||||
|
// GetIssue returns (nil, nil) when issue doesn't exist
|
||||||
|
issue1After, err := store.GetIssue(ctx, "bd-repo1-1")
|
||||||
|
if issue1After != nil || err != nil {
|
||||||
|
t.Errorf("expected bd-repo1-1 to be deleted, got issue=%v, err=%v", issue1After, err)
|
||||||
|
}
|
||||||
|
issue2After, err := store.GetIssue(ctx, "bd-repo1-2")
|
||||||
|
if issue2After != nil || err != nil {
|
||||||
|
t.Errorf("expected bd-repo1-2 to be deleted, got issue=%v, err=%v", issue2After, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify primary issue still exists
|
||||||
|
primary, err := store.GetIssue(ctx, "bd-primary-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("primary issue should still exist: %v", err)
|
||||||
|
}
|
||||||
|
if primary.Title != "Primary Issue" {
|
||||||
|
t.Errorf("expected 'Primary Issue', got %q", primary.Title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns 0 when no issues match", func(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create an issue with a different source_repo
|
||||||
|
issue := &types.Issue{
|
||||||
|
ID: "bd-other-1",
|
||||||
|
Title: "Other Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SourceRepo: ".",
|
||||||
|
}
|
||||||
|
issue.ContentHash = issue.ComputeContentHash()
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from non-existent repo
|
||||||
|
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/nonexistent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
if deletedCount != 0 {
|
||||||
|
t.Errorf("expected 0 issues deleted, got %d", deletedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify original issue still exists
|
||||||
|
_, err = store.GetIssue(ctx, "bd-other-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("issue should still exist: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cleans up related data", func(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create an issue with labels and comments
|
||||||
|
issue := &types.Issue{
|
||||||
|
ID: "bd-cleanup-1",
|
||||||
|
Title: "Cleanup Test Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 1,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SourceRepo: "~/cleanup-repo",
|
||||||
|
Labels: []string{"test", "cleanup"},
|
||||||
|
}
|
||||||
|
issue.ContentHash = issue.ComputeContentHash()
|
||||||
|
|
||||||
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||||
|
t.Fatalf("failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a comment
|
||||||
|
_, err := store.AddIssueComment(ctx, "bd-cleanup-1", "test", "Test comment")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to add comment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the repo
|
||||||
|
deletedCount, err := store.DeleteIssuesBySourceRepo(ctx, "~/cleanup-repo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteIssuesBySourceRepo() error = %v", err)
|
||||||
|
}
|
||||||
|
if deletedCount != 1 {
|
||||||
|
t.Errorf("expected 1 issue deleted, got %d", deletedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issue is gone
|
||||||
|
// GetIssue returns (nil, nil) when issue doesn't exist
|
||||||
|
issueAfter, err := store.GetIssue(ctx, "bd-cleanup-1")
|
||||||
|
if issueAfter != nil || err != nil {
|
||||||
|
t.Errorf("expected issue to be deleted, got issue=%v, err=%v", issueAfter, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify labels are gone (query directly to check)
|
||||||
|
var labelCount int
|
||||||
|
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM labels WHERE issue_id = ?`, "bd-cleanup-1").Scan(&labelCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query labels: %v", err)
|
||||||
|
}
|
||||||
|
if labelCount != 0 {
|
||||||
|
t.Errorf("expected 0 labels, got %d", labelCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify comments are gone
|
||||||
|
var commentCount int
|
||||||
|
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM comments WHERE issue_id = ?`, "bd-cleanup-1").Scan(&commentCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query comments: %v", err)
|
||||||
|
}
|
||||||
|
if commentCount != 0 {
|
||||||
|
t.Errorf("expected 0 comments, got %d", commentCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearRepoMtime(t *testing.T) {
|
||||||
|
t.Run("clears mtime cache for repo", func(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Insert a mtime cache entry directly
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||||
|
|
||||||
|
// Create a dummy JSONL file for the mtime
|
||||||
|
f, err := os.Create(jsonlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create JSONL: %v", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
_, err = store.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO repo_mtimes (repo_path, jsonl_path, mtime_ns, last_checked)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, tmpDir, jsonlPath, 12345, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to insert mtime cache: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
var count int
|
||||||
|
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query mtime cache: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 mtime cache entry, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear it
|
||||||
|
if err := store.ClearRepoMtime(ctx, tmpDir); err != nil {
|
||||||
|
t.Fatalf("ClearRepoMtime() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
err = store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM repo_mtimes WHERE repo_path = ?`, tmpDir).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to query mtime cache: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected 0 mtime cache entries, got %d", count)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles non-existent repo gracefully", func(t *testing.T) {
|
||||||
|
store, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Clear a repo that doesn't exist in cache - should not error
|
||||||
|
err := store.ClearRepoMtime(ctx, "/nonexistent/path")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ClearRepoMtime() should not error for non-existent path: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportToMultiRepo(t *testing.T) {
|
func TestExportToMultiRepo(t *testing.T) {
|
||||||
t.Run("returns nil in single-repo mode", func(t *testing.T) {
|
t.Run("returns nil in single-repo mode", func(t *testing.T) {
|
||||||
store, cleanup := setupTestDB(t)
|
store, cleanup := setupTestDB(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user