Compare commits

...

9 Commits

Author SHA1 Message Date
4ae92b4f85 chore: migrate module path from git.t-juice.club to code.t-juice.club
Update Go module path and all import references for Gitea to Forgejo
host migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:48:25 +01:00
4276ffbda5 feat: add optional basic auth support for Loki client
Some Loki deployments (e.g., behind a reverse proxy or Grafana Cloud)
require HTTP Basic Authentication. This adds optional --loki-username
and --loki-password flags (and corresponding env vars) to the
lab-monitoring server, along with NixOS module options for secure
credential management via systemd LoadCredential.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:32:10 +01:00
aff058dcc0 chore: update flake.lock
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:22:08 +01:00
dcaeb1f517 chore: remove unused gotools and go-tools from devShell
Both are redundant: staticcheck is covered by golangci-lint, and
gotools (goimports, etc.) is covered by gopls and golangci-lint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:20:48 +01:00
fd40e73f1b feat: add package indexing to MCP index_revision tool
The options server's index_revision now also indexes packages when running
under nixpkgs-search, matching the CLI behavior. The packages server gets
its own index_revision tool for standalone package indexing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:12:08 +01:00
a0be405b76 Merge pull request 'feat: add git-explorer MCP server for read-only repository access' (#8) from feature/git-explorer into master
Reviewed-on: #8
2026-02-08 03:30:29 +00:00
75673974a2 feat: add git-explorer MCP server for read-only repository access
Implements a new MCP server that provides read-only access to git
repositories using go-git. Designed for deployment verification by
comparing deployed flake revisions against source repositories.

9 tools: resolve_ref, get_log, get_commit_info, get_diff_files,
get_file_at_commit, is_ancestor, commits_between, list_branches,
search_commits.

Includes CLI commands, NixOS module, and comprehensive tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 04:26:38 +01:00
98bad6c9ba chore: switch devShell from go_1_24 to default go
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:07:32 +01:00
d024f128b5 chore: update flake.lock
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:02:44 +01:00
34 changed files with 3298 additions and 98 deletions

View File

@@ -25,6 +25,13 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other serve
- 8 core tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences - 8 core tools: list/get alerts, search metrics, get metadata, PromQL query, list targets, list/create silences
- 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values - 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 - Configurable Prometheus, Alertmanager, and Loki URLs via flags or environment variables
- Optional basic auth for Loki (`LOKI_USERNAME`/`LOKI_PASSWORD`)
### 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: The nixpkgs/options/hm servers share a database-backed architecture:
- Full-text search across option/package names and descriptions - Full-text search across option/package names and descriptions
@@ -39,7 +46,7 @@ The nixpkgs/options/hm servers share a database-backed architecture:
- **Build System**: Nix flakes - **Build System**: Nix flakes
- **Databases**: PostgreSQL and SQLite (both fully supported) - **Databases**: PostgreSQL and SQLite (both fully supported)
- **Protocol**: MCP (Model Context Protocol) - JSON-RPC over STDIO or HTTP/SSE - **Protocol**: MCP (Model Context Protocol) - JSON-RPC over STDIO or HTTP/SSE
- **Module Path**: `git.t-juice.club/torjus/labmcp` - **Module Path**: `code.t-juice.club/torjus/labmcp`
## Project Status ## Project Status
@@ -62,8 +69,10 @@ labmcp/
│ │ └── main.go # NixOS options CLI (legacy) │ │ └── main.go # NixOS options CLI (legacy)
│ ├── hm-options/ │ ├── hm-options/
│ │ └── main.go # Home Manager options CLI │ │ └── main.go # Home Manager options CLI
── lab-monitoring/ ── lab-monitoring/
└── main.go # Prometheus/Alertmanager CLI └── main.go # Prometheus/Alertmanager CLI
│ └── git-explorer/
│ └── main.go # Git repository explorer CLI
├── internal/ ├── internal/
│ ├── database/ │ ├── database/
│ │ ├── interface.go # Store interface (options + packages) │ │ ├── interface.go # Store interface (options + packages)
@@ -96,18 +105,26 @@ labmcp/
│ │ ├── parser.go # nix-env JSON parsing │ │ ├── parser.go # nix-env JSON parsing
│ │ ├── types.go # Package types, channel aliases │ │ ├── types.go # Package types, channel aliases
│ │ └── *_test.go # Parser tests │ │ └── *_test.go # Parser tests
── monitoring/ ── monitoring/
├── types.go # Prometheus/Alertmanager/Loki API types ├── types.go # Prometheus/Alertmanager/Loki API types
├── prometheus.go # Prometheus HTTP client ├── prometheus.go # Prometheus HTTP client
├── alertmanager.go # Alertmanager HTTP client ├── alertmanager.go # Alertmanager HTTP client
├── loki.go # Loki 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 │ ├── handlers.go # MCP tool definitions + handlers
│ ├── format.go # Markdown formatting utilities │ ├── format.go # Markdown formatters
── *_test.go # Tests (httptest-based) ── validation.go # Path validation
│ └── *_test.go # Tests
├── nix/ ├── nix/
│ ├── module.nix # NixOS module for nixos-options │ ├── module.nix # NixOS module for nixos-options
│ ├── hm-options-module.nix # NixOS module for hm-options │ ├── hm-options-module.nix # NixOS module for hm-options
│ ├── lab-monitoring-module.nix # NixOS module for lab-monitoring │ ├── lab-monitoring-module.nix # NixOS module for lab-monitoring
│ ├── git-explorer-module.nix # NixOS module for git-explorer
│ └── package.nix # Parameterized Nix package │ └── package.nix # Parameterized Nix package
├── testdata/ ├── testdata/
│ └── options-sample.json # Test fixture │ └── options-sample.json # Test fixture
@@ -128,7 +145,7 @@ labmcp/
| `search_options` | Full-text search across option names and descriptions | | `search_options` | Full-text search across option names and descriptions |
| `get_option` | Get full details for a specific option with children | | `get_option` | Get full details for a specific option with children |
| `get_file` | Fetch source file contents from indexed repository | | `get_file` | Fetch source file contents from indexed repository |
| `index_revision` | Index a revision (by hash or channel name) | | `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
@@ -139,6 +156,7 @@ labmcp/
| `search_packages` | Full-text search across package names and descriptions | | `search_packages` | Full-text search across package names and descriptions |
| `get_package` | Get full details for a specific package by attr path | | `get_package` | Get full details for a specific package by attr path |
| `get_file` | Fetch source file contents from nixpkgs | | `get_file` | Fetch source file contents from nixpkgs |
| `index_revision` | Index a revision to make its packages searchable |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
@@ -158,6 +176,20 @@ labmcp/
| `list_labels` | List available label names from Loki (requires `LOKI_URL`) | | `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`) | | `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 ## Key Implementation Details
### Database ### Database
@@ -261,6 +293,20 @@ lab-monitoring labels # List Loki labels
lab-monitoring labels --values job # List values for a label 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 <ref> # Resolve ref to commit hash
git-explorer --repo /path log --limit 10 # Show commit log
git-explorer --repo /path show <ref> # Show commit details
git-explorer --repo /path diff <from> <to> # Files changed between commits
git-explorer --repo /path cat <ref> <path> # File contents at commit
git-explorer --repo /path branches # List branches
git-explorer --repo /path search <query> # Search commit messages
git-explorer --version # Show version
```
### Channel Aliases ### Channel Aliases
**nixpkgs-search/nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc. **nixpkgs-search/nixos-options**: `nixos-unstable`, `nixos-stable`, `nixos-24.11`, `nixos-24.05`, etc.
@@ -314,6 +360,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`) - **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`) - **nixos-options**: `cmd/nixos-options/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`)
- **hm-options**: `cmd/hm-options/main.go` + `internal/mcp/server.go` (`DefaultHomeManagerConfig`) - **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) - **nix/package.nix**: Shared across all packages (bump to highest version when any package changes)
### User Preferences ### User Preferences
@@ -341,6 +388,7 @@ nix build .#nixpkgs-search
nix build .#nixos-options nix build .#nixos-options
nix build .#hm-options nix build .#hm-options
nix build .#lab-monitoring nix build .#lab-monitoring
nix build .#git-explorer
# Run directly # Run directly
nix run .#nixpkgs-search -- options serve nix run .#nixpkgs-search -- options serve
@@ -349,6 +397,7 @@ nix run .#nixpkgs-search -- index nixos-unstable
nix run .#hm-options -- serve nix run .#hm-options -- serve
nix run .#hm-options -- index hm-unstable nix run .#hm-options -- index hm-unstable
nix run .#lab-monitoring -- serve nix run .#lab-monitoring -- serve
nix run .#git-explorer -- --repo . serve
``` ```
### Indexing Performance ### Indexing Performance

164
README.md
View File

@@ -27,6 +27,20 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs from your monitorin
- Manage alert silences - Manage alert silences
- Query logs via LogQL (when Loki is configured) - 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 ### NixOS Options (`nixos-options`) - Legacy
Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search. Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-search` instead, which includes this functionality plus package search.
@@ -45,22 +59,25 @@ Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-se
```bash ```bash
# Build the packages # Build the packages
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search nix build git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options nix build git+https://code.t-juice.club/torjus/labmcp#hm-options
nix build git+https://git.t-juice.club/torjus/labmcp#lab-monitoring nix build git+https://code.t-juice.club/torjus/labmcp#lab-monitoring
nix build git+https://code.t-juice.club/torjus/labmcp#git-explorer
# Or run directly # Or run directly
nix run git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help nix run git+https://code.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://code.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://code.t-juice.club/torjus/labmcp#lab-monitoring -- --help
nix run git+https://code.t-juice.club/torjus/labmcp#git-explorer -- --help
``` ```
### From Source ### From Source
```bash ```bash
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest go install code.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest go install code.t-juice.club/torjus/labmcp/cmd/hm-options@latest
go install git.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest go install code.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
go install code.t-juice.club/torjus/labmcp/cmd/git-explorer@latest
``` ```
## Usage ## Usage
@@ -99,7 +116,16 @@ Configure in your MCP client (e.g., Claude Desktop):
"env": { "env": {
"PROMETHEUS_URL": "http://prometheus.example.com:9090", "PROMETHEUS_URL": "http://prometheus.example.com:9090",
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093", "ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
"LOKI_URL": "http://loki.example.com:3100" "LOKI_URL": "http://loki.example.com:3100",
"LOKI_USERNAME": "optional-username",
"LOKI_PASSWORD": "optional-password"
}
},
"git-explorer": {
"command": "git-explorer",
"args": ["serve"],
"env": {
"GIT_REPO_PATH": "/path/to/your/repo"
} }
} }
} }
@@ -113,33 +139,40 @@ Alternatively, if you have Nix installed, you can use the flake directly without
"mcpServers": { "mcpServers": {
"nixpkgs-options": { "nixpkgs-options": {
"command": "nix", "command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"], "args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "options", "serve"],
"env": { "env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db" "NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
} }
}, },
"nixpkgs-packages": { "nixpkgs-packages": {
"command": "nix", "command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"], "args": ["run", "git+https://code.t-juice.club/torjus/labmcp#nixpkgs-search", "--", "packages", "serve"],
"env": { "env": {
"NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db" "NIXPKGS_SEARCH_DATABASE": "sqlite:///path/to/nixpkgs-search.db"
} }
}, },
"hm-options": { "hm-options": {
"command": "nix", "command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#hm-options", "--", "serve"], "args": ["run", "git+https://code.t-juice.club/torjus/labmcp#hm-options", "--", "serve"],
"env": { "env": {
"HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db" "HM_OPTIONS_DATABASE": "sqlite:///path/to/hm-options.db"
} }
}, },
"lab-monitoring": { "lab-monitoring": {
"command": "nix", "command": "nix",
"args": ["run", "git+https://git.t-juice.club/torjus/labmcp#lab-monitoring", "--", "serve"], "args": ["run", "git+https://code.t-juice.club/torjus/labmcp#lab-monitoring", "--", "serve"],
"env": { "env": {
"PROMETHEUS_URL": "http://prometheus.example.com:9090", "PROMETHEUS_URL": "http://prometheus.example.com:9090",
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093", "ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
"LOKI_URL": "http://loki.example.com:3100" "LOKI_URL": "http://loki.example.com:3100"
} }
},
"git-explorer": {
"command": "nix",
"args": ["run", "git+https://code.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"],
"env": {
"GIT_REPO_PATH": "/path/to/your/repo"
}
} }
} }
} }
@@ -155,6 +188,7 @@ nixpkgs-search options serve --transport http
nixpkgs-search packages serve --transport http nixpkgs-search packages serve --transport http
hm-options serve --transport http hm-options serve --transport http
lab-monitoring serve --transport http lab-monitoring serve --transport http
git-explorer serve --transport http
# Custom address and CORS configuration # Custom address and CORS configuration
nixpkgs-search options serve --transport http \ nixpkgs-search options serve --transport http \
@@ -271,6 +305,35 @@ lab-monitoring labels
lab-monitoring labels --values job 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:** **Delete an indexed revision:**
```bash ```bash
@@ -290,6 +353,8 @@ hm-options delete release-23.11
| `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` | | `PROMETHEUS_URL` | Prometheus base URL for lab-monitoring | `http://localhost:9090` |
| `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` | | `ALERTMANAGER_URL` | Alertmanager base URL for lab-monitoring | `http://localhost:9093` |
| `LOKI_URL` | Loki base URL for lab-monitoring (optional, enables log tools) | *(none)* | | `LOKI_URL` | Loki base URL for lab-monitoring (optional, enables log tools) | *(none)* |
| `LOKI_USERNAME` | Username for Loki basic auth (optional) | *(none)* |
| `LOKI_PASSWORD` | Password for Loki basic auth (optional) | *(none)* |
### Database Connection Strings ### Database Connection Strings
@@ -323,7 +388,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
| `search_options` | Search for options by name or description | | `search_options` | Search for options by name or description |
| `get_option` | Get full details for a specific option | | `get_option` | Get full details for a specific option |
| `get_file` | Fetch source file contents from the repository | | `get_file` | Fetch source file contents from the repository |
| `index_revision` | Index a revision | | `index_revision` | Index a revision (options, files, and packages for nixpkgs) |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
@@ -334,7 +399,7 @@ hm-options -d "sqlite://my.db" index hm-unstable
| `search_packages` | Search for packages by name or description | | `search_packages` | Search for packages by name or description |
| `get_package` | Get full details for a specific package | | `get_package` | Get full details for a specific package |
| `get_file` | Fetch source file contents from nixpkgs | | `get_file` | Fetch source file contents from nixpkgs |
| `index_revision` | Index a revision | | `index_revision` | Index a revision to make its packages searchable |
| `list_revisions` | List all indexed revisions | | `list_revisions` | List all indexed revisions |
| `delete_revision` | Delete an indexed revision | | `delete_revision` | Delete an indexed revision |
@@ -354,6 +419,20 @@ hm-options -d "sqlite://my.db" index hm-unstable
| `list_labels` | List available label names from Loki (requires `LOKI_URL`) | | `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`) | | `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
NixOS modules are provided for running the MCP servers as systemd services. NixOS modules are provided for running the MCP servers as systemd services.
@@ -364,7 +443,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix ```nix
{ {
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp"; inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: { outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -401,7 +480,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix ```nix
{ {
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp"; inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: { outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -424,7 +503,7 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
```nix ```nix
{ {
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp"; inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: { outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -445,11 +524,34 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
} }
``` ```
### git-explorer
```nix
{
inputs.labmcp.url = "git+https://code.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) ### nixos-options (Legacy)
```nix ```nix
{ {
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp"; inputs.labmcp.url = "git+https://code.t-juice.club/torjus/labmcp";
outputs = { self, nixpkgs, labmcp }: { outputs = { self, nixpkgs, labmcp }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
@@ -507,6 +609,8 @@ Both `options.http` and `packages.http` also support:
| `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL | | `prometheusUrl` | string | `"http://localhost:9090"` | Prometheus base URL |
| `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL | | `alertmanagerUrl` | string | `"http://localhost:9093"` | Alertmanager base URL |
| `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) | | `lokiUrl` | nullOr string | `null` | Loki base URL (enables log query tools when set) |
| `lokiUsername` | nullOr string | `null` | Username for Loki basic authentication |
| `lokiPasswordFile` | nullOr path | `null` | Path to file containing Loki password (uses systemd `LoadCredential`) |
| `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) | | `enableSilences` | bool | `false` | Enable the create_silence tool (write operation) |
| `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address | | `http.address` | string | `"127.0.0.1:8084"` | HTTP listen address |
| `http.endpoint` | string | `"/mcp"` | HTTP endpoint path | | `http.endpoint` | string | `"/mcp"` | HTTP endpoint path |
@@ -519,6 +623,25 @@ Both `options.http` and `packages.http` also support:
The lab-monitoring module uses `DynamicUser=true`, so no separate user/group configuration is needed. 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) #### hm-options-mcp / nixos-options-mcp (Legacy)
| Option | Type | Default | Description | | Option | Type | Default | Description |
@@ -579,6 +702,7 @@ go test -bench=. ./internal/database/...
go build ./cmd/nixpkgs-search go build ./cmd/nixpkgs-search
go build ./cmd/hm-options go build ./cmd/hm-options
go build ./cmd/lab-monitoring go build ./cmd/lab-monitoring
go build ./cmd/git-explorer
``` ```
## License ## License

459
cmd/git-explorer/main.go Normal file
View File

@@ -0,0 +1,459 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/urfave/cli/v2"
"code.t-juice.club/torjus/labmcp/internal/gitexplorer"
"code.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: "<ref>",
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: "<ref>",
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: "<from-ref> <to-ref>",
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: "<ref> <path>",
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: "<query>",
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
}

View File

@@ -12,10 +12,10 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/homemanager" "code.t-juice.club/torjus/labmcp/internal/homemanager"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/options" "code.t-juice.club/torjus/labmcp/internal/options"
) )
const ( const (

View File

@@ -11,11 +11,11 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/monitoring" "code.t-juice.club/torjus/labmcp/internal/monitoring"
) )
const version = "0.3.0" const version = "0.3.1"
func main() { func main() {
app := &cli.App{ app := &cli.App{
@@ -40,6 +40,16 @@ func main() {
Usage: "Loki base URL (optional, enables log query tools)", Usage: "Loki base URL (optional, enables log query tools)",
EnvVars: []string{"LOKI_URL"}, EnvVars: []string{"LOKI_URL"},
}, },
&cli.StringFlag{
Name: "loki-username",
Usage: "Username for Loki basic auth",
EnvVars: []string{"LOKI_USERNAME"},
},
&cli.StringFlag{
Name: "loki-password",
Usage: "Password for Loki basic auth",
EnvVars: []string{"LOKI_PASSWORD"},
},
}, },
Commands: []*cli.Command{ Commands: []*cli.Command{
serveCommand(), serveCommand(),
@@ -189,7 +199,11 @@ func runServe(c *cli.Context) error {
var loki *monitoring.LokiClient var loki *monitoring.LokiClient
if lokiURL := c.String("loki-url"); lokiURL != "" { if lokiURL := c.String("loki-url"); lokiURL != "" {
loki = monitoring.NewLokiClient(lokiURL) loki = monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
} }
config.InstructionsFunc = func() string { config.InstructionsFunc = func() string {
@@ -432,7 +446,11 @@ func runLogs(c *cli.Context, logql string) error {
} }
ctx := context.Background() ctx := context.Background()
loki := monitoring.NewLokiClient(lokiURL) loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
now := time.Now() now := time.Now()
start, err := parseCLITime(c.String("start"), now.Add(-time.Hour)) start, err := parseCLITime(c.String("start"), now.Add(-time.Hour))
@@ -487,7 +505,11 @@ func runLabels(c *cli.Context) error {
} }
ctx := context.Background() ctx := context.Background()
loki := monitoring.NewLokiClient(lokiURL) loki := monitoring.NewLokiClient(monitoring.LokiClientOptions{
BaseURL: lokiURL,
Username: c.String("loki-username"),
Password: c.String("loki-password"),
})
if label := c.String("values"); label != "" { if label := c.String("values"); label != "" {
values, err := loki.LabelValues(ctx, label) values, err := loki.LabelValues(ctx, label)

View File

@@ -12,9 +12,9 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/nixos" "code.t-juice.club/torjus/labmcp/internal/nixos"
) )
const ( const (

View File

@@ -12,15 +12,15 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
"git.t-juice.club/torjus/labmcp/internal/nixos" "code.t-juice.club/torjus/labmcp/internal/nixos"
"git.t-juice.club/torjus/labmcp/internal/packages" "code.t-juice.club/torjus/labmcp/internal/packages"
) )
const ( const (
defaultDatabase = "sqlite://nixpkgs-search.db" defaultDatabase = "sqlite://nixpkgs-search.db"
version = "0.3.0" version = "0.4.0"
) )
func main() { func main() {
@@ -310,7 +310,8 @@ func runOptionsServe(c *cli.Context) error {
server := mcp.NewServer(store, logger, config) server := mcp.NewServer(store, logger, config)
indexer := nixos.NewIndexer(store) indexer := nixos.NewIndexer(store)
server.RegisterHandlers(indexer) pkgIndexer := packages.NewIndexer(store)
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
transport := c.String("transport") transport := c.String("transport")
switch transport { switch transport {

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770115704, "lastModified": 1770841267,
"narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -40,6 +40,13 @@
mainProgram = "lab-monitoring"; mainProgram = "lab-monitoring";
description = "MCP server for Prometheus and Alertmanager 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; default = self.packages.${system}.nixpkgs-search;
}); });
@@ -50,10 +57,8 @@
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
go_1_24 go
gopls gopls
gotools
go-tools
golangci-lint golangci-lint
govulncheck govulncheck
postgresql postgresql
@@ -84,6 +89,10 @@
imports = [ ./nix/lab-monitoring-module.nix ]; imports = [ ./nix/lab-monitoring-module.nix ];
services.lab-monitoring.package = lib.mkDefault self.packages.${pkgs.system}.lab-monitoring; 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; default = self.nixosModules.nixpkgs-search-mcp;
}; };
}; };

23
go.mod
View File

@@ -1,24 +1,43 @@
module git.t-juice.club/torjus/labmcp module code.t-juice.club/torjus/labmcp
go 1.24 go 1.24
require ( require (
github.com/go-git/go-git/v5 v5.16.4
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v2 v2.27.5
modernc.org/sqlite v1.34.4 modernc.org/sqlite v1.34.4
) )
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/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/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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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 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/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.6.0 // indirect

102
go.sum
View File

@@ -1,36 +1,134 @@
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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
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 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
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-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= 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/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 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 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/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= 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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=

View File

@@ -0,0 +1,570 @@
package gitexplorer
import (
"errors"
"fmt"
"io"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
var (
// ErrNotFound is returned when a ref, commit, or file is not found.
ErrNotFound = errors.New("not found")
// ErrFileTooLarge is returned when a file exceeds the size limit.
ErrFileTooLarge = errors.New("file too large")
)
// GitClient provides read-only access to a git repository.
type GitClient struct {
repo *git.Repository
defaultRemote string
}
// NewGitClient opens a git repository at the given path.
func NewGitClient(repoPath string, defaultRemote string) (*GitClient, error) {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
if defaultRemote == "" {
defaultRemote = "origin"
}
return &GitClient{
repo: repo,
defaultRemote: defaultRemote,
}, nil
}
// ResolveRef resolves a ref (branch, tag, or commit hash) to a commit hash.
func (c *GitClient) ResolveRef(ref string) (*ResolveResult, error) {
result := &ResolveResult{Ref: ref}
// Try to resolve as a revision
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
}
result.Commit = hash.String()
// Determine the type of ref
// Check if it's a branch
if _, err := c.repo.Reference(plumbing.NewBranchReferenceName(ref), true); err == nil {
result.Type = "branch"
return result, nil
}
// Check if it's a remote branch
if _, err := c.repo.Reference(plumbing.NewRemoteReferenceName(c.defaultRemote, ref), true); err == nil {
result.Type = "branch"
return result, nil
}
// Check if it's a tag
if _, err := c.repo.Reference(plumbing.NewTagReferenceName(ref), true); err == nil {
result.Type = "tag"
return result, nil
}
// Default to commit
result.Type = "commit"
return result, nil
}
// GetLog returns the commit log starting from the given ref.
func (c *GitClient) GetLog(ref string, limit int, author string, since string, path string) ([]LogEntry, error) {
if limit <= 0 || limit > Limits.MaxLogEntries {
limit = Limits.MaxLogEntries
}
// Resolve the ref to a commit hash
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
}
logOpts := &git.LogOptions{
From: *hash,
}
// Add path filter if specified
if path != "" {
if err := ValidatePath(path); err != nil {
return nil, err
}
logOpts.PathFilter = func(p string) bool {
return strings.HasPrefix(p, path) || p == path
}
}
iter, err := c.repo.Log(logOpts)
if err != nil {
return nil, fmt.Errorf("failed to get log: %w", err)
}
defer iter.Close()
var entries []LogEntry
err = iter.ForEach(func(commit *object.Commit) error {
// Apply author filter
if author != "" {
authorLower := strings.ToLower(author)
if !strings.Contains(strings.ToLower(commit.Author.Name), authorLower) &&
!strings.Contains(strings.ToLower(commit.Author.Email), authorLower) {
return nil
}
}
// Apply since filter
if since != "" {
// Parse since as a ref and check if this commit is reachable
sinceHash, err := c.repo.ResolveRevision(plumbing.Revision(since))
if err == nil {
// Stop if we've reached the since commit
if commit.Hash == *sinceHash {
return io.EOF
}
}
}
// Get first line of commit message as subject
subject := commit.Message
if idx := strings.Index(subject, "\n"); idx != -1 {
subject = subject[:idx]
}
subject = strings.TrimSpace(subject)
entries = append(entries, LogEntry{
Hash: commit.Hash.String(),
ShortHash: commit.Hash.String()[:7],
Author: commit.Author.Name,
Email: commit.Author.Email,
Date: commit.Author.When,
Subject: subject,
})
if len(entries) >= limit {
return io.EOF
}
return nil
})
// io.EOF is expected when we hit the limit
if err != nil && err != io.EOF {
return nil, fmt.Errorf("failed to iterate log: %w", err)
}
return entries, nil
}
// GetCommitInfo returns full details about a commit.
func (c *GitClient) GetCommitInfo(ref string, includeStats bool) (*CommitInfo, error) {
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
}
commit, err := c.repo.CommitObject(*hash)
if err != nil {
return nil, fmt.Errorf("failed to get commit: %w", err)
}
info := &CommitInfo{
Hash: commit.Hash.String(),
Author: commit.Author.Name,
Email: commit.Author.Email,
Date: commit.Author.When,
Committer: commit.Committer.Name,
CommitDate: commit.Committer.When,
Message: commit.Message,
}
for _, parent := range commit.ParentHashes {
info.Parents = append(info.Parents, parent.String())
}
if includeStats {
stats, err := c.getCommitStats(commit)
if err == nil {
info.Stats = stats
}
}
return info, nil
}
// getCommitStats computes file change statistics for a commit.
func (c *GitClient) getCommitStats(commit *object.Commit) (*FileStats, error) {
stats, err := commit.Stats()
if err != nil {
return nil, err
}
result := &FileStats{
FilesChanged: len(stats),
}
for _, s := range stats {
result.Additions += s.Addition
result.Deletions += s.Deletion
}
return result, nil
}
// GetDiffFiles returns the files changed between two commits.
func (c *GitClient) GetDiffFiles(fromRef, toRef string) (*DiffResult, error) {
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
}
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
}
fromCommit, err := c.repo.CommitObject(*fromHash)
if err != nil {
return nil, fmt.Errorf("failed to get from commit: %w", err)
}
toCommit, err := c.repo.CommitObject(*toHash)
if err != nil {
return nil, fmt.Errorf("failed to get to commit: %w", err)
}
patch, err := fromCommit.Patch(toCommit)
if err != nil {
return nil, fmt.Errorf("failed to get patch: %w", err)
}
result := &DiffResult{
FromCommit: fromHash.String(),
ToCommit: toHash.String(),
}
for i, filePatch := range patch.FilePatches() {
if i >= Limits.MaxDiffFiles {
break
}
from, to := filePatch.Files()
df := DiffFile{}
// Determine status and paths
switch {
case from == nil && to != nil:
df.Status = "added"
df.Path = to.Path()
case from != nil && to == nil:
df.Status = "deleted"
df.Path = from.Path()
case from != nil && to != nil && from.Path() != to.Path():
df.Status = "renamed"
df.Path = to.Path()
df.OldPath = from.Path()
default:
df.Status = "modified"
if to != nil {
df.Path = to.Path()
} else if from != nil {
df.Path = from.Path()
}
}
// Count additions and deletions
for _, chunk := range filePatch.Chunks() {
content := chunk.Content()
lines := strings.Split(content, "\n")
switch chunk.Type() {
case 1: // Add
df.Additions += len(lines)
case 2: // Delete
df.Deletions += len(lines)
}
}
result.Files = append(result.Files, df)
}
return result, nil
}
// GetFileAtCommit returns the content of a file at a specific commit.
func (c *GitClient) GetFileAtCommit(ref, path string) (*FileContent, error) {
if err := ValidatePath(path); err != nil {
return nil, err
}
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
}
commit, err := c.repo.CommitObject(*hash)
if err != nil {
return nil, fmt.Errorf("failed to get commit: %w", err)
}
file, err := commit.File(path)
if err != nil {
return nil, fmt.Errorf("%w: file '%s'", ErrNotFound, path)
}
// Check file size
if file.Size > Limits.MaxFileContent {
return nil, fmt.Errorf("%w: %d bytes (max %d)", ErrFileTooLarge, file.Size, Limits.MaxFileContent)
}
content, err := file.Contents()
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return &FileContent{
Path: path,
Commit: hash.String(),
Size: file.Size,
Content: content,
}, nil
}
// IsAncestor checks if ancestor is an ancestor of descendant.
func (c *GitClient) IsAncestor(ancestorRef, descendantRef string) (*AncestryResult, error) {
ancestorHash, err := c.repo.ResolveRevision(plumbing.Revision(ancestorRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ancestorRef)
}
descendantHash, err := c.repo.ResolveRevision(plumbing.Revision(descendantRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, descendantRef)
}
ancestorCommit, err := c.repo.CommitObject(*ancestorHash)
if err != nil {
return nil, fmt.Errorf("failed to get ancestor commit: %w", err)
}
descendantCommit, err := c.repo.CommitObject(*descendantHash)
if err != nil {
return nil, fmt.Errorf("failed to get descendant commit: %w", err)
}
isAncestor, err := ancestorCommit.IsAncestor(descendantCommit)
if err != nil {
return nil, fmt.Errorf("failed to check ancestry: %w", err)
}
return &AncestryResult{
Ancestor: ancestorHash.String(),
Descendant: descendantHash.String(),
IsAncestor: isAncestor,
}, nil
}
// CommitsBetween returns commits between two refs (exclusive of from, inclusive of to).
func (c *GitClient) CommitsBetween(fromRef, toRef string, limit int) (*CommitRange, error) {
if limit <= 0 || limit > Limits.MaxLogEntries {
limit = Limits.MaxLogEntries
}
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
}
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
}
iter, err := c.repo.Log(&git.LogOptions{
From: *toHash,
})
if err != nil {
return nil, fmt.Errorf("failed to get log: %w", err)
}
defer iter.Close()
result := &CommitRange{
FromCommit: fromHash.String(),
ToCommit: toHash.String(),
}
err = iter.ForEach(func(commit *object.Commit) error {
// Stop when we reach the from commit (exclusive)
if commit.Hash == *fromHash {
return io.EOF
}
subject := commit.Message
if idx := strings.Index(subject, "\n"); idx != -1 {
subject = subject[:idx]
}
subject = strings.TrimSpace(subject)
result.Commits = append(result.Commits, LogEntry{
Hash: commit.Hash.String(),
ShortHash: commit.Hash.String()[:7],
Author: commit.Author.Name,
Email: commit.Author.Email,
Date: commit.Author.When,
Subject: subject,
})
if len(result.Commits) >= limit {
return io.EOF
}
return nil
})
if err != nil && err != io.EOF {
return nil, fmt.Errorf("failed to iterate log: %w", err)
}
result.Count = len(result.Commits)
return result, nil
}
// ListBranches returns all branches in the repository.
func (c *GitClient) ListBranches(includeRemote bool) (*BranchList, error) {
result := &BranchList{}
// Get HEAD to determine current branch
head, err := c.repo.Head()
if err == nil && head.Name().IsBranch() {
result.Current = head.Name().Short()
}
// List local branches
branchIter, err := c.repo.Branches()
if err != nil {
return nil, fmt.Errorf("failed to list branches: %w", err)
}
err = branchIter.ForEach(func(ref *plumbing.Reference) error {
if len(result.Branches) >= Limits.MaxBranches {
return io.EOF
}
branch := Branch{
Name: ref.Name().Short(),
Commit: ref.Hash().String(),
IsRemote: false,
IsHead: ref.Name().Short() == result.Current,
}
result.Branches = append(result.Branches, branch)
return nil
})
if err != nil && err != io.EOF {
return nil, fmt.Errorf("failed to iterate branches: %w", err)
}
// List remote branches if requested
if includeRemote {
refs, err := c.repo.References()
if err != nil {
return nil, fmt.Errorf("failed to list references: %w", err)
}
err = refs.ForEach(func(ref *plumbing.Reference) error {
if len(result.Branches) >= Limits.MaxBranches {
return io.EOF
}
if ref.Name().IsRemote() {
branch := Branch{
Name: ref.Name().Short(),
Commit: ref.Hash().String(),
IsRemote: true,
IsHead: false,
}
result.Branches = append(result.Branches, branch)
}
return nil
})
if err != nil && err != io.EOF {
return nil, fmt.Errorf("failed to iterate references: %w", err)
}
}
result.Total = len(result.Branches)
return result, nil
}
// SearchCommits searches commit messages for a pattern.
func (c *GitClient) SearchCommits(ref, query string, limit int) (*SearchResult, error) {
if limit <= 0 || limit > Limits.MaxSearchResult {
limit = Limits.MaxSearchResult
}
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
}
iter, err := c.repo.Log(&git.LogOptions{
From: *hash,
})
if err != nil {
return nil, fmt.Errorf("failed to get log: %w", err)
}
defer iter.Close()
result := &SearchResult{
Query: query,
}
queryLower := strings.ToLower(query)
// We need to scan more commits to find matches
scanned := 0
maxScan := limit * 100 // Scan up to 100x the limit
err = iter.ForEach(func(commit *object.Commit) error {
scanned++
if scanned > maxScan {
return io.EOF
}
// Search in message (case-insensitive)
if !strings.Contains(strings.ToLower(commit.Message), queryLower) {
return nil
}
subject := commit.Message
if idx := strings.Index(subject, "\n"); idx != -1 {
subject = subject[:idx]
}
subject = strings.TrimSpace(subject)
result.Commits = append(result.Commits, LogEntry{
Hash: commit.Hash.String(),
ShortHash: commit.Hash.String()[:7],
Author: commit.Author.Name,
Email: commit.Author.Email,
Date: commit.Author.When,
Subject: subject,
})
if len(result.Commits) >= limit {
return io.EOF
}
return nil
})
if err != nil && err != io.EOF {
return nil, fmt.Errorf("failed to search commits: %w", err)
}
result.Count = len(result.Commits)
return result, nil
}

