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 // ([](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("", 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 }