Compare commits

..

13 Commits

Author SHA1 Message Date
TBNilles c6d3b5fae8 Slice 7: expose git commands as MCP tools
Add git_fetch/git_pull/git_push/git_commit/git_discard_changes MCP tools as thin adapters over the service (actor=claude), completing 1.7 symmetry so Claude can run the same commands as the right-click menu. git_discard_changes is flagged destructive (confirm first, 1.4). Extended the MCP test with a git_commit round-trip; synced AGENT.md 8.1 tool list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 09:15:23 -04:00
TBNilles 6ef1c195e3 Slice 6: right-click command menu + git write actions
git boundary: Pull/Push/Commit/DiscardAll (discard is 1.4-destructive). Scanner.RefreshRepo re-scans one repo after a mutation. Service GitFetch/GitPull/GitPush/GitCommit/GitDiscard record a git-* activity event and refresh on success; service.New takes a refresh hook. HTTP POST /api/repo/git. New <repo-menu> overlay with plain-language commands (Get latest/Publish/Check for updates/Save my work/Set active/Ask Claude to switch/Copy path/Discard all changes), summoned by repo-list's repo:contextmenu. Added service test for commit/discard on a temp repo. Verified the menu live for safe commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 09:12:18 -04:00
TBNilles 9d1519222c Slice 5: Gitea forge - PRs and Merge & clean up
New internal/forge (provider-abstracted, Gitea impl via code.gitea.io/sdk/gitea) with tested remote-URL parsing and read+write ops: list open PRs, and merge-and-cleanup (squash-merge + delete head branch when head/base share a repo). Service resolves repo->owner/repo from remotes (prefers origin) and records a pr-merged event; config gains GITEA_URL/GITEA_TOKEN (forge disabled without both). MCP tools list_prs and merge_and_cleanup_pr (merge tool tells Claude to confirm first, 1.4). HTTP GET /api/repo/prs, POST /api/repo/pr/merge. New <pr-list> component with a confirming Merge & clean up button, hidden when no forge. Verified graceful-disabled path; real merge pending token + a designated PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:37:51 -04:00
TBNilles 2b77e15b36 Slice 4: graceful project handoff (pending switch + ack)
Add pending-switch coordination to internal/activity (RequestSwitch/PendingSwitch/AckSwitch/CancelSwitch); AckSwitch atomically sets the active project and records switch-completed with Claude's summary. New MCP tools get_pending_switch and ack_switch (request is user-only via HTTP). HTTP GET/POST/DELETE /api/switch. New <handoff-bar> component (ask/waiting/cancel/completed) over SSE; activity-feed reflects switch-completed. Test covers request->ack->clear. Verified live: request/waiting/cancel over SSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:27:22 -04:00
TBNilles 65daedc902 Slice 3: activity feed and active project (SSE + MCP tools)
Add internal/activity (thread-safe active project + bounded event feed with subscriber fan-out). Service exposes active-project/activity methods and takes the feed. New MCP tools get_active_project/set_active_project/get_activity and HTTP endpoints GET/POST /api/active-project, /api/activity, and /events (SSE). New <activity-feed> component updates live via EventSource; <repo-list> sets the active project on selection. Verified end-to-end in the running app: user actions push live events and set the active project (actor=user), all readable by Claude over MCP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:16:42 -04:00
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
TBNilles 80d2e10fec Add .gitattributes to normalize line endings to LF
Store and check out LF everywhere (project targets Linux/Docker), ending the LF->CRLF churn warnings on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:44:17 -04:00
TBNilles a192a05aaa Add repo-detail panel and /api/repo endpoint
New <repo-detail> component listens for repo:select and shows a repo's remotes, local branches (current + upstream), and recent commits. Backed by GET /api/repo (restricted to indexed repos) and read-only git readers LocalBranches/RecentCommits/RemoteDetails via repos.BuildDetail. <repo-list> highlights the selection; page docks the two panels left/right.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:43:01 -04:00
TBNilles 1a2ad98c33 Scaffold GitManager multi-repo dashboard
Runnable skeleton per AGENT.md: Echo server (/, /help, /healthz, /api/repos), read-only repo scanner with in-memory index, the internal/git boundary, the <repo-list> web component with design tokens, and dev tooling (Dockerfile, docker-compose, air, .env.example).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 16:38:59 -04:00
40 changed files with 4749 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Hot reload for the dev container (AGENT.md §2). Static assets under web/static
# and components/ are served live from disk, so they need no rebuild; only Go
# and template (.html) changes trigger a rebuild + restart.
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/gitmanager ./cmd/server"
bin = "./tmp/gitmanager"
include_ext = ["go", "html"]
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
[misc]
clean_on_exit = true
+72
View File
@@ -0,0 +1,72 @@
# ---------------------------------------------------------------------------
# GitManager configuration. Copy this file to `.env` and fill in values.
# NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5.
# ---------------------------------------------------------------------------
# Address the HTTP server binds to. Localhost-only by default: there is NO
# 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
GIT_REPO_ROOTS=/repos
# DOCKER ONLY: the HOST folder that holds your repositories. docker-compose
# mounts it to /repos inside the container (which GIT_REPO_ROOTS points at).
# Ignored when running the binary directly. Example: C:/Users/you/Projects
REPOS_HOST_PATH=./repos
# Path to the git binary. "git" resolves it from PATH (git is installed in the
# container image).
GIT_BIN=git
# --- Repo scanner (read-only; AGENT.md §5) ---------------------------------
# How often the background scanner refreshes repo state.
SCAN_INTERVAL=30s
# Max directory depth to descend under each root when discovering repos.
SCAN_MAX_DEPTH=4
# Directory names to skip during discovery, comma-separated.
SCAN_IGNORE=node_modules,vendor,.cache
# Allow the scanner to run `git fetch` (network) to keep ahead/behind counts
# current. OFF by default — no unsolicited network. (AGENT.md §5)
SCAN_FETCH_ENABLED=false
# --- Logging (AGENT.md §7) --------------------------------------------------
# "dev" uses a readable console handler; anything else uses structured JSON.
APP_ENV=dev
# Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE=
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
# branch delete.
GITEA_URL=
GITEA_TOKEN=
# Later providers, behind the same interface (unused for now):
GITHUB_TOKEN=
GITLAB_TOKEN=
+25
View File
@@ -0,0 +1,25 @@
# Normalize line endings: store everything with LF in the repo, and check out
# LF in the working tree too (this project targets Linux/Docker). Prevents the
# "LF will be replaced by CRLF" churn on Windows.
* text=auto eol=lf
# Explicitly text (LF) — source and config.
*.go text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.toml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.mod text eol=lf
*.sum text eol=lf
Dockerfile text eol=lf
.env.example text eol=lf
# Treat common binaries as binary (never munge).
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff2 binary
+19
View File
@@ -0,0 +1,19 @@
# Environment (never commit real secrets/config — see .env.example)
.env
# Build output
/bin/
/tmp/
gitmanager
gitmanager.exe
# Logs
*.log
# Local TLS certificates (generated with mkcert; never commit)
/certs/
# Editor / OS
.DS_Store
.idea/
.vscode/
+557
View File
@@ -0,0 +1,557 @@
# AGENT.md
> **This file is the contract for Claude Code working on this repository.**
> Read it fully before any task. It defines the architecture, the nonnegotiable
> rules, and the selfdocumenting workflow you must follow on every change.
>
> When this file and the code disagree, this file wins until you are explicitly
> told to change the file. If a request conflicts with a rule here, **pause and
> ask** before proceeding.
---
## 0. What this project is
A standalone, longlived development application: a **multirepo web dashboard for
Git**. It scans one or more configured roots on the host, discovers every Git
repository under them, and presents a control panel over the whole set — perrepo
status (clean/dirty, ahead/behind, current branch), branches, remotes, recent
commits, stashes, and tags — plus userinitiated Git actions (fetch, pull, push,
checkout, create branch, commit, view diffs, manage remotes). It has peruser
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.
**Trust model:** there is **no user authentication**. The app is a singleoperator
tool that acts as the host user against local repositories. It binds to
**localhost** by default; exposing it to a network is the operator's decision and
their responsibility. Do not add a login/auth layer without asking.
---
## 1. Nonnegotiable architectural laws
These are hard rules. Do not violate them, do not "optimize them away," and do
not assume an exception without asking.
### 1.1 Web Components are the only UI unit (ActiveXspirit, enforced)
The UI is built **exclusively** from native Web Components. The design intent is
deliberately modeled on the *selfcontained control* philosophy of Windows98era
ActiveX/OCX controls — **the spirit, not the dead COM/binary/registry mechanism**.
Concretely, **every** UI element that does real work MUST be:
- A **Custom Element v1** (`customElements.define('x-thing', ...)`).
- Encapsulated with **Shadow DOM** (`attachShadow({mode:'open'})`). Styles and
DOM are isolated. No leaking global CSS into components, no reaching out of a
component into another component's internals.
- **Selfinstantiating**: the page declares the element in markup (or via a thin
loader). The browser "constructs" it. The page does not micromanage it.
- **Independently live**: on connect, each component **fetches its own data and
renders itself**. Components do **not** wait for a central controller to hand
them data. One component being slow or failing must not block another.
- **Selfcontained**: no shared mutable global state. Crosscomponent
communication happens only via (a) DOM custom events (`dispatchEvent` of a
`CustomEvent` that bubbles/composes) or (b) explicit attributes/properties set
on the element. Treat each component like a control you could drop onto any
page and it would just work.
- **Lifecyclecorrect**: implement `connectedCallback` / `disconnectedCallback`
and clean up timers, listeners, and inflight fetches on disconnect.
If you are tempted to introduce a heavy SPA framework (React/Vue/Angular/Svelte)
for the component layer — **stop and ask.** The default answer is no. Vanilla
Web Components are the chosen model. (Small, dependencyfree helpers like
`lit` *may* be proposed, but only with explicit approval, because `lit` still
produces standardsbased custom elements.)
**Shared styling — design tokens.** The one sanctioned crossshadowboundary
styling channel is **CSS custom properties**. The shared theme palette and corner
radii live as tokens (`--color-*`, `--surface-*`, `--border-*`, `--fill-*`,
`--radius*`) in `web/static/app.css :root`; custom properties inherit *into* every
shadow root, so a component references them with `var(--…)` for all shared colors
and radii instead of hardcoding hex. This is **not** the forbidden "leaking global
CSS" — no global *selectors* reach into a component; it is the deliberate theming
layer (change a token once, the whole UI follows). Status feedback is semantic —
destructive/error use the `--color-danger*` set, success uses `--color-success`,
and Gitstate colors (ahead/behind, clean/dirty, conflicted) get their own
semantic tokens. **New or edited components MUST consume the tokens for shared
values** rather than reintroducing hardcoded hex.
### 1.2 Component foldering & percomponent docs
- There is a **toplevel `components/` folder**. All web components live there.
- **Each component gets its own folder**: `components/<component-name>/`.
- **Each component folder contains its own `<component-name>.md`** (see Section 9)
that records the **history and intent** of that component and acts as the
scoped context when working on it. Componentspecific detail belongs in that
file, **not** in this AGENT.md. This keeps AGENT.md lean as the app grows.
### 1.3 Git is the system of record (the app never silently mutates a repo)
- **The Git repositories are the truth.** The app holds only a **derived,
readoptimized inmemory index** of repo state (discovered repos, their status,
branches, remotes, recent log), refreshed by the scanner (Section 5). That index
is a cache — it can be thrown away and rebuilt from the repos at any time.
- **All mutations go through explicit, userinitiated Git operations.** The app
**never** writes to a repository except in direct response to a user action
routed through the `internal/git` boundary. No background process ever mutates a
working tree, index, or ref.
- **The background scanner is readonly.** It may run `status`, `rev-list`,
`for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never
run a command that changes local state.
- **Never invent local persistence for domain state.** If something must survive a
restart and it isn't already in Git, it is either app config (`.env`, Section
1.4) or peruser UI state (browser `localStorage`, Section 4). Adding any other
datastore (SQLite, a serverside DB) requires asking first — the default is no.
### 1.4 Destructive Git operations are explicit, confirmed, and never automatic
Operations that can lose work or rewrite shared history are a distinct class and
must be treated as such:
- **Always require an explicit, deliberate user action** (a dedicated control, not
a side effect of another action) **and a confirmation** that names exactly what
will happen and to which repo/branch.
- This class includes at least: `push --force`/`--force-with-lease`, `reset --hard`,
`checkout`/`restore` that discards uncommitted changes, `clean -fd`, branch/tag
deletion, `stash drop`/`clear`, history rewrites (`rebase`, `commit --amend`),
and remote deletions.
- **Never chain a destructive operation into an automated flow** and never pick a
destructive default. Prefer the safe variant (`--force-with-lease` over
`--force`) and surface it as such.
### 1.5 Configuration via `.env`
Repo scan roots, the `git` binary path, scan/fetch behavior, the listen address,
and any optional forge tokens are all configurable through a single `.env` file. A
committed `.env.example` documents every variable. **Never commit a real `.env`**
and never hardcode paths, tokens, hosts, or the set of watched repositories.
### 1.6 Everything runs in Docker / dockercompose
The dev environment (the Go app + hot reload) is brought up with
`docker compose up`. Because the app operates on repositories that live on the
**host**, the compose file **mounts the configured repo roots into the container**
(readwrite, since Git operations write to them) along with whatever Git needs to
authenticate to remotes (SSH agent socket / mounted keys, or a credential helper).
The app must be runnable by a new developer with: clone → copy `.env.example` to
`.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)
| Concern | Choice | Notes |
|---|---|---|
| Language | **Go** | Backend, page rendering, Git orchestration, scanner. |
| HTTP framework | **Echo** (`github.com/labstack/echo/v4`) | Idiomatic, fast, good middleware story. If you prefer Chi, ask first. |
| 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`) — **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). |
| Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. |
Anything not in this table that you want to add as a dependency: **propose it and
wait for approval.** Keep the dependency surface small. Note in particular there is
**no database and no auth library** — do not add one without asking (Section 1.3,
Section 0).
---
## 3. Repository layout (target)
```
.
├── AGENT.md # this file — lean, architecture-level only
├── CHANGELOG.md # append-only running history of ALL changes (Section 9)
├── .env.example # every config var, documented, no secrets
├── docker-compose.yml # go app (+ hot reload); mounts host repo roots + git creds
├── Dockerfile # multi-stage build for the Go app (git installed in the image)
├── .air.toml # hot reload config (dev)
├── cmd/
│ └── server/main.go # entrypoint: wire config, git, scanner, router
├── internal/
│ ├── 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
│ ├── 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)
│ └── <component-name>/
│ ├── <component-name>.md # history + intent for THIS component (scoped context)
│ ├── <component-name>.js # the custom element
│ └── <component-name>.css # (optional) styles consumed inside shadow DOM
└── web/
├── static/ # shared static assets (app.css tokens, etc. — NOT component-specific)
└── templates/ # Go html/template page shells (incl. help.html)
```
> If a new concern doesn't fit cleanly, **ask** before inventing a new toplevel
> directory. Keep `components/` strictly for web components. There is deliberately
> **no `migrations/` and no `db/`** — see Section 1.3.
---
## 4. UI layout & peruser state (no server DB)
Because there is no authentication and no database, **peruser UI state lives in
the browser** (`localStorage`), namespaced under a `gitmanager.*` prefix:
- Dockable panels: the **repo list docked LEFT**, the **repo detail panel docked
RIGHT** by default. Dock options are `top`, `left`, `bottom`, `right`; positions
are remembered in `localStorage` and reapplied on load.
- Other view state (selected repo, active filter/search, expanded sections, chosen
branch view) also persists in `localStorage`.
- A **reset control** clears every `gitmanager.*` key and reloads to firstload
defaults (mirror the pattern: view state only — never touch the repos).
Serverside there is no session and no user record. Requests act as the single
host operator.
---
## 5. Repo discovery & the refresh scanner (readonly)
**Git is truth; the index is a derived cache.** Implement in `internal/repos`,
using the `internal/git` boundary for every command.
- **Discovery:** walk each configured root (`GIT_REPO_ROOTS`) for directories
containing `.git`, honoring a configurable max depth and ignore globs. Build an
inmemory index keyed by absolute repo path.
- **Refresh (readonly):** for each repo compute status (dirty/clean, staged vs
unstaged counts), current branch, ahead/behind vs upstream, remotes, and a short
recentcommit summary — all via readonly Git commands (Section 1.3). Refresh on
a **configurable interval** and, optionally, on filesystem change via `fsnotify`.
- **Optional background `fetch`:** disabled by default. Only when
`SCAN_FETCH_ENABLED=true` may the scanner run `git fetch` (network, readonly to
the working tree) to keep ahead/behind counts current. Ratelimit it.
- **Never block a request on a slow repo.** The dashboard reads from the index;
the scanner updates the index in the background. A single unreachable remote or
huge repo must not stall the others (perrepo timeouts, isolated goroutines).
Resulting data path:
```
Host repositories (system of record)
│ read-only scan (status / rev-list / for-each-ref / log [/ fetch if enabled])
internal/repos ──► in-memory index (per-repo state)
Echo JSON endpoints ──► web components (self-fetch)
User action ──► internal/git (os/exec) ──► repository mutated ──► index refresh
```
---
## 6. Dashboard & Git operations (client side)
The dashboard is composed of web components, each obeying Section 1 (shadow DOM,
selffetching, independent lifecycle, cleanup on disconnect). Expected surface
(create each with its own folder + `.md` as you build it):
- A **repo list / grid** of all discovered repos with status badges (branch,
dirty/clean, ahead/behind), searchable/filterable.
- 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
naming the target).
- Crosscomponent communication is via bubbling/composed `CustomEvent`s (e.g. a
`repo:select` event the detail panel listens for) — never shared globals.
Server endpoints return JSON for the components to selffetch; mutating endpoints
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.
---
## 7. Logging (structured, to stdout/stderr)
- Use Go's **`log/slog`** as the logging API throughout.
- Emit **structured records to stdout/stderr** (JSON in nondev, a readable
console handler in dev). Capture: timestamp, level, message, structured
attributes, request id, and the repo path when an operation targets one.
- **Log every Git mutation** (the command, target repo/branch, and outcome) so the
operator has an audit trail of what the tool did on their behalf.
- Optionally also write to a **rotating file** when `LOG_FILE` is set. There is no
database sink — do not add one (Section 1.3).
---
## 8. Claude integration (MCP server · activity feed · graceful handoff · forge)
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).
### 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_fetch`, `git_pull`, `git_push`, `git_commit`,
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`,
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.)
- **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** asks (in the app) for Claude to switch to a project →
`POST /api/switch` records a **pendingswitch request** (target + optional
note) and `<handoff-bar>` shows "waiting for Claude to reach a good stopping
point." (The request is a **useronly** action — there is no MCP tool to raise
it; Claude fulfils requests, it doesn't create them.)
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
calls **`ack_switch`** with a short summary of where it left the previous
project. `ack_switch` **atomically** makes the requested target the active
project and clears the request.
3. **App** records `switch-completed` and **notifies the user** over SSE
("Claude switched to *ProjectB* — *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. `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.
---
## 9. Selfdocumenting workflow (MANDATORY on every change)
This is how the project documents itself so AGENT.md stays small and each
component carries its own context.
### 9.1 Root `CHANGELOG.md`
- **On every change you make**, append an entry to root `CHANGELOG.md`. Never
rewrite history; only append. Each entry:
```
## YYYY-MM-DD — <short title>
- **What:** what changed (files, behavior).
- **Why:** the intent / the request behind it.
- **Affects:** components/areas touched.
```
### 9.2 Percomponent `<component-name>.md`
- When you **create a component**, create `components/<name>/<name>.md` with:
```
# <component-name>
## Intent
What this component is for; the ActiveX-spirit contract it fulfills.
## Public surface
Tag name, attributes/properties, emitted events, what data it fetches and from where.
## History
- YYYY-MM-DD: created — <reason>.
- YYYY-MM-DD: <change> — <reason>.
## Notes / gotchas
```
- When you **work on an existing component**, **read its `.md` first** (it is the
scoped context for that component), make the change, then **append to its
History** and update Public surface if it changed.
### 9.3 Userfacing help page
- `web/templates/help.html` (served at `/help`, linked from the app header) explains
**how to use the app** for end users. **When a userfacing feature changes** — a new
control, a changed workflow, a removed/renamed option — **update the help page in the
same change**, the way you update the `CHANGELOG`. Keep it taskoriented (how to do
things), not implementation detail. Document any host prerequisites (SSH agent /
credential helper for pushing from the container).
### 9.4 Keep AGENT.md lean
- Componentspecific detail lives in the component's `.md`, **not here**. Once a
component has its own `.md`, **move any componentspecific detail out of
AGENT.md** into that file. AGENT.md stays architecturelevel only.
- If a change alters an **architectural law or stack choice** in this file,
update AGENT.md too — but only architecturelevel facts belong here.
---
## 10. How to take a new task ("add X functionality")
1. **Read** this AGENT.md, then any relevant component `.md` files.
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 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
and that it operates correctly against a real mounted repo.
7. **Document:** append to `CHANGELOG.md`; create/append the component `.md`;
update `help.html` if userfacing; trim AGENT.md if component detail crept in.
---
## 11. Open items to confirm before/while building
*(Claude Code: surface these to the human at the first relevant moment; don't
silently guess.)*
- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth?
ignore globs?) vs an explicit list of repo paths. Default assumption: recursive
scan with a configurable depth.
- **Background `fetch`:** off by default (no unsolicited network). Confirm whether
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.
- ✅ **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.
---
*End of AGENT.md. Keep it lean. Let the CHANGELOG and percomponent `.md` files
carry the detail.*
+203
View File
@@ -0,0 +1,203 @@
# Changelog
Append-only running history of all changes (AGENT.md §9.1). Newest last.
## 2026-09-19 — Project scaffold
- **What:** Initial runnable skeleton for the GitManager multi-repo dashboard.
Added the Go backend (`cmd/server/main.go` + `internal/{config,logging,git,repos,render}`),
the Echo HTTP server with `/`, `/help`, `/healthz`, and `/api/repos`, a
read-only repo scanner that discovers repositories under `GIT_REPO_ROOTS` and
keeps an in-memory index, the `<repo-list>` web component, shared design tokens
(`web/static/app.css`), page shells (`web/templates/{index,help}.html`), and the
dev tooling: `Dockerfile` (build/dev/runtime stages), `docker-compose.yml`,
`.air.toml`, `.env.example`, `.gitignore`, `go.mod`.
- **Why:** Stand up the architecture defined in AGENT.md so feature work can begin.
- **Affects:** whole repo (foundation); `components/repo-list`.
## 2026-09-19 — Repo detail panel
- **What:** Added the `<repo-detail>` component (right dock) that listens for
`repo:select` and shows a repo's remotes, local branches (current + upstream),
and 20 most recent commits. Backed by a new `GET /api/repo?path=` endpoint
(restricted to indexed repos) and new read-only git readers
(`LocalBranches`, `RecentCommits`, `RemoteDetails`) plus `repos.BuildDetail`
and `Index.Get`. `<repo-list>` now highlights the selected repo; `index.html`
lays the two panels out left/right; help page documents the detail view.
- **Why:** Make the dashboard drill into a single repository (the detail half of
the list+detail default in AGENT.md §4).
- **Affects:** `components/repo-detail`, `components/repo-list`,
`internal/git`, `internal/repos`, `cmd/server`, `web/templates`.
### Notes to confirm (from AGENT.md §11)
- **Go module path** is the placeholder `gitmanager`; change it if this gets a
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.
## 2026-09-20 — Slice 3: activity feed + active project (§8.2)
- **What:** Added `internal/activity` (thread-safe active project + bounded event
feed with subscriber fan-out, mirrored to logs, no datastore). Service gained
`ActiveProject`/`SetActiveProject`/`RecordActivity`/`Activity`/`SubscribeActivity`
(and `service.New` now takes the feed). New MCP tools `get_active_project`,
`set_active_project`, `get_activity` (object-wrapped outputs). New HTTP:
`GET/POST /api/active-project`, `GET /api/activity`, and `GET /events` (SSE).
New `<activity-feed>` component (live via EventSource); `<repo-list>` now sets
the active project on selection (a user action). Extended the MCP test to cover
the new tools; help page documents the feature.
- **Why:** The coordination foundation for the graceful project handoff (§8.3):
the app and Claude share one active-project + activity view. User actions are
recorded as `actor:user`, Claude's as `actor:claude`, so each side can see what
the other did.
- **Affects:** `internal/activity` (new), `internal/service`, `internal/mcp`
(+test), `cmd/server/main.go`, `components/activity-feed` (new),
`components/repo-list`, `web/templates/{index,help}.html`.
## 2026-09-20 — Slice 4: graceful project handoff (§8.3)
- **What:** `internal/activity` gained a pending-switch model
(`RequestSwitch`/`PendingSwitch`/`AckSwitch`/`CancelSwitch`); `AckSwitch`
atomically sets the active project to the requested target and records a
`switch-completed` event with Claude's summary. Service methods added. New MCP
tools `get_pending_switch` and `ack_switch` (request is user-only — no MCP tool
raises it). HTTP: `GET/POST/DELETE /api/switch`. New `<handoff-bar>` component:
"Ask Claude to switch to <active>", the "waiting for a good stopping point"
state with Cancel, and the completion notice; `<activity-feed>` also updates the
active project on `switch-completed`. Extended the MCP test to cover the full
request→ack→clear flow. Help page + AGENT.md §8.3 updated.
- **Why:** The headline feature — the user asks Claude to switch projects; Claude
finishes to a safe stopping point, then `ack_switch` completes it and the app
notifies the user over SSE. The switch is Claude-completed at a checkpoint,
never app-forced (§1.4-class rule).
- **Affects:** `internal/activity`, `internal/service`, `internal/mcp` (+test),
`cmd/server/main.go`, `components/handoff-bar` (new), `components/activity-feed`,
`web/templates/{index,help}.html`, `AGENT.md` (§8.3).
- **Verified live:** request → "waiting" → cancel, all over SSE with activity
logging. The ack/completion path is covered by the test; its live ✅ notice
needs the two new MCP tools, which appear after the next Claude Desktop restart.
## 2026-09-20 — Slice 5: Gitea forge — PRs + "Merge & clean up" (§8.4)
- **What:** New `internal/forge` — provider-abstracted forge boundary with a Gitea
impl (`code.gitea.io/sdk/gitea`), a remote-URL parser (`ParseRemote`, tested),
and read+write ops: `ListPullRequests` and `MergeAndCleanup` (squash-merge +
delete the head branch, only when head/base share a repo). Service resolves a
repo → owner/repo via its remotes (prefers origin) and records a `pr-merged`
activity event. New config `GITEA_URL` + `GITEA_TOKEN` (forge is nil/disabled
without both). New MCP tools `list_prs` and `merge_and_cleanup_pr` (the merge
tool's description tells Claude to confirm first, §1.4). HTTP
`GET /api/repo/prs`, `POST /api/repo/pr/merge`. New `<pr-list>` component with a
confirming "Merge & clean up" button; hidden when no forge is configured.
- **Why:** The feature Thomas asked for — make PRs usable by merging and removing
the branch in one tidy step, from the app or via Claude.
- **Affects:** `internal/forge` (new, +test), `internal/config`,
`internal/service`, `internal/mcp`, `cmd/server/main.go`,
`components/pr-list` (new), `web/templates/{index,help}.html`, `.env.example`,
`go.mod`.
- **Not yet live-tested:** needs `GITEA_URL`+`GITEA_TOKEN` set and a real PR;
build/vet/tests pass and the parser is unit-tested. A real merge is irreversible
— will only run one against a PR Thomas designates, with confirmation.
## 2026-09-20 — Forge live-tested (Merge & clean up)
- **What:** With `GITEA_URL`+`GITEA_TOKEN` set, verified end-to-end against
git.nilles.net: created an isolated throwaway PR via the Gitea API (on a
dedicated base branch so `main` was untouched), listed it through `GET
/api/repo/prs`, then ran `POST /api/repo/pr/merge``{merged:true,
branchDeleted:true}`; confirmed the branch was gone (404), the PR list emptied,
and the feed logged `pr-merged`. Cleaned up the base branch afterward.
- **Why:** Prove the write path with real auth before relying on it.
- **Affects:** none (runtime verification only; no code change).
## 2026-09-20 — Slice 6: right-click command menu + git write actions (§6)
- **What:** git boundary gained `Pull`/`Push`/`Commit`/`DiscardAll` (the last is
§1.4-destructive). Scanner got `RefreshRepo` (single-repo re-scan). Service
gained `GitFetch/GitPull/GitPush/GitCommit/GitDiscard` — each records a `git-*`
activity event (ok/failed) and refreshes the repo after success; `service.New`
takes a refresh hook. New HTTP `POST /api/repo/git {path, op, message?}`. New
`<repo-menu>` overlay (plain-language commands: Get latest, Publish, Check for
updates, Save my work…, Set as active project, Ask Claude to switch here, Copy
path, and the confirmed Discard all changes…); `<repo-list>` emits
`repo:contextmenu` on right-click. Added `internal/service` test covering
commit/discard on a temp repo.
- **Why:** The GUI-first reason the app exists (§0) — run git in plain language
without a terminal. Logic lives in the shared service (§1.7) so the same ops can
be exposed to Claude via MCP next.
- **Affects:** `internal/git`, `internal/repos`, `internal/service` (+test),
`cmd/server/main.go`, `components/repo-menu` (new), `components/repo-list`,
`web/templates/{index,help}.html`.
- **Next:** expose these git ops as MCP tools so Claude can run them too.
## 2026-09-20 — Slice 7: git commands as MCP tools (§1.7 symmetry)
- **What:** Added MCP tools `git_fetch`, `git_pull`, `git_push`, `git_commit`,
and `git_discard_changes` — thin adapters over the existing service methods
(actor=claude), so Claude can run the same commands as the right-click menu.
`git_discard_changes`'s description flags it destructive and tells Claude to
confirm first (§1.4). Extended the MCP test with a `git_commit` round-trip.
Synced AGENT.md §8.1's tool list to the actual names.
- **Why:** Complete the §1.7 symmetry — every capability reachable from both the
GUI and Claude.
- **Affects:** `internal/mcp` (+test), `AGENT.md` (§8.1).
- **Note:** the new tools appear in Claude Desktop only after its next restart
(tool list cached per connection); network ops still need container git creds.
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1
# --- build: compile a static binary ----------------------------------------
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/gitmanager ./cmd/server
# --- dev: hot reload with air (used by docker-compose) ---------------------
FROM golang:1.26 AS dev
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN go install github.com/air-verse/air@latest
WORKDIR /app
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
# --- runtime: small image with git available -------------------------------
# git is required at runtime — the app shells out to it for every Git operation
# (AGENT.md §2). openssh-client + ca-certificates let it reach remotes.
FROM debian:stable-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /out/gitmanager /usr/local/bin/gitmanager
COPY web ./web
COPY components ./components
EXPOSE 8080
CMD ["gitmanager"]
+306
View File
@@ -0,0 +1,306 @@
// Command server is the GitManager entrypoint. It wires config, logging, the
// Git boundary, the repo scanner, and the Echo HTTP server, then serves the
// dashboard shell and the JSON endpoints the web components fetch from.
package main
import (
"context"
"encoding/json"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"gitmanager/internal/activity"
"gitmanager/internal/config"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp"
"gitmanager/internal/render"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
func main() {
cfg, err := config.Load()
if err != nil {
panic(err)
}
log, closer, err := logging.Setup(cfg.Dev, cfg.LogFile)
if err != nil {
panic(err)
}
if closer != nil {
defer closer.Close()
}
g := git.New(cfg.GitBin)
if v, err := g.Version(context.Background()); err != nil {
log.Warn("git binary not usable — repo operations will fail", "bin", cfg.GitBin, "err", err)
} else {
log.Info("git detected", "version", v)
}
// Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled)
// Coordination state: active project + activity feed (§8.2).
feed := activity.New(log, 200)
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4).
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken)
if err != nil {
log.Warn("forge disabled — invalid config", "err", err)
} else if fg != nil {
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL)
} else {
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable")
}
// The one service layer both the HTTP API and the MCP server call (§1.7).
// scanner.RefreshRepo lets a mutating action re-scan just that repo.
svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo)
tmpl, err := render.New("web/templates")
if err != nil {
log.Error("failed to parse templates", "err", err)
os.Exit(1)
}
e := echo.New()
e.HideBanner = true
e.Renderer = tmpl
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
// Static assets and component sources.
e.Static("/static", "web/static")
e.Static("/components", "components")
// Page shells.
e.GET("/", func(c echo.Context) error {
return c.Render(http.StatusOK, "index.html", nil)
})
e.GET("/help", func(c echo.Context) error {
return c.Render(http.StatusOK, "help.html", nil)
})
// JSON API — components self-fetch from here.
e.GET("/healthz", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
e.GET("/api/repos", func(c echo.Context) error {
return c.JSON(http.StatusOK, svc.ListRepos())
})
e.GET("/api/repo", func(c echo.Context) error {
// 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, detail)
})
// Active project + activity (§8.2).
e.GET("/api/active-project", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.POST("/api/active-project", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
// A user action in the UI (actor=user) — distinct from Claude's own switches.
if _, _, err := svc.SetActiveProject(activity.ActorUser, body.Path); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"path": svc.ActiveProject()})
})
e.GET("/api/activity", func(c echo.Context) error {
return c.JSON(http.StatusOK, svc.Activity(0))
})
// Graceful handoff (§8.3): the user requests a switch; Claude completes it.
e.GET("/api/switch", func(c echo.Context) error {
p, ok := svc.PendingSwitch()
if !ok {
return c.JSON(http.StatusOK, map[string]any{"pending": false})
}
return c.JSON(http.StatusOK, map[string]any{
"pending": true, "target": p.Target, "note": p.Note, "requestedAt": p.RequestedAt,
})
})
e.POST("/api/switch", func(c echo.Context) error {
var body struct {
Target string `json:"target"`
Note string `json:"note"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
p, err := svc.RequestSwitch(activity.ActorUser, body.Target, body.Note)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"pending": true, "target": p.Target, "note": p.Note})
})
e.DELETE("/api/switch", func(c echo.Context) error {
svc.CancelSwitch(activity.ActorUser)
return c.JSON(http.StatusOK, map[string]any{"pending": false})
})
// Plain-language git commands (the right-click menu, §6). One endpoint,
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
e.POST("/api/repo/git", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Op string `json:"op"`
Message string `json:"message"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
ctx := c.Request().Context()
var out string
var err error
switch body.Op {
case "fetch":
out, err = svc.GitFetch(ctx, activity.ActorUser, body.Path)
case "pull":
out, err = svc.GitPull(ctx, activity.ActorUser, body.Path)
case "push":
out, err = svc.GitPush(ctx, activity.ActorUser, body.Path)
case "commit":
out, err = svc.GitCommit(ctx, activity.ActorUser, body.Path, body.Message)
case "discard":
out, err = svc.GitDiscard(ctx, activity.ActorUser, body.Path)
default:
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown op: " + body.Op})
}
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"ok": true, "output": out})
})
// Forge PRs + "Merge & clean up" (§8.4).
e.GET("/api/repo/prs", func(c echo.Context) error {
prs, err := svc.ForgePRs(c.Request().Context(), c.QueryParam("path"))
if err != nil {
// Not configured / not on the forge host is a normal "no PRs here"
// state — tell the UI to hide the section rather than error.
if err == forge.ErrNotConfigured || err == forge.ErrNotSupported {
return c.JSON(http.StatusOK, map[string]any{"supported": false})
}
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs})
})
e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Number int64 `json:"number"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
res, err := svc.MergeAndCleanup(c.Request().Context(), activity.ActorUser, body.Path, body.Number)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, res)
})
// SSE stream of activity events for live UI (§8.2).
e.GET("/events", func(c echo.Context) error {
w := c.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
w.Flush()
ch, unsub := svc.SubscribeActivity()
defer unsub()
keepalive := time.NewTicker(25 * time.Second)
defer keepalive.Stop()
for {
select {
case <-c.Request().Context().Done():
return nil
case <-keepalive.C:
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return nil
}
w.Flush()
case ev := <-ch:
data, err := json.Marshal(ev)
if err != nil {
continue
}
if _, err := w.Write([]byte("event: activity\ndata: " + string(data) + "\n\n")); err != nil {
return nil
}
w.Flush()
}
}
})
// MCP server — Claude connects via the local stdio bridge (§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 {
log.Error("server error", "err", err)
os.Exit(1)
}
}()
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
log.Info("shutting down")
stopScan()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
log.Error("graceful shutdown failed", "err", err)
}
}
+141
View File
@@ -0,0 +1,141 @@
// <activity-feed> — shows the active project and a live feed of what happened
// (user AND Claude actions). A self-contained control (AGENT.md §1.1): shadow
// DOM, fetches its own initial data, subscribes to the /events SSE stream, and
// cleans up on disconnect. It reflects the coordination state that Claude reads
// over MCP (§8.2), so the user can see the two staying in sync.
class ActivityFeed extends HTMLElement {
#es = null;
#controller = null;
#events = [];
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadInitial();
// Live updates. EventSource auto-reconnects if the stream drops.
this.#es = new EventSource('/events');
this.#es.addEventListener('activity', (e) => {
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore malformed */ }
});
}
disconnectedCallback() {
this.#es?.close();
this.#controller?.abort();
}
async #loadInitial() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const [apRes, actRes] = await Promise.all([
fetch('/api/active-project', { signal: this.#controller.signal }),
fetch('/api/activity', { signal: this.#controller.signal }),
]);
const ap = await apRes.json();
const events = await actRes.json();
this.#events = Array.isArray(events) ? events : [];
this.#renderActive(ap.path || '');
this.#renderFeed();
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
}
#onEvent(ev) {
this.#events.push(ev);
if (this.#events.length > 200) this.#events = this.#events.slice(-200);
if (ev.kind === 'active-project-changed' || ev.kind === 'switch-completed') {
this.#renderActive(ev.repo || '');
}
this.#renderFeed();
}
#renderActive(path) {
const el = this.shadowRoot.getElementById('active');
el.textContent = path ? this.#base(path) : 'none';
el.title = path;
}
#renderFeed() {
const ul = this.shadowRoot.getElementById('feed');
// Newest first.
ul.replaceChildren(...[...this.#events].reverse().map((ev) => {
const li = document.createElement('li');
const repo = ev.repo ? this.#base(ev.repo) : '';
li.innerHTML = `
<span class="actor ${this.#esc(ev.actor)}">${this.#esc(ev.actor)}</span>
<span class="kind">${this.#esc(this.#label(ev.kind))}</span>
${repo ? `<span class="repo">${this.#esc(repo)}</span>` : ''}
${ev.detail ? `<span class="detail">${this.#esc(ev.detail)}</span>` : ''}
<span class="time">${this.#time(ev.time)}</span>`;
return li;
}));
}
#renderError(err) {
this.shadowRoot.getElementById('feed').innerHTML =
`<li class="error">Could not load activity: ${this.#esc(err.message)}</li>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.box {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
}
.active { margin-bottom: 8px; color: var(--color-fg-muted); }
.active strong { color: var(--fill-accent); }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 8px 0 6px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px;
max-height: 220px; overflow-y: auto; }
li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
.actor { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); text-transform: uppercase; }
.actor.user { color: var(--git-ahead); border-color: var(--git-ahead); }
.actor.claude { color: var(--color-success); border-color: var(--color-success); }
.actor.system { color: var(--color-fg-muted); }
.repo { font-weight: 600; }
.detail { color: var(--color-fg-muted); }
.time { margin-left: auto; color: var(--color-fg-muted); font-size: 11px; white-space: nowrap; }
.error { color: var(--color-danger); }
.empty { color: var(--color-fg-muted); }
</style>
<div class="box">
<div class="active">Active project: <strong id="active">none</strong></div>
<h3>Activity</h3>
<ul id="feed"><li class="empty">No activity yet.</li></ul>
</div>`;
}
#base(p) {
return String(p).split(/[/\\]/).filter(Boolean).pop() || p;
}
#label(kind) {
return String(kind || '').replace(/-/g, ' ');
}
#time(t) {
const d = new Date(t);
return isNaN(d) ? '' : d.toLocaleTimeString();
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('activity-feed', ActivityFeed);
+30
View File
@@ -0,0 +1,30 @@
# activity-feed
## Intent
Shows the **active project** and a **live feed** of what happened — both user and
Claude actions. It makes the coordination state visible (AGENT.md §8.2): the same
active project and events Claude reads over MCP (`get_active_project`,
`get_activity`), so the user can watch the two stay in sync. Groundwork for the
graceful project handoff (§8.3).
## Public surface
- **Tag:** `<activity-feed>`
- **Attributes/properties:** none.
- **Fetches (initial):** `GET /api/active-project`, `GET /api/activity`.
- **Subscribes:** `EventSource('/events')` — SSE stream; listens for `activity`
events (auto-reconnects if the stream drops). Closed on disconnect.
- **Renders:** active project (basename, full path on hover) + newest-first list
of events, each with an actor badge (user/claude/system), a humanized kind,
the repo, optional detail, and a timestamp.
## History
- 2026-09-20: created — slice 3; live active-project + activity view over SSE.
- 2026-09-20: also update the active-project display on `switch-completed` events
(slice 4 handoff), not only `active-project-changed`.
## Notes / gotchas
- All server-derived text is escaped before insertion (repo paths, details,
kinds) — treat as untrusted (§1.1).
- Uses shared design tokens for colors/radii — no hardcoded hex.
- The feed is capped client-side at 200 events to mirror the server ring buffer;
it does not paginate history.
+155
View File
@@ -0,0 +1,155 @@
// <handoff-bar> — the graceful project handoff control (AGENT.md §8.3).
//
// A self-contained control (§1.1): shadow DOM, self-fetching, live over SSE,
// cleans up on disconnect. It lets the user ask Claude to switch to the active
// project, shows the "waiting for a good stopping point" state while a request
// is pending, and announces the completion (with Claude's summary of where it
// left off) when Claude calls ack_switch.
class HandoffBar extends HTMLElement {
#es = null;
#controller = null;
#active = '';
#pending = null;
#completed = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadInitial();
this.#es = new EventSource('/events');
this.#es.addEventListener('activity', (e) => {
try { this.#onEvent(JSON.parse(e.data)); } catch { /* ignore */ }
});
}
disconnectedCallback() {
this.#es?.close();
this.#controller?.abort();
}
async #loadInitial() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const [swRes, apRes] = await Promise.all([
fetch('/api/switch', { signal: this.#controller.signal }),
fetch('/api/active-project', { signal: this.#controller.signal }),
]);
const sw = await swRes.json();
const ap = await apRes.json();
this.#pending = sw.pending ? sw : null;
this.#active = ap.path || '';
this.#render();
} catch (err) {
if (err.name !== 'AbortError') { /* keep default UI */ }
}
}
#onEvent(ev) {
switch (ev.kind) {
case 'switch-requested':
this.#pending = { target: ev.repo, note: ev.detail };
this.#completed = null;
break;
case 'switch-completed':
this.#pending = null;
this.#active = ev.repo || this.#active;
this.#completed = { target: ev.repo, summary: ev.detail };
break;
case 'switch-cancelled':
this.#pending = null;
break;
case 'active-project-changed':
this.#active = ev.repo || '';
break;
default:
return;
}
this.#render();
}
async #request() {
if (!this.#active) return;
await fetch('/api/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: this.#active }),
}).catch(() => {});
// The SSE switch-requested event updates the UI.
}
async #cancel() {
await fetch('/api/switch', { method: 'DELETE' }).catch(() => {});
}
#render() {
const body = this.shadowRoot.getElementById('body');
if (this.#pending) {
body.innerHTML = `
<span class="spinner">⏳</span>
<span>Waiting for Claude to reach a good stopping point to switch to
<strong>${this.#esc(this.#base(this.#pending.target))}</strong>…</span>
<button id="cancel" class="ghost">Cancel</button>`;
this.shadowRoot.getElementById('cancel').onclick = () => this.#cancel();
return;
}
const done = this.#completed
? `<span class="done">✅ Claude switched to
<strong>${this.#esc(this.#base(this.#completed.target))}</strong>${
this.#completed.summary ? ' — ' + this.#esc(this.#completed.summary) : ''
}</span>`
: '';
if (!this.#active) {
body.innerHTML = `${done}<span class="hint">Select a repository to make it the active project, then hand it off to Claude.</span>`;
return;
}
body.innerHTML = `
${done}
<button id="ask" class="primary">Ask Claude to switch to ${this.#esc(this.#base(this.#active))}</button>`;
this.shadowRoot.getElementById('ask').onclick = () => this.#request();
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
#body {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
}
button { font: inherit; border-radius: var(--radius-sm); cursor: pointer;
padding: 5px 12px; border: 1px solid var(--border-strong);
background: var(--surface-2); color: var(--color-fg); }
button.primary { border-color: var(--fill-accent); color: var(--fill-accent); }
button.ghost { color: var(--color-fg-muted); }
button:hover { border-color: var(--fill-accent); }
.spinner { font-size: 15px; }
.done { color: var(--color-success); }
.hint { color: var(--color-fg-muted); }
strong { color: var(--fill-accent); }
#cancel { margin-left: auto; }
</style>
<div id="body"></div>`;
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('handoff-bar', HandoffBar);
+28
View File
@@ -0,0 +1,28 @@
# handoff-bar
## Intent
The user-facing control for the **graceful project handoff** (AGENT.md §8.3). It
lets the user ask Claude to switch to the active project, shows the "waiting for a
good stopping point" state while the request is pending, and announces completion
(with Claude's summary of where it left off) when Claude calls `ack_switch`. The
switch is Claude-completed at a checkpoint, never app-forced (§1.4-class rule).
## Public surface
- **Tag:** `<handoff-bar>`
- **Attributes/properties:** none.
- **Fetches (initial):** `GET /api/switch` (pending request), `GET /api/active-project`.
- **Writes:** `POST /api/switch {target}` to request a handoff to the active
project (actor=user); `DELETE /api/switch` to cancel.
- **Subscribes:** `EventSource('/events')` — reacts to `switch-requested`,
`switch-completed` (shows Claude's summary), `switch-cancelled`, and
`active-project-changed`. Closed on disconnect.
## History
- 2026-09-20: created — slice 4; request/pending/completed UI over the
`/api/switch` endpoints and SSE.
## Notes / gotchas
- All server-derived text is escaped before insertion (targets, summaries).
- The request targets the current **active project**; select a repo first (that
sets the active project via `<repo-list>`).
- The completion message persists until the next request; it is informational.
+139
View File
@@ -0,0 +1,139 @@
// <pr-list> — open pull requests for the selected repo, with "Merge & clean up".
//
// A self-contained control (AGENT.md §1.1): shadow DOM, self-fetching, cleans up
// on disconnect. It listens for `repo:select` and shows the repo's open PRs when
// a forge (Gitea) is configured; otherwise it stays hidden (graceful, §8.4).
// "Merge & clean up" is a DESTRUCTIVE action (§1.4): it confirms — naming the PR,
// base, and branch to be deleted — before calling the server.
class PRList extends HTMLElement {
#controller = null;
#onSelect = null;
#path = '';
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path);
document.addEventListener('repo:select', this.#onSelect);
}
disconnectedCallback() {
document.removeEventListener('repo:select', this.#onSelect);
this.#controller?.abort();
}
async #load(path) {
if (!path) return;
this.#path = path;
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch(`/api/repo/prs?path=${encodeURIComponent(path)}`, { signal: this.#controller.signal });
const data = await res.json();
if (!data.supported) { this.hidden = true; return; }
this.hidden = false;
this.#render(data.prs || []);
} catch (err) {
if (err.name !== 'AbortError') { this.hidden = false; this.#error(err.message); }
}
}
async #merge(pr) {
const ok = window.confirm(
`Merge & clean up PR #${pr.number}: "${pr.title}"?\n\n` +
`This squash-merges it into ${pr.base} and DELETES the branch "${pr.head}".\n` +
`This cannot be undone.`
);
if (!ok) return;
try {
const res = await fetch('/api/repo/pr/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, number: pr.number }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#load(this.#path); // refresh the list
} catch (err) {
this.#error(`Merge failed: ${err.message}`);
}
}
#render(prs) {
const body = this.shadowRoot.getElementById('body');
if (prs.length === 0) {
body.innerHTML = `<p class="muted">No open pull requests.</p>`;
return;
}
body.replaceChildren(...prs.map((pr) => {
const li = document.createElement('li');
li.innerHTML = `
<div class="row">
<a class="num" href="${this.#esc(pr.url)}" target="_blank" rel="noopener">#${pr.number}</a>
<span class="title">${this.#esc(pr.title)}</span>
${pr.draft ? `<span class="badge draft">draft</span>` : ''}
</div>
<div class="meta">
${this.#esc(pr.author)} · <code>${this.#esc(pr.head)}</code> → <code>${this.#esc(pr.base)}</code>
${pr.sameRepo ? '' : `<span class="badge fork">fork</span>`}
</div>`;
const btn = document.createElement('button');
btn.textContent = 'Merge & clean up';
btn.className = 'merge';
btn.title = pr.draft ? 'This PR is a draft' : 'Squash-merge and delete the branch';
btn.onclick = () => this.#merge(pr);
li.querySelector('.row').appendChild(btn);
return li;
}));
}
#error(msg) {
this.shadowRoot.getElementById('body').innerHTML = `<p class="error">${this.#esc(msg)}</p>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0 0 8px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; }
.row { display: flex; align-items: center; gap: 8px; }
.num { color: var(--fill-accent); text-decoration: none; font-weight: 600; }
.title { flex: 1; }
.meta { color: var(--color-fg-muted); font-size: 12px; margin-top: 2px; }
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
.badge { font-size: 11px; padding: 0 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); }
.draft { color: var(--color-warning); border-color: var(--color-warning); }
.fork { color: var(--color-fg-muted); margin-left: 6px; }
button.merge { font: inherit; cursor: pointer; padding: 4px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--color-danger);
color: var(--color-danger); background: transparent; }
button.merge:hover { background: var(--color-danger-bg); }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
</style>
<div class="box">
<h3>Pull requests</h3>
<div id="body"><p class="muted">Select a repository.</p></div>
</div>`;
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('pr-list', PRList);
+26
View File
@@ -0,0 +1,26 @@
# pr-list
## Intent
Shows the open pull requests for the selected repository and provides the
**"Merge & clean up"** action (AGENT.md §8.4) — the feature that makes PRs usable
for someone who otherwise finds them clutter: squash-merge and delete the branch
in one click. The merge is DESTRUCTIVE (§1.4), so the button confirms first,
naming the PR, base, and branch to be deleted.
## Public surface
- **Tag:** `<pr-list>`
- **Attributes/properties:** none. Hides itself (`hidden`) when no forge is
configured or the repo isn't on the forge host.
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden).
- **Writes:** `POST /api/repo/pr/merge {path, number}` after a `confirm()`.
## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
## Notes / gotchas
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
stays hidden (graceful degradation).
- Fork PRs are labelled; their branch lives in the fork, so cleanup only deletes
branches in the same repo (the server enforces this too).
- All server-derived text is escaped; the PR link opens in a new tab.
+140
View File
@@ -0,0 +1,140 @@
// <repo-detail> — detail panel for the repository selected in <repo-list>.
//
// A self-contained control (AGENT.md §1.1): shadow DOM, fetches its own data,
// cleans up on disconnect. It has no reference to <repo-list>; it listens on the
// document for the bubbling/composed `repo:select` event (the sanctioned
// cross-component channel) and fetches `/api/repo?path=…` for the chosen repo.
class RepoDetail extends HTMLElement {
#controller = null;
#onSelect = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#renderEmpty();
this.#onSelect = (e) => this.#load(e.detail?.path);
document.addEventListener('repo:select', this.#onSelect);
}
disconnectedCallback() {
document.removeEventListener('repo:select', this.#onSelect);
this.#controller?.abort();
}
async #load(path) {
if (!path) return;
this.#controller?.abort();
this.#controller = new AbortController();
this.#body().innerHTML = `<p class="muted">Loading…</p>`;
try {
const res = await fetch(`/api/repo?path=${encodeURIComponent(path)}`, {
signal: this.#controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderDetail(await res.json());
} catch (err) {
if (err.name !== 'AbortError') {
this.#body().innerHTML = `<p class="error">Could not load details: ${this.#esc(err.message)}</p>`;
}
}
}
#body() { return this.shadowRoot.getElementById('body'); }
#renderEmpty() {
this.#body().innerHTML = `<p class="muted">Select a repository to see its details.</p>`;
}
#renderDetail(r) {
const remotes = (r.remoteDetails || []).map((rm) =>
`<li><span class="k">${this.#esc(rm.name)}</span> <span class="url">${this.#esc(rm.url)}</span></li>`
).join('') || `<li class="muted">none</li>`;
const branches = (r.branches || []).map((b) =>
`<li class="${b.current ? 'cur' : ''}">
${b.current ? '<span class="dot">●</span>' : ''}${this.#esc(b.name)}
${b.upstream ? `<span class="up">→ ${this.#esc(b.upstream)}</span>` : ''}
</li>`
).join('') || `<li class="muted">none</li>`;
const commits = (r.commits || []).map((c) =>
`<li>
<code>${this.#esc(c.short)}</code>
<span class="subject">${this.#esc(c.subject)}</span>
<span class="meta">${this.#esc(c.author)} · ${this.#esc(c.date)}</span>
</li>`
).join('') || `<li class="muted">none</li>`;
this.#body().innerHTML = `
<div class="head">
<h2>${this.#esc(r.name)}</h2>
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
</div>
<p class="path">${this.#esc(r.path)}</p>
<p class="branch">on <strong>${this.#esc(r.branch || '—')}</strong></p>
${r.error ? `<p class="error">${this.#esc(r.error)}</p>` : ''}
<h3>Remotes</h3>
<ul class="remotes">${remotes}</ul>
<h3>Branches</h3>
<ul class="branches">${branches}</ul>
<h3>Recent commits</h3>
<ul class="commits">${commits}</ul>
`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
#body {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
}
.head { display: flex; align-items: center; gap: 10px; }
h2 { margin: 0; font-size: 16px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 20px 0 6px; }
.path { color: var(--color-fg-muted); font-size: 12px; margin: 6px 0 0;
word-break: break-all; }
.branch { margin: 4px 0 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
li { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
code { background: var(--surface-2); padding: 0 5px; border-radius: var(--radius-sm); }
.k { font-weight: 600; }
.url, .up, .meta { color: var(--color-fg-muted); font-size: 12px; }
.subject { flex: 1; }
.branches .cur { color: var(--git-ahead); font-weight: 600; }
.dot { color: var(--git-ahead); }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
.badge { font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); }
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
</style>
<div id="body"></div>
`;
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-detail', RepoDetail);
+32
View File
@@ -0,0 +1,32 @@
# repo-detail
## Intent
The detail panel for the repository selected in the dashboard. It fulfills the
right-docked "detail panel" role (AGENT.md §4) and demonstrates cross-component
communication done the sanctioned way (AGENT.md §1.1): it holds no reference to
`<repo-list>` — it only listens for the `repo:select` event and fetches its own
data.
## Public surface
- **Tag:** `<repo-detail>`
- **Attributes/properties:** none.
- **Listens:** `repo:select` on `document` — the bubbling/composed event emitted
by `<repo-list>`; `event.detail.path` selects the repo.
- **Fetches:** `GET /api/repo?path=<abs path>` (in-flight request aborted on the
next selection and on disconnect). The endpoint only serves repos already in
the scanner index — it never runs git against an arbitrary query path.
- **Renders:** name + status badges, path, current branch, remotes (name + URL),
local branches (current flagged, upstream shown), and the 20 most recent
commits.
## History
- 2026-09-19: created — first detail panel; consumes `repo:select`, backed by the
new `/api/repo` endpoint and `internal/git` branch/commit/remote readers.
## Notes / gotchas
- Server output is escaped before insertion (`#esc`); branch names, commit
subjects, and remote URLs all originate from repo contents — treat as untrusted.
- Uses shared design tokens for all colors/radii — no hardcoded hex (AGENT.md §1.1).
- Data is fetched on selection only (no polling); it will not auto-refresh while a
repo stays selected. A push/refresh signal can be added without changing the
public surface.
+138
View File
@@ -0,0 +1,138 @@
// <repo-list> — the dashboard's list of discovered repositories.
//
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
// shadow root, fetches its own data from /api/repos on connect, renders itself,
// and cleans up on disconnect. It talks to the rest of the app only via a
// bubbling/composed `repo:select` CustomEvent — no shared globals.
class RepoList extends HTMLElement {
#refreshMs = 15000;
#timer = null;
#controller = null;
#repos = [];
#selected = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
}
disconnectedCallback() {
clearInterval(this.#timer);
this.#controller?.abort();
}
async #load() {
this.#controller?.abort();
this.#controller = new AbortController();
try {
const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json());
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
}
#select(repo) {
this.#selected = repo.path;
this.#renderRepos(this.#repos); // reflect selection highlight
// Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
}));
// Selecting a repo makes it the active project (a user action, §8.2). Fire
// and forget — the SSE feed reflects the change; failure just skips it.
fetch('/api/active-project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: repo.path }),
}).catch(() => {});
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
}
#renderRepos(repos) {
this.#repos = repos || [];
const body = this.shadowRoot.getElementById('body');
if (this.#repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return;
}
const ul = document.createElement('ul');
for (const r of this.#repos) {
const li = document.createElement('li');
if (r.path === this.#selected) li.classList.add('selected');
// Right-click opens the command menu (§6) for this repo — via an event,
// so <repo-menu> stays decoupled from this component (§1.1).
li.addEventListener('contextmenu', (e) => {
e.preventDefault();
this.dispatchEvent(new CustomEvent('repo:contextmenu', {
detail: { repo: r, x: e.clientX, y: e.clientY },
bubbles: true, composed: true,
}));
});
li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span>
<span class="branch">${this.#esc(r.branch || '—')}</span>
<span class="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
${r.behind ? `<span class="badge behind">↓${r.behind}</span>` : ''}
<span class="badge ${r.dirty ? 'dirty' : 'clean'}">${r.dirty ? 'dirty' : 'clean'}</span>
`;
li.addEventListener('click', () => this.#select(r));
ul.appendChild(li);
}
body.replaceChildren(ul);
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('repo-list', RepoList);
+36
View File
@@ -0,0 +1,36 @@
# repo-list
## Intent
The dashboard's list of every discovered repository. It is the first
ActiveX-spirit control in the app (AGENT.md §1.1): a self-contained custom
element that fetches its own data, renders inside its shadow root, and
communicates outward only through events. It exists to prove and anchor the
component pattern the rest of the UI follows.
## Public surface
- **Tag:** `<repo-list>`
- **Attributes/properties:** none yet.
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
aborted on refresh and on disconnect).
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
is the clicked repo's state object. The detail panel (future) listens for this.
## History
- 2026-09-19: created — first component; renders name, branch, ahead/behind, and
a clean/dirty badge; establishes the shadow-DOM + self-fetch + event pattern.
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an
accent border/background (the item that `<repo-detail>` is showing). Caches the
last `/api/repos` payload so re-selecting re-renders without a refetch.
- 2026-09-20: selecting a repo now also `POST`s `/api/active-project` to make it
the active project (a user action, §8.2) — surfaced in `<activity-feed>` and
readable by Claude via `get_active_project`. Fire-and-forget.
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for
`<repo-menu>` (§6). Right-click does not change the selection/active project.
## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
colors and radii — no hardcoded hex (AGENT.md §1.1).
- Server output is escaped before insertion (`#esc`); repo names come from the
filesystem, so treat them as untrusted.
- Polling is a placeholder cadence; a push/SSE update channel can replace it
later without changing the public surface.
+176
View File
@@ -0,0 +1,176 @@
// <repo-menu> — the right-click command menu (AGENT.md §6).
//
// A self-contained control (§1.1): shadow DOM, listens for the bubbling
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of
// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run
// on click; the destructive one ("Discard all changes") confirms first (§1.4).
// It calls the same endpoints Claude uses via MCP (one service layer, §1.7);
// results show up live in <activity-feed>.
const ITEMS = [
{ cmd: 'pull', label: 'Get latest', hint: 'pull' },
{ cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' },
{ sep: true },
{ cmd: 'active', label: 'Set as active project' },
{ cmd: 'handoff', label: 'Ask Claude to switch here' },
{ cmd: 'copy', label: 'Copy path' },
{ sep: true },
{ cmd: 'discard', label: 'Discard all changes…', hint: 'reset --hard', danger: true },
];
class RepoMenu extends HTMLElement {
#repo = null;
#onContext = null;
#onDocClick = null;
#onKey = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onContext = (e) => this.#open(e.detail);
document.addEventListener('repo:contextmenu', this.#onContext);
}
disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext);
this.#teardownDismiss();
}
#open({ repo, x, y }) {
if (!repo) return;
this.#repo = repo;
const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false;
// Position, clamped to the viewport.
const rect = menu.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(8, left) + 'px';
menu.style.top = Math.max(8, top) + 'px';
// Dismiss on next outside click, Esc, or scroll.
this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0);
}
#hideBound() { return () => this.#hide(); }
#hide() {
this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss();
}
#teardownDismiss() {
if (this.#onDocClick) document.removeEventListener('click', this.#onDocClick);
if (this.#onKey) document.removeEventListener('keydown', this.#onKey);
this.#onDocClick = this.#onKey = null;
}
async #dispatch(cmd) {
const repo = this.#repo;
const name = this.#base(repo.path);
this.#hide();
switch (cmd) {
case 'pull': case 'push': case 'fetch':
await this.#git(cmd);
break;
case 'commit': {
const msg = window.prompt(`Commit message for ${name}:`);
if (msg && msg.trim()) await this.#git('commit', { message: msg });
break;
}
case 'discard': {
const ok = window.confirm(
`Discard ALL uncommitted changes in ${name}?\n\n` +
`This resets tracked files to the last commit and cannot be undone.`
);
if (ok) await this.#git('discard');
break;
}
case 'active':
await this.#post('/api/active-project', { path: repo.path });
break;
case 'handoff':
await this.#post('/api/switch', { target: repo.path });
break;
case 'copy':
try { await navigator.clipboard.writeText(repo.path); } catch { /* ignore */ }
break;
}
}
async #git(op, extra = {}) {
try {
const res = await fetch('/api/repo/git', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#repo.path, op, ...extra }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
} catch (err) {
window.alert(`${op} failed: ${err.message}`);
}
}
async #post(url, body) {
try {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch { /* ignore */ }
}
#renderShell() {
const rows = ITEMS.map((it) => it.sep
? '<hr>'
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
</button>`).join('');
this.shadowRoot.innerHTML = `
<style>
#menu {
position: fixed; z-index: 1000; min-width: 220px;
background: var(--surface-2); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 8px 28px rgba(0,0,0,.45);
}
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit;
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; }
button:hover { background: var(--fill-accent); color: #071019; }
button code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; }
button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; }
button.danger:hover code { color: #fff; }
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
</style>
<div id="menu" hidden>
<div class="hdr" id="hdr"></div>
${rows}
</div>`;
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (btn) this.#dispatch(btn.dataset.cmd);
});
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
}
customElements.define('repo-menu', RepoMenu);
+32
View File
@@ -0,0 +1,32 @@
# repo-menu
## Intent
The right-click command menu (AGENT.md §6) — the app's reason for being: run git
in **plain language** ("Get latest", "Publish", "Save my work…") without a
terminal. A self-contained overlay control (§1.1) that any list can summon via an
event. Safe commands run on click; the destructive one confirms first (§1.4).
## Public surface
- **Tag:** `<repo-menu>` (place once, near the end of the page).
- **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Discard all
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/
commit/discard). "Save my work…" prompts for a message; "Discard all
changes…" confirms (destructive).
- Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard.
- Dismisses on outside click, Esc, or scroll.
## History
- 2026-09-20: created — slice 6; plain-language git commands + coordination
actions, backed by the shared service layer (same ops Claude gets via MCP).
## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a
`git-*` event with ok/failed), and errors raise a browser alert.
- Network ops (pull/push/fetch) need git credentials reachable from the server;
inside Docker that means a mounted SSH agent / credential helper (§11) — until
then they'll report an auth error. commit/discard are local and always work.
+45
View File
@@ -0,0 +1,45 @@
# Dev environment: `docker compose up` builds the app with hot reload (air) and
# mounts your host repositories in (AGENT.md §1.6). Because the app operates on
# repos that live on the host, the roots are mounted read-write.
services:
app:
build:
context: .
target: dev
env_file: .env
environment:
# 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
# Cache the Go module + build cache across restarts.
- gomod:/go/pkg/mod
# Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the
# host folder that holds them; defaults to ./repos next to this file.
- "${REPOS_HOST_PATH:-./repos}:/repos"
# --- Optional: let git authenticate to remotes from inside the container.
# Uncomment ONE approach and adjust for your host (AGENT.md §1.6, §11):
# SSH agent socket (Linux/macOS):
# - "${SSH_AUTH_SOCK}:/ssh-agent"
# or mounted keys (read-only):
# - "${HOME}/.ssh:/root/.ssh:ro"
# environment for the SSH-agent option:
# environment:
# - SSH_AUTH_SOCK=/ssh-agent
volumes:
gomod:
+33
View File
@@ -0,0 +1,33 @@
module gitmanager
go 1.26
require (
code.gitea.io/sdk/gitea v0.25.1
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/42wim/httpsig v1.2.4 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hashicorp/go-version v1.9.0 // 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
)
+76
View File
@@ -0,0 +1,76 @@
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
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/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
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=
github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
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.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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=
+224
View File
@@ -0,0 +1,224 @@
// Package activity holds the app's coordination state: the single active project
// (the repo/task currently in focus) and a bounded feed of what happened — user
// AND Claude actions. Both are in-memory (mirrored to the logs, no datastore —
// AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new
// events also fan out to subscribers for the browser SSE stream (§8.2). This is
// the foundation the graceful project handoff (§8.3) builds on.
package activity
import (
"log/slog"
"sync"
"time"
)
// Actor is who caused an event.
type Actor string
const (
ActorUser Actor = "user"
ActorClaude Actor = "claude"
ActorSystem Actor = "system"
)
// Event is one entry in the activity feed.
type Event struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
Actor Actor `json:"actor"`
Kind string `json:"kind"` // e.g. "active-project-changed"
Repo string `json:"repo,omitempty"` // repo path, when relevant
Detail string `json:"detail,omitempty"` // human-readable extra context
}
// PendingSwitch is a user's request for Claude to switch to another project.
// It is the request half of the graceful handoff (§8.3) — Claude fulfils it at a
// safe stopping point via AckSwitch.
type PendingSwitch struct {
Target string `json:"target"` // requested repo path
Note string `json:"note,omitempty"`
RequestedBy Actor `json:"requestedBy"` // normally the user
RequestedAt time.Time `json:"requestedAt"`
}
// Feed is the concurrency-safe active-project + activity store with fan-out.
type Feed struct {
mu sync.RWMutex
active string // active project path ("" = none)
pending *PendingSwitch // an outstanding switch request, if any
events []Event
maxEvents int
nextID int64
subs map[chan Event]struct{}
log *slog.Logger
}
// New builds a Feed keeping at most maxEvents recent events.
func New(log *slog.Logger, maxEvents int) *Feed {
if maxEvents <= 0 {
maxEvents = 200
}
return &Feed{
maxEvents: maxEvents,
nextID: 1,
subs: make(map[chan Event]struct{}),
log: log,
}
}
// ActiveProject returns the current active project path ("" if none).
func (f *Feed) ActiveProject() string {
f.mu.RLock()
defer f.mu.RUnlock()
return f.active
}
// SetActiveProject sets the active project and records an event. It is a no-op
// (changed=false, zero Event) when path already matches, so repeated sets don't
// spam the feed.
func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) {
f.mu.Lock()
if f.active == path {
f.mu.Unlock()
return Event{}, false
}
f.active = path
ev, subs := f.appendLocked(actor, "active-project-changed", path, "")
f.mu.Unlock()
publish(subs, ev)
return ev, true
}
// RequestSwitch records a user's request for Claude to switch to target. The
// latest request wins (it overwrites any outstanding one).
func (f *Feed) RequestSwitch(actor Actor, target, note string) PendingSwitch {
f.mu.Lock()
p := PendingSwitch{Target: target, Note: note, RequestedBy: actor, RequestedAt: time.Now()}
f.pending = &p
ev, subs := f.appendLocked(actor, "switch-requested", target, note)
f.mu.Unlock()
publish(subs, ev)
return p
}
// PendingSwitch returns the outstanding switch request, if any.
func (f *Feed) PendingSwitch() (PendingSwitch, bool) {
f.mu.RLock()
defer f.mu.RUnlock()
if f.pending == nil {
return PendingSwitch{}, false
}
return *f.pending, true
}
// AckSwitch completes a pending switch: it makes the requested target the active
// project, clears the request, and records a "switch-completed" event carrying
// Claude's summary of where it left the previous project. Returns false if there
// was nothing pending.
func (f *Feed) AckSwitch(actor Actor, summary string) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
f.active = p.Target
ev, subs := f.appendLocked(actor, "switch-completed", p.Target, summary)
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// CancelSwitch clears a pending switch (e.g. the user changed their mind).
func (f *Feed) CancelSwitch(actor Actor) (PendingSwitch, bool) {
f.mu.Lock()
if f.pending == nil {
f.mu.Unlock()
return PendingSwitch{}, false
}
p := *f.pending
f.pending = nil
ev, subs := f.appendLocked(actor, "switch-cancelled", p.Target, "")
f.mu.Unlock()
publish(subs, ev)
return p, true
}
// Record adds an arbitrary event to the feed.
func (f *Feed) Record(actor Actor, kind, repo, detail string) Event {
f.mu.Lock()
ev, subs := f.appendLocked(actor, kind, repo, detail)
f.mu.Unlock()
publish(subs, ev)
return ev
}
// Events returns up to limit of the most recent events, oldest first. limit<=0
// returns all retained events.
func (f *Feed) Events(limit int) []Event {
f.mu.RLock()
defer f.mu.RUnlock()
if limit <= 0 || limit > len(f.events) {
limit = len(f.events)
}
out := make([]Event, limit)
copy(out, f.events[len(f.events)-limit:])
return out
}
// Subscribe returns a channel of future events and an unsubscribe func the
// caller MUST invoke when done (e.g. via defer) to avoid leaking the channel.
func (f *Feed) Subscribe() (<-chan Event, func()) {
ch := make(chan Event, 16)
f.mu.Lock()
f.subs[ch] = struct{}{}
f.mu.Unlock()
var once sync.Once
unsub := func() {
once.Do(func() {
f.mu.Lock()
delete(f.subs, ch)
f.mu.Unlock()
close(ch)
})
}
return ch, unsub
}
// appendLocked assigns id/time, appends (trimming to maxEvents), logs, and
// returns the event plus a snapshot of subscriber channels to publish to after
// the lock is released. Caller must hold f.mu.
func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) {
ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail}
f.nextID++
f.events = append(f.events, ev)
if len(f.events) > f.maxEvents {
f.events = f.events[len(f.events)-f.maxEvents:]
}
if f.log != nil {
f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail)
}
subs := make([]chan Event, 0, len(f.subs))
for ch := range f.subs {
subs = append(subs, ch)
}
return ev, subs
}
// publish does a non-blocking send to each subscriber; a full channel (slow
// consumer) drops the event rather than stalling the producer.
func publish(subs []chan Event, ev Event) {
for _, ch := range subs {
select {
case ch <- ev:
default:
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Package config loads GitManager's runtime configuration from the environment
// (and an optional .env file). See AGENT.md §1.5 — every setting is env-driven;
// nothing is hardcoded.
package config
import (
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
// Config is the typed application configuration.
type Config struct {
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
ScanInterval time.Duration // scanner refresh interval
ScanMaxDepth int // max discovery depth under each root
ScanIgnore []string // directory names to skip during discovery
ScanFetchEnabled bool // allow the scanner to run `git fetch`
Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to
GiteaURL string // Gitea/Forgejo base URL (e.g. https://git.nilles.net)
GiteaToken string // Gitea token (read + PR write + branch delete) — §8.4
GitHubToken string // optional forge token (later provider)
GitLabToken string // optional forge token (later provider)
}
// Load reads .env (if present) then the environment, applying defaults.
// A missing .env is not an error — the environment may be set another way.
func Load() (Config, error) {
_ = godotenv.Load()
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),
ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")),
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""),
GiteaURL: env("GITEA_URL", ""),
GiteaToken: env("GITEA_TOKEN", ""),
GitHubToken: env("GITHUB_TOKEN", ""),
GitLabToken: env("GITLAB_TOKEN", ""),
}
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
if err != nil {
return Config{}, err
}
c.ScanInterval = interval
return c, nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
return b
}
}
return def
}
// splitList splits a comma-separated value, trimming spaces and dropping empties.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
+99
View File
@@ -0,0 +1,99 @@
// Package forge is the provider-abstracted boundary to a git hosting service
// (AGENT.md §8.4). Gitea/Forgejo is the first provider; GitHub/GitLab can drop in
// behind the same interface. It is read + write: the write path powers
// "Merge & clean up" (merge a PR + delete its branch), which callers must confirm
// per §1.4. With no provider configured the features degrade gracefully.
package forge
import (
"context"
"errors"
"net/url"
"strings"
)
// Sentinel errors so callers (and the UI) can degrade gracefully.
var (
ErrNotConfigured = errors.New("forge integration not configured")
ErrNotSupported = errors.New("repository is not on a supported forge host")
)
// PullRequest is the provider-neutral view of an open PR/MR.
type PullRequest struct {
Number int64 `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
Head string `json:"head"` // head branch
Base string `json:"base"` // base branch
Draft bool `json:"draft"`
Mergeable bool `json:"mergeable"`
URL string `json:"url"`
SameRepo bool `json:"sameRepo"` // head & base in the same repo (branch is deletable)
}
// MergeMethod selects how a PR is merged.
type MergeMethod string
const (
MergeSquash MergeMethod = "squash"
MergeMerge MergeMethod = "merge"
MergeRebase MergeMethod = "rebase"
)
// MergeResult reports the outcome of a merge-and-cleanup.
type MergeResult struct {
Number int64 `json:"number"`
Merged bool `json:"merged"`
Branch string `json:"branch"`
BranchDeleted bool `json:"branchDeleted"`
}
// Provider talks to one hosting provider.
type Provider interface {
// Handles reports whether this provider serves the given remote host.
Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error)
MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
}
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both
// https ("https://host/owner/repo.git") and scp-like ssh ("git@host:owner/repo.git").
func ParseRemote(remote string) (host, owner, repo string, ok bool) {
remote = strings.TrimSpace(remote)
if remote == "" {
return "", "", "", false
}
// scp-like ssh form has no scheme: user@host:path
if !strings.Contains(remote, "://") && strings.Contains(remote, "@") && strings.Contains(remote, ":") {
rest := remote[strings.Index(remote, "@")+1:]
colon := strings.Index(rest, ":")
host = rest[:colon]
owner, repo, ok = splitOwnerRepo(rest[colon+1:])
return host, owner, repo, ok
}
u, err := url.Parse(remote)
if err != nil || u.Hostname() == "" {
return "", "", "", false
}
owner, repo, ok = splitOwnerRepo(u.Path)
return u.Hostname(), owner, repo, ok
}
// splitOwnerRepo turns "/owner/repo.git" (or subgroups) into (owner, repo). It
// takes the last two path segments, which covers the common single-owner case.
func splitOwnerRepo(path string) (owner, repo string, ok bool) {
path = strings.Trim(path, "/")
path = strings.TrimSuffix(path, ".git")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return "", "", false
}
owner = parts[len(parts)-2]
repo = parts[len(parts)-1]
if owner == "" || repo == "" {
return "", "", false
}
return owner, repo, true
}
+31
View File
@@ -0,0 +1,31 @@
package forge
import "testing"
func TestParseRemote(t *testing.T) {
cases := []struct {
in string
host, owner, repo string
ok bool
}{
{"https://git.nilles.net/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net/TBNilles/GitManager", "git.nilles.net", "TBNilles", "GitManager", true},
{"git@git.nilles.net:TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"https://git.nilles.net:3000/org/sub/Repo.git", "git.nilles.net", "sub", "Repo", true},
{"ssh://git@git.nilles.net:2222/TBNilles/GitManager.git", "git.nilles.net", "TBNilles", "GitManager", true},
{"not a url", "", "", "", false},
{"https://git.nilles.net/", "", "", "", false},
}
for _, c := range cases {
host, owner, repo, ok := ParseRemote(c.in)
if ok != c.ok {
t.Errorf("ParseRemote(%q) ok = %v, want %v", c.in, ok, c.ok)
continue
}
// Field values only matter on success.
if ok && (host != c.host || owner != c.owner || repo != c.repo) {
t.Errorf("ParseRemote(%q) = (%q,%q,%q), want (%q,%q,%q)",
c.in, host, owner, repo, c.host, c.owner, c.repo)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
package forge
import (
"context"
"fmt"
"net/url"
"strings"
"code.gitea.io/sdk/gitea"
)
// Gitea is a Provider backed by a Gitea/Forgejo instance. It also decides which
// repos it serves: only those whose remote host matches its base URL.
type Gitea struct {
host string
client *gitea.Client
}
// NewGitea builds a Gitea provider from a base URL and token. It returns
// (nil, nil) when not configured (either value empty) so forge features simply
// stay absent (§8.4 graceful degradation).
func NewGitea(baseURL, token string) (*Gitea, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
token = strings.TrimSpace(token)
if baseURL == "" || token == "" {
return nil, nil
}
u, err := url.Parse(baseURL)
if err != nil || u.Hostname() == "" {
return nil, fmt.Errorf("invalid GITEA_URL %q", baseURL)
}
c, err := gitea.NewClient(baseURL, gitea.SetToken(token))
if err != nil {
return nil, err
}
return &Gitea{host: u.Hostname(), client: c}, nil
}
// Handles reports whether a remote host is this Gitea instance.
func (g *Gitea) Handles(host string) bool {
return strings.EqualFold(host, g.host)
}
// ListPullRequests lists the open PRs of owner/repo.
func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullRequest, error) {
prs, _, err := g.client.ListRepoPullRequests(owner, repo, gitea.ListPullRequestsOptions{State: gitea.StateOpen})
if err != nil {
return nil, err
}
out := make([]PullRequest, 0, len(prs))
for _, p := range prs {
out = append(out, toPR(p))
}
return out, nil
}
// MergeAndCleanup merges the PR and deletes its head branch (when the head is in
// the same repo — never a fork's branch). Callers MUST have confirmed with the
// user first (§1.4).
func (g *Gitea) MergeAndCleanup(_ context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error) {
pr, _, err := g.client.GetPullRequest(owner, repo, number)
if err != nil {
return MergeResult{}, err
}
branch := ""
if pr.Head != nil {
branch = pr.Head.Ref
}
sameRepo := pr.Head != nil && pr.Base != nil && pr.Head.RepoID == pr.Base.RepoID
del := sameRepo && branch != ""
merged, _, err := g.client.MergePullRequest(owner, repo, number, gitea.MergePullRequestOption{
Style: toStyle(method),
DeleteBranchAfterMerge: &del,
})
if err != nil {
return MergeResult{}, err
}
res := MergeResult{Number: number, Merged: merged, Branch: branch, BranchDeleted: del && merged}
// Best-effort fallback in case the merge option didn't delete the branch
// (older Gitea). Ignore the error — the branch may already be gone.
if merged && del {
_, _, _ = g.client.DeleteRepoBranch(owner, repo, branch)
}
return res, nil
}
func toPR(p *gitea.PullRequest) PullRequest {
pr := PullRequest{
Number: p.Index,
Title: p.Title,
Draft: p.Draft,
Mergeable: p.Mergeable,
URL: p.HTMLURL,
}
if p.Poster != nil {
pr.Author = p.Poster.UserName
}
if p.Head != nil {
pr.Head = p.Head.Ref
}
if p.Base != nil {
pr.Base = p.Base.Ref
}
if p.Head != nil && p.Base != nil {
pr.SameRepo = p.Head.RepoID == p.Base.RepoID
}
return pr
}
func toStyle(m MergeMethod) gitea.MergeStyle {
switch m {
case MergeMerge:
return gitea.MergeStyleMerge
case MergeRebase:
return gitea.MergeStyleRebase
default:
return gitea.MergeStyleSquash
}
}
+252
View File
@@ -0,0 +1,252 @@
// Package git is THE boundary for every Git operation (AGENT.md §1.3). All Git
// access — read and, later, write — goes through here by shelling out to the
// system `git` binary via os/exec, so the user's credential helpers, SSH keys,
// hooks, and config apply exactly.
//
// The methods below are all READ-ONLY. Mutating operations (checkout, commit,
// push, …) will be added here as they are built; destructive ones must obey
// AGENT.md §1.4 (explicit, confirmed, never automatic, never a default).
package git
import (
"bytes"
"context"
"os/exec"
"strconv"
"strings"
)
// CLI runs Git commands via the system binary.
type CLI struct {
Bin string // path to git; "git" resolves from PATH
}
// New returns a CLI using the given binary (defaults to "git").
func New(bin string) *CLI {
if bin == "" {
bin = "git"
}
return &CLI{Bin: bin}
}
// run executes `git <args...>` in dir and returns trimmed stdout. On failure it
// returns an error whose message includes stderr.
func (c *CLI) run(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, c.Bin, args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", &Error{Args: args, Stderr: msg, Err: err}
}
return "", &Error{Args: args, Err: err}
}
return strings.TrimSpace(stdout.String()), nil
}
// Error describes a failed git invocation.
type Error struct {
Args []string
Stderr string
Err error
}
func (e *Error) Error() string {
s := "git " + strings.Join(e.Args, " ") + ": " + e.Err.Error()
if e.Stderr != "" {
s += ": " + e.Stderr
}
return s
}
func (e *Error) Unwrap() error { return e.Err }
// Version returns the installed git version string.
func (c *CLI) Version(ctx context.Context) (string, error) {
return c.run(ctx, "", "version")
}
// CurrentBranch returns the checked-out branch, or "HEAD" when detached.
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
}
// IsDirty reports whether the working tree has staged or unstaged changes.
func (c *CLI) IsDirty(ctx context.Context, dir string) (bool, error) {
out, err := c.run(ctx, dir, "status", "--porcelain")
if err != nil {
return false, err
}
return out != "", nil
}
// AheadBehind returns how many commits HEAD is ahead of and behind its upstream.
// Both are 0 with no error when there is no configured upstream.
func (c *CLI) AheadBehind(ctx context.Context, dir string) (ahead, behind int, err error) {
out, err := c.run(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD")
if err != nil {
// No upstream is a normal state, not a failure to report.
return 0, 0, nil
}
fields := strings.Fields(out)
if len(fields) != 2 {
return 0, 0, nil
}
behind, _ = strconv.Atoi(fields[0])
ahead, _ = strconv.Atoi(fields[1])
return ahead, behind, nil
}
// Remotes returns the configured remote names.
func (c *CLI) Remotes(ctx context.Context, dir string) ([]string, error) {
out, err := c.run(ctx, dir, "remote")
if err != nil {
return nil, err
}
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
}
// Fetch updates remote-tracking refs. It does not modify the working tree, but
// it does touch the network, so the scanner only calls it when explicitly
// enabled (AGENT.md §5).
func (c *CLI) Fetch(ctx context.Context, dir string) error {
_, err := c.run(ctx, dir, "fetch", "--quiet", "--all")
return err
}
// --- Mutating operations (user/Claude-initiated only; §1.3, §1.4) -----------
// Pull integrates the upstream branch (fetch + merge/ff per repo config).
func (c *CLI) Pull(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "pull")
}
// Push publishes the current branch to its upstream. Plain push only — never a
// forced push here (that is a §1.4 action to be added deliberately if ever).
func (c *CLI) Push(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "push")
}
// Commit stages every change and commits it with message.
func (c *CLI) Commit(ctx context.Context, dir, message string) (string, error) {
if _, err := c.run(ctx, dir, "add", "-A"); err != nil {
return "", err
}
return c.run(ctx, dir, "commit", "-m", message)
}
// DiscardAll hard-resets tracked files to HEAD, throwing away uncommitted
// changes. DESTRUCTIVE (§1.4): callers MUST confirm with the user first.
// Untracked files are left in place.
func (c *CLI) DiscardAll(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "reset", "--hard", "HEAD")
}
// Branch is a local branch and its upstream, if any.
type Branch struct {
Name string `json:"name"`
Current bool `json:"current"`
Upstream string `json:"upstream,omitempty"`
}
// Commit is a single log entry.
type Commit struct {
Short string `json:"short"`
Author string `json:"author"`
Date string `json:"date"`
Subject string `json:"subject"`
}
// Remote is a named remote and its fetch URL.
type Remote struct {
Name string `json:"name"`
URL string `json:"url"`
}
// LocalBranches lists local branches (refs/heads), flagging the current one and
// including each branch's upstream when set.
func (c *CLI) LocalBranches(ctx context.Context, dir string) ([]Branch, error) {
const format = "%(refname:short)%09%(HEAD)%09%(upstream:short)"
out, err := c.run(ctx, dir, "for-each-ref", "--format="+format, "refs/heads")
if err != nil {
return nil, err
}
var branches []Branch
for _, line := range splitLines(out) {
f := strings.Split(line, "\t")
if len(f) < 1 || f[0] == "" {
continue
}
b := Branch{Name: f[0]}
if len(f) > 1 {
b.Current = f[1] == "*"
}
if len(f) > 2 {
b.Upstream = f[2]
}
branches = append(branches, b)
}
return branches, nil
}
// RecentCommits returns the newest n commits reachable from HEAD.
func (c *CLI) RecentCommits(ctx context.Context, dir string, n int) ([]Commit, error) {
// Fields separated by TAB (%x09); records by newline.
const format = "%h%x09%an%x09%ad%x09%s"
out, err := c.run(ctx, dir, "log", "-n", strconv.Itoa(n), "--date=short", "--pretty=format:"+format)
if err != nil {
return nil, err
}
var commits []Commit
for _, line := range splitLines(out) {
f := strings.SplitN(line, "\t", 4)
if len(f) < 4 {
continue
}
commits = append(commits, Commit{Short: f[0], Author: f[1], Date: f[2], Subject: f[3]})
}
return commits, nil
}
// RemoteDetails returns each remote with its fetch URL.
func (c *CLI) RemoteDetails(ctx context.Context, dir string) ([]Remote, error) {
out, err := c.run(ctx, dir, "remote", "-v")
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
var remotes []Remote
for _, line := range splitLines(out) {
// Format: "<name>\t<url> (fetch|push)"
f := strings.Fields(line)
if len(f) < 3 || f[2] != "(fetch)" {
continue
}
if _, ok := seen[f[0]]; ok {
continue
}
seen[f[0]] = struct{}{}
remotes = append(remotes, Remote{Name: f[0], URL: f[1]})
}
return remotes, nil
}
// splitLines splits on newlines, dropping empty lines.
func splitLines(s string) []string {
if s == "" {
return nil
}
var out []string
for _, line := range strings.Split(s, "\n") {
if line != "" {
out = append(out, line)
}
}
return out
}
+35
View File
@@ -0,0 +1,35 @@
// Package logging builds the application's slog logger. Logs go to stderr
// (readable console in dev, structured JSON otherwise) and, optionally, to a
// file. There is no database sink — see AGENT.md §7.
package logging
import (
"io"
"log/slog"
"os"
)
// Setup returns a configured *slog.Logger and, if a file sink was opened, an
// io.Closer to flush/close it on shutdown (nil when no file sink is used).
func Setup(dev bool, logFile string) (*slog.Logger, io.Closer, error) {
var w io.Writer = os.Stderr
var closer io.Closer
if logFile != "" {
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, nil, err
}
w = io.MultiWriter(os.Stderr, f)
closer = f
}
var handler slog.Handler
if dev {
handler = slog.NewTextHandler(w, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
handler = slog.NewJSONHandler(w, &slog.HandlerOptions{Level: slog.LevelInfo})
}
return slog.New(handler), closer, nil
}
+266
View File
@@ -0,0 +1,266 @@
// 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"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"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"`
}
// setActiveProjectInput is the argument schema for set_active_project.
type setActiveProjectInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository to make active, exactly as returned by list_repos"`
}
// activeProjectOutput reports the active project path (object, per the rule above).
type activeProjectOutput struct {
Path string `json:"path" jsonschema:"absolute path of the active project, empty when none is set"`
}
// activityOutput wraps the activity feed (object, per the rule above).
type activityOutput struct {
Events []activity.Event `json:"events" jsonschema:"recent activity events, oldest first"`
}
// pendingSwitchOutput reports whether the user has asked Claude to switch projects.
type pendingSwitchOutput struct {
Pending bool `json:"pending" jsonschema:"true if the user has requested a switch you should complete"`
Target string `json:"target,omitempty" jsonschema:"the repository path to switch to"`
Note string `json:"note,omitempty" jsonschema:"an optional note from the user"`
RequestedAt time.Time `json:"requestedAt,omitempty"`
}
// ackSwitchInput is the argument schema for ack_switch.
type ackSwitchInput struct {
Summary string `json:"summary" jsonschema:"a short note on where you left the previous project (shown to the user)"`
}
// ackSwitchOutput reports the completed switch.
type ackSwitchOutput struct {
Switched bool `json:"switched"`
Target string `json:"target,omitempty" jsonschema:"the repository now active"`
}
// repoPathInput selects a repository by path (from list_repos).
type repoPathInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, exactly as returned by list_repos"`
}
// prListOutput wraps the pull requests (object, per the schema rule).
type prListOutput struct {
PRs []forge.PullRequest `json:"prs" jsonschema:"open pull requests for the repository"`
}
// mergePRInput selects the PR to merge and clean up.
type mergePRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"`
}
// gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Message string `json:"message" jsonschema:"the commit message"`
}
// gitActionOutput carries a git command's output (object, per the schema rule).
type gitActionOutput struct {
Output string `json:"output" jsonschema:"the git command output (may be empty)"`
}
// 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
})
// get_active_project — the repo/task currently in focus (§8.2).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_active_project",
Description: "Get the active project — the repository the user is currently focused on. Check this to stay in sync with the user; path is empty when none is set.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// set_active_project — Claude switches the focus to another repo.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "set_active_project",
Description: "Set the active project to the given repository path (from list_repos). Use this when switching which repository you are working in so the app and user stay in sync.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in setActiveProjectInput) (*mcpsdk.CallToolResult, activeProjectOutput, error) {
if _, _, err := svc.SetActiveProject(activity.ActorClaude, in.Path); err != nil {
return nil, activeProjectOutput{}, fmt.Errorf("%w — call list_repos for valid paths", err)
}
return nil, activeProjectOutput{Path: svc.ActiveProject()}, nil
})
// get_activity — recent user + Claude actions, so Claude can catch up.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_activity",
Description: "Get the recent activity feed (user and Claude actions, oldest first): repo selections, active-project changes, and more as features land. Use it to see what the user has done since you last looked.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, activityOutput, error) {
return nil, activityOutput{Events: svc.Activity(50)}, nil
})
// get_pending_switch — has the user asked you to switch projects? (§8.3)
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "get_pending_switch",
Description: "Check whether the user has asked you to switch to a different project. If pending is true, finish your current work to a SAFE stopping point (commit or stash so nothing is lost), then call ack_switch to complete the handoff.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, pendingSwitchOutput, error) {
p, ok := svc.PendingSwitch()
if !ok {
return nil, pendingSwitchOutput{Pending: false}, nil
}
return nil, pendingSwitchOutput{Pending: true, Target: p.Target, Note: p.Note, RequestedAt: p.RequestedAt}, nil
})
// ack_switch — complete a pending handoff and tell the user where you left off.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "ack_switch",
Description: "Complete a pending project switch: makes the requested target the active project and clears the request. Call this only after reaching a safe stopping point in the current project. Pass a short summary of where you left it — the user is notified.",
}, func(_ context.Context, _ *mcpsdk.CallToolRequest, in ackSwitchInput) (*mcpsdk.CallToolResult, ackSwitchOutput, error) {
p, ok := svc.AckSwitch(in.Summary)
if !ok {
return nil, ackSwitchOutput{Switched: false}, fmt.Errorf("no pending switch to acknowledge")
}
return nil, ackSwitchOutput{Switched: true, Target: p.Target}, nil
})
// list_prs — open pull requests for a repo (§8.4).
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "list_prs",
Description: "List the open pull requests for a repository (needs a configured forge such as Gitea). The path must be one returned by list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, prListOutput, error) {
prs, err := svc.ForgePRs(ctx, in.Path)
if err != nil {
return nil, prListOutput{}, err
}
return nil, prListOutput{PRs: prs}, nil
})
// merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr",
Description: "Merge a pull request (squash) AND delete its source branch — the \"Merge & clean up\" action. This is irreversible: confirm the exact PR number and repository with the user BEFORE calling. Merged history remains on the host; only the branch is removed.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRInput) (*mcpsdk.CallToolResult, forge.MergeResult, error) {
res, err := svc.MergeAndCleanup(ctx, activity.ActorClaude, in.Path, in.Number)
if err != nil {
return nil, forge.MergeResult{}, err
}
return nil, res, nil
})
// --- Git commands (the same ops as the right-click menu, §6/§1.7) --------
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_fetch",
Description: "Fetch updates from the remote for a repository (does not change the working tree). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitFetch(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_pull",
Description: "Pull the latest changes (fetch + merge) into a repository's current branch. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPull(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_push",
Description: "Push the current branch to its upstream (plain push, never forced). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitPush(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_commit",
Description: "Stage all changes and commit them with a message. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCommitInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCommit(ctx, activity.ActorClaude, in.Path, in.Message)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_discard_changes",
Description: "DESTRUCTIVE: discard ALL uncommitted changes to tracked files (git reset --hard HEAD). This cannot be undone — confirm the exact repository with the user BEFORE calling. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in repoPathInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitDiscard(ctx, activity.ActorClaude, in.Path)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, 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)
}
+242
View File
@@ -0,0 +1,242 @@
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/activity"
"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, activity.New(log, 200), nil, scanner.RefreshRepo)
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")
}
// active project starts empty.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
if err != nil {
t.Fatalf("get_active_project: %v", err)
}
var ap struct {
Path string `json:"path"`
}
decodeResult(t, res, &ap)
if ap.Path != "" {
t.Fatalf("expected empty active project, got %q", ap.Path)
}
// set_active_project to our repo, then read it back.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("set_active_project: %v", err)
}
if res.IsError {
t.Fatalf("set_active_project returned tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"})
decodeResult(t, res, &ap)
if ap.Path != states[0].Path {
t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path)
}
// setting an unknown project is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "set_active_project",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("set_active_project(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown active project")
}
// the activity feed should now contain the active-project-changed event.
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"})
var act struct {
Events []activity.Event `json:"events"`
}
decodeResult(t, res, &act)
found := false
for _, ev := range act.Events {
if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude {
found = true
}
}
if !found {
t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events)
}
// --- graceful handoff (§8.3): user requests, Claude acks -----------------
svc.RequestSwitch(activity.ActorUser, states[0].Path, "fix a bug")
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
var ps struct {
Pending bool `json:"pending"`
Target string `json:"target"`
}
decodeResult(t, res, &ps)
if !ps.Pending || ps.Target != states[0].Path {
t.Fatalf("expected pending switch to %q, got %+v", states[0].Path, ps)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "left tests green"},
})
if err != nil {
t.Fatalf("ack_switch: %v", err)
}
if res.IsError {
t.Fatalf("ack_switch tool error: %+v", res.Content)
}
res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_pending_switch"})
decodeResult(t, res, &ps)
if ps.Pending {
t.Fatalf("expected no pending switch after ack, got %+v", ps)
}
// ack with nothing pending is a tool error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "ack_switch",
Arguments: map[string]any{"summary": "nothing"},
})
if err != nil {
t.Fatalf("ack_switch(empty) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError acking with no pending switch")
}
// --- git_commit via MCP (adapter wiring) --------------------------------
if err := os.WriteFile(filepath.Join(repoPath, "extra.txt"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "git_commit",
Arguments: map[string]any{"path": repoPath, "message": "add extra via mcp"},
})
if err != nil {
t.Fatalf("git_commit: %v", err)
}
if res.IsError {
t.Fatalf("git_commit tool error: %+v", res.Content)
}
}
// 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)
}
}
+31
View File
@@ -0,0 +1,31 @@
// Package render wires Go html/template page shells into Echo. The server emits
// the page shell and declares web components; the components fetch their own
// data (AGENT.md §1.1, §2).
package render
import (
"html/template"
"io"
"path/filepath"
"github.com/labstack/echo/v4"
)
// Templates implements echo.Renderer over the templates in a directory.
type Templates struct {
tmpl *template.Template
}
// New parses every *.html file in dir.
func New(dir string) (*Templates, error) {
t, err := template.ParseGlob(filepath.Join(dir, "*.html"))
if err != nil {
return nil, err
}
return &Templates{tmpl: t}, nil
}
// Render satisfies echo.Renderer.
func (t *Templates) Render(w io.Writer, name string, data any, _ echo.Context) error {
return t.tmpl.ExecuteTemplate(w, name, data)
}
+56
View File
@@ -0,0 +1,56 @@
package repos
import (
"context"
"time"
"gitmanager/internal/git"
)
// Detail is the enriched view of a single repository shown in the detail panel.
// It embeds the cached State and adds data fetched on demand via the git
// boundary (all read-only — AGENT.md §1.3).
type Detail struct {
State
Branches []git.Branch `json:"branches"`
Commits []git.Commit `json:"commits"`
RemoteDetails []git.Remote `json:"remoteDetails"`
}
// Get returns the cached State for a repo path, or false if it is not indexed.
func (i *Index) Get(path string) (State, bool) {
i.mu.RLock()
defer i.mu.RUnlock()
s, ok := i.byKey[path]
return s, ok
}
// BuildDetail enriches a cached State with branches, recent commits, and remote
// URLs. Errors on the enriching calls are non-fatal: whatever succeeds is
// returned, and the partial failure is recorded on Detail.Error.
func BuildDetail(ctx context.Context, g *git.CLI, base State) Detail {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
d := Detail{State: base}
if branches, err := g.LocalBranches(ctx, base.Path); err == nil {
d.Branches = branches
} else {
d.Error = err.Error()
}
if commits, err := g.RecentCommits(ctx, base.Path, 20); err == nil {
d.Commits = commits
} else if d.Error == "" {
d.Error = err.Error()
}
if remotes, err := g.RemoteDetails(ctx, base.Path); err == nil {
d.RemoteDetails = remotes
} else if d.Error == "" {
d.Error = err.Error()
}
return d
}
+221
View File
@@ -0,0 +1,221 @@
// Package repos discovers Git repositories under the configured roots and keeps
// a read-optimized in-memory index of their state. The repositories are the
// system of record; this index is a derived cache that can be rebuilt at any
// time. The scanner is strictly READ-ONLY (AGENT.md §1.3, §5).
package repos
import (
"context"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gitmanager/internal/git"
)
// State is the cached snapshot of one repository.
type State struct {
Path string `json:"path"`
Name string `json:"name"`
Branch string `json:"branch"`
Dirty bool `json:"dirty"`
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Remotes []string `json:"remotes"`
UpdatedAt time.Time `json:"updatedAt"`
Error string `json:"error,omitempty"` // set if refreshing this repo failed
}
// Index is a concurrency-safe map of repo path -> State.
type Index struct {
mu sync.RWMutex
byKey map[string]State
}
func newIndex() *Index { return &Index{byKey: make(map[string]State)} }
func (i *Index) set(s State) {
i.mu.Lock()
defer i.mu.Unlock()
i.byKey[s.Path] = s
}
// List returns a snapshot of all known repos, sorted by name then path.
func (i *Index) List() []State {
i.mu.RLock()
defer i.mu.RUnlock()
out := make([]State, 0, len(i.byKey))
for _, s := range i.byKey {
out = append(out, s)
}
sort.Slice(out, func(a, b int) bool {
if out[a].Name != out[b].Name {
return out[a].Name < out[b].Name
}
return out[a].Path < out[b].Path
})
return out
}
// Scanner discovers repositories and refreshes the index on an interval.
type Scanner struct {
git *git.CLI
log *slog.Logger
roots []string
maxDepth int
ignore map[string]struct{}
interval time.Duration
fetchEnabled bool
Index *Index
}
// NewScanner builds a scanner. ignore is a set of directory names to skip.
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner {
ig := make(map[string]struct{}, len(ignore))
for _, name := range ignore {
ig[name] = struct{}{}
}
return &Scanner{
git: g,
log: log,
roots: roots,
maxDepth: maxDepth,
ignore: ig,
interval: interval,
fetchEnabled: fetchEnabled,
Index: newIndex(),
}
}
// Run does an immediate refresh, then refreshes every interval until ctx is done.
func (s *Scanner) Run(ctx context.Context) {
s.Refresh(ctx)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.Refresh(ctx)
}
}
}
// Refresh discovers repos and updates the index. Each repo is refreshed under
// its own timeout so a single slow/unreachable repo cannot stall the rest.
func (s *Scanner) Refresh(ctx context.Context) {
paths := s.discover()
s.log.Debug("scan discovered repositories", "count", len(paths))
for _, p := range paths {
select {
case <-ctx.Done():
return
default:
}
s.Index.set(s.refreshOne(ctx, p))
}
}
// RefreshRepo re-scans a single repository and updates the index. Used after a
// mutating action so the UI reflects the new state without waiting for the next
// full scan.
func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
s.Index.set(s.refreshOne(ctx, path))
}
func (s *Scanner) refreshOne(ctx context.Context, path string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
if s.fetchEnabled {
if err := s.git.Fetch(rctx, path); err != nil {
s.log.Warn("scan fetch failed", "repo", path, "err", err)
}
}
branch, err := s.git.CurrentBranch(rctx, path)
if err != nil {
st.Error = err.Error()
return st
}
st.Branch = branch
if dirty, err := s.git.IsDirty(rctx, path); err == nil {
st.Dirty = dirty
} else {
st.Error = err.Error()
}
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
if remotes, err := s.git.Remotes(rctx, path); err == nil {
st.Remotes = remotes
}
return st
}
// discover walks each root looking for directories that contain a .git entry,
// recording the parent as a repo and not descending into it. Depth is measured
// relative to each root; ignored directory names are skipped.
func (s *Scanner) discover() []string {
seen := make(map[string]struct{})
var out []string
for _, root := range s.roots {
root = filepath.Clean(root)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // unreadable entry: skip, don't abort the walk
}
if !d.IsDir() {
return nil
}
name := d.Name()
if path != root {
if _, skip := s.ignore[name]; skip {
return filepath.SkipDir
}
}
if depth(root, path) > s.maxDepth {
return filepath.SkipDir
}
if hasGit(path) {
if _, ok := seen[path]; !ok {
seen[path] = struct{}{}
out = append(out, path)
}
return filepath.SkipDir // don't descend into a repo
}
return nil
})
}
return out
}
// hasGit reports whether dir is a git repository (a .git directory, or a .git
// file for worktrees/submodules).
func hasGit(dir string) bool {
_, err := os.Stat(filepath.Join(dir, ".git"))
return err == nil
}
// depth returns how many path segments below root path is (root itself is 0).
func depth(root, path string) int {
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return 0
}
return strings.Count(rel, string(filepath.Separator)) + 1
}
+252
View File
@@ -0,0 +1,252 @@
// 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"
"fmt"
"path/filepath"
"strings"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// Service holds the shared dependencies the capabilities need.
type Service struct {
git *git.CLI
index *repos.Index
feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
}
// New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, (optionally) a forge provider, and a single-repo refresh hook
// (may be nil) used to re-scan a repo after a mutating action.
func New(g *git.CLI, index *repos.Index, feed *activity.Feed, fg *forge.Gitea, refresh func(context.Context, string)) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh}
}
// 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
}
// --- Activity & active project (§8.2) --------------------------------------
// ActiveProject returns the current active project path ("" if none).
func (s *Service) ActiveProject() string {
return s.feed.ActiveProject()
}
// SetActiveProject makes path the active project (or clears it when empty). It
// rejects a path that is not an indexed repository — the active project must be
// a real repo (§1.3). Returns the recorded event and whether it changed.
func (s *Service) SetActiveProject(actor activity.Actor, path string) (activity.Event, bool, error) {
if path != "" {
path = filepath.Clean(path)
if _, ok := s.index.Get(path); !ok {
return activity.Event{}, false, fmt.Errorf("unknown repository %q", path)
}
}
ev, changed := s.feed.SetActiveProject(actor, path)
return ev, changed, nil
}
// RecordActivity appends an arbitrary event to the feed.
func (s *Service) RecordActivity(actor activity.Actor, kind, repo, detail string) activity.Event {
return s.feed.Record(actor, kind, repo, detail)
}
// Activity returns up to limit recent events, oldest first.
func (s *Service) Activity(limit int) []activity.Event {
return s.feed.Events(limit)
}
// SubscribeActivity returns a channel of future events plus an unsubscribe func
// the caller must invoke when done.
func (s *Service) SubscribeActivity() (<-chan activity.Event, func()) {
return s.feed.Subscribe()
}
// --- Graceful project handoff (§8.3) ---------------------------------------
// RequestSwitch records a user's request for Claude to switch to target. The
// target must be an indexed repository.
func (s *Service) RequestSwitch(actor activity.Actor, target, note string) (activity.PendingSwitch, error) {
target = filepath.Clean(target)
if _, ok := s.index.Get(target); !ok {
return activity.PendingSwitch{}, fmt.Errorf("unknown repository %q", target)
}
return s.feed.RequestSwitch(actor, target, note), nil
}
// PendingSwitch returns the outstanding switch request, if any.
func (s *Service) PendingSwitch() (activity.PendingSwitch, bool) {
return s.feed.PendingSwitch()
}
// AckSwitch completes the pending handoff on Claude's behalf: sets the active
// project to the requested target and records Claude's summary. Returns false if
// nothing was pending.
func (s *Service) AckSwitch(summary string) (activity.PendingSwitch, bool) {
return s.feed.AckSwitch(activity.ActorClaude, summary)
}
// CancelSwitch clears a pending switch request.
func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bool) {
return s.feed.CancelSwitch(actor)
}
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up.
func (s *Service) ForgeConfigured() bool { return s.forge != nil }
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured
// when no provider is set, or forge.ErrNotSupported when the repo's remote is not
// on the configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return nil, err
}
return s.forge.ListPullRequests(ctx, owner, repo)
}
// MergeAndCleanup merges a PR and deletes its branch, then records the action.
// The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude).
func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.MergeResult{}, err
}
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil {
return forge.MergeResult{}, err
}
detail := fmt.Sprintf("merged PR #%d", res.Number)
if res.BranchDeleted && res.Branch != "" {
detail += fmt.Sprintf(", deleted branch %s", res.Branch)
}
s.feed.Record(actor, "pr-merged", filepath.Clean(repoPath), detail)
return res, nil
}
// --- Git actions (the plain-language commands, §6) --------------------------
// gitAction runs one mutating git op through the boundary, records the outcome
// on the activity feed, and refreshes the repo in the index on success. Callers
// are responsible for §1.4 confirmation of destructive ops (e.g. discard).
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) {
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", fmt.Errorf("unknown repository %q", repoPath)
}
out, err := run(base.Path)
if err != nil {
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
return "", err
}
s.feed.Record(actor, kind, base.Path, "ok")
if s.refresh != nil {
s.refresh(ctx, base.Path)
}
return out, nil
}
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
// the right-click menu (and, later, MCP) invoke.
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) {
return "", s.git.Fetch(ctx, d)
})
}
func (s *Service) GitPull(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-pull", func(d string) (string, error) {
return s.git.Pull(ctx, d)
})
}
func (s *Service) GitPush(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-push", func(d string) (string, error) {
return s.git.Push(ctx, d)
})
}
func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath, message string) (string, error) {
if strings.TrimSpace(message) == "" {
return "", fmt.Errorf("a commit message is required")
}
return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) {
return s.git.Commit(ctx, d, message)
})
}
// GitDiscard is DESTRUCTIVE (§1.4) — the caller must confirm with the user first.
func (s *Service) GitDiscard(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-discard", func(d string) (string, error) {
return s.git.DiscardAll(ctx, d)
})
}
// resolveForge maps a repo path to (owner, repo) on the configured forge host via
// its git remotes, preferring "origin".
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) {
if s.forge == nil {
return "", "", forge.ErrNotConfigured
}
base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath)
}
remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil {
return "", "", err
}
// Prefer origin, then any matching remote.
var fallback [2]string
haveFallback := false
for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL)
if !ok || !s.forge.Handles(host) {
continue
}
if rm.Name == "origin" {
return o, r, nil
}
if !haveFallback {
fallback = [2]string{o, r}
haveFallback = true
}
}
if haveFallback {
return fallback[0], fallback[1], nil
}
return "", "", forge.ErrNotSupported
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"gitmanager/internal/activity"
"gitmanager/internal/git"
"gitmanager/internal/repos"
)
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway
// temp repo — never a real one.
func TestGitActions(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "r")
mustMkdir(t, repoPath)
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "t@e.com")
runGit(t, repoPath, "config", "user.name", "T")
writeFile(t, filepath.Join(repoPath, "a.txt"), "one\n")
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "init")
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())
feed := activity.New(log, 200)
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo)
ctx := context.Background()
// Commit a new file, then the repo should be clean in the index.
writeFile(t, filepath.Join(repoPath, "b.txt"), "two\n")
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, "add b"); err != nil {
t.Fatalf("GitCommit: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after commit, got dirty")
}
// Commit with a blank message is rejected.
if _, err := svc.GitCommit(ctx, activity.ActorUser, repoPath, " "); err == nil {
t.Fatalf("expected error committing with blank message")
}
// Modify a tracked file, then discard resets it. (We check the file itself
// rather than index dirtiness, since the index only updates on a refresh.)
writeFile(t, filepath.Join(repoPath, "a.txt"), "CHANGED\n")
if _, err := svc.GitDiscard(ctx, activity.ActorUser, repoPath); err != nil {
t.Fatalf("GitDiscard: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Dirty {
t.Fatalf("expected clean repo after discard")
}
// Trim to ignore autocrlf line-ending normalization on Windows.
if got := strings.TrimSpace(readFile(t, filepath.Join(repoPath, "a.txt"))); got != "one" {
t.Fatalf("a.txt = %q, want restored to \"one\"", got)
}
// The feed recorded the successful actions.
kinds := map[string]bool{}
for _, e := range feed.Events(0) {
if e.Detail == "ok" {
kinds[e.Kind] = true
}
}
if !kinds["git-commit"] || !kinds["git-discard"] {
t.Fatalf("expected git-commit and git-discard ok events, got %v", kinds)
}
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.Mkdir(p, 0o755); err != nil {
t.Fatal(err)
}
}
func writeFile(t *testing.T, p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
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)
}
}
+53
View File
@@ -0,0 +1,53 @@
/* Shared design tokens — the ONE sanctioned cross-shadow-boundary styling
channel (AGENT.md §1.1). Custom properties inherit into every shadow root, so
components reference these with var(--…) instead of hardcoding hex. Change a
token here and the whole UI follows. */
:root {
color-scheme: dark;
/* Surfaces & structure */
--surface-0: #14161a; /* app background */
--surface-1: #1b1e24; /* panels */
--surface-2: #242830; /* raised cards */
--border: #333944;
--border-strong: #454c59;
/* Text */
--color-fg: #e6e9ef;
--color-fg-muted: #9aa4b2;
/* Fills / accents */
--fill-accent: #4a9eff;
/* Semantic status (AGENT.md §1.1) */
--color-danger: #ff5c5c;
--color-danger-bg: #3a1e1e;
--color-success: #4ade80;
--color-warning: #f5c451;
/* Git-state colors */
--git-clean: var(--color-success);
--git-dirty: var(--color-warning);
--git-ahead: #7ab8ff;
--git-behind: #d08bff;
--git-conflict: var(--color-danger);
/* Radii */
--radius: 8px;
--radius-sm: 4px;
--radius-lg: 12px;
}
/* The page shell owns only the app frame; components own everything inside their
shadow roots. Keep global selectors OUT of components (AGENT.md §1.1). */
body {
margin: 0;
background: var(--surface-0);
color: var(--color-fg);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
a {
color: var(--fill-accent);
}
+121
View File
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitManager — Help</title>
<link rel="stylesheet" href="/static/app.css">
<style>
header {
display: flex; align-items: baseline; gap: 16px;
padding: 12px 20px; background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; }
header nav { margin-left: auto; }
main { max-width: 760px; padding: 20px; }
h2 { font-size: 15px; margin-top: 28px; }
code { background: var(--surface-2); padding: 1px 5px; border-radius: var(--radius-sm); }
</style>
</head>
<body>
<header>
<h1>GitManager</h1>
<nav><a href="/">← Dashboard</a></nav>
</header>
<main>
<h1>Using GitManager</h1>
<p>
GitManager scans the folders you configure and shows every Git repository
it finds, with each repo's current branch, whether it has uncommitted
changes, and how far ahead or behind its upstream it is.
</p>
<h2>Getting started</h2>
<ol>
<li>Copy <code>.env.example</code> to <code>.env</code>.</li>
<li>Set <code>GIT_REPO_ROOTS</code> to the folders that hold your repos.</li>
<li>Run <code>docker compose up</code> and open the dashboard.</li>
</ol>
<h2>Reading the dashboard</h2>
<ul>
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
<li><strong>Dirty</strong> — the working tree has staged or unstaged changes.</li>
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
</ul>
<h2>Right-click commands</h2>
<p>
Right-click any repository for a menu of plain-language commands — no git
knowledge needed:
</p>
<ul>
<li><strong>Get latest</strong> — pull the newest changes.</li>
<li><strong>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</li>
<li><strong>Save my work…</strong> — commit everything (asks for a message).</li>
<li><strong>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li>
<li><strong>Discard all changes…</strong> — throw away uncommitted edits
(asks you to confirm; can't be undone).</li>
</ul>
<p>
What each command did shows up in the activity panel. (Get latest / Publish /
Check for updates need your server to have git credentials; until then they'll
report a sign-in error.)
</p>
<h2>Repository details</h2>
<p>
Click any repository in the list to open its details on the right: its
full path and current branch, its remotes and their URLs, every local
branch (with the one you're on marked and its upstream shown), and the 20
most recent commits.
</p>
<h2>Active project &amp; activity</h2>
<p>
Clicking a repository also makes it your <strong>active project</strong>
the one you're currently focused on — shown in the activity panel at the
bottom. That panel also lists recent actions by both you and Claude, updating
live. When GitManager is connected to Claude, Claude can see your active
project and this activity, so you stay on the same page.
</p>
<h2>Pull requests: Merge &amp; clean up</h2>
<p>
When a repository is hosted on your Gitea server, its open pull requests
appear under the details panel. Each has a <strong>Merge &amp; clean up</strong>
button: it squash-merges the pull request and <strong>deletes its
branch</strong> in one step, so finished work doesn't leave branches lying
around. You'll be asked to confirm — it names the pull request and the branch
that will be deleted — because it can't be undone (the merged history stays
on the server; only the branch is removed). If you don't use a Gitea server,
this section stays hidden.
</p>
<h2>Handing a project off to Claude</h2>
<p>
When GitManager is connected to Claude, you can hand the current project
off. Select the repository (that makes it your active project), then click
<strong>"Ask Claude to switch to …"</strong> in the handoff bar. Claude
won't drop what it's doing — it finishes to a safe stopping point (saving
any in-progress work) and then switches. You'll see a "waiting…" message
while it wraps up, and a confirmation with a short note of where it left the
previous project once it has switched. You can <strong>Cancel</strong> a
pending request any time before Claude completes it.
</p>
<h2>Background refresh</h2>
<p>
The dashboard refreshes on its own. By default it does <em>not</em> reach
the network — enable <code>SCAN_FETCH_ENABLED</code> if you want
ahead/behind counts kept current via <code>git fetch</code>.
</p>
<!-- Keep this page task-oriented and update it whenever a user-facing
feature changes, in the same change (AGENT.md §9.3). -->
</main>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitManager</title>
<link rel="stylesheet" href="/static/app.css">
<!-- Web components declare themselves; the page does not micro-manage them
(AGENT.md §1.1). Each fetches its own data on connect. -->
<script type="module" src="/components/repo-list/repo-list.js"></script>
<script type="module" src="/components/repo-detail/repo-detail.js"></script>
<script type="module" src="/components/activity-feed/activity-feed.js"></script>
<script type="module" src="/components/handoff-bar/handoff-bar.js"></script>
<script type="module" src="/components/pr-list/pr-list.js"></script>
<script type="module" src="/components/repo-menu/repo-menu.js"></script>
<style>
header {
display: flex;
align-items: baseline;
gap: 16px;
padding: 12px 20px;
background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; }
header nav { margin-left: auto; }
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4).
A real dockable layout is layered on later; this is the static default. */
main { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.cols {
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 16px;
align-items: start;
}
.right { display: grid; gap: 16px; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>GitManager</h1>
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
<nav><a href="/help">Help</a></nav>
</header>
<main>
<handoff-bar></handoff-bar>
<div class="cols">
<repo-list></repo-list>
<div class="right">
<repo-detail></repo-detail>
<pr-list hidden></pr-list>
</div>
</div>
<activity-feed></activity-feed>
</main>
<repo-menu></repo-menu>
</body>
</html>