View File

@@ -0,0 +1,446 @@
package gitexplorer
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
// createTestRepo creates a temporary git repository with some commits for testing.
func createTestRepo(t *testing.T) (string, func()) {
t.Helper()
dir, err := os.MkdirTemp("", "gitexplorer-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
cleanup := func() {
_ = os.RemoveAll(dir)
}
repo, err := git.PlainInit(dir, false)
if err != nil {
cleanup()
t.Fatalf("failed to init repo: %v", err)
}
wt, err := repo.Worktree()
if err != nil {
cleanup()
t.Fatalf("failed to get worktree: %v", err)
}
// Create initial file and commit
readme := filepath.Join(dir, "README.md")
if err := os.WriteFile(readme, []byte("# Test Repo\n"), 0644); err != nil {
cleanup()
t.Fatalf("failed to write README: %v", err)
}
if _, err := wt.Add("README.md"); err != nil {
cleanup()
t.Fatalf("failed to add README: %v", err)
}
sig := &object.Signature{
Name: "Test User",
Email: "test@example.com",
When: time.Now().Add(-2 * time.Hour),
}
_, err = wt.Commit("Initial commit", &git.CommitOptions{Author: sig})
if err != nil {
cleanup()
t.Fatalf("failed to create initial commit: %v", err)
}
// Create a second file and commit
subdir := filepath.Join(dir, "src")
if err := os.MkdirAll(subdir, 0755); err != nil {
cleanup()
t.Fatalf("failed to create subdir: %v", err)
}
mainFile := filepath.Join(subdir, "main.go")
if err := os.WriteFile(mainFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil {
cleanup()
t.Fatalf("failed to write main.go: %v", err)
}
if _, err := wt.Add("src/main.go"); err != nil {
cleanup()
t.Fatalf("failed to add main.go: %v", err)
}
sig.When = time.Now().Add(-1 * time.Hour)
_, err = wt.Commit("Add main.go", &git.CommitOptions{Author: sig})
if err != nil {
cleanup()
t.Fatalf("failed to create second commit: %v", err)
}
// Update README and commit
if err := os.WriteFile(readme, []byte("# Test Repo\n\nThis is a test repository.\n"), 0644); err != nil {
cleanup()
t.Fatalf("failed to update README: %v", err)
}
if _, err := wt.Add("README.md"); err != nil {
cleanup()
t.Fatalf("failed to add updated README: %v", err)
}
sig.When = time.Now()
_, err = wt.Commit("Update README", &git.CommitOptions{Author: sig})
if err != nil {
cleanup()
t.Fatalf("failed to create third commit: %v", err)
}
return dir, cleanup
}
func TestNewGitClient(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
if client == nil {
t.Fatal("client is nil")
}
if client.defaultRemote != "origin" {
t.Errorf("defaultRemote = %q, want %q", client.defaultRemote, "origin")
}
// Test with invalid path
_, err = NewGitClient("/nonexistent/path", "")
if err == nil {
t.Error("expected error for nonexistent path")
}
}
func TestResolveRef(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
// Test resolving HEAD
result, err := client.ResolveRef("HEAD")
if err != nil {
t.Fatalf("ResolveRef(HEAD) failed: %v", err)
}
if result.Commit == "" {
t.Error("commit hash is empty")
}
// Test resolving master branch
result, err = client.ResolveRef("master")
if err != nil {
t.Fatalf("ResolveRef(master) failed: %v", err)
}
if result.Type != "branch" {
t.Errorf("type = %q, want %q", result.Type, "branch")
}
// Test resolving invalid ref
_, err = client.ResolveRef("nonexistent")
if err == nil {
t.Error("expected error for nonexistent ref")
}
}
func TestGetLog(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
// Get full log
entries, err := client.GetLog("HEAD", 10, "", "", "")
if err != nil {
t.Fatalf("GetLog failed: %v", err)
}
if len(entries) != 3 {
t.Errorf("got %d entries, want 3", len(entries))
}
// Check order (newest first)
if entries[0].Subject != "Update README" {
t.Errorf("first entry subject = %q, want %q", entries[0].Subject, "Update README")
}
// Test with limit
entries, err = client.GetLog("HEAD", 1, "", "", "")
if err != nil {
t.Fatalf("GetLog with limit failed: %v", err)
}
if len(entries) != 1 {
t.Errorf("got %d entries, want 1", len(entries))
}
// Test with author filter
entries, err = client.GetLog("HEAD", 10, "Test User", "", "")
if err != nil {
t.Fatalf("GetLog with author failed: %v", err)
}
if len(entries) != 3 {
t.Errorf("got %d entries, want 3", len(entries))
}
entries, err = client.GetLog("HEAD", 10, "nonexistent", "", "")
if err != nil {
t.Fatalf("GetLog with nonexistent author failed: %v", err)
}
if len(entries) != 0 {
t.Errorf("got %d entries, want 0", len(entries))
}
// Test with path filter
entries, err = client.GetLog("HEAD", 10, "", "", "src")
if err != nil {
t.Fatalf("GetLog with path failed: %v", err)
}
if len(entries) != 1 {
t.Errorf("got %d entries, want 1 (only src/main.go commit)", len(entries))
}
}
func TestGetCommitInfo(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
info, err := client.GetCommitInfo("HEAD", true)
if err != nil {
t.Fatalf("GetCommitInfo failed: %v", err)
}
if info.Author != "Test User" {
t.Errorf("author = %q, want %q", info.Author, "Test User")
}
if info.Email != "test@example.com" {
t.Errorf("email = %q, want %q", info.Email, "test@example.com")
}
if len(info.Parents) != 1 {
t.Errorf("parents = %d, want 1", len(info.Parents))
}
if info.Stats == nil {
t.Error("stats is nil")
}
// Test without stats
info, err = client.GetCommitInfo("HEAD", false)
if err != nil {
t.Fatalf("GetCommitInfo without stats failed: %v", err)
}
if info.Stats != nil {
t.Error("stats should be nil")
}
}
func TestGetDiffFiles(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
result, err := client.GetDiffFiles("HEAD~2", "HEAD")
if err != nil {
t.Fatalf("GetDiffFiles failed: %v", err)
}
if len(result.Files) < 1 {
t.Error("expected at least one changed file")
}
// Check that we have the expected files
foundReadme := false
foundMain := false
for _, f := range result.Files {
if f.Path == "README.md" {
foundReadme = true
}
if f.Path == "src/main.go" {
foundMain = true
}
}
if !foundReadme {
t.Error("expected README.md in diff")
}
if !foundMain {
t.Error("expected src/main.go in diff")
}
}
func TestGetFileAtCommit(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
content, err := client.GetFileAtCommit("HEAD", "README.md")
if err != nil {
t.Fatalf("GetFileAtCommit failed: %v", err)
}
if content.Path != "README.md" {
t.Errorf("path = %q, want %q", content.Path, "README.md")
}
if content.Content == "" {
t.Error("content is empty")
}
// Test nested file
content, err = client.GetFileAtCommit("HEAD", "src/main.go")
if err != nil {
t.Fatalf("GetFileAtCommit for nested file failed: %v", err)
}
if content.Path != "src/main.go" {
t.Errorf("path = %q, want %q", content.Path, "src/main.go")
}
// Test nonexistent file
_, err = client.GetFileAtCommit("HEAD", "nonexistent.txt")
if err == nil {
t.Error("expected error for nonexistent file")
}
// Test path traversal
_, err = client.GetFileAtCommit("HEAD", "../../../etc/passwd")
if err == nil {
t.Error("expected error for path traversal")
}
}
func TestIsAncestor(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
// First commit is ancestor of HEAD
result, err := client.IsAncestor("HEAD~2", "HEAD")
if err != nil {
t.Fatalf("IsAncestor failed: %v", err)
}
if !result.IsAncestor {
t.Error("HEAD~2 should be ancestor of HEAD")
}
// HEAD is not ancestor of first commit
result, err = client.IsAncestor("HEAD", "HEAD~2")
if err != nil {
t.Fatalf("IsAncestor failed: %v", err)
}
if result.IsAncestor {
t.Error("HEAD should not be ancestor of HEAD~2")
}
}
func TestCommitsBetween(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
result, err := client.CommitsBetween("HEAD~2", "HEAD", 10)
if err != nil {
t.Fatalf("CommitsBetween failed: %v", err)
}
// Should have 2 commits (HEAD~1 and HEAD, exclusive of HEAD~2)
if result.Count != 2 {
t.Errorf("count = %d, want 2", result.Count)
}
}
func TestListBranches(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
result, err := client.ListBranches(false)
if err != nil {
t.Fatalf("ListBranches failed: %v", err)
}
if result.Total < 1 {
t.Error("expected at least one branch")
}
foundMaster := false
for _, b := range result.Branches {
if b.Name == "master" {
foundMaster = true
if !b.IsHead {
t.Error("master should be HEAD")
}
}
}
if !foundMaster {
t.Error("expected master branch")
}
}
func TestSearchCommits(t *testing.T) {
repoPath, cleanup := createTestRepo(t)
defer cleanup()
client, err := NewGitClient(repoPath, "")
if err != nil {
t.Fatalf("NewGitClient failed: %v", err)
}
result, err := client.SearchCommits("HEAD", "README", 10)
if err != nil {
t.Fatalf("SearchCommits failed: %v", err)
}
if result.Count < 1 {
t.Error("expected at least one match for 'README'")
}
// Search with no matches
result, err = client.SearchCommits("HEAD", "nonexistent-query-xyz", 10)
if err != nil {
t.Fatalf("SearchCommits for no match failed: %v", err)
}
if result.Count != 0 {
t.Errorf("count = %d, want 0", result.Count)
}
}

View File

@@ -0,0 +1,195 @@
package gitexplorer
import (
"fmt"
"strings"
)
// FormatResolveResult formats a ResolveResult as markdown.
func FormatResolveResult(r *ResolveResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Ref:** %s\n", r.Ref))
sb.WriteString(fmt.Sprintf("**Type:** %s\n", r.Type))
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", r.Commit))
return sb.String()
}
// FormatLogEntries formats a slice of LogEntry as markdown.
func FormatLogEntries(entries []LogEntry) string {
if len(entries) == 0 {
return "No commits found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Commit Log (%d commits)\n\n", len(entries)))
for _, e := range entries {
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
}
return sb.String()
}
// FormatCommitInfo formats a CommitInfo as markdown.
func FormatCommitInfo(info *CommitInfo) string {
var sb strings.Builder
sb.WriteString("## Commit Details\n\n")
sb.WriteString(fmt.Sprintf("**Hash:** %s\n", info.Hash))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", info.Author, info.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n", info.Date.Format("2006-01-02 15:04:05")))
sb.WriteString(fmt.Sprintf("**Committer:** %s\n", info.Committer))
sb.WriteString(fmt.Sprintf("**Commit Date:** %s\n", info.CommitDate.Format("2006-01-02 15:04:05")))
if len(info.Parents) > 0 {
sb.WriteString(fmt.Sprintf("**Parents:** %s\n", strings.Join(info.Parents, ", ")))
}
if info.Stats != nil {
sb.WriteString(fmt.Sprintf("**Changes:** %d file(s), +%d -%d\n",
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions))
}
sb.WriteString("\n### Message\n\n")
sb.WriteString("```\n")
sb.WriteString(info.Message)
if !strings.HasSuffix(info.Message, "\n") {
sb.WriteString("\n")
}
sb.WriteString("```\n")
return sb.String()
}
// FormatDiffResult formats a DiffResult as markdown.
func FormatDiffResult(r *DiffResult) string {
if len(r.Files) == 0 {
return "No files changed."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Files Changed (%d files)\n\n", len(r.Files)))
sb.WriteString(fmt.Sprintf("**From:** %s\n", r.FromCommit[:7]))
sb.WriteString(fmt.Sprintf("**To:** %s\n\n", r.ToCommit[:7]))
sb.WriteString("| Status | Path | Changes |\n")
sb.WriteString("|--------|------|--------|\n")
for _, f := range r.Files {
path := f.Path
if f.OldPath != "" {
path = fmt.Sprintf("%s → %s", f.OldPath, f.Path)
}
changes := fmt.Sprintf("+%d -%d", f.Additions, f.Deletions)
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", f.Status, path, changes))
}
return sb.String()
}
// FormatFileContent formats a FileContent as markdown.
func FormatFileContent(c *FileContent) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## File: %s\n\n", c.Path))
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", c.Commit[:7]))
sb.WriteString(fmt.Sprintf("**Size:** %d bytes\n\n", c.Size))
// Determine language hint from extension
ext := ""
if idx := strings.LastIndex(c.Path, "."); idx != -1 {
ext = c.Path[idx+1:]
}
sb.WriteString(fmt.Sprintf("```%s\n", ext))
sb.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
sb.WriteString("\n")
}
sb.WriteString("```\n")
return sb.String()
}
// FormatAncestryResult formats an AncestryResult as markdown.
func FormatAncestryResult(r *AncestryResult) string {
var sb strings.Builder
sb.WriteString("## Ancestry Check\n\n")
sb.WriteString(fmt.Sprintf("**Ancestor:** %s\n", r.Ancestor[:7]))
sb.WriteString(fmt.Sprintf("**Descendant:** %s\n", r.Descendant[:7]))
if r.IsAncestor {
sb.WriteString("\n✓ **Yes**, the first commit is an ancestor of the second.\n")
} else {
sb.WriteString("\n✗ **No**, the first commit is not an ancestor of the second.\n")
}
return sb.String()
}
// FormatCommitRange formats a CommitRange as markdown.
func FormatCommitRange(r *CommitRange) string {
if r.Count == 0 {
return "No commits in range."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Commits Between (%d commits)\n\n", r.Count))
sb.WriteString(fmt.Sprintf("**From:** %s (exclusive)\n", r.FromCommit[:7]))
sb.WriteString(fmt.Sprintf("**To:** %s (inclusive)\n\n", r.ToCommit[:7]))
for _, e := range r.Commits {
sb.WriteString(fmt.Sprintf("- **%s** %s (%s)\n", e.ShortHash, e.Subject, e.Author))
}
return sb.String()
}
// FormatBranchList formats a BranchList as markdown.
func FormatBranchList(r *BranchList) string {
if r.Total == 0 {
return "No branches found."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Branches (%d total)\n\n", r.Total))
if r.Current != "" {
sb.WriteString(fmt.Sprintf("**Current branch:** %s\n\n", r.Current))
}
sb.WriteString("| Branch | Commit | Type |\n")
sb.WriteString("|--------|--------|------|\n")
for _, b := range r.Branches {
branchType := "local"
if b.IsRemote {
branchType = "remote"
}
marker := ""
if b.IsHead {
marker = " ✓"
}
sb.WriteString(fmt.Sprintf("| %s%s | %s | %s |\n", b.Name, marker, b.Commit[:7], branchType))
}
return sb.String()
}
// FormatSearchResult formats a SearchResult as markdown.
func FormatSearchResult(r *SearchResult) string {
if r.Count == 0 {
return fmt.Sprintf("No commits found matching '%s'.", r.Query)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Search Results for '%s' (%d matches)\n\n", r.Query, r.Count))
for _, e := range r.Commits {
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
}
return sb.String()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,9 @@ import (
"strings" "strings"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/nixos" "code.t-juice.club/torjus/labmcp/internal/nixos"
"git.t-juice.club/torjus/labmcp/internal/options" "code.t-juice.club/torjus/labmcp/internal/options"
) )
// revisionPattern validates revision strings to prevent injection attacks. // revisionPattern validates revision strings to prevent injection attacks.

View File

@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
) )
// TestHomeManagerRevision is a known release branch for testing. // TestHomeManagerRevision is a known release branch for testing.

View File

@@ -8,18 +8,35 @@ import (
"strings" "strings"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options" "code.t-juice.club/torjus/labmcp/internal/options"
"git.t-juice.club/torjus/labmcp/internal/packages" "code.t-juice.club/torjus/labmcp/internal/packages"
) )
// RegisterHandlers registers all tool handlers on the server for options mode. // RegisterHandlers registers all tool handlers on the server for options mode.
// Used by legacy nixos-options and hm-options servers (no package indexing).
func (s *Server) RegisterHandlers(indexer options.Indexer) { func (s *Server) RegisterHandlers(indexer options.Indexer) {
s.registerOptionsHandlers(indexer, nil)
}
// RegisterHandlersWithPackages registers all tool handlers for options mode
// with additional package indexing support. When pkgIndexer is non-nil,
// index_revision will also index packages, and list_revisions will show package counts.
func (s *Server) RegisterHandlersWithPackages(indexer options.Indexer, pkgIndexer *packages.Indexer) {
s.registerOptionsHandlers(indexer, pkgIndexer)
}
// registerOptionsHandlers is the shared implementation for RegisterHandlers and RegisterHandlersWithPackages.
func (s *Server) registerOptionsHandlers(indexer options.Indexer, pkgIndexer *packages.Indexer) {
s.tools["search_options"] = s.handleSearchOptions s.tools["search_options"] = s.handleSearchOptions
s.tools["get_option"] = s.handleGetOption s.tools["get_option"] = s.handleGetOption
s.tools["get_file"] = s.handleGetFile s.tools["get_file"] = s.handleGetFile
s.tools["index_revision"] = s.makeIndexHandler(indexer) s.tools["index_revision"] = s.makeIndexHandler(indexer, pkgIndexer)
s.tools["list_revisions"] = s.handleListRevisions if pkgIndexer != nil {
s.tools["list_revisions"] = s.handleListRevisionsWithPackages
} else {
s.tools["list_revisions"] = s.handleListRevisions
}
s.tools["delete_revision"] = s.handleDeleteRevision s.tools["delete_revision"] = s.handleDeleteRevision
} }
@@ -28,6 +45,7 @@ func (s *Server) RegisterPackageHandlers(pkgIndexer *packages.Indexer) {
s.tools["search_packages"] = s.handleSearchPackages s.tools["search_packages"] = s.handleSearchPackages
s.tools["get_package"] = s.handleGetPackage s.tools["get_package"] = s.handleGetPackage
s.tools["get_file"] = s.handleGetFile s.tools["get_file"] = s.handleGetFile
s.tools["index_revision"] = s.makePackageIndexHandler(pkgIndexer)
s.tools["list_revisions"] = s.handleListRevisionsWithPackages s.tools["list_revisions"] = s.handleListRevisionsWithPackages
s.tools["delete_revision"] = s.handleDeleteRevision s.tools["delete_revision"] = s.handleDeleteRevision
} }
@@ -246,7 +264,8 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{})
} }
// makeIndexHandler creates the index_revision handler with the indexer. // makeIndexHandler creates the index_revision handler with the indexer.
func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler { // If pkgIndexer is non-nil, it will also index packages after options and files.
func (s *Server) makeIndexHandler(indexer options.Indexer, pkgIndexer *packages.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string) revision, _ := args["revision"].(string)
if revision == "" { if revision == "" {
@@ -278,6 +297,17 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
s.logger.Printf("Warning: file indexing failed: %v", err) s.logger.Printf("Warning: file indexing failed: %v", err)
} }
// Index packages if package indexer is available
var packageCount int
if pkgIndexer != nil {
pkgResult, pkgErr := pkgIndexer.IndexPackages(ctx, result.Revision.ID, result.Revision.GitHash)
if pkgErr != nil {
s.logger.Printf("Warning: package indexing failed: %v", pkgErr)
} else {
packageCount = pkgResult.PackageCount
}
}
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash)) sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
if result.Revision.ChannelName != "" { if result.Revision.ChannelName != "" {
@@ -285,6 +315,9 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
} }
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount)) sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount)) sb.WriteString(fmt.Sprintf("Files: %d\n", fileCount))
if packageCount > 0 {
sb.WriteString(fmt.Sprintf("Packages: %d\n", packageCount))
}
// Handle Duration which may be time.Duration or interface{} // Handle Duration which may be time.Duration or interface{}
if dur, ok := result.Duration.(time.Duration); ok { if dur, ok := result.Duration.(time.Duration); ok {
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond))) sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
@@ -296,6 +329,85 @@ func (s *Server) makeIndexHandler(indexer options.Indexer) ToolHandler {
} }
} }
// makePackageIndexHandler creates an index_revision handler for the packages-only server.
// It creates a revision record if needed, then indexes packages.
func (s *Server) makePackageIndexHandler(pkgIndexer *packages.Indexer) ToolHandler {
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revision, _ := args["revision"].(string)
if revision == "" {
return ErrorContent(fmt.Errorf("revision is required")), nil
}
if err := packages.ValidateRevision(revision); err != nil {
return ErrorContent(err), nil
}
// Resolve channel aliases to git ref
ref := pkgIndexer.ResolveRevision(revision)
// Check if revision already exists
rev, err := s.store.GetRevision(ctx, ref)
if err != nil {
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
}
if rev == nil {
// Also try by channel name
rev, err = s.store.GetRevisionByChannel(ctx, revision)
if err != nil {
return ErrorContent(fmt.Errorf("failed to check revision: %w", err)), nil
}
}
if rev == nil {
// Create a new revision record
commitDate, _ := pkgIndexer.GetCommitDate(ctx, ref)
rev = &database.Revision{
GitHash: ref,
ChannelName: pkgIndexer.GetChannelName(revision),
CommitDate: commitDate,
}
if err := s.store.CreateRevision(ctx, rev); err != nil {
return ErrorContent(fmt.Errorf("failed to create revision: %w", err)), nil
}
}
// Check if packages are already indexed for this revision
if rev.PackageCount > 0 {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Revision already indexed: %s\n", rev.GitHash))
if rev.ChannelName != "" {
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
}
sb.WriteString(fmt.Sprintf("Packages: %d\n", rev.PackageCount))
sb.WriteString(fmt.Sprintf("Indexed at: %s\n", rev.IndexedAt.Format("2006-01-02 15:04")))
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
// Index packages
pkgResult, err := pkgIndexer.IndexPackages(ctx, rev.ID, rev.GitHash)
if err != nil {
return ErrorContent(fmt.Errorf("package indexing failed: %w", err)), nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", rev.GitHash))
if rev.ChannelName != "" {
sb.WriteString(fmt.Sprintf("Channel: %s\n", rev.ChannelName))
}
sb.WriteString(fmt.Sprintf("Packages: %d\n", pkgResult.PackageCount))
if dur, ok := pkgResult.Duration.(time.Duration); ok {
sb.WriteString(fmt.Sprintf("Duration: %s\n", dur.Round(time.Millisecond)))
}
return CallToolResult{
Content: []Content{TextContent(sb.String())},
}, nil
}
}
// handleListRevisions handles the list_revisions tool. // handleListRevisions handles the list_revisions tool.
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
revisions, err := s.store.ListRevisions(ctx) revisions, err := s.store.ListRevisions(ctx)

View File

@@ -7,7 +7,7 @@ import (
"io" "io"
"log" "log"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
) )
// ServerMode indicates which type of tools the server should expose. // ServerMode indicates which type of tools the server should expose.
@@ -45,7 +45,7 @@ type ServerConfig struct {
func DefaultNixOSConfig() ServerConfig { func DefaultNixOSConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Name: "nixos-options", Name: "nixos-options",
Version: "0.3.0", Version: "0.4.0",
DefaultChannel: "nixos-stable", DefaultChannel: "nixos-stable",
SourceName: "nixpkgs", SourceName: "nixpkgs",
Mode: ModeOptions, Mode: ModeOptions,
@@ -57,7 +57,9 @@ If the current project contains a flake.lock file, you can index the exact nixpk
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...". Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures option documentation matches the nixpkgs version the project actually uses.`, This ensures option documentation matches the nixpkgs version the project actually uses.
Note: index_revision also indexes packages when available, so both options and packages become searchable.`,
} }
} }
@@ -65,7 +67,7 @@ This ensures option documentation matches the nixpkgs version the project actual
func DefaultNixpkgsPackagesConfig() ServerConfig { func DefaultNixpkgsPackagesConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Name: "nixpkgs-packages", Name: "nixpkgs-packages",
Version: "0.3.0", Version: "0.4.0",
DefaultChannel: "nixos-stable", DefaultChannel: "nixos-stable",
SourceName: "nixpkgs", SourceName: "nixpkgs",
Mode: ModePackages, Mode: ModePackages,
@@ -73,7 +75,9 @@ func DefaultNixpkgsPackagesConfig() ServerConfig {
If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project: If the current project contains a flake.lock file, you can search packages from the exact nixpkgs revision used by the project:
1. Read the flake.lock file to find the nixpkgs "rev" field 1. Read the flake.lock file to find the nixpkgs "rev" field
2. Ensure the revision is indexed (packages are indexed separately from options) 2. Call index_revision with that git hash to index packages for that specific version
Example: If flake.lock contains "rev": "abc123...", call index_revision with revision "abc123...".
This ensures package information matches the nixpkgs version the project actually uses.`, This ensures package information matches the nixpkgs version the project actually uses.`,
} }
@@ -83,7 +87,7 @@ This ensures package information matches the nixpkgs version the project actuall
func DefaultMonitoringConfig() ServerConfig { func DefaultMonitoringConfig() ServerConfig {
return ServerConfig{ return ServerConfig{
Name: "lab-monitoring", Name: "lab-monitoring",
Version: "0.3.0", Version: "0.3.1",
Mode: ModeCustom, Mode: ModeCustom,
Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts. Instructions: `Lab Monitoring MCP Server - Query Prometheus metrics and Alertmanager alerts.
@@ -118,6 +122,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. // Server is an MCP server that handles JSON-RPC requests.
type Server struct { type Server struct {
store database.Store store database.Store
@@ -406,7 +431,7 @@ func (s *Server) getOptionToolDefinitions() []Tool {
}, },
{ {
Name: "index_revision", Name: "index_revision",
Description: fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo), Description: s.indexRevisionDescription(sourceRepo),
InputSchema: InputSchema{ InputSchema: InputSchema{
Type: "object", Type: "object",
Properties: map[string]Property{ Properties: map[string]Property{
@@ -443,6 +468,15 @@ func (s *Server) getOptionToolDefinitions() []Tool {
} }
} }
// indexRevisionDescription returns the description for the index_revision tool,
// adjusted based on whether packages are also indexed.
func (s *Server) indexRevisionDescription(sourceRepo string) string {
if s.config.SourceName == "nixpkgs" {
return fmt.Sprintf("Index a %s revision to make its options and packages searchable", sourceRepo)
}
return fmt.Sprintf("Index a %s revision to make its options searchable", sourceRepo)
}
// getPackageToolDefinitions returns the tool definitions for packages mode. // getPackageToolDefinitions returns the tool definitions for packages mode.
func (s *Server) getPackageToolDefinitions() []Tool { func (s *Server) getPackageToolDefinitions() []Tool {
exampleChannels := "'nixos-unstable', 'nixos-24.05'" exampleChannels := "'nixos-unstable', 'nixos-24.05'"
@@ -526,6 +560,20 @@ func (s *Server) getPackageToolDefinitions() []Tool {
Required: []string{"path"}, Required: []string{"path"},
}, },
}, },
{
Name: "index_revision",
Description: "Index a nixpkgs revision to make its packages searchable",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"revision": {
Type: "string",
Description: fmt.Sprintf("Git hash (full or short) or channel name (e.g., %s)", exampleChannels),
},
},
Required: []string{"revision"},
},
},
{ {
Name: "list_revisions", Name: "list_revisions",
Description: "List all indexed nixpkgs revisions", Description: "List all indexed nixpkgs revisions",

View File

@@ -8,8 +8,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/nixos" "code.t-juice.club/torjus/labmcp/internal/nixos"
"code.t-juice.club/torjus/labmcp/internal/packages"
) )
func TestServerInitialize(t *testing.T) { func TestServerInitialize(t *testing.T) {
@@ -145,6 +146,110 @@ func TestServerNotification(t *testing.T) {
} }
} }
func TestPackagesServerToolsList(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil, DefaultNixpkgsPackagesConfig())
pkgIndexer := packages.NewIndexer(store)
server.RegisterPackageHandlers(pkgIndexer)
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
resp := runRequest(t, server, input)
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
result, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", resp.Result)
}
tools, ok := result["tools"].([]interface{})
if !ok {
t.Fatalf("Expected tools array, got %T", result["tools"])
}
// Should have 6 tools (search_packages, get_package, get_file, index_revision, list_revisions, delete_revision)
if len(tools) != 6 {
t.Errorf("Expected 6 tools, got %d", len(tools))
}
expectedTools := map[string]bool{
"search_packages": false,
"get_package": false,
"get_file": false,
"index_revision": false,
"list_revisions": false,
"delete_revision": false,
}
for _, tool := range tools {
toolMap := tool.(map[string]interface{})
name := toolMap["name"].(string)
if _, ok := expectedTools[name]; ok {
expectedTools[name] = true
}
}
for name, found := range expectedTools {
if !found {
t.Errorf("Tool %q not found in tools list", name)
}
}
}
func TestOptionsServerWithPackagesToolsList(t *testing.T) {
store := setupTestStore(t)
server := NewServer(store, nil, DefaultNixOSConfig())
indexer := nixos.NewIndexer(store)
pkgIndexer := packages.NewIndexer(store)
server.RegisterHandlersWithPackages(indexer, pkgIndexer)
input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`
resp := runRequest(t, server, input)
if resp.Error != nil {
t.Fatalf("Unexpected error: %v", resp.Error)
}
result, ok := resp.Result.(map[string]interface{})
if !ok {
t.Fatalf("Expected map result, got %T", resp.Result)
}
tools, ok := result["tools"].([]interface{})
if !ok {
t.Fatalf("Expected tools array, got %T", result["tools"])
}
// Should still have 6 tools (same as options-only)
if len(tools) != 6 {
t.Errorf("Expected 6 tools, got %d", len(tools))
}
// Verify index_revision is present
found := false
for _, tool := range tools {
toolMap := tool.(map[string]interface{})
if toolMap["name"].(string) == "index_revision" {
found = true
// For nixpkgs source, description should mention packages
desc := toolMap["description"].(string)
if !strings.Contains(desc, "packages") {
t.Errorf("index_revision description should mention packages, got: %s", desc)
}
break
}
}
if !found {
t.Error("index_revision tool not found in tools list")
}
}
func TestGetFilePathValidation(t *testing.T) { func TestGetFilePathValidation(t *testing.T) {
store := setupTestStore(t) store := setupTestStore(t)
server := setupTestServer(t, store) server := setupTestServer(t, store)

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
) )
// AlertSummary queries Alertmanager for active (non-silenced) alerts and returns // AlertSummary queries Alertmanager for active (non-silenced) alerts and returns

View File

@@ -10,7 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.t-juice.club/torjus/labmcp/internal/mcp" "code.t-juice.club/torjus/labmcp/internal/mcp"
) )
// setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers. // setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers.
@@ -31,7 +31,7 @@ func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, loki
var lokiSrv *httptest.Server var lokiSrv *httptest.Server
if len(lokiHandler) > 0 && lokiHandler[0] != nil { if len(lokiHandler) > 0 && lokiHandler[0] != nil {
lokiSrv = httptest.NewServer(lokiHandler[0]) lokiSrv = httptest.NewServer(lokiHandler[0])
loki = NewLokiClient(lokiSrv.URL) loki = NewLokiClient(LokiClientOptions{BaseURL: lokiSrv.URL})
} }
RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true}) RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true})

View File

@@ -11,16 +11,27 @@ import (
"time" "time"
) )
// LokiClientOptions configures the Loki client.
type LokiClientOptions struct {
BaseURL string
Username string
Password string
}
// LokiClient is an HTTP client for the Loki API. // LokiClient is an HTTP client for the Loki API.
type LokiClient struct { type LokiClient struct {
baseURL string baseURL string
username string
password string
httpClient *http.Client httpClient *http.Client
} }
// NewLokiClient creates a new Loki API client. // NewLokiClient creates a new Loki API client.
func NewLokiClient(baseURL string) *LokiClient { func NewLokiClient(opts LokiClientOptions) *LokiClient {
return &LokiClient{ return &LokiClient{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(opts.BaseURL, "/"),
username: opts.Username,
password: opts.Password,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
@@ -94,6 +105,10 @@ func (c *LokiClient) get(ctx context.Context, path string, params url.Values) (j
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
if c.username != "" {
req.SetBasicAuth(c.username, c.password)
}
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("request failed: %w", err)

View File

@@ -42,7 +42,7 @@ func TestLokiClient_QueryRange(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client := NewLokiClient(srv.URL) client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
start := time.Unix(0, 1234567890000000000) start := time.Unix(0, 1234567890000000000)
end := time.Unix(0, 1234567899000000000) end := time.Unix(0, 1234567899000000000)
data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward") data, err := client.QueryRange(context.Background(), `{job="varlogs"}`, start, end, 10, "backward")
@@ -78,7 +78,7 @@ func TestLokiClient_QueryRangeError(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client := NewLokiClient(srv.URL) client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
_, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward") _, err := client.QueryRange(context.Background(), "invalid{", time.Now().Add(-time.Hour), time.Now(), 100, "backward")
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
@@ -102,7 +102,7 @@ func TestLokiClient_Labels(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client := NewLokiClient(srv.URL) client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
labels, err := client.Labels(context.Background()) labels, err := client.Labels(context.Background())
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -130,7 +130,7 @@ func TestLokiClient_LabelValues(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client := NewLokiClient(srv.URL) client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
values, err := client.LabelValues(context.Background(), "job") values, err := client.LabelValues(context.Background(), "job")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -144,6 +144,65 @@ func TestLokiClient_LabelValues(t *testing.T) {
} }
} }
func TestLokiClient_BasicAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
t.Error("expected basic auth to be set")
}
if user != "myuser" {
t.Errorf("expected username=myuser, got %s", user)
}
if pass != "mypass" {
t.Errorf("expected password=mypass, got %s", pass)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"status": "success",
"data": ["job"]
}`))
}))
defer srv.Close()
client := NewLokiClient(LokiClientOptions{
BaseURL: srv.URL,
Username: "myuser",
Password: "mypass",
})
labels, err := client.Labels(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(labels) != 1 || labels[0] != "job" {
t.Errorf("unexpected labels: %v", labels)
}
}
func TestLokiClient_NoAuthWhenNoCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := r.BasicAuth(); ok {
t.Error("expected no basic auth header, but it was set")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"status": "success",
"data": ["job"]
}`))
}))
defer srv.Close()
client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
labels, err := client.Labels(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(labels) != 1 || labels[0] != "job" {
t.Errorf("unexpected labels: %v", labels)
}
}
func TestLokiClient_HTTPError(t *testing.T) { func TestLokiClient_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -151,7 +210,7 @@ func TestLokiClient_HTTPError(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client := NewLokiClient(srv.URL) client := NewLokiClient(LokiClientOptions{BaseURL: srv.URL})
_, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward") _, err := client.QueryRange(context.Background(), `{job="test"}`, time.Now().Add(-time.Hour), time.Now(), 100, "backward")
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")

View File

@@ -15,8 +15,8 @@ import (
"strings" "strings"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
"git.t-juice.club/torjus/labmcp/internal/options" "code.t-juice.club/torjus/labmcp/internal/options"
) )
// revisionPattern validates revision strings to prevent injection attacks. // revisionPattern validates revision strings to prevent injection attacks.

View File

@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
) )
// TestNixpkgsRevision is the revision from flake.lock used for testing. // TestNixpkgsRevision is the revision from flake.lock used for testing.

View File

@@ -4,7 +4,7 @@ package options
import ( import (
"context" "context"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
) )
// IndexResult contains the results of an indexing operation. // IndexResult contains the results of an indexing operation.

View File

@@ -12,7 +12,7 @@ import (
"strings" "strings"
"time" "time"
"git.t-juice.club/torjus/labmcp/internal/database" "code.t-juice.club/torjus/labmcp/internal/database"
) )
// revisionPattern validates revision strings to prevent injection attacks. // revisionPattern validates revision strings to prevent injection attacks.

141
nix/git-explorer-module.nix Normal file
View File

@@ -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 ];
});
};
}

View File

@@ -39,6 +39,18 @@ in
description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values)."; description = "Loki base URL. When set, enables log query tools (query_logs, list_labels, list_label_values).";
}; };
lokiUsername = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Username for Loki basic authentication.";
};
lokiPasswordFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to a file containing the password for Loki basic authentication. Recommended over storing secrets in the Nix store.";
};
enableSilences = lib.mkOption { enableSilences = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
default = false; default = false;
@@ -112,12 +124,17 @@ in
ALERTMANAGER_URL = cfg.alertmanagerUrl; ALERTMANAGER_URL = cfg.alertmanagerUrl;
} // lib.optionalAttrs (cfg.lokiUrl != null) { } // lib.optionalAttrs (cfg.lokiUrl != null) {
LOKI_URL = cfg.lokiUrl; LOKI_URL = cfg.lokiUrl;
} // lib.optionalAttrs (cfg.lokiUsername != null) {
LOKI_USERNAME = cfg.lokiUsername;
}; };
script = let script = let
httpFlags = mkHttpFlags cfg.http; httpFlags = mkHttpFlags cfg.http;
silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences"; silenceFlag = lib.optionalString cfg.enableSilences "--enable-silences";
in '' in ''
${lib.optionalString (cfg.lokiPasswordFile != null) ''
export LOKI_PASSWORD="$(< "$CREDENTIALS_DIRECTORY/loki-password")"
''}
exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag} exec ${cfg.package}/bin/lab-monitoring serve ${httpFlags} ${silenceFlag}
''; '';
@@ -126,7 +143,9 @@ in
DynamicUser = true; DynamicUser = true;
Restart = "on-failure"; Restart = "on-failure";
RestartSec = "5s"; RestartSec = "5s";
} // lib.optionalAttrs (cfg.lokiPasswordFile != null) {
LoadCredential = [ "loki-password:${cfg.lokiPasswordFile}" ];
} // {
# Hardening # Hardening
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";

View File

@@ -7,9 +7,9 @@
buildGoModule { buildGoModule {
inherit pname src; inherit pname src;
version = "0.3.0"; version = "0.4.0";
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";
subPackages = [ subPackage ]; subPackages = [ subPackage ];
@@ -22,7 +22,7 @@ buildGoModule {
meta = with lib; { meta = with lib; {
inherit description mainProgram; inherit description mainProgram;
homepage = "https://git.t-juice.club/torjus/labmcp"; homepage = "https://code.t-juice.club/torjus/labmcp";
license = licenses.mit; license = licenses.mit;
maintainers = [ ]; maintainers = [ ];
}; };