GameOver1337 / GitClone Public
К репозиторию
17.2 KB
internal/services/git.go
17.2 KB
1package services
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "sort"
15 "strconv"
16 "strings"
17 "time"
18)
19
20// GitService — обёртка над git CLI для bare-репозиториев на сервере.
21// Репозитории лежат двухуровнево: ReposDir/<владелец>/<имя>.git,
22// чтобы git-URL выглядели как /<владелец>/<имя>.git.
23type GitService struct {
24 ReposDir string
25 BuildsDir string // корень рабочих копий; пусто — проверка dest отключена
26}
27
28func NewGitService(reposDir string) *GitService {
29 return &GitService{ReposDir: reposDir}
30}
31
32// splitName разбирает "владелец/имя" в компоненты пути внутри ReposDir.
33// Разделитель — "/", обратный слэш приводит к ошибке (защита от выхода за ReposDir).
34func splitName(name string) (owner, repo string, err error) {
35 parts := strings.Split(name, "/")
36 if len(parts) != 2 {
37 return "", "", fmt.Errorf("invalid repo name: %q", name)
38 }
39 for _, p := range parts {
40 if p == "" || p == "." || p == ".." {
41 return "", "", fmt.Errorf("invalid repo name: %q", name)
42 }
43 }
44 return parts[0], parts[1], nil
45}
46
47// RepoPath возвращает путь bare-репозитория ReposDir/<владелец>/<имя>.git.
48func (s *GitService) RepoPath(name string) string {
49 owner, repo, err := splitName(name)
50 if err != nil {
51 // имя всегда приходит из БД и проходит проверку создания; на всякий
52 // случай нейтрализуем его в одно безопасное имя каталога
53 return filepath.Join(s.ReposDir, strings.NewReplacer("/", "_", "\\", "_", ":", "_").Replace(name)+".git")
54 }
55 return filepath.Join(s.ReposDir, owner, repo+".git")
56}
57
58// runGit выполняет git-команду с таймаутом. dir — рабочая директория (может быть пустой).
59func runGit(dir string, timeout time.Duration, args ...string) ([]byte, error) {
60 ctx, cancel := context.WithTimeout(context.Background(), timeout)
61 defer cancel()
62
63 cmd := exec.CommandContext(ctx, "git", args...)
64 if dir != "" {
65 cmd.Dir = dir
66 }
67 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
68 return cmd.CombinedOutput()
69}
70
71// CreateRepo инициализирует новый bare-репозиторий.
72func (s *GitService) CreateRepo(name string) error {
73 dir := s.RepoPath(name)
74 if _, err := os.Stat(dir); err == nil {
75 return fmt.Errorf("repo already exists: %s", name)
76 }
77 if err := os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
78 return fmt.Errorf("create owner dir: %w", err)
79 }
80 cmd := exec.Command("git", "init", "--bare", "--initial-branch=main", dir)
81 out, err := cmd.CombinedOutput()
82 if err != nil {
83 return fmt.Errorf("git init failed: %w\n%s", err, out)
84 }
85 // разрешаем receive-pack по HTTP
86 if out, err := exec.Command("git", "-C", dir, "config", "http.receivepack", "true").CombinedOutput(); err != nil {
87 return fmt.Errorf("git config http.receivepack: %w\n%s", err, out)
88 }
89 if out, err := exec.Command("git", "-C", dir, "config", "receive.denyCurrentBranch", "ignore").CombinedOutput(); err != nil {
90 return fmt.Errorf("git config receive.denyCurrentBranch: %w\n%s", err, out)
91 }
92 return nil
93}
94
95// DeleteRepo удаляет bare-репозиторий с диска.
96func (s *GitService) DeleteRepo(name string) error {
97 return os.RemoveAll(s.RepoPath(name))
98}
99
100// DefaultBranch возвращает ветку по умолчанию (HEAD), иначе первую доступную, иначе main.
101func (s *GitService) DefaultBranch(name string) string {
102 head, err := runGit(s.RepoPath(name), 15*time.Second, "symbolic-ref", "--short", "HEAD")
103 headBranch := ""
104 if err == nil {
105 headBranch = strings.TrimSpace(string(head))
106 }
107
108 branches := s.ListBranches(name)
109 if headBranch != "" {
110 for _, b := range branches {
111 if b == headBranch {
112 return headBranch
113 }
114 }
115 }
116 if len(branches) > 0 {
117 return branches[0]
118 }
119 if headBranch != "" {
120 return headBranch
121 }
122 return "main"
123}
124
125// HasBranch проверяет, существует ли ветка.
126func (s *GitService) HasBranch(name, branch string) bool {
127 if branch == "" {
128 return false
129 }
130 for _, b := range s.ListBranches(name) {
131 if b == branch {
132 return true
133 }
134 }
135 return false
136}
137
138// CloneToWorking обновляет рабочую копию репозитория и переключает её на ветку.
139func (s *GitService) CloneToWorking(name, branch, dest string) error {
140 if branch == "" {
141 branch = s.DefaultBranch(name)
142 }
143 repoDir := s.RepoPath(name)
144
145 // dest не должен выходить за пределы рабочей директории сборок.
146 if s.BuildsDir != "" {
147 absDest, err := filepath.Abs(dest)
148 if err != nil {
149 return fmt.Errorf("invalid destination: %w", err)
150 }
151 absBuilds, err := filepath.Abs(s.BuildsDir)
152 if err != nil {
153 return fmt.Errorf("invalid builds dir: %w", err)
154 }
155 if absDest != absBuilds && !strings.HasPrefix(absDest, absBuilds+string(os.PathSeparator)) {
156 return fmt.Errorf("destination outside builds dir: %s", dest)
157 }
158 }
159
160 if _, err := os.Stat(filepath.Join(dest, ".git")); err == nil {
161 if out, err := runGit(dest, 10*time.Minute, "fetch", "--prune", "origin"); err != nil {
162 return fmt.Errorf("git fetch failed: %w\n%s", err, out)
163 }
164 if out, err := runGit(dest, 5*time.Minute, "reset", "--hard", "origin/"+branch); err != nil {
165 return fmt.Errorf("git reset to origin/%s failed: %w\n%s", branch, err, out)
166 }
167 if out, err := runGit(dest, 5*time.Minute, "clean", "-fdx"); err != nil {
168 return fmt.Errorf("git clean failed: %w\n%s", err, out)
169 }
170 return nil
171 }
172
173 if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
174 return err
175 }
176 out, err := runGit("", 10*time.Minute, "clone", "--branch", branch, repoDir, dest)
177 if err == nil {
178 return nil
179 }
180 // Ветки может не быть в пустом репозитории — пробуем клонировать ветку по умолчанию.
181 _ = os.RemoveAll(dest)
182 if out2, err2 := runGit("", 10*time.Minute, "clone", repoDir, dest); err2 != nil {
183 return fmt.Errorf("git clone failed: %w\n%s\n%s", err, out, out2)
184 }
185 return nil
186}
187
188// ListBranches показывает ветки репозитория.
189func (s *GitService) ListBranches(name string) []string {
190 out, err := runGit(s.RepoPath(name), 15*time.Second, "for-each-ref",
191 "--format=%(refname:short)", "refs/heads/")
192 if err != nil {
193 return nil
194 }
195 var branches []string
196 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
197 if line != "" {
198 branches = append(branches, line)
199 }
200 }
201 return branches
202}
203
204// CommitInfo — одна запись лога.
205type CommitInfo struct {
206 Hash string
207 Author string
208 Time time.Time
209 Subject string
210}
211
212// LastCommits возвращает последние n коммитов ветки.
213func (s *GitService) LastCommits(name, branch string, n int) []CommitInfo {
214 if branch == "" || strings.HasPrefix(branch, "-") {
215 return nil
216 }
217 out, err := runGit(s.RepoPath(name), 30*time.Second, "log",
218 fmt.Sprintf("-n%d", n), "--pretty=format:%H%x09%an%x09%ct%x09%s", branch)
219 if err != nil {
220 return nil
221 }
222 var commits []CommitInfo
223 for _, line := range strings.Split(string(out), "\n") {
224 parts := strings.SplitN(line, "\t", 4)
225 if len(parts) == 4 {
226 commits = append(commits, newCommitInfo(parts[0], parts[1], parts[2], parts[3]))
227 }
228 }
229 return commits
230}
231
232func newCommitInfo(hash, author, unixTs, subject string) CommitInfo {
233 if len(hash) > 8 {
234 hash = hash[:8]
235 }
236 var ts int64
237 if n, err := strconv.ParseInt(strings.TrimSpace(unixTs), 10, 64); err == nil {
238 ts = n
239 }
240 return CommitInfo{
241 Hash: hash,
242 Author: author,
243 Time: time.Unix(ts, 0),
244 Subject: subject,
245 }
246}
247
248// LastCommitPerPath одним вызовом git log определяет последний коммит,
249// затрагивающий каждый из путей (аналог колонки "last commit" в GitHub).
250// Пути ожидаются относительно корня репозитория.
251func (s *GitService) LastCommitPerPath(name, branch string, paths []string) map[string]CommitInfo {
252 if branch == "" || strings.HasPrefix(branch, "-") || len(paths) == 0 {
253 return nil
254 }
255 wanted := make(map[string]bool, len(paths))
256 for _, p := range paths {
257 if p != "" {
258 wanted[p] = true
259 }
260 }
261 if len(wanted) == 0 {
262 return nil
263 }
264
265 out, err := runGit(s.RepoPath(name), 30*time.Second, "log",
266 "-n200", "--name-only", "--pretty=format:%H%x09%an%x09%ct%x09%s", branch)
267 if err != nil {
268 return nil
269 }
270
271 res := make(map[string]CommitInfo, len(wanted))
272 var cur CommitInfo
273 hasCur := false
274 for _, line := range strings.Split(string(out), "\n") {
275 if line == "" {
276 continue
277 }
278 // строка коммита содержит табы, строка пути — нет
279 if parts := strings.SplitN(line, "\t", 4); len(parts) == 4 {
280 cur = newCommitInfo(parts[0], parts[1], parts[2], parts[3])
281 hasCur = true
282 continue
283 }
284 if !hasCur {
285 continue
286 }
287 if wanted[line] {
288 if _, ok := res[line]; !ok {
289 res[line] = cur
290 }
291 continue
292 }
293 // каталоги в выводе git log не встречаются — ищем по префиксу «path/»
294 for p := range wanted {
295 if _, done := res[p]; done {
296 continue
297 }
298 if strings.HasPrefix(line, p+"/") {
299 res[p] = cur
300 }
301 }
302 }
303 return res
304}
305
306// CountCommits считает коммиты ветки.
307func (s *GitService) CountCommits(name, branch string) int {
308 if branch == "" || strings.HasPrefix(branch, "-") {
309 return 0
310 }
311 out, err := runGit(s.RepoPath(name), 30*time.Second, "rev-list", "--count", branch)
312 if err != nil {
313 return 0
314 }
315 n, err := strconv.Atoi(strings.TrimSpace(string(out)))
316 if err != nil {
317 return 0
318 }
319 return n
320}
321
322// ListTags показывает теги репозитория (новые сверху).
323func (s *GitService) ListTags(name string) []string {
324 out, err := runGit(s.RepoPath(name), 15*time.Second, "for-each-ref",
325 "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/")
326 if err != nil {
327 return nil
328 }
329 var tags []string
330 for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
331 if line != "" {
332 tags = append(tags, line)
333 }
334 }
335 return tags
336}
337
338// FileEntry — элемент дерева файлов.
339type FileEntry struct {
340 Name string
341 Path string
342 Type string // "blob" | "tree"
343 Size int64
344 Commit *CommitInfo // последний коммит, затрагивающий путь
345}
346
347// ListTree показывает содержимое директории в HEAD ветки.
348func (s *GitService) ListTree(name, branch, dir string) ([]FileEntry, error) {
349 if branch == "" || strings.HasPrefix(branch, "-") {
350 return nil, fmt.Errorf("invalid branch")
351 }
352 treeish := branch
353 if dir != "" && dir != "." && dir != "/" {
354 treeish = branch + ":" + strings.TrimPrefix(dir, "/")
355 }
356
357 out, err := runGit(s.RepoPath(name), 30*time.Second, "ls-tree", "-l", treeish)
358 if err != nil {
359 return nil, fmt.Errorf("path not found in %s", branch)
360 }
361 var entries []FileEntry
362 for _, line := range strings.Split(string(out), "\n") {
363 if line == "" {
364 continue
365 }
366 // формат: <mode> <type> <hash> <size>\t<name>
367 sp := strings.SplitN(line, "\t", 2)
368 if len(sp) != 2 {
369 continue
370 }
371 meta := strings.Fields(sp[0])
372 if len(meta) < 3 {
373 continue
374 }
375 e := FileEntry{Name: sp[1], Type: meta[1]}
376 if len(meta) >= 4 && meta[3] != "-" {
377 fmt.Sscanf(meta[3], "%d", &e.Size)
378 }
379 if dir == "" || dir == "." || dir == "/" {
380 e.Path = e.Name
381 } else {
382 e.Path = strings.TrimSuffix(strings.TrimPrefix(dir, "/"), "/") + "/" + e.Name
383 }
384 entries = append(entries, e)
385 }
386 // папки первыми, внутри групп — по имени (ls-tree уже отдаёт сортировку по имени)
387 sort.SliceStable(entries, func(i, j int) bool {
388 if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
389 return entries[i].Type == "tree"
390 }
391 return entries[i].Name < entries[j].Name
392 })
393 return entries, nil
394}
395
396// ReadFile читает содержимое файла из ветки.
397func (s *GitService) ReadFile(name, branch, path string) ([]byte, error) {
398 if branch == "" || strings.HasPrefix(branch, "-") || strings.HasPrefix(path, "-") {
399 return nil, fmt.Errorf("invalid path")
400 }
401 out, err := runGit(s.RepoPath(name), 30*time.Second, "show", branch+":"+path)
402 if err != nil {
403 return nil, fmt.Errorf("file not found: %s", strings.TrimSpace(string(out)))
404 }
405 return out, nil
406}
407
408// IsEmpty проверяет, есть ли в репозитории хотя бы один коммит.
409func (s *GitService) IsEmpty(name string) bool {
410 _, err := runGit(s.RepoPath(name), 15*time.Second, "rev-parse", "--verify", "HEAD")
411 return err != nil
412}
413
414// ============================================================
415// Git Smart HTTP через штатный git http-backend
416// ============================================================
417
418// maxGitBodySize — жёсткий лимит на размер тела git-запросов (push/pull pack).
419const maxGitBodySize = 512 << 20 // 512 MiB
420
421// ServeHTTP проксирует запрос в git http-backend (CGI).
422// Авторизация выполняется вызывающим кодом до вызова этого метода.
423func (s *GitService) ServeHTTP(w http.ResponseWriter, r *http.Request, repoName string) {
424 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
425 defer cancel()
426
427 // Защита от OOM: ограничиваем размер тела запроса.
428 r.Body = http.MaxBytesReader(w, r.Body, maxGitBodySize)
429
430 // http-backend требует CONTENT_LENGTH; для chunked-запросов буферизуем тело.
431 var body io.Reader = r.Body
432 contentLength := r.ContentLength
433 if contentLength < 0 {
434 data, err := io.ReadAll(r.Body)
435 if err != nil {
436 http.Error(w, "request body too large or read error", http.StatusRequestEntityTooLarge)
437 return
438 }
439 body = bytes.NewReader(data)
440 contentLength = int64(len(data))
441 }
442
443 cmd := exec.CommandContext(ctx, "git", "http-backend")
444 cmd.Env = gitBackendEnv(r, s.ReposDir, repoName, contentLength)
445 cmd.Stdin = body
446
447 stdout, err := cmd.StdoutPipe()
448 if err != nil {
449 http.Error(w, "git backend error", http.StatusInternalServerError)
450 return
451 }
452 var stderr bytes.Buffer
453 cmd.Stderr = &stderr
454
455 if err := cmd.Start(); err != nil {
456 log.Printf("git http-backend start: %v", err)
457 http.Error(w, "git backend unavailable", http.StatusInternalServerError)
458 return
459 }
460
461 br := bufio.NewReader(stdout)
462 status := http.StatusOK
463 headers := make(map[string]string)
464
465 for {
466 line, err := br.ReadString('\n')
467 if err != nil {
468 log.Printf("git http-backend headers: %v (%s)", err, stderr.String())
469 http.Error(w, "git backend error", http.StatusInternalServerError)
470 _ = cmd.Wait()
471 return
472 }
473 line = strings.TrimRight(line, "\r\n")
474 if line == "" {
475 break
476 }
477 key, value, ok := strings.Cut(line, ":")
478 if !ok {
479 continue
480 }
481 key = strings.TrimSpace(key)
482 value = strings.TrimSpace(value)
483 if strings.EqualFold(key, "Status") {
484 fields := strings.Fields(value)
485 if len(fields) > 0 {
486 if code, err := strconv.Atoi(fields[0]); err == nil {
487 status = code
488 }
489 }
490 continue
491 }
492 headers[key] = value
493 }
494
495 for k, v := range headers {
496 w.Header().Set(k, v)
497 }
498 w.WriteHeader(status)
499 if _, err := io.Copy(w, br); err != nil {
500 log.Printf("git http-backend copy: %v", err)
501 }
502 if err := cmd.Wait(); err != nil && ctx.Err() == nil {
503 log.Printf("git http-backend: %v (%s)", err, stderr.String())
504 }
505}
506
507// gitBackendEnv собирает CGI-окружение для git http-backend.
508// Используем whitelist безопасных переменных вместо копирования всего os.Environ().
509func gitBackendEnv(r *http.Request, reposDir, repoName string, contentLength int64) []string {
510 env := []string{
511 "GIT_PROJECT_ROOT=" + reposDir,
512 "GIT_HTTP_EXPORT_ALL=1",
513 "REQUEST_METHOD=" + r.Method,
514 "PATH_INFO=" + gitPathInfo(r.URL.Path, repoName),
515 "QUERY_STRING=" + r.URL.RawQuery,
516 "CONTENT_LENGTH=" + strconv.FormatInt(max64(contentLength, 0), 10),
517 "CONTENT_TYPE=" + r.Header.Get("Content-Type"),
518 }
519 if v := r.Header.Get("Git-Protocol"); v != "" {
520 env = append(env, "HTTP_GIT_PROTOCOL="+v)
521 }
522 if v := r.Header.Get("Content-Encoding"); v != "" {
523 env = append(env, "HTTP_CONTENT_ENCODING="+v)
524 }
525
526 // безопасные переменные окружения
527 for _, e := range os.Environ() {
528 key := strings.ToUpper(strings.SplitN(e, "=", 2)[0])
529 switch key {
530 case "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "USER", "LOGNAME":
531 env = append(env, e)
532 }
533 }
534 return env
535}
536
537func max64(a, b int64) int64 {
538 if a > b {
539 return a
540 }
541 return b
542}
543
544// gitPathInfo приводит URL к пути внутри GIT_PROJECT_ROOT:
545// /owner/repo(/…).git/info/refs -> /owner/repo.git/info/refs.
546func gitPathInfo(urlPath, repoName string) string {
547 suffix := ""
548 if rest := strings.TrimPrefix(urlPath, "/"+repoName); rest != urlPath {
549 suffix = strings.TrimPrefix(strings.TrimSuffix(rest, ".git"), ".git")
550 }
551 return "/" + repoName + ".git" + suffix
552}