diff --git a/CLAUDE.md b/CLAUDE.md index feffc01..53ab60a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,12 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other serve - 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values - Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables +### Git Explorer (`git-explorer`) +Read-only access to git repository information. Designed for deployment verification. +- 9 tools: resolve_ref, get_log, get_commit_info, get_diff_files, get_file_at_commit, is_ancestor, commits_between, list_branches, search_commits +- Uses go-git library for pure Go implementation +- All operations are read-only (never modifies repository) + The nixpkgs/options/hm servers share a database-backed architecture: - Full-text search across option/package names and descriptions - Query specific options/packages with full metadata @@ -62,8 +68,10 @@ labmcp/ │ │ └── main.go # NixOS options CLI (legacy) │ ├── hm-options/ │ │ └── main.go # Home Manager options CLI -│ └── lab-monitoring/ -│ └── main.go # Prometheus/Alertmanager CLI +│ ├── lab-monitoring/ +│ │ └── main.go # Prometheus/Alertmanager CLI +│ └── git-explorer/ +│ └── main.go # Git repository explorer CLI ├── internal/ │ ├── database/ │ │ ├── interface.go # Store interface (options + packages) @@ -96,18 +104,26 @@ labmcp/ │ │ ├── parser.go # nix-env JSON parsing │ │ ├── types.go # Package types, channel aliases │ │ └── *_test.go # Parser tests -│ └── monitoring/ -│ ├── types.go # Prometheus/Alertmanager/Loki API types -│ ├── prometheus.go # Prometheus HTTP client -│ ├── alertmanager.go # Alertmanager HTTP client -│ ├── loki.go # Loki HTTP client +│ ├── monitoring/ +│ │ ├── types.go # Prometheus/Alertmanager/Loki API types +│ │ ├── prometheus.go # Prometheus HTTP client +│ │ ├── alertmanager.go # Alertmanager HTTP client +│ │ ├── loki.go # Loki HTTP client +│ │ ├── handlers.go # MCP tool definitions + handlers +│ │ ├── format.go # Markdown formatting utilities +│ │ └── *_test.go # Tests (httptest-based) +│ └── gitexplorer/ +│ ├── client.go # go-git repository wrapper +│ ├── types.go # Type definitions │ ├── handlers.go # MCP tool definitions + handlers -│ ├── format.go # Markdown formatting utilities -│ └── *_test.go # Tests (httptest-based) +│ ├── format.go # Markdown formatters +│ ├── validation.go # Path validation +│ └── *_test.go # Tests ├── nix/ │ ├── module.nix # NixOS module for nixos-options │ ├── hm-options-module.nix # NixOS module for hm-options │ ├── lab-monitoring-module.nix # NixOS module for lab-monitoring +│ ├── git-explorer-module.nix # NixOS module for git-explorer │ └── package.nix # Parameterized Nix package ├── testdata/ │ └── options-sample.json # Test fixture @@ -158,6 +174,20 @@ labmcp/ | `list_labels` | List available label names from Loki (requires `LOKI_URL`) | | `list_label_values` | List values for a specific label from Loki (requires `LOKI_URL`) | +### Git Explorer Server (git-explorer) + +| Tool | Description | +|------|-------------| +| `resolve_ref` | Resolve a git ref (branch, tag, commit) to its full commit hash | +| `get_log` | Get commit log with optional filters (author, path, limit) | +| `get_commit_info` | Get full details for a specific commit | +| `get_diff_files` | Get list of files changed between two commits | +| `get_file_at_commit` | Get file contents at a specific commit | +| `is_ancestor` | Check if one commit is an ancestor of another | +| `commits_between` | Get all commits between two refs | +| `list_branches` | List all branches in the repository | +| `search_commits` | Search commit messages for a pattern | + ## Key Implementation Details ### Database @@ -261,6 +291,20 @@ lab-monitoring labels # List Loki labels lab-monitoring labels --values job # List values for a label ``` +### git-explorer +```bash +git-explorer serve # Run MCP server on STDIO +git-explorer serve --transport http # Run MCP server on HTTP +git-explorer --repo /path resolve # Resolve ref to commit hash +git-explorer --repo /path log --limit 10 # Show commit log +git-explorer --repo /path show # Show commit details +git-explorer --repo /path diff # Files changed between commits +git-explorer --repo /path cat # File contents at commit +git-explorer --repo /path branches # List branches +git-explorer --repo /path search # Search commit messages +git-explorer --version # Show version +``` + ### Channel Aliases **nixpkgs-search/nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc. @@ -314,6 +358,7 @@ Each package's version is defined in multiple places that must stay in sync *for - **nixpkgs-search**: `cmd/nixpkgs-search/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`, `DefaultNixpkgsPackagesConfig`) - **nixos-options**: `cmd/nixos-options/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`) - **hm-options**: `cmd/hm-options/main.go` + `internal/mcp/server.go` (`DefaultHomeManagerConfig`) +- **git-explorer**: `cmd/git-explorer/main.go` + `internal/mcp/server.go` (`DefaultGitExplorerConfig`) - **nix/package.nix**: Shared across all packages (bump to highest version when any package changes) ### User Preferences @@ -341,6 +386,7 @@ nix build .#nixpkgs-search nix build .#nixos-options nix build .#hm-options nix build .#lab-monitoring +nix build .#git-explorer # Run directly nix run .#nixpkgs-search -- options serve @@ -349,6 +395,7 @@ nix run .#nixpkgs-search -- index nixos-unstable nix run .#hm-options -- serve nix run .#hm-options -- index hm-unstable nix run .#lab-monitoring -- serve +nix run .#git-explorer -- --repo . serve ``` ### Indexing Performance diff --git a/README.md b/README.md index e25fbaf..406fada 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,20 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs from your monitorin - Manage alert silences - Query logs via LogQL (when Loki is configured) +### Git Explorer (`git-explorer`) + +Read-only access to git repository information. Designed for deployment verification — comparing deployed flake revisions against source repositories. + +- Resolve refs (branches, tags, commits) to commit hashes +- View commit logs with filtering by author, path, or range +- Get full commit details including file change statistics +- Compare commits to see which files changed +- Read file contents at any commit +- Check ancestry relationships between commits +- Search commit messages + +All operations are read-only and will never modify the repository. + ### NixOS Options (`nixos-options`) - Legacy Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search. @@ -48,11 +62,13 @@ Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-se nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search nix build git+https://git.t-juice.club/torjus/labmcp#hm-options nix build git+https://git.t-juice.club/torjus/labmcp#lab-monitoring +nix build git+https://git.t-juice.club/torjus/labmcp#git-explorer # Or run directly nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help +nix run git+https://git.t-juice.club/torjus/labmcp#git-explorer -- --help ``` ### From Source @@ -61,6 +77,7 @@ nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest go install git.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest +go install git.t-juice.club/torjus/labmcp/cmd/git-explorer@latest ``` ## Usage @@ -101,6 +118,13 @@ Configure in your MCP client (e.g., Claude Desktop): "ALERTMANAGER_URL": "http://alertmanager.example.com:9093", "LOKI_URL": "http://loki.example.com:3100" } + }, + "git-explorer": { + "command": "git-explorer", + "args": ["serve"], + "env": { + "GIT_REPO_PATH": "/path/to/your/repo" + } } } } @@ -140,6 +164,13 @@ Alternatively, if you have Nix installed, you can use the flake directly without "ALERTMANAGER_URL": "http://alertmanager.example.com:9093", "LOKI_URL": "http://loki.example.com:3100" } + }, + "git-explorer": { + "command": "nix", + "args": ["run", "git+https://git.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"], + "env": { + "GIT_REPO_PATH": "/path/to/your/repo" + } } } } @@ -155,6 +186,7 @@ nixpkgs-search options serve --transport http nixpkgs-search packages serve --transport http hm-options serve --transport http lab-monitoring serve --transport http +git-explorer serve --transport http # Custom address and CORS configuration nixpkgs-search options serve --transport http \ @@ -271,6 +303,35 @@ lab-monitoring labels lab-monitoring labels --values job ``` +**Git Explorer CLI:** + +```bash +# Resolve a ref to commit hash +git-explorer --repo /path/to/repo resolve main +git-explorer --repo /path/to/repo resolve v1.0.0 + +# View commit log +git-explorer --repo /path/to/repo log --limit 10 +git-explorer --repo /path/to/repo log --author "John" --path src/ + +# Show commit details +git-explorer --repo /path/to/repo show HEAD +git-explorer --repo /path/to/repo show abc1234 + +# Compare commits +git-explorer --repo /path/to/repo diff HEAD~5 HEAD + +# Show file at specific commit +git-explorer --repo /path/to/repo cat HEAD README.md + +# List branches +git-explorer --repo /path/to/repo branches +git-explorer --repo /path/to/repo branches --remote + +# Search commit messages +git-explorer --repo /path/to/repo search "fix bug" +``` + **Delete an indexed revision:** ```bash @@ -354,6 +415,20 @@ hm-options -d "sqlite://my.db" index hm-unstable | `list_labels` | List available label names from Loki (requires `LOKI_URL`) | | `list_label_values` | List values for a specific label from Loki (requires `LOKI_URL`) | +### Git Explorer Server (git-explorer) + +| Tool | Description | +|------|-------------| +| `resolve_ref` | Resolve a git ref (branch, tag, commit) to its full commit hash | +| `get_log` | Get commit log with optional filters (author, path, limit) | +| `get_commit_info` | Get full details for a specific commit | +| `get_diff_files` | Get list of files changed between two commits | +| `get_file_at_commit` | Get file contents at a specific commit | +| `is_ancestor` | Check if one commit is an ancestor of another | +| `commits_between` | Get all commits between two refs | +| `list_branches` | List all branches in the repository | +| `search_commits` | Search commit messages for a pattern | + ## NixOS Modules NixOS modules are provided for running the MCP servers as systemd services. @@ -445,6 +520,29 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages) } ``` +### git-explorer + +```nix +{ + inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp"; + + outputs = { self, nixpkgs, labmcp }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + labmcp.nixosModules.git-explorer-mcp + { + services.git-explorer = { + enable = true; + repoPath = "/path/to/your/git/repo"; + }; + } + ]; + }; + }; +} +``` + ### nixos-options (Legacy) ```nix @@ -519,6 +617,25 @@ Both `options.http` and `packages.http` also support: The lab-monitoring module uses `DynamicUser=true`, so no separate user/group configuration is needed. +#### git-explorer + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `enable` | bool | `false` | Enable the service | +| `package` | package | from flake | Package to use | +| `repoPath` | string | *(required)* | Path to the git repository to serve | +| `defaultRemote` | string | `"origin"` | Default remote name for ref resolution | +| `http.address` | string | `"127.0.0.1:8085"` | HTTP listen address | +| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path | +| `http.allowedOrigins` | list of string | `[]` | Allowed CORS origins | +| `http.sessionTTL` | string | `"30m"` | Session timeout | +| `http.tls.enable` | bool | `false` | Enable TLS | +| `http.tls.certFile` | path | `null` | TLS certificate file | +| `http.tls.keyFile` | path | `null` | TLS private key file | +| `openFirewall` | bool | `false` | Open firewall for HTTP port | + +The git-explorer module uses `DynamicUser=true` and grants read-only access to the repository path. + #### hm-options-mcp / nixos-options-mcp (Legacy) | Option | Type | Default | Description | @@ -579,6 +696,7 @@ go test -bench=. ./internal/database/... go build ./cmd/nixpkgs-search go build ./cmd/hm-options go build ./cmd/lab-monitoring +go build ./cmd/git-explorer ``` ## License diff --git a/cmd/git-explorer/main.go b/cmd/git-explorer/main.go new file mode 100644 index 0000000..43d29ee --- /dev/null +++ b/cmd/git-explorer/main.go @@ -0,0 +1,459 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/urfave/cli/v2" + + "git.t-juice.club/torjus/labmcp/internal/gitexplorer" + "git.t-juice.club/torjus/labmcp/internal/mcp" +) + +const version = "0.1.0" + +func main() { + app := &cli.App{ + Name: "git-explorer", + Usage: "Read-only MCP server for git repository exploration", + Version: version, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "repo", + Aliases: []string{"r"}, + Usage: "Path to git repository", + EnvVars: []string{"GIT_REPO_PATH"}, + Value: ".", + }, + &cli.StringFlag{ + Name: "default-remote", + Usage: "Default remote name", + EnvVars: []string{"GIT_DEFAULT_REMOTE"}, + Value: "origin", + }, + }, + Commands: []*cli.Command{ + serveCommand(), + resolveCommand(), + logCommand(), + showCommand(), + diffCommand(), + catCommand(), + branchesCommand(), + searchCommand(), + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func serveCommand() *cli.Command { + return &cli.Command{ + Name: "serve", + Usage: "Run MCP server for git exploration", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "transport", + Aliases: []string{"t"}, + Usage: "Transport type: 'stdio' or 'http'", + Value: "stdio", + }, + &cli.StringFlag{ + Name: "http-address", + Usage: "HTTP listen address", + Value: "127.0.0.1:8085", + }, + &cli.StringFlag{ + Name: "http-endpoint", + Usage: "HTTP endpoint path", + Value: "/mcp", + }, + &cli.StringSliceFlag{ + Name: "allowed-origins", + Usage: "Allowed Origin headers for CORS", + }, + &cli.StringFlag{ + Name: "tls-cert", + Usage: "TLS certificate file", + }, + &cli.StringFlag{ + Name: "tls-key", + Usage: "TLS key file", + }, + &cli.DurationFlag{ + Name: "session-ttl", + Usage: "Session TTL for HTTP transport", + Value: 30 * time.Minute, + }, + }, + Action: func(c *cli.Context) error { + return runServe(c) + }, + } +} + +func resolveCommand() *cli.Command { + return &cli.Command{ + Name: "resolve", + Usage: "Resolve a ref to a commit hash", + ArgsUsage: "", + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("ref argument required") + } + return runResolve(c, c.Args().First()) + }, + } +} + +func logCommand() *cli.Command { + return &cli.Command{ + Name: "log", + Usage: "Show commit log", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "ref", + Usage: "Starting ref (default: HEAD)", + Value: "HEAD", + }, + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"n"}, + Usage: "Maximum number of commits", + Value: 10, + }, + &cli.StringFlag{ + Name: "author", + Usage: "Filter by author", + }, + &cli.StringFlag{ + Name: "path", + Usage: "Filter by path", + }, + }, + Action: func(c *cli.Context) error { + return runLog(c) + }, + } +} + +func showCommand() *cli.Command { + return &cli.Command{ + Name: "show", + Usage: "Show commit details", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "stats", + Usage: "Include file statistics", + Value: true, + }, + }, + Action: func(c *cli.Context) error { + ref := "HEAD" + if c.NArg() > 0 { + ref = c.Args().First() + } + return runShow(c, ref) + }, + } +} + +func diffCommand() *cli.Command { + return &cli.Command{ + Name: "diff", + Usage: "Show files changed between two commits", + ArgsUsage: " ", + Action: func(c *cli.Context) error { + if c.NArg() < 2 { + return fmt.Errorf("both from-ref and to-ref arguments required") + } + return runDiff(c, c.Args().Get(0), c.Args().Get(1)) + }, + } +} + +func catCommand() *cli.Command { + return &cli.Command{ + Name: "cat", + Usage: "Show file contents at a commit", + ArgsUsage: " ", + Action: func(c *cli.Context) error { + if c.NArg() < 2 { + return fmt.Errorf("both ref and path arguments required") + } + return runCat(c, c.Args().Get(0), c.Args().Get(1)) + }, + } +} + +func branchesCommand() *cli.Command { + return &cli.Command{ + Name: "branches", + Usage: "List branches", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "remote", + Aliases: []string{"r"}, + Usage: "Include remote branches", + }, + }, + Action: func(c *cli.Context) error { + return runBranches(c) + }, + } +} + +func searchCommand() *cli.Command { + return &cli.Command{ + Name: "search", + Usage: "Search commit messages", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "ref", + Usage: "Starting ref (default: HEAD)", + Value: "HEAD", + }, + &cli.IntFlag{ + Name: "limit", + Aliases: []string{"n"}, + Usage: "Maximum number of results", + Value: 20, + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("query argument required") + } + return runSearch(c, c.Args().First()) + }, + } +} + +func runServe(c *cli.Context) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + repoPath := c.String("repo") + client, err := gitexplorer.NewGitClient(repoPath, c.String("default-remote")) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + logger := log.New(os.Stderr, "[mcp] ", log.LstdFlags) + config := mcp.DefaultGitExplorerConfig() + + server := mcp.NewGenericServer(logger, config) + gitexplorer.RegisterHandlers(server, client) + + transport := c.String("transport") + switch transport { + case "stdio": + logger.Printf("Starting git-explorer MCP server on stdio (repo: %s)...", repoPath) + return server.Run(ctx, os.Stdin, os.Stdout) + + case "http": + httpConfig := mcp.HTTPConfig{ + Address: c.String("http-address"), + Endpoint: c.String("http-endpoint"), + AllowedOrigins: c.StringSlice("allowed-origins"), + SessionTTL: c.Duration("session-ttl"), + TLSCertFile: c.String("tls-cert"), + TLSKeyFile: c.String("tls-key"), + } + httpTransport := mcp.NewHTTPTransport(server, httpConfig) + return httpTransport.Run(ctx) + + default: + return fmt.Errorf("unknown transport: %s (use 'stdio' or 'http')", transport) + } +} + +func getClient(c *cli.Context) (*gitexplorer.GitClient, error) { + return gitexplorer.NewGitClient(c.String("repo"), c.String("default-remote")) +} + +func runResolve(c *cli.Context, ref string) error { + client, err := getClient(c) + if err != nil { + return err + } + + result, err := client.ResolveRef(ref) + if err != nil { + return err + } + + fmt.Printf("%s (%s) -> %s\n", result.Ref, result.Type, result.Commit) + return nil +} + +func runLog(c *cli.Context) error { + client, err := getClient(c) + if err != nil { + return err + } + + entries, err := client.GetLog( + c.String("ref"), + c.Int("limit"), + c.String("author"), + "", + c.String("path"), + ) + if err != nil { + return err + } + + if len(entries) == 0 { + fmt.Println("No commits found.") + return nil + } + + for _, e := range entries { + fmt.Printf("%s %s\n", e.ShortHash, e.Subject) + fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email) + fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05")) + } + + return nil +} + +func runShow(c *cli.Context, ref string) error { + client, err := getClient(c) + if err != nil { + return err + } + + info, err := client.GetCommitInfo(ref, c.Bool("stats")) + if err != nil { + return err + } + + fmt.Printf("commit %s\n", info.Hash) + fmt.Printf("Author: %s <%s>\n", info.Author, info.Email) + fmt.Printf("Date: %s\n", info.Date.Format("2006-01-02 15:04:05")) + + if len(info.Parents) > 0 { + fmt.Printf("Parents: %v\n", info.Parents) + } + + if info.Stats != nil { + fmt.Printf("\n%d file(s) changed, %d insertions(+), %d deletions(-)\n", + info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions) + } + + fmt.Printf("\n%s", info.Message) + + return nil +} + +func runDiff(c *cli.Context, fromRef, toRef string) error { + client, err := getClient(c) + if err != nil { + return err + } + + result, err := client.GetDiffFiles(fromRef, toRef) + if err != nil { + return err + } + + if len(result.Files) == 0 { + fmt.Println("No files changed.") + return nil + } + + fmt.Printf("Comparing %s..%s\n\n", result.FromCommit[:7], result.ToCommit[:7]) + + for _, f := range result.Files { + status := f.Status[0:1] // First letter: A, M, D, R + path := f.Path + if f.OldPath != "" { + path = fmt.Sprintf("%s -> %s", f.OldPath, f.Path) + } + fmt.Printf("%s %s (+%d -%d)\n", status, path, f.Additions, f.Deletions) + } + + return nil +} + +func runCat(c *cli.Context, ref, path string) error { + client, err := getClient(c) + if err != nil { + return err + } + + content, err := client.GetFileAtCommit(ref, path) + if err != nil { + return err + } + + fmt.Print(content.Content) + return nil +} + +func runBranches(c *cli.Context) error { + client, err := getClient(c) + if err != nil { + return err + } + + result, err := client.ListBranches(c.Bool("remote")) + if err != nil { + return err + } + + if result.Total == 0 { + fmt.Println("No branches found.") + return nil + } + + for _, b := range result.Branches { + marker := " " + if b.IsHead { + marker = "*" + } + remoteMarker := "" + if b.IsRemote { + remoteMarker = " (remote)" + } + fmt.Printf("%s %s -> %s%s\n", marker, b.Name, b.Commit[:7], remoteMarker) + } + + return nil +} + +func runSearch(c *cli.Context, query string) error { + client, err := getClient(c) + if err != nil { + return err + } + + result, err := client.SearchCommits(c.String("ref"), query, c.Int("limit")) + if err != nil { + return err + } + + if result.Count == 0 { + fmt.Printf("No commits matching '%s'.\n", query) + return nil + } + + fmt.Printf("Found %d commit(s) matching '%s':\n\n", result.Count, query) + for _, e := range result.Commits { + fmt.Printf("%s %s\n", e.ShortHash, e.Subject) + fmt.Printf(" Author: %s <%s>\n", e.Author, e.Email) + fmt.Printf(" Date: %s\n\n", e.Date.Format("2006-01-02 15:04:05")) + } + + return nil +} diff --git a/flake.nix b/flake.nix index e5317fd..e7cd540 100644 --- a/flake.nix +++ b/flake.nix @@ -40,6 +40,13 @@ mainProgram = "lab-monitoring"; description = "MCP server for Prometheus and Alertmanager monitoring"; }; + git-explorer = pkgs.callPackage ./nix/package.nix { + src = ./.; + pname = "git-explorer"; + subPackage = "cmd/git-explorer"; + mainProgram = "git-explorer"; + description = "Read-only MCP server for git repository exploration"; + }; default = self.packages.${system}.nixpkgs-search; }); @@ -84,6 +91,10 @@ imports = [ ./nix/lab-monitoring-module.nix ]; services.lab-monitoring.package = lib.mkDefault self.packages.${pkgs.system}.lab-monitoring; }; + git-explorer-mcp = { pkgs, ... }: { + imports = [ ./nix/git-explorer-module.nix ]; + services.git-explorer.package = lib.mkDefault self.packages.${pkgs.system}.git-explorer; + }; default = self.nixosModules.nixpkgs-search-mcp; }; }; diff --git a/go.mod b/go.mod index d0ae844..3c288c8 100644 --- a/go.mod +++ b/go.mod @@ -9,16 +9,35 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.16.4 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/go.sum b/go.sum index d786f63..8ba1cfa 100644 --- a/go.sum +++ b/go.sum @@ -1,36 +1,103 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= diff --git a/internal/gitexplorer/client.go b/internal/gitexplorer/client.go new file mode 100644 index 0000000..c860657 --- /dev/null +++ b/internal/gitexplorer/client.go @@ -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 +} diff --git a/internal/gitexplorer/client_test.go b/internal/gitexplorer/client_test.go new file mode 100644 index 0000000..43b53bd --- /dev/null +++ b/internal/gitexplorer/client_test.go @@ -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) + } +} diff --git a/internal/gitexplorer/format.go b/internal/gitexplorer/format.go new file mode 100644 index 0000000..f08d08e --- /dev/null +++ b/internal/gitexplorer/format.go @@ -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() +} diff --git a/internal/gitexplorer/handlers.go b/internal/gitexplorer/handlers.go new file mode 100644 index 0000000..10e5287 --- /dev/null +++ b/internal/gitexplorer/handlers.go @@ -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 + } +} diff --git a/internal/gitexplorer/types.go b/internal/gitexplorer/types.go new file mode 100644 index 0000000..df89864 --- /dev/null +++ b/internal/gitexplorer/types.go @@ -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, +} diff --git a/internal/gitexplorer/validation.go b/internal/gitexplorer/validation.go new file mode 100644 index 0000000..533a07e --- /dev/null +++ b/internal/gitexplorer/validation.go @@ -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 +} diff --git a/internal/gitexplorer/validation_test.go b/internal/gitexplorer/validation_test.go new file mode 100644 index 0000000..1e2150b --- /dev/null +++ b/internal/gitexplorer/validation_test.go @@ -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) + } + }) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1e2c145..b236bb7 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -118,6 +118,27 @@ This ensures option documentation matches the home-manager version the project a } } +// DefaultGitExplorerConfig returns the default configuration for the git-explorer server. +func DefaultGitExplorerConfig() ServerConfig { + return ServerConfig{ + Name: "git-explorer", + Version: "0.1.0", + Mode: ModeCustom, + Instructions: `Git Explorer MCP Server - Read-only access to git repository information. + +Tools for exploring git repositories: +- Resolve refs (branches, tags, commits) to commit hashes +- View commit logs with filtering by author, path, or range +- Get full commit details including file change statistics +- Compare commits to see which files changed +- Read file contents at any commit +- Check ancestry relationships between commits +- Search commit messages + +All operations are read-only and will never modify the repository.`, + } +} + // Server is an MCP server that handles JSON-RPC requests. type Server struct { store database.Store diff --git a/nix/git-explorer-module.nix b/nix/git-explorer-module.nix new file mode 100644 index 0000000..378d1d5 --- /dev/null +++ b/nix/git-explorer-module.nix @@ -0,0 +1,141 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.git-explorer; + + mkHttpFlags = httpCfg: lib.concatStringsSep " " ([ + "--transport http" + "--http-address '${httpCfg.address}'" + "--http-endpoint '${httpCfg.endpoint}'" + "--session-ttl '${httpCfg.sessionTTL}'" + ] ++ lib.optionals (httpCfg.allowedOrigins != []) ( + map (origin: "--allowed-origins '${origin}'") httpCfg.allowedOrigins + ) ++ lib.optionals httpCfg.tls.enable [ + "--tls-cert '${httpCfg.tls.certFile}'" + "--tls-key '${httpCfg.tls.keyFile}'" + ]); +in +{ + options.services.git-explorer = { + enable = lib.mkEnableOption "Git Explorer MCP server"; + + package = lib.mkPackageOption pkgs "git-explorer" { }; + + repoPath = lib.mkOption { + type = lib.types.str; + description = "Path to the git repository to serve."; + }; + + defaultRemote = lib.mkOption { + type = lib.types.str; + default = "origin"; + description = "Default remote name for ref resolution."; + }; + + http = { + address = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1:8085"; + description = "HTTP listen address for the MCP server."; + }; + + endpoint = lib.mkOption { + type = lib.types.str; + default = "/mcp"; + description = "HTTP endpoint path for MCP requests."; + }; + + allowedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Allowed Origin headers for CORS."; + }; + + sessionTTL = lib.mkOption { + type = lib.types.str; + default = "30m"; + description = "Session TTL for HTTP transport."; + }; + + tls = { + enable = lib.mkEnableOption "TLS for HTTP transport"; + + certFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to TLS certificate file."; + }; + + keyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to TLS private key file."; + }; + }; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the MCP HTTP server."; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.http.tls.enable || (cfg.http.tls.certFile != null && cfg.http.tls.keyFile != null); + message = "services.git-explorer.http.tls: both certFile and keyFile must be set when TLS is enabled"; + } + ]; + + systemd.services.git-explorer = { + description = "Git Explorer MCP Server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = { + GIT_REPO_PATH = cfg.repoPath; + GIT_DEFAULT_REMOTE = cfg.defaultRemote; + }; + + script = let + httpFlags = mkHttpFlags cfg.http; + in '' + exec ${cfg.package}/bin/git-explorer serve ${httpFlags} + ''; + + serviceConfig = { + Type = "simple"; + DynamicUser = true; + Restart = "on-failure"; + RestartSec = "5s"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + LockPersonality = true; + + # Read-only access to repo path + ReadOnlyPaths = [ cfg.repoPath ]; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall (let + addressParts = lib.splitString ":" cfg.http.address; + port = lib.toInt (lib.last addressParts); + in { + allowedTCPPorts = [ port ]; + }); + }; +} diff --git a/nix/package.nix b/nix/package.nix index f992e84..88908c2 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -9,7 +9,7 @@ buildGoModule { inherit pname src; version = "0.3.0"; - vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; + vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I="; subPackages = [ subPackage ];