~bigbes/sr-ht-compare

714752996fc44e8b173a369b66e8648a74da5656 — bigbes 30 days ago 9473e26
web: http server, sourcehut chrome, compare/commit pages

Implement the compare.sr.ht HTTP layer over the committed core/gitx/authz
packages:

- Server assembly (New) from shared SourceHut config: [git.sr.ht] repos,
  [meta.sr.ht]/[compare.sr.ht] origins, [sr.ht] site-name/environment, and
  the hashed stylesheet resolved by globbing the embedded static FS.
- Routes: landing, repo page, compare (base..base...head, .patch escape
  hatch), single-commit (+.patch), embedded static assets, healthz. Compare
  form GET canonicalizes to a clean URL via 302.
- SourceHut chrome port to html/template: nav service switcher (canonical
  order, paste/pages/hub excluded, compare active), login/logout with
  return_to, environment banner.
- Embedded compare-data JSON (template.JS so html/template's script-context
  escaper leaves it verbatim; json.Marshal's HTML escaping blocks a
  </script> breakout) plus the vendored bundle.js scaffold.
- httptest coverage with a stub Authorizer and a real git-CLI fixture repo.

The cmd layer must wire, in order: RealIP, Recoverer, Logger,
config.Middleware(conf,"compare.sr.ht"), authz.Middleware.
A web/chrome.go => web/chrome.go +121 -0
@@ 0,0 1,121 @@
package web

import (
	"net/http"
	"net/url"
	"sort"
	"strings"

	"git.sr.ht/~sircmpwn/core-go/config"
	"github.com/vaughan0/go-ini"

	"go.bigb.es/sourcehut-compare/authz"
)

// navCanonical is the SourceHut service-switcher order. Services not listed here
// (including our own compare) sort alphabetically after these.
var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}

// navExcluded are service sections that never appear in the switcher: paste and
// pages have no top-level UI worth linking, and hub is rendered as the brand.
var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}

// navItem is one entry in the service switcher.
type navItem struct {
	Name   string // short service name, e.g. "git"
	Origin string // external origin URL
	Active bool   // true for compare.sr.ht (this service)
}

// buildNav derives the service switcher from the shared config: every section
// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub,
// ordered by navCanonical then alphabetically, with compare.sr.ht marked active.
func buildNav(conf ini.File) []navItem {
	var items []navItem
	for section := range conf {
		if !strings.HasSuffix(section, ".sr.ht") {
			continue
		}
		short := strings.TrimSuffix(section, ".sr.ht")
		if navExcluded[short] {
			continue
		}
		origin := config.GetOrigin(conf, section, true)
		if origin == "" {
			continue
		}
		items = append(items, navItem{
			Name:   short,
			Origin: origin,
			Active: section == "compare.sr.ht",
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
		if ci != cj {
			return ci < cj
		}
		return items[i].Name < items[j].Name
	})
	return items
}

// canonIndex returns a service's position in navCanonical, or a sentinel past
// the end for services that are not canonically ordered.
func canonIndex(name string) int {
	for i, n := range navCanonical {
		if n == name {
			return i
		}
	}
	return len(navCanonical)
}

// viewData is the root value every template is executed against: the chrome
// fields are common to all pages; Data carries the page-specific payload.
type viewData struct {
	Title       string
	SiteName    string
	HubOrigin   string // non-empty ⇒ brand links to hub instead of "/"
	Nav         []navItem
	Username    string // "" for an anonymous viewer
	LoginURL    string
	LogoutURL   string
	RegisterURL string
	ProfileURL  string
	CSSHref     string
	Environment string
	ShowBanner  bool

	Data any
}

// chrome builds the common chrome fields for a request. Login return_to is the
// current full URL (so the viewer lands back where they were); logout return_to
// is this service's origin.
func (s *Server) chrome(r *http.Request) viewData {
	username := authz.ForContext(r.Context())

	current := s.compareOrigin + r.URL.RequestURI()
	loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current)
	logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.compareOrigin)

	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username
	}

	return viewData{
		SiteName:    s.siteName,
		HubOrigin:   s.hubOrigin,
		Nav:         s.nav,
		Username:    username,
		LoginURL:    loginURL,
		LogoutURL:   logoutURL,
		RegisterURL: s.metaOrigin,
		ProfileURL:  profileURL,
		CSSHref:     s.cssHref,
		Environment: strings.ToUpper(s.environment),
		ShowBanner:  s.environment != "" && s.environment != "production",
	}
}

A web/handlers.go => web/handlers.go +429 -0
@@ 0,0 1,429 @@
package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/sirupsen/logrus"

	"go.bigb.es/sourcehut-compare/authz"
	"go.bigb.es/sourcehut-compare/core"
	"go.bigb.es/sourcehut-compare/gitx"
)

// recentCommitLimit bounds the first-parent history shown on the repo page.
const recentCommitLimit = 20

// compareLogLimit bounds the commit list on the compare page.
const compareLogLimit = 50

// ---- JSON transport (consumed by the front-end bundle) --------------------

// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
// path with NO a/ or b/ prefix.
type jsonFile struct {
	Path      string `json:"path"`
	OldPath   string `json:"oldPath"`
	Status    string `json:"status"`
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
	Binary    bool   `json:"binary"`
}

type jsonSpec struct {
	Base     string `json:"base"`
	Head     string `json:"head"`
	ThreeDot bool   `json:"threeDot"`
}

type compareData struct {
	Mode      string     `json:"mode"`
	Patch     string     `json:"patch"`
	Truncated bool       `json:"truncated"`
	Files     []jsonFile `json:"files"`
	Spec      jsonSpec   `json:"spec"`
}

// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
// <script> element even when a file path contains "</script>". The bytes are
// returned as template.JS: any <script> is a JS context to html/template, so a
// plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
// is emitted verbatim, and the marshaler's escaping already blocks a breakout.
func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
	cd := compareData{
		Mode:      mode,
		Patch:     patch.Text,
		Truncated: patch.Truncated,
		Files:     []jsonFile{},
		Spec:      spec,
	}
	for _, f := range files {
		cd.Files = append(cd.Files, jsonFile{
			Path:      f.Path,
			OldPath:   f.OldPath,
			Status:    f.Status,
			Additions: f.Additions,
			Deletions: f.Deletions,
			Binary:    f.Binary,
		})
	}
	b, err := json.Marshal(cd)
	if err != nil {
		return "", err
	}
	return template.JS(b), nil
}

// ---- error mapping --------------------------------------------------------

// httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
// core.ErrNotFound so a private repo is a 404, never a 403.
func httpStatusFor(err error) int {
	switch {
	case errors.Is(err, core.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, core.ErrBadRef):
		return http.StatusBadRequest
	case errors.Is(err, core.ErrForbidden):
		return http.StatusForbidden
	default:
		return http.StatusInternalServerError
	}
}

// fail renders the chrome error page for err, logging 5xx causes.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		logrus.WithError(err).WithField("path", r.URL.Path).Error("web: request failed")
		s.renderError(w, r, status, "an internal error occurred")
		return
	}
	s.renderError(w, r, status, err.Error())
}

// resolve authorizes and opens a repository, returning the git handle and the
// authz metadata. Any error is already mapped to the right HTTP status by the
// caller via fail.
func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
	viewer := authz.ForContext(ctx)
	info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	g, err := gitx.Open(s.reposRoot, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	return g, info, nil
}

// ---- index ----------------------------------------------------------------

type indexData struct {
	LoggedIn bool
	Repos    []authz.RepoInfo
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	username := authz.ForContext(ctx)

	vd := s.chrome(r)
	vd.Title = s.siteName + " compare"

	if username == "" {
		vd.Data = indexData{LoggedIn: false}
		s.render(w, http.StatusOK, "index", vd)
		return
	}

	repos, err := s.authorizer.MyRepos(ctx, username)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	vd.Data = indexData{LoggedIn: true, Repos: repos}
	s.render(w, http.StatusOK, "index", vd)
}

// ---- repo page ------------------------------------------------------------

type repoData struct {
	Owner         string
	Info          *authz.RepoInfo
	DefaultBranch string
	Branches      []gitx.Ref
	Tags          []gitx.Ref
	Commits       []gitx.CommitInfo
}

func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	branches, tags, err := g.Refs(ctx)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	def, _ := g.DefaultBranch(ctx)
	commits, _ := recentCommits(ctx, g, def, recentCommitLimit)

	vd := s.chrome(r)
	vd.Title = "~" + owner + "/" + repo
	vd.Data = repoData{
		Owner:         owner,
		Info:          info,
		DefaultBranch: def,
		Branches:      branches,
		Tags:          tags,
		Commits:       commits,
	}
	s.render(w, http.StatusOK, "repo", vd)
}

// recentCommits walks first-parent history from rev, returning up to limit
// commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
// range. An unresolvable starting revision (e.g. an empty repository) yields an
// empty slice rather than an error.
func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
	if rev == "" {
		return nil, nil
	}
	var out []gitx.CommitInfo
	cur := rev
	for i := 0; i < limit; i++ {
		ci, err := g.ResolveCommit(ctx, cur)
		if err != nil {
			if i == 0 {
				return nil, nil
			}
			break
		}
		out = append(out, *ci)
		if len(ci.ParentSHAs) == 0 {
			break
		}
		cur = ci.ParentSHAs[0]
	}
	return out, nil
}

// ---- compare page ---------------------------------------------------------

type compareView struct {
	Owner      string
	RepoName   string
	Info       *authz.RepoInfo
	Spec       core.CompareSpec
	MergeBase  string
	Commits    []gitx.CommitInfo
	Files      []gitx.FileChange
	Truncated  bool
	CompareURL string
	PatchURL   string
	JSON       template.JS
}

func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	raw := chi.URLParam(r, "*")

	// Empty wildcard: this is the compare form's GET target. Canonicalize the
	// base/head/mode query into a clean compare URL and redirect.
	if raw == "" {
		s.compareRedirect(w, r, owner, repo)
		return
	}

	patchMode := strings.HasSuffix(raw, ".patch")
	specRaw := strings.TrimSuffix(raw, ".patch")

	spec, err := core.ParseCompareSpec(specRaw)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)

	if patchMode {
		patch, err := g.RawDiff(ctx, spec)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		s.writePatch(w, patch.Text)
		return
	}

	patch, err := g.Diff(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	files, err := g.DiffStat(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	var mergeBase string
	if spec.ThreeDot {
		mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
	}

	jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
		Base:     spec.Base,
		Head:     spec.Head,
		ThreeDot: spec.ThreeDot,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head)
	vd.Data = compareView{
		Owner:      owner,
		RepoName:   repo,
		Info:       info,
		Spec:       spec,
		MergeBase:  mergeBase,
		Commits:    commits,
		Files:      files,
		Truncated:  patch.Truncated,
		CompareURL: compareURL,
		PatchURL:   compareURL + ".patch",
		JSON:       jsonPayload,
	}
	s.render(w, http.StatusOK, "compare", vd)
}

// compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
// "two" selects the two-dot range; anything else (the default) is three-dot.
func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
	q := r.URL.Query()
	base := strings.TrimSpace(q.Get("base"))
	head := strings.TrimSpace(q.Get("head"))
	if base == "" || head == "" {
		s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
		return
	}
	sep := "..."
	if q.Get("mode") == "two" {
		sep = ".."
	}
	http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
}

