prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
Mcmd/server/handlers/projects.go | 8+-------
Minternal/content/projects.go | 206+++++++++++++++++++++++--------------------------------------------------------
Ainternal/content/projects_test.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/external/git.go | 17+++++++++++++++++
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")