From 32ae17cc9fefb34c1b312edb8acefa4a012806f8 Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Tue, 22 Sep 2026 05:52:03 -0400 Subject: [PATCH] Config: .env = projects root only; derive each project's forge from git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 92 ++---------------- AGENT.md | 60 ++++++------ CHANGELOG.md | 20 ++++ cmd/server/main.go | 100 ++++---------------- components/repo-list/repo-list.js | 3 + components/repo-list/repo-list.md | 2 + components/settings-panel/settings-panel.js | 64 ++----------- components/settings-panel/settings-panel.md | 18 ++-- docker-compose.yml | 10 +- internal/config/config.go | 31 +++--- internal/forge/forge.go | 10 ++ internal/repos/repos.go | 26 ++++- internal/service/service.go | 67 ++++--------- internal/store/store.go | 92 ------------------ web/templates/help.html | 14 ++- 15 files changed, 175 insertions(+), 434 deletions(-) diff --git a/.env.example b/.env.example index 764224e..545e54a 100644 --- a/.env.example +++ b/.env.example @@ -1,87 +1,15 @@ # --------------------------------------------------------------------------- -# GitManager configuration. Copy this file to `.env` and fill in values. -# NEVER commit a real `.env` (it is git-ignored). See AGENT.md §1.5. +# GitManager configuration. Copy this file to `.env`. +# NEVER commit a real `.env` (it is git-ignored). # -# NOTE: forges (URL + token), project directories, and the git identity now live -# in the config DATABASE (a private SQLite store, AGENT.md §1.3), managed in the -# app's Settings. The GITEA_*, GIT_REPO_ROOTS, and GIT_USER_* values below are -# used ONLY to seed that DB the first time the app starts with an empty store; -# after that, edit them in Settings (changing .env has no effect). +# 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. # --------------------------------------------------------------------------- -# Config store location (SQLite). docker-compose points this at a PRIVATE named -# volume that is not bind-mounted or exposed, so credentials in it are only -# reachable inside the container (AGENT.md §1.3). -GITMANAGER_DB=/data/gitmanager.db - -# Address the HTTP server binds to. Localhost-only by default: there is NO -# authentication (AGENT.md §0). Only bind to a non-local interface deliberately. -LISTEN_ADDR=127.0.0.1:8080 - -# HTTPS (optional, but REQUIRED for the MCP connector — Claude Desktop only -# accepts https:// URLs). When HTTPS_ADDR and both cert/key are set, an HTTPS -# listener starts alongside HTTP. docker-compose sets these to the mounted certs. -# Generate a locally-trusted cert on the HOST with mkcert (installs a local CA -# your OS — and Claude Desktop — will trust), from the project root: -# winget install FiloSottile.mkcert -# mkcert -install # trust step (adds the local CA) -# mkdir certs -# mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1 ::1 -# Then connect Claude Desktop to https://localhost:8443/mcp -HTTPS_ADDR= -TLS_CERT_FILE= -TLS_KEY_FILE= - -# SEED ONLY (first run) → project directories in the config DB. Comma-separated -# *container* paths (the host roots are mounted here; see docker-compose.yml). -# After first run, manage these in Settings. 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 +# Host folder that holds your projects. docker-compose mounts it to /repos inside +# the container (scanned as PROJECTS_ROOT). Example: C:/Users/you/Projects REPOS_HOST_PATH=./repos - -# Path to the git binary. "git" resolves it from PATH (git is installed in the -# container image). -GIT_BIN=git - -# --- Repo scanner (read-only; AGENT.md §5) --------------------------------- - -# How often the background scanner refreshes repo state. -SCAN_INTERVAL=30s - -# Max directory depth to descend under each root when discovering repos. -SCAN_MAX_DEPTH=4 - -# Directory names to skip during discovery, comma-separated. -SCAN_IGNORE=node_modules,vendor,.cache - -# Allow the scanner to run `git fetch` (network) to keep ahead/behind counts -# current. OFF by default — no unsolicited network. (AGENT.md §5) -SCAN_FETCH_ENABLED=false - -# --- Logging (AGENT.md §7) -------------------------------------------------- - -# "dev" uses a readable console handler; anything else uses structured JSON. -APP_ENV=dev - -# SEED ONLY (first run) → git identity in the config DB. Without an identity, -# commits inside the container fail with "empty ident". After first run, set it -# in Settings. -GIT_USER_NAME= -GIT_USER_EMAIL= -# The app configures git to authenticate to each configured forge over HTTPS -# (an http.extraheader per host), 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 — SEED ONLY (first run) → forges in the config DB ---- -# Multiple forges are now supported and managed in Settings; this pair only -# seeds the FIRST one on an empty DB. Set BOTH to enable PRs + "Merge & clean up" -# for repos whose remote host matches this URL. Token scope: repo read + PR write -# + branch delete. Writes are confirmed per AGENT.md §1.4. -GITEA_URL= -GITEA_TOKEN= diff --git a/AGENT.md b/AGENT.md index 2eda251..da7f881 100644 --- a/AGENT.md +++ b/AGENT.md @@ -124,7 +124,7 @@ values** rather than reintroducing hardcoded hex. - **Git stays the system of record for repository data** — never mirror repo contents into another store. The one sanctioned datastore is the **config store** (§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens, - the project directories to scan, git identity), not git data. Anything beyond + 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). @@ -146,22 +146,24 @@ must be treated as such: ### 1.5 Configuration: bootstrap `.env` + a private config store -Configuration is split: +Configuration is split three ways: -- **Bootstrap `.env`** — only what's needed to start the process and can't live in - the DB: listen address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan - tuning (interval/depth/ignore/fetch), and the **config DB path**. A committed - `.env.example` documents every variable; **never commit a real `.env`**. -- **Config store (`internal/store`, SQLite)** — the domain config that used to - live in `.env`: **forge hosts + access tokens, the project directories to scan, - and the git commit identity.** Managed at runtime in the app's **Settings** - (`/settings`), not by editing files. On first run with an empty DB it is - **seeded** from the `.env` values (`GITEA_*`, `GIT_REPO_ROOTS`, `GIT_USER_*`); - after that those `.env` values are ignored. -- **The DB must not be reachable outside the container.** It lives on a **private - named Docker volume** (`/data`) — never bind‑mounted into the project, never on a - published port. Tokens are stored there relying on that isolation. Never hardcode - tokens/hosts/paths in code. +- **`.env` — the projects root, and nothing else.** The only value a user sets in + `.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 @@ -227,7 +229,7 @@ without asking. │ └── server/main.go # entrypoint: wire config, git, scanner, router ├── internal/ │ ├── config/ # bootstrap .env loading, typed config struct (§1.5) -│ ├── store/ # SQLite config store: forges+tokens, project dirs, identity (§1.5) +│ ├── store/ # SQLite config store: forges+tokens, git identity (§1.5) │ ├── git/ # THE Git boundary: interface + os/exec impl (all git ops) │ ├── repos/ # discovery, in-memory index/cache, refresh scanner worker │ ├── service/ # the ONE service layer both the HTTP API and MCP call (§1.7) @@ -456,6 +458,10 @@ later behind the same interface). 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 host the user didn't configure. @@ -540,16 +546,16 @@ component carries its own context. *(Claude Code: surface these to the human at the first relevant moment; don't silently guess.)* -- ✅ **RESOLVED 2026-09-22:** **Domain config moved from `.env` to a private - SQLite store** (`internal/store`, §1.5): forges+tokens, project directories, git - identity — managed in Settings (`/settings`), seeded from `.env` on first run. - DB on a private `/data` volume (not bind‑mounted, no port). Forge is now - **multi‑host**; git auth sets an `http.extraheader` per forge. -- **Container mount constraint (multiple project dirs):** the container can only - scan host paths that are **bind‑mounted at `up` time**. Today `~/Projects` is - mounted to `/repos`, so project dirs added in Settings must resolve under a - mounted base. A dir outside it needs a new compose mount — surface this if asked - to add such a path. +- ✅ **RESOLVED 2026-09-22:** **`.env` holds only the projects root; forges + + identity live in a private SQLite store** (`internal/store`, §1.5), managed in + 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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a81af..56365a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -293,6 +293,26 @@ Append-only running history of all changes (AGENT.md §9.1). Newest last. 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:** `` gained a search box (name/path, case-insensitive) and "Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is diff --git a/cmd/server/main.go b/cmd/server/main.go index 3b18f0c..34e8d38 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,7 +6,6 @@ package main import ( "context" "encoding/json" - "log/slog" "net/http" "os" "os/signal" @@ -30,36 +29,6 @@ import ( "gitmanager/internal/store" ) -// seedStore migrates config from .env into the store on first run only, so an -// existing deployment keeps working after the switch to the DB (§1.3). -func seedStore(ctx context.Context, st *store.Store, cfg config.Config, log *slog.Logger) { - empty, err := st.IsEmpty(ctx) - if err != nil { - log.Warn("config store check failed", "err", err) - return - } - if !empty { - return - } - log.Info("seeding config store from environment (first run)") - if cfg.GiteaURL != "" && cfg.GiteaToken != "" { - if _, err := st.AddForge(ctx, store.Forge{Name: "Gitea", Kind: "gitea", BaseURL: strings.TrimRight(cfg.GiteaURL, "/"), Token: cfg.GiteaToken}); err != nil { - log.Warn("seed forge failed", "err", err) - } - } - for _, r := range cfg.RepoRoots { - if _, err := st.AddProjectDir(ctx, r); err != nil { - log.Warn("seed project dir failed", "dir", r, "err", err) - } - } - if cfg.GitUserName != "" { - _ = st.SetSetting(ctx, "git_user_name", cfg.GitUserName) - } - if cfg.GitUserEmail != "" { - _ = st.SetSetting(ctx, "git_user_email", cfg.GitUserEmail) - } -} - func main() { cfg, err := config.Load() if err != nil { @@ -81,38 +50,40 @@ func main() { log.Info("git detected", "version", v) } - // Config store (forges, project dirs, git identity) on a private volume (§1.3). + // Config store (forges + tokens, git identity) on a private volume (§1.3). st, err := store.Open(cfg.DBPath) if err != nil { log.Error("failed to open config store", "path", cfg.DBPath, "err", err) os.Exit(1) } defer st.Close() - seedStore(context.Background(), st, cfg, log) // Coordination state: active project + activity feed (§8.2). feed := activity.New(log, 200) - // The read-only scanner reads its roots fresh from the store each cycle, so - // project-directory changes take effect without a restart (§5). + // The scanner walks the single projects root; each subdirectory is a project. + // Each project's forge is derived from its git remote (svc.ForgeDisplay). + var svc *service.Service scanCfg := func(ctx context.Context) repos.Config { - roots, err := st.EnabledRoots(ctx) - if err != nil { - log.Warn("could not read project dirs", "err", err) + return repos.Config{ + Roots: []string{cfg.ProjectsRoot}, + MaxDepth: cfg.ScanMaxDepth, + Ignore: cfg.ScanIgnore, + Fetch: cfg.ScanFetchEnabled, + ForgeFor: func(remoteURL string) string { return svc.ForgeDisplay(ctx, remoteURL) }, } - return repos.Config{Roots: roots, MaxDepth: cfg.ScanMaxDepth, Ignore: cfg.ScanIgnore, Fetch: cfg.ScanFetchEnabled} } scanner := repos.NewScanner(g, log, scanCfg, cfg.ScanInterval) - scanCtx, stopScan := context.WithCancel(context.Background()) - defer stopScan() - go scanner.Run(scanCtx) - log.Info("scanner started", "interval", cfg.ScanInterval.String()) // The one service layer both the HTTP API and the MCP server call (§1.7). // scanner.RefreshRepo lets a mutating action re-scan just that repo. - svc := service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo) - // Configure git (identity + per-forge auth) from the store. - svc.ApplyGitConfig(context.Background()) + 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") if err != nil { @@ -258,43 +229,6 @@ func main() { return c.JSON(http.StatusOK, map[string]bool{"ok": true}) }) - e.GET("/api/config/project-dirs", func(c echo.Context) error { - dirs, err := svc.ProjectDirs(c.Request().Context()) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, dirs) - }) - e.POST("/api/config/project-dirs", func(c echo.Context) error { - var b struct{ Path string } - if err := c.Bind(&b); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) - } - d, err := svc.AddProjectDir(c.Request().Context(), b.Path) - if err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, d) - }) - e.PUT("/api/config/project-dirs/:id", func(c echo.Context) error { - id, _ := strconv.ParseInt(c.Param("id"), 10, 64) - var b struct{ Enabled bool } - if err := c.Bind(&b); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid body"}) - } - if err := svc.SetProjectDirEnabled(c.Request().Context(), id, b.Enabled); 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/project-dirs/:id", func(c echo.Context) error { - id, _ := strconv.ParseInt(c.Param("id"), 10, 64) - if err := svc.DeleteProjectDir(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}) diff --git a/components/repo-list/repo-list.js b/components/repo-list/repo-list.js index 56bad45..acce328 100644 --- a/components/repo-list/repo-list.js +++ b/components/repo-list/repo-list.js @@ -116,6 +116,7 @@ class RepoList extends HTMLElement { }); li.innerHTML = ` ${this.#esc(r.name)} + ${r.forge ? `${this.#esc(r.forge)}` : ''} ${this.#esc(r.branch || '—')} ${r.ahead ? `↑${r.ahead}` : ''} @@ -166,6 +167,8 @@ class RepoList extends HTMLElement { 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 { diff --git a/components/repo-list/repo-list.md b/components/repo-list/repo-list.md index d162025..af54411 100644 --- a/components/repo-list/repo-list.md +++ b/components/repo-list/repo-list.md @@ -32,6 +32,8 @@ component pattern the rest of the UI follows. `` (§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 - Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all diff --git a/components/settings-panel/settings-panel.js b/components/settings-panel/settings-panel.js index dc3b6e2..c088cdd 100644 --- a/components/settings-panel/settings-panel.js +++ b/components/settings-panel/settings-panel.js @@ -15,7 +15,6 @@ class SettingsPanel extends HTMLElement { connectedCallback() { this.#renderShell(); this.#loadForges(); - this.#loadDirs(); this.#loadIdentity(); } @@ -67,51 +66,6 @@ class SettingsPanel extends HTMLElement { } } - // --- project directories -------------------------------------------------- - - async #loadDirs() { - const box = this.shadowRoot.getElementById('dirs'); - try { - const dirs = await this.#json('GET', '/api/config/project-dirs'); - if (!dirs || dirs.length === 0) { - box.innerHTML = `

