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:
2026-02-08 04:26:38 +01:00
parent 98bad6c9ba
commit 75673974a2
16 changed files with 2814 additions and 11 deletions

View File

@@ -0,0 +1,195 @@
package gitexplorer
import (
"fmt"
"strings"
)
// FormatResolveResult formats a ResolveResult as markdown.
func FormatResolveResult(r *ResolveResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Ref:** %s\n", r.Ref))
sb.WriteString(fmt.Sprintf("**Type:** %s\n", r.Type))
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", r.Commit))
return sb.String()
}
// FormatLogEntries formats a slice of LogEntry as markdown.
func FormatLogEntries(entries []LogEntry) string {
if len(entries) == 0 {
return "No commits found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Commit Log (%d commits)\n\n", len(entries)))
for _, e := range entries {
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
}
return sb.String()
}
// FormatCommitInfo formats a CommitInfo as markdown.
func FormatCommitInfo(info *CommitInfo) string {
var sb strings.Builder
sb.WriteString("## Commit Details\n\n")
sb.WriteString(fmt.Sprintf("**Hash:** %s\n", info.Hash))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", info.Author, info.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n", info.Date.Format("2006-01-02 15:04:05")))
sb.WriteString(fmt.Sprintf("**Committer:** %s\n", info.Committer))
sb.WriteString(fmt.Sprintf("**Commit Date:** %s\n", info.CommitDate.Format("2006-01-02 15:04:05")))
if len(info.Parents) > 0 {
sb.WriteString(fmt.Sprintf("**Parents:** %s\n", strings.Join(info.Parents, ", ")))
}
if info.Stats != nil {
sb.WriteString(fmt.Sprintf("**Changes:** %d file(s), +%d -%d\n",
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions))
}
sb.WriteString("\n### Message\n\n")
sb.WriteString("```\n")
sb.WriteString(info.Message)
if !strings.HasSuffix(info.Message, "\n") {
sb.WriteString("\n")
}
sb.WriteString("```\n")
return sb.String()
}
// FormatDiffResult formats a DiffResult as markdown.
func FormatDiffResult(r *DiffResult) string {
if len(r.Files) == 0 {
return "No files changed."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Files Changed (%d files)\n\n", len(r.Files)))
sb.WriteString(fmt.Sprintf("**From:** %s\n", r.FromCommit[:7]))
sb.WriteString(fmt.Sprintf("**To:** %s\n\n", r.ToCommit[:7]))
sb.WriteString("| Status | Path | Changes |\n")
sb.WriteString("|--------|------|--------|\n")
for _, f := range r.Files {
path := f.Path
if f.OldPath != "" {
path = fmt.Sprintf("%s → %s", f.OldPath, f.Path)
}
changes := fmt.Sprintf("+%d -%d", f.Additions, f.Deletions)
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", f.Status, path, changes))
}
return sb.String()
}
// FormatFileContent formats a FileContent as markdown.
func FormatFileContent(c *FileContent) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## File: %s\n\n", c.Path))
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", c.Commit[:7]))
sb.WriteString(fmt.Sprintf("**Size:** %d bytes\n\n", c.Size))
// Determine language hint from extension
ext := ""
if idx := strings.LastIndex(c.Path, "."); idx != -1 {
ext = c.Path[idx+1:]
}
sb.WriteString(fmt.Sprintf("```%s\n", ext))
sb.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
sb.WriteString("\n")
}
sb.WriteString("```\n")
return sb.String()
}
// FormatAncestryResult formats an AncestryResult as markdown.
func FormatAncestryResult(r *AncestryResult) string {
var sb strings.Builder
sb.WriteString("## Ancestry Check\n\n")
sb.WriteString(fmt.Sprintf("**Ancestor:** %s\n", r.Ancestor[:7]))
sb.WriteString(fmt.Sprintf("**Descendant:** %s\n", r.Descendant[:7]))
if r.IsAncestor {
sb.WriteString("\n✓ **Yes**, the first commit is an ancestor of the second.\n")
} else {
sb.WriteString("\n✗ **No**, the first commit is not an ancestor of the second.\n")
}
return sb.String()
}
// FormatCommitRange formats a CommitRange as markdown.
func FormatCommitRange(r *CommitRange) string {
if r.Count == 0 {
return "No commits in range."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Commits Between (%d commits)\n\n", r.Count))
sb.WriteString(fmt.Sprintf("**From:** %s (exclusive)\n", r.FromCommit[:7]))
sb.WriteString(fmt.Sprintf("**To:** %s (inclusive)\n\n", r.ToCommit[:7]))
for _, e := range r.Commits {
sb.WriteString(fmt.Sprintf("- **%s** %s (%s)\n", e.ShortHash, e.Subject, e.Author))
}
return sb.String()
}
// FormatBranchList formats a BranchList as markdown.
func FormatBranchList(r *BranchList) string {
if r.Total == 0 {
return "No branches found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Branches (%d total)\n\n", r.Total))
if r.Current != "" {
sb.WriteString(fmt.Sprintf("**Current branch:** %s\n\n", r.Current))
}
sb.WriteString("| Branch | Commit | Type |\n")
sb.WriteString("|--------|--------|------|\n")
for _, b := range r.Branches {
branchType := "local"
if b.IsRemote {
branchType = "remote"
}
marker := ""
if b.IsHead {
marker = " ✓"
}
sb.WriteString(fmt.Sprintf("| %s%s | %s | %s |\n", b.Name, marker, b.Commit[:7], branchType))
}
return sb.String()
}
// FormatSearchResult formats a SearchResult as markdown.
func FormatSearchResult(r *SearchResult) string {
if r.Count == 0 {
return fmt.Sprintf("No commits found matching '%s'.", r.Query)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Search Results for '%s' (%d matches)\n\n", r.Query, r.Count))
for _, e := range r.Commits {
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
}
return sb.String()
}