Download
247 lines 7.4 KiBGoModified 2026-10-20 raw
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
}