Implements a new MCP server that provides read-only access to git repositories using go-git. Designed for deployment verification by comparing deployed flake revisions against source repositories. 9 tools: resolve_ref, get_log, get_commit_info, get_diff_files, get_file_at_commit, is_ancestor, commits_between, list_branches, search_commits. Includes CLI commands, NixOS module, and comprehensive tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
571 lines
14 KiB
Go
571 lines
14 KiB
Go
package gitexplorer
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
)
|
|
|
|
var (
|
|
// ErrNotFound is returned when a ref, commit, or file is not found.
|
|
ErrNotFound = errors.New("not found")
|
|
// ErrFileTooLarge is returned when a file exceeds the size limit.
|
|
ErrFileTooLarge = errors.New("file too large")
|
|
)
|
|
|
|
// GitClient provides read-only access to a git repository.
|
|
type GitClient struct {
|
|
repo *git.Repository
|
|
defaultRemote string
|
|
}
|
|
|
|
// NewGitClient opens a git repository at the given path.
|
|
func NewGitClient(repoPath string, defaultRemote string) (*GitClient, error) {
|
|
repo, err := git.PlainOpen(repoPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open repository: %w", err)
|
|
}
|
|
|
|
if defaultRemote == "" {
|
|
defaultRemote = "origin"
|
|
}
|
|
|
|
return &GitClient{
|
|
repo: repo,
|
|
defaultRemote: defaultRemote,
|
|
}, nil
|
|
}
|
|
|
|
// ResolveRef resolves a ref (branch, tag, or commit hash) to a commit hash.
|
|
func (c *GitClient) ResolveRef(ref string) (*ResolveResult, error) {
|
|
result := &ResolveResult{Ref: ref}
|
|
|
|
// Try to resolve as a revision
|
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
|
}
|
|
|
|
result.Commit = hash.String()
|
|
|
|
// Determine the type of ref
|
|
// Check if it's a branch
|
|
if _, err := c.repo.Reference(plumbing.NewBranchReferenceName(ref), true); err == nil {
|
|
result.Type = "branch"
|
|
return result, nil
|
|
}
|
|
|
|
// Check if it's a remote branch
|
|
if _, err := c.repo.Reference(plumbing.NewRemoteReferenceName(c.defaultRemote, ref), true); err == nil {
|
|
result.Type = "branch"
|
|
return result, nil
|
|
}
|
|
|
|
// Check if it's a tag
|
|
if _, err := c.repo.Reference(plumbing.NewTagReferenceName(ref), true); err == nil {
|
|
result.Type = "tag"
|
|
return result, nil
|
|
}
|
|
|
|
// Default to commit
|
|
result.Type = "commit"
|
|
return result, nil
|
|
}
|
|
|
|
// GetLog returns the commit log starting from the given ref.
|
|
func (c *GitClient) GetLog(ref string, limit int, author string, since string, path string) ([]LogEntry, error) {
|
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
|
limit = Limits.MaxLogEntries
|
|
}
|
|
|
|
// Resolve the ref to a commit hash
|
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
|
}
|
|
|
|
logOpts := &git.LogOptions{
|
|
From: *hash,
|
|
}
|
|
|
|
// Add path filter if specified
|
|
if path != "" {
|
|
if err := ValidatePath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
logOpts.PathFilter = func(p string) bool {
|
|
return strings.HasPrefix(p, path) || p == path
|
|
}
|
|
}
|
|
|
|
iter, err := c.repo.Log(logOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
|
}
|
|
defer iter.Close()
|
|
|
|
var entries []LogEntry
|
|
err = iter.ForEach(func(commit *object.Commit) error {
|
|
// Apply author filter
|
|
if author != "" {
|
|
authorLower := strings.ToLower(author)
|
|
if !strings.Contains(strings.ToLower(commit.Author.Name), authorLower) &&
|
|
!strings.Contains(strings.ToLower(commit.Author.Email), authorLower) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Apply since filter
|
|
if since != "" {
|
|
// Parse since as a ref and check if this commit is reachable
|
|
sinceHash, err := c.repo.ResolveRevision(plumbing.Revision(since))
|
|
if err == nil {
|
|
// Stop if we've reached the since commit
|
|
if commit.Hash == *sinceHash {
|
|
return io.EOF
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get first line of commit message as subject
|
|
subject := commit.Message
|
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
|
subject = subject[:idx]
|
|
}
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
entries = append(entries, LogEntry{
|
|
Hash: commit.Hash.String(),
|
|
ShortHash: commit.Hash.String()[:7],
|
|
Author: commit.Author.Name,
|
|
Email: commit.Author.Email,
|
|
Date: commit.Author.When,
|
|
Subject: subject,
|
|
})
|
|
|
|
if len(entries) >= limit {
|
|
return io.EOF
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// io.EOF is expected when we hit the limit
|
|
if err != nil && err != io.EOF {
|
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// GetCommitInfo returns full details about a commit.
|
|
func (c *GitClient) GetCommitInfo(ref string, includeStats bool) (*CommitInfo, error) {
|
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
|
}
|
|
|
|
commit, err := c.repo.CommitObject(*hash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
|
}
|
|
|
|
info := &CommitInfo{
|
|
Hash: commit.Hash.String(),
|
|
Author: commit.Author.Name,
|
|
Email: commit.Author.Email,
|
|
Date: commit.Author.When,
|
|
Committer: commit.Committer.Name,
|
|
CommitDate: commit.Committer.When,
|
|
Message: commit.Message,
|
|
}
|
|
|
|
for _, parent := range commit.ParentHashes {
|
|
info.Parents = append(info.Parents, parent.String())
|
|
}
|
|
|
|
if includeStats {
|
|
stats, err := c.getCommitStats(commit)
|
|
if err == nil {
|
|
info.Stats = stats
|
|
}
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// getCommitStats computes file change statistics for a commit.
|
|
func (c *GitClient) getCommitStats(commit *object.Commit) (*FileStats, error) {
|
|
stats, err := commit.Stats()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := &FileStats{
|
|
FilesChanged: len(stats),
|
|
}
|
|
|
|
for _, s := range stats {
|
|
result.Additions += s.Addition
|
|
result.Deletions += s.Deletion
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetDiffFiles returns the files changed between two commits.
|
|
func (c *GitClient) GetDiffFiles(fromRef, toRef string) (*DiffResult, error) {
|
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
|
}
|
|
|
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
|
}
|
|
|
|
fromCommit, err := c.repo.CommitObject(*fromHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get from commit: %w", err)
|
|
}
|
|
|
|
toCommit, err := c.repo.CommitObject(*toHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get to commit: %w", err)
|
|
}
|
|
|
|
patch, err := fromCommit.Patch(toCommit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get patch: %w", err)
|
|
}
|
|
|
|
result := &DiffResult{
|
|
FromCommit: fromHash.String(),
|
|
ToCommit: toHash.String(),
|
|
}
|
|
|
|
for i, filePatch := range patch.FilePatches() {
|
|
if i >= Limits.MaxDiffFiles {
|
|
break
|
|
}
|
|
|
|
from, to := filePatch.Files()
|
|
|
|
df := DiffFile{}
|
|
|
|
// Determine status and paths
|
|
switch {
|
|
case from == nil && to != nil:
|
|
df.Status = "added"
|
|
df.Path = to.Path()
|
|
case from != nil && to == nil:
|
|
df.Status = "deleted"
|
|
df.Path = from.Path()
|
|
case from != nil && to != nil && from.Path() != to.Path():
|
|
df.Status = "renamed"
|
|
df.Path = to.Path()
|
|
df.OldPath = from.Path()
|
|
default:
|
|
df.Status = "modified"
|
|
if to != nil {
|
|
df.Path = to.Path()
|
|
} else if from != nil {
|
|
df.Path = from.Path()
|
|
}
|
|
}
|
|
|
|
// Count additions and deletions
|
|
for _, chunk := range filePatch.Chunks() {
|
|
content := chunk.Content()
|
|
lines := strings.Split(content, "\n")
|
|
switch chunk.Type() {
|
|
case 1: // Add
|
|
df.Additions += len(lines)
|
|
case 2: // Delete
|
|
df.Deletions += len(lines)
|
|
}
|
|
}
|
|
|
|
result.Files = append(result.Files, df)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// GetFileAtCommit returns the content of a file at a specific commit.
|
|
func (c *GitClient) GetFileAtCommit(ref, path string) (*FileContent, error) {
|
|
if err := ValidatePath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
|
}
|
|
|
|
commit, err := c.repo.CommitObject(*hash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
|
}
|
|
|
|
file, err := commit.File(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: file '%s'", ErrNotFound, path)
|
|
}
|
|
|
|
// Check file size
|
|
if file.Size > Limits.MaxFileContent {
|
|
return nil, fmt.Errorf("%w: %d bytes (max %d)", ErrFileTooLarge, file.Size, Limits.MaxFileContent)
|
|
}
|
|
|
|
content, err := file.Contents()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
return &FileContent{
|
|
Path: path,
|
|
Commit: hash.String(),
|
|
Size: file.Size,
|
|
Content: content,
|
|
}, nil
|
|
}
|
|
|
|
// IsAncestor checks if ancestor is an ancestor of descendant.
|
|
func (c *GitClient) IsAncestor(ancestorRef, descendantRef string) (*AncestryResult, error) {
|
|
ancestorHash, err := c.repo.ResolveRevision(plumbing.Revision(ancestorRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ancestorRef)
|
|
}
|
|
|
|
descendantHash, err := c.repo.ResolveRevision(plumbing.Revision(descendantRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, descendantRef)
|
|
}
|
|
|
|
ancestorCommit, err := c.repo.CommitObject(*ancestorHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ancestor commit: %w", err)
|
|
}
|
|
|
|
descendantCommit, err := c.repo.CommitObject(*descendantHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get descendant commit: %w", err)
|
|
}
|
|
|
|
isAncestor, err := ancestorCommit.IsAncestor(descendantCommit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check ancestry: %w", err)
|
|
}
|
|
|
|
return &AncestryResult{
|
|
Ancestor: ancestorHash.String(),
|
|
Descendant: descendantHash.String(),
|
|
IsAncestor: isAncestor,
|
|
}, nil
|
|
}
|
|
|
|
// CommitsBetween returns commits between two refs (exclusive of from, inclusive of to).
|
|
func (c *GitClient) CommitsBetween(fromRef, toRef string, limit int) (*CommitRange, error) {
|
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
|
limit = Limits.MaxLogEntries
|
|
}
|
|
|
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
|
}
|
|
|
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
|
}
|
|
|
|
iter, err := c.repo.Log(&git.LogOptions{
|
|
From: *toHash,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
|
}
|
|
defer iter.Close()
|
|
|
|
result := &CommitRange{
|
|
FromCommit: fromHash.String(),
|
|
ToCommit: toHash.String(),
|
|
}
|
|
|
|
err = iter.ForEach(func(commit *object.Commit) error {
|
|
// Stop when we reach the from commit (exclusive)
|
|
if commit.Hash == *fromHash {
|
|
return io.EOF
|
|
}
|
|
|
|
subject := commit.Message
|
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
|
subject = subject[:idx]
|
|
}
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
result.Commits = append(result.Commits, LogEntry{
|
|
Hash: commit.Hash.String(),
|
|
ShortHash: commit.Hash.String()[:7],
|
|
Author: commit.Author.Name,
|
|
Email: commit.Author.Email,
|
|
Date: commit.Author.When,
|
|
Subject: subject,
|
|
})
|
|
|
|
if len(result.Commits) >= limit {
|
|
return io.EOF
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil && err != io.EOF {
|
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
|
}
|
|
|
|
result.Count = len(result.Commits)
|
|
return result, nil
|
|
}
|
|
|
|
// ListBranches returns all branches in the repository.
|
|
func (c *GitClient) ListBranches(includeRemote bool) (*BranchList, error) {
|
|
result := &BranchList{}
|
|
|
|
// Get HEAD to determine current branch
|
|
head, err := c.repo.Head()
|
|
if err == nil && head.Name().IsBranch() {
|
|
result.Current = head.Name().Short()
|
|
}
|
|
|
|
// List local branches
|
|
branchIter, err := c.repo.Branches()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list branches: %w", err)
|
|
}
|
|
|
|
err = branchIter.ForEach(func(ref *plumbing.Reference) error {
|
|
if len(result.Branches) >= Limits.MaxBranches {
|
|
return io.EOF
|
|
}
|
|
|
|
branch := Branch{
|
|
Name: ref.Name().Short(),
|
|
Commit: ref.Hash().String(),
|
|
IsRemote: false,
|
|
IsHead: ref.Name().Short() == result.Current,
|
|
}
|
|
|
|
result.Branches = append(result.Branches, branch)
|
|
return nil
|
|
})
|
|
if err != nil && err != io.EOF {
|
|
return nil, fmt.Errorf("failed to iterate branches: %w", err)
|
|
}
|
|
|
|
// List remote branches if requested
|
|
if includeRemote {
|
|
refs, err := c.repo.References()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list references: %w", err)
|
|
}
|
|
|
|
err = refs.ForEach(func(ref *plumbing.Reference) error {
|
|
if len(result.Branches) >= Limits.MaxBranches {
|
|
return io.EOF
|
|
}
|
|
|
|
if ref.Name().IsRemote() {
|
|
branch := Branch{
|
|
Name: ref.Name().Short(),
|
|
Commit: ref.Hash().String(),
|
|
IsRemote: true,
|
|
IsHead: false,
|
|
}
|
|
result.Branches = append(result.Branches, branch)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil && err != io.EOF {
|
|
return nil, fmt.Errorf("failed to iterate references: %w", err)
|
|
}
|
|
}
|
|
|
|
result.Total = len(result.Branches)
|
|
return result, nil
|
|
}
|
|
|
|
// SearchCommits searches commit messages for a pattern.
|
|
func (c *GitClient) SearchCommits(ref, query string, limit int) (*SearchResult, error) {
|
|
if limit <= 0 || limit > Limits.MaxSearchResult {
|
|
limit = Limits.MaxSearchResult
|
|
}
|
|
|
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
|
}
|
|
|
|
iter, err := c.repo.Log(&git.LogOptions{
|
|
From: *hash,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
|
}
|
|
defer iter.Close()
|
|
|
|
result := &SearchResult{
|
|
Query: query,
|
|
}
|
|
queryLower := strings.ToLower(query)
|
|
|
|
// We need to scan more commits to find matches
|
|
scanned := 0
|
|
maxScan := limit * 100 // Scan up to 100x the limit
|
|
|
|
err = iter.ForEach(func(commit *object.Commit) error {
|
|
scanned++
|
|
if scanned > maxScan {
|
|
return io.EOF
|
|
}
|
|
|
|
// Search in message (case-insensitive)
|
|
if !strings.Contains(strings.ToLower(commit.Message), queryLower) {
|
|
return nil
|
|
}
|
|
|
|
subject := commit.Message
|
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
|
subject = subject[:idx]
|
|
}
|
|
subject = strings.TrimSpace(subject)
|
|
|
|
result.Commits = append(result.Commits, LogEntry{
|
|
Hash: commit.Hash.String(),
|
|
ShortHash: commit.Hash.String()[:7],
|
|
Author: commit.Author.Name,
|
|
Email: commit.Author.Email,
|
|
Date: commit.Author.When,
|
|
Subject: subject,
|
|
})
|
|
|
|
if len(result.Commits) >= limit {
|
|
return io.EOF
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil && err != io.EOF {
|
|
return nil, fmt.Errorf("failed to search commits: %w", err)
|
|
}
|
|
|
|
result.Count = len(result.Commits)
|
|
return result, nil
|
|
}
|