Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
808 changes: 656 additions & 152 deletions internal/cli/sync.go

Large diffs are not rendered by default.

229 changes: 229 additions & 0 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
}
8 changes: 8 additions & 0 deletions internal/github/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading