20be99b287
Deploy PR-Helper / deploy (push) Successful in 34s
- 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
597 lines
14 KiB
Go
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
|
|
}
|