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:
+10
-82
@@ -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=
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -53,18 +53,22 @@
|
||||
<li><strong>Ahead / behind</strong> — commits your branch leads or trails its upstream by.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Projects & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user