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:
195
internal/gitexplorer/format.go
Normal file
195
internal/gitexplorer/format.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user