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. # 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).
# #
# NOTE: forges (URL + token), project directories, and the git identity now live # The ONLY thing configured in .env is the root directory that holds your
# in the config DATABASE (a private SQLite store, AGENT.md §1.3), managed in the # projects. Each project is a subdirectory of that root, and its forge is
# app's Settings. The GITEA_*, GIT_REPO_ROOTS, and GIT_USER_* values below are # detected from its git remote. Everything else — forge hosts + access tokens
# used ONLY to seed that DB the first time the app starts with an empty store; # and the git commit identity — is managed in the app's Settings (/settings) and
# after that, edit them in Settings (changing .env has no effect). # 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 # Host folder that holds your projects. docker-compose mounts it to /repos inside
# volume that is not bind-mounted or exposed, so credentials in it are only # the container (scanned as PROJECTS_ROOT). Example: C:/Users/you/Projects
# 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
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
# 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 - **Git stays the system of record for repository data** — never mirror repo
contents into another store. The one sanctioned datastore is the **config store** contents into another store. The one sanctioned datastore is the **config store**
(§1.5): a private SQLite DB holding *app configuration* (forge hosts + tokens, (§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. that (mirroring repo/PR data, a serverside app DB) still requires asking first.
Peruser UI state remains in browser `localStorage` (§4). 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 ### 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 - **`.env`the projects root, and nothing else.** The only value a user sets in
the DB: listen address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan `.env` is `REPOS_HOST_PATH`: the host directory that holds their projects, mounted
tuning (interval/depth/ignore/fetch), and the **config DB path**. A committed into the container as the single **projects root** (`PROJECTS_ROOT`, default
`.env.example` documents every variable; **never commit a real `.env`**. `/repos`). **Each project is a subdirectory of that root.** `.env` is gitignored;
- **Config store (`internal/store`, SQLite)** — the domain config that used to a committed `.env.example` documents it.
live in `.env`: **forge hosts + access tokens, the project directories to scan, - **Runtime bootstrap — compose/env defaults, not the `.env` file.** Listen
and the git commit identity.** Managed at runtime in the app's **Settings** address, TLS, `APP_ENV`, `LOG_FILE`, the `git` binary, scan tuning, the config
(`/settings`), not by editing files. On first run with an empty DB it is DB path, and `PROJECTS_ROOT` come from `docker-compose.yml`'s `environment:` and
**seeded** from the `.env` values (`GITEA_*`, `GIT_REPO_ROOTS`, `GIT_USER_*`); incode defaults — not from the user's `.env`.
after that those `.env` values are ignored. - **Config store (`internal/store`, SQLite)** — **forge hosts + access tokens** and
- **The DB must not be reachable outside the container.** It lives on a **private the **git commit identity**, managed at runtime in the app's **Settings**
named Docker volume** (`/data`) — never bindmounted into the project, never on a (`/settings`), not by editing files. A project's **forge is derived from its git
published port. Tokens are stored there relying on that isolation. Never hardcode remote** (matched to a configured forge) — not stored per project (§8.4). The DB
tokens/hosts/paths in code. 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 ### 1.6 Everything runs in Docker / dockercompose
@@ -227,7 +229,7 @@ without asking.
│ └── server/main.go # entrypoint: wire config, git, scanner, router │ └── server/main.go # entrypoint: wire config, git, scanner, router
├── internal/ ├── internal/
│ ├── config/ # bootstrap .env loading, typed config struct (§1.5) │ ├── 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) │ ├── 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)
@@ -456,6 +458,10 @@ later behind the same interface).
matching the forge features are simply absent (**graceful degradation**). The matching the forge features are simply absent (**graceful degradation**). The
service resolves a repo → provider by matching remotes (preferring `origin`) service resolves a repo → provider by matching remotes (preferring `origin`)
against the stored forges, caching a client per host. 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.
@@ -540,16 +546,16 @@ 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.)*
- ✅ **RESOLVED 2026-09-22:** **Domain config moved from `.env` to a private - ✅ **RESOLVED 2026-09-22:** **`.env` holds only the projects root; forges +
SQLite store** (`internal/store`, §1.5): forges+tokens, project directories, git identity live in a private SQLite store** (`internal/store`, §1.5), managed in
identity — managed in Settings (`/settings`), seeded from `.env` on first run. Settings. **Projects are the subdirectories** of the single root
DB on a private `/data` volume (not bindmounted, no port). Forge is now (`REPOS_HOST_PATH` → `/repos`); each project's **forge is derived from its git
**multihost**; git auth sets an `http.extraheader` per forge. remote** and shown next to its name. DB on a private `/data` volume (not
- **Container mount constraint (multiple project dirs):** the container can only bindmounted, no port). Forge is **multihost**; git auth sets an
scan host paths that are **bindmounted at `up` time**. Today `~/Projects` is `http.extraheader` per forge.
mounted to `/repos`, so project dirs added in Settings must resolve under a - **Mount constraint:** the container only sees host paths bindmounted at `up`
mounted base. A dir outside it needs a new compose mount — surface this if asked time. All projects must live under the mounted root (`REPOS_HOST_PATH`); a
to add such a path. project elsewhere needs its own compose mount + a widened/extra root.
- **Token encryption at rest:** tokens are stored plaintext in the private DB - **Token encryption at rest:** tokens are stored plaintext in the private DB
(isolation is the control). Confirm before adding encryptionatrest (a key (isolation is the control). Confirm before adding encryptionatrest (a key
would then need storing too). 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` 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). 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 ## 2026-09-20 — Slice 13: repo search + filtering
- **What:** `<repo-list>` gained a search box (name/path, case-insensitive) and - **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 "Dirty" / "Ahead/behind" filter chips with a "N of M" count. Filtering is
+17 -83
View File
@@ -6,7 +6,6 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -30,36 +29,6 @@ import (
"gitmanager/internal/store" "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() { func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@@ -81,38 +50,40 @@ func main() {
log.Info("git detected", "version", v) 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) st, err := store.Open(cfg.DBPath)
if err != nil { if err != nil {
log.Error("failed to open config store", "path", cfg.DBPath, "err", err) log.Error("failed to open config store", "path", cfg.DBPath, "err", err)
os.Exit(1) os.Exit(1)
} }
defer st.Close() defer st.Close()
seedStore(context.Background(), st, cfg, log)
// 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)
// The read-only scanner reads its roots fresh from the store each cycle, so // The scanner walks the single projects root; each subdirectory is a project.
// project-directory changes take effect without a restart (§5). // Each project's forge is derived from its git remote (svc.ForgeDisplay).
var svc *service.Service
scanCfg := func(ctx context.Context) repos.Config { scanCfg := func(ctx context.Context) repos.Config {
roots, err := st.EnabledRoots(ctx) return repos.Config{
if err != nil { Roots: []string{cfg.ProjectsRoot},
log.Warn("could not read project dirs", "err", err) 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) 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). // 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, st, log, scanner.RefreshRepo) svc = service.New(g, scanner.Index, feed, st, log, scanner.RefreshRepo)
// Configure git (identity + per-forge auth) from the store. svc.ApplyGitConfig(context.Background()) // git identity + per-forge auth from the store
svc.ApplyGitConfig(context.Background())
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 {
@@ -258,43 +229,6 @@ func main() {
return c.JSON(http.StatusOK, map[string]bool{"ok": true}) 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 { e.GET("/api/config/identity", func(c echo.Context) error {
name, email := svc.GitIdentity(c.Request().Context()) name, email := svc.GitIdentity(c.Request().Context())
return c.JSON(http.StatusOK, map[string]string{"name": name, "email": email}) 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 = ` 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>` : ''}
@@ -166,6 +167,8 @@ class RepoList extends HTMLElement {
li:hover { border-color: var(--border-strong); } li:hover { border-color: var(--border-strong); }
li.selected { border-color: var(--fill-accent); background: var(--surface-2); } li.selected { border-color: var(--fill-accent); background: var(--surface-2); }
.name { font-weight: 600; } .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); } .branch { color: var(--color-fg-muted); }
.spacer { margin-left: auto; } .spacer { margin-left: auto; }
.badge { .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. `<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, - 2026-09-20: added search + "Dirty"/"Ahead-behind" filter chips with a count,
persisted in localStorage; filtering is client-side (slice 13). 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
+6 -58
View File
@@ -15,7 +15,6 @@ class SettingsPanel extends HTMLElement {
connectedCallback() { connectedCallback() {
this.#renderShell(); this.#renderShell();
this.#loadForges(); this.#loadForges();
this.#loadDirs();
this.#loadIdentity(); 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 --------------------------------------------------------- // --- git identity ---------------------------------------------------------
async #loadIdentity() { async #loadIdentity() {
@@ -174,6 +128,8 @@ class SettingsPanel extends HTMLElement {
border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; } border-radius: var(--radius); padding: 16px 18px; margin-bottom: 16px; }
h2 { font-size: 15px; margin: 0 0 4px; } h2 { font-size: 15px; margin: 0 0 4px; }
.hint { color: var(--color-fg-muted); font-size: 13px; margin: 0 0 12px; } .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; .row { display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-top: 1px solid var(--border); } border-top: 1px solid var(--border); }
.row:first-of-type { border-top: none; } .row:first-of-type { border-top: none; }
@@ -197,6 +153,10 @@ class SettingsPanel extends HTMLElement {
.error { color: var(--color-danger); } .error { color: var(--color-danger); }
</style> </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> <section>
<h2>Forges</h2> <h2>Forges</h2>
<p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens. <p class="hint">Hosting servers (Gitea/Forgejo) and their access tokens.
@@ -210,17 +170,6 @@ class SettingsPanel extends HTMLElement {
</div> </div>
</section> </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> <section>
<h2>Git identity</h2> <h2>Git identity</h2>
<p class="hint">Author used for commits the app makes.</p> <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('f-add').onclick = () => this.#addForge();
this.shadowRoot.getElementById('d-add').onclick = () => this.#addDir();
this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity(); this.shadowRoot.getElementById('i-save').onclick = () => this.#saveIdentity();
} }
+8 -10
View File
@@ -1,30 +1,28 @@
# settings-panel # settings-panel
## Intent ## Intent
The UI for the config store (AGENT.md §1.3) — manage forge hosts + tokens, The UI for the config store (AGENT.md §1.5) — manage forge hosts + tokens and the
project directories to scan, and the git commit identity, replacing hand-editing git commit identity, replacing hand-editing `.env` for domain config. Projects
`.env` for domain config. Served at `/settings`. 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 ## Public surface
- **Tag:** `<settings-panel>` - **Tag:** `<settings-panel>`
- **Fetches:** `GET /api/config/forges`, `GET /api/config/project-dirs`, - **Fetches:** `GET /api/config/forges`, `GET /api/config/identity`.
`GET /api/config/identity`.
- **Writes:** - **Writes:**
- Forges: `POST /api/config/forges` (add), `DELETE /api/config/forges/:id`. - 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`. - Identity: `PUT /api/config/identity`.
- **Reports:** results via `toast` CustomEvents (needs `<toast-host>` on the page). - **Reports:** results via `toast` CustomEvents (needs `<toast-host>` on the page).
## History ## History
- 2026-09-22: created — settings UI for the SQLite config store (forges, project - 2026-09-22: created — settings UI for the SQLite config store (forges, project
dirs, git identity). 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 ## Notes / gotchas
- Tokens are **write-only** from the client: the server returns only `hasToken`, - 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. 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` - Adding/removing a forge reapplies git auth (the server sets an `http.extraheader`
per forge); changes take effect immediately. per forge); changes take effect immediately.
+5 -5
View File
@@ -18,11 +18,11 @@ 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
# SEED ONLY (first run): the scanner's roots now live in the config DB. # The single projects root inside the container (each subdirectory is a
# This is copied into the DB the first time the app starts with an empty DB. # project). The host folder is REPOS_HOST_PATH in .env, mounted here.
- GIT_REPO_ROOTS=/repos - PROJECTS_ROOT=/repos
# Config store (forges, project dirs, git identity) on the PRIVATE volume # Config store (forges + tokens, git identity) on the PRIVATE volume below —
# below — not bind-mounted into the project, no network port (§1.3). # not bind-mounted into the project, no network port (§1.3).
- GITMANAGER_DB=/data/gitmanager.db - GITMANAGER_DB=/data/gitmanager.db
ports: ports:
- "127.0.0.1:8080:8080" - "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) TLSCertFile string // PEM cert (e.g. an mkcert leaf trusted by the OS store)
TLSKeyFile string // PEM private key TLSKeyFile string // PEM private key
// DBPath is the SQLite config store (forges, project dirs, git identity). // DBPath is the SQLite config store (forges + tokens, git identity). It lives
// It lives on a private volume, not accessible outside the container (§1.3). // on a private volume, not accessible outside the container (§1.3).
DBPath string 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 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.
@@ -55,22 +52,16 @@ 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"), 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"))
+10
View File
@@ -66,6 +66,16 @@ type Provider interface {
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) {
+21 -5
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
} }
@@ -69,6 +70,9 @@ type Config struct {
MaxDepth int MaxDepth int
Ignore []string Ignore []string
Fetch bool 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. // ConfigFunc supplies the current scan configuration.
@@ -123,7 +127,7 @@ func (s *Scanner) Refresh(ctx context.Context) {
return return
default: 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 // 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. // 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, 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) rctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel() 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) 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
+20 -47
View File
@@ -9,7 +9,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -391,46 +390,6 @@ func (s *Service) DeleteForge(ctx context.Context, id int64) error {
return nil 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. // GitIdentity returns the configured commit identity.
func (s *Service) GitIdentity(ctx context.Context) (name, email string) { func (s *Service) GitIdentity(ctx context.Context) (name, email string) {
name, _ = s.store.GetSetting(ctx, "git_user_name", "") 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 return nil
} }
// ScanRoots returns the enabled project directories (for the scanner). // ForgeDisplay maps a repo's origin remote URL to a label for the forge it
func (s *Service) ScanRoots(ctx context.Context) []string { // belongs to: a configured forge's name (or its host) when the remote host
roots, err := s.store.EnabledRoots(ctx) // matches one, otherwise the bare remote host, otherwise "". Used by the scanner
if err != nil && s.log != nil { // to show each project's forge next to its name.
s.log.Warn("could not read project dirs", "err", err) 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 // 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"` 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. // Store wraps the SQLite connection.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
@@ -69,12 +61,6 @@ CREATE TABLE IF NOT EXISTS forges (
token TEXT NOT NULL DEFAULT '', token TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@@ -142,73 +128,6 @@ func (s *Store) DeleteForge(ctx context.Context, id int64) error {
return err 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) --------------------------------------------------------- // --- settings (kv) ---------------------------------------------------------
func (s *Store) GetSetting(ctx context.Context, key, def string) (string, error) { 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) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
return err 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> <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> <h2>Settings</h2>
<p> <p>
Open <a href="/settings">Settings</a> to manage your configuration (it's Open <a href="/settings">Settings</a> to manage your configuration (stored in
stored in a private database inside the app, not in files): a private database inside the app, not in files):
</p> </p>
<ul> <ul>
<li><strong>Forges</strong> — add your Git hosting servers (Gitea) and their <li><strong>Forges</strong> — add your Git hosting servers (Gitea) and their
access tokens. Tokens are saved securely and never shown again; to change access tokens. Tokens are saved securely and never shown again; to change
one, remove the forge and add it back.</li> 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> <li><strong>Git identity</strong> — the name and email used for commits.</li>
</ul> </ul>