// ---- commit page ----------------------------------------------------------

type commitView struct {
	Owner     string
	RepoName  string
	Info      *authz.RepoInfo
	Commit    *gitx.CommitInfo
	Files     []gitx.FileChange
	IsMerge   bool
	Truncated bool
	CommitURL string
	PatchURL  string
	JSON      template.JS
}

func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	rev := chi.URLParam(r, "rev")

	patchMode := strings.HasSuffix(rev, ".patch")
	rev = strings.TrimSuffix(rev, ".patch")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	patch, files, ci, err := g.CommitPatch(ctx, rev)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	if patchMode {
		s.writePatch(w, patch.Text)
		return
	}

	commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)

	base := ""
	if len(ci.ParentSHAs) > 0 {
		base = ci.ParentSHAs[0]
	}
	jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
		Base:     base,
		Head:     ci.SHA,
		ThreeDot: false,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA)
	vd.Data = commitView{
		Owner:     owner,
		RepoName:  repo,
		Info:      info,
		Commit:    ci,
		Files:     files,
		IsMerge:   len(ci.ParentSHAs) > 1,
		Truncated: patch.Truncated,
		CommitURL: commitURL,
		PatchURL:  commitURL + ".patch",
		JSON:      jsonPayload,
	}
	s.render(w, http.StatusOK, "commit", vd)
}

// writePatch emits a raw unified diff as an inline text/plain document.
func (s *Server) writePatch(w http.ResponseWriter, text string) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Content-Disposition", "inline")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(text))
}

A web/router.go => web/router.go +58 -0
@@ 0,0 1,58 @@
package web

import (
	"net/http"
	"path"
	"strings"

	"github.com/go-chi/chi/v5"
)

// Register mounts every compare.sr.ht route onto r. The caller is responsible
// for installing the middleware documented on the package (config + authz at a
// minimum); Register adds no middleware of its own.
func (s *Server) Register(r chi.Router) {
	r.Get("/", s.handleIndex)
	r.Get("/jump", s.handleJump)
	r.Get("/healthz", s.handleHealthz)
	r.Get("/static/*", s.handleStatic)

	r.Get("/~{owner}/{repo}", s.handleRepo)
	// A single wildcard route serves both the form target (empty wildcard ⇒
	// redirect to the canonical URL) and the compare view itself.
	r.Get("/~{owner}/{repo}/compare/*", s.handleCompare)
	r.Get("/~{owner}/{repo}/commit/{rev}", s.handleCommit)
}

// handleHealthz is a dependency-free liveness probe.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	_, _ = w.Write([]byte("ok\n"))
}

// handleStatic serves the embedded assets, tagging the content-addressed
// stylesheet as immutable and forcing a JS content type for the bundle.
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
	name := path.Base(r.URL.Path)
	if strings.HasSuffix(name, ".js") {
		w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
	}
	if hashedCSSRe.MatchString(name) {
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	} else {
		w.Header().Set("Cache-Control", "public, max-age=3600")
	}
	s.staticFileServer.ServeHTTP(w, r)
}

// handleJump powers the owner/repo jump form: it redirects to the canonical repo
// URL. A leading "~" on the owner is tolerated.
func (s *Server) handleJump(w http.ResponseWriter, r *http.Request) {
	owner := strings.TrimPrefix(strings.TrimSpace(r.URL.Query().Get("owner")), "~")
	repo := strings.TrimSpace(r.URL.Query().Get("repo"))
	if owner == "" || repo == "" {
		s.renderError(w, r, http.StatusBadRequest, "both owner and repository are required")
		return
	}
	http.Redirect(w, r, "/~"+owner+"/"+repo, http.StatusFound)
}

A web/server.go => web/server.go +120 -0
@@ 0,0 1,120 @@
// Package web is the HTTP layer of compare.sr.ht. It ports the SourceHut chrome
// (nav/service-switcher, login block, environment banner) to Go html/templates,
// renders the repository landing, compare (base...head) and single-commit pages
// server-side, and embeds a compact JSON payload plus the vendored esbuild
// bundle so the browser renders the diff with @pierre/diffs and @pierre/trees.
//
// The package owns no state of its own: identity comes from the authz cookie
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and
// git data from gitx over bare repositories on disk. Every request that touches
// a repository authorizes first (a not-found or forbidden repo is a 404, never
// a 403, so private-repo existence never leaks) and only then reads the disk.
//
// # What the cmd layer must wire
//
// Register only installs routes; it assumes the following middleware is already
// applied to the router it is handed, in this order (outermost first):
//
//	chi middleware.RealIP
//	chi middleware.Recoverer
//	chi middleware.Logger        (optional, but recommended)
//	config.Middleware(conf, "compare.sr.ht")   // required: authz + gitx read it
//	authz.Middleware()           // required: never 401s; sets the viewer
//
// config.Middleware must run before authz.Middleware is irrelevant to authz
// itself (it only reads the cookie), but the GraphQL authorizer invoked inside
// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
// config.Middleware is mandatory on every request that reaches a handler.
package web

import (
	"fmt"
	"io/fs"
	"net/http"
	"path"
	"regexp"

	"git.sr.ht/~sircmpwn/core-go/config"
	"github.com/vaughan0/go-ini"

	"go.bigb.es/sourcehut-compare/authz"
)

// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
	authorizer authz.Authorizer
	reposRoot  string
	conf       ini.File

	siteName      string
	environment   string
	metaOrigin    string
	compareOrigin string
	hubOrigin     string
	cssHref       string

	nav              []navItem
	staticFileServer http.Handler
}

