prime8dev

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

commit ac4057cd7bb1f817700e679cb14685011bb1a1f4
parent 08ed3ab546f0da819ec1aa2b432ec9be753e1799
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 19:26:00 +0000

fix(projects): load readmes keyed on the repo HEAD, including on a cold cache

Diffstat:
Mcmd/server/handlers/projects.go | 17+++++++++++++----
Mcmd/server/handlers/render.go | 2+-
Minternal/content/projects.go | 171++++++++++++++++++++++++++++---------------------------------------------------
Ainternal/content/projects_test.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/external/git.go | 17+++++++++++++++++
5 files changed, 158 insertions(+), 116 deletions(-)

diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go @@ -34,11 +34,11 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) { } if utils.IsHTMXRequest(r) { - h.renderTemplate(w, r, "projects", data, fragmentCacheMaxAgeSeconds) + h.renderTemplate(w, r, "projects", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds)) return } - h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds) + h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds)) return } @@ -55,7 +55,16 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) { data.SelectedProjectID = id applyProjectDetailData(&data, project) - h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds) + h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds)) +} + +// detailMaxAge never lets a browser cache a page whose README failed to +// load, so a transient fetch error isn't pinned for an hour. +func detailMaxAge(data web.Data, maxAge int) int { + if data.Project != nil && strings.TrimSpace(string(data.ProjectReadme)) == "" { + return 0 + } + return maxAge } func applyProjectDetailData(data *web.Data, project *config.Project) { @@ -84,7 +93,7 @@ func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) { ProjectReadme: template.HTML(project.HTML), } - h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds) + h.renderTemplate(w, r, "project_detail", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds)) } // ProjectRawHandler serves a file from HEAD of a public project's repo, so diff --git a/cmd/server/handlers/render.go b/cmd/server/handlers/render.go @@ -27,7 +27,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name st cacheEnabled := shouldCacheResponses() w.Header().Set("Content-Type", "text/html; charset=utf-8") - if !cacheEnabled { + if !cacheEnabled || maxAgeSeconds == 0 { applyNoCacheHeaders(w) } else if maxAgeSeconds > 0 { w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAgeSeconds)) diff --git a/internal/content/projects.go b/internal/content/projects.go @@ -5,109 +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() - projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL) - startProjectsRefresh(gitService) - - return cloneProjects(projects), nil + for i := range projects { + withReadme(gitService, &projects[i]) + } + 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 - 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) { @@ -120,46 +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 - } - - projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL) - }() + 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")