Compare commits

...

11 Commits

Author SHA1 Message Date
TBNilles 69d38484e8 Slice 13: repo search + filtering (+ no-cache dev assets)
<repo-list> gains a search box (name/path) and Dirty/Ahead-behind filter chips with an N-of-M count; filtering is client-side and the state persists in localStorage (gitmanager.repolist.filters, §4). Refresh/selection re-apply filters. cmd/server sends Cache-Control: no-cache for /components and /static so hot-reloaded asset edits show on reload. Server-verified (curl); live click-through pending an unresponsive browser pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 18:02:30 -04:00
TBNilles 34ed127653 Slice 12: inline command-result toasts
New <toast-host> overlay: components post 'toast' CustomEvents ({message, kind: success|error|info}) and it shows brief, auto-dismissing, bottom-right toasts (errors linger). <repo-menu> (git + coordination actions) and <pr-list> (create/merge) now post friendly success/error toasts instead of alert(); the activity feed still logs everything. Also adds the slice 11 CHANGELOG entry. Verified live: 'Checked for updates' toast shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:50:32 -04:00
TBNilles 68b6c3a3f8 Slice 11: branch-picker submenu in the right-click menu
Replace the Switch branch text prompt with a flyout submenu populated from GET /api/repo (the repo's branches; current one disabled); clicking a branch checks it out. Flips leftward near the viewport edge. New branch still prompts. Verified live: submenu lists branches and switches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:44:55 -04:00
TBNilles ad00654487 Slice 10: create_pr (open a pull request)
forge CreatePullRequest (Gitea; empty base resolves to the repo default branch). Service CreatePR records a pr-created event. HTTP POST /api/repo/pr/create; MCP tool create_pr; <pr-list> New pull request button (head = selected repo current branch, base = default). Live-tested end-to-end: app create_pr opened a real PR (base auto-resolved), listed, then cleaned up; main untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:34:13 -04:00
TBNilles e1999bcf21 Slice 9: git_checkout and create_branch (menu + MCP)
git boundary Checkout/CreateBranch; service GitCheckout/GitCreateBranch (activity detail names the branch; gitAction takes an ok-detail). HTTP /api/repo/git ops checkout + create-branch (branch field). MCP tools git_checkout and create_branch. <repo-menu> gains Switch branch… and New branch… (prompt for name). Service test covers create+switch and existing-branch failure. Checkout is not destructive - git refuses if it would overwrite changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:27:58 -04:00
TBNilles e59d5bbd29 Slice 8: configure git credentials + identity in the container
On startup the app runs git config --global to set a commit identity (GIT_USER_NAME/GIT_USER_EMAIL), safe.directory=* for host-owned mounts, and http.<GITEA_URL>.extraheader with the Gitea token so push/fetch/pull authenticate over HTTPS without an SSH key. New git.CLI.SetGlobalConfig and config fields. Verified: fetch via the app works and the identity/header are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 17:22:34 -04:00
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
31 changed files with 2975 additions and 64 deletions
+15 -4
View File
@@ -55,14 +55,25 @@ SCAN_FETCH_ENABLED=false
# "dev" uses a readable console handler; anything else uses structured JSON. # "dev" uses a readable console handler; anything else uses structured JSON.
APP_ENV=dev APP_ENV=dev
# Commit identity for git actions the app runs (commit/etc.). Without these,
# commits inside the container fail with "empty ident". Set to your name/email.
GIT_USER_NAME=
GIT_USER_EMAIL=
# Note: when GITEA_URL + GITEA_TOKEN are set, the app also configures git to
# authenticate to that host over HTTPS (an http.extraheader), so push/fetch/pull
# work from the container without a separate SSH key or credential helper.
# Optional: also append structured logs to this file. Leave empty to disable. # Optional: also append structured logs to this file. Leave empty to disable.
LOG_FILE= LOG_FILE=
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ---------- # --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
# The primary host is a self-hosted Gitea/Forgejo (git.nilles.net). With no # The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
# token the forge features are simply absent; the rest of the app is unaffected. # token to enable PRs + "Merge & clean up"; with neither, the forge features are
# Writes (merge PR + delete branch, for "Merge & clean up") are each confirmed # simply absent and the rest of the app is unaffected. A repo is forge-enabled
# per AGENT.md §1.4. The token needs repo read + PR write + branch delete scope. # 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= GITEA_TOKEN=
# Later providers, behind the same interface (unused for now): # Later providers, behind the same interface (unused for now):
GITHUB_TOKEN= GITHUB_TOKEN=
+19 -12
View File
@@ -365,8 +365,9 @@ obeys the safety rules (§1.4).
the tool handlers (§1.7). Expected tools (grow as features land): the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`, - Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`. `get_pending_switch`, `list_prs`.
- Act: `git_status/checkout/commit/push/pull/create_branch`, - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
`create_pr`, `merge_and_cleanup_pr`, `set_active_project`, `ack_switch`. `create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
`set_active_project`, `ack_switch`.
- **A tool's result type must be a struct, never a bare slice/map/scalar.** The - **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 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 structured output must be a JSON **object** (`type: "object"`). A handler that
@@ -395,18 +396,20 @@ obeys the safety rules (§1.4).
When the user switches project/task in the app, it is a **request**, not an When the user switches project/task in the app, it is a **request**, not an
instant yank. The cooperative protocol: instant yank. The cooperative protocol:
1. **User** picks a new project/task in the app → the app records a 1. **User** asks (in the app) for Claude to switch to a project →
**pendingswitch request** (target + optional note) and the UI shows `POST /api/switch` records a **pendingswitch request** (target + optional
"waiting for Claude to reach a good stopping point." 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 2. **Claude** sees the pending request (it checks at its natural turnboundary
checkpoints via `get_pending_switch`). It **finishes to a safe stopping checkpoints via `get_pending_switch`). It **finishes to a safe stopping
point and preserves work** — never abandons uncommitted changes to switch; point and preserves work** — never abandons uncommitted changes to switch;
it completes the inflight step and commits/stashes as appropriate — then it completes the inflight step and commits/stashes as appropriate — then
performs the switch (`set_active_project`, moving its working context to the calls **`ack_switch`** with a short summary of where it left the previous
new repo) and calls **`ack_switch`** with a short summary of where it left project. `ack_switch` **atomically** makes the requested target the active
the previous project. project and clears the request.
3. **App** marks the request fulfilled and **notifies the user** over SSE 3. **App** records `switch-completed` and **notifies the user** over SSE
("Claude switched to *ProjectB*; *ProjectA* left at: …"). The user proceeds. ("Claude switched to *ProjectB* — *left at: …*"). The user proceeds.
**Rule:** the switch is Claudecompleted at a checkpoint, not appforced. Losing **Rule:** the switch is Claudecompleted at a checkpoint, not appforced. Losing
or interrupting uncommitted work to satisfy a switch is a §1.4class violation. or interrupting uncommitted work to satisfy a switch is a §1.4class violation.
@@ -545,8 +548,12 @@ silently guess.)*
branch delete) and how it is provisioned; document in `.env.example`. branch delete) and how it is provisioned; document in `.env.example`.
- **Listen address / exposure:** localhostonly by default (covers `/mcp` too). - **Listen address / exposure:** localhostonly by default (covers `/mcp` too).
Confirm before binding to a nonlocal interface — there is no auth (Section 0). 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 - ✅ **RESOLVED 2026-09-20:** **Container git auth = the Gitea token over HTTPS.**
credential helper, for pushing/fetching from inside Docker. On startup the app runs `git config --global` to set a commit identity
(`GIT_USER_NAME`/`GIT_USER_EMAIL`), `safe.directory=*` (host-owned mounts), and
`http.<GITEA_URL>.extraheader: Authorization: token …` so push/fetch/pull work
without SSH keys. The token lands in the container's gitconfig (ephemeral,
localhost). An SSH-key path stays possible later for non-Gitea remotes.
--- ---
+175
View File
@@ -98,3 +98,178 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
endpoint directly — no public exposure, no HTTPS needed for this path. endpoint directly — no public exposure, no HTTPS needed for this path.
- **Affects:** `AGENT.md` (§8.1, §11); user's `claude_desktop_config.json` (outside - **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. 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.
## 2026-09-20 — Slice 8: git credentials + identity in the container (§11)
- **What:** On startup the app configures the container's git (`git config
--global`): a commit identity (`GIT_USER_NAME`/`GIT_USER_EMAIL`),
`safe.directory=*` for host-owned mounts, and — when `GITEA_URL`+`GITEA_TOKEN`
are set — `http.<url>.extraheader: Authorization: token …` so push/fetch/pull
authenticate over HTTPS with no SSH key. New `git.CLI.SetGlobalConfig`; new
config `GIT_USER_NAME`/`GIT_USER_EMAIL`; `.env.example` documents them.
- **Why:** Make the network git commands (menu + MCP) actually work from Docker,
and let commits have an author.
- **Affects:** `internal/config`, `internal/git`, `cmd/server/main.go`,
`.env.example`, `AGENT.md` (§11).
- **Verified:** startup logs "git remote auth configured"; container git identity
set; `http.extraheader` present; `git_fetch` via the app returned ok.
- **Security note:** the token is written to the container's ephemeral gitconfig
and passed in a `git config` argv — acceptable for a localhost dev container.
## 2026-09-20 — Slice 9: git_checkout + create_branch
- **What:** git boundary `Checkout` (switch existing branch) and `CreateBranch`
(git checkout -b). Service `GitCheckout`/`GitCreateBranch` (feed detail names
the branch; `gitAction` now takes an ok-detail). HTTP `/api/repo/git` gained
ops `checkout` and `create-branch` (+`branch` field). MCP tools `git_checkout`
and `create_branch`. `<repo-menu>` gained "Switch branch…" and "New branch…"
(prompt for the name). Service test covers create+switch+existing-branch-fails.
- **Why:** Round out the everyday git commands in both front doors (§1.7).
- **Affects:** `internal/git`, `internal/service` (+test), `internal/mcp`,
`cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`,
`AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
overwrite uncommitted changes.
## 2026-09-20 — Slice 10: create_pr
- **What:** forge `CreatePullRequest` (Gitea; empty base → repo default branch,
via GetRepo). Service `CreatePR` (records `pr-created`). HTTP
`POST /api/repo/pr/create`. MCP tool `create_pr`. `<pr-list>` gained a
"New pull request…" button (head = selected repo's current branch, base =
default). AGENT.md §8.1 lists `create_pr` in Act.
- **Why:** Open PRs from the app or Claude — the front half of the PR workflow
whose back half is "Merge & clean up".
- **Affects:** `internal/forge`, `internal/service`, `internal/mcp`,
`cmd/server/main.go`, `components/pr-list`, `web/templates/help.html`,
`AGENT.md`.
- **Note:** the head branch must already exist on the remote (push first).
## 2026-09-20 — Slice 11: branch-picker submenu
- **What:** `<repo-menu>` "Switch branch" is now a flyout submenu populated from
`GET /api/repo` (the repo's branches; current one disabled), flipping leftward
near the viewport edge; clicking a branch checks it out. "New branch…" still
prompts. No backend change.
- **Affects:** `components/repo-menu`, `web/templates/help.html`.
## 2026-09-20 — Slice 12: inline command-result toasts
- **What:** New `<toast-host>` overlay — components post `toast` CustomEvents
(`{message, kind}`; success/error/info) and it shows brief, auto-dismissing,
bottom-right toasts. `<repo-menu>` (git ops + coordination actions) and
`<pr-list>` (create/merge) now post success/error toasts with friendly labels
instead of `alert()`. Activity feed still logs everything.
- **Why:** Immediate, legible feedback for the non-expert audience (§6 polish).
- **Affects:** `components/toast-host` (new), `components/repo-menu`,
`components/pr-list`, `web/templates/index.html`.
## 2026-09-20 — Slice 13: repo search + filtering
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
client-side over the fetched list; state persists per-viewer in
`localStorage["gitmanager.repolist.filters"]` (§4). Refresh/selection re-apply
the active filters.
- **Why:** Keep the dashboard usable as the number of repos grows.
- **Affects:** `components/repo-list`, `web/templates/help.html`.
- **Dev-server tweak:** `cmd/server` now sends `Cache-Control: no-cache` for
`/components` and `/static` so browsers revalidate assets on reload (cached ES
modules were defeating hot reload). Server serves the new component (curl-
verified); live click-through pending (browser pane was unresponsive).
+229 -2
View File
@@ -5,16 +5,21 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"gitmanager/internal/activity"
"gitmanager/internal/config" "gitmanager/internal/config"
"gitmanager/internal/forge"
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/logging" "gitmanager/internal/logging"
mcpserver "gitmanager/internal/mcp" mcpserver "gitmanager/internal/mcp"
@@ -23,6 +28,30 @@ import (
"gitmanager/internal/service" "gitmanager/internal/service"
) )
// configureGit prepares the container's git for operating on the mounted repos:
// a commit identity (so commits don't fail with "empty ident"), permission to
// work on host-owned mounts, and — when a Gitea token is set — an auth header so
// pushes/fetches over HTTPS succeed. The token is written to the container's
// gitconfig (ephemeral, localhost); see AGENT.md §11.
func configureGit(ctx context.Context, g *git.CLI, cfg config.Config, log *slog.Logger) {
set := func(key, value string) {
if err := g.SetGlobalConfig(ctx, key, value); err != nil {
log.Warn("git config failed", "key", key, "err", err)
}
}
set("safe.directory", "*") // mounted repos are host-owned
if cfg.GitUserName != "" {
set("user.name", cfg.GitUserName)
}
if cfg.GitUserEmail != "" {
set("user.email", cfg.GitUserEmail)
}
if cfg.GiteaURL != "" && cfg.GiteaToken != "" {
set("http."+cfg.GiteaURL+".extraheader", "Authorization: token "+cfg.GiteaToken)
log.Info("git remote auth configured", "host", cfg.GiteaURL)
}
}
func main() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -43,6 +72,7 @@ func main() {
} else { } else {
log.Info("git detected", "version", v) log.Info("git detected", "version", v)
} }
configureGit(context.Background(), g, cfg, log)
// Start the read-only scanner in the background. // Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled) scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
@@ -51,8 +81,22 @@ func main() {
go scanner.Run(scanCtx) go scanner.Run(scanCtx)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled) 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). // The one service layer both the HTTP API and the MCP server call (§1.7).
svc := service.New(g, scanner.Index) // 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") tmpl, err := render.New("web/templates")
if err != nil { if err != nil {
@@ -66,6 +110,18 @@ func main() {
e.Use(middleware.Recover()) e.Use(middleware.Recover())
e.Use(middleware.RequestID()) e.Use(middleware.RequestID())
// Ask browsers to revalidate component/static assets so edits show up on
// reload (the dev server hot-reloads; cached ES modules would defeat that).
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.Request().URL.Path
if strings.HasPrefix(p, "/components/") || strings.HasPrefix(p, "/static/") {
c.Response().Header().Set("Cache-Control", "no-cache")
}
return next(c)
}
})
// Static assets and component sources. // Static assets and component sources.
e.Static("/static", "web/static") e.Static("/static", "web/static")
e.Static("/components", "components") e.Static("/components", "components")
@@ -95,7 +151,178 @@ func main() {
return c.JSON(http.StatusOK, detail) return c.JSON(http.StatusOK, detail)
}) })
// MCP server — Claude connects here as a custom connector (§8.1). Same // 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"`
Branch string `json:"branch"`
}
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)
case "checkout":
out, err = svc.GitCheckout(ctx, activity.ActorUser, body.Path, body.Branch)
case "create-branch":
out, err = svc.GitCreateBranch(ctx, activity.ActorUser, body.Path, body.Branch)
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/create", func(c echo.Context) error {
var body struct {
Path string `json:"path"`
Head string `json:"head"`
Base string `json:"base"`
Title string `json:"title"`
Body string `json:"body"`
}
if err := c.Bind(&body); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
pr, err := svc.CreatePR(c.Request().Context(), activity.ActorUser, body.Path, body.Head, body.Base, body.Title, body.Body)
if err != nil {
return c.JSON(http.StatusBadGateway, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, pr)
})
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. // service layer as the HTTP API (§1.7); localhost-bound like everything else.
mcpSrv := mcpserver.NewServer(svc, "0.1.0") mcpSrv := mcpserver.NewServer(svc, "0.1.0")
e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv))) e.Any("/mcp", echo.WrapHandler(mcpserver.Handler(mcpSrv)))
+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.
+174
View File
@@ -0,0 +1,174 @@
// <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 = '';
#repo = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#onSelect = (e) => { this.#repo = e.detail; 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.#toast(`Merged & cleaned up PR #${pr.number}`, 'success');
this.#load(this.#path); // refresh the list
} catch (err) {
this.#toast(`Merge failed: ${err.message}`, 'error');
}
}
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
async #create() {
const branch = this.#repo?.branch;
if (!branch) return;
const title = window.prompt(`Open a pull request from "${branch}" (into the default branch).\nTitle:`, branch);
if (title === null) return;
try {
const res = await fetch('/api/repo/pr/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.#path, head: branch, base: '', title: title.trim() || branch }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#toast(`Opened PR #${data.number}`, 'success');
this.#load(this.#path); // refresh so the new PR appears
} catch (err) {
this.#toast(`Create PR failed: ${err.message}`, 'error');
}
}
#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; }
.hd { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
color: var(--color-fg-muted); margin: 0; }
#new { margin-left: auto; font: inherit; cursor: pointer; padding: 3px 10px;
border-radius: var(--radius-sm); border: 1px solid var(--fill-accent);
color: var(--fill-accent); background: transparent; }
#new:hover { background: var(--surface-2); }
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">
<div class="hd">
<h3>Pull requests</h3>
<button id="new" title="Open a PR from the current branch">New pull request…</button>
</div>
<div id="body"><p class="muted">Select a repository.</p></div>
</div>`;
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('pr-list', PRList);
+30
View File
@@ -0,0 +1,30 @@
# 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()`;
`POST /api/repo/pr/create {path, head, base, title}` via the "New pull request…"
button (head = the selected repo's current branch, base = the repo default).
## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up".
- 2026-09-20: added "New pull request…" (create_pr) — opens a PR from the
selected repo's current branch into the default branch (slice 10).
## 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.
+137 -32
View File
@@ -2,8 +2,12 @@
// //
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a // 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, // 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 // and cleans up on disconnect. It talks to the rest of the app only via
// bubbling/composed `repo:select` CustomEvent — no shared globals. // bubbling/composed CustomEvents (`repo:select`, `repo:contextmenu`) — no shared
// globals. Search + filter state is per-viewer view state kept in localStorage
// (§4); filtering is client-side over the already-fetched list.
const FILTER_KEY = 'gitmanager.repolist.filters';
class RepoList extends HTMLElement { class RepoList extends HTMLElement {
#refreshMs = 15000; #refreshMs = 15000;
@@ -11,6 +15,7 @@ class RepoList extends HTMLElement {
#controller = null; #controller = null;
#repos = []; #repos = [];
#selected = null; #selected = null;
#filters = { q: '', dirty: false, aheadBehind: false };
constructor() { constructor() {
super(); super();
@@ -18,6 +23,7 @@ class RepoList extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
this.#loadFilters();
this.#renderShell(); this.#renderShell();
this.#load(); this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs); this.#timer = setInterval(() => this.#load(), this.#refreshMs);
@@ -34,7 +40,8 @@ class RepoList extends HTMLElement {
try { try {
const res = await fetch('/api/repos', { signal: this.#controller.signal }); const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json()); this.#repos = (await res.json()) || [];
this.#apply();
} catch (err) { } catch (err) {
if (err.name !== 'AbortError') this.#renderError(err); if (err.name !== 'AbortError') this.#renderError(err);
} }
@@ -42,17 +49,111 @@ class RepoList extends HTMLElement {
#select(repo) { #select(repo) {
this.#selected = repo.path; this.#selected = repo.path;
this.#renderRepos(this.#repos); // reflect selection highlight this.#apply(); // reflect selection highlight
// Cross-component communication is via events only (AGENT.md §1.1). // Cross-component communication is via events only (AGENT.md §1.1).
this.dispatchEvent(new CustomEvent('repo:select', { this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true, 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(() => {});
}
#loadFilters() {
try {
const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
} catch { /* ignore — use defaults */ }
}
#saveFilters() {
try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
}
// #apply computes the filtered set and renders the body + count.
#apply() {
const body = this.shadowRoot.getElementById('body');
const count = this.shadowRoot.getElementById('count');
const { q, dirty, aheadBehind } = this.#filters;
const ql = q.trim().toLowerCase();
const filtered = this.#repos.filter((r) => {
if (ql && !(String(r.name).toLowerCase().includes(ql) || String(r.path).toLowerCase().includes(ql))) return false;
if (dirty && !r.dirty) return false;
if (aheadBehind && !((r.ahead || 0) > 0 || (r.behind || 0) > 0)) return false;
return true;
});
count.textContent = this.#repos.length ? `${filtered.length} of ${this.#repos.length}` : '';
if (this.#repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return;
}
if (filtered.length === 0) {
body.innerHTML = `<p class="empty">No repositories match your search.</p>`;
return;
}
this.#renderList(filtered, body);
}
#renderList(repos, body) {
const ul = document.createElement('ul');
for (const r of 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);
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
} }
#renderShell() { #renderShell() {
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { display: block; } :host { display: block; }
.controls { display: grid; gap: 8px; margin-bottom: 10px; }
#search {
width: 100%; box-sizing: border-box; font: inherit;
background: var(--surface-1); color: var(--color-fg);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 10px;
}
#search:focus { outline: none; border-color: var(--fill-accent); }
.chips { display: flex; align-items: center; gap: 6px; }
.chip {
font: inherit; font-size: 12px; cursor: pointer; padding: 3px 10px;
border-radius: 999px; border: 1px solid var(--border-strong);
background: transparent; color: var(--color-fg-muted);
}
.chip.active { border-color: var(--fill-accent); color: var(--fill-accent);
background: var(--surface-2); }
.count { margin-left: auto; color: var(--color-fg-muted); font-size: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li { li {
background: var(--surface-1); background: var(--surface-1);
@@ -78,38 +179,42 @@ class RepoList extends HTMLElement {
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; } .empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); } .error { color: var(--color-danger); }
</style> </style>
<div class="controls">
<input id="search" type="search" placeholder="Search repositories…" autocomplete="off" />
<div class="chips">
<button id="f-dirty" class="chip" type="button">Dirty</button>
<button id="f-ab" class="chip" type="button">Ahead/behind</button>
<span id="count" class="count"></span>
</div>
</div>
<div id="body"><p class="empty">Loading repositories…</p></div> <div id="body"><p class="empty">Loading repositories…</p></div>
`; `;
}
#renderError(err) { const search = this.shadowRoot.getElementById('search');
this.shadowRoot.getElementById('body').innerHTML = const dirtyBtn = this.shadowRoot.getElementById('f-dirty');
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`; const abBtn = this.shadowRoot.getElementById('f-ab');
} // Reflect persisted state.
search.value = this.#filters.q;
dirtyBtn.classList.toggle('active', this.#filters.dirty);
abBtn.classList.toggle('active', this.#filters.aheadBehind);
#renderRepos(repos) { search.addEventListener('input', () => {
this.#repos = repos || []; this.#filters.q = search.value;
const body = this.shadowRoot.getElementById('body'); this.#saveFilters();
if (this.#repos.length === 0) { this.#apply();
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`; });
return; dirtyBtn.addEventListener('click', () => {
} this.#filters.dirty = !this.#filters.dirty;
const ul = document.createElement('ul'); dirtyBtn.classList.toggle('active', this.#filters.dirty);
for (const r of this.#repos) { this.#saveFilters();
const li = document.createElement('li'); this.#apply();
if (r.path === this.#selected) li.classList.add('selected'); });
li.innerHTML = ` abBtn.addEventListener('click', () => {
<span class="name">${this.#esc(r.name)}</span> this.#filters.aheadBehind = !this.#filters.aheadBehind;
<span class="branch">${this.#esc(r.branch || '—')}</span> abBtn.classList.toggle('active', this.#filters.aheadBehind);
<span class="spacer"></span> this.#saveFilters();
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''} this.#apply();
${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) { #esc(s) {
+12 -1
View File
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
## Public surface ## Public surface
- **Tag:** `<repo-list>` - **Tag:** `<repo-list>`
- **Attributes/properties:** none yet. - **Attributes/properties:** none.
- **Search + filter:** a search box (matches name/path, case-insensitive) plus
"Dirty" and "Ahead/behind" toggle chips, with a "N of M" count. Filtering is
client-side over the fetched list; the state persists per-viewer in
`localStorage["gitmanager.repolist.filters"]` (§4).
- **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is - **Fetches:** `GET /api/repos` on connect and every 15s (in-flight request is
aborted on refresh and on disconnect). aborted on refresh and on disconnect).
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail` - **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
@@ -21,6 +25,13 @@ component pattern the rest of the UI follows.
- 2026-09-19: added a selected-item highlight — the clicked repo keeps an - 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 accent border/background (the item that `<repo-detail>` is showing). Caches the
last `/api/repos` payload so re-selecting re-renders without a refetch. 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.
- 2026-09-20: added search + "Dirty"/"Ahead-behind" filter chips with a count,
persisted in localStorage; filtering is client-side (slice 13).
## Notes / gotchas ## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+262
View File
@@ -0,0 +1,262 @@
// <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).
// "Switch branch" is a flyout submenu populated from the repo's branches. 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' },
{ sub: 'branches', label: 'Switch branch', hint: 'checkout' },
{ cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
{ 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;
#branchController = 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.#branchController?.abort();
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;
// Clamp to viewport; flip submenus leftward when near the right edge.
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';
menu.classList.toggle('flip', left + rect.width + 200 > window.innerWidth);
this.#loadBranches(repo);
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);
}, 0);
}
#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 #loadBranches(repo) {
const sub = this.shadowRoot.getElementById('branches');
sub.innerHTML = `<div class="note">Loading…</div>`;
this.#branchController?.abort();
this.#branchController = new AbortController();
try {
const res = await fetch(`/api/repo?path=${encodeURIComponent(repo.path)}`, { signal: this.#branchController.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const detail = await res.json();
const branches = detail.branches || [];
if (branches.length === 0) { sub.innerHTML = `<div class="note">No branches.</div>`; return; }
sub.replaceChildren(...branches.map((b) => {
const btn = document.createElement('button');
btn.className = 'branch' + (b.current ? ' cur' : '');
btn.textContent = (b.current ? '● ' : '') + b.name;
if (b.current) { btn.disabled = true; btn.title = 'Current branch'; }
else { btn.dataset.branch = b.name; }
return btn;
}));
} catch (err) {
if (err.name !== 'AbortError') sub.innerHTML = `<div class="note err">Couldn't load branches.</div>`;
}
}
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 'newbranch': {
const b = window.prompt(`New branch name in ${name}:`);
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() });
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 });
this.#toast(`${name} is now the active project`, 'info');
break;
case 'handoff':
await this.#post('/api/switch', { target: repo.path });
this.#toast(`Asked Claude to switch to ${name}`, 'info');
break;
case 'copy':
try { await navigator.clipboard.writeText(repo.path); this.#toast('Path copied', 'info'); }
catch { this.#toast('Could not copy path', 'error'); }
break;
}
}
async #checkoutBranch(branch) {
this.#hide();
await this.#git('checkout', { branch });
}
#okLabel(op, extra) {
switch (op) {
case 'pull': return 'Got the latest';
case 'push': return 'Published';
case 'fetch': return 'Checked for updates';
case 'commit': return 'Saved your work';
case 'checkout': return `Switched to ${extra.branch}`;
case 'create-branch': return `Created branch ${extra.branch}`;
case 'discard': return 'Discarded changes';
default: return 'Done';
}
}
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}`);
this.#toast(this.#okLabel(op, extra), 'success');
} catch (err) {
this.#toast(`${this.#okLabel(op, extra)} failed: ${err.message}`, 'error');
}
}
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
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) => {
if (it.sep) return '<hr>';
if (it.sub) {
return `<div class="item has-sub" tabindex="0">
<span>${it.label}</span><span class="arrow">▸</span>
<div class="submenu" id="${it.sub}"></div>
</div>`;
}
return `<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, .item { display: flex; align-items: center; gap: 10px; width: 100%;
box-sizing: border-box; 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, .item:hover, .item:focus { 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; }
hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.has-sub { position: relative; }
.has-sub .arrow { margin-left: auto; color: var(--color-fg-muted); }
.has-sub:hover .arrow, .has-sub:focus .arrow, .has-sub:focus-within .arrow { color: #071019; }
.submenu {
position: absolute; left: 100%; top: -5px; display: none;
min-width: 180px; max-height: 260px; overflow-y: auto;
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);
}
#menu.flip .submenu { left: auto; right: 100%; }
.has-sub:hover .submenu, .has-sub:focus-within .submenu { display: block; }
.submenu .branch { color: var(--color-fg); }
.submenu .branch.cur { color: var(--color-fg-muted); cursor: default; }
.submenu .branch:disabled { background: none; color: var(--color-fg-muted); }
.submenu .note { padding: 6px 10px; color: var(--color-fg-muted); font-size: 12px; }
.submenu .note.err { color: var(--color-danger); }
</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) return;
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
});
}
#base(p) { return String(p).split(/[/\\]/).filter(Boolean).pop() || p; }
}
customElements.define('repo-menu', RepoMenu);
+39
View File
@@ -0,0 +1,39 @@
# 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… / New branch… /
Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
(op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
prompts for a message; "New branch…" prompts for a name; "Discard all
changes…" confirms (destructive).
- **Switch branch ▸** — a flyout submenu populated from `GET /api/repo?path=`
(the repo's branches; current one disabled). Clicking a branch → checkout.
- 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).
- 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
both prompting for the branch name (slice 9).
- 2026-09-20: "Switch branch" is now a flyout submenu listing the repo's branches
(fetched from /api/repo), not a text prompt (slice 11). "New branch…" still prompts.
## 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.
+67
View File
@@ -0,0 +1,67 @@
// <toast-host> — a singleton overlay that shows brief command-result toasts.
//
// A self-contained control (AGENT.md §1.1): shadow DOM, no data of its own. Any
// component posts a toast by dispatching a `toast` CustomEvent on document:
// document.dispatchEvent(new CustomEvent('toast',
// { detail: { message: 'Published', kind: 'success' } }));
// kind ∈ success | error | info. Toasts stack bottom-right, auto-dismiss (errors
// linger longer), and dismiss on click.
class ToastHost extends HTMLElement {
#onToast = null;
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#render();
this.#onToast = (e) => this.#show(e.detail || {});
document.addEventListener('toast', this.#onToast);
}
disconnectedCallback() {
document.removeEventListener('toast', this.#onToast);
}
#show({ message, kind = 'info', timeout }) {
if (!message) return;
const t = document.createElement('div');
t.className = 'toast ' + (['success', 'error', 'info'].includes(kind) ? kind : 'info');
t.textContent = String(message);
t.addEventListener('click', () => t.remove());
this.shadowRoot.getElementById('stack').appendChild(t);
requestAnimationFrame(() => t.classList.add('in'));
const ms = timeout || (kind === 'error' ? 6000 : 3500);
setTimeout(() => {
t.classList.remove('in');
setTimeout(() => t.remove(), 200);
}, ms);
}
#render() {
this.shadowRoot.innerHTML = `
<style>
#stack {
position: fixed; right: 16px; bottom: 16px; z-index: 1100;
display: flex; flex-direction: column-reverse; gap: 8px;
max-width: min(360px, 90vw);
}
.toast {
background: var(--surface-2); color: var(--color-fg);
border: 1px solid var(--border-strong); border-left-width: 3px;
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
box-shadow: 0 8px 28px rgba(0,0,0,.45); cursor: pointer;
opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s;
}
.toast.in { opacity: 1; transform: none; }
.toast.success { border-left-color: var(--color-success); }
.toast.error { border-left-color: var(--color-danger); }
.toast.info { border-left-color: var(--fill-accent); }
</style>
<div id="stack"></div>`;
}
}
customElements.define('toast-host', ToastHost);
+22
View File
@@ -0,0 +1,22 @@
# toast-host
## Intent
A singleton overlay for brief command-result feedback (AGENT.md §6 polish). It
gives immediate, legible confirmation of what a UI action did — "Published",
"Merged & cleaned up PR #3", or an error — instead of only the activity feed or a
browser `alert()`. Any component can post to it without a reference to it.
## Public surface
- **Tag:** `<toast-host>` (place once, near the end of the page).
- **Listens:** `toast` on `document``detail: { message, kind?, timeout? }`,
where `kind``success | error | info` (default `info`).
- **Behavior:** toasts stack bottom-right, animate in, auto-dismiss (errors last
longer — 6s vs 3.5s), and dismiss on click. Renders nothing until posted to.
## History
- 2026-09-20: created — slice 12; inline toasts for menu/PR command results.
## Notes / gotchas
- Posters build the `CustomEvent` themselves (components are standalone, no shared
module); keep the detail shape in sync with this contract.
- Messages are set via `textContent` (no HTML injection).
+5
View File
@@ -3,13 +3,18 @@ module gitmanager
go 1.26 go 1.26
require ( require (
code.gitea.io/sdk/gitea v0.25.1
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.15.4 github.com/labstack/echo/v4 v4.15.4
github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/modelcontextprotocol/go-sdk v1.8.0
) )
require ( 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/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/labstack/gommon v0.5.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
+24
View File
@@ -1,11 +1,21 @@
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
@@ -32,20 +42,34 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 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 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= 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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= 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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+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:
}
}
}
+11 -2
View File
@@ -34,8 +34,13 @@ type Config struct {
Dev bool // readable console logging vs structured JSON Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to LogFile string // optional file to also append logs to
GitHubToken string // optional forge token (AGENT.md §8) GitUserName string // commit identity for git actions run by the app
GitLabToken string // optional forge token (AGENT.md §8) GitUserEmail string // commit identity for git actions run by the app
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. // Load reads .env (if present) then the environment, applying defaults.
@@ -55,6 +60,10 @@ func Load() (Config, error) {
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false), ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"), Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
LogFile: env("LOG_FILE", ""), LogFile: env("LOG_FILE", ""),
GitUserName: env("GIT_USER_NAME", ""),
GitUserEmail: env("GIT_USER_EMAIL", ""),
GiteaURL: env("GITEA_URL", ""),
GiteaToken: env("GITEA_TOKEN", ""),
GitHubToken: env("GITHUB_TOKEN", ""), GitHubToken: env("GITHUB_TOKEN", ""),
GitLabToken: env("GITLAB_TOKEN", ""), GitLabToken: env("GITLAB_TOKEN", ""),
} }
+109
View File
@@ -0,0 +1,109 @@
// 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"`
}
// NewPR describes a pull request to open. An empty Base means "the repo's
// default branch".
type NewPR struct {
Head string
Base string
Title string
Body string
}
// 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)
CreatePullRequest(ctx context.Context, owner, repo string, pr NewPR) (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)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
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
}
// CreatePullRequest opens a PR. An empty Base resolves to the repo's default
// branch. Requires the head branch to already exist on the remote.
func (g *Gitea) CreatePullRequest(_ context.Context, owner, repo string, pr NewPR) (PullRequest, error) {
if strings.TrimSpace(pr.Head) == "" {
return PullRequest{}, fmt.Errorf("a head branch is required")
}
base := strings.TrimSpace(pr.Base)
if base == "" {
r, _, err := g.client.GetRepo(owner, repo)
if err != nil {
return PullRequest{}, err
}
base = r.DefaultBranch
}
title := pr.Title
if strings.TrimSpace(title) == "" {
title = pr.Head
}
created, _, err := g.client.CreatePullRequest(owner, repo, gitea.CreatePullRequestOption{
Head: pr.Head,
Base: base,
Title: title,
Body: pr.Body,
})
if err != nil {
return PullRequest{}, err
}
return toPR(created), 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
}
}
+47
View File
@@ -69,6 +69,13 @@ func (c *CLI) Version(ctx context.Context) (string, error) {
return c.run(ctx, "", "version") return c.run(ctx, "", "version")
} }
// SetGlobalConfig sets a global git config value (git config --global key value).
// Used at startup to give the container git a commit identity and remote auth.
func (c *CLI) SetGlobalConfig(ctx context.Context, key, value string) error {
_, err := c.run(ctx, "", "config", "--global", key, value)
return err
}
// CurrentBranch returns the checked-out branch, or "HEAD" when detached. // CurrentBranch returns the checked-out branch, or "HEAD" when detached.
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) { func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
@@ -120,6 +127,46 @@ func (c *CLI) Fetch(ctx context.Context, dir string) error {
return err 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")
}
// Checkout switches to an existing branch. Git refuses if uncommitted changes
// would be overwritten, so this is not destructive — the error is surfaced.
func (c *CLI) Checkout(ctx context.Context, dir, branch string) (string, error) {
return c.run(ctx, dir, "checkout", branch)
}
// CreateBranch creates a new branch from the current HEAD and switches to it
// (git checkout -b). Fails if the branch already exists.
func (c *CLI) CreateBranch(ctx context.Context, dir, name string) (string, error) {
return c.run(ctx, dir, "checkout", "-b", name)
}
// Branch is a local branch and its upstream, if any. // Branch is a local branch and its upstream, if any.
type Branch struct { type Branch struct {
Name string `json:"name"` Name string `json:"name"`
+251
View File
@@ -9,9 +9,12 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/service" "gitmanager/internal/service"
) )
@@ -28,6 +31,88 @@ type listReposOutput struct {
Repos []repos.State `json:"repos" jsonschema:"the discovered repositories"` 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"`
}
// createPRInput describes a pull request to open.
type createPRInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Head string `json:"head" jsonschema:"the branch to merge from (must already exist on the remote)"`
Base string `json:"base" jsonschema:"the branch to merge into; leave empty for the repo's default branch"`
Title string `json:"title" jsonschema:"the pull request title"`
Body string `json:"body" jsonschema:"the pull request description (optional)"`
}
// 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)"`
}
// gitCheckoutInput selects a branch to switch to.
type gitCheckoutInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Branch string `json:"branch" jsonschema:"the existing branch to switch to"`
}
// createBranchInput names a new branch to create.
type createBranchInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
Name string `json:"name" jsonschema:"the new branch name to create and switch to"`
}
// NewServer builds the MCP server and registers the (currently read-only) tools. // NewServer builds the MCP server and registers the (currently read-only) tools.
func NewServer(svc *service.Service, version string) *mcpsdk.Server { func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{ s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -57,6 +142,172 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, detail, nil 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
})
// create_pr — open a pull request from a branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "create_pr",
Description: "Open a pull request from head into base (leave base empty for the repo's default branch). The head branch must already exist on the remote — push it first. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRInput) (*mcpsdk.CallToolResult, forge.PullRequest, error) {
pr, err := svc.CreatePR(ctx, activity.ActorClaude, in.Path, in.Head, in.Base, in.Title, in.Body)
if err != nil {
return nil, forge.PullRequest{}, err
}
return nil, pr, 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
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "git_checkout",
Description: "Switch a repository to an existing branch. Git refuses if uncommitted changes would be overwritten (the error is returned). Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitCheckoutInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCheckout(ctx, activity.ActorClaude, in.Path, in.Branch)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "create_branch",
Description: "Create a new branch from the current HEAD and switch to it (git checkout -b). Fails if the branch already exists. Path is from list_repos.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createBranchInput) (*mcpsdk.CallToolResult, gitActionOutput, error) {
out, err := svc.GitCreateBranch(ctx, activity.ActorClaude, in.Path, in.Name)
if err != nil {
return nil, gitActionOutput{}, err
}
return nil, gitActionOutput{Output: out}, nil
})
return s return s
} }
+117 -1
View File
@@ -13,6 +13,7 @@ import (
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/activity"
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/service" "gitmanager/internal/service"
@@ -41,7 +42,7 @@ func TestMCPRoundTrip(t *testing.T) {
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background()) scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index) svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo)
srv := NewServer(svc, "test") srv := NewServer(svc, "test")
// Wire an in-memory client<->server session. // Wire an in-memory client<->server session.
@@ -100,6 +101,121 @@ func TestMCPRoundTrip(t *testing.T) {
if !res.IsError { if !res.IsError {
t.Fatalf("expected IsError for unknown repo, got success") 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. // decodeResult unmarshals the JSON text content of a tool result into v.
+7
View File
@@ -122,6 +122,13 @@ func (s *Scanner) Refresh(ctx context.Context) {
} }
} }
// 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 { func (s *Scanner) refreshOne(ctx context.Context, path string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second) rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
+249 -5
View File
@@ -7,21 +7,30 @@ package service
import ( import (
"context" "context"
"fmt"
"path/filepath" "path/filepath"
"strings"
"gitmanager/internal/activity"
"gitmanager/internal/forge"
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/repos" "gitmanager/internal/repos"
) )
// Service holds the shared dependencies the capabilities need. // Service holds the shared dependencies the capabilities need.
type Service struct { type Service struct {
git *git.CLI git *git.CLI
index *repos.Index 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 and the scanner's repo index. // New builds a Service over the git boundary, the scanner's repo index, the
func New(g *git.CLI, index *repos.Index) *Service { // activity feed, (optionally) a forge provider, and a single-repo refresh hook
return &Service{git: g, index: index} // (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. // ListRepos returns a snapshot of every discovered repository.
@@ -45,3 +54,238 @@ func (s *Service) RepoDetail(ctx context.Context, path string) (repos.Detail, bo
} }
return repos.BuildDetail(ctx, s.git, base), true 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)
}
// CreatePR opens a pull request from head into base (empty base = the repo's
// default branch) and records the action. The head branch must already exist on
// the remote (push it first).
func (s *Service) CreatePR(ctx context.Context, actor activity.Actor, repoPath, head, base, title, body string) (forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.PullRequest{}, err
}
pr, err := s.forge.CreatePullRequest(ctx, owner, repo, forge.NewPR{Head: head, Base: base, Title: title, Body: body})
if err != nil {
return forge.PullRequest{}, err
}
s.feed.Record(actor, "pr-created", filepath.Clean(repoPath), fmt.Sprintf("PR #%d %s → %s", pr.Number, pr.Head, pr.Base))
return pr, nil
}
// 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, okDetail 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
}
if okDetail == "" {
okDetail = "ok"
}
s.feed.Record(actor, kind, base.Path, okDetail)
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", "ok", 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", "ok", 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", "ok", 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", "ok", 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", "ok", func(d string) (string, error) {
return s.git.DiscardAll(ctx, d)
})
}
// GitCheckout switches to an existing branch.
func (s *Service) GitCheckout(ctx context.Context, actor activity.Actor, repoPath, branch string) (string, error) {
if strings.TrimSpace(branch) == "" {
return "", fmt.Errorf("a branch name is required")
}
return s.gitAction(ctx, actor, repoPath, "git-checkout", "switched to "+branch, func(d string) (string, error) {
return s.git.Checkout(ctx, d, branch)
})
}
// GitCreateBranch creates a new branch from HEAD and switches to it.
func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, repoPath, name string) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("a branch name is required")
}
return s.gitAction(ctx, actor, repoPath, "git-create-branch", "created "+name, func(d string) (string, error) {
return s.git.CreateBranch(ctx, d, name)
})
}
// 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
}
+128
View File
@@ -0,0 +1,128 @@
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)
}
// Create a branch (switches to it), then switch back to main.
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err != nil {
t.Fatalf("GitCreateBranch: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Branch != "feature-x" {
t.Fatalf("branch = %q, want feature-x", st.Branch)
}
if _, err := svc.GitCheckout(ctx, activity.ActorUser, repoPath, "main"); err != nil {
t.Fatalf("GitCheckout: %v", err)
}
if st, _ := svc.GetRepo(repoPath); st.Branch != "main" {
t.Fatalf("branch = %q, want main", st.Branch)
}
// Creating an existing branch fails.
if _, err := svc.GitCreateBranch(ctx, activity.ActorUser, repoPath, "feature-x"); err == nil {
t.Fatalf("expected error creating an existing branch")
}
// 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)
}
}
+66
View File
@@ -38,6 +38,14 @@
<li>Run <code>docker compose up</code> and open the dashboard.</li> <li>Run <code>docker compose up</code> and open the dashboard.</li>
</ol> </ol>
<h2>Finding a repository</h2>
<p>
Use the search box above the list to filter by name or path, and the
<strong>Dirty</strong> and <strong>Ahead/behind</strong> chips to show only
repos with uncommitted changes or commits to sync. The count shows how many
match. Your search and filters are remembered on this device.
</p>
<h2>Reading the dashboard</h2> <h2>Reading the dashboard</h2>
<ul> <ul>
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li> <li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
@@ -45,6 +53,29 @@
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li> <li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
</ul> </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>Switch branch ▸</strong> — hover to pick from the repo's branches.</li>
<li><strong>New branch…</strong> — create a branch and switch to it.</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> <h2>Repository details</h2>
<p> <p>
Click any repository in the list to open its details on the right: its Click any repository in the list to open its details on the right: its
@@ -53,6 +84,41 @@
most recent commits. most recent commits.
</p> </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. Use <strong>New pull request…</strong> to
open one from the selected repo's current branch (push the branch first).
Each open PR 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> <h2>Background refresh</h2>
<p> <p>
The dashboard refreshes on its own. By default it does <em>not</em> reach The dashboard refreshes on its own. By default it does <em>not</em> reach
+20 -5
View File
@@ -9,6 +9,11 @@
(AGENT.md §1.1). Each fetches its own data on connect. --> (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-list/repo-list.js"></script>
<script type="module" src="/components/repo-detail/repo-detail.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>
<script type="module" src="/components/toast-host/toast-host.js"></script>
<style> <style>
header { header {
display: flex; display: flex;
@@ -22,14 +27,15 @@
header nav { margin-left: auto; } header nav { margin-left: auto; }
/* Repo list docks LEFT, detail panel docks RIGHT by default (AGENT.md §4). /* 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. */ A real dockable layout is layered on later; this is the static default. */
main { main { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.cols {
display: grid; display: grid;
grid-template-columns: minmax(280px, 360px) 1fr; grid-template-columns: minmax(280px, 360px) 1fr;
gap: 16px; gap: 16px;
padding: 20px;
align-items: start; align-items: start;
} }
@media (max-width: 720px) { main { grid-template-columns: 1fr; } } .right { display: grid; gap: 16px; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -39,8 +45,17 @@
<nav><a href="/help">Help</a></nav> <nav><a href="/help">Help</a></nav>
</header> </header>
<main> <main>
<repo-list></repo-list> <handoff-bar></handoff-bar>
<repo-detail></repo-detail> <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> </main>
<repo-menu></repo-menu>
<toast-host></toast-host>
</body> </body>
</html> </html>