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,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
}

View 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)
}
}

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()
}

View File

@@ -0,0 +1,440 @@
package gitexplorer
import (
"context"
"fmt"
"git.t-juice.club/torjus/labmcp/internal/mcp"
)
// RegisterHandlers registers all git-explorer tool handlers on the MCP server.
func RegisterHandlers(server *mcp.Server, client *GitClient) {
server.RegisterTool(resolveRefTool(), makeResolveRefHandler(client))
server.RegisterTool(getLogTool(), makeGetLogHandler(client))
server.RegisterTool(getCommitInfoTool(), makeGetCommitInfoHandler(client))
server.RegisterTool(getDiffFilesTool(), makeGetDiffFilesHandler(client))
server.RegisterTool(getFileAtCommitTool(), makeGetFileAtCommitHandler(client))
server.RegisterTool(isAncestorTool(), makeIsAncestorHandler(client))
server.RegisterTool(commitsBetweenTool(), makeCommitsBetweenHandler(client))
server.RegisterTool(listBranchesTool(), makeListBranchesHandler(client))
server.RegisterTool(searchCommitsTool(), makeSearchCommitsHandler(client))
}
// Tool definitions
func resolveRefTool() mcp.Tool {
return mcp.Tool{
Name: "resolve_ref",
Description: "Resolve a git ref (branch, tag, or commit hash) to its full commit hash",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Git ref to resolve (e.g., 'main', 'v1.0.0', 'HEAD', commit hash)",
},
},
Required: []string{"ref"},
},
}
}
func getLogTool() mcp.Tool {
return mcp.Tool{
Name: "get_log",
Description: "Get commit log starting from a ref, with optional filters",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Starting ref for the log (default: HEAD)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of commits to return (default: 20, max: %d)", Limits.MaxLogEntries),
Default: 20,
},
"author": {
Type: "string",
Description: "Filter by author name or email (substring match)",
},
"since": {
Type: "string",
Description: "Stop log at this ref (exclusive)",
},
"path": {
Type: "string",
Description: "Filter commits that affect this path",
},
},
},
}
}
func getCommitInfoTool() mcp.Tool {
return mcp.Tool{
Name: "get_commit_info",
Description: "Get full details for a specific commit including message, author, and optionally file statistics",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Commit ref (hash, branch, tag, or HEAD)",
},
"include_stats": {
Type: "boolean",
Description: "Include file change statistics (default: true)",
Default: true,
},
},
Required: []string{"ref"},
},
}
}
func getDiffFilesTool() mcp.Tool {
return mcp.Tool{
Name: "get_diff_files",
Description: "Get list of files changed between two commits with change type and line counts",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"from_ref": {
Type: "string",
Description: "Starting commit ref (the older commit)",
},
"to_ref": {
Type: "string",
Description: "Ending commit ref (the newer commit)",
},
},
Required: []string{"from_ref", "to_ref"},
},
}
}
func getFileAtCommitTool() mcp.Tool {
return mcp.Tool{
Name: "get_file_at_commit",
Description: fmt.Sprintf("Get the contents of a file at a specific commit (max %dKB)", Limits.MaxFileContent/1024),
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ref": {
Type: "string",
Description: "Commit ref (hash, branch, tag, or HEAD)",
},
"path": {
Type: "string",
Description: "Path to the file relative to repository root",
},
},
Required: []string{"ref", "path"},
},
}
}
func isAncestorTool() mcp.Tool {
return mcp.Tool{
Name: "is_ancestor",
Description: "Check if one commit is an ancestor of another",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"ancestor": {
Type: "string",
Description: "Potential ancestor commit ref",
},
"descendant": {
Type: "string",
Description: "Potential descendant commit ref",
},
},
Required: []string{"ancestor", "descendant"},
},
}
}
func commitsBetweenTool() mcp.Tool {
return mcp.Tool{
Name: "commits_between",
Description: "Get all commits between two refs (from is exclusive, to is inclusive)",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"from_ref": {
Type: "string",
Description: "Starting commit ref (exclusive - commits after this)",
},
"to_ref": {
Type: "string",
Description: "Ending commit ref (inclusive - up to and including this)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of commits (default: %d)", Limits.MaxLogEntries),
Default: Limits.MaxLogEntries,
},
},
Required: []string{"from_ref", "to_ref"},
},
}
}
func listBranchesTool() mcp.Tool {
return mcp.Tool{
Name: "list_branches",
Description: "List all branches in the repository",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"include_remote": {
Type: "boolean",
Description: "Include remote-tracking branches (default: false)",
Default: false,
},
},
},
}
}
func searchCommitsTool() mcp.Tool {
return mcp.Tool{
Name: "search_commits",
Description: "Search commit messages for a pattern (case-insensitive)",
InputSchema: mcp.InputSchema{
Type: "object",
Properties: map[string]mcp.Property{
"query": {
Type: "string",
Description: "Search pattern to match in commit messages",
},
"ref": {
Type: "string",
Description: "Starting ref for the search (default: HEAD)",
},
"limit": {
Type: "integer",
Description: fmt.Sprintf("Maximum number of results (default: 20, max: %d)", Limits.MaxSearchResult),
Default: 20,
},
},
Required: []string{"query"},
},
}
}
// Handler constructors
func makeResolveRefHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
result, err := client.ResolveRef(ref)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatResolveResult(result))},
}, nil
}
}
func makeGetLogHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref := "HEAD"
if r, ok := args["ref"].(string); ok && r != "" {
ref = r
}
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
author, _ := args["author"].(string)
since, _ := args["since"].(string)
path, _ := args["path"].(string)
entries, err := client.GetLog(ref, limit, author, since, path)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatLogEntries(entries))},
}, nil
}
}
func makeGetCommitInfoHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
includeStats := true
if s, ok := args["include_stats"].(bool); ok {
includeStats = s
}
info, err := client.GetCommitInfo(ref, includeStats)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatCommitInfo(info))},
}, nil
}
}
func makeGetDiffFilesHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
fromRef, _ := args["from_ref"].(string)
if fromRef == "" {
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
}
toRef, _ := args["to_ref"].(string)
if toRef == "" {
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
}
result, err := client.GetDiffFiles(fromRef, toRef)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatDiffResult(result))},
}, nil
}
}
func makeGetFileAtCommitHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ref, _ := args["ref"].(string)
if ref == "" {
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
}
path, _ := args["path"].(string)
if path == "" {
return mcp.ErrorContent(fmt.Errorf("path is required")), nil
}
content, err := client.GetFileAtCommit(ref, path)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatFileContent(content))},
}, nil
}
}
func makeIsAncestorHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
ancestor, _ := args["ancestor"].(string)
if ancestor == "" {
return mcp.ErrorContent(fmt.Errorf("ancestor is required")), nil
}
descendant, _ := args["descendant"].(string)
if descendant == "" {
return mcp.ErrorContent(fmt.Errorf("descendant is required")), nil
}
result, err := client.IsAncestor(ancestor, descendant)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatAncestryResult(result))},
}, nil
}
}
func makeCommitsBetweenHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
fromRef, _ := args["from_ref"].(string)
if fromRef == "" {
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
}
toRef, _ := args["to_ref"].(string)
if toRef == "" {
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
}
limit := Limits.MaxLogEntries
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
result, err := client.CommitsBetween(fromRef, toRef, limit)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatCommitRange(result))},
}, nil
}
}
func makeListBranchesHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
includeRemote := false
if r, ok := args["include_remote"].(bool); ok {
includeRemote = r
}
result, err := client.ListBranches(includeRemote)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatBranchList(result))},
}, nil
}
}
func makeSearchCommitsHandler(client *GitClient) mcp.ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
query, _ := args["query"].(string)
if query == "" {
return mcp.ErrorContent(fmt.Errorf("query is required")), nil
}
ref := "HEAD"
if r, ok := args["ref"].(string); ok && r != "" {
ref = r
}
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
}
result, err := client.SearchCommits(ref, query, limit)
if err != nil {
return mcp.ErrorContent(err), nil
}
return mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent(FormatSearchResult(result))},
}, nil
}
}

