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