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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.


</details>

<details>
Expand Down
96 changes: 87 additions & 9 deletions actions/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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')
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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
}

Expand Down
9 changes: 9 additions & 0 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

</details>

Expand All @@ -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" \
Expand All @@ -286,6 +293,8 @@ yankrun generate \

</details>

**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`
Expand Down
14 changes: 14 additions & 0 deletions domain/cache.go
Original file line number Diff line number Diff line change
@@ -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"`
}
5 changes: 5 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down
122 changes: 122 additions & 0 deletions services/cache.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
Loading
Loading