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