123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 | package render
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"code.bruce-hill.com/repo-renderer/internal/gitx"
)
type refLine struct{ name, sha string }
// ExportPub mirrors the minimum a dumb-HTTP clone needs into htdocs/<host>/git/<repo>.git:
// HEAD, the branch ref, tags, and packfiles holding exactly the objects
// those refs reach. Nothing else (no config, hooks, other branches, their
// objects, or loose objects) ever reaches the webroot.
func (s *Site) ExportPub(repo, branch string) error {
g := gitx.Repo{Dir: s.Lay.GitDir(repo)}
dst := s.Lay.PubDir(repo)
for _, d := range []string{"objects/pack", "objects/info", "info", "refs/heads", "refs/tags"} {
if err := os.MkdirAll(filepath.Join(dst, d), 0o755); err != nil {
return err
}
}
sha, err := g.RevParse("refs/heads/" + branch)
if err != nil {
return err
}
if sha == "" {
return fmt.Errorf("%s: branch %s does not exist", repo, branch)
}
tags, err := g.Tags()
if err != nil {
return err
}
refs := []refLine{{"refs/heads/" + branch, sha}}
heads := []refLine{{branch, sha}}
// Allowlisted branches are browsable on the site, so they are clonable.
tips, _ := s.allowlisted(g, repo, branch)
heads = append(heads, tips...)
for _, t := range tips {
refs = append(refs, refLine{"refs/heads/" + t.name, t.sha})
}
var tagFiles []refLine
for _, t := range tags {
tagFiles = append(tagFiles, refLine{t.Name, t.Object})
refs = append(refs, refLine{"refs/tags/" + t.Name, t.Object})
if t.SHA != t.Object {
refs = append(refs, refLine{"refs/tags/" + t.Name + "^{}", t.SHA})
}
}
slices.SortFunc(refs, func(a, b refLine) int { return strings.Compare(a.name, b.name) })
var infoRefs strings.Builder
for _, r := range refs {
fmt.Fprintf(&infoRefs, "%s\t%s\n", r.sha, r.name)
}
// The packs: together exactly what the exported refs reach. A push
// that only moves branches forward and adds refs gets a small pack of
// what is new, beside the existing ones; anything else (a rewound or
// deleted ref, a moved tag) would leave unexported objects behind, so
// it rebuilds one pack from scratch, as does reaching maxPubPacks.
// The ref list in the mark file is the key for skipping pack-objects
// when nothing changed, and records what the packs already hold.
packDir := filepath.Join(dst, "objects", "pack")
markPath := filepath.Join(dst, "objects", "info", "repo-render-export")
key := infoRefs.String()
packs, oldKey := readPubMark(markPath, packDir)
if len(packs) == 0 || oldKey != key {
var want []string
for _, r := range refs {
if !strings.HasSuffix(r.name, "^{}") {
want = append(want, r.sha)
}
}
if len(packs) > 0 && len(packs) < maxPubPacks && pubGrows(g, oldKey, refs) {
for line := range strings.SplitSeq(oldKey, "\n") {
if sha, name, ok := strings.Cut(line, "\t"); ok && !strings.HasSuffix(name, "^{}") {
want = append(want, "^"+sha)
}
}
} else {
packs = nil
}
hash, err := g.PackObjects(filepath.Join(packDir, "pack"), want)
if err != nil {
return err
}
if name := "pack-" + hash; !slices.Contains(packs, name) {
packs = append(packs, name)
}
}
// Metadata first, so a clone racing with us sees a packs list that
// names existing packs.
var packsList strings.Builder
for _, name := range packs {
packsList.WriteString("P " + name + ".pack\n")
}
packsList.WriteString("\n")
if err := writeFileAtomic(filepath.Join(dst, "objects", "info", "packs"), []byte(packsList.String())); err != nil {
return err
}
if err := writeFileAtomic(markPath, []byte(strings.Join(packs, " ")+"\n"+key)); err != nil {
return err
}
// Refs: write the new set, then remove what is no longer exported, so
// a clone in flight never sees an empty refs directory.
if err := writeFileAtomic(filepath.Join(dst, "HEAD"), []byte("ref: refs/heads/"+branch+"\n")); err != nil {
return err
}
headsDir := filepath.Join(dst, "refs", "heads")
keepHeads := map[string]bool{}
for _, h := range heads {
rel := filepath.FromSlash(h.name)
keepHeads[rel] = true
if err := writeFileAtomic(filepath.Join(headsDir, rel), []byte(h.sha+"\n")); err != nil {
return err
}
}
if err := removeOthers(headsDir, keepHeads); err != nil {
return err
}
tagsDir := filepath.Join(dst, "refs", "tags")
keepTags := map[string]bool{}
for _, t := range tagFiles {
rel := filepath.FromSlash(t.name)
keepTags[rel] = true
if err := writeFileAtomic(filepath.Join(tagsDir, rel), []byte(t.sha+"\n")); err != nil {
return err
}
}
if err := removeOthers(tagsDir, keepTags); err != nil {
return err
}
if err := writeFileAtomic(filepath.Join(dst, "info", "refs"), []byte(key)); err != nil {
return err
}
// Superseded packs go last: until info/refs changed above, a clone
// could still be asking for objects that only the old pack holds.
old, err := listDir(packDir, func(n string) bool { return strings.HasPrefix(n, "pack-") })
if err != nil {
return err
}
for _, p := range old {
base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) // .pack, .idx, .rev
if !slices.Contains(packs, base) {
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
}
}
return nil
}
// maxPubPacks is how many packs an export may build up through
// incremental updates before the next change rebuilds it as one: a
// dumb-HTTP clone fetches each pack separately.
const maxPubPacks = 8
// readPubMark reads the mark file: the packs already exported and the ref
// list they hold. The packs are nil if any of them has gone missing.
func readPubMark(markPath, packDir string) (packs []string, key string) {
old, err := os.ReadFile(markPath)
if err != nil {
return nil, ""
}
names, key, ok := strings.Cut(string(old), "\n")
if !ok {
return nil, ""
}
packs = strings.Fields(names)
for _, name := range packs {
if _, err := os.Stat(filepath.Join(packDir, name+".idx")); err != nil {
return nil, key
}
}
return packs, key
}
// pubGrows reports whether every ref in the export recorded by oldKey (an
// info/refs listing) is still exported at the same object or, for a
// branch, at a descendant: then everything the existing packs hold is
// still reachable, and only new objects need packing.
func pubGrows(g gitx.Repo, oldKey string, refs []refLine) bool {
now := map[string]string{}
for _, r := range refs {
now[r.name] = r.sha
}
for line := range strings.SplitSeq(oldKey, "\n") {
sha, name, ok := strings.Cut(line, "\t")
if !ok || strings.HasSuffix(name, "^{}") {
continue
}
cur, ok := now[name]
switch {
case !ok:
return false
case cur == sha:
case !strings.HasPrefix(name, "refs/heads/"):
return false
default:
if up, err := g.IsAncestor(sha, cur); err != nil || !up {
return false
}
}
}
return true
}
// removeOthers deletes every file under dir whose dir-relative path is not
// in keep, then any directories left empty.
func removeOthers(dir string, keep map[string]bool) error {
var dirs []string
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == dir {
return nil
}
if d.IsDir() {
dirs = append(dirs, p)
return nil
}
rel, _ := filepath.Rel(dir, p)
if keep[rel] {
return nil
}
return os.Remove(p)
})
if err != nil {
return err
}
// WalkDir lists parents before children; remove children first.
for _, d := range slices.Backward(dirs) {
_ = os.Remove(d) // only succeeds when empty
}
return nil
}
|