prime8dev

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

git.go (6394B)


      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 	webBase  string
     31 }
     32 
     33 func NewGit() *Git {
     34 	reposDir := strings.TrimSpace(os.Getenv("GIT_REPOS_DIR"))
     35 	if reposDir == "" {
     36 		reposDir = defaultGitReposDir
     37 	}
     38 
     39 	return &Git{
     40 		reposDir: reposDir,
     41 		webBase:  config.GitWebBase(),
     42 	}
     43 }
     44 
     45 type GitMetadata struct {
     46 	Readme       string
     47 	DateCreated  time.Time
     48 	DateModified time.Time
     49 }
     50 
     51 func (s *Git) FetchMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
     52 	if ctx == nil {
     53 		ctx = context.Background()
     54 	}
     55 
     56 	return s.readMetadata(ctx, project)
     57 }
     58 
     59 func (s *Git) RefreshMetadata(ctx context.Context, project config.Project) error {
     60 	if ctx == nil {
     61 		ctx = context.Background()
     62 	}
     63 
     64 	_, err := s.readMetadata(ctx, project)
     65 	return err
     66 }
     67 
     68 func (s *Git) repoDir(repo string) string {
     69 	return filepath.Join(s.reposDir, repo+".git")
     70 }
     71 
     72 func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
     73 	repo := strings.TrimSpace(project.Repo)
     74 	if repo == "" {
     75 		return nil, fmt.Errorf("project %q has no repo configured", project.ID)
     76 	}
     77 	if strings.Contains(repo, "/") || strings.Contains(repo, "..") {
     78 		return nil, fmt.Errorf("invalid repo name %q", repo)
     79 	}
     80 
     81 	dir := s.repoDir(repo)
     82 	if info, err := os.Stat(dir); err != nil {
     83 		return nil, fmt.Errorf("repo %s: %w", repo, err)
     84 	} else if !info.IsDir() {
     85 		return nil, fmt.Errorf("repo %s: %s is not a directory", repo, dir)
     86 	}
     87 
     88 	modified, err := s.commitTime(ctx, dir, "log", "-1", "--format=%ct", "HEAD")
     89 	if err != nil {
     90 		return nil, fmt.Errorf("repo %s: last commit: %w", repo, err)
     91 	}
     92 	created, err := s.commitTime(ctx, dir, "log", "--reverse", "--format=%ct", "HEAD")
     93 	if err != nil {
     94 		return nil, fmt.Errorf("repo %s: first commit: %w", repo, err)
     95 	}
     96 
     97 	readme := ""
     98 	for _, name := range readmeNames {
     99 		out, err := s.git(ctx, dir, "show", "HEAD:"+name)
    100 		if err != nil {
    101 			continue
    102 		}
    103 		readme = fixRelativePaths(s.webBase, repo, string(out))
    104 		break
    105 	}
    106 
    107 	return &GitMetadata{
    108 		Readme:       readme,
    109 		DateCreated:  created,
    110 		DateModified: modified,
    111 	}, nil
    112 }
    113 
    114 // commitTime runs a git log command printing %ct per line and returns the
    115 // time on the first line.
    116 func (s *Git) commitTime(ctx context.Context, dir string, args ...string) (time.Time, error) {
    117 	out, err := s.git(ctx, dir, args...)
    118 	if err != nil {
    119 		return time.Time{}, err
    120 	}
    121 
    122 	line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
    123 	line = strings.TrimSpace(line)
    124 	if line == "" {
    125 		return time.Time{}, errors.New("no commits")
    126 	}
    127 
    128 	sec, err := strconv.ParseInt(line, 10, 64)
    129 	if err != nil {
    130 		return time.Time{}, fmt.Errorf("parse commit time %q: %w", line, err)
    131 	}
    132 
    133 	return time.Unix(sec, 0).UTC(), nil
    134 }
    135 
    136 func (s *Git) git(ctx context.Context, dir string, args ...string) ([]byte, error) {
    137 	ctx, cancel := context.WithTimeout(ctx, gitCommandTimeout)
    138 	defer cancel()
    139 
    140 	cmd := exec.CommandContext(ctx, "git", append([]string{"--git-dir=" + dir}, args...)...)
    141 	var stderr bytes.Buffer
    142 	cmd.Stderr = &stderr
    143 	out, err := cmd.Output()
    144 	if err != nil {
    145 		if msg := strings.TrimSpace(stderr.String()); msg != "" {
    146 			return nil, fmt.Errorf("git %s: %w: %s", args[0], err, msg)
    147 		}
    148 		return nil, fmt.Errorf("git %s: %w", args[0], err)
    149 	}
    150 
    151 	return out, nil
    152 }
    153 
    154 // imageRegex and linkRegex match inline markdown images and links with an
    155 // optional title: [text](target "title").
    156 var (
    157 	imageRegex     = regexp.MustCompile(`!\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
    158 	linkRegex      = regexp.MustCompile(`\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
    159 	urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
    160 )
    161 
    162 const imagePlaceholder = "\x00img%d\x00"
    163 
    164 // fixRelativePaths rewrites repo-relative links and images in a README to the
    165 // stagit web layout: links to <base>/<repo>/file/<path>.html and images to
    166 // <base>/<repo>/raw/<path>. Absolute URLs and #anchors are left unchanged.
    167 func fixRelativePaths(webBase, repo, markdown string) string {
    168 	repoBase := strings.TrimSuffix(webBase, "/") + "/" + repo
    169 
    170 	// Images first, swapped for placeholders so that links wrapping an image
    171 	// ([![badge](img.svg)](target)) are still matched as links afterwards.
    172 	images := []string{}
    173 	markdown = imageRegex.ReplaceAllStringFunc(markdown, func(match string) string {
    174 		parts := imageRegex.FindStringSubmatch(match)
    175 		if path, ok := repoRelativePath(parts[2]); ok {
    176 			match = fmt.Sprintf("![%s](%s/raw/%s%s)", parts[1], repoBase, path, parts[3])
    177 		}
    178 		images = append(images, match)
    179 		return fmt.Sprintf(imagePlaceholder, len(images)-1)
    180 	})
    181 
    182 	markdown = linkRegex.ReplaceAllStringFunc(markdown, func(match string) string {
    183 		parts := linkRegex.FindStringSubmatch(match)
    184 		target := parts[2]
    185 		path, ok := repoRelativePath(target)
    186 		if !ok {
    187 			return match
    188 		}
    189 
    190 		var rewritten string
    191 		if strings.HasSuffix(path, "/") {
    192 			rewritten = repoBase + "/files.html"
    193 		} else {
    194 			rewritten = fmt.Sprintf("%s/file/%s.html", repoBase, path)
    195 			if _, fragment, found := strings.Cut(target, "#"); found && fragment != "" {
    196 				rewritten += "#" + fragment
    197 			}
    198 		}
    199 
    200 		return fmt.Sprintf("[%s](%s%s)", parts[1], rewritten, parts[3])
    201 	})
    202 
    203 	for i, image := range images {
    204 		markdown = strings.Replace(markdown, fmt.Sprintf(imagePlaceholder, i), image, 1)
    205 	}
    206 
    207 	return markdown
    208 }
    209 
    210 // repoRelativePath returns the cleaned repo-relative path for a markdown
    211 // target, or false when the target is absolute (has a scheme or is
    212 // protocol-relative) or is a bare #anchor.
    213 func repoRelativePath(target string) (string, bool) {
    214 	if target == "" || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "//") || urlSchemeRegex.MatchString(target) {
    215 		return "", false
    216 	}
    217 
    218 	path, _, _ := strings.Cut(target, "#")
    219 	path, _, _ = strings.Cut(path, "?")
    220 	for strings.HasPrefix(path, "./") {
    221 		path = strings.TrimPrefix(path, "./")
    222 	}
    223 	path = strings.TrimLeft(path, "/")
    224 	if path == "" {
    225 		return "", false
    226 	}
    227 
    228 	return path, true
    229 }