Compare commits

...

6 Commits

Author SHA1 Message Date
TBNilles 32ae17cc9f Config: .env = projects root only; derive each project's forge from git
Reduce .env to just REPOS_HOST_PATH (the projects root); runtime bootstrap moves to compose/defaults. Projects are the subdirectories of the single root — removed the project-directories feature (store table, service methods, /api/config/project-dirs, Settings section). Each project's forge is derived from its git remote (matched to a configured forge, else the bare host) and shown as a pill next to its name (State.Forge via scanner ForgeFor + svc.ForgeDisplay). Removed first-run .env seeding; forges + identity are managed in Settings. Added forge.HostOf. AGENT.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:52:03 -04:00
TBNilles e30c3b632a Move domain config from .env to a private SQLite store
Forges (multi-host) + tokens, project directories, and git identity now live in a private SQLite config store (internal/store, modernc.org/sqlite) on a /data named volume that is not bind-mounted or exposed, so credentials aren't reachable outside the container. New Settings page (/settings) + <settings-panel> with /api/config CRUD. Scanner reads roots fresh from the store each cycle; service resolves forges per-repo from the store and reapplies per-forge git auth on change. First run seeds the store from .env. Overturns the old no-datastore/.env-config laws (AGENT.md updated). Verified live end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-22 05:31:28 -04:00
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
29 changed files with 1578 additions and 326 deletions
+11 -76
View File
@@ -1,80 +1,15 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GitManager configuration. Copy this file to `.env` and fill in values. # GitManager configuration. Copy this file to `.env`.
# NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5. # NEVER commit a real `.env` (it is git-ignored).
#
# The ONLY thing configured in .env is the root directory that holds your
# projects. Each project is a subdirectory of that root, and its forge is
# detected from its git remote. Everything else — forge hosts + access tokens
# and the git commit identity — is managed in the app's Settings (/settings) and
# stored in a private database (AGENT.md §1.5). Runtime bootstrap (listen address,
# TLS, DB path, the container projects root) is set by docker-compose.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Address the HTTP server binds to. Localhost-only by default: there is NO # Host folder that holds your projects. docker-compose mounts it to /repos inside
# authentication (AGENT.md §0). Only bind to a non-local interface deliberately. # the container (scanned as PROJECTS_ROOT). Example: C:/Users/you/Projects
LISTEN_ADDR=127.0.0.1:8080
# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only
# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS
# listener starts alongside HTTP. docker-compose sets these to the mounted certs.
# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA
# your OS — and Claude Desktop — will trust), from the project root:
# winget install FiloSottile.mkcert
# mkcert -install # trust step (adds the local CA)
# mkdir certs
# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1
# Then connect Claude Desktop to https://localhost:8443/mcp
HTTPS_ADDR=
TLS_CERT_FILE=
TLS_KEY_FILE=
# Roots to scan for Git repositories, comma-separated (absolute paths).
# Inside Docker these must be the *container* paths that the host roots are
# mounted to (see docker-compose.yml). Example: /repos,/work/other
GIT_REPO_ROOTS=/repos
# DOCKER ONLY: the HOST folder that holds your repositories. docker-compose
# mounts it to /repos inside the container (which GIT_REPO_ROOTS points at).
# Ignored when running the binary directly. Example: C:/Users/you/Projects
REPOS_HOST_PATH=./repos REPOS_HOST_PATH=./repos
# Path to the git binary. "git" resolves it from PATH (git is installed in the
# container image).
GIT_BIN=git
# --- Repo scanner (read-only; AGENT.md §5) ---------------------------------
# How often the background scanner refreshes repo state.
SCAN_INTERVAL=30s
# Max directory depth to descend under each root when discovering repos.
SCAN_MAX_DEPTH=4
# Directory names to skip during discovery, comma-separated.
SCAN_IGNORE=node_modules,vendor,.cache
# Allow the scanner to run `git fetch` (network) to keep ahead/behind counts
# current. OFF by default — no unsolicited network. (AGENT.md §5)
SCAN_FETCH_ENABLED=false
# --- Logging (AGENT.md §7) --------------------------------------------------
# "dev" uses a readable console handler; anything else uses structured JSON.
APP_ENV=dev
# 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=
# --- Forge integration — token-gated, READ + WRITE (AGENT.md §8.4) ----------
# The primary host is a self-hosted Gitea/Forgejo. Set BOTH the base URL and a
# token to enable PRs + "Merge & clean up"; with neither, the forge features are
# simply absent and the rest of the app is unaffected. A repo is forge-enabled
# when its origin remote host matches GITEA_URL's host. Writes (merge PR + delete
# branch) are confirmed per AGENT.md §1.4. Token scope: repo read + PR write +
# branch delete.
GITEA_URL=
GITEA_TOKEN=
# Later providers, behind the same interface (unused for now):
GITHUB_TOKEN=
GITLAB_TOKEN=
+69 -28
View File
@@ -121,10 +121,12 @@ values** rather than reintroducing hardcoded hex.
- **The background scanner is read‑only.** It may run `status`, `rev-list`, - **The background scanner is read‑only.** It may run `status`, `rev-list`,
`for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never `for-each-ref`, `log`, and (only when explicitly enabled) `fetch`. It must never
run a command that changes local state. run a command that changes local state.
- **Never invent local persistence for domain state.** If something must survive a - **Git stays the system of record for repository data** — never mirror repo
restart and it isn't already in Git, it is either app config (`.env`, Section contents into another store. The one sanctioned datastore is the **config store**
1.4) or per‑user UI state (browser `localStorage`, Section 4). Adding any other (§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens,
datastore (SQLite, a server‑side DB) requires asking first — the default is no. git identity), not git data. Anything beyond
that (mirroring repo/PR data, a server‑side app DB) still requires asking first.
Per‑user UI state remains in browser `localStorage` (§4).
### 1.4 Destructive Git operations are explicit, confirmed, and never automatic ### 1.4 Destructive Git operations are explicit, confirmed, and never automatic
@@ -142,12 +144,26 @@ must be treated as such:
destructive default. Prefer the safe variant (`--force-with-lease` over destructive default. Prefer the safe variant (`--force-with-lease` over
`--force`) and surface it as such. `--force`) and surface it as such.
### 1.5 Configuration via `.env` ### 1.5 Configuration: bootstrap `.env` + a private config store
Repo scan roots, the `git` binary path, scan/fetch behavior, the listen address, Configuration is split three ways:
and any optional forge tokens are all configurable through a single `.env` file. A
committed `.env.example` documents every variable. **Never commit a real `.env`** - **`.env` — the projects root, and nothing else.** The only value a user sets in
and never hardcode paths, tokens, hosts, or the set of watched repositories. `.env` is `REPOS_HOST_PATH`: the host directory that holds their projects, mounted
into the container as the single **projects root** (`PROJECTS_ROOT`, default
`/repos`). **Each project is a subdirectory of that root.** `.env` is git‑ignored;
a committed `.env.example` documents it.
- **Runtime bootstrap — compose/env defaults, not the `.env` file.** Listen
address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan tuning, the config
DB path, and `PROJECTS_ROOT` come from `docker-compose.yml`'s `environment:` and
in‑code defaults — not from the user's `.env`.
- **Config store (`internal/store`, SQLite)** — **forge hosts + access tokens** and
the **git commit identity**, managed at runtime in the app's **Settings**
(`/settings`), not by editing files. A project's **forge is derived from its git
remote** (matched to a configured forge) — not stored per project (§8.4). The DB
must not be reachable outside the container: it lives on a **private named Docker
volume** (`/data`) — never bind‑mounted, never on a published port; tokens rely on
that isolation. Never hardcode tokens/hosts/paths in code.
### 1.6 Everything runs in Docker / docker‑compose ### 1.6 Everything runs in Docker / docker‑compose
@@ -187,14 +203,15 @@ MCP handler.
| MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. | | MCP server | **`github.com/modelcontextprotocol/go-sdk`**, served over **Streamable HTTP** at `/mcp` | Claude Desktop connects as a custom connector. Tools are thin adapters over the shared service layer (§1.7). See Section 8. |
| Real‑time (app → browser) | **Server‑Sent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. | | Real‑time (app → browser) | **Server‑Sent Events** (`net/http`, stdlib) | Push activity + handoff notifications and live repo updates to the components; replaces list polling over time. |
| Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. | | Diff rendering | Server produces unified diff from `git`; client renders it in a component | No heavy client diff lib without asking. |
| Config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5. | | Bootstrap config | **`.env`** via `github.com/joho/godotenv` + a typed config struct | Section 1.5 (bootstrap only). |
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. No database sink (there is no database). | | Config store | **SQLite via `modernc.org/sqlite`** (pure Go, no CGO) in `internal/store`, on a private `/data` volume | Forges + tokens, project dirs, git identity. Not the git data store (§1.3). |
| Logging | **`log/slog`** → stdout/stderr (structured), optional rotating file sink | See Section 7. |
| Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. | | Hot reload (dev) | **air** (`github.com/air-verse/air`) | Inside the app container. |
Anything not in this table that you want to add as a dependency: **propose it and Anything not in this table that you want to add as a dependency: **propose it and
wait for approval.** Keep the dependency surface small. Note in particular there is wait for approval.** Keep the dependency surface small. There is **no auth library**
**no database and no auth library** — do not add one without asking (Section 1.3, (§0) and **no datastore beyond the config store** (§1.3, §1.5) — do not add one
Section 0). without asking.
--- ---
@@ -211,7 +228,8 @@ Section 0).
├── cmd/ ├── cmd/
│ └── server/main.go # entrypoint: wire config, git, scanner, router │ └── server/main.go # entrypoint: wire config, git, scanner, router
├── internal/ ├── internal/
│ ├── config/ # .env loading, typed config struct │ ├── config/ # bootstrap .env loading, typed config struct (§1.5)
│ ├── store/ # SQLite config store: forges+tokens, git identity (§1.5)
│ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops) │ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops)
│ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker │ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker
│ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7) │ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7)
@@ -231,8 +249,10 @@ Section 0).
``` ```
> If a new concern doesn't fit cleanly, **ask** before inventing a new top‑level > If a new concern doesn't fit cleanly, **ask** before inventing a new top‑level
> directory. Keep `components/` strictly for web components. There is deliberately > directory. Keep `components/` strictly for web components. The only datastore is
> **no `migrations/` and no `db/`** — see Section 1.3. > the SQLite **config store** (`internal/store`) on the private `/data` volume
> (§1.5) — schema is created in‑code (`CREATE TABLE IF NOT EXISTS`); there is no
> `migrations/` framework yet (add one if the schema grows non‑trivially).
--- ---
@@ -366,8 +386,8 @@ obeys the safety rules (§1.4).
- 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_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`, - Act: `git_fetch`, `git_pull`, `git_push`, `git_commit`, `git_checkout`,
`create_branch`, `git_discard_changes`, `merge_and_cleanup_pr`, `create_branch`, `git_discard_changes`, `create_pr`, `merge_and_cleanup_pr`,
`set_active_project`, `ack_switch`. (More — `create_pr` — as they land.) `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
@@ -432,9 +452,16 @@ later behind the same interface).
per §1.4**, names the PR/branch, and prefers the tidy default (squash‑merge + per §1.4**, names the PR/branch, and prefers the tidy default (squash‑merge +
delete branch). Note a merged PR remains in the host's history; "clean up" delete branch). Note a merged PR remains in the host's history; "clean up"
means removing the **branch**, not falsifying history. means removing the **branch**, not falsifying history.
- **Enablement is per‑host and token‑gated.** Tokens come from `.env` - **Multiple hosts, configured in the store.** Forges (base URL + token) live in
(e.g. `GITEA_TOKEN`); with no token the forge features are simply absent and the **config store** (§1.5) and are managed in Settings — not `.env`. A repo is
the rest of the app works unchanged (**graceful degradation**). forge‑enabled when a remote's host matches a configured forge; with none
matching the forge features are simply absent (**graceful degradation**). The
service resolves a repo → provider by matching remotes (preferring `origin`)
against the stored forges, caching a client per host.
- **A project's forge is derived from git, not stored per project.** The scanner
reads each repo's `origin` remote and labels it with the matching configured
forge's name (or the bare host when unmatched); the dashboard shows that label
next to the project name (`repos.State.Forge`, via `svc.ForgeDisplay`).
- The provider is inferred from a repo's remote URL. Never send repo data to a - The provider is inferred from a repo's remote URL. Never send repo data to a
host the user didn't configure. host the user didn't configure.
@@ -502,9 +529,10 @@ component carries its own context.
4. If it touches repositories or forges: put the logic in the **service layer** 4. If it touches repositories or forges: put the logic in the **service layer**
(§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP (§1.7) over the `internal/git` / `internal/forge` boundaries — never in an HTTP
or MCP handler — so both the GUI and Claude get it. Respect **Git = system of or MCP handler — so both the GUI and Claude get it. Respect **Git = system of
record** (§1.3), treat **destructive operations** per §1.4, and never add a record** (§1.3), treat **destructive operations** per §1.4, and don't mirror
datastore. A new capability generally means: service method → HTTP handler → repo/PR data into a datastore (app *config* goes in the store, §1.5). A new
MCP tool → UI control. capability generally means: service method → HTTP handler → MCP tool → UI
control.
5. If it needs a **new dependency** or a **new top‑level folder**, propose it and 5. If it needs a **new dependency** or a **new top‑level folder**, propose it and
wait for approval. wait for approval.
6. Implement, run it in the **docker‑compose dev environment**, verify hot reload 6. Implement, run it in the **docker‑compose dev environment**, verify hot reload
@@ -518,9 +546,22 @@ component carries its own context.
*(Claude Code: surface these to the human at the first relevant moment; don't *(Claude Code: surface these to the human at the first relevant moment; don't
silently guess.)* silently guess.)*
- **Repo discovery strategy:** recursive scan of `GIT_REPO_ROOTS` (max depth? - ✅ **RESOLVED 2026-09-22:** **`.env` holds only the projects root; forges +
ignore globs?) vs an explicit list of repo paths. Default assumption: recursive identity live in a private SQLite store** (`internal/store`, §1.5), managed in
scan with a configurable depth. Settings. **Projects are the subdirectories** of the single root
(`REPOS_HOST_PATH` → `/repos`); each project's **forge is derived from its git
remote** and shown next to its name. DB on a private `/data` volume (not
bind‑mounted, no port). Forge is **multi‑host**; git auth sets an
`http.extraheader` per forge.
- **Mount constraint:** the container only sees host paths bind‑mounted at `up`
time. All projects must live under the mounted root (`REPOS_HOST_PATH`); a
project elsewhere needs its own compose mount + a widened/extra root.
- **Token encryption at rest:** tokens are stored plaintext in the private DB
(isolation is the control). Confirm before adding encryption‑at‑rest (a key
would then need storing too).
- **Repo discovery strategy:** recursive scan of the store's project directories
(max depth / ignore from `.env` scan tuning). Default: recursive with a
configurable depth.
- **Background `fetch`:** off by default (no unsolicited network). Confirm whether - **Background `fetch`:** off by default (no unsolicited network). Confirm whether
it should be enabled, and the interval / rate limit, before turning it on. it should be enabled, and the interval / rate limit, before turning it on.
- **Git access library:** system `git` via `os/exec` is the locked default - **Git access library:** system `git` via `os/exec` is the locked default
+95
View File
@@ -230,3 +230,98 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last.
`cmd/server/main.go`, `components/repo-menu`, `web/templates/help.html`, `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 `AGENT.md` (§8.1). Checkout isn't §1.4-destructive — git refuses if it would
overwrite uncommitted changes. 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-22 — Config store: move forges/dirs/identity from .env to a private DB
- **What:** Domain config now lives in a **private SQLite store** (`internal/store`,
modernc.org/sqlite, pure Go) instead of `.env`: **forge hosts + tokens**
(multi‑host), the **project directories** to scan, and the **git identity**.
New Settings page (`/settings`) + `<settings-panel>` component + `<toast-host>`
there; `/api/config/{forges,project-dirs,identity}` CRUD endpoints. The scanner
now reads its roots **fresh from the store each cycle** (add/remove dirs without
a restart); the service resolves a repo → forge by matching remotes against the
stored forges (client cached per host) and reapplies per‑forge git auth
(`http.<url>.extraheader`) on change; git identity comes from the store. On first
run with an empty DB, the store is **seeded from `.env`** (`GITEA_*`,
`GIT_REPO_ROOTS`, `GIT_USER_*`), so existing deploys keep working. The DB lives
on a **private named Docker volume `/data`** — not bind‑mounted, no port — so
credentials aren't reachable outside the container.
- **Why:** Support multiple repos/forges and project directories with credentials,
managed at runtime, without hand‑editing `.env` (user request). Overturns the
old "no datastore" / "config via .env" laws — AGENT.md §0/§1.3/§1.5/§2/§3/§8.4
updated.
- **Affects:** `internal/store` (new), `internal/config`, `internal/repos`
(scanner now dynamic), `internal/service` (store‑backed forges + config CRUD +
`ApplyGitConfig`), `cmd/server/main.go`, `components/settings-panel` (new),
`web/templates/{settings,index,help}.html`, `docker-compose.yml` (private
`gmdata` volume + `GITMANAGER_DB`), `.env.example`, `go.mod`.
- **Verified live:** seeded on first run; forges/dirs/identity served from the DB
(tokens never returned); repos still discovered (now both GitManager and
app-template under /repos); forge PRs work from the stored token; add/validate/
delete of project dirs works; DB is not present in the project directory; the
Settings UI renders and manages all three.
- **Notes:** tokens stored plaintext relying on volume isolation (encryption‑at‑
rest is an open item); scan tuning (interval/depth/ignore/fetch) stays in `.env`
for now; container can only scan paths under a mounted base (mount constraint).
## 2026-09-22 — .env = projects root only; forge derived from git per project
- **What:** Refined the config model. `.env` now holds **only the projects root**
(`REPOS_HOST_PATH`); runtime bootstrap (listen/TLS/DB path/`PROJECTS_ROOT`/scan
tuning) comes from compose + code defaults. The config store keeps **forges +
tokens and git identity** only — the **project-directories** feature was removed
(table, service methods, `/api/config/project-dirs`, and the Settings section):
**projects are simply the subdirectories of the single root.** Each project's
**forge is derived from its git remote** (matched to a configured forge, else the
bare host) and shown as a pill next to its name (`repos.State.Forge` via the
scanner's `ForgeFor` + `svc.ForgeDisplay`). First-run `.env` seeding was removed
(forges/identity are added in Settings). `.env` reduced to one line;
`forge.HostOf` added; scanner config gained `ForgeFor`.
- **Why:** User: "the only thing in .env should be the root directory; each project
has its own directory and shows which forge it belongs to — get it from git."
- **Affects:** `internal/config`, `internal/store` (dropped project_dirs),
`internal/repos` (State.Forge + ForgeFor), `internal/service` (dropped project-dir
methods, added ForgeDisplay), `internal/forge` (HostOf), `cmd/server/main.go`,
`components/{settings-panel,repo-list}`, `web/templates/{help}.html`,
`docker-compose.yml`, `.env`/`.env.example`, `AGENT.md` (§1.3/§1.5/§3/§8.4/§11).
## 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).
+114 -41
View File
@@ -6,10 +6,11 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
@@ -25,32 +26,9 @@ import (
"gitmanager/internal/render" "gitmanager/internal/render"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/service" "gitmanager/internal/service"
"gitmanager/internal/store"
) )
// 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 {
@@ -71,31 +49,41 @@ 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. // Config store (forges + tokens, git identity) on a private volume (§1.3).
scanner := repos.NewScanner(g, log, cfg.RepoRoots, cfg.ScanMaxDepth, cfg.ScanIgnore, cfg.ScanInterval, cfg.ScanFetchEnabled) st, err := store.Open(cfg.DBPath)
scanCtx, stopScan := context.WithCancel(context.Background()) if err != nil {
defer stopScan() log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
go scanner.Run(scanCtx) os.Exit(1)
log.Info("scanner started", "roots", cfg.RepoRoots, "interval", cfg.ScanInterval.String(), "fetch", cfg.ScanFetchEnabled) }
defer st.Close()
// Coordination state: active project + activity feed (§8.2). // Coordination state: active project + activity feed (§8.2).
feed := activity.New(log, 200) feed := activity.New(log, 200)
// Forge provider (Gitea) — optional; nil when unconfigured (§8.4). // The scanner walks the single projects root; each subdirectory is a project.
fg, err := forge.NewGitea(cfg.GiteaURL, cfg.GiteaToken) // Each project's forge is derived from its git remote (svc.ForgeDisplay).
if err != nil { var svc *service.Service
log.Warn("forge disabled — invalid config", "err", err) scanCfg := func(ctx context.Context) repos.Config {
} else if fg != nil { return repos.Config{
log.Info("forge enabled", "provider", "gitea", "url", cfg.GiteaURL) Roots: []string{cfg.ProjectsRoot},
} else { MaxDepth: cfg.ScanMaxDepth,
log.Info("forge disabled — set GITEA_URL and GITEA_TOKEN to enable") Ignore: cfg.ScanIgnore,
Fetch: cfg.ScanFetchEnabled,
ForgeFor: func(remoteURL string) string { return svc.ForgeDisplay(ctx, remoteURL) },
}
} }
scanner := repos.NewScanner(g, log, scanCfg, cfg.ScanInterval)
// 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).
// scanner.RefreshRepo lets a mutating action re-scan just that repo. // scanner.RefreshRepo lets a mutating action re-scan just that repo.
svc := service.New(g, scanner.Index, feed, fg, scanner.RefreshRepo) svc = service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
svc.ApplyGitConfig(context.Background()) // git identity + per-forge auth from the store
scanCtx, stopScan := context.WithCancel(context.Background())
defer stopScan()
go scanner.Run(scanCtx) // started after svc is set, so ForgeFor is ready
log.Info("scanner started", "root", cfg.ProjectsRoot, "interval", cfg.ScanInterval.String())
tmpl, err := render.New("web/templates") tmpl, err := render.New("web/templates")
if err != nil { if err != nil {
@@ -109,6 +97,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")
@@ -120,6 +120,9 @@ func main() {
e.GET("/help", func(c echo.Context) error { e.GET("/help", func(c echo.Context) error {
return c.Render(http.StatusOK, "help.html", nil) return c.Render(http.StatusOK, "help.html", nil)
}) })
e.GET("/settings", func(c echo.Context) error {
return c.Render(http.StatusOK, "settings.html", nil)
})
// JSON API — components self-fetch from here. // JSON API — components self-fetch from here.
e.GET("/healthz", func(c echo.Context) error { e.GET("/healthz", func(c echo.Context) error {
@@ -188,6 +191,59 @@ func main() {
return c.JSON(http.StatusOK, map[string]any{"pending": false}) return c.JSON(http.StatusOK, map[string]any{"pending": false})
}) })
// Configuration store: forges, project directories, git identity (§1.3).
e.GET("/api/config/forges", func(c echo.Context) error {
forges, err := svc.Forges(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, forges)
})
e.POST("/api/config/forges", func(c echo.Context) error {
var b struct{ Name, Kind, BaseURL, Token string }
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
f, err := svc.AddForge(c.Request().Context(), b.Name, b.Kind, b.BaseURL, b.Token)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, f)
})
e.PUT("/api/config/forges/:id", func(c echo.Context) error {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var b struct{ Name, Kind, BaseURL, Token string }
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
if err := svc.UpdateForge(c.Request().Context(), id, b.Name, b.Kind, b.BaseURL, b.Token); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
e.DELETE("/api/config/forges/:id", func(c echo.Context) error {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := svc.DeleteForge(c.Request().Context(), id); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
e.GET("/api/config/identity", func(c echo.Context) error {
name, email := svc.GitIdentity(c.Request().Context())
return c.JSON(http.StatusOK, map[string]string{"name": name, "email": email})
})
e.PUT("/api/config/identity", func(c echo.Context) error {
var b struct{ Name, Email string }
if err := c.Bind(&b); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"})
}
if err := svc.SetGitIdentity(c.Request().Context(), b.Name, b.Email); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
})
// Plain-language git commands (the right-click menu, §6). One endpoint, // Plain-language git commands (the right-click menu, §6). One endpoint,
// op-switched. Destructive ops (discard) are confirmed UI-side per §1.4. // op-switched. Destructive ops (discard) are confirmed UI-side per §1.4.
e.POST("/api/repo/git", func(c echo.Context) error { e.POST("/api/repo/git", func(c echo.Context) error {
@@ -240,6 +296,23 @@ func main() {
} }
return c.JSON(http.StatusOK, map[string]any{"supported": true, "prs": prs}) 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 { e.POST("/api/repo/pr/merge", func(c echo.Context) error {
var body struct { var body struct {
Path string `json:"path"` Path string `json:"path"`
+39 -4
View File
@@ -10,6 +10,7 @@ class PRList extends HTMLElement {
#controller = null; #controller = null;
#onSelect = null; #onSelect = null;
#path = ''; #path = '';
#repo = null;
constructor() { constructor() {
super(); super();
@@ -18,7 +19,7 @@ class PRList extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#renderShell(); 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); document.addEventListener('repo:select', this.#onSelect);
} }
@@ -58,9 +59,34 @@ class PRList extends HTMLElement {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); 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 this.#load(this.#path); // refresh the list
} catch (err) { } 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; } :host { display: block; }
.box { background: var(--surface-1); border: 1px solid var(--border); .box { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; } 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; 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; } ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
li { border-top: 1px solid var(--border); padding-top: 8px; } li { border-top: 1px solid var(--border); padding-top: 8px; }
li:first-child { border-top: none; padding-top: 0; } li:first-child { border-top: none; padding-top: 0; }
@@ -124,9 +155,13 @@ class PRList extends HTMLElement {
.error { color: var(--color-danger); } .error { color: var(--color-danger); }
</style> </style>
<div class="box"> <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 id="body"><p class="muted">Select a repository.</p></div>
</div>`; </div>`;
this.shadowRoot.getElementById('new').addEventListener('click', () => this.#create());
} }
#esc(s) { #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. configured or the repo isn't on the forge host.
- **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`. - **Listens:** `repo:select` on `document` — loads PRs for `event.detail.path`.
- **Fetches:** `GET /api/repo/prs?path=…` (`{supported:false}` → hidden). - **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 ## History
- 2026-09-20: created — slice 5 (forge); list open PRs + "Merge & clean up". - 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 ## Notes / gotchas
- Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component - Requires `GITEA_URL` + `GITEA_TOKEN` on the server; otherwise the component
+133 -41
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,7 +49,7 @@ 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,
@@ -56,53 +63,46 @@ class RepoList extends HTMLElement {
}).catch(() => {}); }).catch(() => {});
} }
#renderShell() { #loadFilters() {
this.shadowRoot.innerHTML = ` try {
<style> const saved = JSON.parse(localStorage.getItem(FILTER_KEY) || '{}');
:host { display: block; } this.#filters = { q: '', dirty: false, aheadBehind: false, ...saved };
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } } catch { /* ignore — use defaults */ }
li {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
cursor: pointer;
display: flex; align-items: center; gap: 12px;
}
li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; }
.branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; }
.badge {
font-size: 12px; padding: 1px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
}
.dirty { color: var(--git-dirty); border-color: var(--git-dirty); }
.clean { color: var(--git-clean); border-color: var(--git-clean); }
.ahead { color: var(--git-ahead); }
.behind { color: var(--git-behind); }
.empty, .error { color: var(--color-fg-muted); padding: 12px 0; }
.error { color: var(--color-danger); }
</style>
<div id="body"><p class="empty">Loading repositories…</p></div>
`;
} }
#renderError(err) { #saveFilters() {
this.shadowRoot.getElementById('body').innerHTML = try { localStorage.setItem(FILTER_KEY, JSON.stringify(this.#filters)); } catch { /* ignore */ }
`<p class="error">Could not load repositories: ${this.#esc(err.message)}</p>`;
} }
#renderRepos(repos) { // #apply computes the filtered set and renders the body + count.
this.#repos = repos || []; #apply() {
const body = this.shadowRoot.getElementById('body'); 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) { if (this.#repos.length === 0) {
body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`; body.innerHTML = `<p class="empty">No repositories found. Check GIT_REPO_ROOTS.</p>`;
return; 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'); const ul = document.createElement('ul');
for (const r of this.#repos) { for (const r of repos) {
const li = document.createElement('li'); const li = document.createElement('li');
if (r.path === this.#selected) li.classList.add('selected'); if (r.path === this.#selected) li.classList.add('selected');
// Right-click opens the command menu (§6) for this repo — via an event, // Right-click opens the command menu (§6) for this repo — via an event,
@@ -116,6 +116,7 @@ class RepoList extends HTMLElement {
}); });
li.innerHTML = ` li.innerHTML = `
<span class="name">${this.#esc(r.name)}</span> <span class="name">${this.#esc(r.name)}</span>
${r.forge ? `<span class="forge">${this.#esc(r.forge)}</span>` : ''}
<span class="branch">${this.#esc(r.branch || '—')}</span> <span class="branch">${this.#esc(r.branch || '—')}</span>
<span class="spacer"></span> <span class="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''} ${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
@@ -128,6 +129,97 @@ class RepoList extends HTMLElement {
body.replaceChildren(ul); 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; }
.forge { font-size: 11px; color: var(--fill-accent); border: 1px solid var(--border-strong);
border-radius: 999px; padding: 0 8px; }
.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) { #esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ( return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
+9 -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`
@@ -26,6 +30,10 @@ component pattern the rest of the UI follows.
readable by Claude via `get_active_project`. Fire-and-forget. readable by Claude via `get_active_project`. Fire-and-forget.
- 2026-09-20: right-clicking a repo emits `repo:contextmenu` `{repo, x, y}` for - 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. `<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).
- 2026-09-22: show each project's forge (derived from its git remote,
`State.Forge`) as a pill next to the name.
## Notes / gotchas ## Notes / gotchas
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+99 -25
View File
@@ -4,7 +4,8 @@
// `repo:contextmenu` event from <repo-list>, and shows a positioned menu of // `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 // 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). // 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>. // results show up live in <activity-feed>.
const ITEMS = [ const ITEMS = [
@@ -12,7 +13,7 @@ const ITEMS = [
{ cmd: 'push', label: 'Publish', hint: 'push' }, { cmd: 'push', label: 'Publish', hint: 'push' },
{ cmd: 'fetch', label: 'Check for updates', hint: 'fetch' }, { cmd: 'fetch', label: 'Check for updates', hint: 'fetch' },
{ cmd: 'commit', label: 'Save my work…', hint: 'commit' }, { cmd: 'commit', label: 'Save my work…', hint: 'commit' },
{ cmd: 'checkout', label: 'Switch branch…', hint: 'checkout' }, { sub: 'branches', label: 'Switch branch', hint: 'checkout' },
{ cmd: 'newbranch', label: 'New branch…', hint: 'branch' }, { cmd: 'newbranch', label: 'New branch…', hint: 'branch' },
{ sep: true }, { sep: true },
{ cmd: 'active', label: 'Set as active project' }, { cmd: 'active', label: 'Set as active project' },
@@ -27,6 +28,7 @@ class RepoMenu extends HTMLElement {
#onContext = null; #onContext = null;
#onDocClick = null; #onDocClick = null;
#onKey = null; #onKey = null;
#branchController = null;
constructor() { constructor() {
super(); super();
@@ -41,6 +43,7 @@ class RepoMenu extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener('repo:contextmenu', this.#onContext); document.removeEventListener('repo:contextmenu', this.#onContext);
this.#branchController?.abort();
this.#teardownDismiss(); this.#teardownDismiss();
} }
@@ -50,25 +53,25 @@ class RepoMenu extends HTMLElement {
const menu = this.shadowRoot.getElementById('menu'); const menu = this.shadowRoot.getElementById('menu');
this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path); this.shadowRoot.getElementById('hdr').textContent = this.#base(repo.path);
menu.hidden = false; menu.hidden = false;
// Position, clamped to the viewport.
// Clamp to viewport; flip submenus leftward when near the right edge.
const rect = menu.getBoundingClientRect(); const rect = menu.getBoundingClientRect();
const left = Math.min(x, window.innerWidth - rect.width - 8); const left = Math.min(x, window.innerWidth - rect.width - 8);
const top = Math.min(y, window.innerHeight - rect.height - 8); const top = Math.min(y, window.innerHeight - rect.height - 8);
menu.style.left = Math.max(8, left) + 'px'; menu.style.left = Math.max(8, left) + 'px';
menu.style.top = Math.max(8, top) + '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.#onDocClick = (ev) => { if (!ev.composedPath().includes(this)) this.#hide(); };
this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); }; this.#onKey = (ev) => { if (ev.key === 'Escape') this.#hide(); };
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', this.#onDocClick, { once: true }); document.addEventListener('click', this.#onDocClick, { once: true });
document.addEventListener('keydown', this.#onKey); document.addEventListener('keydown', this.#onKey);
window.addEventListener('scroll', this.#hideBound(), { once: true, capture: true });
}, 0); }, 0);
} }
#hideBound() { return () => this.#hide(); }
#hide() { #hide() {
this.shadowRoot.getElementById('menu').hidden = true; this.shadowRoot.getElementById('menu').hidden = true;
this.#teardownDismiss(); this.#teardownDismiss();
@@ -80,6 +83,30 @@ class RepoMenu extends HTMLElement {
this.#onDocClick = this.#onKey = null; 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) { async #dispatch(cmd) {
const repo = this.#repo; const repo = this.#repo;
const name = this.#base(repo.path); const name = this.#base(repo.path);
@@ -93,11 +120,6 @@ class RepoMenu extends HTMLElement {
if (msg && msg.trim()) await this.#git('commit', { message: msg }); if (msg && msg.trim()) await this.#git('commit', { message: msg });
break; break;
} }
case 'checkout': {
const b = window.prompt(`Switch ${name} to which existing branch?`);
if (b && b.trim()) await this.#git('checkout', { branch: b.trim() });
break;
}
case 'newbranch': { case 'newbranch': {
const b = window.prompt(`New branch name in ${name}:`); const b = window.prompt(`New branch name in ${name}:`);
if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() }); if (b && b.trim()) await this.#git('create-branch', { branch: b.trim() });
@@ -113,16 +135,37 @@ class RepoMenu extends HTMLElement {
} }
case 'active': case 'active':
await this.#post('/api/active-project', { path: repo.path }); await this.#post('/api/active-project', { path: repo.path });
this.#toast(`${name} is now the active project`, 'info');
break; break;
case 'handoff': case 'handoff':
await this.#post('/api/switch', { target: repo.path }); await this.#post('/api/switch', { target: repo.path });
this.#toast(`Asked Claude to switch to ${name}`, 'info');
break; break;
case 'copy': 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; 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 = {}) { async #git(op, extra = {}) {
try { try {
const res = await fetch('/api/repo/git', { const res = await fetch('/api/repo/git', {
@@ -132,11 +175,16 @@ class RepoMenu extends HTMLElement {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
this.#toast(this.#okLabel(op, extra), 'success');
} catch (err) { } 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) { async #post(url, body) {
try { try {
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
@@ -144,11 +192,19 @@ class RepoMenu extends HTMLElement {
} }
#renderShell() { #renderShell() {
const rows = ITEMS.map((it) => it.sep const rows = ITEMS.map((it) => {
? '<hr>' if (it.sep) return '<hr>';
: `<button data-cmd="${it.cmd}" class="${it.danger ? 'danger' : ''}"> if (it.sub) {
<span>${it.label}</span>${it.hint ? `<code>${it.hint}</code>` : ''} return `<div class="item has-sub" tabindex="0">
</button>`).join(''); <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 = ` this.shadowRoot.innerHTML = `
<style> <style>
#menu { #menu {
@@ -160,25 +216,43 @@ class RepoMenu extends HTMLElement {
.hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px; .hdr { padding: 6px 10px 4px; color: var(--color-fg-muted); font-size: 12px;
border-bottom: 1px solid var(--border); margin-bottom: 4px; border-bottom: 1px solid var(--border); margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button { display: flex; align-items: center; gap: 10px; width: 100%; button, .item { display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: none; color: var(--color-fg); font: inherit; box-sizing: border-box; background: none; border: none; color: var(--color-fg);
text-align: left; padding: 7px 10px; border-radius: var(--radius-sm); font: inherit; text-align: left; padding: 7px 10px; border-radius: var(--radius-sm);
cursor: pointer; } 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 code { margin-left: auto; font-size: 11px; color: var(--color-fg-muted); }
button:hover code { color: #071019; } button:hover code { color: #071019; }
button.danger { color: var(--color-danger); } button.danger { color: var(--color-danger); }
button.danger:hover { background: var(--color-danger); color: #fff; } 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; } 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> </style>
<div id="menu" hidden> <div id="menu" hidden>
<div class="hdr" id="hdr"></div> <div class="hdr" id="hdr"></div>
${rows} ${rows}
</div>`; </div>`;
this.shadowRoot.getElementById('menu').addEventListener('click', (e) => { this.shadowRoot.getElementById('menu').addEventListener('click', (e) => {
const btn = e.target.closest('button'); 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);
}); });
} }
+9 -5
View File
@@ -11,11 +11,13 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
- **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }` - **Listens:** `repo:contextmenu` on `document` — `detail: { repo, x, y }`
(dispatched by `<repo-list>` on right-click). Shows the menu at (x, y). (dispatched by `<repo-list>` on right-click). Shows the menu at (x, y).
- **Commands → endpoints:** - **Commands → endpoints:**
- Get latest / Publish / Check for updates / Save my work… / Switch branch… / - Get latest / Publish / Check for updates / Save my work… / New branch… /
New branch… / Discard all changes… → `POST /api/repo/git {path, op, Discard all changes… → `POST /api/repo/git {path, op, message?, branch?}`
message?, branch?}` (op: pull/push/fetch/commit/checkout/create-branch/ (op: pull/push/fetch/commit/create-branch/checkout/discard). "Save my work…"
discard). "Save my work…" prompts for a message; "Switch branch…"/"New prompts for a message; "New branch…" prompts for a name; "Discard all
branch…" prompt for the branch; "Discard all changes…" confirms (destructive). 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`. - Set as active project → `POST /api/active-project`.
- Ask Claude to switch here → `POST /api/switch` (the handoff request). - Ask Claude to switch here → `POST /api/switch` (the handoff request).
- Copy path → clipboard. - Copy path → clipboard.
@@ -26,6 +28,8 @@ event. Safe commands run on click; the destructive one confirms first (§1.4).
actions, backed by the shared service layer (same ops Claude gets via MCP). 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), - 2026-09-20: added "Switch branch…" (checkout) and "New branch…" (create-branch),
both prompting for the branch name (slice 9). 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 ## Notes / gotchas
- Results aren't shown inline; they appear in `<activity-feed>` (each op records a - Results aren't shown inline; they appear in `<activity-feed>` (each op records a
+195
View File
@@ -0,0 +1,195 @@
// <settings-panel> — manage the config store (AGENT.md §1.3): forge hosts +
// tokens, project directories to scan, and the git commit identity. Replaces
// editing .env for domain config.
//
// A self-contained control (§1.1): shadow DOM, fetches its own data, posts to the
// /api/config/* endpoints, and reports results via `toast` events. Tokens are
// write-only from here — the server never returns them (only whether one is set).
class SettingsPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#renderShell();
this.#loadForges();
this.#loadIdentity();
}
// --- forges ---------------------------------------------------------------
async #loadForges() {
const box = this.shadowRoot.getElementById('forges');
try {
const forges = await this.#json('GET', '/api/config/forges');
if (!forges || forges.length === 0) {
box.innerHTML = `<p class="muted">No forges configured.</p>`;
return;
}
box.replaceChildren(...forges.map((f) => {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<div>
<span class="k">${this.#esc(f.name || f.kind)}</span>
<span class="url">${this.#esc(f.baseUrl)}</span>
<span class="badge ${f.hasToken ? 'ok' : 'warn'}">${f.hasToken ? 'token set' : 'no token'}</span>
</div>`;
const del = document.createElement('button');
del.className = 'danger';
del.textContent = 'Remove';
del.onclick = () => this.#delete(`/api/config/forges/${f.id}`, 'Forge removed', () => this.#loadForges());
row.appendChild(del);
return row;
}));
} catch (err) {
box.innerHTML = `<p class="error">${this.#esc(err.message)}</p>`;
}
}
async #addForge() {
const name = this.shadowRoot.getElementById('f-name').value.trim();
const baseUrl = this.shadowRoot.getElementById('f-url').value.trim();
const token = this.shadowRoot.getElementById('f-token').value;
if (!baseUrl) { this.#toast('A base URL is required', 'error'); return; }
try {
await this.#json('POST', '/api/config/forges', { name, kind: 'gitea', baseUrl, token });
this.shadowRoot.getElementById('f-name').value = '';
this.shadowRoot.getElementById('f-url').value = '';
this.shadowRoot.getElementById('f-token').value = '';
this.#toast('Forge added', 'success');
this.#loadForges();
} catch (err) {
this.#toast(`Add forge failed: ${err.message}`, 'error');
}
}
// --- git identity ---------------------------------------------------------
async #loadIdentity() {
try {
const id = await this.#json('GET', '/api/config/identity');
this.shadowRoot.getElementById('i-name').value = id.name || '';
this.shadowRoot.getElementById('i-email').value = id.email || '';
} catch { /* leave blank */ }
}
async #saveIdentity() {
const name = this.shadowRoot.getElementById('i-name').value.trim();
const email = this.shadowRoot.getElementById('i-email').value.trim();
try {
await this.#put('/api/config/identity', { name, email }, 'Identity saved');
} catch (err) {
this.#toast(`Save failed: ${err.message}`, 'error');
}
}
// --- helpers --------------------------------------------------------------
async #json(method, url, body) {
const opts = { method };
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
async #put(url, body, okMsg) {
await this.#json('PUT', url, body);
if (okMsg) this.#toast(okMsg, 'success');
}
async #delete(url, okMsg, after) {
if (!window.confirm('Remove this entry?')) return;
try {
await this.#json('DELETE', url);
this.#toast(okMsg, 'success');
after?.();
} catch (err) {
this.#toast(`Remove failed: ${err.message}`, 'error');
}
}
#toast(message, kind) {
document.dispatchEvent(new CustomEvent('toast', { detail: { message, kind } }));
}
#renderShell() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
section { background: var(--surface-1); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
h2 { font-size: 15px; margin: 0 0 4px; }
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; }
.intro { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 16px; }
.intro code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
.row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-top: 1px solid var(--border); }
.row:first-of-type { border-top: none; }
.row > div:first-child, .dir { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
.k { font-weight: 600; }
.url { color: var(--color-fg-muted); }
code { background: var(--surface-2); padding: 1px 6px; border-radius: var(--radius-sm); }
.badge { font-size: 11px; padding: 1px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); }
.badge.ok { color: var(--color-success); border-color: var(--color-success); }
.badge.warn { color: var(--color-warning); border-color: var(--color-warning); }
.form { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
input[type=text], input[type=password] {
font: inherit; background: var(--surface-2); color: var(--color-fg);
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 9px; }
input.grow { flex: 1; min-width: 160px; }
button { font: inherit; cursor: pointer; padding: 6px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--fill-accent); color: var(--fill-accent); background: transparent; }
button:hover { background: var(--surface-2); }
button.danger { border-color: var(--color-danger); color: var(--color-danger); margin-left: auto; }
.muted { color: var(--color-fg-muted); }
.error { color: var(--color-danger); }
</style>
<p class="intro">Projects are the subdirectories of the root folder set in
<code>.env</code>; each project's forge is detected from its git remote and
shown next to its name on the dashboard.</p>
<section>
<h2>Forges</h2>
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
Tokens are stored in the private config database and never shown again.</p>
<div id="forges"><p class="muted">Loading…</p></div>
<div class="form">
<input id="f-name" type="text" placeholder="Name (e.g. Gitea)">
<input id="f-url" type="text" class="grow" placeholder="https://git.example.com">
<input id="f-token" type="password" placeholder="Access token">
<button id="f-add">Add forge</button>
</div>
</section>
<section>
<h2>Git identity</h2>
<p class="hint">Author used for commits the app makes.</p>
<div class="form">
<input id="i-name" type="text" placeholder="Your Name">
<input id="i-email" type="text" class="grow" placeholder="you@example.com">
<button id="i-save">Save</button>
</div>
</section>
`;
this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge();
this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity();
}
#esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}
}
customElements.define('settings-panel', SettingsPanel);
@@ -0,0 +1,28 @@
# settings-panel
## Intent
The UI for the config store (AGENT.md §1.5) — manage forge hosts + tokens and the
git commit identity, replacing hand-editing `.env` for domain config. Projects
themselves are the subdirectories of the single root set in `.env`, and each
project's forge is derived from its git remote (not configured here). Served at
`/settings`.
## Public surface
- **Tag:** `<settings-panel>`
- **Fetches:** `GET /api/config/forges`, `GET /api/config/identity`.
- **Writes:**
- Forges: `POST /api/config/forges` (add), `DELETE /api/config/forges/:id`.
- Identity: `PUT /api/config/identity`.
- **Reports:** results via `toast` CustomEvents (needs `<toast-host>` on the page).
## History
- 2026-09-22: created — settings UI for the SQLite config store (forges, project
dirs, git identity).
- 2026-09-22: removed the Project directories section — projects are now the
subdirectories of the single `.env` root; only Forges + Git identity remain.
## Notes / gotchas
- Tokens are **write-only** from the client: the server returns only `hasToken`,
never the value. Removing + re-adding a forge is how you rotate a token.
- Adding/removing a forge reapplies git auth (the server sets an `http.extraheader`
per forge); changes take effect immediately.
+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).
+11 -2
View File
@@ -18,8 +18,12 @@ services:
- HTTPS_ADDR=0.0.0.0:8443 - HTTPS_ADDR=0.0.0.0:8443
- TLS_CERT_FILE=/app/certs/localhost.pem - TLS_CERT_FILE=/app/certs/localhost.pem
- TLS_KEY_FILE=/app/certs/localhost-key.pem - TLS_KEY_FILE=/app/certs/localhost-key.pem
# The scanner looks here; matches the volume mount below. # The single projects root inside the container (each subdirectory is a
- GIT_REPO_ROOTS=/repos # project). The host folder is REPOS_HOST_PATH in .env, mounted here.
- PROJECTS_ROOT=/repos
# Config store (forges + tokens, git identity) on the PRIVATE volume below —
# not bind-mounted into the project, no network port (§1.3).
- GITMANAGER_DB=/data/gitmanager.db
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
- "127.0.0.1:8443:8443" - "127.0.0.1:8443:8443"
@@ -28,6 +32,10 @@ services:
- .:/app - .:/app
# Cache the Go module + build cache across restarts. # Cache the Go module + build cache across restarts.
- gomod:/go/pkg/mod - gomod:/go/pkg/mod
# Config store — a PRIVATE named volume, deliberately NOT bind-mounted to
# the host project and NOT exposed on any port, so the credentials it holds
# are only reachable by the app inside the container (§1.3).
- gmdata:/data
# Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the # Your repositories. Set REPOS_HOST_PATH in .env (or your shell) to the
# host folder that holds them; defaults to ./repos next to this file. # host folder that holds them; defaults to ./repos next to this file.
- "${REPOS_HOST_PATH:-./repos}:/repos" - "${REPOS_HOST_PATH:-./repos}:/repos"
@@ -43,3 +51,4 @@ services:
volumes: volumes:
gomod: gomod:
gmdata:
+11 -3
View File
@@ -7,17 +7,22 @@ require (
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
modernc.org/sqlite v1.59.0
) )
require ( require (
github.com/42wim/httpsig v1.2.4 // indirect github.com/42wim/httpsig v1.2.4 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-fed/httpsig v1.1.0 // 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/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-version v1.9.0 // 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.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -26,8 +31,11 @@ require (
golang.org/x/crypto v0.53.0 // indirect golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
) )
+50 -8
View File
@@ -6,6 +6,8 @@ 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 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 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/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=
@@ -14,8 +16,14 @@ 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/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= 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/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/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=
@@ -24,12 +32,16 @@ github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
@@ -47,19 +59,21 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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.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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
@@ -70,7 +84,35 @@ 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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+14 -18
View File
@@ -23,24 +23,25 @@ type Config struct {
TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store) TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
TLSKeyFile string // PEM private key TLSKeyFile string // PEM private key
RepoRoots []string // roots to scan for git repositories // DBPath is the SQLite config store (forges + tokens, git identity). It lives
GitBin string // path to the git binary // on a private volume, not accessible outside the container (§1.3).
DBPath string
// ProjectsRoot is the single directory (inside the container) under which each
// project lives in its own subdirectory. This is the one thing set in .env
// (as REPOS_HOST_PATH on the host, mounted here); everything else is either a
// compose/env default or lives in the config store.
ProjectsRoot string
GitBin string // path to the git binary
ScanInterval time.Duration // scanner refresh interval ScanInterval time.Duration // scanner refresh interval
ScanMaxDepth int // max discovery depth under each root ScanMaxDepth int // max discovery depth under the root
ScanIgnore []string // directory names to skip during discovery ScanIgnore []string // directory names to skip during discovery
ScanFetchEnabled bool // allow the scanner to run `git fetch` ScanFetchEnabled bool // allow the scanner to run `git fetch`
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
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)
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.
@@ -50,22 +51,17 @@ func Load() (Config, error) {
c := Config{ c := Config{
ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"), ListenAddr: env("LISTEN_ADDR", "127.0.0.1:8080"),
DBPath: env("GITMANAGER_DB", "/data/gitmanager.db"),
ProjectsRoot: env("PROJECTS_ROOT", "/repos"),
HTTPSAddr: env("HTTPS_ADDR", ""), HTTPSAddr: env("HTTPS_ADDR", ""),
TLSCertFile: env("TLS_CERT_FILE", ""), TLSCertFile: env("TLS_CERT_FILE", ""),
TLSKeyFile: env("TLS_KEY_FILE", ""), TLSKeyFile: env("TLS_KEY_FILE", ""),
RepoRoots: splitList(env("GIT_REPO_ROOTS", "")),
GitBin: env("GIT_BIN", "git"), GitBin: env("GIT_BIN", "git"),
ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4), ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4),
ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")), ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")),
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", ""),
GitLabToken: env("GITLAB_TOKEN", ""),
} }
interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s")) interval, err := time.ParseDuration(env("SCAN_INTERVAL", "30s"))
+20
View File
@@ -48,14 +48,34 @@ type MergeResult struct {
BranchDeleted bool `json:"branchDeleted"` 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. // Provider talks to one hosting provider.
type Provider interface { type Provider interface {
// Handles reports whether this provider serves the given remote host. // Handles reports whether this provider serves the given remote host.
Handles(host string) bool Handles(host string) bool
ListPullRequests(ctx context.Context, owner, repo string) ([]PullRequest, error) 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) MergeAndCleanup(ctx context.Context, owner, repo string, number int64, method MergeMethod) (MergeResult, error)
} }
// HostOf returns the hostname of a base URL (e.g. "https://git.example.com:3000"
// → "git.example.com"), or "" if it can't be parsed.
func HostOf(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return ""
}
return u.Hostname()
}
// ParseRemote extracts (host, owner, repo) from a git remote URL, handling both // 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"). // 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) { func ParseRemote(remote string) (host, owner, repo string, ok bool) {
+30
View File
@@ -54,6 +54,36 @@ func (g *Gitea) ListPullRequests(_ context.Context, owner, repo string) ([]PullR
return out, nil 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 // 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 // the same repo — never a fork's branch). Callers MUST have confirmed with the
// user first (§1.4). // user first (§1.4).
+21
View File
@@ -81,6 +81,15 @@ type mergePRInput struct {
Number int64 `json:"number" jsonschema:"the pull request number to merge and clean up"` 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. // gitCommitInput is the argument schema for git_commit.
type gitCommitInput struct { type gitCommitInput struct {
Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"` Path string `json:"path" jsonschema:"absolute path of the repository, from list_repos"`
@@ -196,6 +205,18 @@ func NewServer(svc *service.Service, version string) *mcpsdk.Server {
return nil, prListOutput{PRs: prs}, nil 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. // merge_and_cleanup_pr — DESTRUCTIVE: merges a PR and deletes its branch.
mcpsdk.AddTool(s, &mcpsdk.Tool{ mcpsdk.AddTool(s, &mcpsdk.Tool{
Name: "merge_and_cleanup_pr", Name: "merge_and_cleanup_pr",
+10 -2
View File
@@ -17,6 +17,7 @@ import (
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/service" "gitmanager/internal/service"
"gitmanager/internal/store"
) )
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner -> // TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
@@ -39,10 +40,17 @@ func TestMCPRoundTrip(t *testing.T) {
// Populate the index via the real scanner, then build service + MCP server. // Populate the index via the real scanner, then build service + MCP server.
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git") g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) scanner := repos.NewScanner(g, log, func(context.Context) repos.Config {
return repos.Config{Roots: []string{root}, MaxDepth: 3, Fetch: false}
}, time.Minute)
scanner.Refresh(context.Background()) scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index, activity.New(log, 200), nil, scanner.RefreshRepo) st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
svc := service.New(g, scanner.Index, activity.New(log, 200), st, log, scanner.RefreshRepo)
srv := NewServer(svc, "test") srv := NewServer(svc, "test")
// Wire an in-memory client<->server session. // Wire an in-memory client<->server session.
+59 -34
View File
@@ -26,6 +26,7 @@ type State struct {
Ahead int `json:"ahead"` Ahead int `json:"ahead"`
Behind int `json:"behind"` Behind int `json:"behind"`
Remotes []string `json:"remotes"` Remotes []string `json:"remotes"`
Forge string `json:"forge,omitempty"` // forge this project belongs to (derived from its remote)
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
Error string `json:"error,omitempty"` // set if refreshing this repo failed Error string `json:"error,omitempty"` // set if refreshing this repo failed
} }
@@ -61,34 +62,41 @@ func (i *Index) List() []State {
return out return out
} }
// Config is the per-cycle scan configuration. It is fetched fresh on every scan
// (from the store), so changing project directories at runtime takes effect
// without a restart.
type Config struct {
Roots []string
MaxDepth int
Ignore []string
Fetch bool
// ForgeFor maps a repo's origin remote URL to a display label for the forge it
// belongs to (a configured forge's name, else the bare host, else ""). May be nil.
ForgeFor func(remoteURL string) string
}
// ConfigFunc supplies the current scan configuration.
type ConfigFunc func(context.Context) Config
// Scanner discovers repositories and refreshes the index on an interval. // Scanner discovers repositories and refreshes the index on an interval.
type Scanner struct { type Scanner struct {
git *git.CLI git *git.CLI
log *slog.Logger log *slog.Logger
roots []string cfgFn ConfigFunc
maxDepth int interval time.Duration
ignore map[string]struct{}
interval time.Duration
fetchEnabled bool
Index *Index Index *Index
} }
// NewScanner builds a scanner. ignore is a set of directory names to skip. // NewScanner builds a scanner. cfgFn supplies the roots/depth/ignore/fetch fresh
func NewScanner(g *git.CLI, log *slog.Logger, roots []string, maxDepth int, ignore []string, interval time.Duration, fetchEnabled bool) *Scanner { // each cycle; interval is fixed for the life of the process.
ig := make(map[string]struct{}, len(ignore)) func NewScanner(g *git.CLI, log *slog.Logger, cfgFn ConfigFunc, interval time.Duration) *Scanner {
for _, name := range ignore {
ig[name] = struct{}{}
}
return &Scanner{ return &Scanner{
git: g, git: g,
log: log, log: log,
roots: roots, cfgFn: cfgFn,
maxDepth: maxDepth, interval: interval,
ignore: ig, Index: newIndex(),
interval: interval,
fetchEnabled: fetchEnabled,
Index: newIndex(),
} }
} }
@@ -110,32 +118,34 @@ func (s *Scanner) Run(ctx context.Context) {
// Refresh discovers repos and updates the index. Each repo is refreshed under // Refresh discovers repos and updates the index. Each repo is refreshed under
// its own timeout so a single slow/unreachable repo cannot stall the rest. // its own timeout so a single slow/unreachable repo cannot stall the rest.
func (s *Scanner) Refresh(ctx context.Context) { func (s *Scanner) Refresh(ctx context.Context) {
paths := s.discover() cfg := s.cfgFn(ctx)
s.log.Debug("scan discovered repositories", "count", len(paths)) paths := discover(cfg)
s.log.Debug("scan discovered repositories", "roots", cfg.Roots, "count", len(paths))
for _, p := range paths { for _, p := range paths {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
default: default:
} }
s.Index.set(s.refreshOne(ctx, p)) s.Index.set(s.refreshOne(ctx, p, cfg.Fetch, cfg.ForgeFor))
} }
} }
// RefreshRepo re-scans a single repository and updates the index. Used after a // 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 // mutating action so the UI reflects the new state without waiting for the next
// full scan. // full scan. It never fetches (network) — it only re-reads local state.
func (s *Scanner) RefreshRepo(ctx context.Context, path string) { func (s *Scanner) RefreshRepo(ctx context.Context, path string) {
s.Index.set(s.refreshOne(ctx, path)) cfg := s.cfgFn(ctx)
s.Index.set(s.refreshOne(ctx, path, false, cfg.ForgeFor))
} }
func (s *Scanner) refreshOne(ctx context.Context, path string) State { func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool, forgeFor func(string) string) State {
rctx, cancel := context.WithTimeout(ctx, 20*time.Second) rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() defer cancel()
st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()} st := State{Path: path, Name: filepath.Base(path), UpdatedAt: time.Now()}
if s.fetchEnabled { if fetch {
if err := s.git.Fetch(rctx, path); err != nil { if err := s.git.Fetch(rctx, path); err != nil {
s.log.Warn("scan fetch failed", "repo", path, "err", err) s.log.Warn("scan fetch failed", "repo", path, "err", err)
} }
@@ -156,8 +166,19 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path) st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path)
if remotes, err := s.git.Remotes(rctx, path); err == nil { // Remote names, plus the origin URL used to derive which forge this project
st.Remotes = remotes // belongs to (§ "get it from git").
if details, err := s.git.RemoteDetails(rctx, path); err == nil {
var originURL string
for _, rm := range details {
st.Remotes = append(st.Remotes, rm.Name)
if rm.Name == "origin" || originURL == "" {
originURL = rm.URL
}
}
if forgeFor != nil && originURL != "" {
st.Forge = forgeFor(originURL)
}
} }
return st return st
@@ -166,11 +187,15 @@ func (s *Scanner) refreshOne(ctx context.Context, path string) State {
// discover walks each root looking for directories that contain a .git entry, // discover walks each root looking for directories that contain a .git entry,
// recording the parent as a repo and not descending into it. Depth is measured // recording the parent as a repo and not descending into it. Depth is measured
// relative to each root; ignored directory names are skipped. // relative to each root; ignored directory names are skipped.
func (s *Scanner) discover() []string { func discover(cfg Config) []string {
ignore := make(map[string]struct{}, len(cfg.Ignore))
for _, name := range cfg.Ignore {
ignore[name] = struct{}{}
}
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var out []string var out []string
for _, root := range s.roots { for _, root := range cfg.Roots {
root = filepath.Clean(root) root = filepath.Clean(root)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -182,12 +207,12 @@ func (s *Scanner) discover() []string {
name := d.Name() name := d.Name()
if path != root { if path != root {
if _, skip := s.ignore[name]; skip { if _, skip := ignore[name]; skip {
return filepath.SkipDir return filepath.SkipDir
} }
} }
if depth(root, path) > s.maxDepth { if depth(root, path) > cfg.MaxDepth {
return filepath.SkipDir return filepath.SkipDir
} }
+220 -32
View File
@@ -8,13 +8,16 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"gitmanager/internal/activity" "gitmanager/internal/activity"
"gitmanager/internal/forge" "gitmanager/internal/forge"
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/store"
) )
// Service holds the shared dependencies the capabilities need. // Service holds the shared dependencies the capabilities need.
@@ -22,15 +25,22 @@ type Service struct {
git *git.CLI git *git.CLI
index *repos.Index index *repos.Index
feed *activity.Feed feed *activity.Feed
forge *forge.Gitea // nil when no forge is configured store *store.Store
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil) log *slog.Logger
refresh func(context.Context, string) // re-scan one repo after a mutation (may be nil)
mu sync.Mutex // guards forgeCache
forgeCache map[string]*forge.Gitea // base_url -> provider
} }
// New builds a Service over the git boundary, the scanner's repo index, the // New builds a Service over the git boundary, the scanner's repo index, the
// activity feed, (optionally) a forge provider, and a single-repo refresh hook // activity feed, the config store, and a single-repo refresh hook (may be nil)
// (may be nil) used to re-scan a repo after a mutating action. // 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 { func New(g *git.CLI, index *repos.Index, feed *activity.Feed, st *store.Store, log *slog.Logger, refresh func(context.Context, string)) *Service {
return &Service{git: g, index: index, feed: feed, forge: fg, refresh: refresh} return &Service{
git: g, index: index, feed: feed, store: st, log: log, refresh: refresh,
forgeCache: make(map[string]*forge.Gitea),
}
} }
// ListRepos returns a snapshot of every discovered repository. // ListRepos returns a snapshot of every discovered repository.
@@ -123,29 +133,48 @@ func (s *Service) CancelSwitch(actor activity.Actor) (activity.PendingSwitch, bo
// --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) --------------------- // --- Forge (Gitea) — PRs and "Merge & clean up" (§8.4) ---------------------
// ForgeConfigured reports whether any forge provider is set up. // ForgeConfigured reports whether any forge is configured in the store.
func (s *Service) ForgeConfigured() bool { return s.forge != nil } func (s *Service) ForgeConfigured(ctx context.Context) bool {
forges, err := s.store.ListForges(ctx)
return err == nil && len(forges) > 0
}
// ForgePRs lists open pull requests for a repo. Returns forge.ErrNotConfigured // 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 // when no forge is configured, or forge.ErrNotSupported when the repo's remote is
// on the configured host. // not on a configured host.
func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) { func (s *Service) ForgePRs(ctx context.Context, repoPath string) ([]forge.PullRequest, error) {
owner, repo, err := s.resolveForge(ctx, repoPath) prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.forge.ListPullRequests(ctx, owner, repo) return prov.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) {
prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil {
return forge.PullRequest{}, err
}
pr, err := prov.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. // 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 // The caller is responsible for confirming with the user first (§1.4); actor
// distinguishes a UI action (user) from an MCP one (claude). // 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) { func (s *Service) MergeAndCleanup(ctx context.Context, actor activity.Actor, repoPath string, number int64) (forge.MergeResult, error) {
owner, repo, err := s.resolveForge(ctx, repoPath) prov, owner, repo, err := s.resolveForge(ctx, repoPath)
if err != nil { if err != nil {
return forge.MergeResult{}, err return forge.MergeResult{}, err
} }
res, err := s.forge.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash) res, err := prov.MergeAndCleanup(ctx, owner, repo, number, forge.MergeSquash)
if err != nil { if err != nil {
return forge.MergeResult{}, err return forge.MergeResult{}, err
} }
@@ -238,38 +267,197 @@ func (s *Service) GitCreateBranch(ctx context.Context, actor activity.Actor, rep
}) })
} }
// resolveForge maps a repo path to (owner, repo) on the configured forge host via // resolveForge maps a repo path to (provider, owner, repo) by matching its git
// its git remotes, preferring "origin". // remotes (preferring "origin") against the forges configured in the store.
func (s *Service) resolveForge(ctx context.Context, repoPath string) (owner, repo string, err error) { func (s *Service) resolveForge(ctx context.Context, repoPath string) (*forge.Gitea, string, string, error) {
if s.forge == nil { forges, err := s.store.ListForges(ctx)
return "", "", forge.ErrNotConfigured if err != nil {
return nil, "", "", err
}
if len(forges) == 0 {
return nil, "", "", forge.ErrNotConfigured
} }
base, ok := s.index.Get(filepath.Clean(repoPath)) base, ok := s.index.Get(filepath.Clean(repoPath))
if !ok { if !ok {
return "", "", fmt.Errorf("unknown repository %q", repoPath) return nil, "", "", fmt.Errorf("unknown repository %q", repoPath)
} }
remotes, err := s.git.RemoteDetails(ctx, base.Path) remotes, err := s.git.RemoteDetails(ctx, base.Path)
if err != nil { if err != nil {
return "", "", err return nil, "", "", err
} }
match := func(remoteURL string) (*forge.Gitea, string, string, bool) {
host, o, r, ok := forge.ParseRemote(remoteURL)
if !ok {
return nil, "", "", false
}
for _, f := range forges {
prov, perr := s.forgeFor(f)
if perr != nil {
continue
}
if prov.Handles(host) {
return prov, o, r, true
}
}
return nil, "", "", false
}
// Prefer origin, then any matching remote. // Prefer origin, then any matching remote.
var fallback [2]string var fb *forge.Gitea
haveFallback := false var fo, fr string
for _, rm := range remotes { for _, rm := range remotes {
host, o, r, ok := forge.ParseRemote(rm.URL) prov, o, r, ok := match(rm.URL)
if !ok || !s.forge.Handles(host) { if !ok {
continue continue
} }
if rm.Name == "origin" { if rm.Name == "origin" {
return o, r, nil return prov, o, r, nil
} }
if !haveFallback { if fb == nil {
fallback = [2]string{o, r} fb, fo, fr = prov, o, r
haveFallback = true
} }
} }
if haveFallback { if fb != nil {
return fallback[0], fallback[1], nil return fb, fo, fr, nil
}
return nil, "", "", forge.ErrNotSupported
}
// forgeFor returns a cached *forge.Gitea for a configured forge, building it on
// first use. The cache is cleared whenever forges change (invalidateForges).
func (s *Service) forgeFor(f store.Forge) (*forge.Gitea, error) {
s.mu.Lock()
defer s.mu.Unlock()
if p, ok := s.forgeCache[f.BaseURL]; ok {
return p, nil
}
p, err := forge.NewGitea(f.BaseURL, f.Token)
if err != nil {
return nil, err
}
if p == nil {
return nil, forge.ErrNotConfigured
}
s.forgeCache[f.BaseURL] = p
return p, nil
}
func (s *Service) invalidateForges() {
s.mu.Lock()
s.forgeCache = make(map[string]*forge.Gitea)
s.mu.Unlock()
}
// --- Configuration store (forges, project dirs, git identity) — §1.3 --------
// Forges lists configured forges (tokens are never included).
func (s *Service) Forges(ctx context.Context) ([]store.Forge, error) {
return s.store.ListForges(ctx)
}
// AddForge adds a forge, then reapplies git auth so pushes to it work.
func (s *Service) AddForge(ctx context.Context, name, kind, baseURL, token string) (store.Forge, error) {
if strings.TrimSpace(baseURL) == "" {
return store.Forge{}, fmt.Errorf("a base URL is required")
}
f, err := s.store.AddForge(ctx, store.Forge{Name: name, Kind: kind, BaseURL: strings.TrimRight(baseURL, "/"), Token: token})
if err != nil {
return store.Forge{}, err
}
s.invalidateForges()
s.ApplyGitConfig(ctx)
s.feed.Record(activity.ActorUser, "forge-added", "", baseURL)
return f, nil
}
// UpdateForge edits a forge (empty token keeps the existing one).
func (s *Service) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, token string) error {
if err := s.store.UpdateForge(ctx, id, name, kind, strings.TrimRight(baseURL, "/"), token); err != nil {
return err
}
s.invalidateForges()
s.ApplyGitConfig(ctx)
return nil
}
// DeleteForge removes a forge.
func (s *Service) DeleteForge(ctx context.Context, id int64) error {
if err := s.store.DeleteForge(ctx, id); err != nil {
return err
}
s.invalidateForges()
return nil
}
// GitIdentity returns the configured commit identity.
func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
name, _ = s.store.GetSetting(ctx, "git_user_name", "")
email, _ = s.store.GetSetting(ctx, "git_user_email", "")
return name, email
}
// SetGitIdentity stores the commit identity and reapplies it to git config.
func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error {
if err := s.store.SetSetting(ctx, "git_user_name", name); err != nil {
return err
}
if err := s.store.SetSetting(ctx, "git_user_email", email); err != nil {
return err
}
s.ApplyGitConfig(ctx)
return nil
}
// ForgeDisplay maps a repo's origin remote URL to a label for the forge it
// belongs to: a configured forge's name (or its host) when the remote host
// matches one, otherwise the bare remote host, otherwise "". Used by the scanner
// to show each project's forge next to its name.
func (s *Service) ForgeDisplay(ctx context.Context, remoteURL string) string {
host, _, _, ok := forge.ParseRemote(remoteURL)
if !ok || host == "" {
return ""
}
forges, err := s.store.ListForges(ctx)
if err == nil {
for _, f := range forges {
if strings.EqualFold(forge.HostOf(f.BaseURL), host) {
if f.Name != "" {
return f.Name
}
return host
}
}
}
return host // not a configured forge, but still informative
}
// ApplyGitConfig writes the container's git global config from the store: a
// commit identity, safe.directory for host-owned mounts, and an auth header per
// forge so push/fetch/pull over HTTPS work. Called at startup and after changes.
func (s *Service) ApplyGitConfig(ctx context.Context) {
set := func(key, value string) {
if err := s.git.SetGlobalConfig(ctx, key, value); err != nil && s.log != nil {
s.log.Warn("git config failed", "key", key, "err", err)
}
}
set("safe.directory", "*")
if name, email := s.GitIdentity(ctx); name != "" || email != "" {
if name != "" {
set("user.name", name)
}
if email != "" {
set("user.email", email)
}
}
forges, err := s.store.ListForges(ctx)
if err != nil {
return
}
for _, f := range forges {
if f.Token == "" {
continue
}
set("http."+f.BaseURL+".extraheader", "Authorization: token "+f.Token)
} }
return "", "", forge.ErrNotSupported
} }
+21 -2
View File
@@ -14,8 +14,27 @@ import (
"gitmanager/internal/activity" "gitmanager/internal/activity"
"gitmanager/internal/git" "gitmanager/internal/git"
"gitmanager/internal/repos" "gitmanager/internal/repos"
"gitmanager/internal/store"
) )
// testScanner builds a scanner whose roots are fixed to the given paths.
func testScanner(g *git.CLI, log *slog.Logger, roots ...string) *repos.Scanner {
return repos.NewScanner(g, log, func(context.Context) repos.Config {
return repos.Config{Roots: roots, MaxDepth: 3, Fetch: false}
}, time.Minute)
}
// testStore opens a throwaway SQLite store.
func testStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "config.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
// TestGitActions exercises the mutating commands (commit, discard) on a throwaway // TestGitActions exercises the mutating commands (commit, discard) on a throwaway
// temp repo — never a real one. // temp repo — never a real one.
func TestGitActions(t *testing.T) { func TestGitActions(t *testing.T) {
@@ -31,10 +50,10 @@ func TestGitActions(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git") g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false) scanner := testScanner(g, log, root)
scanner.Refresh(context.Background()) scanner.Refresh(context.Background())
feed := activity.New(log, 200) feed := activity.New(log, 200)
svc := New(g, scanner.Index, feed, nil, scanner.RefreshRepo) svc := New(g, scanner.Index, feed, testStore(t), log, scanner.RefreshRepo)
ctx := context.Background() ctx := context.Background()
// Commit a new file, then the repo should be clean in the index. // Commit a new file, then the repo should be clean in the index.
+150
View File
@@ -0,0 +1,150 @@
// Package store is GitManager's small SQLite-backed configuration store: forge
// hosts + credentials, the project directories to scan, and a few settings
// (git identity). It replaces the domain config that used to live in .env.
//
// The DB file lives on a PRIVATE named Docker volume (not bind-mounted into the
// project, no network port), so it is not accessible outside the container
// (AGENT.md §0/§1.3). Tokens are stored as-is, relying on that isolation. Pure-Go
// driver (modernc.org/sqlite) so the static CGO_ENABLED=0 build keeps working.
package store
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Forge is a configured hosting provider (Gitea/Forgejo, later GitHub/GitLab).
type Forge struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // "gitea" (only kind for now)
BaseURL string `json:"baseUrl"` // e.g. https://git.nilles.net
Token string `json:"-"` // never serialized to the client
HasToken bool `json:"hasToken"`
CreatedAt time.Time `json:"createdAt"`
}
// Store wraps the SQLite connection.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite DB at path and runs migrations.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // SQLite: serialize writers to avoid "database is locked"
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS forges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'gitea',
base_url TEXT NOT NULL UNIQUE,
token TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`)
return err
}
// --- forges ----------------------------------------------------------------
func (s *Store) ListForges(ctx context.Context) ([]Forge, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, name, kind, base_url, token, created_at FROM forges ORDER BY base_url`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Forge
for rows.Next() {
var f Forge
var created string
if err := rows.Scan(&f.ID, &f.Name, &f.Kind, &f.BaseURL, &f.Token, &created); err != nil {
return nil, err
}
f.CreatedAt, _ = time.Parse(time.RFC3339, created)
f.HasToken = f.Token != ""
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) AddForge(ctx context.Context, f Forge) (Forge, error) {
if f.Kind == "" {
f.Kind = "gitea"
}
f.CreatedAt = time.Now()
res, err := s.db.ExecContext(ctx,
`INSERT INTO forges (name, kind, base_url, token, created_at) VALUES (?,?,?,?,?)`,
f.Name, f.Kind, f.BaseURL, f.Token, f.CreatedAt.Format(time.RFC3339))
if err != nil {
return Forge{}, err
}
f.ID, _ = res.LastInsertId()
f.HasToken = f.Token != ""
return f, nil
}
// UpdateForge updates name/base_url/kind, and the token only when newToken != ""
// (empty means "keep the existing token").
func (s *Store) UpdateForge(ctx context.Context, id int64, name, kind, baseURL, newToken string) error {
if kind == "" {
kind = "gitea"
}
if newToken != "" {
_, err := s.db.ExecContext(ctx,
`UPDATE forges SET name=?, kind=?, base_url=?, token=? WHERE id=?`,
name, kind, baseURL, newToken, id)
return err
}
_, err := s.db.ExecContext(ctx,
`UPDATE forges SET name=?, kind=?, base_url=? WHERE id=?`, name, kind, baseURL, id)
return err
}
func (s *Store) DeleteForge(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM forges WHERE id=?`, id)
return err
}
// --- settings (kv) ---------------------------------------------------------
func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err == sql.ErrNoRows {
return def, nil
}
if err != nil {
return def, err
}
return v, nil
}
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err
}
+31 -2
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,25 @@
<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>Projects &amp; forges</h2>
<p>
Your projects are the subdirectories of one root folder (set once in
<code>.env</code>). Each project shows which <strong>forge</strong> it belongs
to next to its name — that's detected automatically from the project's git
remote.
</p>
<h2>Settings</h2>
<p>
Open <a href="/settings">Settings</a> to manage your configuration (stored in
a private database inside the app, not in files):
</p>
<ul>
<li><strong>Forges</strong> — add your Git hosting servers (Gitea) and their
access tokens. Tokens are saved securely and never shown again; to change
one, remove the forge and add it back.</li>
<li><strong>Git identity</strong> — the name and email used for commits.</li>
</ul>
<h2>Right-click commands</h2> <h2>Right-click commands</h2>
<p> <p>
Right-click any repository for a menu of plain-language commands — no git Right-click any repository for a menu of plain-language commands — no git
@@ -55,7 +82,7 @@
<li><strong>Publish</strong> — push your commits.</li> <li><strong>Publish</strong> — push your commits.</li>
<li><strong>Check for updates</strong> — fetch without changing your files.</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>Save my work…</strong> — commit everything (asks for a message).</li>
<li><strong>Switch branch…</strong> — move to another existing branch.</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>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>Set as active project</strong> / <strong>Ask Claude to switch here</strong>.</li>
<li><strong>Copy path</strong>.</li> <li><strong>Copy path</strong>.</li>
@@ -88,7 +115,9 @@
<h2>Pull requests: Merge &amp; clean up</h2> <h2>Pull requests: Merge &amp; clean up</h2>
<p> <p>
When a repository is hosted on your Gitea server, its open pull requests 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 button: it squash-merges the pull request and <strong>deletes its
branch</strong> in one step, so finished work doesn't leave branches lying 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 around. You'll be asked to confirm — it names the pull request and the branch
+3 -1
View File
@@ -13,6 +13,7 @@
<script type="module" src="/components/handoff-bar/handoff-bar.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/pr-list/pr-list.js"></script>
<script type="module" src="/components/repo-menu/repo-menu.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;
@@ -41,7 +42,7 @@
<header> <header>
<h1>GitManager</h1> <h1>GitManager</h1>
<span style="color: var(--color-fg-muted)">multi-repo dashboard</span> <span style="color: var(--color-fg-muted)">multi-repo dashboard</span>
<nav><a href="/help">Help</a></nav> <nav><a href="/settings">Settings</a> · <a href="/help">Help</a></nav>
</header> </header>
<main> <main>
<handoff-bar></handoff-bar> <handoff-bar></handoff-bar>
@@ -55,5 +56,6 @@
<activity-feed></activity-feed> <activity-feed></activity-feed>
</main> </main>
<repo-menu></repo-menu> <repo-menu></repo-menu>
<toast-host></toast-host>
</body> </body>
</html> </html>
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GitManager — Settings</title>
<link rel="stylesheet" href="/static/app.css">
<script type="module" src="/components/settings-panel/settings-panel.js"></script>
<script type="module" src="/components/toast-host/toast-host.js"></script>
<style>
header {
display: flex; align-items: baseline; gap: 16px;
padding: 12px 20px; background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; }
header nav { margin-left: auto; }
main { max-width: 820px; padding: 20px; }
</style>
</head>
<body>
<header>
<h1>GitManager</h1>
<span style="color: var(--color-fg-muted)">settings</span>
<nav><a href="/">← Dashboard</a></nav>
</header>
<main>
<settings-panel></settings-panel>
</main>
<toast-host></toast-host>
</body>
</html>