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:
446
internal/gitexplorer/client_test.go
Normal file
446
internal/gitexplorer/client_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package gitexplorer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
)
|
||||
|
||||
// createTestRepo creates a temporary git repository with some commits for testing.
|
||||
func createTestRepo(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.MkdirTemp("", "gitexplorer-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
repo, err := git.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to init repo: %v", err)
|
||||
}
|
||||
|
||||
wt, err := repo.Worktree()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to get worktree: %v", err)
|
||||
}
|
||||
|
||||
// Create initial file and commit
|
||||
readme := filepath.Join(dir, "README.md")
|
||||
if err := os.WriteFile(readme, []byte("# Test Repo\n"), 0644); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to write README: %v", err)
|
||||
}
|
||||
|
||||
if _, err := wt.Add("README.md"); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to add README: %v", err)
|
||||
}
|
||||
|
||||
sig := &object.Signature{
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
When: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
_, err = wt.Commit("Initial commit", &git.CommitOptions{Author: sig})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to create initial commit: %v", err)
|
||||
}
|
||||
|
||||
// Create a second file and commit
|
||||
subdir := filepath.Join(dir, "src")
|
||||
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
mainFile := filepath.Join(subdir, "main.go")
|
||||
if err := os.WriteFile(mainFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to write main.go: %v", err)
|
||||
}
|
||||
|
||||
if _, err := wt.Add("src/main.go"); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to add main.go: %v", err)
|
||||
}
|
||||
|
||||
sig.When = time.Now().Add(-1 * time.Hour)
|
||||
_, err = wt.Commit("Add main.go", &git.CommitOptions{Author: sig})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to create second commit: %v", err)
|
||||
}
|
||||
|
||||
// Update README and commit
|
||||
if err := os.WriteFile(readme, []byte("# Test Repo\n\nThis is a test repository.\n"), 0644); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to update README: %v", err)
|
||||
}
|
||||
|
||||
if _, err := wt.Add("README.md"); err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to add updated README: %v", err)
|
||||
}
|
||||
|
||||
sig.When = time.Now()
|
||||
_, err = wt.Commit("Update README", &git.CommitOptions{Author: sig})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("failed to create third commit: %v", err)
|
||||
}
|
||||
|
||||
return dir, cleanup
|
||||
}
|
||||
|
||||
func TestNewGitClient(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
if client.defaultRemote != "origin" {
|
||||
t.Errorf("defaultRemote = %q, want %q", client.defaultRemote, "origin")
|
||||
}
|
||||
|
||||
// Test with invalid path
|
||||
_, err = NewGitClient("/nonexistent/path", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRef(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
// Test resolving HEAD
|
||||
result, err := client.ResolveRef("HEAD")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRef(HEAD) failed: %v", err)
|
||||
}
|
||||
if result.Commit == "" {
|
||||
t.Error("commit hash is empty")
|
||||
}
|
||||
|
||||
// Test resolving master branch
|
||||
result, err = client.ResolveRef("master")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRef(master) failed: %v", err)
|
||||
}
|
||||
if result.Type != "branch" {
|
||||
t.Errorf("type = %q, want %q", result.Type, "branch")
|
||||
}
|
||||
|
||||
// Test resolving invalid ref
|
||||
_, err = client.ResolveRef("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLog(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
// Get full log
|
||||
entries, err := client.GetLog("HEAD", 10, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLog failed: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("got %d entries, want 3", len(entries))
|
||||
}
|
||||
|
||||
// Check order (newest first)
|
||||
if entries[0].Subject != "Update README" {
|
||||
t.Errorf("first entry subject = %q, want %q", entries[0].Subject, "Update README")
|
||||
}
|
||||
|
||||
// Test with limit
|
||||
entries, err = client.GetLog("HEAD", 1, "", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLog with limit failed: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("got %d entries, want 1", len(entries))
|
||||
}
|
||||
|
||||
// Test with author filter
|
||||
entries, err = client.GetLog("HEAD", 10, "Test User", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLog with author failed: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Errorf("got %d entries, want 3", len(entries))
|
||||
}
|
||||
|
||||
entries, err = client.GetLog("HEAD", 10, "nonexistent", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLog with nonexistent author failed: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("got %d entries, want 0", len(entries))
|
||||
}
|
||||
|
||||
// Test with path filter
|
||||
entries, err = client.GetLog("HEAD", 10, "", "", "src")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLog with path failed: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("got %d entries, want 1 (only src/main.go commit)", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommitInfo(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := client.GetCommitInfo("HEAD", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommitInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if info.Author != "Test User" {
|
||||
t.Errorf("author = %q, want %q", info.Author, "Test User")
|
||||
}
|
||||
if info.Email != "test@example.com" {
|
||||
t.Errorf("email = %q, want %q", info.Email, "test@example.com")
|
||||
}
|
||||
if len(info.Parents) != 1 {
|
||||
t.Errorf("parents = %d, want 1", len(info.Parents))
|
||||
}
|
||||
if info.Stats == nil {
|
||||
t.Error("stats is nil")
|
||||
}
|
||||
|
||||
// Test without stats
|
||||
info, err = client.GetCommitInfo("HEAD", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommitInfo without stats failed: %v", err)
|
||||
}
|
||||
if info.Stats != nil {
|
||||
t.Error("stats should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDiffFiles(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := client.GetDiffFiles("HEAD~2", "HEAD")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDiffFiles failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Files) < 1 {
|
||||
t.Error("expected at least one changed file")
|
||||
}
|
||||
|
||||
// Check that we have the expected files
|
||||
foundReadme := false
|
||||
foundMain := false
|
||||
for _, f := range result.Files {
|
||||
if f.Path == "README.md" {
|
||||
foundReadme = true
|
||||
}
|
||||
if f.Path == "src/main.go" {
|
||||
foundMain = true
|
||||
}
|
||||
}
|
||||
if !foundReadme {
|
||||
t.Error("expected README.md in diff")
|
||||
}
|
||||
if !foundMain {
|
||||
t.Error("expected src/main.go in diff")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFileAtCommit(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := client.GetFileAtCommit("HEAD", "README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileAtCommit failed: %v", err)
|
||||
}
|
||||
|
||||
if content.Path != "README.md" {
|
||||
t.Errorf("path = %q, want %q", content.Path, "README.md")
|
||||
}
|
||||
if content.Content == "" {
|
||||
t.Error("content is empty")
|
||||
}
|
||||
|
||||
// Test nested file
|
||||
content, err = client.GetFileAtCommit("HEAD", "src/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("GetFileAtCommit for nested file failed: %v", err)
|
||||
}
|
||||
if content.Path != "src/main.go" {
|
||||
t.Errorf("path = %q, want %q", content.Path, "src/main.go")
|
||||
}
|
||||
|
||||
// Test nonexistent file
|
||||
_, err = client.GetFileAtCommit("HEAD", "nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
|
||||
// Test path traversal
|
||||
_, err = client.GetFileAtCommit("HEAD", "../../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAncestor(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
// First commit is ancestor of HEAD
|
||||
result, err := client.IsAncestor("HEAD~2", "HEAD")
|
||||
if err != nil {
|
||||
t.Fatalf("IsAncestor failed: %v", err)
|
||||
}
|
||||
if !result.IsAncestor {
|
||||
t.Error("HEAD~2 should be ancestor of HEAD")
|
||||
}
|
||||
|
||||
// HEAD is not ancestor of first commit
|
||||
result, err = client.IsAncestor("HEAD", "HEAD~2")
|
||||
if err != nil {
|
||||
t.Fatalf("IsAncestor failed: %v", err)
|
||||
}
|
||||
if result.IsAncestor {
|
||||
t.Error("HEAD should not be ancestor of HEAD~2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsBetween(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := client.CommitsBetween("HEAD~2", "HEAD", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("CommitsBetween failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 commits (HEAD~1 and HEAD, exclusive of HEAD~2)
|
||||
if result.Count != 2 {
|
||||
t.Errorf("count = %d, want 2", result.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBranches(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := client.ListBranches(false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBranches failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Total < 1 {
|
||||
t.Error("expected at least one branch")
|
||||
}
|
||||
|
||||
foundMaster := false
|
||||
for _, b := range result.Branches {
|
||||
if b.Name == "master" {
|
||||
foundMaster = true
|
||||
if !b.IsHead {
|
||||
t.Error("master should be HEAD")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundMaster {
|
||||
t.Error("expected master branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchCommits(t *testing.T) {
|
||||
repoPath, cleanup := createTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
client, err := NewGitClient(repoPath, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewGitClient failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := client.SearchCommits("HEAD", "README", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchCommits failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Count < 1 {
|
||||
t.Error("expected at least one match for 'README'")
|
||||
}
|
||||
|
||||
// Search with no matches
|
||||
result, err = client.SearchCommits("HEAD", "nonexistent-query-xyz", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchCommits for no match failed: %v", err)
|
||||
}
|
||||
if result.Count != 0 {
|
||||
t.Errorf("count = %d, want 0", result.Count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user