No project directories configured.

`; - return; - } - box.replaceChildren(...dirs.map((d) => { - const row = document.createElement('div'); - row.className = 'row'; - row.innerHTML = ` - `; - row.querySelector('input').onchange = (e) => - this.#put(`/api/config/project-dirs/${d.id}`, { enabled: e.target.checked }, e.target.checked ? 'Directory enabled' : 'Directory disabled'); - const del = document.createElement('button'); - del.className = 'danger'; - del.textContent = 'Remove'; - del.onclick = () => this.#delete(`/api/config/project-dirs/${d.id}`, 'Directory removed', () => this.#loadDirs()); - row.appendChild(del); - return row; - })); - } catch (err) { - box.innerHTML = `

${this.#esc(err.message)}

`; - } - } - - async #addDir() { - const path = this.shadowRoot.getElementById('d-path').value.trim(); - if (!path) { this.#toast('A path is required', 'error'); return; } - try { - await this.#json('POST', '/api/config/project-dirs', { path }); - this.shadowRoot.getElementById('d-path').value = ''; - this.#toast('Directory added', 'success'); - this.#loadDirs(); - } catch (err) { - this.#toast(`Add directory failed: ${err.message}`, 'error'); - } - } - // --- git identity --------------------------------------------------------- async #loadIdentity() { @@ -174,6 +128,8 @@ class SettingsPanel extends HTMLElement { 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; } @@ -197,6 +153,10 @@ class SettingsPanel extends HTMLElement { .error { color: var(--color-danger); } +

Projects are the subdirectories of the root folder set in + .env; each project's forge is detected from its git remote and + shown next to its name on the dashboard.

+

Forges

Hosting servers (Gitea/Forgejo) and their access tokens. @@ -210,17 +170,6 @@ class SettingsPanel extends HTMLElement {

-
-

Project directories

-

Directories to scan for repositories. Paths are inside the - container — they must be under a mounted directory (e.g. under /repos).

-

Loading…

-
- - -
-
-

Git identity

Author used for commits the app makes.

@@ -233,7 +182,6 @@ class SettingsPanel extends HTMLElement { `; this.shadowRoot.getElementById('f-add').onclick = () => this.#addForge(); - this.shadowRoot.getElementById('d-add').onclick = () => this.#addDir(); this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity(); } diff --git a/components/settings-panel/settings-panel.md b/components/settings-panel/settings-panel.md index 7e52ffb..e81543e 100644 --- a/components/settings-panel/settings-panel.md +++ b/components/settings-panel/settings-panel.md @@ -1,30 +1,28 @@ # settings-panel ## Intent -The UI for the config store (AGENT.md §1.3) — manage forge hosts + tokens, -project directories to scan, and the git commit identity, replacing hand-editing -`.env` for domain config. Served at `/settings`. +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:** `` -- **Fetches:** `GET /api/config/forges`, `GET /api/config/project-dirs`, - `GET /api/config/identity`. +- **Fetches:** `GET /api/config/forges`, `GET /api/config/identity`. - **Writes:** - Forges: `POST /api/config/forges` (add), `DELETE /api/config/forges/:id`. - - Project dirs: `POST /api/config/project-dirs` (add), - `PUT /api/config/project-dirs/:id` (enable/disable), - `DELETE /api/config/project-dirs/:id`. - Identity: `PUT /api/config/identity`. - **Reports:** results via `toast` CustomEvents (needs `` 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. -- Project-dir paths are **container paths** and must resolve inside the container - (under a mounted base); the server validates existence before adding. - Adding/removing a forge reapplies git auth (the server sets an `http.extraheader` per forge); changes take effect immediately. diff --git a/docker-compose.yml b/docker-compose.yml index b6dc11b..4ba875d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: - HTTPS_ADDR=0.0.0.0:8443 - TLS_CERT_FILE=/app/certs/localhost.pem - TLS_KEY_FILE=/app/certs/localhost-key.pem - # SEED ONLY (first run): the scanner's roots now live in the config DB. - # This is copied into the DB the first time the app starts with an empty DB. - - GIT_REPO_ROOTS=/repos - # Config store (forges, project dirs, git identity) on the PRIVATE volume - # below — not bind-mounted into the project, no network port (§1.3). + # The single projects root inside the container (each subdirectory is a + # 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: - "127.0.0.1:8080:8080" diff --git a/internal/config/config.go b/internal/config/config.go index dc1bbe0..33e4f3b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,28 +23,25 @@ type Config struct { TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store) TLSKeyFile string // PEM private key - // DBPath is the SQLite config store (forges, project dirs, git identity). - // It lives on a private volume, not accessible outside the container (§1.3). + // DBPath is the SQLite config store (forges + tokens, git identity). It lives + // on a private volume, not accessible outside the container (§1.3). DBPath string - RepoRoots []string // SEED ONLY: roots to scan, used to seed the store on first run - GitBin string // path to the git binary + // 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 - ScanMaxDepth int // max discovery depth under each root + ScanMaxDepth int // max discovery depth under the root ScanIgnore []string // directory names to skip during discovery ScanFetchEnabled bool // allow the scanner to run `git fetch` Dev bool // readable console logging vs structured JSON LogFile string // optional file to also append logs to - - 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. @@ -55,22 +52,16 @@ func Load() (Config, error) { c := Config{ 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", ""), TLSCertFile: env("TLS_CERT_FILE", ""), TLSKeyFile: env("TLS_KEY_FILE", ""), - RepoRoots: splitList(env("GIT_REPO_ROOTS", "")), GitBin: env("GIT_BIN", "git"), ScanMaxDepth: envInt("SCAN_MAX_DEPTH", 4), ScanIgnore: splitList(env("SCAN_IGNORE", "node_modules,vendor,.cache")), ScanFetchEnabled: envBool("SCAN_FETCH_ENABLED", false), Dev: strings.EqualFold(env("APP_ENV", "dev"), "dev"), LogFile: env("LOG_FILE", ""), - 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")) diff --git a/internal/forge/forge.go b/internal/forge/forge.go index ac33d4f..848d4e2 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -66,6 +66,16 @@ type Provider interface { 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 // 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) { diff --git a/internal/repos/repos.go b/internal/repos/repos.go index ca079bc..ae6f7f2 100644 --- a/internal/repos/repos.go +++ b/internal/repos/repos.go @@ -26,6 +26,7 @@ type State struct { Ahead int `json:"ahead"` Behind int `json:"behind"` Remotes []string `json:"remotes"` + Forge string `json:"forge,omitempty"` // forge this project belongs to (derived from its remote) UpdatedAt time.Time `json:"updatedAt"` Error string `json:"error,omitempty"` // set if refreshing this repo failed } @@ -69,6 +70,9 @@ type Config struct { 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. @@ -123,7 +127,7 @@ func (s *Scanner) Refresh(ctx context.Context) { return default: } - s.Index.set(s.refreshOne(ctx, p, cfg.Fetch)) + s.Index.set(s.refreshOne(ctx, p, cfg.Fetch, cfg.ForgeFor)) } } @@ -131,10 +135,11 @@ func (s *Scanner) Refresh(ctx context.Context) { // mutating action so the UI reflects the new state without waiting for the next // full scan. It never fetches (network) — it only re-reads local state. func (s *Scanner) RefreshRepo(ctx context.Context, path string) { - s.Index.set(s.refreshOne(ctx, path, false)) + cfg := s.cfgFn(ctx) + s.Index.set(s.refreshOne(ctx, path, false, cfg.ForgeFor)) } -func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) 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) defer cancel() @@ -161,8 +166,19 @@ func (s *Scanner) refreshOne(ctx context.Context, path string, fetch bool) State st.Ahead, st.Behind, _ = s.git.AheadBehind(rctx, path) - if remotes, err := s.git.Remotes(rctx, path); err == nil { - st.Remotes = remotes + // Remote names, plus the origin URL used to derive which forge this project + // 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 diff --git a/internal/service/service.go b/internal/service/service.go index f75e61c..c8a9ac2 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -9,7 +9,6 @@ import ( "context" "fmt" "log/slog" - "os" "path/filepath" "strings" "sync" @@ -391,46 +390,6 @@ func (s *Service) DeleteForge(ctx context.Context, id int64) error { return nil } -// ProjectDirs lists configured project directories. -func (s *Service) ProjectDirs(ctx context.Context) ([]store.ProjectDir, error) { - return s.store.ListProjectDirs(ctx) -} - -// AddProjectDir adds a directory to scan. The path must exist inside the -// container (i.e. be under a mounted base) and be a directory. -func (s *Service) AddProjectDir(ctx context.Context, path string) (store.ProjectDir, error) { - path = filepath.Clean(strings.TrimSpace(path)) - if path == "" { - return store.ProjectDir{}, fmt.Errorf("a path is required") - } - info, err := os.Stat(path) - if err != nil { - return store.ProjectDir{}, fmt.Errorf("path not found in the container: %s (is it under a mounted directory?)", path) - } - if !info.IsDir() { - return store.ProjectDir{}, fmt.Errorf("not a directory: %s", path) - } - d, err := s.store.AddProjectDir(ctx, path) - if err != nil { - return store.ProjectDir{}, err - } - s.feed.Record(activity.ActorUser, "project-dir-added", path, "") - if s.refresh == nil { - return d, nil - } - return d, nil -} - -// SetProjectDirEnabled toggles a project directory. -func (s *Service) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error { - return s.store.SetProjectDirEnabled(ctx, id, enabled) -} - -// DeleteProjectDir removes a project directory. -func (s *Service) DeleteProjectDir(ctx context.Context, id int64) error { - return s.store.DeleteProjectDir(ctx, id) -} - // GitIdentity returns the configured commit identity. func (s *Service) GitIdentity(ctx context.Context) (name, email string) { name, _ = s.store.GetSetting(ctx, "git_user_name", "") @@ -450,13 +409,27 @@ func (s *Service) SetGitIdentity(ctx context.Context, name, email string) error return nil } -// ScanRoots returns the enabled project directories (for the scanner). -func (s *Service) ScanRoots(ctx context.Context) []string { - roots, err := s.store.EnabledRoots(ctx) - if err != nil && s.log != nil { - s.log.Warn("could not read project dirs", "err", err) +// 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 "" } - return roots + 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 diff --git a/internal/store/store.go b/internal/store/store.go index 3d696c7..72e57a5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -28,14 +28,6 @@ type Forge struct { CreatedAt time.Time `json:"createdAt"` } -// ProjectDir is a directory (inside the container) to scan for repositories. -type ProjectDir struct { - ID int64 `json:"id"` - Path string `json:"path"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"createdAt"` -} - // Store wraps the SQLite connection. type Store struct { db *sql.DB @@ -69,12 +61,6 @@ CREATE TABLE IF NOT EXISTS forges ( token TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS project_dirs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE, - enabled INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL -); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -142,73 +128,6 @@ func (s *Store) DeleteForge(ctx context.Context, id int64) error { return err } -// --- project dirs ---------------------------------------------------------- - -func (s *Store) ListProjectDirs(ctx context.Context) ([]ProjectDir, error) { - rows, err := s.db.QueryContext(ctx, `SELECT id, path, enabled, created_at FROM project_dirs ORDER BY path`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []ProjectDir - for rows.Next() { - var d ProjectDir - var created string - var enabled int - if err := rows.Scan(&d.ID, &d.Path, &enabled, &created); err != nil { - return nil, err - } - d.Enabled = enabled != 0 - d.CreatedAt, _ = time.Parse(time.RFC3339, created) - out = append(out, d) - } - return out, rows.Err() -} - -// EnabledRoots returns the paths of enabled project directories. -func (s *Store) EnabledRoots(ctx context.Context) ([]string, error) { - rows, err := s.db.QueryContext(ctx, `SELECT path FROM project_dirs WHERE enabled=1 ORDER BY path`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var p string - if err := rows.Scan(&p); err != nil { - return nil, err - } - out = append(out, p) - } - return out, rows.Err() -} - -func (s *Store) AddProjectDir(ctx context.Context, path string) (ProjectDir, error) { - d := ProjectDir{Path: path, Enabled: true, CreatedAt: time.Now()} - res, err := s.db.ExecContext(ctx, - `INSERT INTO project_dirs (path, enabled, created_at) VALUES (?,1,?)`, - path, d.CreatedAt.Format(time.RFC3339)) - if err != nil { - return ProjectDir{}, err - } - d.ID, _ = res.LastInsertId() - return d, nil -} - -func (s *Store) SetProjectDirEnabled(ctx context.Context, id int64, enabled bool) error { - v := 0 - if enabled { - v = 1 - } - _, err := s.db.ExecContext(ctx, `UPDATE project_dirs SET enabled=? WHERE id=?`, v, id) - return err -} - -func (s *Store) DeleteProjectDir(ctx context.Context, id int64) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM project_dirs WHERE id=?`, id) - return err -} - // --- settings (kv) --------------------------------------------------------- func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) { @@ -229,14 +148,3 @@ func (s *Store) SetSetting(ctx context.Context, key, value string) error { ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value) return err } - -// IsEmpty reports whether the store has no forges and no project dirs (used to -// decide whether to seed from .env on first run). -func (s *Store) IsEmpty(ctx context.Context) (bool, error) { - var n int - if err := s.db.QueryRowContext(ctx, - `SELECT (SELECT COUNT(*) FROM forges) + (SELECT COUNT(*) FROM project_dirs)`).Scan(&n); err != nil { - return false, err - } - return n == 0, nil -} diff --git a/web/templates/help.html b/web/templates/help.html index 0f17fea..c1401d7 100644 --- a/web/templates/help.html +++ b/web/templates/help.html @@ -53,18 +53,22 @@
  • Ahead / behind — commits your branch leads or trails its upstream by.
  • +

    Projects & forges

    +

    + Your projects are the subdirectories of one root folder (set once in + .env). Each project shows which forge it belongs + to next to its name — that's detected automatically from the project's git + remote. +

    Settings

    - Open Settings to manage your configuration (it's - stored in a private database inside the app, not in files): + Open Settings to manage your configuration (stored in + a private database inside the app, not in files):

    • Forges — 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.
    • -
    • Project directories — the folders scanned for - repositories. Paths are inside the app's container (under a mounted folder - such as /repos); enable, disable, or remove them.
    • Git identity — the name and email used for commits.