socle

serve: publish {repos, baseurl} as one atomic snapshot

commit 701ac20d8a20ee09f2bd624b6797ed0db562d85a · parent 4eed84cb53 · browse this tree
Ximianth, 2026-10-27 17:06:46 UTC

Pure plumbing plus the clone-URL half of baseurl consumption:
when configured, the repo home shows the canonical clone address
instead of the request-derived guess (which stays as the fallback,
and is right whenever no rewriting proxy intervenes).
The two values swap together so a reload can never pair
old repos with a new address.
diff --git a/lang_test.go b/lang_test.go
index 07ef146..d5803e8 100644
--- a/lang_test.go
+++ b/lang_test.go
@@ -120,7 +120,7 @@ func TestLangCacheBounds(t *testing.T) {
 func TestLangStatsCached(t *testing.T) {
 	t.Parallel()
 	s := fixtureServer(t)
-	rp := (*s.repos.Load())["fixturama"]
+	rp := s.snap.Load().repos["fixturama"]
 	planted := []langStat{{"Ada", 42}}
 	s.langs.put(langCacheKey(rp.name, fixtureShaMain), planted)
 	got, err := s.langStats(rp, fixtureShaMain)
diff --git a/main.go b/main.go
index bf0cd42..94d6528 100644
--- a/main.go
+++ b/main.go
@@ -37,7 +37,7 @@ func main() {
 	for _, w := range warnings {
 		log.Print("socle: ", w)
 	}
-	srv := newServer(repos, display.NewTheme(seed))
+	srv := newServer(repos, cfg.BaseURL, display.NewTheme(seed))
 
 	// Config changes apply live: reloads are fail-stable
 	// (any problem logs loudly and the old repo set keeps serving),
@@ -69,6 +69,6 @@ func reloadConfig(srv *server, path string) {
 		log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened")
 		return
 	}
-	srv.setRepos(repos)
+	srv.publish(repos, cfg.BaseURL)
 	log.Printf("socle: config reloaded: %d repos", len(repos))
 }
diff --git a/pages.go b/pages.go
index 10889eb..722ba75 100644
--- a/pages.go
+++ b/pages.go
@@ -209,15 +209,21 @@ func langSegments(stats []langStat) []langSeg {
 	return segs
 }
 
-func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) {
-	scheme := "http"
-	if r.TLS != nil {
-		scheme = "https"
+func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, baseURL string) {
+	// The clone URL: the configured canonical address when there is one,
+	// else derived from the request (right whenever no rewriting proxy intervenes).
+	cloneURL := baseURL + "/" + rp.name
+	if baseURL == "" {
+		scheme := "http"
+		if r.TLS != nil {
+			scheme = "https"
+		}
+		cloneURL = scheme + "://" + r.Host + "/" + rp.name
 	}
 	db := rp.defaultBranch()
 	d := homeData{
 		Description: rp.description,
-		CloneURL:    scheme + "://" + r.Host + "/" + rp.name,
+		CloneURL:    cloneURL,
 		Ref:         db,
 	}
 	branches, tags, err := rp.refs()
diff --git a/serve.go b/serve.go
index dcf0d3f..23b1226 100644
--- a/serve.go
+++ b/serve.go
@@ -13,45 +13,55 @@ import (
 	"vitrine/display"
 )
 
+// snapshot is the server's immutable view of one loaded config:
+// the opened repo set, plus the canonical base URL (empty when unconfigured;
+// see config.BaseURL for what each state means).
+// The two travel together so a reload can never pair old repos with a new address.
+type snapshot struct {
+	repos   map[string]*repo
+	baseURL string
+}
+
 // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch.
-// The repo set lives behind an atomic pointer so a config reload can swap the whole set
+// The config snapshot lives behind an atomic pointer so a reload can swap the whole thing
 // at once while requests read it lock-free: each request loads the pointer exactly once
 // (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration.
-// A published map is never mutated.
+// A published snapshot is never mutated.
 type server struct {
-	repos  atomic.Pointer[map[string]*repo]
+	snap   atomic.Pointer[snapshot]
 	theme  display.Theme     // fixed for the process lifetime; see display/theme.go
 	sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go
 	style  []byte            // the assembled /-/style.css body; see buildStyle
 	langs  *langCache        // language composition per (repo, sha); see lang.go
 }
 
-func newServer(repos map[string]*repo, th display.Theme) *server {
+func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server {
 	s := &server{
 		theme:  th,
 		sketch: sketchAssets(th.Name),
 		style:  buildStyle(th),
 		langs:  newLangCache(langCacheCap),
 	}
-	s.setRepos(repos)
+	s.publish(repos, baseURL)
 	return s
 }
 
-// setRepos publishes a new repo set atomically; in-flight requests keep the one they loaded.
+// publish swaps in a new config snapshot atomically; in-flight requests keep the one they loaded.
 // The old set needs no teardown: a repo holds only strings (git runs as a subprocess per
 // operation), so the garbage collector reclaims it once the last such request drops it.
 // The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha)
 // and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload
 // but never wrong, and the cache's cap already bounds orphans.
-func (s *server) setRepos(repos map[string]*repo) {
-	s.repos.Store(&repos)
+func (s *server) publish(repos map[string]*repo, baseURL string) {
+	s.snap.Store(&snapshot{repos: repos, baseURL: baseURL})
 }
 
 // ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching
 // (matchRepo), then the first path segment after the repo name selects the verb.
 func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	p := strings.TrimPrefix(r.URL.Path, "/")
-	repos := *s.repos.Load() // this request's snapshot; a concurrent reload cannot alter it
+	snap := s.snap.Load() // this request's snapshot; a concurrent reload cannot alter it
+	repos := snap.repos
 
 	// Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD).
 	isRPC := false
@@ -92,7 +102,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently)
 			return
 		}
-		s.serveHome(w, r, rp)
+		s.serveHome(w, r, rp, snap.baseURL)
 		return
 	}
 
diff --git a/serve_test.go b/serve_test.go
index 852927d..70aef6e 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -17,7 +17,7 @@ import (
 // with a pinned theme seed so output stays deterministic.
 func fixtureServer(t *testing.T) *server {
 	t.Helper()
-	return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, display.NewTheme("test"))
+	return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", display.NewTheme("test"))
 }
 
 // do runs one request through the handler and returns the recorded response.
@@ -208,7 +208,7 @@ func TestFileViewModes(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	s := newServer(map[string]*repo{"media": rp}, display.NewTheme("test"))
+	s := newServer(map[string]*repo{"media": rp}, "", display.NewTheme("test"))
 	for _, c := range []struct {
 		target string
 		want   string
@@ -252,7 +252,7 @@ func TestCommitPageShapes(t *testing.T) {
 	if err != nil {
 		t.Fatal(err)
 	}
-	s := newServer(map[string]*repo{"merger": rp}, display.NewTheme("test"))
+	s := newServer(map[string]*repo{"merger": rp}, "", display.NewTheme("test"))
 	rec := do(s, "/merger/commit/main", nil, nil)
 	if rec.Code != 200 {
 		t.Fatalf("status = %d", rec.Code)
diff --git a/watch_test.go b/watch_test.go
index f5d1887..58b9557 100644
--- a/watch_test.go
+++ b/watch_test.go
@@ -51,7 +51,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture {
 	if len(warnings) > 0 {
 		t.Fatalf("fixture repos should all open: %v", warnings)
 	}
-	fx.srv = newServer(repos, display.NewTheme("test"))
+	fx.srv = newServer(repos, cfg.BaseURL, display.NewTheme("test"))
 	watchConfig(fx.cfgPath, pollInterval, func() {
 		reloadConfig(fx.srv, fx.cfgPath)
 		fx.checked.Add(1)