feat: Phase 2 — Git 核心功能,go-git 克隆、SSE 进度、D3.js 图形、diff2html 查看器
This commit is contained in:
+610
@@ -0,0 +1,610 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user