Files
PR-Helper/services/git.go
T
wonder 20be99b287
Deploy PR-Helper / deploy (push) Successful in 34s
feat: add git pull to update cached repositories from remote
- Replace dead FetchRemote with PullRepo (fetch + merge) in services/git.go
- Add POST /api/repos/:id/pull endpoint with DB size/last_used update
- Add '更新' button next to each cached repo in the index page
2026-06-21 14:31:33 +08:00

597 lines
14 KiB
Go

package services
import (
"fmt"
"log"
"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
})
} else {
log.Printf("warning: failed to count commits: %v", err)
}
branches, err := repo.Branches()
if err == nil {
_ = branches.ForEach(func(ref *plumbing.Reference) error {
result.Branches = append(result.Branches, ref.Name().Short())
return nil
})
} else {
log.Printf("warning: failed to list branches: %v", err)
}
tags, err := repo.Tags()
if err == nil {
_ = tags.ForEach(func(ref *plumbing.Reference) error {
result.Tags = append(result.Tags, ref.Name().Short())
return nil
})
} else {
log.Printf("warning: failed to list tags: %v", err)
}
result.SizeBytes = dirSize(opts.Dir)
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)
}
// PullRepo fetches and merges latest changes from origin into the current branch.
func PullRepo(repoPath string) (*CloneResult, error) {
repo, err := OpenRepo(repoPath)
if err != nil {
return nil, err
}
w, err := repo.Worktree()
if err != nil {
return nil, fmt.Errorf("worktree: %w", err)
}
err = w.Pull(&git.PullOptions{})
if err != nil && err != git.NoErrAlreadyUpToDate {
return nil, fmt.Errorf("git pull: %w", err)
}
result := &CloneResult{RepoPath: repoPath}
iter, err := repo.CommitObjects()
if err == nil {
_ = iter.ForEach(func(c *object.Commit) error {
result.CommitNum++
return nil
})
}
branches, err := repo.Branches()
if err == nil {
_ = branches.ForEach(func(ref *plumbing.Reference) error {
result.Branches = append(result.Branches, ref.Name().Short())
return nil
})
}
tags, err := repo.Tags()
if err == nil {
_ = tags.ForEach(func(ref *plumbing.Reference) error {
result.Tags = append(result.Tags, ref.Name().Short())
return nil
})
}
result.SizeBytes = dirSize(repoPath)
return result, nil
}
// 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
}
// 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).
// 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
}