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) } // 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, }) } // 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 }