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:
570
internal/gitexplorer/client.go
Normal file
570
internal/gitexplorer/client.go
Normal 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
440
internal/gitexplorer/handlers.go
Normal file
440
internal/gitexplorer/handlers.go
Normal 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
|
||||
}
|
||||
}
|
||||
121
internal/gitexplorer/types.go
Normal file
121
internal/gitexplorer/types.go
Normal 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,
|
||||
}
|
||||
57
internal/gitexplorer/validation.go
Normal file
57
internal/gitexplorer/validation.go
Normal 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
|
||||
}
|
||||
91
internal/gitexplorer/validation_test.go
Normal file
91
internal/gitexplorer/validation_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user