commit bfd3a4e2f6d7de349fd505a6c8d1361ee755b119
parent 4d5ac0f58f908dcda8320de4f335af448f49257e
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 19:26:00 +0000
refactor(content): key project readmes on HEAD instead of a ttl cache
Repos are local, so a rev-parse per request keeps READMEs fresh after a push with no TTL, background refresh or last-good fallback.
Diffstat:
4 files changed, 145 insertions(+), 153 deletions(-)
diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go
@@ -30,13 +30,7 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
if id == "" {
if len(projects) > 0 {
- // Resolve through GetProjectByID so a cold cache fetches the
- // README synchronously instead of rendering "not available".
- first := &projects[0]
- if p, err := content.GetProjectByID(h.gitService, first.ID); err == nil && p != nil {
- first = p
- }
- applyProjectDetailData(&data, first)
+ applyProjectDetailData(&data, &projects[0])
}
if utils.IsHTMXRequest(r) {
diff --git a/internal/content/projects.go b/internal/content/projects.go
@@ -5,110 +5,99 @@ import (
"log/slog"
"strings"
"sync"
- "time"
"git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/external"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
- "github.com/patrickmn/go-cache"
-)
-
-const (
- projectsCacheKey = "projects"
- projectsCacheTTL = 1 * time.Hour
- projectsCacheCleanup = 4 * time.Hour
)
+// readmeMemo holds each project's rendered README keyed by the HEAD it was
+// rendered from. The repos are local, so checking HEAD is one rev-parse and a
+// push shows up on the next request; there is no TTL or background refresh.
var (
- projectsCache = cache.New(projectsCacheTTL, projectsCacheCleanup)
- projectsRefreshMu sync.Mutex
- projectsRefreshInProgress bool
+ readmeMu sync.Mutex
+ readmeMemo = map[string]memoEntry{}
)
+type memoEntry struct {
+ sha string
+ markdown string
+ html string
+}
+
+// LoadProjects returns the configured projects with their READMEs.
func LoadProjects(gitService *external.Git) ([]config.Project, error) {
if gitService == nil {
gitService = external.NewGit()
}
- if cached, found := projectsCache.Get(projectsCacheKey); found {
- if projects, ok := cached.([]config.Project); ok {
- return cloneProjects(projects), nil
- }
+ projects := config.GetProjects()
+ for i := range projects {
+ withReadme(gitService, &projects[i])
}
-
- projects := withLastGoodReadmes(config.GetProjects())
- projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
- startProjectsRefresh(gitService)
-
- return cloneProjects(projects), nil
+ return projects, nil
}
+// GetProjectByID returns one configured project with its README, or nil.
func GetProjectByID(gitService *external.Git, id string) (*config.Project, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, nil
}
-
- projects, err := LoadProjects(gitService)
- if err != nil {
- return nil, err
+ if gitService == nil {
+ gitService = external.NewGit()
}
- for i := range projects {
- if projects[i].ID != id {
- continue
- }
- if gitService != nil && strings.TrimSpace(projects[i].Markdown) == "" && strings.TrimSpace(projects[i].HTML) == "" {
- if readme, err := fetchProjectReadme(gitService, projects[i]); err == nil {
- projects[i].Markdown = readme.Markdown
- projects[i].HTML = readme.HTML
- rememberReadmes(projects[i : i+1])
- projectsCache.Set(projectsCacheKey, cloneProjects(projects), projectsCacheTTL)
- }
- }
-
- project := projects[i]
- return &project, nil
+ project := config.GetProjectByID(id)
+ if project == nil {
+ return nil, nil
}
-
- return nil, nil
+ p := *project
+ withReadme(gitService, &p)
+ return &p, nil
}
-func fetchProjects(gitService *external.Git) ([]config.Project, error) {
- projects := config.GetProjects()
- for i := range projects {
- readme, err := fetchProjectReadme(gitService, projects[i])
- if err != nil {
- slog.Warn("project metadata fetch failed", "project_id", projects[i].ID, "error", err)
- continue
- }
-
- projects[i].Markdown = readme.Markdown
- projects[i].HTML = readme.HTML
+func withReadme(gitService *external.Git, project *config.Project) {
+ if strings.TrimSpace(project.Repo) == "" {
+ return
}
+ ctx := context.Background()
- return projects, nil
-}
-
-func fetchProjectReadme(gitService *external.Git, project config.Project) (config.Project, error) {
- metadata, err := gitService.FetchMetadata(context.Background(), project)
+ sha, err := gitService.HeadSHA(ctx, *project)
if err != nil {
- return config.Project{}, err
- }
- if metadata == nil {
- return config.Project{}, nil
+ slog.Warn("project head lookup failed", "project_id", project.ID, "error", err)
+ return
}
- html, err := projectReadmeHTML(metadata)
- if err != nil {
- slog.Warn("project readme markdown render failed", "project_id", project.ID, "error", err)
- return config.Project{}, err
+ readmeMu.Lock()
+ entry, ok := readmeMemo[project.ID]
+ readmeMu.Unlock()
+
+ if !ok || entry.sha != sha {
+ metadata, err := gitService.FetchMetadata(ctx, *project)
+ if err != nil {
+ slog.Warn("project metadata fetch failed", "project_id", project.ID, "error", err)
+ if !ok {
+ return
+ }
+ } else {
+ html, err := projectReadmeHTML(metadata)
+ if err != nil {
+ slog.Warn("project readme markdown render failed", "project_id", project.ID, "error", err)
+ if !ok {
+ return
+ }
+ } else {
+ entry = memoEntry{sha: sha, markdown: strings.TrimSpace(metadata.Readme), html: strings.TrimSpace(html)}
+ readmeMu.Lock()
+ readmeMemo[project.ID] = entry
+ readmeMu.Unlock()
+ }
+ }
}
- return config.Project{
- Markdown: strings.TrimSpace(metadata.Readme),
- HTML: strings.TrimSpace(html),
- }, nil
+ project.Markdown = entry.markdown
+ project.HTML = entry.html
}
func projectReadmeHTML(metadata *external.GitMetadata) (string, error) {
@@ -121,80 +110,5 @@ func projectReadmeHTML(metadata *external.GitMetadata) (string, error) {
return "", nil
}
- rendered, err := utils.MarkdownToHTML(utils.RemoveFirstH1(markdown))
- if err != nil {
- return "", err
- }
-
- return rendered, nil
-}
-
-func cloneProjects(projects []config.Project) []config.Project {
- cloned := make([]config.Project, len(projects))
- copy(cloned, projects)
- return cloned
-}
-
-func startProjectsRefresh(gitService *external.Git) {
- if gitService == nil {
- return
- }
-
- projectsRefreshMu.Lock()
- if projectsRefreshInProgress {
- projectsRefreshMu.Unlock()
- return
- }
- projectsRefreshInProgress = true
- projectsRefreshMu.Unlock()
-
- go func() {
- defer func() {
- projectsRefreshMu.Lock()
- projectsRefreshInProgress = false
- projectsRefreshMu.Unlock()
- }()
-
- projects, err := fetchProjects(gitService)
- if err != nil {
- slog.Warn("project metadata background refresh failed", "error", err)
- return
- }
-
- projects = withLastGoodReadmes(projects)
- rememberReadmes(projects)
- projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
- }()
-}
-
-// lastGoodReadmes outlives the projects cache, so an expired cache or a failed
-// refresh serves the previous README instead of "README not available".
-var (
- lastGoodMu sync.RWMutex
- lastGoodReadmes = map[string]config.Project{}
-)
-
-func rememberReadmes(projects []config.Project) {
- lastGoodMu.Lock()
- defer lastGoodMu.Unlock()
- for _, p := range projects {
- if strings.TrimSpace(p.HTML) != "" {
- lastGoodReadmes[p.ID] = config.Project{Markdown: p.Markdown, HTML: p.HTML}
- }
- }
-}
-
-func withLastGoodReadmes(projects []config.Project) []config.Project {
- lastGoodMu.RLock()
- defer lastGoodMu.RUnlock()
- for i := range projects {
- if strings.TrimSpace(projects[i].HTML) != "" {
- continue
- }
- if good, ok := lastGoodReadmes[projects[i].ID]; ok {
- projects[i].Markdown = good.Markdown
- projects[i].HTML = good.HTML
- }
- }
- return projects
+ return utils.MarkdownToHTML(utils.RemoveFirstH1(markdown))
}
diff --git a/internal/content/projects_test.go b/internal/content/projects_test.go
@@ -0,0 +1,67 @@
+package content
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/external"
+)
+
+func run(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+}
+
+func commitReadme(t *testing.T, work, body string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "add", ".")
+ run(t, work, "commit", "-qm", "readme")
+ run(t, work, "push", "-q", "origin", "HEAD:master")
+}
+
+func TestReadmeFollowsHead(t *testing.T) {
+ root := t.TempDir()
+ repos := filepath.Join(root, "repos")
+ work := filepath.Join(root, "work")
+ run(t, root, "init", "-q", "--bare", "-b", "master", filepath.Join(repos, "demo.git"))
+ run(t, root, "init", "-q", "-b", "master", work)
+ run(t, work, "remote", "add", "origin", filepath.Join(repos, "demo.git"))
+
+ t.Setenv("GIT_REPOS_DIR", repos)
+ git := external.NewGit()
+ readmeMemo = map[string]memoEntry{}
+
+ commitReadme(t, work, "# demo\n\nfirst version\n")
+ p := config.Project{ID: "demo", Repo: "demo"}
+ withReadme(git, &p)
+ if !strings.Contains(p.HTML, "first version") {
+ t.Fatalf("first render: %q", p.HTML)
+ }
+
+ commitReadme(t, work, "# demo\n\nsecond version\n")
+ p = config.Project{ID: "demo", Repo: "demo"}
+ withReadme(git, &p)
+ if !strings.Contains(p.HTML, "second version") {
+ t.Fatalf("after push: %q", p.HTML)
+ }
+}
+
+func TestNoRepoNoReadme(t *testing.T) {
+ p := config.Project{ID: "x"}
+ withReadme(external.NewGit(), &p)
+ if p.HTML != "" {
+ t.Fatalf("got %q", p.HTML)
+ }
+}
diff --git a/internal/external/git.go b/internal/external/git.go
@@ -61,6 +61,23 @@ func (s *Git) RefreshMetadata(ctx context.Context, project config.Project) error
return err
}
+// HeadSHA returns the commit HEAD points at, a cheap freshness key for
+// anything derived from the repo.
+func (s *Git) HeadSHA(ctx context.Context, project config.Project) (string, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ dir, err := s.projectRepoDir(project)
+ if err != nil {
+ return "", err
+ }
+ out, err := s.git(ctx, dir, "rev-parse", "HEAD")
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
// ErrNotFound is returned by ReadFile when the path is not a file at HEAD.
var ErrNotFound = errors.New("not found")