feat: add git-explorer MCP server for read-only repository access #8
65
CLAUDE.md
65
CLAUDE.md
@@ -26,6 +26,12 @@ Query Prometheus metrics, Alertmanager alerts, and Loki logs. Unlike other serve
|
|||||||
- 3 optional Loki tools (when `LOKI_URL` is set): query_logs, list_labels, list_label_values
|
- 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
|
||||||
|
|
||||||
|
### 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
|
||||||
- Query specific options/packages with full metadata
|
- Query specific options/packages with full metadata
|
||||||
@@ -62,8 +68,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 +104,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
|
||||||
@@ -158,6 +174,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 +291,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 +358,7 @@ Each package's version is defined in multiple places that must stay in sync *for
|
|||||||
- **nixpkgs-search**: `cmd/nixpkgs-search/main.go` + `internal/mcp/server.go` (`DefaultNixOSConfig`, `DefaultNixpkgsPackagesConfig`)
|
- **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 +386,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 +395,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
|
||||||
|
|||||||
118
README.md
118
README.md
@@ -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.
|
||||||
@@ -48,11 +62,13 @@ Search and query NixOS configuration options. **Note**: Prefer using `nixpkgs-se
|
|||||||
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
|
nix build git+https://git.t-juice.club/torjus/labmcp#nixpkgs-search
|
||||||
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
|
nix build git+https://git.t-juice.club/torjus/labmcp#hm-options
|
||||||
nix build git+https://git.t-juice.club/torjus/labmcp#lab-monitoring
|
nix build git+https://git.t-juice.club/torjus/labmcp#lab-monitoring
|
||||||
|
nix build git+https://git.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://git.t-juice.club/torjus/labmcp#nixpkgs-search -- --help
|
||||||
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
|
nix run git+https://git.t-juice.club/torjus/labmcp#hm-options -- --help
|
||||||
nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help
|
nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help
|
||||||
|
nix run git+https://git.t-juice.club/torjus/labmcp#git-explorer -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
@@ -61,6 +77,7 @@ nix run git+https://git.t-juice.club/torjus/labmcp#lab-monitoring -- --help
|
|||||||
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
|
go install git.t-juice.club/torjus/labmcp/cmd/nixpkgs-search@latest
|
||||||
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
|
go install git.t-juice.club/torjus/labmcp/cmd/hm-options@latest
|
||||||
go install git.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
|
go install git.t-juice.club/torjus/labmcp/cmd/lab-monitoring@latest
|
||||||
|
go install git.t-juice.club/torjus/labmcp/cmd/git-explorer@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -101,6 +118,13 @@ Configure in your MCP client (e.g., Claude Desktop):
|
|||||||
"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": "git-explorer",
|
||||||
|
"args": ["serve"],
|
||||||
|
"env": {
|
||||||
|
"GIT_REPO_PATH": "/path/to/your/repo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +164,13 @@ Alternatively, if you have Nix installed, you can use the flake directly without
|
|||||||
"ALERTMANAGER_URL": "http://alertmanager.example.com:9093",
|
"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://git.t-juice.club/torjus/labmcp#git-explorer", "--", "serve"],
|
||||||
|
"env": {
|
||||||
|
"GIT_REPO_PATH": "/path/to/your/repo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +186,7 @@ nixpkgs-search options serve --transport http
|
|||||||
nixpkgs-search packages serve --transport http
|
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 +303,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
|
||||||
@@ -354,6 +415,20 @@ hm-options -d "sqlite://my.db" index hm-unstable
|
|||||||
| `list_labels` | List available label names from Loki (requires `LOKI_URL`) |
|
| `list_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.
|
||||||
@@ -445,6 +520,29 @@ The `nixpkgs-search` module runs two separate MCP servers (options and packages)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### git-explorer
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.labmcp.url = "git+https://git.t-juice.club/torjus/labmcp";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, labmcp }: {
|
||||||
|
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||||
|
system = "x86_64-linux";
|
||||||
|
modules = [
|
||||||
|
labmcp.nixosModules.git-explorer-mcp
|
||||||
|
{
|
||||||
|
services.git-explorer = {
|
||||||
|
enable = true;
|
||||||
|
repoPath = "/path/to/your/git/repo";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### nixos-options (Legacy)
|
### nixos-options (Legacy)
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
@@ -519,6 +617,25 @@ Both `options.http` and `packages.http` also support:
|
|||||||
|
|
||||||
The lab-monitoring module uses `DynamicUser=true`, so no separate user/group configuration is needed.
|
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 +696,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
459
cmd/git-explorer/main.go
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/gitexplorer"
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const version = "0.1.0"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := &cli.App{
|
||||||
|
Name: "git-explorer",
|
||||||
|
Usage: "Read-only MCP server for git repository exploration",
|
||||||
|
Version: version,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "repo",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "Path to git repository",
|
||||||
|
EnvVars: []string{"GIT_REPO_PATH"},
|
||||||
|
Value: ".",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "default-remote",
|
||||||
|
Usage: "Default remote name",
|
||||||
|
EnvVars: []string{"GIT_DEFAULT_REMOTE"},
|
||||||
|
Value: "origin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
serveCommand(),
|
||||||
|
resolveCommand(),
|
||||||
|
logCommand(),
|
||||||
|
showCommand(),
|
||||||
|
diffCommand(),
|
||||||
|
catCommand(),
|
||||||
|
branchesCommand(),
|
||||||
|
searchCommand(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.Run(os.Args); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "serve",
|
||||||
|
Usage: "Run MCP server for git exploration",
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "transport",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Usage: "Transport type: 'stdio' or 'http'",
|
||||||
|
Value: "stdio",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "http-address",
|
||||||
|
Usage: "HTTP listen address",
|
||||||
|
Value: "127.0.0.1:8085",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "http-endpoint",
|
||||||
|
Usage: "HTTP endpoint path",
|
||||||
|
Value: "/mcp",
|
||||||
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "allowed-origins",
|
||||||
|
Usage: "Allowed Origin headers for CORS",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "tls-cert",
|
||||||
|
Usage: "TLS certificate file",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "tls-key",
|
||||||
|
Usage: "TLS key file",
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "session-ttl",
|
||||||
|
Usage: "Session TTL for HTTP transport",
|
||||||
|
Value: 30 * time.Minute,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return runServe(c)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCommand() *cli.Command {
|
||||||
|
return &cli.Command{
|
||||||
|
Name: "resolve",
|
||||||
|
Usage: "Resolve a ref to a commit hash",
|
||||||
|
ArgsUsage: "<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
|
||||||
|
}
|
||||||
11
flake.nix
11
flake.nix
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +91,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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
21
go.mod
21
go.mod
@@ -9,16 +9,35 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/go-git/go-git/v5 v5.16.4 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/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
|
||||||
|
|||||||
67
go.sum
67
go.sum
@@ -1,36 +1,103 @@
|
|||||||
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||||
|
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||||
|
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||||
|
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 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/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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||||
|
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||||
|
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||||
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd 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/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 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/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||||
|
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 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/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/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/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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
golang.org/x/tools v0.19.0 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/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||||
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 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=
|
||||||
|
|||||||
570
internal/gitexplorer/client.go
Normal file
570
internal/gitexplorer/client.go
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when a ref, commit, or file is not found.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
// ErrFileTooLarge is returned when a file exceeds the size limit.
|
||||||
|
ErrFileTooLarge = errors.New("file too large")
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitClient provides read-only access to a git repository.
|
||||||
|
type GitClient struct {
|
||||||
|
repo *git.Repository
|
||||||
|
defaultRemote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGitClient opens a git repository at the given path.
|
||||||
|
func NewGitClient(repoPath string, defaultRemote string) (*GitClient, error) {
|
||||||
|
repo, err := git.PlainOpen(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultRemote == "" {
|
||||||
|
defaultRemote = "origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GitClient{
|
||||||
|
repo: repo,
|
||||||
|
defaultRemote: defaultRemote,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRef resolves a ref (branch, tag, or commit hash) to a commit hash.
|
||||||
|
func (c *GitClient) ResolveRef(ref string) (*ResolveResult, error) {
|
||||||
|
result := &ResolveResult{Ref: ref}
|
||||||
|
|
||||||
|
// Try to resolve as a revision
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Commit = hash.String()
|
||||||
|
|
||||||
|
// Determine the type of ref
|
||||||
|
// Check if it's a branch
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewBranchReferenceName(ref), true); err == nil {
|
||||||
|
result.Type = "branch"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a remote branch
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewRemoteReferenceName(c.defaultRemote, ref), true); err == nil {
|
||||||
|
result.Type = "branch"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a tag
|
||||||
|
if _, err := c.repo.Reference(plumbing.NewTagReferenceName(ref), true); err == nil {
|
||||||
|
result.Type = "tag"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to commit
|
||||||
|
result.Type = "commit"
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLog returns the commit log starting from the given ref.
|
||||||
|
func (c *GitClient) GetLog(ref string, limit int, author string, since string, path string) ([]LogEntry, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
||||||
|
limit = Limits.MaxLogEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the ref to a commit hash
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
logOpts := &git.LogOptions{
|
||||||
|
From: *hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add path filter if specified
|
||||||
|
if path != "" {
|
||||||
|
if err := ValidatePath(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logOpts.PathFilter = func(p string) bool {
|
||||||
|
return strings.HasPrefix(p, path) || p == path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(logOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
var entries []LogEntry
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
// Apply author filter
|
||||||
|
if author != "" {
|
||||||
|
authorLower := strings.ToLower(author)
|
||||||
|
if !strings.Contains(strings.ToLower(commit.Author.Name), authorLower) &&
|
||||||
|
!strings.Contains(strings.ToLower(commit.Author.Email), authorLower) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply since filter
|
||||||
|
if since != "" {
|
||||||
|
// Parse since as a ref and check if this commit is reachable
|
||||||
|
sinceHash, err := c.repo.ResolveRevision(plumbing.Revision(since))
|
||||||
|
if err == nil {
|
||||||
|
// Stop if we've reached the since commit
|
||||||
|
if commit.Hash == *sinceHash {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first line of commit message as subject
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
entries = append(entries, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(entries) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// io.EOF is expected when we hit the limit
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommitInfo returns full details about a commit.
|
||||||
|
func (c *GitClient) GetCommitInfo(ref string, includeStats bool) (*CommitInfo, error) {
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := c.repo.CommitObject(*hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &CommitInfo{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Committer: commit.Committer.Name,
|
||||||
|
CommitDate: commit.Committer.When,
|
||||||
|
Message: commit.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, parent := range commit.ParentHashes {
|
||||||
|
info.Parents = append(info.Parents, parent.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeStats {
|
||||||
|
stats, err := c.getCommitStats(commit)
|
||||||
|
if err == nil {
|
||||||
|
info.Stats = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCommitStats computes file change statistics for a commit.
|
||||||
|
func (c *GitClient) getCommitStats(commit *object.Commit) (*FileStats, error) {
|
||||||
|
stats, err := commit.Stats()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FileStats{
|
||||||
|
FilesChanged: len(stats),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range stats {
|
||||||
|
result.Additions += s.Addition
|
||||||
|
result.Deletions += s.Deletion
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiffFiles returns the files changed between two commits.
|
||||||
|
func (c *GitClient) GetDiffFiles(fromRef, toRef string) (*DiffResult, error) {
|
||||||
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCommit, err := c.repo.CommitObject(*fromHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get from commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toCommit, err := c.repo.CommitObject(*toHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get to commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch, err := fromCommit.Patch(toCommit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get patch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &DiffResult{
|
||||||
|
FromCommit: fromHash.String(),
|
||||||
|
ToCommit: toHash.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, filePatch := range patch.FilePatches() {
|
||||||
|
if i >= Limits.MaxDiffFiles {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
from, to := filePatch.Files()
|
||||||
|
|
||||||
|
df := DiffFile{}
|
||||||
|
|
||||||
|
// Determine status and paths
|
||||||
|
switch {
|
||||||
|
case from == nil && to != nil:
|
||||||
|
df.Status = "added"
|
||||||
|
df.Path = to.Path()
|
||||||
|
case from != nil && to == nil:
|
||||||
|
df.Status = "deleted"
|
||||||
|
df.Path = from.Path()
|
||||||
|
case from != nil && to != nil && from.Path() != to.Path():
|
||||||
|
df.Status = "renamed"
|
||||||
|
df.Path = to.Path()
|
||||||
|
df.OldPath = from.Path()
|
||||||
|
default:
|
||||||
|
df.Status = "modified"
|
||||||
|
if to != nil {
|
||||||
|
df.Path = to.Path()
|
||||||
|
} else if from != nil {
|
||||||
|
df.Path = from.Path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count additions and deletions
|
||||||
|
for _, chunk := range filePatch.Chunks() {
|
||||||
|
content := chunk.Content()
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
switch chunk.Type() {
|
||||||
|
case 1: // Add
|
||||||
|
df.Additions += len(lines)
|
||||||
|
case 2: // Delete
|
||||||
|
df.Deletions += len(lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Files = append(result.Files, df)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileAtCommit returns the content of a file at a specific commit.
|
||||||
|
func (c *GitClient) GetFileAtCommit(ref, path string) (*FileContent, error) {
|
||||||
|
if err := ValidatePath(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := c.repo.CommitObject(*hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := commit.File(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: file '%s'", ErrNotFound, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if file.Size > Limits.MaxFileContent {
|
||||||
|
return nil, fmt.Errorf("%w: %d bytes (max %d)", ErrFileTooLarge, file.Size, Limits.MaxFileContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := file.Contents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileContent{
|
||||||
|
Path: path,
|
||||||
|
Commit: hash.String(),
|
||||||
|
Size: file.Size,
|
||||||
|
Content: content,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAncestor checks if ancestor is an ancestor of descendant.
|
||||||
|
func (c *GitClient) IsAncestor(ancestorRef, descendantRef string) (*AncestryResult, error) {
|
||||||
|
ancestorHash, err := c.repo.ResolveRevision(plumbing.Revision(ancestorRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ancestorRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
descendantHash, err := c.repo.ResolveRevision(plumbing.Revision(descendantRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, descendantRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
ancestorCommit, err := c.repo.CommitObject(*ancestorHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get ancestor commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
descendantCommit, err := c.repo.CommitObject(*descendantHash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get descendant commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isAncestor, err := ancestorCommit.IsAncestor(descendantCommit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check ancestry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AncestryResult{
|
||||||
|
Ancestor: ancestorHash.String(),
|
||||||
|
Descendant: descendantHash.String(),
|
||||||
|
IsAncestor: isAncestor,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitsBetween returns commits between two refs (exclusive of from, inclusive of to).
|
||||||
|
func (c *GitClient) CommitsBetween(fromRef, toRef string, limit int) (*CommitRange, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxLogEntries {
|
||||||
|
limit = Limits.MaxLogEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
fromHash, err := c.repo.ResolveRevision(plumbing.Revision(fromRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, fromRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
toHash, err := c.repo.ResolveRevision(plumbing.Revision(toRef))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, toRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(&git.LogOptions{
|
||||||
|
From: *toHash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
result := &CommitRange{
|
||||||
|
FromCommit: fromHash.String(),
|
||||||
|
ToCommit: toHash.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
// Stop when we reach the from commit (exclusive)
|
||||||
|
if commit.Hash == *fromHash {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
result.Commits = append(result.Commits, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(result.Commits) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate log: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.Commits)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBranches returns all branches in the repository.
|
||||||
|
func (c *GitClient) ListBranches(includeRemote bool) (*BranchList, error) {
|
||||||
|
result := &BranchList{}
|
||||||
|
|
||||||
|
// Get HEAD to determine current branch
|
||||||
|
head, err := c.repo.Head()
|
||||||
|
if err == nil && head.Name().IsBranch() {
|
||||||
|
result.Current = head.Name().Short()
|
||||||
|
}
|
||||||
|
|
||||||
|
// List local branches
|
||||||
|
branchIter, err := c.repo.Branches()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list branches: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = branchIter.ForEach(func(ref *plumbing.Reference) error {
|
||||||
|
if len(result.Branches) >= Limits.MaxBranches {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := Branch{
|
||||||
|
Name: ref.Name().Short(),
|
||||||
|
Commit: ref.Hash().String(),
|
||||||
|
IsRemote: false,
|
||||||
|
IsHead: ref.Name().Short() == result.Current,
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Branches = append(result.Branches, branch)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate branches: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List remote branches if requested
|
||||||
|
if includeRemote {
|
||||||
|
refs, err := c.repo.References()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = refs.ForEach(func(ref *plumbing.Reference) error {
|
||||||
|
if len(result.Branches) >= Limits.MaxBranches {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.Name().IsRemote() {
|
||||||
|
branch := Branch{
|
||||||
|
Name: ref.Name().Short(),
|
||||||
|
Commit: ref.Hash().String(),
|
||||||
|
IsRemote: true,
|
||||||
|
IsHead: false,
|
||||||
|
}
|
||||||
|
result.Branches = append(result.Branches, branch)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to iterate references: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Total = len(result.Branches)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchCommits searches commit messages for a pattern.
|
||||||
|
func (c *GitClient) SearchCommits(ref, query string, limit int) (*SearchResult, error) {
|
||||||
|
if limit <= 0 || limit > Limits.MaxSearchResult {
|
||||||
|
limit = Limits.MaxSearchResult
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := c.repo.ResolveRevision(plumbing.Revision(ref))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: ref '%s'", ErrNotFound, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
iter, err := c.repo.Log(&git.LogOptions{
|
||||||
|
From: *hash,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get log: %w", err)
|
||||||
|
}
|
||||||
|
defer iter.Close()
|
||||||
|
|
||||||
|
result := &SearchResult{
|
||||||
|
Query: query,
|
||||||
|
}
|
||||||
|
queryLower := strings.ToLower(query)
|
||||||
|
|
||||||
|
// We need to scan more commits to find matches
|
||||||
|
scanned := 0
|
||||||
|
maxScan := limit * 100 // Scan up to 100x the limit
|
||||||
|
|
||||||
|
err = iter.ForEach(func(commit *object.Commit) error {
|
||||||
|
scanned++
|
||||||
|
if scanned > maxScan {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in message (case-insensitive)
|
||||||
|
if !strings.Contains(strings.ToLower(commit.Message), queryLower) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := commit.Message
|
||||||
|
if idx := strings.Index(subject, "\n"); idx != -1 {
|
||||||
|
subject = subject[:idx]
|
||||||
|
}
|
||||||
|
subject = strings.TrimSpace(subject)
|
||||||
|
|
||||||
|
result.Commits = append(result.Commits, LogEntry{
|
||||||
|
Hash: commit.Hash.String(),
|
||||||
|
ShortHash: commit.Hash.String()[:7],
|
||||||
|
Author: commit.Author.Name,
|
||||||
|
Email: commit.Author.Email,
|
||||||
|
Date: commit.Author.When,
|
||||||
|
Subject: subject,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(result.Commits) >= limit {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("failed to search commits: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Count = len(result.Commits)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
446
internal/gitexplorer/client_test.go
Normal file
446
internal/gitexplorer/client_test.go
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestRepo creates a temporary git repository with some commits for testing.
|
||||||
|
func createTestRepo(t *testing.T) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir, err := os.MkdirTemp("", "gitexplorer-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := git.PlainInit(dir, false)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to init repo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wt, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to get worktree: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial file and commit
|
||||||
|
readme := filepath.Join(dir, "README.md")
|
||||||
|
if err := os.WriteFile(readme, []byte("# Test Repo\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to write README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("README.md"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := &object.Signature{
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "test@example.com",
|
||||||
|
When: time.Now().Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = wt.Commit("Initial commit", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create initial commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a second file and commit
|
||||||
|
subdir := filepath.Join(dir, "src")
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create subdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFile := filepath.Join(subdir, "main.go")
|
||||||
|
if err := os.WriteFile(mainFile, []byte("package main\n\nfunc main() {}\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to write main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("src/main.go"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add main.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig.When = time.Now().Add(-1 * time.Hour)
|
||||||
|
_, err = wt.Commit("Add main.go", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create second commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update README and commit
|
||||||
|
if err := os.WriteFile(readme, []byte("# Test Repo\n\nThis is a test repository.\n"), 0644); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to update README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := wt.Add("README.md"); err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to add updated README: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig.When = time.Now()
|
||||||
|
_, err = wt.Commit("Update README", &git.CommitOptions{Author: sig})
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to create third commit: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGitClient(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
t.Fatal("client is nil")
|
||||||
|
}
|
||||||
|
if client.defaultRemote != "origin" {
|
||||||
|
t.Errorf("defaultRemote = %q, want %q", client.defaultRemote, "origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with invalid path
|
||||||
|
_, err = NewGitClient("/nonexistent/path", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRef(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving HEAD
|
||||||
|
result, err := client.ResolveRef("HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveRef(HEAD) failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Commit == "" {
|
||||||
|
t.Error("commit hash is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving master branch
|
||||||
|
result, err = client.ResolveRef("master")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveRef(master) failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Type != "branch" {
|
||||||
|
t.Errorf("type = %q, want %q", result.Type, "branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test resolving invalid ref
|
||||||
|
_, err = client.ResolveRef("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent ref")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLog(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full log
|
||||||
|
entries, err := client.GetLog("HEAD", 10, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("got %d entries, want 3", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check order (newest first)
|
||||||
|
if entries[0].Subject != "Update README" {
|
||||||
|
t.Errorf("first entry subject = %q, want %q", entries[0].Subject, "Update README")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with limit
|
||||||
|
entries, err = client.GetLog("HEAD", 1, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with limit failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("got %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with author filter
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "Test User", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with author failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("got %d entries, want 3", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "nonexistent", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with nonexistent author failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("got %d entries, want 0", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with path filter
|
||||||
|
entries, err = client.GetLog("HEAD", 10, "", "", "src")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLog with path failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("got %d entries, want 1 (only src/main.go commit)", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCommitInfo(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetCommitInfo("HEAD", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommitInfo failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Author != "Test User" {
|
||||||
|
t.Errorf("author = %q, want %q", info.Author, "Test User")
|
||||||
|
}
|
||||||
|
if info.Email != "test@example.com" {
|
||||||
|
t.Errorf("email = %q, want %q", info.Email, "test@example.com")
|
||||||
|
}
|
||||||
|
if len(info.Parents) != 1 {
|
||||||
|
t.Errorf("parents = %d, want 1", len(info.Parents))
|
||||||
|
}
|
||||||
|
if info.Stats == nil {
|
||||||
|
t.Error("stats is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test without stats
|
||||||
|
info, err = client.GetCommitInfo("HEAD", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCommitInfo without stats failed: %v", err)
|
||||||
|
}
|
||||||
|
if info.Stats != nil {
|
||||||
|
t.Error("stats should be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDiffFiles(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.GetDiffFiles("HEAD~2", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDiffFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Files) < 1 {
|
||||||
|
t.Error("expected at least one changed file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that we have the expected files
|
||||||
|
foundReadme := false
|
||||||
|
foundMain := false
|
||||||
|
for _, f := range result.Files {
|
||||||
|
if f.Path == "README.md" {
|
||||||
|
foundReadme = true
|
||||||
|
}
|
||||||
|
if f.Path == "src/main.go" {
|
||||||
|
foundMain = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundReadme {
|
||||||
|
t.Error("expected README.md in diff")
|
||||||
|
}
|
||||||
|
if !foundMain {
|
||||||
|
t.Error("expected src/main.go in diff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFileAtCommit(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := client.GetFileAtCommit("HEAD", "README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileAtCommit failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Path != "README.md" {
|
||||||
|
t.Errorf("path = %q, want %q", content.Path, "README.md")
|
||||||
|
}
|
||||||
|
if content.Content == "" {
|
||||||
|
t.Error("content is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test nested file
|
||||||
|
content, err = client.GetFileAtCommit("HEAD", "src/main.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFileAtCommit for nested file failed: %v", err)
|
||||||
|
}
|
||||||
|
if content.Path != "src/main.go" {
|
||||||
|
t.Errorf("path = %q, want %q", content.Path, "src/main.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test nonexistent file
|
||||||
|
_, err = client.GetFileAtCommit("HEAD", "nonexistent.txt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path traversal
|
||||||
|
_, err = client.GetFileAtCommit("HEAD", "../../../etc/passwd")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for path traversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAncestor(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First commit is ancestor of HEAD
|
||||||
|
result, err := client.IsAncestor("HEAD~2", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsAncestor failed: %v", err)
|
||||||
|
}
|
||||||
|
if !result.IsAncestor {
|
||||||
|
t.Error("HEAD~2 should be ancestor of HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEAD is not ancestor of first commit
|
||||||
|
result, err = client.IsAncestor("HEAD", "HEAD~2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsAncestor failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.IsAncestor {
|
||||||
|
t.Error("HEAD should not be ancestor of HEAD~2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitsBetween(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CommitsBetween("HEAD~2", "HEAD", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CommitsBetween failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 2 commits (HEAD~1 and HEAD, exclusive of HEAD~2)
|
||||||
|
if result.Count != 2 {
|
||||||
|
t.Errorf("count = %d, want 2", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListBranches(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ListBranches(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBranches failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Total < 1 {
|
||||||
|
t.Error("expected at least one branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMaster := false
|
||||||
|
for _, b := range result.Branches {
|
||||||
|
if b.Name == "master" {
|
||||||
|
foundMaster = true
|
||||||
|
if !b.IsHead {
|
||||||
|
t.Error("master should be HEAD")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMaster {
|
||||||
|
t.Error("expected master branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchCommits(t *testing.T) {
|
||||||
|
repoPath, cleanup := createTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
client, err := NewGitClient(repoPath, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewGitClient failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.SearchCommits("HEAD", "README", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchCommits failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Count < 1 {
|
||||||
|
t.Error("expected at least one match for 'README'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search with no matches
|
||||||
|
result, err = client.SearchCommits("HEAD", "nonexistent-query-xyz", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SearchCommits for no match failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Count != 0 {
|
||||||
|
t.Errorf("count = %d, want 0", result.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
195
internal/gitexplorer/format.go
Normal file
195
internal/gitexplorer/format.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatResolveResult formats a ResolveResult as markdown.
|
||||||
|
func FormatResolveResult(r *ResolveResult) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("**Ref:** %s\n", r.Ref))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Type:** %s\n", r.Type))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", r.Commit))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLogEntries formats a slice of LogEntry as markdown.
|
||||||
|
func FormatLogEntries(entries []LogEntry) string {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return "No commits found."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Commit Log (%d commits)\n\n", len(entries)))
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCommitInfo formats a CommitInfo as markdown.
|
||||||
|
func FormatCommitInfo(info *CommitInfo) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Commit Details\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("**Hash:** %s\n", info.Hash))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", info.Author, info.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n", info.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Committer:** %s\n", info.Committer))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit Date:** %s\n", info.CommitDate.Format("2006-01-02 15:04:05")))
|
||||||
|
|
||||||
|
if len(info.Parents) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Parents:** %s\n", strings.Join(info.Parents, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Stats != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Changes:** %d file(s), +%d -%d\n",
|
||||||
|
info.Stats.FilesChanged, info.Stats.Additions, info.Stats.Deletions))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n### Message\n\n")
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
sb.WriteString(info.Message)
|
||||||
|
if !strings.HasSuffix(info.Message, "\n") {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDiffResult formats a DiffResult as markdown.
|
||||||
|
func FormatDiffResult(r *DiffResult) string {
|
||||||
|
if len(r.Files) == 0 {
|
||||||
|
return "No files changed."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Files Changed (%d files)\n\n", len(r.Files)))
|
||||||
|
sb.WriteString(fmt.Sprintf("**From:** %s\n", r.FromCommit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**To:** %s\n\n", r.ToCommit[:7]))
|
||||||
|
|
||||||
|
sb.WriteString("| Status | Path | Changes |\n")
|
||||||
|
sb.WriteString("|--------|------|--------|\n")
|
||||||
|
|
||||||
|
for _, f := range r.Files {
|
||||||
|
path := f.Path
|
||||||
|
if f.OldPath != "" {
|
||||||
|
path = fmt.Sprintf("%s → %s", f.OldPath, f.Path)
|
||||||
|
}
|
||||||
|
changes := fmt.Sprintf("+%d -%d", f.Additions, f.Deletions)
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s | %s | %s |\n", f.Status, path, changes))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatFileContent formats a FileContent as markdown.
|
||||||
|
func FormatFileContent(c *FileContent) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## File: %s\n\n", c.Path))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Commit:** %s\n", c.Commit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Size:** %d bytes\n\n", c.Size))
|
||||||
|
|
||||||
|
// Determine language hint from extension
|
||||||
|
ext := ""
|
||||||
|
if idx := strings.LastIndex(c.Path, "."); idx != -1 {
|
||||||
|
ext = c.Path[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("```%s\n", ext))
|
||||||
|
sb.WriteString(c.Content)
|
||||||
|
if !strings.HasSuffix(c.Content, "\n") {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatAncestryResult formats an AncestryResult as markdown.
|
||||||
|
func FormatAncestryResult(r *AncestryResult) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Ancestry Check\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("**Ancestor:** %s\n", r.Ancestor[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Descendant:** %s\n", r.Descendant[:7]))
|
||||||
|
|
||||||
|
if r.IsAncestor {
|
||||||
|
sb.WriteString("\n✓ **Yes**, the first commit is an ancestor of the second.\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n✗ **No**, the first commit is not an ancestor of the second.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatCommitRange formats a CommitRange as markdown.
|
||||||
|
func FormatCommitRange(r *CommitRange) string {
|
||||||
|
if r.Count == 0 {
|
||||||
|
return "No commits in range."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Commits Between (%d commits)\n\n", r.Count))
|
||||||
|
sb.WriteString(fmt.Sprintf("**From:** %s (exclusive)\n", r.FromCommit[:7]))
|
||||||
|
sb.WriteString(fmt.Sprintf("**To:** %s (inclusive)\n\n", r.ToCommit[:7]))
|
||||||
|
|
||||||
|
for _, e := range r.Commits {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s** %s (%s)\n", e.ShortHash, e.Subject, e.Author))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatBranchList formats a BranchList as markdown.
|
||||||
|
func FormatBranchList(r *BranchList) string {
|
||||||
|
if r.Total == 0 {
|
||||||
|
return "No branches found."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Branches (%d total)\n\n", r.Total))
|
||||||
|
|
||||||
|
if r.Current != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("**Current branch:** %s\n\n", r.Current))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("| Branch | Commit | Type |\n")
|
||||||
|
sb.WriteString("|--------|--------|------|\n")
|
||||||
|
|
||||||
|
for _, b := range r.Branches {
|
||||||
|
branchType := "local"
|
||||||
|
if b.IsRemote {
|
||||||
|
branchType = "remote"
|
||||||
|
}
|
||||||
|
marker := ""
|
||||||
|
if b.IsHead {
|
||||||
|
marker = " ✓"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("| %s%s | %s | %s |\n", b.Name, marker, b.Commit[:7], branchType))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSearchResult formats a SearchResult as markdown.
|
||||||
|
func FormatSearchResult(r *SearchResult) string {
|
||||||
|
if r.Count == 0 {
|
||||||
|
return fmt.Sprintf("No commits found matching '%s'.", r.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("## Search Results for '%s' (%d matches)\n\n", r.Query, r.Count))
|
||||||
|
|
||||||
|
for _, e := range r.Commits {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %s %s\n", e.ShortHash, e.Subject))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Author:** %s <%s>\n", e.Author, e.Email))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Date:** %s\n\n", e.Date.Format("2006-01-02 15:04:05")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
440
internal/gitexplorer/handlers.go
Normal file
440
internal/gitexplorer/handlers.go
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterHandlers registers all git-explorer tool handlers on the MCP server.
|
||||||
|
func RegisterHandlers(server *mcp.Server, client *GitClient) {
|
||||||
|
server.RegisterTool(resolveRefTool(), makeResolveRefHandler(client))
|
||||||
|
server.RegisterTool(getLogTool(), makeGetLogHandler(client))
|
||||||
|
server.RegisterTool(getCommitInfoTool(), makeGetCommitInfoHandler(client))
|
||||||
|
server.RegisterTool(getDiffFilesTool(), makeGetDiffFilesHandler(client))
|
||||||
|
server.RegisterTool(getFileAtCommitTool(), makeGetFileAtCommitHandler(client))
|
||||||
|
server.RegisterTool(isAncestorTool(), makeIsAncestorHandler(client))
|
||||||
|
server.RegisterTool(commitsBetweenTool(), makeCommitsBetweenHandler(client))
|
||||||
|
server.RegisterTool(listBranchesTool(), makeListBranchesHandler(client))
|
||||||
|
server.RegisterTool(searchCommitsTool(), makeSearchCommitsHandler(client))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definitions
|
||||||
|
|
||||||
|
func resolveRefTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "resolve_ref",
|
||||||
|
Description: "Resolve a git ref (branch, tag, or commit hash) to its full commit hash",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Git ref to resolve (e.g., 'main', 'v1.0.0', 'HEAD', commit hash)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_log",
|
||||||
|
Description: "Get commit log starting from a ref, with optional filters",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting ref for the log (default: HEAD)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of commits to return (default: 20, max: %d)", Limits.MaxLogEntries),
|
||||||
|
Default: 20,
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter by author name or email (substring match)",
|
||||||
|
},
|
||||||
|
"since": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Stop log at this ref (exclusive)",
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Filter commits that affect this path",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCommitInfoTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_commit_info",
|
||||||
|
Description: "Get full details for a specific commit including message, author, and optionally file statistics",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Commit ref (hash, branch, tag, or HEAD)",
|
||||||
|
},
|
||||||
|
"include_stats": {
|
||||||
|
Type: "boolean",
|
||||||
|
Description: "Include file change statistics (default: true)",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDiffFilesTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_diff_files",
|
||||||
|
Description: "Get list of files changed between two commits with change type and line counts",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"from_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting commit ref (the older commit)",
|
||||||
|
},
|
||||||
|
"to_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Ending commit ref (the newer commit)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"from_ref", "to_ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFileAtCommitTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "get_file_at_commit",
|
||||||
|
Description: fmt.Sprintf("Get the contents of a file at a specific commit (max %dKB)", Limits.MaxFileContent/1024),
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Commit ref (hash, branch, tag, or HEAD)",
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Path to the file relative to repository root",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ref", "path"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAncestorTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "is_ancestor",
|
||||||
|
Description: "Check if one commit is an ancestor of another",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"ancestor": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Potential ancestor commit ref",
|
||||||
|
},
|
||||||
|
"descendant": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Potential descendant commit ref",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"ancestor", "descendant"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitsBetweenTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "commits_between",
|
||||||
|
Description: "Get all commits between two refs (from is exclusive, to is inclusive)",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"from_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting commit ref (exclusive - commits after this)",
|
||||||
|
},
|
||||||
|
"to_ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Ending commit ref (inclusive - up to and including this)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of commits (default: %d)", Limits.MaxLogEntries),
|
||||||
|
Default: Limits.MaxLogEntries,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"from_ref", "to_ref"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listBranchesTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "list_branches",
|
||||||
|
Description: "List all branches in the repository",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"include_remote": {
|
||||||
|
Type: "boolean",
|
||||||
|
Description: "Include remote-tracking branches (default: false)",
|
||||||
|
Default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchCommitsTool() mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "search_commits",
|
||||||
|
Description: "Search commit messages for a pattern (case-insensitive)",
|
||||||
|
InputSchema: mcp.InputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]mcp.Property{
|
||||||
|
"query": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Search pattern to match in commit messages",
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
Type: "string",
|
||||||
|
Description: "Starting ref for the search (default: HEAD)",
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
Type: "integer",
|
||||||
|
Description: fmt.Sprintf("Maximum number of results (default: 20, max: %d)", Limits.MaxSearchResult),
|
||||||
|
Default: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"query"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler constructors
|
||||||
|
|
||||||
|
func makeResolveRefHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ResolveRef(ref)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatResolveResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetLogHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref := "HEAD"
|
||||||
|
if r, ok := args["ref"].(string); ok && r != "" {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
author, _ := args["author"].(string)
|
||||||
|
since, _ := args["since"].(string)
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
|
||||||
|
entries, err := client.GetLog(ref, limit, author, since, path)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatLogEntries(entries))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetCommitInfoHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
includeStats := true
|
||||||
|
if s, ok := args["include_stats"].(bool); ok {
|
||||||
|
includeStats = s
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := client.GetCommitInfo(ref, includeStats)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatCommitInfo(info))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetDiffFilesHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
fromRef, _ := args["from_ref"].(string)
|
||||||
|
if fromRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toRef, _ := args["to_ref"].(string)
|
||||||
|
if toRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.GetDiffFiles(fromRef, toRef)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatDiffResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGetFileAtCommitHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ref, _ := args["ref"].(string)
|
||||||
|
if ref == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("path is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := client.GetFileAtCommit(ref, path)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatFileContent(content))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeIsAncestorHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
ancestor, _ := args["ancestor"].(string)
|
||||||
|
if ancestor == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("ancestor is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
descendant, _ := args["descendant"].(string)
|
||||||
|
if descendant == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("descendant is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.IsAncestor(ancestor, descendant)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatAncestryResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCommitsBetweenHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
fromRef, _ := args["from_ref"].(string)
|
||||||
|
if fromRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("from_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
toRef, _ := args["to_ref"].(string)
|
||||||
|
if toRef == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("to_ref is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := Limits.MaxLogEntries
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.CommitsBetween(fromRef, toRef, limit)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatCommitRange(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeListBranchesHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
includeRemote := false
|
||||||
|
if r, ok := args["include_remote"].(bool); ok {
|
||||||
|
includeRemote = r
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.ListBranches(includeRemote)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatBranchList(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSearchCommitsHandler(client *GitClient) mcp.ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (mcp.CallToolResult, error) {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
if query == "" {
|
||||||
|
return mcp.ErrorContent(fmt.Errorf("query is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := "HEAD"
|
||||||
|
if r, ok := args["ref"].(string); ok && r != "" {
|
||||||
|
ref = r
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if l, ok := args["limit"].(float64); ok && l > 0 {
|
||||||
|
limit = int(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := client.SearchCommits(ref, query, limit)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{mcp.TextContent(FormatSearchResult(result))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/gitexplorer/types.go
Normal file
121
internal/gitexplorer/types.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveResult contains the result of resolving a ref to a commit.
|
||||||
|
type ResolveResult struct {
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Type string `json:"type"` // "branch", "tag", "commit"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry represents a single commit in the log.
|
||||||
|
type LogEntry struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
ShortHash string `json:"short_hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitInfo contains full details about a commit.
|
||||||
|
type CommitInfo struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Committer string `json:"committer"`
|
||||||
|
CommitDate time.Time `json:"commit_date"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Parents []string `json:"parents"`
|
||||||
|
Stats *FileStats `json:"stats,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStats contains statistics about file changes.
|
||||||
|
type FileStats struct {
|
||||||
|
FilesChanged int `json:"files_changed"`
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffFile represents a file changed between two commits.
|
||||||
|
type DiffFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OldPath string `json:"old_path,omitempty"` // For renames
|
||||||
|
Status string `json:"status"` // "added", "modified", "deleted", "renamed"
|
||||||
|
Additions int `json:"additions"`
|
||||||
|
Deletions int `json:"deletions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffResult contains the list of files changed between two commits.
|
||||||
|
type DiffResult struct {
|
||||||
|
FromCommit string `json:"from_commit"`
|
||||||
|
ToCommit string `json:"to_commit"`
|
||||||
|
Files []DiffFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileContent represents the content of a file at a specific commit.
|
||||||
|
type FileContent struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AncestryResult contains the result of an ancestry check.
|
||||||
|
type AncestryResult struct {
|
||||||
|
Ancestor string `json:"ancestor"`
|
||||||
|
Descendant string `json:"descendant"`
|
||||||
|
IsAncestor bool `json:"is_ancestor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitRange represents commits between two refs.
|
||||||
|
type CommitRange struct {
|
||||||
|
FromCommit string `json:"from_commit"`
|
||||||
|
ToCommit string `json:"to_commit"`
|
||||||
|
Commits []LogEntry `json:"commits"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch represents a git branch.
|
||||||
|
type Branch struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
IsRemote bool `json:"is_remote"`
|
||||||
|
IsHead bool `json:"is_head"`
|
||||||
|
Upstream string `json:"upstream,omitempty"`
|
||||||
|
AheadBy int `json:"ahead_by,omitempty"`
|
||||||
|
BehindBy int `json:"behind_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchList contains the list of branches.
|
||||||
|
type BranchList struct {
|
||||||
|
Branches []Branch `json:"branches"`
|
||||||
|
Current string `json:"current"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult represents a commit matching a search query.
|
||||||
|
type SearchResult struct {
|
||||||
|
Commits []LogEntry `json:"commits"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits defines the maximum values for various operations.
|
||||||
|
var Limits = struct {
|
||||||
|
MaxFileContent int64 // Maximum file size in bytes
|
||||||
|
MaxLogEntries int // Maximum commit log entries
|
||||||
|
MaxBranches int // Maximum branches to list
|
||||||
|
MaxDiffFiles int // Maximum files in diff
|
||||||
|
MaxSearchResult int // Maximum search results
|
||||||
|
}{
|
||||||
|
MaxFileContent: 100 * 1024, // 100KB
|
||||||
|
MaxLogEntries: 100,
|
||||||
|
MaxBranches: 500,
|
||||||
|
MaxDiffFiles: 1000,
|
||||||
|
MaxSearchResult: 100,
|
||||||
|
}
|
||||||
57
internal/gitexplorer/validation.go
Normal file
57
internal/gitexplorer/validation.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrPathTraversal is returned when a path attempts to traverse outside the repository.
|
||||||
|
ErrPathTraversal = errors.New("path traversal not allowed")
|
||||||
|
// ErrAbsolutePath is returned when an absolute path is provided.
|
||||||
|
ErrAbsolutePath = errors.New("absolute paths not allowed")
|
||||||
|
// ErrNullByte is returned when a path contains null bytes.
|
||||||
|
ErrNullByte = errors.New("null bytes not allowed in path")
|
||||||
|
// ErrEmptyPath is returned when a path is empty.
|
||||||
|
ErrEmptyPath = errors.New("path cannot be empty")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatePath validates a file path for security.
|
||||||
|
// It rejects:
|
||||||
|
// - Absolute paths
|
||||||
|
// - Paths containing null bytes
|
||||||
|
// - Paths that attempt directory traversal (contain "..")
|
||||||
|
// - Empty paths
|
||||||
|
func ValidatePath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return ErrEmptyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if strings.Contains(path, "\x00") {
|
||||||
|
return ErrNullByte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for absolute paths
|
||||||
|
if filepath.IsAbs(path) {
|
||||||
|
return ErrAbsolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path and check for traversal
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Check if cleaned path starts with ".."
|
||||||
|
if strings.HasPrefix(cleaned, "..") {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for ".." components in the path
|
||||||
|
parts := strings.Split(cleaned, string(filepath.Separator))
|
||||||
|
if slices.Contains(parts, "..") {
|
||||||
|
return ErrPathTraversal
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
91
internal/gitexplorer/validation_test.go
Normal file
91
internal/gitexplorer/validation_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package gitexplorer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
// Valid paths
|
||||||
|
{
|
||||||
|
name: "simple file",
|
||||||
|
path: "README.md",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested file",
|
||||||
|
path: "internal/gitexplorer/types.go",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file with dots",
|
||||||
|
path: "file.test.go",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current dir prefix",
|
||||||
|
path: "./README.md",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deeply nested",
|
||||||
|
path: "a/b/c/d/e/f/g.txt",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invalid paths
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
path: "",
|
||||||
|
wantErr: ErrEmptyPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path unix",
|
||||||
|
path: "/etc/passwd",
|
||||||
|
wantErr: ErrAbsolutePath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal simple",
|
||||||
|
path: "../secret.txt",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal nested",
|
||||||
|
path: "foo/../../../etc/passwd",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent dir traversal in middle",
|
||||||
|
path: "foo/bar/../../../secret",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null byte",
|
||||||
|
path: "file\x00.txt",
|
||||||
|
wantErr: ErrNullByte,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null byte in middle",
|
||||||
|
path: "foo/bar\x00baz/file.txt",
|
||||||
|
wantErr: ErrNullByte,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double dot only",
|
||||||
|
path: "..",
|
||||||
|
wantErr: ErrPathTraversal,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidatePath(tt.path)
|
||||||
|
if err != tt.wantErr {
|
||||||
|
t.Errorf("ValidatePath(%q) = %v, want %v", tt.path, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,27 @@ This ensures option documentation matches the home-manager version the project a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultGitExplorerConfig returns the default configuration for the git-explorer server.
|
||||||
|
func DefaultGitExplorerConfig() ServerConfig {
|
||||||
|
return ServerConfig{
|
||||||
|
Name: "git-explorer",
|
||||||
|
Version: "0.1.0",
|
||||||
|
Mode: ModeCustom,
|
||||||
|
Instructions: `Git Explorer MCP Server - Read-only access to git repository information.
|
||||||
|
|
||||||
|
Tools for exploring git repositories:
|
||||||
|
- Resolve refs (branches, tags, commits) to commit hashes
|
||||||
|
- View commit logs with filtering by author, path, or range
|
||||||
|
- Get full commit details including file change statistics
|
||||||
|
- Compare commits to see which files changed
|
||||||
|
- Read file contents at any commit
|
||||||
|
- Check ancestry relationships between commits
|
||||||
|
- Search commit messages
|
||||||
|
|
||||||
|
All operations are read-only and will never modify the repository.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server is an MCP server that handles JSON-RPC requests.
|
// Server is an MCP server that handles JSON-RPC requests.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store database.Store
|
store database.Store
|
||||||
|
|||||||
141
nix/git-explorer-module.nix
Normal file
141
nix/git-explorer-module.nix
Normal 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 ];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ buildGoModule {
|
|||||||
inherit pname src;
|
inherit pname src;
|
||||||
version = "0.3.0";
|
version = "0.3.0";
|
||||||
|
|
||||||
vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY=";
|
vendorHash = "sha256-XrTtiaQT5br+0ZXz8//rc04GZn/HlQk7l8Nx/+Uil/I=";
|
||||||
|
|
||||||
subPackages = [ subPackage ];
|
subPackages = [ subPackage ];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user