prime8dev

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

git.go (7757B)


      1 package external
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"errors"
      7 	"fmt"
      8 	"os"
      9 	"os/exec"
     10 	"path/filepath"
     11 	"regexp"
     12 	"strconv"
     13 	"strings"
     14 	"time"
     15 
     16 	"git.prime8.dev/moncheeta/prime8dev/internal/config"
     17 )
     18 
     19 const (
     20 	defaultGitReposDir = "/srv/git/repos"
     21 	gitCommandTimeout  = 10 * time.Second
     22 )
     23 
     24 // readmeNames are tried in order when reading a repository README from HEAD.
     25 var readmeNames = []string{"README.md", "readme.md", "README", "README.markdown"}
     26 
     27 // Git reads project metadata from local bare repositories.
     28 type Git struct {
     29 	reposDir string
     30 }
     31 
     32 func NewGit() *Git {
     33 	reposDir := strings.TrimSpace(os.Getenv("GIT_REPOS_DIR"))
     34 	if reposDir == "" {
     35 		reposDir = defaultGitReposDir
     36 	}
     37 
     38 	return &Git{reposDir: reposDir}
     39 }
     40 
     41 type GitMetadata struct {
     42 	Readme       string
     43 	DateCreated  time.Time
     44 	DateModified time.Time
     45 }
     46 
     47 func (s *Git) FetchMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
     48 	if ctx == nil {
     49 		ctx = context.Background()
     50 	}
     51 
     52 	return s.readMetadata(ctx, project)
     53 }
     54 
     55 func (s *Git) RefreshMetadata(ctx context.Context, project config.Project) error {
     56 	if ctx == nil {
     57 		ctx = context.Background()
     58 	}
     59 
     60 	_, err := s.readMetadata(ctx, project)
     61 	return err
     62 }
     63 
     64 // HeadSHA returns the commit HEAD points at, a cheap freshness key for
     65 // anything derived from the repo.
     66 func (s *Git) HeadSHA(ctx context.Context, project config.Project) (string, error) {
     67 	if ctx == nil {
     68 		ctx = context.Background()
     69 	}
     70 	dir, err := s.projectRepoDir(project)
     71 	if err != nil {
     72 		return "", err
     73 	}
     74 	out, err := s.git(ctx, dir, "rev-parse", "HEAD")
     75 	if err != nil {
     76 		return "", err
     77 	}
     78 	return strings.TrimSpace(string(out)), nil
     79 }
     80 
     81 // ErrNotFound is returned by ReadFile when the path is not a file at HEAD.
     82 var ErrNotFound = errors.New("not found")
     83 
     84 func (s *Git) repoDir(repo string) string {
     85 	return filepath.Join(s.reposDir, repo+".git")
     86 }
     87 
     88 // projectRepoDir validates a project's repo name and returns its bare repo
     89 // directory.
     90 func (s *Git) projectRepoDir(project config.Project) (string, error) {
     91 	repo := strings.TrimSpace(project.Repo)
     92 	if repo == "" {
     93 		return "", fmt.Errorf("project %q has no repo configured", project.ID)
     94 	}
     95 	if strings.Contains(repo, "/") || strings.Contains(repo, "..") {
     96 		return "", fmt.Errorf("invalid repo name %q", repo)
     97 	}
     98 
     99 	dir := s.repoDir(repo)
    100 	if info, err := os.Stat(dir); err != nil {
    101 		return "", fmt.Errorf("repo %s: %w", repo, err)
    102 	} else if !info.IsDir() {
    103 		return "", fmt.Errorf("repo %s: %s is not a directory", repo, dir)
    104 	}
    105 
    106 	return dir, nil
    107 }
    108 
    109 // ReadFile returns the contents of a file at HEAD of a project's repo.
    110 func (s *Git) ReadFile(ctx context.Context, project config.Project, path string) ([]byte, error) {
    111 	if ctx == nil {
    112 		ctx = context.Background()
    113 	}
    114 
    115 	path = strings.Trim(path, "/")
    116 	if path == "" || strings.HasPrefix(path, ".git/") {
    117 		return nil, ErrNotFound
    118 	}
    119 	for _, part := range strings.Split(path, "/") {
    120 		if part == "" || part == "." || part == ".." {
    121 			return nil, ErrNotFound
    122 		}
    123 	}
    124 
    125 	dir, err := s.projectRepoDir(project)
    126 	if err != nil {
    127 		return nil, err
    128 	}
    129 
    130 	kind, err := s.git(ctx, dir, "cat-file", "-t", "HEAD:"+path)
    131 	if err != nil || strings.TrimSpace(string(kind)) != "blob" {
    132 		return nil, ErrNotFound
    133 	}
    134 
    135 	return s.git(ctx, dir, "cat-file", "blob", "HEAD:"+path)
    136 }
    137 
    138 func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
    139 	repo := strings.TrimSpace(project.Repo)
    140 	dir, err := s.projectRepoDir(project)
    141 	if err != nil {
    142 		return nil, err
    143 	}
    144 
    145 	modified, err := s.commitTime(ctx, dir, "log", "-1", "--format=%ct", "HEAD")
    146 	if err != nil {
    147 		return nil, fmt.Errorf("repo %s: last commit: %w", repo, err)
    148 	}
    149 	created, err := s.commitTime(ctx, dir, "log", "--reverse", "--format=%ct", "HEAD")
    150 	if err != nil {
    151 		return nil, fmt.Errorf("repo %s: first commit: %w", repo, err)
    152 	}
    153 
    154 	readme := ""
    155 	for _, name := range readmeNames {
    156 		out, err := s.git(ctx, dir, "show", "HEAD:"+name)
    157 		if err != nil {
    158 			continue
    159 		}
    160 		readme = fixRelativePaths(project.ID, string(out))
    161 		break
    162 	}
    163 
    164 	return &GitMetadata{
    165 		Readme:       readme,
    166 		DateCreated:  created,
    167 		DateModified: modified,
    168 	}, nil
    169 }
    170 
    171 // commitTime runs a git log command printing %ct per line and returns the
    172 // time on the first line.
    173 func (s *Git) commitTime(ctx context.Context, dir string, args ...string) (time.Time, error) {
    174 	out, err := s.git(ctx, dir, args...)
    175 	if err != nil {
    176 		return time.Time{}, err
    177 	}
    178 
    179 	line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
    180 	line = strings.TrimSpace(line)
    181 	if line == "" {
    182 		return time.Time{}, errors.New("no commits")
    183 	}
    184 
    185 	sec, err := strconv.ParseInt(line, 10, 64)
    186 	if err != nil {
    187 		return time.Time{}, fmt.Errorf("parse commit time %q: %w", line, err)
    188 	}
    189 
    190 	return time.Unix(sec, 0).UTC(), nil
    191 }
    192 
    193 func (s *Git) git(ctx context.Context, dir string, args ...string) ([]byte, error) {
    194 	ctx, cancel := context.WithTimeout(ctx, gitCommandTimeout)
    195 	defer cancel()
    196 
    197 	cmd := exec.CommandContext(ctx, "git", append([]string{"--git-dir=" + dir}, args...)...)
    198 	var stderr bytes.Buffer
    199 	cmd.Stderr = &stderr
    200 	out, err := cmd.Output()
    201 	if err != nil {
    202 		if msg := strings.TrimSpace(stderr.String()); msg != "" {
    203 			return nil, fmt.Errorf("git %s: %w: %s", args[0], err, msg)
    204 		}
    205 		return nil, fmt.Errorf("git %s: %w", args[0], err)
    206 	}
    207 
    208 	return out, nil
    209 }
    210 
    211 // imageRegex and linkRegex match inline markdown images and links with an
    212 // optional title: [text](target "title").
    213 var (
    214 	imageRegex     = regexp.MustCompile(`!\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
    215 	linkRegex      = regexp.MustCompile(`\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
    216 	urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
    217 )
    218 
    219 const imagePlaceholder = "\x00img%d\x00"
    220 
    221 // fixRelativePaths rewrites repo-relative links and images in a README to
    222 // files served from HEAD by the site (config.ProjectRawURL). Links to
    223 // directories point at the project page. Absolute URLs and #anchors are left
    224 // unchanged.
    225 func fixRelativePaths(projectID, markdown string) string {
    226 	// Images first, swapped for placeholders so that links wrapping an image
    227 	// ([![badge](img.svg)](target)) are still matched as links afterwards.
    228 	images := []string{}
    229 	markdown = imageRegex.ReplaceAllStringFunc(markdown, func(match string) string {
    230 		parts := imageRegex.FindStringSubmatch(match)
    231 		if path, ok := repoRelativePath(parts[2]); ok {
    232 			match = fmt.Sprintf("![%s](%s%s)", parts[1], config.ProjectRawURL(projectID, path), parts[3])
    233 		}
    234 		images = append(images, match)
    235 		return fmt.Sprintf(imagePlaceholder, len(images)-1)
    236 	})
    237 
    238 	markdown = linkRegex.ReplaceAllStringFunc(markdown, func(match string) string {
    239 		parts := linkRegex.FindStringSubmatch(match)
    240 		path, ok := repoRelativePath(parts[2])
    241 		if !ok {
    242 			return match
    243 		}
    244 
    245 		rewritten := config.ProjectRawURL(projectID, path)
    246 		if strings.HasSuffix(path, "/") {
    247 			rewritten = config.SiteURL + "/projects/" + projectID
    248 		}
    249 
    250 		return fmt.Sprintf("[%s](%s%s)", parts[1], rewritten, parts[3])
    251 	})
    252 
    253 	for i, image := range images {
    254 		markdown = strings.Replace(markdown, fmt.Sprintf(imagePlaceholder, i), image, 1)
    255 	}
    256 
    257 	return markdown
    258 }
    259 
    260 // repoRelativePath returns the cleaned repo-relative path for a markdown
    261 // target, or false when the target is absolute (has a scheme or is
    262 // protocol-relative) or is a bare #anchor.
    263 func repoRelativePath(target string) (string, bool) {
    264 	if target == "" || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "//") || urlSchemeRegex.MatchString(target) {
    265 		return "", false
    266 	}
    267 
    268 	path, _, _ := strings.Cut(target, "#")
    269 	path, _, _ = strings.Cut(path, "?")
    270 	for strings.HasPrefix(path, "./") {
    271 		path = strings.TrimPrefix(path, "./")
    272 	}
    273 	path = strings.TrimLeft(path, "/")
    274 	if path == "" {
    275 		return "", false
    276 	}
    277 
    278 	return path, true
    279 }