feat: add git-explorer MCP server for read-only repository access
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>
This commit is contained in:
570
internal/gitexplorer/client.go
Normal file
570
internal/gitexplorer/client.go
Normal file
@@ -0,0 +1,570 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user