View File

@@ -0,0 +1,121 @@
package gitexplorer
import (
"time"
)
// ResolveResult contains the result of resolving a ref to a commit.
type ResolveResult struct {
Ref string `json:"ref"`
Commit string `json:"commit"`
Type string `json:"type"` // "branch", "tag", "commit"
}
// LogEntry represents a single commit in the log.
type LogEntry struct {
Hash string `json:"hash"`
ShortHash string `json:"short_hash"`
Author string `json:"author"`
Email string `json:"email"`
Date time.Time `json:"date"`
Subject string `json:"subject"`
}
// CommitInfo contains full details about a commit.
type CommitInfo struct {
Hash string `json:"hash"`
Author string `json:"author"`
Email string `json:"email"`
Date time.Time `json:"date"`
Committer string `json:"committer"`
CommitDate time.Time `json:"commit_date"`
Message string `json:"message"`
Parents []string `json:"parents"`
Stats *FileStats `json:"stats,omitempty"`
}
// FileStats contains statistics about file changes.
type FileStats struct {
FilesChanged int `json:"files_changed"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
// DiffFile represents a file changed between two commits.
type DiffFile struct {
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"` // For renames
Status string `json:"status"` // "added", "modified", "deleted", "renamed"
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
// DiffResult contains the list of files changed between two commits.
type DiffResult struct {
FromCommit string `json:"from_commit"`
ToCommit string `json:"to_commit"`
Files []DiffFile `json:"files"`
}
// FileContent represents the content of a file at a specific commit.
type FileContent struct {
Path string `json:"path"`
Commit string `json:"commit"`
Size int64 `json:"size"`
Content string `json:"content"`
}
// AncestryResult contains the result of an ancestry check.
type AncestryResult struct {
Ancestor string `json:"ancestor"`
Descendant string `json:"descendant"`
IsAncestor bool `json:"is_ancestor"`
}
// CommitRange represents commits between two refs.
type CommitRange struct {
FromCommit string `json:"from_commit"`
ToCommit string `json:"to_commit"`
Commits []LogEntry `json:"commits"`
Count int `json:"count"`
}
// Branch represents a git branch.
type Branch struct {
Name string `json:"name"`
Commit string `json:"commit"`
IsRemote bool `json:"is_remote"`
IsHead bool `json:"is_head"`
Upstream string `json:"upstream,omitempty"`
AheadBy int `json:"ahead_by,omitempty"`
BehindBy int `json:"behind_by,omitempty"`
}
// BranchList contains the list of branches.
type BranchList struct {
Branches []Branch `json:"branches"`
Current string `json:"current"`
Total int `json:"total"`
}
// SearchResult represents a commit matching a search query.
type SearchResult struct {
Commits []LogEntry `json:"commits"`
Query string `json:"query"`
Count int `json:"count"`
}
// Limits defines the maximum values for various operations.
var Limits = struct {
MaxFileContent int64 // Maximum file size in bytes
MaxLogEntries int // Maximum commit log entries
MaxBranches int // Maximum branches to list
MaxDiffFiles int // Maximum files in diff
MaxSearchResult int // Maximum search results
}{
MaxFileContent: 100 * 1024, // 100KB
MaxLogEntries: 100,
MaxBranches: 500,
MaxDiffFiles: 1000,
MaxSearchResult: 100,
}

View File

@@ -0,0 +1,57 @@
package gitexplorer
import (
"errors"
"path/filepath"
"slices"
"strings"
)
var (
// ErrPathTraversal is returned when a path attempts to traverse outside the repository.
ErrPathTraversal = errors.New("path traversal not allowed")
// ErrAbsolutePath is returned when an absolute path is provided.
ErrAbsolutePath = errors.New("absolute paths not allowed")
// ErrNullByte is returned when a path contains null bytes.
ErrNullByte = errors.New("null bytes not allowed in path")
// ErrEmptyPath is returned when a path is empty.
ErrEmptyPath = errors.New("path cannot be empty")
)
// ValidatePath validates a file path for security.
// It rejects:
// - Absolute paths
// - Paths containing null bytes
// - Paths that attempt directory traversal (contain "..")
// - Empty paths
func ValidatePath(path string) error {
if path == "" {
return ErrEmptyPath
}
// Check for null bytes
if strings.Contains(path, "\x00") {
return ErrNullByte
}
// Check for absolute paths
if filepath.IsAbs(path) {
return ErrAbsolutePath
}
// Clean the path and check for traversal
cleaned := filepath.Clean(path)
// Check if cleaned path starts with ".."
if strings.HasPrefix(cleaned, "..") {
return ErrPathTraversal
}
// Check for ".." components in the path
parts := strings.Split(cleaned, string(filepath.Separator))
if slices.Contains(parts, "..") {
return ErrPathTraversal
}
return nil
}

View File

@@ -0,0 +1,91 @@
package gitexplorer
import (
"testing"
)
func TestValidatePath(t *testing.T) {
tests := []struct {
name string
path string
wantErr error
}{
// Valid paths
{
name: "simple file",
path: "README.md",
wantErr: nil,
},
{
name: "nested file",
path: "internal/gitexplorer/types.go",
wantErr: nil,
},
{
name: "file with dots",
path: "file.test.go",
wantErr: nil,
},
{
name: "current dir prefix",
path: "./README.md",
wantErr: nil,
},
{
name: "deeply nested",
path: "a/b/c/d/e/f/g.txt",
wantErr: nil,
},
// Invalid paths
{
name: "empty path",
path: "",
wantErr: ErrEmptyPath,
},
{
name: "absolute path unix",
path: "/etc/passwd",
wantErr: ErrAbsolutePath,
},
{
name: "parent dir traversal simple",
path: "../secret.txt",
wantErr: ErrPathTraversal,
},
{
name: "parent dir traversal nested",
path: "foo/../../../etc/passwd",
wantErr: ErrPathTraversal,
},
{
name: "parent dir traversal in middle",
path: "foo/bar/../../../secret",
wantErr: ErrPathTraversal,
},
{
name: "null byte",
path: "file\x00.txt",
wantErr: ErrNullByte,
},
{
name: "null byte in middle",
path: "foo/bar\x00baz/file.txt",
wantErr: ErrNullByte,
},
{
name: "double dot only",
path: "..",
wantErr: ErrPathTraversal,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePath(tt.path)
if err != tt.wantErr {
t.Errorf("ValidatePath(%q) = %v, want %v", tt.path, err, tt.wantErr)
}
})
}
}