// New assembles a Server from the shared SourceHut config. It reads
// [git.sr.ht] repos, [meta.sr.ht] origin and [compare.sr.ht] origin (all
// required), the [sr.ht] site-name/environment display values, and resolves the
// hashed stylesheet name by globbing the embedded static FS. A missing required
// key is a clear error, not a panic, so the cmd layer can fail startup loudly.
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	reposRoot, ok := conf.Get("git.sr.ht", "repos")
	if !ok || reposRoot == "" {
		return nil, fmt.Errorf("web: [git.sr.ht] repos is required")
	}
	metaOrigin := config.GetOrigin(conf, "meta.sr.ht", true)
	if metaOrigin == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}
	compareOrigin := config.GetOrigin(conf, "compare.sr.ht", true)
	if compareOrigin == "" {
		return nil, fmt.Errorf("web: [compare.sr.ht] origin is required")
	}

	cssHref, err := resolveCSSHref()
	if err != nil {
		return nil, err
	}

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)
	}

	return &Server{
		authorizer:       authorizer,
		reposRoot:        reposRoot,
		conf:             conf,
		siteName:         config.GetString(conf, "sr.ht", "site-name", "sourcehut"),
		environment:      config.GetString(conf, "sr.ht", "environment", "production"),
		metaOrigin:       metaOrigin,
		compareOrigin:    compareOrigin,
		hubOrigin:        config.GetOrigin(conf, "hub.sr.ht", true),
		cssHref:          cssHref,
		nav:              buildNav(conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL.
func resolveCSSHref() (string, error) {
	matches, err := fs.Glob(staticFS, "static/main.min.*.css")
	if err != nil {
		return "", fmt.Errorf("web: glob stylesheet: %w", err)
	}
	if len(matches) == 0 {
		return "", fmt.Errorf("web: no main.min.*.css in embedded static assets")
	}
	return "/static/" + path.Base(matches[0]), nil
}

A web/templates.go => web/templates.go +94 -0
@@ 0,0 1,94 @@
package web

import (
	"bytes"
	"embed"
	"html/template"
	"net/http"
	"time"

	"github.com/sirupsen/logrus"
)

// tmplFS holds the page templates. Each page is parsed together with the shared
// layout into its own template set so that per-page "content"/"scripts" defines
// do not collide across pages.
//
//go:embed templates/*.html
var tmplFS embed.FS

// staticFS holds the built front-end assets (bundle.js, the hashed stylesheet,
// logo.svg). It is served read-only under /static/.
//
//go:embed static
var staticFS embed.FS

// funcMap holds the template helpers shared by every page.
var funcMap = template.FuncMap{
	// shortsha abbreviates an object id to 8 hex chars.
	"shortsha": func(s string) string {
		if len(s) > 8 {
			return s[:8]
		}
		return s
	},
	// date formats a commit timestamp for display.
	"date": func(t time.Time) string {
		return t.UTC().Format("2006-01-02 15:04 MST")
	},
}

// pageNames are the content templates; each is parsed with layout.html.
var pageNames = []string{"index", "repo", "compare", "commit", "error"}

// pages maps a page name to its parsed template set (layout + that page).
var pages = func() map[string]*template.Template {
	m := make(map[string]*template.Template, len(pageNames))
	for _, name := range pageNames {
		t := template.New("layout.html").Funcs(funcMap)
		t = template.Must(t.ParseFS(tmplFS, "templates/layout.html", "templates/"+name+".html"))
		m[name] = t
	}
	return m
}()

// render executes a page into a buffer first, so a template error yields a clean
// 500 rather than a half-written response. On success it writes the status and
// the buffered HTML.
func (s *Server) render(w http.ResponseWriter, status int, page string, vd viewData) {
	t, ok := pages[page]
	if !ok {
		logrus.WithField("page", page).Error("web: unknown template page")
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	var buf bytes.Buffer
	if err := t.ExecuteTemplate(&buf, "layout.html", vd); err != nil {
		logrus.WithError(err).WithField("page", page).Error("web: template execution failed")
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	_, _ = buf.WriteTo(w)
}

// errorData is the payload of the error page.
type errorData struct {
	Status     int
	StatusText string
	Message    string
}

// renderError renders the chrome-wrapped error page. It never recurses into
// render on failure (render falls back to http.Error itself).
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
	vd := s.chrome(r)
	vd.Title = http.StatusText(status)
	vd.Data = errorData{
		Status:     status,
		StatusText: http.StatusText(status),
		Message:    message,
	}
	s.render(w, status, "error", vd)
}

A web/templates/commit.html => web/templates/commit.html +81 -0
@@ 0,0 1,81 @@
{{define "content"}}
{{$owner := .Data.Owner}}
{{$repo := .Data.RepoName}}
{{$c := .Data.Commit}}
<div class="row">
  <div class="col-md-12">
    <h2>
      <a href="/~{{$owner}}/{{$repo}}">~{{$owner}}/{{$repo}}</a>:
      <code>{{$c.ShortSHA}}</code>
    </h2>
    <p><strong>{{$c.Subject}}</strong></p>
    {{if $c.Body}}<pre class="commit-body">{{$c.Body}}</pre>{{end}}
    <p class="text-muted">
      {{$c.AuthorName}} &lt;{{$c.AuthorEmail}}&gt; — {{date $c.Date}}
    </p>
    <p class="text-muted">
      Commit <code>{{$c.SHA}}</code> —
      <a href="{{.Data.PatchURL}}">view raw patch</a>
    </p>
    {{if $c.ParentSHAs}}
    <p>
      Parent(s):
      {{range $c.ParentSHAs}}
      <a href="/~{{$owner}}/{{$repo}}/commit/{{.}}"><code>{{shortsha .}}</code></a>
      {{end}}
    </p>
    {{end}}
  </div>
</div>

{{if .Data.IsMerge}}
<div class="alert alert-info">
  This is a merge commit; the diff shown is against its first parent.
</div>
{{end}}

{{if .Data.Truncated}}
<div class="alert alert-warning compare-truncated">
  This diff is too large to render in full.
  <a href="{{.Data.PatchURL}}">Download the raw patch</a> to see everything.
</div>
{{end}}

<div class="row">
  <div class="col-md-12">
    <h3>{{len .Data.Files}} changed file(s)</h3>
    <table class="table">
      <thead>
        <tr><th>File</th><th>Status</th><th>+</th><th>&minus;</th></tr>
      </thead>
      <tbody>
        {{range .Data.Files}}
        <tr>
          <td>
            {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
            {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
          </td>
          <td>{{.Status}}</td>
          <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
          <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
        </tr>
        {{end}}
      </tbody>
    </table>
  </div>
</div>

<div class="diff-layout-toggle">
  <button type="button" data-diff-layout="split">Split</button>
  <button type="button" data-diff-layout="stacked">Unified</button>
</div>
<div id="compare-app">
  <div id="tree-root"></div>
  <div id="diff-root"></div>
</div>
<script id="compare-data" type="application/json">{{.Data.JSON}}</script>
{{end}}

{{define "scripts"}}
<script type="module" src="/static/bundle.js"></script>
{{end}}

A web/templates/compare.html => web/templates/compare.html +83 -0
@@ 0,0 1,83 @@
{{define "content"}}
{{$owner := .Data.Owner}}
{{$repo := .Data.RepoName}}
{{$sep := "..."}}{{if not .Data.Spec.ThreeDot}}{{$sep = ".."}}{{end}}
<div class="row">
  <div class="col-md-12">
    <h2>
      <a href="/~{{$owner}}/{{$repo}}">~{{$owner}}/{{$repo}}</a>:
      <code>{{.Data.Spec.Base}}</code>{{$sep}}<code>{{.Data.Spec.Head}}</code>
    </h2>
    <p class="text-muted">
      {{if .Data.Spec.ThreeDot}}
      Three-dot comparison (symmetric difference from the merge base).
      {{if .Data.MergeBase}}Merge base: <code>{{shortsha .Data.MergeBase}}</code>.{{end}}
      {{else}}
      Two-dot comparison (direct range <code>{{.Data.Spec.Base}}..{{.Data.Spec.Head}}</code>).
      {{end}}
    </p>
  </div>
</div>

{{if .Data.Truncated}}
<div class="alert alert-warning compare-truncated">
  This diff is too large to render in full.
  <a href="{{.Data.PatchURL}}">Download the raw patch</a> to see everything.
</div>
{{end}}

<div class="row">
  <div class="col-md-12">
    <details open>
      <summary>{{len .Data.Commits}} commit(s)</summary>
      <ul class="list-unstyled">
        {{range .Data.Commits}}
        <li>
          <a href="/~{{$owner}}/{{$repo}}/commit/{{.SHA}}"><code>{{.ShortSHA}}</code></a>
          {{.Subject}}
          <span class="text-muted">— {{.AuthorName}}, {{date .Date}}</span>
        </li>
        {{end}}
      </ul>
    </details>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <h3>{{len .Data.Files}} changed file(s)</h3>
    <table class="table">
      <thead>
        <tr><th>File</th><th>Status</th><th>+</th><th>&minus;</th></tr>
      </thead>
      <tbody>
        {{range .Data.Files}}
        <tr>
          <td>
            {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
            {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
          </td>
          <td>{{.Status}}</td>
          <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
          <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
        </tr>
        {{end}}
      </tbody>
    </table>
  </div>
</div>

<div class="diff-layout-toggle">
  <button type="button" data-diff-layout="split">Split</button>
  <button type="button" data-diff-layout="stacked">Unified</button>
</div>
<div id="compare-app">
  <div id="tree-root"></div>
  <div id="diff-root"></div>
</div>
<script id="compare-data" type="application/json">{{.Data.JSON}}</script>
{{end}}

{{define "scripts"}}
<script type="module" src="/static/bundle.js"></script>
{{end}}

A web/templates/error.html => web/templates/error.html +9 -0
@@ 0,0 1,9 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.Data.Status}} — {{.Data.StatusText}}</h2>
    {{if .Data.Message}}<p class="text-muted">{{.Data.Message}}</p>{{end}}
    <p><a href="/">Return to the landing page</a>.</p>
  </div>
</div>
{{end}}

A web/templates/index.html => web/templates/index.html +54 -0
@@ 0,0 1,54 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.SiteName}} <span class="text-danger">compare</span></h2>
    <p>
      Compare two references — branches, tags or commits — of any git repository
      on this instance, and review the diff file by file.
    </p>
  </div>
</div>

{{if .Data.LoggedIn}}
<div class="row">
  <div class="col-md-12">
    <h3>Your repositories</h3>
    {{if .Data.Repos}}
    <ul class="list-unstyled">
      {{range .Data.Repos}}
      <li>
        <a href="/~{{$.Username}}/{{.Name}}"><strong>~{{$.Username}}/{{.Name}}</strong></a>
        <span class="badge badge-secondary">{{.Visibility}}</span>
        {{if .Description}}<span class="text-muted"> — {{.Description}}</span>{{end}}
      </li>
      {{end}}
    </ul>
    {{else}}
    <p class="text-muted">You have no repositories yet.</p>
    {{end}}
  </div>
</div>
{{else}}
<div class="row">
  <div class="col-md-12">
    <p>
      <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to list your own
      repositories, or jump directly to any repository below.
    </p>
    <form method="GET" action="/jump" class="form-inline">
      <div class="form-group">
        <label class="sr-only" for="owner">Owner</label>
        <input class="form-control" type="text" id="owner" name="owner"
               placeholder="~owner" autocomplete="off">
      </div>
      <div class="form-group">
        <label class="sr-only" for="repo">Repository</label>
        <input class="form-control" type="text" id="repo" name="repo"
               placeholder="repo" autocomplete="off">
      </div>
      <button class="btn btn-primary" type="submit">Go</button>
    </form>
  </div>
</div>
{{end}}
{{end}}

A web/templates/layout.html => web/templates/layout.html +61 -0
@@ 0,0 1,61 @@
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{.Title}}</title>
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
    <link rel="stylesheet" href="{{.CSSHref}}">
    {{block "head" .}}{{end}}
  </head>
  <body>
    {{if .ShowBanner}}
    <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
      {{.Environment}} ENVIRONMENT
    </div>
    {{end}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      {{if .HubOrigin}}
      <span class="navbar-brand">
        <a href="{{.HubOrigin}}">{{.SiteName}}</a>
      </span>
      {{else}}
      <span class="navbar-brand">
        <a class="navbar-brand" href="/">
          {{.SiteName}}
          <span class="text-danger">compare</span>
        </a>
      </span>
      {{end}}
      <ul class="navbar-nav">
        {{if .Username}}
        {{range .Nav}}
        <li class="nav-item {{if .Active}}active{{end}}">
          <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
        </li>
        {{end}}
        {{end}}
      </ul>
      <div class="login">
        {{if .Username}}
        <span class="navbar-text">
          Logged in as
          <a href="{{.ProfileURL}}">{{.Username}}</a>
          &mdash;
          <a href="{{.LogoutURL}}">Log out</a>
        </span>
        {{else}}
        <span class="navbar-text">
          <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
          &mdash;
          <a href="{{.RegisterURL}}">Register</a>
        </span>
        {{end}}
      </div>
    </nav>
    <div class="container">
      {{template "content" .}}
    </div>
    {{block "scripts" .}}{{end}}
  </body>
</html>

A web/templates/repo.html => web/templates/repo.html +72 -0
@@ 0,0 1,72 @@
{{define "content"}}
{{$owner := .Data.Owner}}
{{$repo := .Data.Info.Name}}
<div class="row">
  <div class="col-md-12">
    <h2>~{{$owner}}/{{$repo}}
      <span class="badge badge-secondary">{{.Data.Info.Visibility}}</span>
    </h2>
    {{if .Data.Info.Description}}<p class="text-muted">{{.Data.Info.Description}}</p>{{end}}
    {{if .Data.DefaultBranch}}
    <p>Default branch: <code>{{.Data.DefaultBranch}}</code></p>
    {{end}}
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <h3>Compare</h3>
    <form method="GET" action="/~{{$owner}}/{{$repo}}/compare/" class="form-inline">
      <div class="form-group">
        <label class="sr-only" for="base">Base</label>
        <select class="form-control" id="base" name="base">
          <optgroup label="Branches">
            {{range .Data.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
          </optgroup>
          <optgroup label="Tags">
            {{range .Data.Tags}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
          </optgroup>
        </select>
      </div>
      <span class="mx-2">...</span>
      <div class="form-group">
        <label class="sr-only" for="head">Head</label>
        <select class="form-control" id="head" name="head">
          <optgroup label="Branches">
            {{range .Data.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
          </optgroup>
          <optgroup label="Tags">
            {{range .Data.Tags}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
          </optgroup>
        </select>
      </div>
      <div class="form-group mx-2">
        <select class="form-control" name="mode">
          <option value="three">three-dot (merge base)</option>
          <option value="two">two-dot (direct range)</option>
        </select>
      </div>
      <button class="btn btn-primary" type="submit">Compare</button>
    </form>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <h3>Recent commits</h3>
    {{if .Data.Commits}}
    <ul class="list-unstyled">
      {{range .Data.Commits}}
      <li>
        <a href="/~{{$owner}}/{{$repo}}/commit/{{.SHA}}"><code>{{.ShortSHA}}</code></a>
        {{.Subject}}
        <span class="text-muted">— {{.AuthorName}}, {{date .Date}}</span>
      </li>
      {{end}}
    </ul>
    {{else}}
    <p class="text-muted">No commits.</p>
    {{end}}
  </div>
</div>
{{end}}

A web/web_test.go => web/web_test.go +485 -0
@@ 0,0 1,485 @@
package web

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	"github.com/fernet/fernet-go"
	"github.com/go-chi/chi/v5"
	"github.com/vaughan0/go-ini"

	"go.bigb.es/sourcehut-compare/authz"
	"go.bigb.es/sourcehut-compare/core"
	"go.bigb.es/sourcehut-compare/gitx"
)

// testConf carries the crypto keys established in TestMain so tests can seal
// unified-login cookies.
var testConf ini.File

func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet key: " + err.Error())
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		panic("generate webhook seed: " + err.Error())
	}
	testConf = ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
	crypto.InitCrypto(testConf)
	os.Exit(m.Run())
}

// ---- fixtures -------------------------------------------------------------

// stubAuthorizer is a fixed-map Authorizer with optional error injection.
type stubAuthorizer struct {
	repos map[string]authz.RepoInfo // key "owner/name"
	my    []authz.RepoInfo
	err   error // when set, every call fails with this (transport-style) error
}

func (s *stubAuthorizer) Repo(_ context.Context, _, owner, name string) (*authz.RepoInfo, error) {
	if s.err != nil {
		return nil, s.err
	}
	owner = strings.TrimPrefix(owner, "~")
	if info, ok := s.repos[owner+"/"+name]; ok {
		return &info, nil
	}
	return nil, core.ErrNotFound
}

func (s *stubAuthorizer) MyRepos(_ context.Context, _ string) ([]authz.RepoInfo, error) {
	if s.err != nil {
		return nil, s.err
	}
	return s.my, nil
}

// gitFixture drives the git CLI to build a bare repo at <root>/~alice/demo:
//
//	c1 (main): add a.txt
//	c2 (main): add b.txt, edit a.txt          <- main HEAD
//	feature off c1: add feature.txt           <- branch "feature"
//
// It returns the repos root and the full SHA of main's HEAD.
func gitFixture(t *testing.T) (root, mainSHA string) {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skipf("git not available: %v", err)
	}
	root = t.TempDir()
	work := t.TempDir()

	git := func(date string, args ...string) string {
		cmd := exec.Command("git", args...)
		cmd.Dir = work
		cmd.Env = append(os.Environ(),
			"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
			"GIT_TERMINAL_PROMPT=0", "LC_ALL=C",
			"GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.com",
			"GIT_COMMITTER_NAME=Alice", "GIT_COMMITTER_EMAIL=alice@example.com",
			"GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date,
		)
		out, err := cmd.CombinedOutput()
		if err != nil {
			t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
		}
		return string(out)
	}
	write := func(name, data string) {
		if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil {
			t.Fatal(err)
		}
	}

	d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"
	git(d1, "init", "-b", "main")
	write("a.txt", "hello\nworld\n")
	git(d1, "add", "a.txt")
	git(d1, "commit", "-m", "add a.txt")
	git(d2, "branch", "feature")
	write("a.txt", "hello\nworld\nmore\n")
	write("b.txt", "bee\n")
	git(d2, "add", "a.txt", "b.txt")
	git(d2, "commit", "-m", "add b, edit a")
	git(d3, "checkout", "feature")
	write("feature.txt", "feature\n")
	git(d3, "add", "feature.txt")
	git(d3, "commit", "-m", "add feature.txt")
	git(d3, "checkout", "main")

	if err := os.MkdirAll(filepath.Join(root, "~alice"), 0o755); err != nil {
		t.Fatal(err)
	}
	bare := filepath.Join(root, "~alice", "demo")
	git(d3, "clone", "--bare", work, bare)

	mainSHA = strings.TrimSpace(runGit(t, bare, "rev-parse", "main"))
	return root, mainSHA
}

func runGit(t *testing.T, dir string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
	out, err := cmd.CombinedOutput()
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return string(out)
}

// testServer wires a Server (fixture repo + given authorizer) behind the same
// middleware the cmd layer installs, and returns the handler.
func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
	t.Helper()
	conf := ini.File{
		"sr.ht": ini.Section{
			"network-key": testConf.Section("sr.ht")["network-key"],
			"site-name":   "sourcehut",
			"environment": "development",
		},
		"webhooks":      ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
		"compare.sr.ht": ini.Section{"origin": "https://compare.example"},
		"meta.sr.ht":    ini.Section{"origin": "https://meta.example"},
		"git.sr.ht":     ini.Section{"origin": "https://git.example", "repos": root},
		// Extra service sections to exercise nav ordering/exclusions.
		"todo.sr.ht":   ini.Section{"origin": "https://todo.example"},
		"builds.sr.ht": ini.Section{"origin": "https://builds.example"},
		"lists.sr.ht":  ini.Section{"origin": "https://lists.example"},
		"paste.sr.ht":  ini.Section{"origin": "https://paste.example"},
		"pages.sr.ht":  ini.Section{"origin": "https://pages.example"},
		"hub.sr.ht":    ini.Section{"origin": "https://hub.example"},
	}
	srv, err := New(conf, az)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	r := chi.NewRouter()
	r.Use(config.Middleware(conf, "compare.sr.ht"))
	r.Use(authz.Middleware())
	srv.Register(r)
	return r
}

// login seals a unified-login cookie for the given user onto a request.
func login(req *http.Request, user string) {
	payload, _ := json.Marshal(map[string]string{"name": user})
	req.AddCookie(&http.Cookie{Name: authz.CookieName, Value: string(crypto.Encrypt(payload))})
}

func demoAuthorizer() *stubAuthorizer {
	return &stubAuthorizer{
		repos: map[string]authz.RepoInfo{
			"alice/demo": {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
		},
		my: []authz.RepoInfo{
			{ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
			{ID: 2, Name: "secret", Description: "", Visibility: "PRIVATE"},
		},
	}
}

func get(t *testing.T, h http.Handler, target string, user string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, target, nil)
	if user != "" {
		login(req, user)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

// ---- tests ----------------------------------------------------------------

func TestComparePage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, `id="compare-data"`) {
		t.Fatal("missing compare-data script")
	}
	if !strings.Contains(body, `src="/static/bundle.js"`) {
		t.Fatal("missing bundle.js script tag")
	}

	cd := extractCompareData(t, body)
	if cd.Mode != "compare" {
		t.Fatalf("mode = %q, want compare", cd.Mode)
	}
	if cd.Spec.Base != "main" || cd.Spec.Head != "feature" || !cd.Spec.ThreeDot {
		t.Fatalf("spec = %+v, want base=main head=feature threeDot=true", cd.Spec)
	}
	// feature adds feature.txt relative to the merge base (c1).
	found := false
	for _, f := range cd.Files {
		if f.Path == "feature.txt" {
			found = true
			if strings.HasPrefix(f.Path, "a/") || strings.HasPrefix(f.Path, "b/") {
				t.Fatalf("file path has diff prefix: %q", f.Path)
			}
		}
	}
	if !found {
		t.Fatalf("feature.txt not in files: %+v", cd.Files)
	}
}

func TestTwoDotVsThreeDot(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	two := extractCompareData(t, get(t, h, "/~alice/demo/compare/main..feature", "").Body.String())
	if two.Spec.ThreeDot {
		t.Fatal("main..feature parsed as three-dot")
	}
	three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String())
	if !three.Spec.ThreeDot {
		t.Fatal("main...feature parsed as two-dot")
	}
}

func TestComparePatchRoute(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature.patch", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Fatalf("content-type = %q, want text/plain", ct)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatalf("patch body missing diff header:\n%s", rec.Body.String())
	}
}

func TestCommitPage(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	cd := extractCompareData(t, rec.Body.String())
	if cd.Mode != "commit" {
		t.Fatalf("mode = %q, want commit", cd.Mode)
	}
	// c2 modifies a.txt and adds b.txt.
	if len(cd.Files) == 0 {
		t.Fatal("commit page has no files")
	}
}

func TestCommitPatchRoute(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/~alice/demo/commit/"+mainSHA+".patch", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatal("commit patch missing diff header")
	}
}

func TestUnknownRepoIs404(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/~alice/nope/compare/main...feature", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
}

func TestPrivateRepoInvisibleIs404(t *testing.T) {
	// Authorizer reports the repo as not-found (visibility hidden) even though
	// the bare repo exists on disk.
	root, _ := gitFixture(t)
	az := &stubAuthorizer{repos: map[string]authz.RepoInfo{}}
	h := testServer(t, root, az)
	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
}

func TestAuthorizerTransportErrorIs500(t *testing.T) {
	root, _ := gitFixture(t)
	az := &stubAuthorizer{err: errors.New("graphql unreachable")}
	h := testServer(t, root, az)
	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 (transport error must not be 404)", rec.Code)
	}
}

func TestBadRefIs400(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/~alice/demo/compare/..bad", "")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", rec.Code)
	}
}

