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 }