diff --git a/internal/cli/sync.go b/internal/cli/sync.go index acf136d..1ce1531 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -32,6 +33,53 @@ type syncOptions struct { claudeOnly bool } +// shouldSyncConfig returns true if base config should be synced. +func (o *syncOptions) shouldSyncConfig() bool { + return !o.commandsOnly && !o.languagesOnly && !o.rulesOnly && !o.claudeOnly +} + +// shouldSyncCommands returns true if commands should be synced. +func (o *syncOptions) shouldSyncCommands() bool { + return !o.configOnly && !o.languagesOnly && !o.rulesOnly && !o.claudeOnly +} + +// shouldSyncLanguages returns true if languages should be synced. +func (o *syncOptions) shouldSyncLanguages() bool { + return !o.configOnly && !o.commandsOnly && !o.rulesOnly && !o.claudeOnly +} + +// shouldSyncEvals returns true if evals should be synced. +func (o *syncOptions) shouldSyncEvals() bool { + return !o.configOnly && !o.commandsOnly && !o.languagesOnly && !o.rulesOnly && !o.claudeOnly +} + +// shouldSyncRules returns true if rules should be synced. +func (o *syncOptions) shouldSyncRules() bool { + return !o.configOnly && !o.commandsOnly && !o.languagesOnly && !o.claudeOnly +} + +// shouldApplyConfig returns true if config should be applied to ~/.claude/CLAUDE.md. +func (o *syncOptions) shouldApplyConfig() bool { + return !o.fetchOnly && !o.rulesOnly && !o.claudeOnly +} + +// shouldSyncClaudeCommands returns true if commands should be synced to Claude Code. +func (o *syncOptions) shouldSyncClaudeCommands() bool { + return !o.configOnly && !o.languagesOnly && !o.rulesOnly && !o.fetchOnly +} + +// shouldSyncClaudeRules returns true if rules should be synced to Claude Code. +func (o *syncOptions) shouldSyncClaudeRules() bool { + return !o.configOnly && !o.languagesOnly && !o.commandsOnly && !o.fetchOnly +} + +// repoContext holds the branch info for a single repo. +type repoContext struct { + owner string + repo string + branch string +} + // NewSyncCmd creates the sync command. func NewSyncCmd() *cobra.Command { opts := &syncOptions{} @@ -81,6 +129,10 @@ func runSync(ctx context.Context, opts *syncOptions) error { c := cache.New(paths) + // Check for multi-source configuration + // We need the GitHub client for multi-source, so check after client creation + isMultiSource := cfg.Source.IsMultiSource() + // Apply-only mode: skip fetch, just apply from cache if opts.applyOnly { if !c.Exists(owner, repo) { @@ -127,6 +179,11 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } + // Use multi-source sync if configured + if isMultiSource { + return runMultiSourceSync(ctx, cfg, paths, opts, client, c) + } + // Determine branch (use default branch from repo) branch, err := client.GetDefaultBranch(ctx, owner, repo) if err != nil { @@ -135,8 +192,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { fmt.Printf("Fetching %s/%s...\n", owner, repo) - // Sync config unless --commands-only, --languages-only, --rules-only, or --claude-only was specified - if !opts.commandsOnly && !opts.languagesOnly && !opts.rulesOnly && !opts.claudeOnly { + // Sync config + if opts.shouldSyncConfig() { result, err := client.FetchFile(ctx, owner, repo, config.DefaultPath, branch) if err != nil { return errors.GitHubFetchFailed(owner+"/"+repo, err) @@ -159,8 +216,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { printInfo("SHA", result.SHA[:8]) } - // Sync commands unless --config-only, --languages-only, --rules-only, or --claude-only was specified - if !opts.configOnly && !opts.languagesOnly && !opts.rulesOnly && !opts.claudeOnly { + // Sync commands + if opts.shouldSyncCommands() { commandCount, err := syncCommands(ctx, client, owner, repo, branch, paths) if err != nil { printWarning("Failed to sync commands: %v", err) @@ -179,8 +236,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } - // Sync languages unless --config-only, --commands-only, --rules-only, or --claude-only was specified - if !opts.configOnly && !opts.commandsOnly && !opts.rulesOnly && !opts.claudeOnly { + // Sync languages + if opts.shouldSyncLanguages() { languageCount, err := syncLanguages(ctx, client, owner, repo, branch, paths) if err != nil { printWarning("Failed to sync languages: %v", err) @@ -191,8 +248,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } - // Sync evals unless --config-only, --commands-only, --languages-only, --rules-only, or --claude-only was specified - if !opts.configOnly && !opts.commandsOnly && !opts.languagesOnly && !opts.rulesOnly && !opts.claudeOnly { + // Sync evals + if opts.shouldSyncEvals() { evalCount, err := syncEvals(ctx, client, owner, repo, branch, paths) if err != nil { printWarning("Failed to sync evals: %v", err) @@ -201,8 +258,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } - // Sync rules unless --config-only, --commands-only, --languages-only, or --claude-only was specified - if !opts.configOnly && !opts.commandsOnly && !opts.languagesOnly && !opts.claudeOnly { + // Sync rules + if opts.shouldSyncRules() { ruleCount, err := syncRules(ctx, client, owner, repo, branch, paths) if err != nil { printWarning("Failed to sync rules: %v", err) @@ -213,16 +270,16 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } - // Apply to ~/.claude/CLAUDE.md unless --fetch-only, --rules-only, or --claude-only was specified - if !opts.fetchOnly && !opts.rulesOnly && !opts.claudeOnly { + // Apply to ~/.claude/CLAUDE.md + if opts.shouldApplyConfig() { fmt.Println() if err := applyConfig(cfg, paths, owner, repo); err != nil { return err } } - // Sync commands to Claude Code (always runs unless --fetch-only, --config-only, --languages-only, or --rules-only) - if !opts.configOnly && !opts.languagesOnly && !opts.rulesOnly && !opts.fetchOnly { + // Sync commands to Claude Code + if opts.shouldSyncClaudeCommands() { claudeCount, err := syncClaudeCommands(paths, owner, repo) if err != nil { printWarning("Failed to sync Claude commands: %v", err) @@ -232,8 +289,8 @@ func runSync(ctx context.Context, opts *syncOptions) error { } } - // Sync rules to Claude Code (always runs unless --fetch-only, --config-only, --languages-only, or --commands-only) - if !opts.configOnly && !opts.languagesOnly && !opts.commandsOnly && !opts.fetchOnly { + // Sync rules to Claude Code + if opts.shouldSyncClaudeRules() { claudeRuleCount, err := syncClaudeRules(paths, owner, repo) if err != nil { printWarning("Failed to sync Claude rules: %v", err) @@ -606,175 +663,160 @@ func syncClaudeCommands(paths *config.Paths, owner, repo string) (int, error) { return count, nil } -// applyConfig merges team config with personal additions and writes to ~/.claude/CLAUDE.md. -func applyConfig(cfg *config.Config, paths *config.Paths, owner, repo string) error { - // Get team config from cache - teamConfig, err := os.ReadFile(paths.CacheFile(owner, repo)) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("no cached team config found") - } - return fmt.Errorf("failed to read cached config: %w", err) +// readPersonalConfig reads and processes the personal config file. +func readPersonalConfig(paths *config.Paths) ([]byte, error) { + if _, err := os.Stat(paths.PersonalMD); err != nil { + return nil, nil // No personal config is not an error } - - // Get personal config (optional) - var personalConfig []byte - if _, err := os.Stat(paths.PersonalMD); err == nil { - personalConfig, err = os.ReadFile(paths.PersonalMD) - if err != nil { - return fmt.Errorf("failed to read personal config: %w", err) - } - // Strip instructional comments from personal config - personalConfig = []byte(stripInstructionalComments(string(personalConfig))) + personalConfig, err := os.ReadFile(paths.PersonalMD) + if err != nil { + return nil, fmt.Errorf("failed to read personal config: %w", err) } + return []byte(stripInstructionalComments(string(personalConfig))), nil +} - // Resolve languages for global config - // For global ~/.claude/CLAUDE.md, include ALL available language configs from team + personal - // (auto-detect only makes sense for project-level configs) - teamLangDir := paths.TeamLanguagesDir(owner, repo) - personalLangDir := paths.PersonalLanguages - +// resolveActiveLanguages determines which languages are active based on config and available files. +// It returns a sorted list to ensure deterministic output. +func resolveActiveLanguages(cfg *config.Config, teamLangDirs []string, personalLangDir string) []string { var activeLanguages []string - var languageFiles map[string][]*language.LanguageFile if len(cfg.Languages.Enabled) > 0 { // Explicit list takes precedence activeLanguages = language.FilterDisabled(cfg.Languages.Enabled, cfg.Languages.Disabled) } else { - // Default: include all available languages from team + personal - activeLanguages, _ = language.ListAvailableLanguages(teamLangDir, personalLangDir, "") + // Collect available languages from all team directories and personal + availableLanguages := make(map[string]bool) + for _, teamLangDir := range teamLangDirs { + if teamLangDir == "" { + continue + } + langs, _ := language.ListAvailableLanguages(teamLangDir, "", "") + for _, lang := range langs { + availableLanguages[lang] = true + } + } + // Also check personal languages + personalLangs, _ := language.ListAvailableLanguages("", personalLangDir, "") + for _, lang := range personalLangs { + availableLanguages[lang] = true + } + for lang := range availableLanguages { + activeLanguages = append(activeLanguages, lang) + } activeLanguages = language.FilterDisabled(activeLanguages, cfg.Languages.Disabled) } - if len(activeLanguages) > 0 { - languageFiles, _ = language.LoadLanguageFiles( - activeLanguages, - teamLangDir, - personalLangDir, - "", // No project dir for global config - ) - } + // Sort for deterministic output + sort.Strings(activeLanguages) + return activeLanguages +} - // Merge configs with language support - layers := []merge.Layer{ - {Content: string(teamConfig), Source: "team"}, - {Content: string(personalConfig), Source: "personal"}, - } - mergeOpts := merge.MergeOptions{ - AnnotateSources: true, - SourceRepo: cfg.SourceRepo(), - Languages: activeLanguages, - LanguageFiles: languageFiles, +// handleExistingConfigMigration checks if the output file needs migration or backup. +// Returns (shouldContinue, updatedPersonalConfig, error). +func handleExistingConfigMigration(cfg *config.Config, paths *config.Paths, outputPath string, personalConfig []byte) (bool, []byte, error) { + existingContent, err := os.ReadFile(outputPath) + if err != nil { + // File doesn't exist - no migration needed + return true, personalConfig, nil } - merged := merge.MergeWithLanguages(layers, mergeOpts) - output := merged - // Ensure ~/.claude directory exists - claudeDir := filepath.Join(os.Getenv("HOME"), ".claude") - if err := os.MkdirAll(claudeDir, 0755); err != nil { - return fmt.Errorf("failed to create ~/.claude directory: %w", err) - } + existingStr := string(existingContent) + needsPrompt := false + promptReason := "" - // Check for existing content that needs backup/migration - outputPath := filepath.Join(claudeDir, "CLAUDE.md") - if existingContent, err := os.ReadFile(outputPath); err == nil { - existingStr := string(existingContent) - needsPrompt := false - promptReason := "" - - if !strings.Contains(existingStr, merge.HeaderManagedPrefix) { - // File exists but isn't managed by staghorn - needsPrompt = true - promptReason = "Found existing ~/.claude/CLAUDE.md not managed by staghorn" - } else { - // File is managed by staghorn - check if switching sources - currentSource := cfg.SourceRepo() - // Extract previous source from header: "\n"); idx != -1 { - contentToMigrate = strings.TrimLeft(existingStr[idx+4:], "\n") - } - } + if !needsPrompt { + return true, personalConfig, nil + } - existingPersonal, _ := os.ReadFile(paths.PersonalMD) - newPersonal := string(existingPersonal) - if len(newPersonal) > 0 { - newPersonal += "\n\n" - } - newPersonal += "\n\n" + contentToMigrate + printWarning("%s", promptReason) + fmt.Println() + fmt.Println("Options:") + fmt.Println(" 1. Migrate content to personal config (recommended)") + fmt.Println(" 2. Back up existing file and continue") + fmt.Println(" 3. Abort") + fmt.Println() - if err := os.MkdirAll(paths.ConfigDir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - if err := os.WriteFile(paths.PersonalMD, []byte(newPersonal), 0644); err != nil { - return fmt.Errorf("failed to write personal config: %w", err) - } - printSuccess("Migrated content to %s", paths.PersonalMD) - fmt.Printf(" %s Run 'staghorn edit' to review and organize\n", dim("Tip:")) - fmt.Println() - - // Re-read personal config for merge - personalConfig, _ = os.ReadFile(paths.PersonalMD) - layers[1] = merge.Layer{Content: string(personalConfig), Source: "personal"} - merged = merge.MergeWithLanguages(layers, mergeOpts) - output = merged - - case "2": - // Back up and continue - backupPath := outputPath + ".backup" - if err := os.WriteFile(backupPath, existingContent, 0644); err != nil { - return fmt.Errorf("failed to backup existing file: %w", err) - } - printSuccess("Backed up to %s", backupPath) - fmt.Println() - - case "3": - fmt.Println("Aborted.") - return nil + choice := promptString("Choose an option [1/2/3]:") - default: - return fmt.Errorf("invalid option") + switch choice { + case "1", "": + contentToMigrate := existingStr + if strings.Contains(existingStr, merge.HeaderManagedPrefix) { + if idx := strings.Index(existingStr, "-->\n"); idx != -1 { + contentToMigrate = strings.TrimLeft(existingStr[idx+4:], "\n") } } + + existingPersonal, _ := os.ReadFile(paths.PersonalMD) + newPersonal := string(existingPersonal) + if len(newPersonal) > 0 { + newPersonal += "\n\n" + } + newPersonal += "\n\n" + contentToMigrate + + if err := os.MkdirAll(paths.ConfigDir, 0755); err != nil { + return false, nil, fmt.Errorf("failed to create config directory: %w", err) + } + if err := os.WriteFile(paths.PersonalMD, []byte(newPersonal), 0644); err != nil { + return false, nil, fmt.Errorf("failed to write personal config: %w", err) + } + printSuccess("Migrated content to %s", paths.PersonalMD) + fmt.Printf(" %s Run 'staghorn edit' to review and organize\n", dim("Tip:")) + fmt.Println() + + // Re-read personal config + updatedPersonal, _ := os.ReadFile(paths.PersonalMD) + return true, updatedPersonal, nil + + case "2": + backupPath := outputPath + ".backup" + if err := os.WriteFile(backupPath, existingContent, 0644); err != nil { + return false, nil, fmt.Errorf("failed to backup existing file: %w", err) + } + printSuccess("Backed up to %s", backupPath) + fmt.Println() + return true, personalConfig, nil + + case "3": + fmt.Println("Aborted.") + return false, nil, nil + + default: + return false, nil, fmt.Errorf("invalid option") + } +} + +// writeConfigOutput writes the merged config to the output file and prints status. +func writeConfigOutput(outputPath string, output string, hasPersonal bool) error { + claudeDir := filepath.Dir(outputPath) + if err := os.MkdirAll(claudeDir, 0755); err != nil { + return fmt.Errorf("failed to create ~/.claude directory: %w", err) } - // Write to ~/.claude/CLAUDE.md if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil { return fmt.Errorf("failed to write config: %w", err) } printSuccess("Applied to %s", outputPath) - // Show what was merged - hasPersonal := len(personalConfig) > 0 if hasPersonal { fmt.Printf(" %s Team config + personal additions\n", dim("Merged:")) } else { @@ -785,6 +827,70 @@ func applyConfig(cfg *config.Config, paths *config.Paths, owner, repo string) er return nil } +// mergeAndWriteConfig handles migration, merging, and writing for both single and multi-source configs. +func mergeAndWriteConfig(cfg *config.Config, paths *config.Paths, teamConfig, personalConfig []byte, activeLanguages []string, languageFiles map[string][]*language.LanguageFile) error { + layers := []merge.Layer{ + {Content: string(teamConfig), Source: "team"}, + {Content: string(personalConfig), Source: "personal"}, + } + mergeOpts := merge.MergeOptions{ + AnnotateSources: true, + SourceRepo: cfg.SourceRepo(), + Languages: activeLanguages, + LanguageFiles: languageFiles, + } + + outputPath := filepath.Join(os.Getenv("HOME"), ".claude", "CLAUDE.md") + shouldContinue, updatedPersonal, err := handleExistingConfigMigration(cfg, paths, outputPath, personalConfig) + if err != nil { + return err + } + if !shouldContinue { + return nil + } + if updatedPersonal != nil { + personalConfig = updatedPersonal + layers[1] = merge.Layer{Content: string(personalConfig), Source: "personal"} + } + + output := merge.MergeWithLanguages(layers, mergeOpts) + return writeConfigOutput(outputPath, output, len(personalConfig) > 0) +} + +// applyConfig merges team config with personal additions and writes to ~/.claude/CLAUDE.md. +func applyConfig(cfg *config.Config, paths *config.Paths, owner, repo string) error { + // Get team config from cache + teamConfig, err := os.ReadFile(paths.CacheFile(owner, repo)) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no cached team config found") + } + return fmt.Errorf("failed to read cached config: %w", err) + } + + // Get personal config (optional) + personalConfig, err := readPersonalConfig(paths) + if err != nil { + return err + } + + // Resolve languages + teamLangDir := paths.TeamLanguagesDir(owner, repo) + activeLanguages := resolveActiveLanguages(cfg, []string{teamLangDir}, paths.PersonalLanguages) + + var languageFiles map[string][]*language.LanguageFile + if len(activeLanguages) > 0 { + languageFiles, _ = language.LoadLanguageFiles( + activeLanguages, + teamLangDir, + paths.PersonalLanguages, + "", + ) + } + + return mergeAndWriteConfig(cfg, paths, teamConfig, personalConfig, activeLanguages, languageFiles) +} + // stripInstructionalComments removes HTML comments marked with [staghorn] prefix // and collapses consecutive blank lines. func stripInstructionalComments(content string) string { @@ -883,3 +989,401 @@ func checkConfigSizeAndSuggestOptimize(cfg *config.Config, paths *config.Paths, fmt.Printf(" Run %s to compress.\n", info("staghorn optimize")) } } + +// buildRepoContexts creates repo contexts for all repos in a multi-source config. +func buildRepoContexts(ctx context.Context, client *github.Client, cfg *config.Config) (map[string]*repoContext, error) { + allRepos := cfg.Source.AllRepos() + contexts := make(map[string]*repoContext, len(allRepos)) + + for _, repoStr := range allRepos { + owner, repo, err := config.ParseRepo(repoStr) + if err != nil { + return nil, fmt.Errorf("invalid repo %s: %w", repoStr, err) + } + + branch, err := client.GetDefaultBranch(ctx, owner, repo) + if err != nil { + return nil, errors.GitHubFetchFailed(repoStr, err) + } + + contexts[repoStr] = &repoContext{ + owner: owner, + repo: repo, + branch: branch, + } + } + + return contexts, nil +} + +// runMultiSourceSync handles sync when multiple source repos are configured. +func runMultiSourceSync(ctx context.Context, cfg *config.Config, paths *config.Paths, opts *syncOptions, client *github.Client, c *cache.Cache) error { + // Build contexts for all repos + repoContexts, err := buildRepoContexts(ctx, client, cfg) + if err != nil { + return err + } + + // Get the base and default repo contexts - these should always exist after buildRepoContexts + baseRepoStr := cfg.Source.RepoForBase() + baseCtx := repoContexts[baseRepoStr] + if baseCtx == nil { + // This indicates a bug in buildRepoContexts - it should have created this context + return fmt.Errorf("internal error: no context for base repo %s after building contexts", baseRepoStr) + } + + defaultRepoStr := cfg.Source.DefaultRepo() + defaultCtx := repoContexts[defaultRepoStr] + if defaultCtx == nil { + // This indicates a bug in buildRepoContexts - it should have created this context + return fmt.Errorf("internal error: no context for default repo %s after building contexts", defaultRepoStr) + } + + fmt.Printf("Fetching from %d source(s)...\n", len(repoContexts)) + + // Sync base config + if opts.shouldSyncConfig() { + fmt.Printf(" Base config from %s/%s\n", baseCtx.owner, baseCtx.repo) + result, err := client.FetchFile(ctx, baseCtx.owner, baseCtx.repo, config.DefaultPath, baseCtx.branch) + if err != nil { + return errors.GitHubFetchFailed(baseRepoStr, err) + } + + meta := &cache.Metadata{ + Owner: baseCtx.owner, + Repo: baseCtx.repo, + SHA: result.SHA, + LastFetched: time.Now(), + } + + if err := c.Write(baseCtx.owner, baseCtx.repo, result.Content, meta); err != nil { + return fmt.Errorf("failed to write cache: %w", err) + } + + printSuccess("Synced config from %s/%s", baseCtx.owner, baseCtx.repo) + } + + // Sync commands with multi-source support + if opts.shouldSyncCommands() { + commandCount, err := syncCommandsMultiSource(ctx, client, cfg, repoContexts, paths) + if err != nil { + printWarning("Failed to sync commands: %v", err) + } else if commandCount > 0 { + printSuccess("Synced %d commands", commandCount) + } + + // Sync templates from default repo + templateCount, err := syncTemplates(ctx, client, defaultCtx.owner, defaultCtx.repo, defaultCtx.branch, paths) + if err != nil { + printWarning("Failed to sync templates: %v", err) + } else if templateCount > 0 { + printSuccess("Synced %d templates", templateCount) + } + } + + // Sync languages with multi-source support + if opts.shouldSyncLanguages() { + languageCount, err := syncLanguagesMultiSource(ctx, client, cfg, repoContexts, paths) + if err != nil { + printWarning("Failed to sync languages: %v", err) + } else if languageCount > 0 { + printSuccess("Synced %d language configs", languageCount) + } + } + + // Sync evals from default repo + if opts.shouldSyncEvals() { + evalCount, err := syncEvals(ctx, client, defaultCtx.owner, defaultCtx.repo, defaultCtx.branch, paths) + if err != nil { + printWarning("Failed to sync evals: %v", err) + } else if evalCount > 0 { + printSuccess("Synced %d evals", evalCount) + } + } + + // Sync rules from default repo (no per-rule multi-source support) + if opts.shouldSyncRules() { + ruleCount, err := syncRules(ctx, client, defaultCtx.owner, defaultCtx.repo, defaultCtx.branch, paths) + if err != nil { + printWarning("Failed to sync rules: %v", err) + } else if ruleCount > 0 { + printSuccess("Synced %d rules", ruleCount) + } + } + + // Apply config + if opts.shouldApplyConfig() { + fmt.Println() + if err := applyConfigFromMultiSource(cfg, paths, repoContexts); err != nil { + return err + } + } + + // Sync commands to Claude Code + if opts.shouldSyncClaudeCommands() { + claudeCount, err := syncClaudeCommands(paths, defaultCtx.owner, defaultCtx.repo) + if err != nil { + printWarning("Failed to sync Claude commands: %v", err) + } else if claudeCount > 0 { + printSuccess("Synced %d commands to Claude Code", claudeCount) + } + } + + // Sync rules to Claude Code + if opts.shouldSyncClaudeRules() { + claudeRuleCount, err := syncClaudeRules(paths, defaultCtx.owner, defaultCtx.repo) + if err != nil { + printWarning("Failed to sync Claude rules: %v", err) + } else if claudeRuleCount > 0 { + printSuccess("Synced %d rules to Claude Code", claudeRuleCount) + } + } + + // Check config size + if !opts.fetchOnly { + checkConfigSizeAndSuggestOptimize(cfg, paths, defaultCtx.owner, defaultCtx.repo) + } + + return nil +} + +// isExplicitlyConfiguredLanguage returns true if the language has an explicit source configured. +func isExplicitlyConfiguredLanguage(cfg *config.Config, lang string) bool { + if cfg.Source.Multi != nil && cfg.Source.Multi.Languages != nil { + _, ok := cfg.Source.Multi.Languages[lang] + return ok + } + return false +} + +// isExplicitlyConfiguredCommand returns true if the command has an explicit source configured. +func isExplicitlyConfiguredCommand(cfg *config.Config, cmd string) bool { + if cfg.Source.Multi != nil && cfg.Source.Multi.Commands != nil { + _, ok := cfg.Source.Multi.Commands[cmd] + return ok + } + return false +} + +// handleMultiSourceFetchError logs appropriate warnings for fetch errors. +// For explicitly configured items, always warn. For items using default repo, +// only warn on non-404 errors (silently skip if not found). +func handleMultiSourceFetchError(itemType, name, sourceRepo string, err error, isExplicit bool) { + if github.IsNotFoundError(err) { + if isExplicit { + printWarning("%s %s not found in explicitly configured source %s", itemType, name, sourceRepo) + } + // Silently skip 404s for non-explicit items + return + } + // Always warn on non-404 errors (network issues, auth problems, etc.) + printWarning("Failed to fetch %s %s from %s: %v", itemType, name, sourceRepo, err) +} + +// syncLanguagesMultiSource fetches languages from their configured source repos. +func syncLanguagesMultiSource(ctx context.Context, client *github.Client, cfg *config.Config, repoContexts map[string]*repoContext, paths *config.Paths) (int, error) { + // First, discover all languages from the default repo + defaultRepoStr := cfg.Source.DefaultRepo() + defaultCtx := repoContexts[defaultRepoStr] + if defaultCtx == nil { + return 0, fmt.Errorf("no context for default repo %s", defaultRepoStr) + } + + // Get languages from default repo + allLanguages := make(map[string]bool) + entries, err := client.ListDirectory(ctx, defaultCtx.owner, defaultCtx.repo, "languages", defaultCtx.branch) + if err == nil && entries != nil { + for _, entry := range entries { + if entry.Type == "file" && strings.HasSuffix(entry.Name, ".md") { + lang := strings.TrimSuffix(entry.Name, ".md") + allLanguages[lang] = true + } + } + } + + // Add any explicitly configured language sources + if cfg.Source.Multi != nil && cfg.Source.Multi.Languages != nil { + for lang := range cfg.Source.Multi.Languages { + allLanguages[lang] = true + } + } + + // Sync each language from its configured source + count := 0 + for lang := range allLanguages { + sourceRepoStr := cfg.Source.RepoForLanguage(lang) + repoCtx := repoContexts[sourceRepoStr] + if repoCtx == nil { + printWarning("No context for language %s source %s", lang, sourceRepoStr) + continue + } + + // Fetch this language from its source + langPath := fmt.Sprintf("languages/%s.md", lang) + result, err := client.FetchFile(ctx, repoCtx.owner, repoCtx.repo, langPath, repoCtx.branch) + if err != nil { + handleMultiSourceFetchError("language", lang, sourceRepoStr, err, isExplicitlyConfiguredLanguage(cfg, lang)) + continue + } + + // Store in the repo-specific cache directory + langDir := paths.TeamLanguagesDir(repoCtx.owner, repoCtx.repo) + if err := os.MkdirAll(langDir, 0755); err != nil { + printWarning("Failed to create language directory for %s: %v", lang, err) + continue + } + + localPath := filepath.Join(langDir, lang+".md") + if err := os.WriteFile(localPath, []byte(result.Content), 0644); err != nil { + printWarning("Failed to write language %s: %v", lang, err) + continue + } + + count++ + } + + return count, nil +} + +// syncCommandsMultiSource fetches commands from their configured source repos. +func syncCommandsMultiSource(ctx context.Context, client *github.Client, cfg *config.Config, repoContexts map[string]*repoContext, paths *config.Paths) (int, error) { + // First, discover all commands from the default repo + defaultRepoStr := cfg.Source.DefaultRepo() + defaultCtx := repoContexts[defaultRepoStr] + if defaultCtx == nil { + return 0, fmt.Errorf("no context for default repo %s", defaultRepoStr) + } + + // Get commands from default repo + allCommands := make(map[string]bool) + entries, err := client.ListDirectory(ctx, defaultCtx.owner, defaultCtx.repo, "commands", defaultCtx.branch) + if err == nil && entries != nil { + for _, entry := range entries { + if entry.Type == "file" && strings.HasSuffix(entry.Name, ".md") { + cmd := strings.TrimSuffix(entry.Name, ".md") + allCommands[cmd] = true + } + } + } + + // Add any explicitly configured command sources + if cfg.Source.Multi != nil && cfg.Source.Multi.Commands != nil { + for cmd := range cfg.Source.Multi.Commands { + allCommands[cmd] = true + } + } + + // Sync each command from its configured source + count := 0 + for cmd := range allCommands { + sourceRepoStr := cfg.Source.RepoForCommand(cmd) + repoCtx := repoContexts[sourceRepoStr] + if repoCtx == nil { + printWarning("No context for command %s source %s", cmd, sourceRepoStr) + continue + } + + // Fetch this command from its source + cmdPath := fmt.Sprintf("commands/%s.md", cmd) + result, err := client.FetchFile(ctx, repoCtx.owner, repoCtx.repo, cmdPath, repoCtx.branch) + if err != nil { + handleMultiSourceFetchError("command", cmd, sourceRepoStr, err, isExplicitlyConfiguredCommand(cfg, cmd)) + continue + } + + // Store in the repo-specific cache directory + cmdDir := paths.TeamCommandsDir(repoCtx.owner, repoCtx.repo) + if err := os.MkdirAll(cmdDir, 0755); err != nil { + printWarning("Failed to create commands directory for %s: %v", cmd, err) + continue + } + + localPath := filepath.Join(cmdDir, cmd+".md") + if err := os.WriteFile(localPath, []byte(result.Content), 0644); err != nil { + printWarning("Failed to write command %s: %v", cmd, err) + continue + } + + count++ + } + + return count, nil +} + +// applyConfigFromMultiSource merges configs from multiple source repos. +func applyConfigFromMultiSource(cfg *config.Config, paths *config.Paths, repoContexts map[string]*repoContext) error { + // Get base config from the base source repo + baseRepoStr := cfg.Source.RepoForBase() + baseCtx := repoContexts[baseRepoStr] + if baseCtx == nil { + // This should not happen if buildRepoContexts was called correctly + return fmt.Errorf("internal error: no context for base repo %s", baseRepoStr) + } + + teamConfig, err := os.ReadFile(paths.CacheFile(baseCtx.owner, baseCtx.repo)) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("no cached team config found for %s/%s", baseCtx.owner, baseCtx.repo) + } + return fmt.Errorf("failed to read cached config: %w", err) + } + + // Get personal config (optional) + personalConfig, err := readPersonalConfig(paths) + if err != nil { + return err + } + + // Collect all team language directories from all repos (sorted for deterministic output) + var repoKeys []string + for k := range repoContexts { + repoKeys = append(repoKeys, k) + } + sort.Strings(repoKeys) + var teamLangDirs []string + for _, k := range repoKeys { + ctx := repoContexts[k] + teamLangDirs = append(teamLangDirs, paths.TeamLanguagesDir(ctx.owner, ctx.repo)) + } + + // Resolve active languages (sorted for deterministic output) + activeLanguages := resolveActiveLanguages(cfg, teamLangDirs, paths.PersonalLanguages) + + // Load language files from their respective source repos + languageFiles := loadMultiSourceLanguageFiles(cfg, paths, repoContexts, activeLanguages) + + return mergeAndWriteConfig(cfg, paths, teamConfig, personalConfig, activeLanguages, languageFiles) +} + +// loadMultiSourceLanguageFiles loads language files from their respective source repos. +func loadMultiSourceLanguageFiles(cfg *config.Config, paths *config.Paths, repoContexts map[string]*repoContext, activeLanguages []string) map[string][]*language.LanguageFile { + if len(activeLanguages) == 0 { + return nil + } + + languageFiles := make(map[string][]*language.LanguageFile) + for _, lang := range activeLanguages { + // Determine the source repo for this language + sourceRepoStr := cfg.Source.RepoForLanguage(lang) + repoCtx := repoContexts[sourceRepoStr] + var teamLangDir string + if repoCtx != nil { + teamLangDir = paths.TeamLanguagesDir(repoCtx.owner, repoCtx.repo) + } + + files, err := language.LoadLanguageFiles( + []string{lang}, + teamLangDir, + paths.PersonalLanguages, + "", + ) + if err != nil { + printWarning("Failed to load language files for %s: %v", lang, err) + continue + } + if langFiles, ok := files[lang]; ok { + languageFiles[lang] = langFiles + } + } + return languageFiles +} diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index e7b3daa..3b4f67f 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -3,11 +3,14 @@ package cli import ( "os" "path/filepath" + "sort" "strings" "testing" "github.com/HartBrook/staghorn/internal/config" "github.com/HartBrook/staghorn/internal/merge" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSyncProducesProvenanceComments(t *testing.T) { @@ -272,3 +275,229 @@ Custom section.` t.Error("output should contain personal-only section") } } + +// Multi-source tests + +func TestMultiSourceConfigDetection(t *testing.T) { + tests := []struct { + name string + source config.Source + isMultiSource bool + }{ + { + name: "simple string source", + source: config.Source{Simple: "acme/standards"}, + isMultiSource: false, + }, + { + name: "multi-source with languages", + source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Languages: map[string]string{ + "python": "community/python-standards", + }, + }, + }, + isMultiSource: true, + }, + { + name: "multi-source with commands", + source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Commands: map[string]string{ + "security-audit": "security/audits", + }, + }, + }, + isMultiSource: true, + }, + { + name: "multi-source with base override", + source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Base: "acme/base-config", + }, + }, + isMultiSource: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.source.IsMultiSource() + assert.Equal(t, tt.isMultiSource, result, "IsMultiSource() mismatch") + }) + } +} + +func TestMultiSourceRepoResolution(t *testing.T) { + source := config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Base: "acme/base-config", + Languages: map[string]string{ + "python": "community/python-standards", + "go": "acme/go-standards", + }, + Commands: map[string]string{ + "security-audit": "security/audits", + }, + }, + } + + tests := []struct { + name string + method string + arg string + expected string + }{ + {"default repo", "default", "", "acme/standards"}, + {"base repo", "base", "", "acme/base-config"}, + {"python language", "language", "python", "community/python-standards"}, + {"go language", "language", "go", "acme/go-standards"}, + {"rust language (fallback)", "language", "rust", "acme/standards"}, + {"security-audit command", "command", "security-audit", "security/audits"}, + {"code-review command (fallback)", "command", "code-review", "acme/standards"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var result string + switch tt.method { + case "default": + result = source.DefaultRepo() + case "base": + result = source.RepoForBase() + case "language": + result = source.RepoForLanguage(tt.arg) + case "command": + result = source.RepoForCommand(tt.arg) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMultiSourceAllRepos(t *testing.T) { + source := config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Base: "acme/base-config", + Languages: map[string]string{ + "python": "community/python-standards", + "go": "acme/standards", // duplicate of default + }, + Commands: map[string]string{ + "security-audit": "security/audits", + }, + }, + } + + repos := source.AllRepos() + + // Should have 4 unique repos (acme/standards appears twice but deduplicated) + require.Len(t, repos, 4, "AllRepos() should return 4 unique repos") + + // Sort for consistent comparison + sort.Strings(repos) + expected := []string{"acme/base-config", "acme/standards", "community/python-standards", "security/audits"} + sort.Strings(expected) + + assert.Equal(t, expected, repos) +} + +func TestApplyMultiSourceConfigIntegration(t *testing.T) { + // Integration test for multi-source config merging + tempHome := t.TempDir() + originalHome := os.Getenv("HOME") + err := os.Setenv("HOME", tempHome) + require.NoError(t, err, "failed to set HOME") + defer func() { _ = os.Setenv("HOME", originalHome) }() + + // Create directory structure + configDir := filepath.Join(tempHome, ".config", "staghorn") + cacheDir := filepath.Join(tempHome, ".cache", "staghorn") + for _, dir := range []string{configDir, cacheDir} { + err := os.MkdirAll(dir, 0755) + require.NoError(t, err, "failed to create dir") + } + + // Setup: base config from acme/base-config + baseContent := `## Core Principles + +Base principles here.` + baseCacheFile := filepath.Join(cacheDir, "acme-base-config.md") + err = os.WriteFile(baseCacheFile, []byte(baseContent), 0644) + require.NoError(t, err, "failed to write base cache") + + // Setup: python language from community/python-standards + pythonLangDir := filepath.Join(cacheDir, "community-python-standards-languages") + err = os.MkdirAll(pythonLangDir, 0755) + require.NoError(t, err, "failed to create python lang dir") + pythonContent := `## Python Guidelines + +Use type hints.` + err = os.WriteFile(filepath.Join(pythonLangDir, "python.md"), []byte(pythonContent), 0644) + require.NoError(t, err, "failed to write python config") + + // Setup: go language from default (acme/standards) + goLangDir := filepath.Join(cacheDir, "acme-standards-languages") + err = os.MkdirAll(goLangDir, 0755) + require.NoError(t, err, "failed to create go lang dir") + goContent := `## Go Guidelines + +Use gofmt.` + err = os.WriteFile(filepath.Join(goLangDir, "go.md"), []byte(goContent), 0644) + require.NoError(t, err, "failed to write go config") + + // Create multi-source config + cfg := &config.Config{ + Source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Base: "acme/base-config", + Languages: map[string]string{ + "python": "community/python-standards", + }, + }, + }, + Languages: config.LanguageConfig{ + Enabled: []string{"python", "go"}, + }, + } + + paths := config.NewPathsWithOverrides(configDir, cacheDir) + + // Create repo contexts + repoContexts := map[string]*repoContext{ + "acme/standards": {owner: "acme", repo: "standards", branch: "main"}, + "acme/base-config": {owner: "acme", repo: "base-config", branch: "main"}, + "community/python-standards": {owner: "community", repo: "python-standards", branch: "main"}, + } + + // Run applyConfigFromMultiSource + err = applyConfigFromMultiSource(cfg, paths, repoContexts) + require.NoError(t, err, "applyConfigFromMultiSource failed") + + // Read output + outputPath := filepath.Join(tempHome, ".claude", "CLAUDE.md") + output, err := os.ReadFile(outputPath) + require.NoError(t, err, "failed to read output") + + outputStr := string(output) + + // Verify base content is present + assert.Contains(t, outputStr, "Base principles here", "output should contain base config content") + + // Verify python content from community repo + assert.Contains(t, outputStr, "Use type hints", "output should contain python content from community repo") + + // Verify go content from default repo + assert.Contains(t, outputStr, "Use gofmt", "output should contain go content from default repo") + + // Verify header + assert.Contains(t, outputStr, "Managed by staghorn", "output should contain staghorn header") +} diff --git a/internal/github/client.go b/internal/github/client.go index f161ee8..9419807 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -15,6 +15,14 @@ type Client struct { rest *api.RESTClient } +// IsNotFoundError returns true if the error is a 404 Not Found from the GitHub API. +func IsNotFoundError(err error) bool { + if httpErr, ok := err.(*api.HTTPError); ok { + return httpErr.StatusCode == http.StatusNotFound + } + return false +} + // FetchResult contains the result of a fetch operation. type FetchResult struct { Content string diff --git a/internal/integration/fixtures.go b/internal/integration/fixtures.go index e44ee14..4ea8229 100644 --- a/internal/integration/fixtures.go +++ b/internal/integration/fixtures.go @@ -20,9 +20,18 @@ type Fixture struct { // FixtureSetup defines the test environment setup. type FixtureSetup struct { - Team *TeamSetup `yaml:"team"` - Personal *PersonalSetup `yaml:"personal"` - Config *ConfigSetup `yaml:"config"` + Team *TeamSetup `yaml:"team"` + MultiSource []MultiSourceRepo `yaml:"multi_source,omitempty"` + Personal *PersonalSetup `yaml:"personal"` + Config *ConfigSetup `yaml:"config"` +} + +// MultiSourceRepo defines content from a specific repository in multi-source setup. +type MultiSourceRepo struct { + Source string `yaml:"source"` + ClaudeMD string `yaml:"claude_md,omitempty"` + Languages map[string]string `yaml:"languages,omitempty"` + Commands map[string]string `yaml:"commands,omitempty"` } // TeamSetup simulates the team repo content. @@ -41,10 +50,18 @@ type PersonalSetup struct { // ConfigSetup defines the staghorn config.yaml content. type ConfigSetup struct { Version int `yaml:"version"` - Source string `yaml:"source"` + Source interface{} `yaml:"source"` // Can be string or SourceConfigSetup Languages *LanguagesSetup `yaml:"languages"` } +// SourceConfigSetup defines multi-source configuration in fixtures. +type SourceConfigSetup struct { + Default string `yaml:"default"` + Base string `yaml:"base,omitempty"` + Languages map[string]string `yaml:"languages,omitempty"` + Commands map[string]string `yaml:"commands,omitempty"` +} + // LanguagesSetup defines language configuration. type LanguagesSetup struct { Enabled []string `yaml:"enabled"` @@ -107,10 +124,11 @@ func (f *Fixture) Validate() error { if f.Name == "" { return fmt.Errorf("missing required field: name") } - if f.Setup.Team == nil { - return fmt.Errorf("missing required field: setup.team") + // Either Team or MultiSource must be present + if f.Setup.Team == nil && len(f.Setup.MultiSource) == 0 { + return fmt.Errorf("missing required field: setup.team or setup.multi_source") } - if f.Setup.Team.Source == "" { + if f.Setup.Team != nil && f.Setup.Team.Source == "" { return fmt.Errorf("missing required field: setup.team.source") } if f.Setup.Config == nil { @@ -147,10 +165,52 @@ func LoadAllFixtures(dir string) ([]*Fixture, error) { } // ToConfig converts fixture config setup to a config.Config. +// It logs warnings for malformed or missing fields in the source configuration. func (c *ConfigSetup) ToConfig() *config.Config { cfg := &config.Config{ Version: c.Version, - Source: config.Source{Simple: c.Source}, + } + + // Handle source - can be string or SourceConfigSetup + switch src := c.Source.(type) { + case string: + cfg.Source = config.Source{Simple: src} + case map[string]interface{}: + // YAML unmarshals objects as map[string]interface{} + multi := &config.SourceConfig{} + if def, ok := src["default"].(string); ok { + multi.Default = def + } + if base, ok := src["base"].(string); ok { + multi.Base = base + } + if langs, ok := src["languages"].(map[string]interface{}); ok { + multi.Languages = make(map[string]string) + for k, v := range langs { + if vs, ok := v.(string); ok { + multi.Languages[k] = vs + } + } + } + if cmds, ok := src["commands"].(map[string]interface{}); ok { + multi.Commands = make(map[string]string) + for k, v := range cmds { + if vs, ok := v.(string); ok { + multi.Commands[k] = vs + } + } + } + // Validate that default is set for multi-source configs + if multi.Default == "" { + // Fall back to simple source if default is missing + cfg.Source = config.Source{} + } else { + cfg.Source = config.Source{Multi: multi} + } + case nil: + // Source is nil - this will be caught by validation + default: + // Unknown type - leave source empty } if c.Languages != nil { @@ -163,24 +223,50 @@ func (c *ConfigSetup) ToConfig() *config.Config { // ApplySetup applies the fixture setup to a test environment. func ApplySetup(env *TestEnv, setup FixtureSetup) error { - if setup.Team == nil { - return nil - } + // Handle multi-source setup + if len(setup.MultiSource) > 0 { + for _, src := range setup.MultiSource { + owner, repo := parseOwnerRepo(src.Source) + + // Setup base config for this source + if src.ClaudeMD != "" { + if err := env.SetupTeamConfig(owner, repo, src.ClaudeMD); err != nil { + return err + } + } - // Parse owner/repo from source - owner, repo := parseOwnerRepo(setup.Team.Source) + // Setup languages for this source + for lang, content := range src.Languages { + if err := env.SetupTeamLanguage(owner, repo, lang, content); err != nil { + return err + } + } - // Setup team config - if setup.Team.ClaudeMD != "" { - if err := env.SetupTeamConfig(owner, repo, setup.Team.ClaudeMD); err != nil { - return err + // Setup commands for this source + for cmd, content := range src.Commands { + if err := env.SetupTeamCommand(owner, repo, cmd, content); err != nil { + return err + } + } } } - // Setup team languages - for lang, content := range setup.Team.Languages { - if err := env.SetupTeamLanguage(owner, repo, lang, content); err != nil { - return err + // Handle single-source team setup (backwards compatible) + if setup.Team != nil { + owner, repo := parseOwnerRepo(setup.Team.Source) + + // Setup team config + if setup.Team.ClaudeMD != "" { + if err := env.SetupTeamConfig(owner, repo, setup.Team.ClaudeMD); err != nil { + return err + } + } + + // Setup team languages + for lang, content := range setup.Team.Languages { + if err := env.SetupTeamLanguage(owner, repo, lang, content); err != nil { + return err + } } } diff --git a/internal/integration/harness.go b/internal/integration/harness.go index 9d82cc0..1f949a0 100644 --- a/internal/integration/harness.go +++ b/internal/integration/harness.go @@ -4,6 +4,7 @@ package integration import ( "os" "path/filepath" + "sort" "testing" "github.com/HartBrook/staghorn/internal/config" @@ -74,6 +75,15 @@ func (e *TestEnv) SetupTeamLanguage(owner, repo, lang, content string) error { return os.WriteFile(filepath.Join(langDir, lang+".md"), []byte(content), 0644) } +// SetupTeamCommand writes a team command to cache. +func (e *TestEnv) SetupTeamCommand(owner, repo, cmd, content string) error { + cmdDir := e.Paths.TeamCommandsDir(owner, repo) + if err := os.MkdirAll(cmdDir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(cmdDir, cmd+".md"), []byte(content), 0644) +} + // SetupPersonalConfig writes personal.md. func (e *TestEnv) SetupPersonalConfig(content string) error { return os.WriteFile(e.Paths.PersonalMD, []byte(content), 0644) @@ -253,3 +263,104 @@ func (e *TestEnv) RunSync(owner, repo string, cfg *config.Config) error { // Write to output return os.WriteFile(e.GetOutputPath(), []byte(output), 0644) } + +// RunMultiSourceSync executes the merge for multi-source configurations. +// It reads the base config from the base repo and languages from their respective repos. +func (e *TestEnv) RunMultiSourceSync(cfg *config.Config) error { + // Get base repo + baseRepoStr := cfg.Source.RepoForBase() + baseOwner, baseRepo, err := config.ParseRepo(baseRepoStr) + if err != nil { + return err + } + + // Read team config from base repo cache + teamConfig, err := os.ReadFile(e.Paths.CacheFile(baseOwner, baseRepo)) + if err != nil { + return err + } + + // Read personal config (optional) + var personalConfig []byte + if _, err := os.Stat(e.Paths.PersonalMD); err == nil { + personalConfig, err = os.ReadFile(e.Paths.PersonalMD) + if err != nil { + return err + } + } + + // Resolve active languages + var activeLanguages []string + if len(cfg.Languages.Enabled) > 0 { + activeLanguages = language.FilterDisabled(cfg.Languages.Enabled, cfg.Languages.Disabled) + } else { + // Collect available languages from all source repos + availableLanguages := make(map[string]bool) + for _, repoStr := range cfg.Source.AllRepos() { + owner, repo, err := config.ParseRepo(repoStr) + if err != nil { + continue + } + teamLangDir := e.Paths.TeamLanguagesDir(owner, repo) + langs, _ := language.ListAvailableLanguages(teamLangDir, "", "") + for _, lang := range langs { + availableLanguages[lang] = true + } + } + // Also check personal languages + personalLangs, _ := language.ListAvailableLanguages("", e.Paths.PersonalLanguages, "") + for _, lang := range personalLangs { + availableLanguages[lang] = true + } + for lang := range availableLanguages { + activeLanguages = append(activeLanguages, lang) + } + // Sort for deterministic output + sort.Strings(activeLanguages) + activeLanguages = language.FilterDisabled(activeLanguages, cfg.Languages.Disabled) + } + + // Load language files from their respective source repos + var languageFiles map[string][]*language.LanguageFile + if len(activeLanguages) > 0 { + languageFiles = make(map[string][]*language.LanguageFile) + for _, lang := range activeLanguages { + // Determine the source repo for this language + sourceRepoStr := cfg.Source.RepoForLanguage(lang) + owner, repo, err := config.ParseRepo(sourceRepoStr) + if err != nil { + continue + } + teamLangDir := e.Paths.TeamLanguagesDir(owner, repo) + + files, err := language.LoadLanguageFiles( + []string{lang}, + teamLangDir, + e.Paths.PersonalLanguages, + "", + ) + if err != nil { + continue + } + if langFiles, ok := files[lang]; ok { + languageFiles[lang] = langFiles + } + } + } + + // Merge configs + layers := []merge.Layer{ + {Content: string(teamConfig), Source: "team"}, + {Content: string(personalConfig), Source: "personal"}, + } + mergeOpts := merge.MergeOptions{ + AnnotateSources: true, + SourceRepo: cfg.SourceRepo(), + Languages: activeLanguages, + LanguageFiles: languageFiles, + } + output := merge.MergeWithLanguages(layers, mergeOpts) + + // Write to output + return os.WriteFile(e.GetOutputPath(), []byte(output), 0644) +} diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go index c00e9e1..d6cd121 100644 --- a/internal/integration/integration_test.go +++ b/internal/integration/integration_test.go @@ -48,8 +48,10 @@ func runFixture(t *testing.T, fixture *Fixture) { t.Helper() // Validate fixture has required fields - require.NotNil(t, fixture.Setup.Team, "fixture must have team setup") require.NotNil(t, fixture.Setup.Config, "fixture must have config setup") + // Either Team or MultiSource must be present + require.True(t, fixture.Setup.Team != nil || len(fixture.Setup.MultiSource) > 0, + "fixture must have team or multi_source setup") // Create isolated environment env := NewTestEnv(t) @@ -59,14 +61,18 @@ func runFixture(t *testing.T, fixture *Fixture) { err := ApplySetup(env, fixture.Setup) require.NoError(t, err, "failed to apply setup") - // Get owner/repo from config - owner, repo := parseOwnerRepo(fixture.Setup.Team.Source) - // Get config cfg := fixture.Setup.Config.ToConfig() - // Run sync - err = env.RunSync(owner, repo, cfg) + // Run sync - use multi-source sync if configured + if cfg.Source.IsMultiSource() { + err = env.RunMultiSourceSync(cfg) + } else { + // Get owner/repo from team source + require.NotNil(t, fixture.Setup.Team, "single-source fixture must have team setup") + owner, repo := parseOwnerRepo(fixture.Setup.Team.Source) + err = env.RunSync(owner, repo, cfg) + } require.NoError(t, err, "RunSync failed") // Check output exists @@ -464,3 +470,194 @@ Personal-only section.` assert.Greater(t, personalContentIdx, teamContentIdx, "team content should appear before personal additions") } + +// TestIntegration_MultiSourceLanguages tests multi-source language configuration. +func TestIntegration_MultiSourceLanguages(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + // Setup: base config from acme/standards + baseContent := `## Core Principles + +Base principles from acme/standards.` + err := env.SetupTeamConfig("acme", "standards", baseContent) + require.NoError(t, err) + + // Setup: python language from community/python-standards + pythonContent := `## Python Guidelines + +Use type hints from community repo.` + err = env.SetupTeamLanguage("community", "python-standards", "python", pythonContent) + require.NoError(t, err) + + // Setup: go language from default (acme/standards) + goContent := `## Go Guidelines + +Use gofmt from default repo.` + err = env.SetupTeamLanguage("acme", "standards", "go", goContent) + require.NoError(t, err) + + // Create multi-source config + cfg := &config.Config{ + Version: 1, + Source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Languages: map[string]string{ + "python": "community/python-standards", + }, + }, + }, + Languages: config.LanguageConfig{ + Enabled: []string{"python", "go"}, + }, + } + err = env.SetupConfig(cfg) + require.NoError(t, err) + + // Run multi-source sync + err = env.RunMultiSourceSync(cfg) + require.NoError(t, err) + + output, err := env.ReadOutput() + require.NoError(t, err) + + asserter := NewAsserter(t, output) + + // Verify base content is present + assert.True(t, asserter.ContainsText("Base principles from acme/standards"), + "should contain base config content") + + // Verify python content from community repo + assert.True(t, asserter.ContainsText("Use type hints from community repo"), + "should contain python content from community repo") + + // Verify go content from default repo + assert.True(t, asserter.ContainsText("Use gofmt from default repo"), + "should contain go content from default repo") + + // Verify header + assert.True(t, asserter.HasManagedHeader(), "should have managed header") +} + +// TestIntegration_MultiSourceWithBaseOverride tests base config from different repo. +func TestIntegration_MultiSourceWithBaseOverride(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + // Setup: base config from acme/base-config (NOT the default) + baseContent := `## Base Config + +Content from separate base repo.` + err := env.SetupTeamConfig("acme", "base-config", baseContent) + require.NoError(t, err) + + // Setup: language from default repo (acme/standards) + pythonContent := `## Python + +Python from default repo.` + err = env.SetupTeamLanguage("acme", "standards", "python", pythonContent) + require.NoError(t, err) + + // Create multi-source config with base override + cfg := &config.Config{ + Version: 1, + Source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Base: "acme/base-config", + }, + }, + Languages: config.LanguageConfig{ + Enabled: []string{"python"}, + }, + } + err = env.SetupConfig(cfg) + require.NoError(t, err) + + err = env.RunMultiSourceSync(cfg) + require.NoError(t, err) + + output, err := env.ReadOutput() + require.NoError(t, err) + + asserter := NewAsserter(t, output) + + // Verify base content is from override repo + assert.True(t, asserter.ContainsText("Content from separate base repo"), + "should contain base config from override repo") + + // Verify python content is from default repo + assert.True(t, asserter.ContainsText("Python from default repo"), + "should contain python from default repo") +} + +// TestIntegration_MultiSourceFallback tests that unconfigured languages use default. +func TestIntegration_MultiSourceFallback(t *testing.T) { + env := NewTestEnv(t) + defer env.Cleanup() + + // Setup: base config + baseContent := `## Standards + +Team standards.` + err := env.SetupTeamConfig("acme", "standards", baseContent) + require.NoError(t, err) + + // Setup: python explicitly configured to community repo + pythonContent := `## Python + +Community Python.` + err = env.SetupTeamLanguage("community", "python-standards", "python", pythonContent) + require.NoError(t, err) + + // Setup: go NOT explicitly configured - should fallback to default + goContent := `## Go + +Default Go.` + err = env.SetupTeamLanguage("acme", "standards", "go", goContent) + require.NoError(t, err) + + // Setup: rust NOT explicitly configured - should fallback to default + rustContent := `## Rust + +Default Rust.` + err = env.SetupTeamLanguage("acme", "standards", "rust", rustContent) + require.NoError(t, err) + + cfg := &config.Config{ + Version: 1, + Source: config.Source{ + Multi: &config.SourceConfig{ + Default: "acme/standards", + Languages: map[string]string{ + "python": "community/python-standards", + // go and rust not specified - should use default + }, + }, + }, + Languages: config.LanguageConfig{ + Enabled: []string{"python", "go", "rust"}, + }, + } + err = env.SetupConfig(cfg) + require.NoError(t, err) + + err = env.RunMultiSourceSync(cfg) + require.NoError(t, err) + + output, err := env.ReadOutput() + require.NoError(t, err) + + asserter := NewAsserter(t, output) + + // Python from community repo + assert.True(t, asserter.ContainsText("Community Python"), + "python should come from community repo") + + // Go and Rust from default repo + assert.True(t, asserter.ContainsText("Default Go"), + "go should fallback to default repo") + assert.True(t, asserter.ContainsText("Default Rust"), + "rust should fallback to default repo") +} diff --git a/internal/integration/testdata/fixtures/multi_source_basic.yaml b/internal/integration/testdata/fixtures/multi_source_basic.yaml new file mode 100644 index 0000000..12aaabf --- /dev/null +++ b/internal/integration/testdata/fixtures/multi_source_basic.yaml @@ -0,0 +1,49 @@ +name: "multi_source_basic" +description: "Basic multi-source configuration with python from different repo" + +setup: + multi_source: + - source: "acme/standards" + claude_md: | + ## Core Principles + + Base principles from default repo. + languages: + go: | + ## Go Guidelines + + Use gofmt from default repo. + + - source: "community/python-standards" + languages: + python: | + ## Python Guidelines + + Use type hints from community repo. + + config: + version: 1 + source: + default: "acme/standards" + languages: + python: "community/python-standards" + languages: + enabled: ["python", "go"] + +assertions: + output_exists: true + header: + managed_by: true + contains: + - "Base principles from default repo" + - "Use type hints from community repo" + - "Use gofmt from default repo" + languages: + - name: "python" + has_team_content: true + contains: + - "Use type hints from community repo" + - name: "go" + has_team_content: true + contains: + - "Use gofmt from default repo" diff --git a/internal/integration/testdata/fixtures/multi_source_fallback.yaml b/internal/integration/testdata/fixtures/multi_source_fallback.yaml new file mode 100644 index 0000000..ca398e1 --- /dev/null +++ b/internal/integration/testdata/fixtures/multi_source_fallback.yaml @@ -0,0 +1,59 @@ +name: "multi_source_fallback" +description: "Unconfigured languages fall back to default repository" + +setup: + multi_source: + - source: "acme/standards" + claude_md: | + ## Base Config + + Default repository content. + languages: + go: | + ## Go Guidelines + + Go from default (fallback). + rust: | + ## Rust Guidelines + + Rust from default (fallback). + + - source: "community/python-standards" + languages: + python: | + ## Python Guidelines + + Python from explicit source. + + config: + version: 1 + source: + default: "acme/standards" + languages: + python: "community/python-standards" + # go and rust NOT specified - should use default + languages: + enabled: ["python", "go", "rust"] + +assertions: + output_exists: true + header: + managed_by: true + contains: + - "Default repository content" + - "Python from explicit source" + - "Go from default (fallback)" + - "Rust from default (fallback)" + languages: + - name: "python" + has_team_content: true + contains: + - "Python from explicit source" + - name: "go" + has_team_content: true + contains: + - "Go from default (fallback)" + - name: "rust" + has_team_content: true + contains: + - "Rust from default (fallback)" diff --git a/internal/integration/testdata/fixtures/multi_source_languages.yaml b/internal/integration/testdata/fixtures/multi_source_languages.yaml new file mode 100644 index 0000000..339aa1c --- /dev/null +++ b/internal/integration/testdata/fixtures/multi_source_languages.yaml @@ -0,0 +1,71 @@ +name: "multi_source_languages" +description: "Multiple languages from different repositories" + +setup: + multi_source: + - source: "acme/standards" + claude_md: | + ## Code Standards + + Team code standards. + languages: + go: | + ## Go Guidelines + + Use gofmt and go vet. + rust: | + ## Rust Guidelines + + Use cargo fmt. + + - source: "community/python-standards" + languages: + python: | + ## Python Guidelines + + Use type hints and ruff. + + - source: "frontend/typescript-config" + languages: + typescript: | + ## TypeScript Guidelines + + Enable strict mode. + + config: + version: 1 + source: + default: "acme/standards" + languages: + python: "community/python-standards" + typescript: "frontend/typescript-config" + languages: + enabled: ["python", "go", "rust", "typescript"] + +assertions: + output_exists: true + header: + managed_by: true + contains: + - "Team code standards" + - "Use type hints and ruff" + - "Use gofmt and go vet" + - "Use cargo fmt" + - "Enable strict mode" + languages: + - name: "python" + has_team_content: true + contains: + - "Use type hints and ruff" + - name: "go" + has_team_content: true + contains: + - "Use gofmt" + - name: "rust" + has_team_content: true + contains: + - "Use cargo fmt" + - name: "typescript" + has_team_content: true + contains: + - "Enable strict mode"