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>
447 lines
10 KiB
Go
447 lines
10 KiB
Go
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)
|
|
}
|
|
}
|