From 39d8157363b617bb0229cf3f552210ccaf8ce54e Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Wed, 11 Feb 2026 16:25:16 +0100 Subject: [PATCH] feat: add caching for generate command Cache GitHub-discovered repos and template variables in ~/.yankrun/cache.yaml to avoid redundant API calls and cloning. Dry runs use cached data when available. Add --noCache flag to bypass. --- README.md | 11 +- actions/generate.go | 96 +++++++++++-- docs/user/README.md | 9 ++ domain/cache.go | 14 ++ flags.go | 5 + main.go | 2 +- services/cache.go | 122 ++++++++++++++++ services/cache_test.go | 314 +++++++++++++++++++++++++++++++++++++++++ services/cloner.go | 13 ++ 9 files changed, 574 insertions(+), 12 deletions(-) create mode 100644 domain/cache.go create mode 100644 services/cache.go create mode 100644 services/cache_test.go diff --git a/README.md b/README.md index 76968ac..51cbaac 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ yankrun template --dir ./my-project --input values.yaml --verbose - **JSON/YAML inputs** and ignore patterns - **Transformation functions** (`toUpperCase`, `toLowerCase`, `gsub`) - **Template file processing** (`.tpl` files processed and renamed) +- **Caching** for `generate` - caches GitHub repos and template variables in `~/.yankrun/cache.yaml` ## Documentation @@ -236,12 +237,18 @@ yankrun generate --prompt --verbose # Non-interactive with template filter yankrun generate --template "go-service" --input values.yaml --outputDir ./new-project -# With branch selection -yankrun generate --template "api" --branch "feature/v2" --outputDir ./new-api +# Dry run uses cache to show placeholders without cloning +yankrun generate --template "go-service" --dryRun + +# Force fresh data (skip cache) +yankrun generate --template "go-service" --noCache --outputDir ./new-project ``` Requires templates configured in `~/.yankrun/config.yaml` or GitHub discovery enabled. +The `generate` command caches GitHub-discovered repos and template variables in `~/.yankrun/cache.yaml`. Subsequent dry runs use cached data to avoid re-cloning. Use `--noCache` to bypass. + +
diff --git a/actions/generate.go b/actions/generate.go index 5199ff5..35da896 100644 --- a/actions/generate.go +++ b/actions/generate.go @@ -42,6 +42,7 @@ func (a *GenerateAction) Execute(c *cli.Context) error { onlyTemplates := c.Bool("onlyTemplates") dryRun := c.Bool("dryRun") ignoreFlags := c.StringSlice("ignore") + noCache := c.Bool("noCache") // Validate flag combination if onlyTemplates && !processTemplates { @@ -91,6 +92,10 @@ func (a *GenerateAction) Execute(c *cli.Context) error { fileSizeLimit = "3 mb" } + // Load cache + cache, _ := services.LoadCache() + cacheUpdated := false + r := bufio.NewReader(os.Stdin) // Aggregate configured repos + discovered GitHub repos @@ -100,15 +105,27 @@ func (a *GenerateAction) Execute(c *cli.Context) error { repos = append(repos, domain.TemplateRepo{Name: templateFilter, URL: templateFilter, DefaultBranch: "main"}) } if cfg.GitHub.User != "" || len(cfg.GitHub.Orgs) > 0 { - ghClient := services.NewGitHubClient() - found, err := ghClient.ListRepos(context.Background(), cfg.GitHub) - if err != nil { - helpers.Log.Warn().Err(err).Msg("Failed to discover GitHub repos") - } - for _, fr := range found { - repos = append(repos, domain.TemplateRepo{ - Name: fr.FullName, URL: fr.SSHURL, Description: fr.Description, DefaultBranch: fr.DefaultBranch, - }) + configSHA := services.GitHubConfigSHA(cfg.GitHub) + if !noCache && configSHA == cache.GitHubConfigSHA && len(cache.GitHubRepos) > 0 { + repos = append(repos, cache.GitHubRepos...) + helpers.Log.Debug().Msg("Using cached GitHub repos") + } else { + ghClient := services.NewGitHubClient() + found, err := ghClient.ListRepos(context.Background(), cfg.GitHub) + if err != nil { + helpers.Log.Warn().Err(err).Msg("Failed to discover GitHub repos") + } + var ghRepos []domain.TemplateRepo + for _, fr := range found { + tr := domain.TemplateRepo{ + Name: fr.FullName, URL: fr.SSHURL, Description: fr.Description, DefaultBranch: fr.DefaultBranch, + } + repos = append(repos, tr) + ghRepos = append(ghRepos, tr) + } + cache.GitHubConfigSHA = configSHA + cache.GitHubRepos = ghRepos + cacheUpdated = true } } if len(repos) == 0 { @@ -219,6 +236,45 @@ func (a *GenerateAction) Execute(c *cli.Context) error { } } + // Dry-run with cache: show cached variables without cloning + if dryRun && !noCache { + if cached, ok := services.LookupVars(cache, chosen.URL, br); ok { + var provided domain.InputReplacement + if input != "" { + provided, _ = a.parser.Parse(input) + } + values := map[string]string{} + for _, rpl := range provided.Variables { + values[rpl.Key] = rpl.Value + } + + helpers.Log.Info().Msgf("Using cached template data (SHA: %s)", cached.SHA) + helpers.Log.Info().Msg("Discovered placeholders:") + keys := make([]string, 0, len(cached.Variables)) + for k := range cached.Variables { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := values[k] + if v == "" { + v = "(unset)" + } + fmt.Printf(" %-24s matches=%-6d value=%s\n", k, cached.Variables[k], v) + } + + totalMatches := 0 + for _, c := range cached.Variables { + totalMatches += c + } + helpers.Log.Info().Msgf("Dry run (cached): %d placeholders with %d total matches. No files modified.", len(cached.Variables), totalMatches) + if cacheUpdated { + _ = services.SaveCache(cache) + } + return nil + } + } + if outputDir == "" { fmt.Printf("Output directory [./new-project]: ") out, _ := r.ReadString('\n') @@ -238,6 +294,9 @@ func (a *GenerateAction) Execute(c *cli.Context) error { } helpers.Log.Info().Msgf("Cloned %s@%s into %s", chosen.Name, br, outputDir) + // Get HEAD SHA before removing .git for cache + headSHA, _ := services.HeadSHA(outputDir) + // Remove .git directory to make it a fresh repo gitDir := filepath.Join(outputDir, ".git") if err := os.RemoveAll(gitDir); err != nil { @@ -262,8 +321,18 @@ func (a *GenerateAction) Execute(c *cli.Context) error { if err != nil { return err } + + // Update cache with discovered variables + if headSHA != "" && len(counts) > 0 { + services.UpdateVars(cache, chosen.URL, br, headSHA, counts) + cacheUpdated = true + } + if len(counts) == 0 { helpers.Log.Info().Msg("No placeholders found.") + if cacheUpdated { + _ = services.SaveCache(cache) + } return nil } @@ -312,6 +381,9 @@ func (a *GenerateAction) Execute(c *cli.Context) error { if len(final.Variables) == 0 { helpers.Log.Info().Msg("No values provided; nothing to replace.") + if cacheUpdated { + _ = services.SaveCache(cache) + } return nil } @@ -322,6 +394,9 @@ func (a *GenerateAction) Execute(c *cli.Context) error { totalMatches += c } helpers.Log.Info().Msgf("Dry run: %d replacements across %d placeholders would be applied. No files modified.", totalMatches, len(final.Variables)) + if cacheUpdated { + _ = services.SaveCache(cache) + } return nil } @@ -341,6 +416,9 @@ func (a *GenerateAction) Execute(c *cli.Context) error { } helpers.Log.Info().Msg("Templating complete.") + if cacheUpdated { + _ = services.SaveCache(cache) + } return nil } diff --git a/docs/user/README.md b/docs/user/README.md index 4c6bb16..9d5285c 100644 --- a/docs/user/README.md +++ b/docs/user/README.md @@ -263,6 +263,7 @@ yankrun generate [options] | `--input` | `-i` | Values file path | - | | `--prompt` | `-p` | Interactive mode | `false` | | `--verbose` | `-v` | Show detailed output | `false` | +| `--noCache` | `--nc` | Bypass cache, fetch fresh data | `false` |
@@ -276,6 +277,12 @@ yankrun generate --prompt --verbose # Filter templates and auto-select yankrun generate --template "go-service" --outputDir ./new-service +# Dry run - shows cached placeholders without cloning +yankrun generate --template "go-service" --dryRun + +# Force fresh data (skip cache) +yankrun generate --template "go-service" --noCache --outputDir ./new-service + # Non-interactive with values yankrun generate \ --template "api-template" \ @@ -286,6 +293,8 @@ yankrun generate \ +**Caching:** The `generate` command caches GitHub-discovered repos and template variables in `~/.yankrun/cache.yaml`. Dry runs use cached data when available, avoiding re-cloning. Use `--noCache` to force fresh data. + --- ### `setup` diff --git a/domain/cache.go b/domain/cache.go new file mode 100644 index 0000000..0d4e150 --- /dev/null +++ b/domain/cache.go @@ -0,0 +1,14 @@ +package domain + +type Cache struct { + GitHubConfigSHA string `yaml:"github_config_sha"` + GitHubRepos []TemplateRepo `yaml:"github_repos"` + TemplateVars []CachedTemplateVars `yaml:"template_vars"` +} + +type CachedTemplateVars struct { + URL string `yaml:"url"` + Branch string `yaml:"branch"` + SHA string `yaml:"sha"` + Variables map[string]int `yaml:"variables"` +} diff --git a/flags.go b/flags.go index b2eee44..946029f 100644 --- a/flags.go +++ b/flags.go @@ -85,3 +85,8 @@ var ignoreFlag = cli.StringSliceFlag{ Name: "ignore", Usage: "Glob patterns for files/directories to skip (e.g. --ignore '*.generated.*' --ignore 'migrations/*')", } + +var noCacheFlag = cli.BoolFlag{ + Name: "noCache, nc", + Usage: "Bypass cache and fetch fresh data from remote", +} diff --git a/main.go b/main.go index 7fdbdd5..680b90d 100644 --- a/main.go +++ b/main.go @@ -59,7 +59,7 @@ func main() { { Name: "generate", Usage: "Interactively choose a template repo/branch and clone it as a new repo (removes .git)", - Flags: []cli.Flag{inputFlag, outputDirFlag, verboseFlag, fileSizeLimitFlag, startDelimFlag, endDelimFlag, interactiveFlag, templateNameFlag, branchFlag, processTemplatesFlag, onlyTemplatesFlag, dryRunFlag, ignoreFlag}, + Flags: []cli.Flag{inputFlag, outputDirFlag, verboseFlag, fileSizeLimitFlag, startDelimFlag, endDelimFlag, interactiveFlag, templateNameFlag, branchFlag, processTemplatesFlag, onlyTemplatesFlag, dryRunFlag, ignoreFlag, noCacheFlag}, Action: generateAction.Execute, }, { diff --git a/services/cache.go b/services/cache.go new file mode 100644 index 0000000..b9229fc --- /dev/null +++ b/services/cache.go @@ -0,0 +1,122 @@ +package services + +import ( + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/mitchellh/go-homedir" + "gopkg.in/yaml.v3" + + "github.com/AxeForging/yankrun/domain" +) + +func cachePath() (string, error) { + home, err := homedir.Dir() + if err != nil { + return "", err + } + return filepath.Join(home, ".yankrun", "cache.yaml"), nil +} + +// LoadCacheFrom loads cache from a specific path +func LoadCacheFrom(path string) (*domain.Cache, error) { + cache := &domain.Cache{} + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cache, nil + } + return cache, err + } + if err := yaml.Unmarshal(data, cache); err != nil { + return &domain.Cache{}, nil + } + return cache, nil +} + +// SaveCacheTo saves cache to a specific path +func SaveCacheTo(path string, cache *domain.Cache) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + enc := yaml.NewEncoder(f) + enc.SetIndent(2) + return enc.Encode(cache) +} + +// LoadCache loads cache from the default path (~/.yankrun/cache.yaml) +func LoadCache() (*domain.Cache, error) { + path, err := cachePath() + if err != nil { + return &domain.Cache{}, err + } + return LoadCacheFrom(path) +} + +// SaveCache saves cache to the default path (~/.yankrun/cache.yaml) +func SaveCache(cache *domain.Cache) error { + path, err := cachePath() + if err != nil { + return err + } + return SaveCacheTo(path, cache) +} + +// GitHubConfigSHA returns a deterministic hash of the GitHub config for cache invalidation +func GitHubConfigSHA(gh domain.GitHubConfig) string { + h := sha256.New() + h.Write([]byte(gh.User)) + h.Write([]byte{0}) + orgs := make([]string, len(gh.Orgs)) + copy(orgs, gh.Orgs) + sort.Strings(orgs) + for _, o := range orgs { + h.Write([]byte(o)) + h.Write([]byte{0}) + } + h.Write([]byte(gh.Topic)) + h.Write([]byte{0}) + h.Write([]byte(gh.Prefix)) + h.Write([]byte{0}) + if gh.IncludePrivate { + h.Write([]byte("1")) + } else { + h.Write([]byte("0")) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// LookupVars finds cached variables for a given URL and branch +func LookupVars(cache *domain.Cache, url, branch string) (*domain.CachedTemplateVars, bool) { + for i := range cache.TemplateVars { + if cache.TemplateVars[i].URL == url && cache.TemplateVars[i].Branch == branch { + return &cache.TemplateVars[i], true + } + } + return nil, false +} + +// UpdateVars updates or adds cached variables for a given URL and branch +func UpdateVars(cache *domain.Cache, url, branch, sha string, vars map[string]int) { + for i := range cache.TemplateVars { + if cache.TemplateVars[i].URL == url && cache.TemplateVars[i].Branch == branch { + cache.TemplateVars[i].SHA = sha + cache.TemplateVars[i].Variables = vars + return + } + } + cache.TemplateVars = append(cache.TemplateVars, domain.CachedTemplateVars{ + URL: url, + Branch: branch, + SHA: sha, + Variables: vars, + }) +} diff --git a/services/cache_test.go b/services/cache_test.go new file mode 100644 index 0000000..0ba1f6b --- /dev/null +++ b/services/cache_test.go @@ -0,0 +1,314 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/AxeForging/yankrun/domain" +) + +func TestLoadCacheFrom_NonExistent(t *testing.T) { + cache, err := LoadCacheFrom("/nonexistent/path/cache.yaml") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cache.TemplateVars) != 0 { + t.Errorf("expected empty cache, got %d template vars", len(cache.TemplateVars)) + } + if len(cache.GitHubRepos) != 0 { + t.Errorf("expected empty GitHub repos, got %d", len(cache.GitHubRepos)) + } +} + +func TestLoadCacheFrom_Corrupt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.yaml") + if err := os.WriteFile(path, []byte("{{invalid yaml"), 0644); err != nil { + t.Fatal(err) + } + cache, err := LoadCacheFrom(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cache.TemplateVars) != 0 { + t.Errorf("expected empty cache on corrupt file, got %d template vars", len(cache.TemplateVars)) + } +} + +func TestSaveCacheAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.yaml") + + cache := &domain.Cache{ + GitHubConfigSHA: "abc123", + GitHubRepos: []domain.TemplateRepo{ + {Name: "test/repo", URL: "https://github.com/test/repo.git", DefaultBranch: "main"}, + }, + TemplateVars: []domain.CachedTemplateVars{ + {URL: "https://github.com/test/repo.git", Branch: "main", SHA: "def456", Variables: map[string]int{"APP_NAME": 5, "VERSION": 2}}, + }, + } + + if err := SaveCacheTo(path, cache); err != nil { + t.Fatalf("SaveCacheTo failed: %v", err) + } + + loaded, err := LoadCacheFrom(path) + if err != nil { + t.Fatalf("LoadCacheFrom failed: %v", err) + } + + if loaded.GitHubConfigSHA != "abc123" { + t.Errorf("expected GitHubConfigSHA abc123, got %s", loaded.GitHubConfigSHA) + } + if len(loaded.GitHubRepos) != 1 { + t.Fatalf("expected 1 GitHub repo, got %d", len(loaded.GitHubRepos)) + } + if loaded.GitHubRepos[0].Name != "test/repo" { + t.Errorf("expected repo name test/repo, got %s", loaded.GitHubRepos[0].Name) + } + if loaded.GitHubRepos[0].DefaultBranch != "main" { + t.Errorf("expected default branch main, got %s", loaded.GitHubRepos[0].DefaultBranch) + } + if len(loaded.TemplateVars) != 1 { + t.Fatalf("expected 1 template vars entry, got %d", len(loaded.TemplateVars)) + } + if loaded.TemplateVars[0].SHA != "def456" { + t.Errorf("expected SHA def456, got %s", loaded.TemplateVars[0].SHA) + } + if loaded.TemplateVars[0].Variables["APP_NAME"] != 5 { + t.Errorf("expected APP_NAME count 5, got %d", loaded.TemplateVars[0].Variables["APP_NAME"]) + } + if loaded.TemplateVars[0].Variables["VERSION"] != 2 { + t.Errorf("expected VERSION count 2, got %d", loaded.TemplateVars[0].Variables["VERSION"]) + } +} + +func TestSaveCacheTo_CreatesDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "dir", "cache.yaml") + + cache := &domain.Cache{GitHubConfigSHA: "test"} + if err := SaveCacheTo(path, cache); err != nil { + t.Fatalf("SaveCacheTo failed to create parent dirs: %v", err) + } + + loaded, err := LoadCacheFrom(path) + if err != nil { + t.Fatalf("LoadCacheFrom failed: %v", err) + } + if loaded.GitHubConfigSHA != "test" { + t.Errorf("expected SHA test, got %s", loaded.GitHubConfigSHA) + } +} + +func TestGitHubConfigSHA_Deterministic(t *testing.T) { + cfg1 := domain.GitHubConfig{User: "alice", Orgs: []string{"org1"}} + cfg2 := domain.GitHubConfig{User: "alice", Orgs: []string{"org1"}} + + sha1 := GitHubConfigSHA(cfg1) + sha2 := GitHubConfigSHA(cfg2) + + if sha1 != sha2 { + t.Errorf("same config should produce same SHA: %s vs %s", sha1, sha2) + } +} + +func TestGitHubConfigSHA_DifferentUsers(t *testing.T) { + cfg1 := domain.GitHubConfig{User: "alice", Orgs: []string{"org1"}} + cfg2 := domain.GitHubConfig{User: "bob", Orgs: []string{"org1"}} + + if GitHubConfigSHA(cfg1) == GitHubConfigSHA(cfg2) { + t.Error("different users should produce different SHA") + } +} + +func TestGitHubConfigSHA_OrgOrderIndependent(t *testing.T) { + cfg1 := domain.GitHubConfig{User: "alice", Orgs: []string{"org2", "org1"}} + cfg2 := domain.GitHubConfig{User: "alice", Orgs: []string{"org1", "org2"}} + + sha1 := GitHubConfigSHA(cfg1) + sha2 := GitHubConfigSHA(cfg2) + + if sha1 != sha2 { + t.Errorf("org order should not matter: %s vs %s", sha1, sha2) + } +} + +func TestGitHubConfigSHA_IncludePrivate(t *testing.T) { + cfg1 := domain.GitHubConfig{User: "alice", IncludePrivate: false} + cfg2 := domain.GitHubConfig{User: "alice", IncludePrivate: true} + + if GitHubConfigSHA(cfg1) == GitHubConfigSHA(cfg2) { + t.Error("include_private change should produce different SHA") + } +} + +func TestGitHubConfigSHA_TopicAndPrefix(t *testing.T) { + cfg1 := domain.GitHubConfig{User: "alice", Topic: "template"} + cfg2 := domain.GitHubConfig{User: "alice", Topic: "boilerplate"} + + if GitHubConfigSHA(cfg1) == GitHubConfigSHA(cfg2) { + t.Error("different topics should produce different SHA") + } + + cfg3 := domain.GitHubConfig{User: "alice", Prefix: "tpl-"} + cfg4 := domain.GitHubConfig{User: "alice", Prefix: "tmpl-"} + + if GitHubConfigSHA(cfg3) == GitHubConfigSHA(cfg4) { + t.Error("different prefixes should produce different SHA") + } +} + +func TestLookupVars_Found(t *testing.T) { + cache := &domain.Cache{ + TemplateVars: []domain.CachedTemplateVars{ + {URL: "https://github.com/test/a.git", Branch: "main", SHA: "aaa", Variables: map[string]int{"FOO": 1}}, + {URL: "https://github.com/test/b.git", Branch: "dev", SHA: "bbb", Variables: map[string]int{"BAR": 2}}, + }, + } + + v, ok := LookupVars(cache, "https://github.com/test/a.git", "main") + if !ok { + t.Fatal("expected to find cached vars") + } + if v.SHA != "aaa" { + t.Errorf("expected SHA aaa, got %s", v.SHA) + } + if v.Variables["FOO"] != 1 { + t.Errorf("expected FOO=1, got %d", v.Variables["FOO"]) + } +} + +func TestLookupVars_NotFound(t *testing.T) { + cache := &domain.Cache{ + TemplateVars: []domain.CachedTemplateVars{ + {URL: "https://github.com/test/a.git", Branch: "main", SHA: "aaa", Variables: map[string]int{"FOO": 1}}, + }, + } + + // Wrong branch + _, ok := LookupVars(cache, "https://github.com/test/a.git", "dev") + if ok { + t.Error("should not find vars for wrong branch") + } + + // Wrong URL + _, ok = LookupVars(cache, "https://github.com/test/c.git", "main") + if ok { + t.Error("should not find vars for wrong URL") + } +} + +func TestLookupVars_EmptyCache(t *testing.T) { + cache := &domain.Cache{} + + _, ok := LookupVars(cache, "https://github.com/test/a.git", "main") + if ok { + t.Error("should not find vars in empty cache") + } +} + +func TestUpdateVars_NewEntry(t *testing.T) { + cache := &domain.Cache{} + + UpdateVars(cache, "https://github.com/test/a.git", "main", "aaa", map[string]int{"FOO": 3}) + + if len(cache.TemplateVars) != 1 { + t.Fatalf("expected 1 entry, got %d", len(cache.TemplateVars)) + } + if cache.TemplateVars[0].URL != "https://github.com/test/a.git" { + t.Errorf("unexpected URL: %s", cache.TemplateVars[0].URL) + } + if cache.TemplateVars[0].Branch != "main" { + t.Errorf("unexpected branch: %s", cache.TemplateVars[0].Branch) + } + if cache.TemplateVars[0].SHA != "aaa" { + t.Errorf("unexpected SHA: %s", cache.TemplateVars[0].SHA) + } + if cache.TemplateVars[0].Variables["FOO"] != 3 { + t.Errorf("expected FOO=3, got %d", cache.TemplateVars[0].Variables["FOO"]) + } +} + +func TestUpdateVars_ExistingEntry(t *testing.T) { + cache := &domain.Cache{ + TemplateVars: []domain.CachedTemplateVars{ + {URL: "https://github.com/test/a.git", Branch: "main", SHA: "old", Variables: map[string]int{"FOO": 1}}, + }, + } + + UpdateVars(cache, "https://github.com/test/a.git", "main", "new", map[string]int{"FOO": 5, "BAR": 2}) + + if len(cache.TemplateVars) != 1 { + t.Fatalf("expected 1 entry (updated), got %d", len(cache.TemplateVars)) + } + if cache.TemplateVars[0].SHA != "new" { + t.Errorf("expected SHA new, got %s", cache.TemplateVars[0].SHA) + } + if cache.TemplateVars[0].Variables["FOO"] != 5 { + t.Errorf("expected FOO=5, got %d", cache.TemplateVars[0].Variables["FOO"]) + } + if cache.TemplateVars[0].Variables["BAR"] != 2 { + t.Errorf("expected BAR=2, got %d", cache.TemplateVars[0].Variables["BAR"]) + } +} + +func TestUpdateVars_MultipleEntries(t *testing.T) { + cache := &domain.Cache{} + + UpdateVars(cache, "https://github.com/test/a.git", "main", "aaa", map[string]int{"FOO": 1}) + UpdateVars(cache, "https://github.com/test/b.git", "dev", "bbb", map[string]int{"BAR": 2}) + UpdateVars(cache, "https://github.com/test/a.git", "main", "ccc", map[string]int{"FOO": 3}) + + if len(cache.TemplateVars) != 2 { + t.Fatalf("expected 2 entries, got %d", len(cache.TemplateVars)) + } + // First entry should be updated + if cache.TemplateVars[0].SHA != "ccc" { + t.Errorf("expected SHA ccc for first entry, got %s", cache.TemplateVars[0].SHA) + } + // Second entry should be unchanged + if cache.TemplateVars[1].SHA != "bbb" { + t.Errorf("expected SHA bbb for second entry, got %s", cache.TemplateVars[1].SHA) + } +} + +func TestCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.yaml") + + cache := &domain.Cache{} + UpdateVars(cache, "git@github.com:org/repo.git", "main", "abc123", map[string]int{"APP_NAME": 10, "VERSION": 3}) + cache.GitHubConfigSHA = GitHubConfigSHA(domain.GitHubConfig{User: "testuser", Orgs: []string{"org1"}}) + cache.GitHubRepos = []domain.TemplateRepo{ + {Name: "org1/template-go", URL: "git@github.com:org1/template-go.git", Description: "Go template", DefaultBranch: "main"}, + } + + if err := SaveCacheTo(path, cache); err != nil { + t.Fatalf("save failed: %v", err) + } + + loaded, err := LoadCacheFrom(path) + if err != nil { + t.Fatalf("load failed: %v", err) + } + + // Verify GitHub repos + if len(loaded.GitHubRepos) != 1 || loaded.GitHubRepos[0].Name != "org1/template-go" { + t.Errorf("GitHub repos mismatch: %+v", loaded.GitHubRepos) + } + + // Verify template vars via lookup + v, ok := LookupVars(loaded, "git@github.com:org/repo.git", "main") + if !ok { + t.Fatal("expected to find cached vars after round-trip") + } + if v.SHA != "abc123" { + t.Errorf("expected SHA abc123, got %s", v.SHA) + } + if v.Variables["APP_NAME"] != 10 || v.Variables["VERSION"] != 3 { + t.Errorf("unexpected variables: %v", v.Variables) + } +} diff --git a/services/cloner.go b/services/cloner.go index f9fd0f4..d2d0c92 100644 --- a/services/cloner.go +++ b/services/cloner.go @@ -115,6 +115,19 @@ func (gc *GitCloner) ListRemoteBranches(repoURL string) ([]string, error) { return branches, nil } +// HeadSHA returns the HEAD commit SHA from a local git repository +func HeadSHA(repoPath string) (string, error) { + repo, err := git.PlainOpen(repoPath) + if err != nil { + return "", err + } + ref, err := repo.Head() + if err != nil { + return "", err + } + return ref.Hash().String(), nil +} + func (gc *GitCloner) isSSH(repoURL string) bool { return strings.HasPrefix(repoURL, "git@") || strings.HasPrefix(repoURL, "ssh://") }