Compare commits

...

5 Commits

Author SHA1 Message Date
TBNilles d3abd4416e Fix MCP tools/list rejection: wrap list_repos slice in object
The go-sdk infers each tool's outputSchema from its handler result
type, and MCP structured output must be type "object". list_repos
returned []repos.State, yielding outputSchema.type "array", which
Claude Desktop rejects at tools/list — taking the whole server down.

Wrap the slice in listReposOutput{Repos: ...} so the schema is an
object, update the round-trip test, and record the struct-result rule
in AGENT.md §8.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:00:30 -04:00
TBNilles e3336efc25 Correct Claude Desktop connection: local stdio bridge, not GUI connector
The GUI custom-connector flow validates/calls from Anthropic's cloud and can't reach a localhost server, so a 127.0.0.1 URL fails there. The working path is a local mcp-remote stdio bridge configured in claude_desktop_config.json pointing at http://127.0.0.1:8080/mcp. Updated AGENT.md 8.1/11 and CHANGELOG. HTTPS on :8443 stays available but optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 07:52:31 -04:00
TBNilles f23f2f2b30 Serve MCP over HTTPS for the Claude Desktop connector
Claude Desktop's custom connector only accepts https URLs. Added an optional TLS listener (HTTPS_ADDR + TLS_CERT_FILE/TLS_KEY_FILE) alongside HTTP; docker-compose publishes 127.0.0.1:8443 and mounts a local mkcert cert from certs/ (git-ignored). Best-effort: a missing cert logs a warning and stays HTTP-only. Verified the Windows store trusts the mkcert cert and MCP initialize succeeds over https://127.0.0.1:8443/mcp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 07:40:21 -04:00
TBNilles 2d7f814a23 Add service layer and MCP server with read tools
Slice 1: internal/service is the one capability layer both the HTTP API and the MCP server call (AGENT.md 1.7); /api/repos and /api/repo route through it. Slice 2: internal/mcp serves an MCP server over Streamable HTTP at /mcp (go-sdk v1.8.0) with read tools list_repos and get_repo as thin adapters over the service, plus a round-trip test. Enabled air polling so hot reload works across the Docker-on-Windows bind mount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:18:40 -04:00
TBNilles dfa45de40c Redefine contract: two-way Claude companion (MCP + handoff + forge writes)
AGENT.md now makes the Claude integration the defining pillar: law 1.7 (one service layer behind GUI + MCP), MCP server over Streamable HTTP at /mcp, SSE app->browser, Gitea-first read+write forge, right-click command vocabulary, and rewritten section 8 (MCP server, activity feed, graceful project handoff, merge & clean up). No code yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 17:11:51 -04:00
13 changed files with 587 additions and 38 deletions
+5
View File
@@ -12,6 +12,11 @@ tmp_dir = "tmp"
exclude_dir = ["tmp", "bin", ".git", "repos"]
delay = 500
stop_on_error = true
# Poll for changes instead of relying on fsnotify: filesystem events do NOT
# cross the Windows host -> Linux container bind mount, so watch-based reload
# silently never fires. Polling is the reliable option in Docker on Windows.
poll = true
poll_interval = 500
[log]
time = true
+21 -2
View File
@@ -7,6 +7,20 @@
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately.
LISTEN_ADDR=127.0.0.1:8080
# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only
# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS
# listener starts alongside HTTP. docker-compose sets these to the mounted certs.
# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA
# your OS — and Claude Desktop — will trust), from the project root:
# winget install FiloSottile.mkcert
# mkcert -install # trust step (adds the local CA)
# mkdir certs
# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
# Then connect Claude Desktop to https://localhost:8443/mcp
HTTPS_ADDR=
TLS_CERT_FILE=
TLS_KEY_FILE=
# Roots to scan for Git repositories, comma-separated (absolute paths).
# Inside Docker these must be the *container* paths that the host roots are
# mounted to (see docker-compose.yml). Example: /repos,/work/other
@@ -44,7 +58,12 @@ APP_ENV=dev
# Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE=
# --- Forge integration — OPTIONAL, read-only, token-gated (AGENT.md §8) -----
# With no token the feature is simply absent; the rest of the app is unaffected.
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
# The primary host is a self-hosted Gitea/Forgejo (git.nilles.net). With no
# token the forge features are simply absent; the rest of the app is unaffected.
# Writes (merge PR + delete branch, for "Merge & clean up") are each confirmed
# per AGENT.md §1.4. The token needs repo read + PR write + branch delete scope.
GITEA_TOKEN=
# Later providers, behind the same interface (unused for now):
GITHUB_TOKEN=
GITLAB_TOKEN=
+3
View File
@@ -10,6 +10,9 @@ gitmanager.exe
# Logs
*.log
# Local TLS certificates (generated with mkcert; never commit)
/certs/
# Editor / OS
.DS_Store
.idea/
+172 -27
View File
@@ -22,6 +22,23 @@ dockable UI panels and is architected so that host/forge integrations
(GitHub/GitLab pull and mergerequests) can be attached without touching the
core.
**The larger purpose — a twoway companion to Claude.** The dashboard is the
visible half. The app is also built to work *with* Claude (used from Claude
Desktop / Claude Code) in both directions:
- **Claude → app:** the app is an **MCP server** Claude can drive — list/switch
repos, run Git operations, and create/merge/cleanup pull requests — so Claude
can manage repositories on the user's behalf.
- **App → Claude:** when the user does something in the app (switches project,
merges a PR), the app makes that known so Claude stays in sync. The headline
case is a **graceful project handoff**: the user switches project in the app,
Claude finishes to a safe stopping point, switches, and the user is notified
(Section 8).
The intended user is a developer who does **not** want to memorize Git commands:
the GUI offers plainlanguage, rightclick commands (Section 6), and Claude can
run the same operations through the MCP surface. This is why the app exists — a
plain dashboard is only step one.
This is a **dev environment first**. It is expected to grow continuously via
incremental requests ("add X functionality"). Every addition must keep the
selfdocumenting workflow in Section 9 intact.
@@ -143,6 +160,18 @@ The app must be runnable by a new developer with: clone → copy `.env.example`
`.env` → set the repo roots → `docker compose up`. Document any hostside
prerequisites (SSH agent, credential helper) in `.env.example` and the help page.
### 1.7 One service layer behind both the GUI and the MCP server
The web UI and the MCP server (Section 8) are **two front doors to the same
capabilities** — never two implementations. Every operation (a Git action, a
forge action, switching the active project) lives once in an internal service
that goes through the `internal/git` and `internal/forge` boundaries; the Echo
HTTP handlers and the MCP tool handlers are **thin adapters** that call it. A
capability added for the GUI is therefore available to Claude, and vice versa,
and safety rules (§1.4) are enforced in the shared layer so neither front door
can bypass them. Do not implement a Git/forge operation directly in an HTTP or
MCP handler.
---
## 2. Technology stack (locked unless told otherwise)
@@ -154,7 +183,9 @@ prerequisites (SSH agent, credential helper) in `.env.example` and the help page
| Page rendering | Go serverrendered HTML shell | Server emits the page + declares web components; components fetch their own data. Use `html/template`. |
| Git access | **System `git` via `os/exec`**, wrapped behind an `internal/git` interface | The system binary is authoritative: it honors the user's credential helpers, SSH keys, hooks, and config exactly. PureGo `go-git` may be proposed for cheap readonly queries, but only behind the same interface and only with approval. |
| Repo discovery / index | **Inmemory cache + `github.com/fsnotify/fsnotify`** (optional) | Scan roots for `.git`, hold an index, refresh on interval and/or on filesystem change. |
| Forge integration (PRs/MRs) | **Providerabstracted** (`internal/forge`) — GitHub via `github.com/google/go-github`, GitLab via `gitlab.com/gitlab-org/api/client-go` | Optional, readonly by default, enabled perhost when a token is configured. See Section 8. |
| Forge integration (PRs/MRs) | **Providerabstracted** (`internal/forge`) — **Gitea/Forgejo first** via `code.gitea.io/sdk/gitea`; GitHub (`github.com/google/go-github`) / GitLab later behind the same interface | **Read/write**, tokengated per host. Writes (merge PR, delete branch) are confirmed per §1.4. See Section 8. The primary host is a selfhosted Gitea (`git.nilles.net`). |
| MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. |
| Realtime (app → browser) | **ServerSent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. |
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. |
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). |
@@ -183,7 +214,10 @@ Section 0).
│ ├── config/ # .env loading, typed config struct
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
│ ├── forge/ # OPTIONAL seam: GitHub/GitLab PR/MR + remote metadata (provider-abstracted)
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
│ ├── forge/ # provider-abstracted PR/MR read+write (Gitea first) — Section 8
│ ├── mcp/ # MCP server: tool handlers (thin adapters over service) — Section 8
│ ├── activity/ # active-project state, activity feed, pending-switch handoff — Section 8
│ ├── logging/ # slog handler → stdout/stderr (+ optional file)
│ └── render/ # html/template page shell rendering
├── components/ # ALL web components live here (Section 1.2)
@@ -266,6 +300,13 @@ selffetching, independent lifecycle, cleanup on disconnect). Expected surface
- A **repo detail panel** for the selected repo: branches, remotes, recent commit
log, stashes, tags.
- **Diff / commit views** rendered from serverproduced unified diffs.
- A **rightclick context menu** on any item (repo, branch, PR, stash) is the
primary way commands are run. This is the app's reason for being (Section 0):
commands read in **plain language** for people who don't memorize Git — e.g.
"Get latest" (pull), "Save my work" (commit), "Publish" (push), "Merge &
clean up" (merge PR + delete branch). Keep a friendlyname → Git/forgeop
vocabulary; the same operations are exposed to Claude as MCP tools (Section 8),
both calling the one service layer (§1.7).
- An **action surface** for Git operations. Safe operations (fetch, pull,
checkout, create branch, stage, commit, push) can proceed on a normal click;
**destructive operations follow Section 1.4** (explicit control + confirmation
@@ -274,8 +315,8 @@ selffetching, independent lifecycle, cleanup on disconnect). Expected surface
`repo:select` event the detail panel listens for) — never shared globals.
Server endpoints return JSON for the components to selffetch; mutating endpoints
route through `internal/git` and trigger an index refresh for the affected repo so
the UI reflects reality without a full rescan.
route through the service layer (§1.7) and trigger an index refresh for the
affected repo so the UI reflects reality without a full rescan.
---
@@ -292,25 +333,107 @@ the UI reflects reality without a full rescan.
---
## 8. Forge integration — OPTIONAL seam (PRs / MRs)
## 8. Claude integration (MCP server · activity feed · graceful handoff · forge)
Viewing pull/merge requests requires talking to a hosting provider, which is
**outside** the "Git is the store" core. Keep it isolated and optional.
This is the app's defining pillar (Section 0): GitManager works *with* Claude in
both directions. Everything here goes through the one service layer (§1.7) and
obeys the safety rules (§1.4).
- Live in `internal/forge` behind a **provider interface** so GitHub, GitLab, and
others can drop in without touching the dashboard or the Git boundary.
- **Readonly by default**: list open PRs/MRs and their CI/check status for a repo
whose remote points at a supported host. Any write action (comment, merge,
approve) is a **separate, explicitlyrequested** capability — do not build write
paths without asking, and route them through Section 1.4 if destructive.
### 8.1 MCP server — Claude drives the app (`internal/mcp`)
- The app serves an **MCP endpoint over Streamable HTTP at `/mcp`** using
`github.com/modelcontextprotocol/go-sdk`. Like the rest of the app it is
**localhostbound and unauthenticated** (Section 0) — do not expose it offhost.
- **How Claude Desktop connects — the local stdio bridge, NOT the GUI connector.**
The "Add custom connector" GUI is for **remote, publiclyreachable** servers:
it probes (and would call tools) **from Anthropic's cloud**, which cannot reach
`127.0.0.1`. So a localhost URL there fails "couldn't reach the server" even
though a local browser reaches it. The working path is a **local stdio bridge**
configured in `claude_desktop_config.json` under `mcpServers`, launched on the
user's machine:
```json
"gitmanager": { "command": "cmd",
"args": ["/c","npx","-y","mcp-remote","http://127.0.0.1:8080/mcp"] }
```
`mcp-remote` runs locally and speaks stdio to Claude Desktop, so it reaches the
local endpoint directly and needs **no public exposure and no HTTPS**. Do **not**
reach for a public tunnel — that would expose an unauthenticated repomanagement
app to the internet.
- **HTTPS is still available** (`:8443`, mkcert cert) for clients that require it,
but is not needed for the stdiobridge path above.
- MCP **tools are thin adapters** over the service layer — no Git/forge logic in
the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`.
- Act: `git_status/checkout/commit/push/pull/create_branch`,
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`.
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The
go-sdk infers each tool's `outputSchema` from its handler's result type, and MCP
structured output must be a JSON **object** (`type: "object"`). A handler that
returns `[]T` yields `outputSchema.type: "array"`, which Claude Desktop rejects
at `tools/list` — and one bad tool takes the whole server down. Wrap any
collection result in a named output struct (e.g. `list_repos` returns
`listReposOutput{ Repos []repos.State }`, not `[]repos.State`). Fixed 2026-09-20.
- **Destructive tools carry the §1.4 contract into MCP:** they describe exactly
what they will do and default to the safe variant. The confirmation is the
human's — surfaced through Claude and/or the app UI — not something the tool
silently assumes.
### 8.2 Activity feed & active project (`internal/activity`)
- The app keeps, in memory (and mirrored to the logs — no new datastore, §1.3):
- the **active project** (the single repo/task currently in focus), and
- an **activity feed** of what happened (user *and* Claude actions: repo
switched, committed, PR merged, …).
- Both are **queryable** (`get_active_project`, `get_activity`) so Claude can
**sync on any turn boundary** — the reliable, pullbased foundation. The app
also **pushes** these to the browser over SSE for live UI. This pullfirst
design does not depend on the host letting a connector wake Claude.
### 8.3 Graceful project handoff (the headline flow)
When the user switches project/task in the app, it is a **request**, not an
instant yank. The cooperative protocol:
1. **User** picks a new project/task in the app → the app records a
**pendingswitch request** (target + optional note) and the UI shows
"waiting for Claude to reach a good stopping point."
2. **Claude** sees the pending request (it checks at its natural turnboundary
checkpoints via `get_pending_switch`). It **finishes to a safe stopping
point and preserves work** — never abandons uncommitted changes to switch;
it completes the inflight step and commits/stashes as appropriate — then
performs the switch (`set_active_project`, moving its working context to the
new repo) and calls **`ack_switch`** with a short summary of where it left
the previous project.
3. **App** marks the request fulfilled and **notifies the user** over SSE
("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds.
**Rule:** the switch is Claudecompleted at a checkpoint, not appforced. Losing
or interrupting uncommitted work to satisfy a switch is a §1.4class violation.
> Fully autonomous "Claude starts working the instant you click, with no turn
> from you" is intentionally **not** assumed — it depends on host push support.
> Build 8.28.3 pullfirst; layer any autowake on top only where the host allows.
### 8.4 Forge integration — read **and write** (`internal/forge`)
Talking to the hosting provider is isolated behind a **provider interface**
(**Gitea/Forgejo first** — the primary host is `git.nilles.net`; GitHub/GitLab
later behind the same interface).
- **Read:** list open PRs/MRs and their CI/check status for a repo whose remote
points at a supported host.
- **Write (enabled):** create a PR, **merge a PR, and delete the source branch**
— this powers "**Merge & clean up**", the feature that makes PRs usable for a
user who otherwise finds them clutter (Section 0). Every write is **confirmed
per §1.4**, names the PR/branch, and prefers the tidy default (squashmerge +
delete branch). Note a merged PR remains in the host's history; "clean up"
means removing the **branch**, not falsifying history.
- **Enablement is perhost and tokengated.** Tokens come from `.env`
(e.g. `GITHUB_TOKEN`, `GITLAB_TOKEN`); with no token, the feature is simply
absent and the rest of the app works unchanged (**graceful degradation** — never
a hard dependency).
- The provider is inferred from a repo's remote URL. Never send repo data to a host
the user didn't configure.
Design the interface cleanly now; implement providers incrementally as requested.
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and
the rest of the app works unchanged (**graceful degradation**).
- The provider is inferred from a repo's remote URL. Never send repo data to a
host the user didn't configure.
---
@@ -373,9 +496,12 @@ component carries its own context.
2. **Check the rules** in Section 1. If the request conflicts, **pause and ask.**
3. If the work is UI: it is a **web component** in `components/<name>/` with its
own `.md`. No exceptions without asking.
4. If it touches repositories: go through the **`internal/git` boundary**, respect
**Git = system of record** (Section 1.3), and treat **destructive operations**
per Section 1.4. Never add a datastore.
4. If it touches repositories or forges: put the logic in the **service layer**
(§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP
or MCP handler — so both the GUI and Claude get it. Respect **Git = system of
record** (§1.3), treat **destructive operations** per §1.4, and never add a
datastore. A new capability generally means: service method → HTTP handler →
MCP tool → UI control.
5. If it needs a **new dependency** or a **new toplevel folder**, propose it and
wait for approval.
6. Implement, run it in the **dockercompose dev environment**, verify hot reload
@@ -396,10 +522,29 @@ silently guess.)*
it should be enabled, and the interval / rate limit, before turning it on.
- **Git access library:** system `git` via `os/exec` is the locked default
(Section 2). Confirm before introducing `go-git` for any read path.
- **Forge providers:** which to support first (GitHub? GitLab?), and whether any
**write** actions (merge/comment/approve) are ever in scope (default: readonly).
- **Listen address / exposure:** localhostonly by default. Confirm before binding
to a nonlocal interface — there is no auth (Section 0).
- ✅ **RESOLVED 2026-09-19:** **Forge = Gitea/Forgejo first** (`git.nilles.net`),
**read + write** — merge PR + delete branch ("Merge & clean up"), each confirmed
per §1.4 (Section 8.4). GitHub/GitLab later behind the same interface.
- ✅ **RESOLVED 2026-09-19:** **MCP transport = Streamable HTTP at `/mcp`**, added
in Claude Desktop as a custom connector (Section 8.1).
- ✅ **RESOLVED 2026-09-19:** **Project handoff is cooperative** — user requests a
switch, Claude finishes to a safe checkpoint, switches, and the user is notified;
pullfirst, not autonomous (Section 8.3).
- ✅ **RESOLVED 2026-09-20:** Claude Desktop's **GUI "custom connector" cannot
reach a localhost server** — it validates/calls from Anthropic's cloud. Solved
with the **local stdio bridge** (`claude_desktop_config.json` → `mcpServers` →
`mcp-remote http://127.0.0.1:8080/mcp`), §8.1. (HTTPS on `:8443` via mkcert was
added earlier and still works, but is not required for this path.)
- **Idletrigger for handoff:** the pull model syncs at Claude's turn boundaries.
If Claude is idle when the user switches, decide the nudge (user's next message,
a heartbeat/poll, or a host push if available) — do not assume instant wake.
- **Coordinationstate lifetime:** active project / pendingswitch / activity feed
are inmemory today (§1.3). Confirm if any must survive an app restart before
adding any persistence.
- **Gitea token scope:** which token scopes to require (repo read + PR write +
branch delete) and how it is provisioned; document in `.env.example`.
- **Listen address / exposure:** localhostonly by default (covers `/mcp` too).
Confirm before binding to a nonlocal interface — there is no auth (Section 0).
- **Credential path from the container:** SSH agent socket vs mounted keys vs
credential helper, for pushing/fetching from inside Docker.
+66
View File
@@ -32,3 +32,69 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
canonical import path (e.g. a GitHub URL).
- All items in AGENT.md §11 (discovery strategy, background fetch, forge
providers, listen address, container credentials) remain open.
## 2026-09-19 — Redefine the app as a two-way Claude companion (contract only)
- **What:** Updated AGENT.md to make the Claude integration the defining pillar,
no code yet. §0 now states the two-way purpose (Claude↔app) and the
non-expert, GUI-first goal; added law §1.7 (one service layer behind both the
GUI and the MCP server); stack table gained MCP server (Go SDK over Streamable
HTTP at `/mcp`), SSE (app→browser), and flipped forge to Gitea-first read+write;
layout added `internal/{service,mcp,activity}`; §6 added the right-click
plain-language command vocabulary; **§8 rewritten** into "Claude integration"
(8.1 MCP server, 8.2 activity feed + active project, 8.3 graceful project
handoff, 8.4 forge read+write with "Merge & clean up"); §10 step 4 and §11
updated (three decisions resolved, new open items). `.env.example` now documents
`GITEA_TOKEN` (read+write scope).
- **Why:** Thomas described the real vision — the app should act as an extension
of Claude: usable like an MCP by Claude, notifying Claude of in-app actions to
stay in sync, cooperative project handoff when he's interrupted, plain-language
right-click commands for non-experts, and one-click "merge & clean up" so PRs
stop cluttering repos. Decisions locked: cooperative pull-first handoff; Gitea
writes enabled (confirmed per §1.4); MCP over HTTP `/mcp`.
- **Affects:** `AGENT.md`, `.env.example` (architecture/contract only — no code).
## 2026-09-19 — Service layer + MCP server (Claude integration, read tools)
- **What:** Slice 1 — extracted `internal/service`, the one capability layer both
the HTTP API and the MCP server call (§1.7); the `/api/repos` and `/api/repo`
handlers now route through it. Slice 2 — added `internal/mcp`: an MCP server
(`github.com/modelcontextprotocol/go-sdk` v1.8.0) served over Streamable HTTP at
`/mcp`, with read tools `list_repos` and `get_repo` as thin adapters over the
service. Added a round-trip test (`internal/mcp/mcp_test.go`) using a real temp
git repo + the in-memory MCP transport. Verified the HTTP `/mcp` handshake
locally and in Docker.
- **Why:** First step of the two-way Claude integration (AGENT.md §8.1) — prove
Claude can connect to the app over MCP before building deeper features on it.
- **Affects:** `internal/service` (new), `internal/mcp` (new), `cmd/server/main.go`,
`go.mod`/`go.sum`, `.air.toml`.
- **Gotcha:** Docker-on-Windows bind mounts do NOT deliver filesystem events, so
air's watch-based reload silently never fired. Fixed by enabling air polling
(`poll = true`, `poll_interval = 500` in `.air.toml`).
## 2026-09-20 — HTTPS for the MCP connector (local TLS via mkcert)
- **What:** Added an optional HTTPS listener alongside HTTP. New config
`HTTPS_ADDR`, `TLS_CERT_FILE`, `TLS_KEY_FILE`; when set, `cmd/server` starts
`e.StartTLS` on the same Echo app (best-effort — a missing cert logs a warning
and stays HTTP-only). docker-compose publishes `127.0.0.1:8443` and points the
TLS vars at `certs/localhost.pem` (mounted via the existing source mount).
`.gitignore` ignores `/certs/`; `.env.example` documents the mkcert steps.
- **Why:** Claude Desktop's custom MCP connector only accepts `https://` URLs.
Local TLS with an mkcert-trusted cert lets `https://localhost:8443/mcp` work
without exposing the unauthenticated app via a public tunnel (AGENT.md §8.1).
- **Affects:** `internal/config`, `cmd/server/main.go`, `docker-compose.yml`,
`.gitignore`, `.env.example`, `AGENT.md` (§8.1, §11).
- **Host setup (user-run):** the local CA install (`mkcert -install`) is a
security-settings change performed by the user, not the app.
## 2026-09-20 — Connect Claude Desktop via local stdio bridge (mcp-remote)
- **What:** Corrected the Claude Desktop connection method in AGENT.md (§8.1, §11).
The GUI "Add custom connector" flow can NOT reach a localhost server — it probes
and calls tools from Anthropic's cloud, so `https://127.0.0.1:8443/mcp` fails
"couldn't reach the server" even though a local browser reaches it. The working
path is a local stdio bridge in `claude_desktop_config.json`:
`mcpServers.gitmanager = cmd /c npx -y mcp-remote http://127.0.0.1:8080/mcp`.
Added that entry to the user's Claude Desktop config (backup saved alongside).
- **Why:** Keep the app localhost-only + unauthenticated (§0) while still letting
Claude Desktop drive it. `mcp-remote` runs locally, so it reaches the local
endpoint directly — no public exposure, no HTTPS needed for this path.
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside
the repo). HTTPS/`:8443` from the prior entry stays available but is now optional.
+32 -8
View File
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
@@ -18,8 +17,10 @@ import (
"gitmanager/internal/config"
"gitmanager/internal/git"
"gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp"
"gitmanager/internal/render"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
func main() {
@@ -50,6 +51,9 @@ func main() {
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
// The one service layer both the HTTP API and the MCP server call (§1.7).
svc := service.New(g, scanner.Index)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
@@ -79,20 +83,24 @@ func main() {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, scanner.Index.List())
return c.JSON(http.StatusOK, svc.ListRepos())
})
e.GET("/api/repo", func(c echo.Context) error {
// Only serve details for a repo we already discovered — never run git
// against an arbitrary path supplied in the query string. Clean the
// input so separator style (/, \) doesn't defeat the exact-match lookup.
path := filepath.Clean(c.QueryParam("path"))
base, ok := scanner.Index.Get(path)
// The service only serves details for an already-discovered repo — it
// never runs git against an arbitrary caller-supplied path (§1.3).
detail, ok := svc.RepoDetail(c.Request().Context(), c.QueryParam("path"))
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown repository"})
}
return c.JSON(http.StatusOK, repos.BuildDetail(c.Request().Context(), g, base))
return c.JSON(http.StatusOK, detail)
})
// MCP server — Claude connects here as a custom connector (§8.1). Same
// service layer as the HTTP API (§1.7); localhost-bound like everything else.
mcpSrv := mcpserver.NewServer(svc, "0.1.0")
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
log.Info("mcp server mounted", "path", "/mcp")
// Serve with graceful shutdown.
go func() {
if err := e.Start(cfg.ListenAddr); err != nil && err != http.ErrServerClosed {
@@ -102,6 +110,22 @@ func main() {
}()
log.Info("listening", "addr", cfg.ListenAddr)
// Optional HTTPS listener (same Echo app). Required for the MCP connector,
// which only accepts https:// URLs (§8.1). Best-effort: a missing/unreadable
// cert logs a warning and leaves the app running over HTTP.
if cfg.HTTPSAddr != "" && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
if _, err := os.Stat(cfg.TLSCertFile); err != nil {
log.Warn("HTTPS requested but cert not readable — serving HTTP only", "cert", cfg.TLSCertFile, "err", err)
} else {
go func() {
if err := e.StartTLS(cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed {
log.Error("TLS server error", "err", err)
}
}()
log.Info("listening (https)", "addr", cfg.HTTPSAddr)
}
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
+7
View File
@@ -12,10 +12,17 @@ services:
# Bind all interfaces INSIDE the container so the published port reaches
# it; the `ports` mapping below still keeps it localhost-only on the HOST.
- LISTEN_ADDR=0.0.0.0:8080
# HTTPS for the MCP connector (Claude Desktop only accepts https URLs).
# Certs are generated on the host with mkcert (see README/.env.example)
# and mounted read-only below.
- HTTPS_ADDR=0.0.0.0:8443
- TLS_CERT_FILE=/app/certs/localhost.pem
- TLS_KEY_FILE=/app/certs/localhost-key.pem
# The scanner looks here; matches the volume mount below.
- GIT_REPO_ROOTS=/repos
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:8443:8443"
volumes:
# Source, for hot reload.
- .:/app
+7
View File
@@ -5,16 +5,23 @@ go 1.26
require (
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.15.4
github.com/modelcontextprotocol/go-sdk v1.8.0
)
require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
+20
View File
@@ -1,5 +1,11 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
@@ -10,23 +16,37 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
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/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+11 -1
View File
@@ -14,7 +14,14 @@ import (
// Config is the typed application configuration.
type Config struct {
ListenAddr string // address the HTTP server binds to
ListenAddr string // address the plain HTTP server binds to
// TLS: when HTTPSAddr and both cert/key files are set, an HTTPS listener is
// started in addition to the HTTP one. Claude Desktop's MCP connector only
// accepts https:// URLs, so /mcp must be reachable over TLS (AGENT.md §8.1).
HTTPSAddr string // address the HTTPS server binds to ("" disables TLS)
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
TLSKeyFile string // PEM private key
RepoRoots []string // roots to scan for git repositories
GitBin string // path to the git binary
@@ -38,6 +45,9 @@ func Load() (Config, error) {
c := Config{
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
HTTPSAddr: env("HTTPS_ADDR", ""),
TLSCertFile: env("TLS_CERT_FILE", ""),
TLSKeyFile: env("TLS_KEY_FILE", ""),
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
GitBin: env("GIT_BIN", "git"),
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
+70
View File
@@ -0,0 +1,70 @@
// Package mcp exposes GitManager's capabilities to Claude as an MCP server over
// Streamable HTTP (AGENT.md §8.1). The tool handlers are THIN ADAPTERS over the
// shared service layer (§1.7) — no Git/forge logic lives here. Read tools only
// for now; acting/destructive tools arrive with the service methods that back
// them, carrying the §1.4 confirmation contract.
package mcp
import (
"context"
"fmt"
"net/http"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
// getRepoInput is the argument schema for the get_repo tool.
type getRepoInput struct {
Path string `json:"path" jsonschema:"absolute filesystem path of the repository, exactly as returned by list_repos"`
}
// listReposOutput wraps the repository list. MCP structured output must be a JSON
// object, so the SDK-inferred outputSchema has to be type "object" — returning a
// bare slice yields type "array", which Claude Desktop rejects at tools/list.
type listReposOutput struct {
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
Name: "gitmanager",
Title: "GitManager",
Version: version,
Description: "Discover and inspect the user's local Git repositories.",
}, nil)
// list_repos — no arguments (empty struct = object schema with no properties).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_repos",
Description: "List every Git repository GitManager has discovered, each with its current branch, dirty/clean state, ahead/behind counts, and remote names.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listReposOutput, error) {
return nil, listReposOutput{Repos: svc.ListRepos()}, nil
})
// get_repo — details for one already-discovered repository.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_repo",
Description: "Get details for one repository: its local branches (with upstreams), recent commits, and remote URLs. The path must be one returned by list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRepoInput) (*mcpsdk.CallToolResult, repos.Detail, error) {
detail, ok := svc.RepoDetail(ctx, in.Path)
if !ok {
return nil, repos.Detail{}, fmt.Errorf("unknown repository %q — call list_repos for valid paths", in.Path)
}
return nil, detail, nil
})
return s
}
// Handler serves the MCP server over Streamable HTTP. Mount it at /mcp. Like the
// rest of the app it is localhost-bound and unauthenticated (§8.1) — the same
// server instance backs every session.
func Handler(s *mcpsdk.Server) http.Handler {
return mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server {
return s
}, nil)
}
+126
View File
@@ -0,0 +1,126 @@
package mcp
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/git"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
// service -> MCP tools, called by an in-memory MCP client.
func TestMCPRoundTrip(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "myrepo")
if err := os.Mkdir(repoPath, 0o755); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "first commit")
// Populate the index via the real scanner, then build service + MCP server.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index)
srv := NewServer(svc, "test")
// Wire an in-memory client<->server session.
ctx := context.Background()
clientT, serverT := mcpsdk.NewInMemoryTransports()
serverSession, err := srv.Connect(ctx, serverT, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer serverSession.Close()
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil)
cs, err := client.Connect(ctx, clientT, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
// list_repos should find our one repo.
res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"})
if err != nil {
t.Fatalf("list_repos: %v", err)
}
var listOut listReposOutput
decodeResult(t, res, &listOut)
states := listOut.Repos
if len(states) != 1 {
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
}
if states[0].Name != "myrepo" || states[0].Branch != "main" {
t.Fatalf("unexpected repo state: %+v", states[0])
}
// get_repo should return detail including the commit we made.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("get_repo: %v", err)
}
var detail repos.Detail
decodeResult(t, res, &detail)
if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" {
t.Fatalf("unexpected detail commits: %+v", detail.Commits)
}
// get_repo with a bad path is a tool error, not a protocol error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("get_repo(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown repo, got success")
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.
func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) {
t.Helper()
for _, c := range res.Content {
if tc, ok := c.(*mcpsdk.TextContent); ok {
if err := json.Unmarshal([]byte(tc.Text), v); err != nil {
t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text)
}
return
}
}
t.Fatalf("no text content in result: %+v", res.Content)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package service is the ONE capability layer behind both the HTTP API and the
// MCP server (AGENT.md §1.7). HTTP handlers and MCP tool handlers are thin
// adapters that call these methods; Git/forge logic never lives in a handler.
// Everything here goes through the internal/git (and later internal/forge)
// boundaries and obeys the safety rules (§1.4).
package service
import (
"context"
"path/filepath"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// Service holds the shared dependencies the capabilities need.
type Service struct {
git *git.CLI
index *repos.Index
}
// New builds a Service over the git boundary and the scanner's repo index.
func New(g *git.CLI, index *repos.Index) *Service {
return &Service{git: g, index: index}
}
// ListRepos returns a snapshot of every discovered repository.
func (s *Service) ListRepos() []repos.State {
return s.index.List()
}
// GetRepo returns the cached state for one repo, or false if it is not indexed.
// The path is cleaned so separator style does not defeat the exact-match lookup.
func (s *Service) GetRepo(path string) (repos.State, bool) {
return s.index.Get(filepath.Clean(path))
}
// RepoDetail returns the enriched detail (branches, commits, remotes) for one
// repo. The second return is false when the path is not an indexed repository —
// we never run git against an arbitrary caller-supplied path (§1.3).
func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bool) {
base, ok := s.index.Get(filepath.Clean(path))
if !ok {
return repos.Detail{}, false
}
return repos.BuildDetail(ctx, s.git, base), true
}