Compare commits

..

6 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
21 changed files with 733 additions and 82 deletions
+8
View File
@@ -55,6 +55,14 @@ SCAN_FETCH_ENABLED=false
# "dev" uses a readable console handler; anything else uses structured JSON.
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.
LOG_FILE=
+9 -5
View File
@@ -365,9 +365,9 @@ obeys the safety rules (§1.4).
the tool handlers (§1.7). Expected tools (grow as features land):
- Read: `list_repos`, `get_repo`, `get_active_project`, `get_activity`,
`get_pending_switch`, `list_prs`.
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`,
`git_discard_changes`, `merge_and_cleanup_pr`, `set_active_project`,
`ack_switch`. (More — `git_checkout`, `create_branch`, `create_pr` — as they land.)
- Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
`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
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
@@ -548,8 +548,12 @@ silently guess.)*
branch delete) and how it is provisioned; document in `.env.example`.
- **Listen address / exposure:** localhostonly by default (covers `/mcp` too).
Confirm before binding to a nonlocal interface — there is no auth (Section 0).
- **Credential path from the container:** SSH agent socket vs mounted keys vs
credential helper, for pushing/fetching from inside Docker.
- ✅ **RESOLVED 2026-09-20:** **Container git auth = the Gitea token over HTTPS.**
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.
---
+72
View File
@@ -201,3 +201,75 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
- **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).
+61
View File
@@ -6,9 +6,11 @@ package main
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -26,6 +28,30 @@ import (
"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() {
cfg, err := config.Load()
if err != nil {
@@ -46,6 +72,7 @@ func main() {
} else {
log.Info("git detected", "version", v)
}
configureGit(context.Background(), g, cfg, log)
// Start the read-only scanner in the background.
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled)
@@ -83,6 +110,18 @@ func main() {
e.Use(middleware.Recover())
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.
e.Static("/static", "web/static")
e.Static("/components", "components")
@@ -169,6 +208,7 @@ func main() {
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"})
@@ -187,6 +227,10 @@ func main() {
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})
}
@@ -209,6 +253,23 @@ func main() {
}
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"`
+39 -4
View File
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
#controller = null;
#onSelect = null;
#path = '';
#repo = null;
constructor() {
super();
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
connectedCallback() {
this.#renderShell();
this.#onSelect = (e) => this.#load(e.detail?.path);
this.#onSelect = (e) => { this.#repo = e.detail; this.#load(e.detail?.path); };
document.addEventListener('repo:select', this.#onSelect);
}
@@ -58,9 +59,34 @@ class PRList extends HTMLElement {
});
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.#error(`Merge failed: ${err.message}`);
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');
}
}
@@ -102,8 +128,13 @@ class PRList extends HTMLElement {
: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 0 8px; }
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; }
@@ -124,9 +155,13 @@ class PRList extends HTMLElement {
.error { color: var(--color-danger); }
</style>
<div class="box">
<h3>Pull requests</h3>
<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) {
+5 -1
View File
@@ -13,10 +13,14 @@ naming the PR, base, and branch to be deleted.
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()`.
- **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
+130 -41
View File
@@ -2,8 +2,12 @@
//
// A self-contained control in the ActiveX spirit (AGENT.md §1.1): it lives in a
// shadow root, fetches its own data from /api/repos on connect, renders itself,
// and cleans up on disconnect. It talks to the rest of the app only via a
// bubbling/composed `repo:select` CustomEvent — no shared globals.
// and cleans up on disconnect. It talks to the rest of the app only via
// 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 {
#refreshMs = 15000;
@@ -11,6 +15,7 @@ class RepoList extends HTMLElement {
#controller = null;
#repos = [];
#selected = null;
#filters = { q: '', dirty: false, aheadBehind: false };
constructor() {
super();
@@ -18,6 +23,7 @@ class RepoList extends HTMLElement {
}
connectedCallback() {
this.#loadFilters();
this.#renderShell();
this.#load();
this.#timer = setInterval(() => this.#load(), this.#refreshMs);
@@ -34,7 +40,8 @@ class RepoList extends HTMLElement {
try {
const res = await fetch('/api/repos', { signal: this.#controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.#renderRepos(await res.json());
this.#repos = (await res.json()) || [];
this.#apply();
} catch (err) {
if (err.name !== 'AbortError') this.#renderError(err);
}
@@ -42,7 +49,7 @@ class RepoList extends HTMLElement {
#select(repo) {
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).
this.dispatchEvent(new CustomEvent('repo:select', {
detail: repo, bubbles: true, composed: true,
@@ -56,53 +63,46 @@ class RepoList extends HTMLElement {
}).catch(() => {});
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
#loadFilters() {
try {
const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
} catch { /* ignore — use defaults */ }
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
#saveFilters() {
try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
}
#renderRepos(repos) {
this.#repos = repos || [];
// #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 this.#repos) {
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,
@@ -128,6 +128,95 @@ class RepoList extends HTMLElement {
body.replaceChildren(ul);
}
#renderError(err) {
this.shadowRoot.getElementById('body').innerHTML =
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
: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; }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div 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>
`;
const search = this.shadowRoot.getElementById('search');
const dirtyBtn = this.shadowRoot.getElementById('f-dirty');
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);
search.addEventListener('input', () => {
this.#filters.q = search.value;
this.#saveFilters();
this.#apply();
});
dirtyBtn.addEventListener('click', () => {
this.#filters.dirty = !this.#filters.dirty;
dirtyBtn.classList.toggle('active', this.#filters.dirty);
this.#saveFilters();
this.#apply();
});
abBtn.addEventListener('click', () => {
this.#filters.aheadBehind = !this.#filters.aheadBehind;
abBtn.classList.toggle('active', this.#filters.aheadBehind);
this.#saveFilters();
this.#apply();
});
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
+7 -1
View File
@@ -9,7 +9,11 @@ component pattern the rest of the UI follows.
## Public surface
- **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
aborted on refresh and on disconnect).
- **Emits:** `repo:select` — a `CustomEvent` (bubbles + composed) whose `detail`
@@ -26,6 +30,8 @@ component pattern the rest of the UI follows.
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
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+105 -19
View File
@@ -4,7 +4,8 @@
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of
// PLAIN-LANGUAGE commands for people who don't memorize git. Safe commands run
// on click; the destructive one ("Discard all changes") confirms first (§1.4).
// It calls the same endpoints Claude uses via MCP (one service layer, §1.7);
// "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 = [
@@ -12,6 +13,8 @@ const ITEMS = [
{ 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' },
@@ -25,6 +28,7 @@ class RepoMenu extends HTMLElement {
#onContext = null;
#onDocClick = null;
#onKey = null;
#branchController = null;
constructor() {
super();
@@ -39,6 +43,7 @@ class RepoMenu extends HTMLElement {
disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext);
this.#branchController?.abort();
this.#teardownDismiss();
}
@@ -48,25 +53,25 @@ class RepoMenu extends HTMLElement {
const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false;
// Position, clamped to the viewport.
// 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);
// Dismiss on next outside click, Esc, or scroll.
this.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0);
}
#hideBound() { return () => this.#hide(); }
#hide() {
this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss();
@@ -78,6 +83,30 @@ class RepoMenu extends HTMLElement {
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);
@@ -91,6 +120,11 @@ class RepoMenu extends HTMLElement {
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` +
@@ -101,16 +135,37 @@ class RepoMenu extends HTMLElement {
}
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); } catch { /* ignore */ }
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', {
@@ -120,11 +175,16 @@ class RepoMenu extends HTMLElement {
});
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) {
window.alert(`${op} failed: ${err.message}`);
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) });
@@ -132,11 +192,19 @@ class RepoMenu extends HTMLElement {
}
#renderShell() {
const rows = ITEMS.map((it) => it.sep
? '<hr>'
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}">
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''}
</button>`).join('');
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 {
@@ -148,25 +216,43 @@ class RepoMenu extends HTMLElement {
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit;
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
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 { background: var(--fill-accent); color: #071019; }
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; }
button.danger:hover code { 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) this.#dispatch(btn.dataset.cmd);
if (!btn) return;
if (btn.dataset.branch !== undefined) { this.#checkoutBranch(btn.dataset.branch); return; }
if (btn.dataset.cmd) this.#dispatch(btn.dataset.cmd);
});
}
+10 -3
View File
@@ -11,10 +11,13 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
- **Listens:** `repo:contextmenu` on `document``detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Discard all
changes… → `POST /api/repo/git {path, op, message?}` (op: pull/push/fetch/
commit/discard). "Save my work…" prompts for a message; "Discard all
- 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.
@@ -23,6 +26,10 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
## 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
+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
@@ -34,6 +34,9 @@ type Config struct {
Dev bool // readable console logging vs structured JSON
LogFile string // optional file to also append logs to
GitUserName string // commit identity for git actions run by the app
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)
@@ -57,6 +60,8 @@ func Load() (Config, error) {
ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false),
Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"),
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", ""),
+10
View File
@@ -48,11 +48,21 @@ type MergeResult struct {
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)
}
+30
View File
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
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).
+19
View File
@@ -69,6 +69,13 @@ func (c *CLI) Version(ctx context.Context) (string, error) {
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.
func (c *CLI) CurrentBranch(ctx context.Context, dir string) (string, error) {
return c.run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
@@ -148,6 +155,18 @@ 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.
type Branch struct {
Name string `json:"name"`
+55
View File
@@ -81,6 +81,15 @@ type mergePRInput struct {
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"`
@@ -92,6 +101,18 @@ 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.
func NewServer(svc *service.Service, version string) *mcpsdk.Server {
s := mcpsdk.NewServer(&mcpsdk.Implementation{
@@ -184,6 +205,18 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
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",
@@ -253,6 +286,28 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
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
}
+46 -7
View File
@@ -137,6 +137,22 @@ func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRe
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).
@@ -162,7 +178,7 @@ func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, rep
// gitAction runs one mutating git op through the boundary, records the outcome
// on the activity feed, and refreshes the repo in the index on success. Callers
// are responsible for §1.4 confirmation of destructive ops (e.g. discard).
func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath, kind string, run func(dir string) (string, error)) (string, error) {
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)
@@ -172,7 +188,10 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
s.feed.Record(actor, kind, base.Path, "failed: "+err.Error())
return "", err
}
s.feed.Record(actor, kind, base.Path, "ok")
if okDetail == "" {
okDetail = "ok"
}
s.feed.Record(actor, kind, base.Path, okDetail)
if s.refresh != nil {
s.refresh(ctx, base.Path)
}
@@ -182,19 +201,19 @@ func (s *Service) gitAction(ctx context.Context, actor activity.Actor, repoPath,
// GitFetch, GitPull, GitPush, GitCommit, GitDiscard are the mutating commands
// the right-click menu (and, later, MCP) invoke.
func (s *Service) GitFetch(ctx context.Context, actor activity.Actor, repoPath string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-fetch", func(d string) (string, error) {
return s.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", func(d 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", func(d string) (string, error) {
return s.gitAction(ctx, actor, repoPath, "git-push", "ok", func(d string) (string, error) {
return s.git.Push(ctx, d)
})
}
@@ -203,18 +222,38 @@ func (s *Service) GitCommit(ctx context.Context, actor activity.Actor, repoPath,
if strings.TrimSpace(message) == "" {
return "", fmt.Errorf("a commit message is required")
}
return s.gitAction(ctx, actor, repoPath, "git-commit", func(d string) (string, error) {
return s.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", func(d 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) {
+18
View File
@@ -65,6 +65,24 @@ func TestGitActions(t *testing.T) {
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) {
+13 -1
View File
@@ -38,6 +38,14 @@
<li>Run <code>docker compose up</code> and open the dashboard.</li>
</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>
<ul>
<li><strong>Branch</strong> — the checked-out branch (or <code>HEAD</code> when detached).</li>
@@ -55,6 +63,8 @@
<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
@@ -86,7 +96,9 @@
<h2>Pull requests: Merge &amp; clean up</h2>
<p>
When a repository is hosted on your Gitea server, its open pull requests
appear under the details panel. Each has a <strong>Merge &amp; clean up</strong>
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
+2
View File
@@ -13,6 +13,7 @@
<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>
header {
display: flex;
@@ -55,5 +56,6 @@
<activity-feed></activity-feed>
</main>
<repo-menu></repo-menu>
<toast-host></toast-host>
</body>
</html>