rin
Публичный Open-source lightweight git hosting.
git.go · 1040 строк · 27.4 KB
Raw
1 package git
2
3 import (
4 "bytes"
5 "context"
6 "encoding/binary"
7 "errors"
8 "fmt"
9 "os"
10 "os/exec"
11 "path"
12 "path/filepath"
13 "sort"
14 "strconv"
15 "strings"
16 "time"
17 "unicode"
18 "unicode/utf16"
19 "unicode/utf8"
20 )
21
22 var (
23 ErrInvalidRef = errors.New("invalid git reference")
24 ErrInvalidPath = errors.New("invalid repository file path")
25 ErrNotFound = errors.New("git object not found")
26 ErrEmptyRepo = errors.New("repository is empty")
27 )
28
29 type TreeEntry struct {
30 Mode string
31 Type string // "tree" or "blob"
32 Hash string
33 Size int64
34 Name string
35 Path string
36 CommitHash string
37 CommitMsg string
38 CommitTime time.Time
39 }
40
41 type Blob struct {
42 Name string
43 Path string
44 Size int64
45 Data []byte
46 IsBinary bool
47 LineCount int
48 }
49
50 type Commit struct {
51 Hash string
52 ShortHash string
53 AuthorName string
54 AuthorEmail string
55 AuthoredAt time.Time
56 Subject string
57 Body string
58 Parents []string
59 }
60
61 type DiffLine struct {
62 Type string // "add", "del", "ctx", "hunk"
63 Content string
64 OldNum int
65 NewNum int
66 }
67
68 type FileDiff struct {
69 OldPath string
70 NewPath string
71 Status string // "added", "deleted", "modified", "renamed"
72 Additions int
73 Deletions int
74 IsBinary bool
75 Lines []DiffLine
76 }
77
78 type CommitDetail struct {
79 Commit Commit
80 FilesChanged int
81 TotalAdditions int
82 TotalDeletions int
83 Files []FileDiff
84 }
85
86 func ValidateRef(ref string) error {
87 ref = strings.TrimSpace(ref)
88 if ref == "" || len(ref) > 255 {
89 return ErrInvalidRef
90 }
91 if strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") ||
92 strings.ContainsAny(ref, " \t\n\r~^:?*[\\`\x00") ||
93 strings.HasSuffix(ref, ".lock") || strings.HasSuffix(ref, "/") {
94 return ErrInvalidRef
95 }
96 return nil
97 }
98
99 func CleanSubPath(p string) (string, error) {
100 p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/"))
101 p = strings.TrimPrefix(p, "/")
102 p = strings.TrimSuffix(p, "/")
103 if p == "" || p == "." {
104 return "", nil
105 }
106 if strings.Contains(p, "\x00") || strings.HasPrefix(p, "-") {
107 return "", ErrInvalidPath
108 }
109 cleaned := path.Clean(p)
110 if cleaned == "." {
111 return "", nil
112 }
113 if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, "/../") {
114 return "", ErrInvalidPath
115 }
116 return cleaned, nil
117 }
118
119 func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) {
120 cctx, cancel := context.WithTimeout(ctx, timeout)
121 defer cancel()
122
123 fullArgs := make([]string, 0, len(args)+2)
124 if repoPath != "" {
125 fullArgs = append(fullArgs, "-C", repoPath)
126 }
127 fullArgs = append(fullArgs, args...)
128
129 cmd := exec.CommandContext(cctx, "git", fullArgs...)
130 cmd.Env = append(os.Environ(),
131 "GIT_TERMINAL_PROMPT=0",
132 "LC_ALL=C",
133 )
134 var stdout, stderr bytes.Buffer
135 cmd.Stdout = &stdout
136 cmd.Stderr = &stderr
137
138 if err := cmd.Run(); err != nil {
139 return nil, fmt.Errorf("git %v: %w (%s)", args, err, strings.TrimSpace(stderr.String()))
140 }
141 return stdout.Bytes(), nil
142 }
143
144 func InitBare(ctx context.Context, repoPath, defaultBranch string) error {
145 if defaultBranch == "" {
146 defaultBranch = "main"
147 }
148 if err := ValidateRef(defaultBranch); err != nil {
149 defaultBranch = "main"
150 }
151 if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil {
152 return fmt.Errorf("mkdir repo parent: %w", err)
153 }
154
155 _, err := runGit(ctx, "", 10*time.Second, "init", "--bare", "--initial-branch="+defaultBranch, repoPath)
156 if err != nil {
157 if _, err2 := runGit(ctx, "", 10*time.Second, "init", "--bare", repoPath); err2 != nil {
158 return fmt.Errorf("init bare repo: %w", err2)
159 }
160 }
161 _, _ = runGit(ctx, repoPath, 5*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch)
162 _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.receivepack", "true")
163 _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.uploadpack", "true")
164 return nil
165 }
166
167 func EnsureDefaultBranch(ctx context.Context, repoPath, preferred string) (string, bool) {
168 if preferred == "" {
169 preferred = "main"
170 }
171 branches, err := ListBranches(ctx, repoPath)
172 if err != nil || len(branches) == 0 {
173 return preferred, true
174 }
175
176 out, err := runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "--short", "HEAD")
177 headBranch := strings.TrimSpace(string(out))
178 if err == nil && headBranch != "" {
179 for _, b := range branches {
180 if b == headBranch {
181 return headBranch, false
182 }
183 }
184 }
185
186 chosen := branches[0]
187 for _, b := range branches {
188 if b == preferred {
189 chosen = preferred
190 break
191 }
192 }
193 _, _ = runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+chosen)
194 return chosen, false
195 }
196
197 func ListBranches(ctx context.Context, repoPath string) ([]string, error) {
198 out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
199 if err != nil {
200 return nil, err
201 }
202 return splitNonEmptyLines(string(out)), nil
203 }
204
205 func ListTags(ctx context.Context, repoPath string) ([]string, error) {
206 out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/")
207 if err != nil {
208 return nil, err
209 }
210 return splitNonEmptyLines(string(out)), nil
211 }
212
213 func ListTree(ctx context.Context, repoPath, ref, subPath string) ([]TreeEntry, error) {
214 if err := ValidateRef(ref); err != nil {
215 return nil, err
216 }
217 cleanPath, err := CleanSubPath(subPath)
218 if err != nil {
219 return nil, err
220 }
221
222 target := ref
223 if cleanPath != "" {
224 target = ref + ":" + cleanPath
225 }
226
227 out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-l", target)
228 if err != nil {
229 if strings.Contains(err.Error(), "Not a valid object name") || strings.Contains(err.Error(), "bad file") {
230 return nil, ErrNotFound
231 }
232 return nil, err
233 }
234
235 lines := splitNonEmptyLines(string(out))
236 entries := make([]TreeEntry, 0, len(lines))
237 for _, line := range lines {
238 // Format: <mode> <type> <object> <size>\t<file>
239 tabIdx := strings.IndexByte(line, '\t')
240 if tabIdx == -1 {
241 continue
242 }
243 meta := strings.Fields(line[:tabIdx])
244 name := line[tabIdx+1:]
245 if len(meta) < 4 {
246 continue
247 }
248 var size int64
249 if meta[3] != "-" {
250 size, _ = strconv.ParseInt(meta[3], 10, 64)
251 }
252 entryPath := name
253 if cleanPath != "" {
254 entryPath = cleanPath + "/" + name
255 }
256 entries = append(entries, TreeEntry{
257 Mode: meta[0],
258 Type: meta[1],
259 Hash: meta[2],
260 Size: size,
261 Name: name,
262 Path: entryPath,
263 })
264 }
265
266 sort.Slice(entries, func(i, j int) bool {
267 if entries[i].Type != entries[j].Type {
268 return entries[i].Type == "tree"
269 }
270 return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
271 })
272
273 // Populate last commit summary for up to 50 entries
274 if len(entries) <= 50 {
275 for i := range entries {
276 if c, err := GetLatestCommit(ctx, repoPath, ref, entries[i].Path); err == nil && c != nil {
277 entries[i].CommitHash = c.ShortHash
278 entries[i].CommitMsg = c.Subject
279 entries[i].CommitTime = c.AuthoredAt
280 }
281 }
282 }
283
284 return entries, nil
285 }
286
287 func ListAllFiles(ctx context.Context, repoPath, ref string) ([]string, error) {
288 if err := ValidateRef(ref); err != nil {
289 return nil, err
290 }
291 out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-r", "--name-only", ref)
292 if err != nil {
293 return nil, err
294 }
295 files := splitNonEmptyLines(string(out))
296 if len(files) > 1000 {
297 files = files[:1000]
298 }
299 return files, nil
300 }
301
302 func ReadBlob(ctx context.Context, repoPath, ref, filePath string) (*Blob, error) {
303 if err := ValidateRef(ref); err != nil {
304 return nil, err
305 }
306 cleanPath, err := CleanSubPath(filePath)
307 if err != nil || cleanPath == "" {
308 return nil, ErrInvalidPath
309 }
310
311 spec := ref + ":" + cleanPath
312 typeOut, err := runGit(ctx, repoPath, 5*time.Second, "cat-file", "-t", spec)
313 if err != nil {
314 return nil, ErrNotFound
315 }
316 if strings.TrimSpace(string(typeOut)) != "blob" {
317 return nil, ErrNotFound
318 }
319
320 data, err := runGit(ctx, repoPath, 10*time.Second, "cat-file", "-p", spec)
321 if err != nil {
322 return nil, ErrNotFound
323 }
324
325 data = normalizeTextEncoding(data)
326
327 checkLen := len(data)
328 if checkLen > 8000 {
329 checkLen = 8000
330 }
331 isBinary := bytes.IndexByte(data[:checkLen], 0) != -1 || !utf8.Valid(data[:checkLen])
332
333 lineCount := 0
334 if !isBinary && len(data) > 0 {
335 lineCount = bytes.Count(data, []byte{'\n'})
336 if data[len(data)-1] != '\n' {
337 lineCount++
338 }
339 }
340
341 return &Blob{
342 Name: path.Base(cleanPath),
343 Path: cleanPath,
344 Size: int64(len(data)),
345 Data: data,
346 IsBinary: isBinary,
347 LineCount: lineCount,
348 }, nil
349 }
350
351 func FindReadme(ctx context.Context, repoPath, ref, subPath string) (*Blob, error) {
352 candidates := []string{"README.md", "readme.md", "Readme.md", "README.markdown", "README.txt", "README"}
353 cleanSub, err := CleanSubPath(subPath)
354 if err != nil {
355 return nil, err
356 }
357 for _, name := range candidates {
358 p := name
359 if cleanSub != "" {
360 p = cleanSub + "/" + name
361 }
362 if blob, err := ReadBlob(ctx, repoPath, ref, p); err == nil && !blob.IsBinary {
363 return blob, nil
364 }
365 }
366 return nil, ErrNotFound
367 }
368
369 // normalizeTextEncoding converts BOM-marked text (UTF-8/UTF-16) to UTF-8 so
370 // files written by Windows tooling (e.g. PowerShell) render as text instead
371 // of being misdetected as binary.
372 func normalizeTextEncoding(data []byte) []byte {
373 if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
374 return data[3:]
375 }
376 if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE {
377 if len(data) >= 4 && data[2] == 0x00 && data[3] == 0x00 {
378 return data // UTF-32LE is left untouched
379 }
380 return utf16ToUTF8(data[2:], binary.LittleEndian)
381 }
382 if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF {
383 return utf16ToUTF8(data[2:], binary.BigEndian)
384 }
385 return data
386 }
387
388 func utf16ToUTF8(data []byte, order binary.ByteOrder) []byte {
389 runes := make([]rune, 0, len(data)/2)
390 for i := 0; i+1 < len(data); i += 2 {
391 unit := order.Uint16(data[i : i+2])
392 if unit == 0 {
393 break
394 }
395 if utf16.IsSurrogate(rune(unit)) && i+3 < len(data) {
396 next := order.Uint16(data[i+2 : i+4])
397 if combined := utf16.DecodeRune(rune(unit), rune(next)); combined != unicode.ReplacementChar {
398 runes = append(runes, combined)
399 i += 2
400 continue
401 }
402 }
403 runes = append(runes, rune(unit))
404 }
405 return []byte(string(runes))
406 }
407
408 const commitFormat = "%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1f%P%x1e"
409
410 func GetLatestCommit(ctx context.Context, repoPath, ref, subPath string) (*Commit, error) {
411 if err := ValidateRef(ref); err != nil {
412 return nil, err
413 }
414 args := []string{"log", "-n", "1", "--format=" + commitFormat, ref}
415 if subPath != "" {
416 cleanPath, err := CleanSubPath(subPath)
417 if err != nil {
418 return nil, err
419 }
420 if cleanPath != "" {
421 args = append(args, "--", cleanPath)
422 }
423 }
424 out, err := runGit(ctx, repoPath, 5*time.Second, args...)
425 if err != nil {
426 return nil, err
427 }
428 commits := parseCommits(string(out))
429 if len(commits) == 0 {
430 return nil, ErrNotFound
431 }
432 return &commits[0], nil
433 }
434
435 func ListCommits(ctx context.Context, repoPath, ref string, limit, offset int) ([]Commit, error) {
436 if err := ValidateRef(ref); err != nil {
437 return nil, err
438 }
439 if limit <= 0 || limit > 100 {
440 limit = 30
441 }
442 if offset < 0 {
443 offset = 0
444 }
445 out, err := runGit(ctx, repoPath, 10*time.Second,
446 "log",
447 "--max-count="+strconv.Itoa(limit),
448 "--skip="+strconv.Itoa(offset),
449 "--format="+commitFormat,
450 ref,
451 "--",
452 )
453 if err != nil {
454 return nil, err
455 }
456 return parseCommits(string(out)), nil
457 }
458
459 func GetCommitDiff(ctx context.Context, repoPath, commitHash string) (*CommitDetail, error) {
460 if err := ValidateRef(commitHash); err != nil {
461 return nil, err
462 }
463
464 metaOut, err := runGit(ctx, repoPath, 5*time.Second, "show", "-s", "--format="+commitFormat, commitHash)
465 if err != nil {
466 return nil, ErrNotFound
467 }
468 commits := parseCommits(string(metaOut))
469 if len(commits) == 0 {
470 return nil, ErrNotFound
471 }
472 commit := commits[0]
473
474 diffOut, err := runGit(ctx, repoPath, 15*time.Second, "show", "--patch", "--find-renames", "--format=", commitHash)
475 if err != nil {
476 return nil, err
477 }
478
479 files, adds, dels := parseUnifiedDiff(string(diffOut))
480 return &CommitDetail{
481 Commit: commit,
482 FilesChanged: len(files),
483 TotalAdditions: adds,
484 TotalDeletions: dels,
485 Files: files,
486 }, nil
487 }
488
489 func parseCommits(raw string) []Commit {
490 records := strings.Split(raw, "\x1e")
491 var commits []Commit
492 for _, rec := range records {
493 rec = strings.TrimSpace(rec)
494 if rec == "" {
495 continue
496 }
497 fields := strings.Split(rec, "\x1f")
498 if len(fields) < 7 {
499 continue
500 }
501 ts, _ := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64)
502 var parents []string
503 if len(fields) >= 8 && strings.TrimSpace(fields[7]) != "" {
504 parents = strings.Fields(fields[7])
505 }
506 commits = append(commits, Commit{
507 Hash: strings.TrimSpace(fields[0]),
508 ShortHash: strings.TrimSpace(fields[1]),
509 AuthorName: strings.TrimSpace(fields[2]),
510 AuthorEmail: strings.TrimSpace(fields[3]),
511 AuthoredAt: time.Unix(ts, 0).UTC(),
512 Subject: strings.TrimSpace(fields[5]),
513 Body: strings.TrimSpace(fields[6]),
514 Parents: parents,
515 })
516 }
517 return commits
518 }
519
520 func parseUnifiedDiff(raw string) ([]FileDiff, int, int) {
521 var files []FileDiff
522 var current *FileDiff
523 totalAdds := 0
524 totalDels := 0
525
526 oldLine := 0
527 newLine := 0
528
529 lines := strings.Split(raw, "\n")
530 for _, line := range lines {
531 line = strings.TrimSuffix(line, "\r")
532 if strings.HasPrefix(line, "diff --git ") {
533 if current != nil {
534 files = append(files, *current)
535 }
536 oldP, newP := parseDiffGitHeader(line)
537 current = &FileDiff{
538 OldPath: oldP,
539 NewPath: newP,
540 Status: "modified",
541 }
542 continue
543 }
544 if current == nil {
545 continue
546 }
547
548 if strings.HasPrefix(line, "new file mode ") {
549 current.Status = "added"
550 continue
551 }
552 if strings.HasPrefix(line, "deleted file mode ") {
553 current.Status = "deleted"
554 continue
555 }
556 if strings.HasPrefix(line, "rename from ") {
557 current.Status = "renamed"
558 current.OldPath = strings.TrimPrefix(line, "rename from ")
559 continue
560 }
561 if strings.HasPrefix(line, "rename to ") {
562 current.Status = "renamed"
563 current.NewPath = strings.TrimPrefix(line, "rename to ")
564 continue
565 }
566 if strings.HasPrefix(line, "Binary files ") {
567 current.IsBinary = true
568 continue
569 }
570 if strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
571 continue
572 }
573
574 if strings.HasPrefix(line, "@@ ") {
575 oldLine, newLine = parseHunkHeader(line)
576 current.Lines = append(current.Lines, DiffLine{
577 Type: "hunk",
578 Content: line,
579 })
580 continue
581 }
582
583 if len(current.Lines) == 0 {
584 continue
585 }
586
587 if strings.HasPrefix(line, "+") {
588 current.Additions++
589 totalAdds++
590 current.Lines = append(current.Lines, DiffLine{
591 Type: "add",
592 Content: line[1:],
593 NewNum: newLine,
594 })
595 newLine++
596 } else if strings.HasPrefix(line, "-") {
597 current.Deletions++
598 totalDels++
599 current.Lines = append(current.Lines, DiffLine{
600 Type: "del",
601 Content: line[1:],
602 OldNum: oldLine,
603 })
604 oldLine++
605 } else if strings.HasPrefix(line, " ") {
606 current.Lines = append(current.Lines, DiffLine{
607 Type: "ctx",
608 Content: line[1:],
609 OldNum: oldLine,
610 NewNum: newLine,
611 })
612 oldLine++
613 newLine++
614 }
615 }
616
617 if current != nil {
618 files = append(files, *current)
619 }
620 return files, totalAdds, totalDels
621 }
622
623 func parseDiffGitHeader(line string) (string, string) {
624 // diff --git a/foo b/foo
625 rest := strings.TrimPrefix(line, "diff --git ")
626 parts := strings.SplitN(rest, " b/", 2)
627 if len(parts) == 2 {
628 return strings.TrimPrefix(parts[0], "a/"), parts[1]
629 }
630 return rest, rest
631 }
632
633 func parseHunkHeader(line string) (int, int) {
634 // @@ -oldStart,oldCount +newStart,newCount @@
635 oldStart, newStart := 1, 1
636 endIdx := strings.Index(line[3:], "@@")
637 if endIdx == -1 {
638 return oldStart, newStart
639 }
640 header := strings.TrimSpace(line[3 : 3+endIdx])
641 fields := strings.Fields(header)
642 for _, f := range fields {
643 if strings.HasPrefix(f, "-") {
644 nums := strings.SplitN(f[1:], ",", 2)
645 if v, err := strconv.Atoi(nums[0]); err == nil {
646 oldStart = v
647 }
648 } else if strings.HasPrefix(f, "+") {
649 nums := strings.SplitN(f[1:], ",", 2)
650 if v, err := strconv.Atoi(nums[0]); err == nil {
651 newStart = v
652 }
653 }
654 }
655 return oldStart, newStart
656 }
657
658 func splitNonEmptyLines(s string) []string {
659 raw := strings.Split(s, "\n")
660 out := make([]string, 0, len(raw))
661 for _, l := range raw {
662 l = strings.TrimSpace(l)
663 if l != "" {
664 out = append(out, l)
665 }
666 }
667 return out
668 }
669
670 type BranchInfo struct {
671 Name string
672 Hash string
673 ShortHash string
674 Subject string
675 AuthorName string
676 UpdatedAt time.Time
677 IsDefault bool
678 }
679
680 type TagInfo struct {
681 Name string
682 Hash string
683 ShortHash string
684 Subject string
685 CreatedAt time.Time
686 }
687
688 func ListBranchDetails(ctx context.Context, repoPath, defaultBranch string) ([]BranchInfo, error) {
689 out, err := runGit(ctx, repoPath, 5*time.Second,
690 "for-each-ref",
691 "--sort=-committerdate",
692 "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(authorname)%00%(committerdate:unix)",
693 "refs/heads/",
694 )
695 if err != nil {
696 return nil, err
697 }
698 lines := splitNonEmptyLines(string(out))
699 res := make([]BranchInfo, 0, len(lines))
700 for _, l := range lines {
701 parts := strings.Split(l, "\x00")
702 if len(parts) < 6 {
703 continue
704 }
705 ts, _ := strconv.ParseInt(parts[5], 10, 64)
706 var tm time.Time
707 if ts > 0 {
708 tm = time.Unix(ts, 0).UTC()
709 }
710 res = append(res, BranchInfo{
711 Name: parts[0],
712 Hash: parts[1],
713 ShortHash: parts[2],
714 Subject: parts[3],
715 AuthorName: parts[4],
716 UpdatedAt: tm,
717 IsDefault: parts[0] == defaultBranch,
718 })
719 }
720 sort.SliceStable(res, func(i, j int) bool {
721 if res[i].IsDefault != res[j].IsDefault {
722 return res[i].IsDefault
723 }
724 return res[i].UpdatedAt.After(res[j].UpdatedAt)
725 })
726 return res, nil
727 }
728
729 func ListTagDetails(ctx context.Context, repoPath string) ([]TagInfo, error) {
730 out, err := runGit(ctx, repoPath, 5*time.Second,
731 "for-each-ref",
732 "--sort=-creatordate",
733 "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(creatordate:unix)",
734 "refs/tags/",
735 )
736 if err != nil {
737 return nil, err
738 }
739 lines := splitNonEmptyLines(string(out))
740 res := make([]TagInfo, 0, len(lines))
741 for _, l := range lines {
742 parts := strings.Split(l, "\x00")
743 if len(parts) < 5 {
744 continue
745 }
746 ts, _ := strconv.ParseInt(parts[4], 10, 64)
747 var tm time.Time
748 if ts > 0 {
749 tm = time.Unix(ts, 0).UTC()
750 }
751 res = append(res, TagInfo{
752 Name: parts[0],
753 Hash: parts[1],
754 ShortHash: parts[2],
755 Subject: parts[3],
756 CreatedAt: tm,
757 })
758 }
759 return res, nil
760 }
761
762 func CreateBranch(ctx context.Context, repoPath, branchName, fromRef string) error {
763 branchName = strings.TrimSpace(branchName)
764 fromRef = strings.TrimSpace(fromRef)
765 if err := ValidateRef(branchName); err != nil {
766 return err
767 }
768 if err := ValidateRef(fromRef); err != nil {
769 return err
770 }
771 _, err := runGit(ctx, repoPath, 5*time.Second, "branch", branchName, fromRef)
772 return err
773 }
774
775 func DeleteBranch(ctx context.Context, repoPath, branchName string) error {
776 branchName = strings.TrimSpace(branchName)
777 if err := ValidateRef(branchName); err != nil {
778 return err
779 }
780 _, err := runGit(ctx, repoPath, 5*time.Second, "branch", "-D", branchName)
781 return err
782 }
783
784 func CreateTag(ctx context.Context, repoPath, tagName, fromRef, message string) error {
785 tagName = strings.TrimSpace(tagName)
786 fromRef = strings.TrimSpace(fromRef)
787 message = strings.TrimSpace(message)
788 if err := ValidateRef(tagName); err != nil {
789 return err
790 }
791 if err := ValidateRef(fromRef); err != nil {
792 return err
793 }
794 if message != "" {
795 _, err := runGit(ctx, repoPath, 5*time.Second,
796 "-c", "user.name=Polar",
797 "-c", "user.email=polar@localhost",
798 "tag", "-a", tagName, "-m", message, fromRef,
799 )
800 return err
801 }
802 _, err := runGit(ctx, repoPath, 5*time.Second, "tag", tagName, fromRef)
803 return err
804 }
805
806 func DeleteTag(ctx context.Context, repoPath, tagName string) error {
807 tagName = strings.TrimSpace(tagName)
808 if err := ValidateRef(tagName); err != nil {
809 return err
810 }
811 _, err := runGit(ctx, repoPath, 5*time.Second, "tag", "-d", tagName)
812 return err
813 }
814
815 func ArchiveZip(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) {
816 ref = strings.TrimSpace(ref)
817 if err := ValidateRef(ref); err != nil {
818 return nil, err
819 }
820 prefix = strings.Trim(prefix, "/")
821 if prefix == "" {
822 prefix = "repo"
823 }
824 return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=zip", "--prefix="+prefix+"/", ref)
825 }
826
827 func ArchiveTarGz(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) {
828 ref = strings.TrimSpace(ref)
829 if err := ValidateRef(ref); err != nil {
830 return nil, err
831 }
832 prefix = strings.Trim(prefix, "/")
833 if prefix == "" {
834 prefix = "repo"
835 }
836 return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref)
837 }
838
839 func CompareBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (*CommitDetail, []Commit, error) {
840 if err := ValidateRef(baseBranch); err != nil {
841 return nil, nil, err
842 }
843 if err := ValidateRef(headBranch); err != nil {
844 return nil, nil, err
845 }
846 out, err := runGit(ctx, repoPath, 10*time.Second, "log", "--format="+commitFormat, baseBranch+".."+headBranch, "--")
847 if err != nil {
848 return nil, nil, fmt.Errorf("git log base..head: %w", err)
849 }
850 commits := parseCommits(string(out))
851
852 diffOut, err := runGit(ctx, repoPath, 15*time.Second, "diff", "--patch", "--find-renames", baseBranch+"..."+headBranch)
853 if err != nil {
854 return nil, nil, fmt.Errorf("git diff base...head: %w", err)
855 }
856 files, adds, dels := parseUnifiedDiff(string(diffOut))
857
858 detail := &CommitDetail{
859 FilesChanged: len(files),
860 TotalAdditions: adds,
861 TotalDeletions: dels,
862 Files: files,
863 }
864 return detail, commits, nil
865 }
866
867 func CanMergeBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (canMerge bool, isFastForward bool, err error) {
868 if err := ValidateRef(baseBranch); err != nil {
869 return false, false, err
870 }
871 if err := ValidateRef(headBranch); err != nil {
872 return false, false, err
873 }
874
875 _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", headBranch, baseBranch)
876 if err == nil {
877 return true, true, nil
878 }
879
880 _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", baseBranch, headBranch)
881 if err == nil {
882 return true, true, nil
883 }
884
885 baseCommit, err := runGit(ctx, repoPath, 5*time.Second, "merge-base", baseBranch, headBranch)
886 if err != nil {
887 return false, false, fmt.Errorf("find merge base: %w", err)
888 }
889 baseHash := strings.TrimSpace(string(baseCommit))
890 if baseHash == "" {
891 return false, false, errors.New("no common ancestor")
892 }
893
894 treeOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", baseHash, baseBranch, headBranch)
895 if err != nil {
896 return false, false, nil
897 }
898 if strings.Contains(string(treeOut), "+<<<<<<<") {
899 return false, false, nil
900 }
901 return true, false, nil
902 }
903
904 func MergeBranches(ctx context.Context, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail string) (string, error) {
905 if err := ValidateRef(baseBranch); err != nil {
906 return "", err
907 }
908 if err := ValidateRef(headBranch); err != nil {
909 return "", err
910 }
911 return mergeRefs(ctx, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail)
912 }
913
914 // MergeFromPath fetches headBranch from another local repository (a fork) and merges
915 // it into baseBranch of repoPath. Used to merge cross-repository pull requests.
916 func MergeFromPath(ctx context.Context, repoPath, forkPath, baseBranch, headBranch, tempRef, commitMsg, authorName, authorEmail string) (string, error) {
917 if err := ValidateRef(baseBranch); err != nil {
918 return "", err
919 }
920 if err := ValidateRef(headBranch); err != nil {
921 return "", err
922 }
923 if tempRef == "" {
924 tempRef = "refs/polar/tmp-merge"
925 }
926 if err := FetchToTemp(ctx, repoPath, forkPath, headBranch, tempRef); err != nil {
927 return "", err
928 }
929 defer DeleteRef(context.WithoutCancel(ctx), repoPath, tempRef)
930 return mergeRefs(ctx, repoPath, baseBranch, tempRef, commitMsg, authorName, authorEmail)
931 }
932
933 // FetchToTemp fetches a branch from another local repository into a temporary ref.
934 func FetchToTemp(ctx context.Context, repoPath, srcPath, branch, tempRef string) error {
935 if err := ValidateRef(branch); err != nil {
936 return err
937 }
938 _, err := runGit(ctx, repoPath, 120*time.Second, "fetch", "--no-tags", "--quiet", srcPath, "+refs/heads/"+branch+":"+tempRef)
939 if err != nil {
940 return fmt.Errorf("fetch branch %s: %w", branch, err)
941 }
942 return nil
943 }
944
945 // DeleteRef removes a ref; missing refs are ignored.
946 func DeleteRef(ctx context.Context, repoPath, ref string) {
947 _, _ = runGit(ctx, repoPath, 10*time.Second, "update-ref", "-d", ref)
948 }
949
950 // CloneBare copies a bare repository to a new location (used when creating forks).
951 func CloneBare(ctx context.Context, srcPath, dstPath string) error {
952 if _, err := runGit(ctx, "", 180*time.Second, "clone", "--bare", "--quiet", srcPath, dstPath); err != nil {
953 return fmt.Errorf("clone bare repository: %w", err)
954 }
955 _, _ = runGit(ctx, dstPath, 10*time.Second, "remote", "remove", "origin")
956 return nil
957 }
958
959 // BranchExists reports whether a local branch exists in a bare repository.
960 func BranchExists(ctx context.Context, repoPath, branch string) bool {
961 if err := ValidateRef(branch); err != nil {
962 return false
963 }
964 _, err := runGit(ctx, repoPath, 5*time.Second, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
965 return err == nil
966 }
967
968 // CountCommits returns the number of commits reachable from ref (0 on error).
969 func CountCommits(ctx context.Context, repoPath, ref string) int {
970 if err := ValidateRef(ref); err != nil {
971 return 0
972 }
973 out, err := runGit(ctx, repoPath, 10*time.Second, "rev-list", "--count", ref)
974 if err != nil {
975 return 0
976 }
977 n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
978 return n
979 }
980
981 func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, authorName, authorEmail string) (string, error) {
982 canMerge, isFF, err := CanMergeBranches(ctx, repoPath, baseRef, headRef)
983 if err != nil {
984 return "", err
985 }
986 if !canMerge {
987 return "", errors.New("cannot merge: conflicting changes detected")
988 }
989
990 headCommit, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef)
991 if err != nil {
992 return "", fmt.Errorf("rev-parse head: %w", err)
993 }
994 headHash := strings.TrimSpace(string(headCommit))
995
996 if isFF {
997 _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, headHash)
998 if err != nil {
999 return "", fmt.Errorf("update-ref fast-forward: %w", err)
1000 }
1001 return headHash, nil
1002 }
1003
1004 wtOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", "--write-tree", baseRef, headRef)
1005 var treeHash string
1006 if err == nil && len(strings.TrimSpace(string(wtOut))) == 40 {
1007 treeHash = strings.TrimSpace(string(wtOut))
1008 } else {
1009 headTree, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef+"^{tree}")
1010 treeHash = strings.TrimSpace(string(headTree))
1011 }
1012
1013 baseHeadHash, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", baseRef)
1014 baseHashStr := strings.TrimSpace(string(baseHeadHash))
1015
1016 if commitMsg == "" {
1017 commitMsg = fmt.Sprintf("Merge branch '%s' into %s", headRef, baseRef)
1018 }
1019
1020 commitArgs := []string{"commit-tree", treeHash, "-p", baseHashStr, "-p", headHash, "-m", commitMsg}
1021 cmd := exec.CommandContext(ctx, "git", commitArgs...)
1022 cmd.Dir = repoPath
1023 cmd.Env = append(os.Environ(),
1024 "GIT_AUTHOR_NAME="+authorName,
1025 "GIT_AUTHOR_EMAIL="+authorEmail,
1026 "GIT_COMMITTER_NAME="+authorName,
1027 "GIT_COMMITTER_EMAIL="+authorEmail,
1028 )
1029 out, err := cmd.CombinedOutput()
1030 if err != nil {
1031 return "", fmt.Errorf("commit-tree failed: %s (%w)", string(out), err)
1032 }
1033 mergeHash := strings.TrimSpace(string(out))
1034
1035 _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, mergeHash)
1036 if err != nil {
1037 return "", fmt.Errorf("update-ref: %w", err)
1038 }
1039 return mergeHash, nil
1040 }
1041