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>
This commit is contained in:
2026-09-22 05:52:03 -04:00
parent e30c3b632a
commit 32ae17cc9f
15 changed files with 175 additions and 434 deletions
+10 -82
View File
@@ -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=
+33 -27
View File
@@ -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 serverside app DB) still requires asking first.
Peruser 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 bindmounted 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 gitignored;
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
incode 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 bindmounted, never on a published port; tokens rely on
that isolation. Never hardcode tokens/hosts/paths in code.
### 1.6 Everything runs in Docker / dockercompose
@@ -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 bindmounted, no port). Forge is now
**multihost**; git auth sets an `http.extraheader` per forge.
- **Container mount constraint (multiple project dirs):** the container can only
scan host paths that are **bindmounted 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
bindmounted, no port). Forge is **multihost**; git auth sets an
`http.extraheader` per forge.
- **Mount constraint:** the container only sees host paths bindmounted 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 encryptionatrest (a key
would then need storing too).
+20
View File
@@ -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:** `<repo-list>` gained a search box (name/path, case-insensitive) and
"Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
+17 -83
View File
@@ -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})
+3
View File
@@ -116,6 +116,7 @@ class RepoList extends HTMLElement {
});
li.innerHTML = `
<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="spacer"></span>
${r.ahead ? `<span class="badge ahead">↑${r.ahead}</span>` : ''}
@@ -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 {
+2
View File
@@ -32,6 +32,8 @@ component pattern the rest of the UI follows.
`<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
- Uses shared design tokens (`--git-*`, `--surface-*`, `--radius*`) for all
+6 -58
View File
@@ -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 = `<p class="muted">No project directories configured.</p>`;
return;
}
box.replaceChildren(...dirs.map((d) => {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<label class="dir">
<input type="checkbox" ${d.enabled ? 'checked' : ''}>
<code>${this.#esc(d.path)}</code>
</label>`;
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 = `<p class="error">${this.#esc(err.message)}</p>`;
}
}
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); }
</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.
@@ -210,17 +170,6 @@ class SettingsPanel extends HTMLElement {
</div>
</section>
<section>
<h2>Project directories</h2>
<p class="hint">Directories to scan for repositories. Paths are inside the
container — they must be under a mounted directory (e.g. under <code>/repos</code>).</p>
<div id="dirs"><p class="muted">Loading…</p></div>
<div class="form">
<input id="d-path" type="text" class="grow" placeholder="/repos/some-folder">
<button id="d-add">Add directory</button>
</div>
</section>
<section>
<h2>Git identity</h2>
<p class="hint">Author used for commits the app makes.</p>
@@ -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();
}
+8 -10
View File
@@ -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:** `<settings-panel>`
- **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 `<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.
- 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.
+5 -5
View File
@@ -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"
+10 -19
View File
@@ -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
// 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"))
+10
View File
@@ -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) {
+21 -5
View File
@@ -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
+20 -47
View File
@@ -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
-92
View File
@@ -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
}
+9 -5
View File
@@ -53,18 +53,22 @@
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
</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 (it's
stored in a private database inside the app, not in files):
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>Project directories</strong> — the folders scanned for
repositories. Paths are inside the app's container (under a mounted folder
such as <code>/repos</code>); enable, disable, or remove them.</li>
<li><strong>Git identity</strong> — the name and email used for commits.</li>
</ul>