func TestIndexAnonymous(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/", "")
	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, `action="/jump"`) {
		t.Fatal("anonymous index missing jump form")
	}
	if !strings.Contains(body, "return_to=") {
		t.Fatal("login URL missing return_to")
	}
}

func TestIndexLoggedIn(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/", "bigbes")
	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, "/~bigbes/demo") {
		t.Fatal("logged-in index missing repo link from MyRepos")
	}
	if !strings.Contains(body, "PRIVATE") {
		t.Fatal("logged-in index missing visibility badge")
	}
}

func TestNavExclusionsAndActive(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	// Nav switcher only renders for a logged-in viewer.
	body := get(t, h, "/", "bigbes").Body.String()

	if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
		t.Fatal("nav missing expected services")
	}
	if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
		t.Fatal("nav must exclude paste/pages")
	}
	// hub is the brand, never a switcher item.
	nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
	if strings.Contains(nav, "hub.example") {
		t.Fatal("hub must not appear in the switcher list")
	}
	if !strings.Contains(nav, `nav-item active`) {
		t.Fatal("compare should be the active nav item")
	}
}

func TestHealthz(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/healthz", "")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
		t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
	}
}

func TestStaticBundleAndCSS(t *testing.T) {
	root, _ := gitFixture(t)
	srvHandler := testServer(t, root, demoAuthorizer())

	rec := get(t, srvHandler, "/static/bundle.js", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("bundle.js status = %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Fatalf("bundle.js content-type = %q", ct)
	}

	css := cssName(t)
	rec = get(t, srvHandler, "/static/"+css, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("css status = %d", rec.Code)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
		t.Fatalf("hashed css cache-control = %q, want immutable", cc)
	}
}

// TestCompareJSONNoScriptBreakout verifies a file path containing "</script>"
// cannot break out of the embedded <script> element.
func TestCompareJSONNoScriptBreakout(t *testing.T) {
	patch := &gitx.Patch{Text: "diff --git a/x b/x\n"}
	files := []gitx.FileChange{{Path: "evil</script><script>alert(1)</script>.txt", Status: "A", Additions: 1}}
	html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"})
	if err != nil {
		t.Fatal(err)
	}
	s := string(html)
	// The '<' of "</script>" must be escaped, so no literal "</script" tag can
	// appear to close the embedding element.
	if strings.Contains(s, "</script") {
		t.Fatalf("raw </script present in JSON (breakout possible): %s", s)
	}
	// ...and it must appear in its escaped </script form instead, proving
	// the marshaler HTML-escaped the '<'.
	if !strings.Contains(s, "\\u003c/script") {
		t.Fatalf("expected escaped \\u003c/script, got: %s", s)
	}
}

func cssName(t *testing.T) string {
	t.Helper()
	href, err := resolveCSSHref()
	if err != nil {
		t.Fatal(err)
	}
	return strings.TrimPrefix(href, "/static/")
}

// extractCompareData pulls and decodes the embedded JSON payload from a page.
func extractCompareData(t *testing.T, body string) compareData {
	t.Helper()
	const open = `id="compare-data" type="application/json">`
	i := strings.Index(body, open)
	if i < 0 {
		t.Fatalf("no compare-data script in body:\n%s", body)
	}
	rest := body[i+len(open):]
	j := strings.Index(rest, "</script>")
	if j < 0 {
		t.Fatal("compare-data script not closed")
	}
	var cd compareData
	if err := json.Unmarshal([]byte(rest[:j]), &cd); err != nil {
		t.Fatalf("decode compare-data: %v\nraw: %s", err, rest[:j])
	}
	return cd
}