Files
PR-Helper/services/git.go
T

611 lines
14 KiB
Go

package services
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
)
// RefInfo represents a branch or tag.
type RefInfo struct {
Name string `json:"name"`
Hash string `json:"hash"`
IsHead bool `json:"is_head"`
IsTag bool `json:"is_tag"`
}
// CommitInfo is a commit in the graph.
type CommitInfo struct {
Hash string `json:"hash"`
ShortHash string `json:"short_hash"`
Message string `json:"message"`
Author string `json:"author"`
Email string `json:"email"`
Timestamp string `json:"timestamp"`
ParentIDs []string `json:"parent_ids"`
}
// GraphData is the response for GET /api/repos/:id/graph.
type GraphData struct {
Commits []CommitInfo `json:"commits"`
Refs []RefInfo `json:"refs"`
Edges []Edge `json:"edges"`
}
// Edge connects a commit to a parent.
type Edge struct {
Source string `json:"source"`
Target string `json:"target"`
}
// CloneOptions holds clone configuration.
type CloneOptions struct {
URL string
Dir string
Username string
Password string
Depth int
}
// CloneResult is returned after a successful clone.
type CloneResult struct {
RepoPath string
Branches []string
Tags []string
CommitNum int
SizeBytes int64
}
// Clone clones a repository with optional progress callback.
// progressFn receives SSE-formatted messages; nil means no progress reporting.
func Clone(opts CloneOptions, progressFn func(event string, data interface{})) (*CloneResult, error) {
if err := os.MkdirAll(opts.Dir, 0o755); err != nil {
return nil, fmt.Errorf("create dir: %w", err)
}
cloneOpts := &git.CloneOptions{
URL: opts.URL,
}
if opts.Depth > 0 {
cloneOpts.Depth = opts.Depth
}
if opts.Username != "" {
cloneOpts.Auth = &http.BasicAuth{
Username: opts.Username,
Password: opts.Password,
}
}
if progressFn != nil {
progressFn("progress", map[string]interface{}{
"step": "开始克隆",
"current": 0,
"total": 0,
})
}
repo, err := git.PlainClone(opts.Dir, false, cloneOpts)
if err != nil {
if progressFn != nil {
progressFn("error", map[string]interface{}{
"message": err.Error(),
})
}
return nil, fmt.Errorf("git clone: %w", err)
}
if progressFn != nil {
progressFn("progress", map[string]interface{}{
"step": "克隆完成",
"current": 1,
"total": 1,
})
}
result := &CloneResult{RepoPath: opts.Dir}
// Count commits and collect branches/tags
iter, err := repo.CommitObjects()
if err == nil {
_ = iter.ForEach(func(c *object.Commit) error {
result.CommitNum++
return nil
})
}
branches, _ := repo.Branches()
_ = branches.ForEach(func(ref *plumbing.Reference) error {
result.Branches = append(result.Branches, ref.Name().Short())
return nil
})
tags, _ := repo.Tags()
_ = tags.ForEach(func(ref *plumbing.Reference) error {
result.Tags = append(result.Tags, ref.Name().Short())
return nil
})
result.SizeBytes = dirSize(opts.Dir)
if progressFn != nil {
progressFn("complete", map[string]interface{}{
"repo_id": filepath.Base(opts.Dir),
"size_bytes": result.SizeBytes,
})
}
return result, nil
}
// OpenRepo opens a bare or non-bare repo at the given path.
func OpenRepo(path string) (*git.Repository, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, fmt.Errorf("open repo: %w", err)
}
return repo, nil
}
// GetRefs returns all branches and tags.
func GetRefs(repo *git.Repository) ([]RefInfo, error) {
var refs []RefInfo
head, _ := repo.Head()
branches, err := repo.Branches()
if err != nil {
return nil, err
}
_ = branches.ForEach(func(ref *plumbing.Reference) error {
isHead := head != nil && ref.Hash() == head.Hash()
refs = append(refs, RefInfo{
Name: ref.Name().Short(),
Hash: ref.Hash().String(),
IsHead: isHead,
IsTag: false,
})
return nil
})
tags, err := repo.Tags()
if err != nil {
return nil, err
}
_ = tags.ForEach(func(ref *plumbing.Reference) error {
// For annotated tags, dereference to the commit
hash := ref.Hash()
tagObj, err := repo.TagObject(hash)
if err == nil {
commit, err := tagObj.Commit()
if err == nil {
hash = commit.Hash
}
}
refs = append(refs, RefInfo{
Name: ref.Name().Short(),
Hash: hash.String(),
IsHead: false,
IsTag: true,
})
return nil
})
return refs, nil
}
// GetGraph returns D3.js-compatible graph data with commits, refs, and edges.
func GetGraph(repo *git.Repository, maxCommits int) (*GraphData, error) {
graph := &GraphData{}
// Collect refs first
refs, err := GetRefs(repo)
if err != nil {
return nil, err
}
graph.Refs = refs
// Collect ref hashes to mark commit targets
refHashes := make(map[string]bool)
for _, r := range refs {
refHashes[r.Hash] = true
}
// Walk commits from HEAD
head, err := repo.Head()
if err != nil {
return graph, nil // empty repo
}
commitObj, err := repo.CommitObject(head.Hash())
if err != nil {
return graph, nil
}
seen := make(map[string]bool)
queue := []*object.Commit{commitObj}
for len(queue) > 0 && len(graph.Commits) < maxCommits {
c := queue[0]
queue = queue[1:]
if seen[c.Hash.String()] {
continue
}
seen[c.Hash.String()] = true
ci := CommitInfo{
Hash: c.Hash.String(),
ShortHash: c.Hash.String()[:7],
Message: strings.Split(c.Message, "\n")[0],
Author: c.Author.Name,
Email: c.Author.Email,
Timestamp: c.Author.When.Format(time.RFC3339),
}
for _, parent := range c.ParentHashes {
ci.ParentIDs = append(ci.ParentIDs, parent.String())
graph.Edges = append(graph.Edges, Edge{
Source: c.Hash.String(),
Target: parent.String(),
})
if !seen[parent.String()] {
parentCommit, err := repo.CommitObject(parent)
if err == nil {
queue = append(queue, parentCommit)
}
}
}
graph.Commits = append(graph.Commits, ci)
}
return graph, nil
}
// GetDiff returns the unified diff between two refs for all changed files.
func GetDiff(repo *git.Repository, baseRef, headRef string) (string, error) {
baseHash, err := repo.ResolveRevision(plumbing.Revision(baseRef))
if err != nil {
return "", fmt.Errorf("resolve base ref %q: %w", baseRef, err)
}
headHash, err := repo.ResolveRevision(plumbing.Revision(headRef))
if err != nil {
return "", fmt.Errorf("resolve head ref %q: %w", headRef, err)
}
baseCommit, err := repo.CommitObject(*baseHash)
if err != nil {
return "", fmt.Errorf("base commit: %w", err)
}
headCommit, err := repo.CommitObject(*headHash)
if err != nil {
return "", fmt.Errorf("head commit: %w", err)
}
baseTree, err := baseCommit.Tree()
if err != nil {
return "", err
}
headTree, err := headCommit.Tree()
if err != nil {
return "", err
}
changes, err := object.DiffTree(baseTree, headTree)
if err != nil {
return "", fmt.Errorf("diff tree: %w", err)
}
var diffParts []string
for _, change := range changes {
patch, err := change.Patch()
if err != nil {
continue
}
diffParts = append(diffParts, patch.String())
}
return strings.Join(diffParts, "\n"), nil
}
// GetDiffFiles returns per-file diffs between two refs.
type FileDiff struct {
Filename string `json:"filename"`
Patch string `json:"patch"`
}
func GetDiffFiles(repo *git.Repository, baseRef, headRef string) ([]FileDiff, error) {
baseHash, err := repo.ResolveRevision(plumbing.Revision(baseRef))
if err != nil {
return nil, fmt.Errorf("resolve base ref %q: %w", baseRef, err)
}
headHash, err := repo.ResolveRevision(plumbing.Revision(headRef))
if err != nil {
return nil, fmt.Errorf("resolve head ref %q: %w", headRef, err)
}
baseCommit, err := repo.CommitObject(*baseHash)
if err != nil {
return nil, err
}
headCommit, err := repo.CommitObject(*headHash)
if err != nil {
return nil, err
}
baseTree, err := baseCommit.Tree()
if err != nil {
return nil, err
}
headTree, err := headCommit.Tree()
if err != nil {
return nil, err
}
changes, err := object.DiffTree(baseTree, headTree)
if err != nil {
return nil, err
}
var files []FileDiff
for _, change := range changes {
patch, err := change.Patch()
if err != nil {
continue
}
name := change.To.Name
if name == "" {
name = change.From.Name
}
files = append(files, FileDiff{
Filename: name,
Patch: patch.String(),
})
}
return files, nil
}
// GetCommitLog returns recent commits on a given ref.
func GetCommitLog(repo *git.Repository, refName string, maxCommits int) ([]CommitInfo, error) {
hash, err := repo.ResolveRevision(plumbing.Revision(refName))
if err != nil {
return nil, fmt.Errorf("resolve ref %q: %w", refName, err)
}
commit, err := repo.CommitObject(*hash)
if err != nil {
return nil, err
}
var commits []CommitInfo
seen := make(map[string]bool)
queue := []*object.Commit{commit}
for len(queue) > 0 && len(commits) < maxCommits {
c := queue[0]
queue = queue[1:]
if seen[c.Hash.String()] {
continue
}
seen[c.Hash.String()] = true
commits = append(commits, CommitInfo{
Hash: c.Hash.String(),
ShortHash: c.Hash.String()[:7],
Message: strings.Split(c.Message, "\n")[0],
Author: c.Author.Name,
Email: c.Author.Email,
Timestamp: c.Author.When.Format(time.RFC3339),
ParentIDs: func() []string {
var ids []string
for _, p := range c.ParentHashes {
ids = append(ids, p.String())
}
return ids
}(),
})
for _, parent := range c.ParentHashes {
if !seen[parent.String()] {
p, err := repo.CommitObject(parent)
if err == nil {
queue = append(queue, p)
}
}
}
}
return commits, nil
}
// GetBranchCommits returns commit history for a specific branch.
func GetBranchCommits(repoPath, branchName string, maxCommits int) ([]CommitInfo, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
return GetCommitLog(repo, branchName, maxCommits)
}
// FetchRemote fetches latest changes for a repo.
func FetchRemote(repoPath string) error {
repo, err := OpenRepo(repoPath)
if err != nil {
return err
}
remote, err := repo.Remote("origin")
if err != nil {
return err
}
return remote.Fetch(&git.FetchOptions{
Force: true,
})
}
// CloneSSEEvent formats an SSE event for the clone progress stream.
func CloneSSEEvent(event string, data interface{}) string {
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, toJSON(data))
}
// toJSON is a simple JSON encoder for SSE data.
func toJSON(v interface{}) string {
switch d := v.(type) {
case map[string]interface{}:
var parts []string
for k, val := range d {
switch vv := val.(type) {
case string:
parts = append(parts, fmt.Sprintf("%q:%q", k, vv))
case int:
parts = append(parts, fmt.Sprintf("%q:%d", k, vv))
case int64:
parts = append(parts, fmt.Sprintf("%q:%d", k, vv))
default:
parts = append(parts, fmt.Sprintf("%q:%q", k, fmt.Sprintf("%v", vv)))
}
}
return "{" + strings.Join(parts, ",") + "}"
default:
return fmt.Sprintf("%q", fmt.Sprintf("%v", v))
}
}
// dirSize returns the total size of files in a directory.
func dirSize(path string) int64 {
var size int64
_ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size
}
// EstimateSize estimates repo size without cloning (for display).
func EstimateSize(url string) int64 {
// We can't know without cloning, return 0
return 0
}
// GetCloneProgressReader wraps a git clone with progress reporting.
type CloneProgressReader struct {
scanner *bufio.Scanner
progressFn func(event string, data interface{})
}
func NewCloneProgressReader(progressFn func(event string, data interface{})) *CloneProgressReader {
return &CloneProgressReader{
progressFn: progressFn,
}
}
func (r *CloneProgressReader) Read(p []byte) (n int, err error) {
// This is a simplified version - real implementation would parse git protocol
return 0, nil
}
// CompareCommits returns the diff between two commits.
func CompareCommits(repoPath, base, head string) (string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return "", err
}
return GetDiff(repo, base, head)
}
// CompareCommitsFiles returns per-file diffs between two commits.
func CompareCommitsFiles(repoPath, base, head string) ([]FileDiff, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
return GetDiffFiles(repo, base, head)
}
// GetRepoBranches returns branch names for a repository.
func GetRepoBranches(repoPath string) ([]string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
var branches []string
iter, err := repo.Branches()
if err != nil {
return nil, err
}
_ = iter.ForEach(func(ref *plumbing.Reference) error {
branches = append(branches, ref.Name().Short())
return nil
})
sort.Strings(branches)
return branches, nil
}
// GetRepoTags returns tag names for a repository.
func GetRepoTags(repoPath string) ([]string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
var tags []string
iter, err := repo.Tags()
if err != nil {
return nil, err
}
_ = iter.ForEach(func(ref *plumbing.Reference) error {
tags = append(tags, ref.Name().Short())
return nil
})
sort.Strings(tags)
return tags, nil
}
// CloneWithProgress clones with a pipe-based progress reporter.
func CloneWithProgress(opts CloneOptions, progressFn func(event string, data interface{})) (*CloneResult, error) {
return Clone(opts, progressFn)
}
// GetDefaultBranch returns the default branch name (main or master).
func GetDefaultBranch(repoPath string) (string, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return "", err
}
head, err := repo.Head()
if err != nil {
return "main", nil
}
return head.Name().Short(), nil
}
// MemoryClone clones to memory for size estimation (lightweight).
func MemoryClone(url string) (*git.Repository, error) {
repo, err := git.Clone(memory.NewStorage(), nil, &git.CloneOptions{
URL: url,
Depth: 1,
})
if err != nil {
return nil, err
}
return repo, nil
}