From d63ffa1bec3f4c714bec69a7c9e6085aca05ae47 Mon Sep 17 00:00:00 2001 From: Elton Turing Date: Fri, 4 Sep 2026 19:26:15 +0200 Subject: [PATCH] =?UTF-8?q?Nimm=20weitere=20Git-Repos=20als=20Quellen=20in?= =?UTF-8?q?=20die=20Ablage=20=E2=80=94=20zuerst=20die=20info-Doku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KM_SOURCES=name=url@branch legt je Quelle einen Checkout unter /doku/ in der Ablage an. SilverBullet sieht die Seiten, der Index nimmt sie mit (typ doku, projekt = Name), und je Takt gehen Änderungen aus der Oberfläche als Commit ins Quell-Repo zurück, danach pull --rebase. Die Ablage ignoriert */doku/, damit die Wahrheit nur im Quell-Repo liegt. Vorerst komplett und ungefiltert (Andreas 04.09.: "sobald hive.corp steht, trennen wir die Quellen"). Doku in docs/quellen.md. Co-Authored-By: Claude Fable 5.1 --- deploy/compose.yml | 2 ++ deploy/env.example | 1 + docs/quellen.md | 42 +++++++++++++++++++++++ image/km/config.py | 22 ++++++++++++ image/km/gitrepo.py | 81 +++++++++++++++++++++++++++++---------------- image/km/index.py | 10 +++--- image/km/main.py | 7 ++-- image/km/runner.py | 14 ++++++-- image/km/server.py | 1 + 9 files changed, 144 insertions(+), 36 deletions(-) create mode 100644 docs/quellen.md diff --git a/deploy/compose.yml b/deploy/compose.yml index 385b410..b419b9d 100644 --- a/deploy/compose.yml +++ b/deploy/compose.yml @@ -41,6 +41,8 @@ services: KM_GITEA_PASSWORD: ${KM_GITEA_PASSWORD} KM_GITEA_ORGS: ${KM_GITEA_ORGS} KM_GITEA_REPOS: ${KM_GITEA_REPOS} + # weitere Git-Quellen als Checkout unter /doku/ (docs/quellen.md) + KM_SOURCES: ${KM_SOURCES} KM_INTERVAL: ${KM_INTERVAL} # Vektoren erst, wenn es einen Abnehmer gibt (Andreas 04.09.); die # Ollama-Instanz auf s18 ist vom LXC aus nur mit VLAN5-Route erreichbar. diff --git a/deploy/env.example b/deploy/env.example index cf9e3f1..12883e2 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -17,3 +17,4 @@ KM_INTERVAL=300 KM_EMBED=0 KM_OLLAMA_URL=http://10.18.5.30:11434 SB_USER=andreas:aus-openbao +KM_SOURCES=info=https://git.home/ahmann/info.git@master diff --git a/docs/quellen.md b/docs/quellen.md new file mode 100644 index 0000000..9039287 --- /dev/null +++ b/docs/quellen.md @@ -0,0 +1,42 @@ +# Quellen — fremde Git-Repos als Checkout in der Ablage + +Festlegung Andreas, 2026-09-04: die `info`-Doku kommt als zweite Quelle in +km — **vorerst komplett**, ohne Filter nach Klassifikation. *„Sobald +hive.corp steht, trennen wir die Quellen."* Konfiguration `KM_SOURCES`, Code +in `gitrepo.py` (`sources_of`) und `runner.py`. + +## Bauform + +Jede Quelle ist ein eigener Git-Checkout **in** der Ablage: + +``` +//doku// ← z. B. familie/doku/info/ = Clone von ahmann/info +``` + +- SilverBullet sieht die Seiten wie alle anderen; der Index nimmt sie mit + (`typ: doku`, `projekt: `, `classification` aus dem Frontmatter). +- Die Ablage ignoriert `*/doku/` in ihrer `.gitignore`: die Wahrheit dieser + Seiten liegt im Quell-Repo, nicht ein zweites Mal in `ahmann/km`. +- **Je Takt:** Änderungen aus der Oberfläche werden im Checkout committet + („km: Änderungen aus der Oberfläche", Autor `KM_GIT_NAME`) und **in das + Quell-Repo gepusht**, danach `git pull --rebase`. Ein Commit aus + SilverBullet landet also Minuten später auf `master` von `ahmann/info` — + ohne PR und ohne Umlaut-Riegel. Das ist gewollt (Auto-Commit und Push). + +## Konfiguration + +``` +KM_SOURCES=info=https://git.home/ahmann/info.git@master +``` + +`name=url[@branch]`, mehrere durch Komma. Zugang: dieselben `KM_GIT_USER`/ +`KM_GIT_PASSWORD` wie für die Ablage; die `.netrc` bekommt jeden Remote-Host. + +## Was Andreas wissen sollte + +- **Alles ist drin**, auch `corp`- und `lan`-Seiten — bis die Quellen + getrennt werden, ist die Familien-Oberfläche damit nicht nur Familie. +- **Admonitions** (`!!! warning`) aus MkDocs zeigt SilverBullet als Rohtext + (Test vom 04.09., spine/core#8). +- Der Checkout enthält das ganze Repo (`ai/`, `deployment/`, …), nicht nur + `docs/`; indexiert werden nur `*.md`. diff --git a/image/km/config.py b/image/km/config.py index c2971ef..6c911f9 100644 --- a/image/km/config.py +++ b/image/km/config.py @@ -9,6 +9,7 @@ und nichts tut, ist teurer als einer, der gar nicht startet (spine/images). from __future__ import annotations import os +import re from dataclasses import dataclass, field from pathlib import Path @@ -21,6 +22,19 @@ def _clean(v: str | None) -> str: return "" if v.startswith("${") else v +def _sources(v: str) -> list[tuple[str, str, str]]: + out = [] + for item in _list(v): + if "=" not in item: + raise SystemExit(f"km: KM_SOURCES: '{item}' braucht die Form name=url[@branch]") + name, rest = item.split("=", 1) + url, _, branch = rest.rpartition("@") if "@" in rest.split("/")[-1] else (rest, "", "") + if not re.fullmatch(r"[A-Za-z0-9_.-]+", name): + raise SystemExit(f"km: KM_SOURCES: Name '{name}' ungueltig") + out.append((name, url, branch or "main")) + return out + + def _list(v: str) -> list[str]: return [x.strip() for x in _clean(v).replace(";", ",").split(",") if x.strip()] @@ -41,6 +55,11 @@ class Config: gitea_password: str | None gitea_orgs: list[str] = field(default_factory=list) gitea_repos: list[str] = field(default_factory=list) + # weitere Quellen als eigene Git-Checkouts unter /doku/, + # komplett und ungefiltert (Andreas 04.09.: "vorerst alles komplett rein, + # sobald hive.corp steht, trennen wir die Quellen"). + # "info=https://git.home/ahmann/info.git@master" + sources: list[tuple[str, str, str]] = field(default_factory=list) interval: int = 300 embed: bool = False ollama_url: str = "http://10.18.5.30:11434" @@ -74,6 +93,7 @@ class Config: gitea_password=e("KM_GITEA_PASSWORD") or None, gitea_orgs=_list(e("KM_GITEA_ORGS", "")), gitea_repos=_list(e("KM_GITEA_REPOS", "")), + sources=_sources(e("KM_SOURCES", "")), interval=int(e("KM_INTERVAL", "300")), embed=e("KM_EMBED", "0").lower() in ("1", "true", "yes", "ja"), ollama_url=e("KM_OLLAMA_URL", "http://10.18.5.30:11434"), @@ -86,6 +106,8 @@ class Config: missing.append("KM_NAMESPACE (z. B. familie oder 42i)") if cfg.git_remote and cfg.git_remote.startswith("http") and not (cfg.git_user and cfg.git_password): missing.append("KM_GIT_USER und KM_GIT_PASSWORD (Push an KM_GIT_REMOTE)") + if any(u.startswith("http") for _, u, _ in cfg.sources) and not (cfg.git_user and cfg.git_password): + missing.append("KM_GIT_USER und KM_GIT_PASSWORD (Quellen in KM_SOURCES)") if cfg.gitea_url and not (cfg.gitea_user and cfg.gitea_password): missing.append("KM_GITEA_USER und KM_GITEA_PASSWORD (Ingest von KM_GITEA_URL)") if cfg.gitea_url and not (cfg.gitea_orgs or cfg.gitea_repos): diff --git a/image/km/gitrepo.py b/image/km/gitrepo.py index c1c6d78..54ed321 100644 --- a/image/km/gitrepo.py +++ b/image/km/gitrepo.py @@ -26,65 +26,82 @@ _REJECTED = re.compile(r"\b(rejected|non-fast-forward|fetch first|behind)\b", re class Ablage: - def __init__(self, cfg: Config) -> None: + """Ein Git-Checkout, den km fuehrt: die Ablage selbst oder eine Quelle + (z. B. die info-Doku), die unter /quellen/ eingehaengt ist und + ihr eigenes Remote behaelt -- Aenderungen aus der Oberflaeche gehen dort + zurueck, nicht in eine Kopie.""" + + def __init__(self, cfg: Config, path: Path | None = None, remote: str | None = None, + branch: str | None = None, name: str = "ablage") -> None: self.cfg = cfg + self.name = name + self.path = path or cfg.ablage + self.remote = cfg.git_remote if path is None else remote + self.branch = branch or cfg.git_branch self.lock = threading.Lock() self.last_push_error: str | None = None self.last_push: float | None = None # ------------------------------------------------------------ Hilfen def _git(self, *args: str, check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run(["git", *args], cwd=self.cfg.ablage, check=check, + return subprocess.run(["git", *args], cwd=self.path, check=check, capture_output=True, text=True) def write_netrc(self) -> None: - if not (self.cfg.git_remote and self.cfg.git_user and self.cfg.git_password): - return - host = urlparse(self.cfg.git_remote).hostname - if not host: + """Zugang fuer jeden Remote-Host (Ablage und Quellen) in ~/.netrc, 0600.""" + if not (self.cfg.git_user and self.cfg.git_password): return + hosts = {urlparse(u).hostname for u in [self.cfg.git_remote, *[src[1] for src in self.cfg.sources]] if u} netrc = Path.home() / ".netrc" - line = f"machine {host} login {self.cfg.git_user} password {self.cfg.git_password}\n" existing = netrc.read_text() if netrc.exists() else "" - if f"machine {host} " not in existing: - netrc.write_text(existing + line) + for host in sorted(h for h in hosts if h): + if f"machine {host} " not in existing: + existing += f"machine {host} login {self.cfg.git_user} password {self.cfg.git_password}\n" + netrc.write_text(existing) netrc.chmod(0o600) # ------------------------------------------------------------ Lebenszyklus def ensure(self) -> None: """Checkout vorhanden machen: klonen, sonst init -- nie stillschweigend leer.""" - a = self.cfg.ablage + a = self.path if not (a / ".git").exists(): # Kein clone: das Verzeichnis existiert oft schon, weil SilverBullet # dasselbe Volume mountet und dort seine Dateien anlegt, bevor km # dran ist. Also in place initialisieren und den Remote-Stand holen. a.mkdir(parents=True, exist_ok=True) - self._git("init", "-q", "-b", self.cfg.git_branch) - if self.cfg.git_remote: - self._git("remote", "add", "origin", self.cfg.git_remote) + self._git("init", "-q", "-b", self.branch) + if self.remote: + self._git("remote", "add", "origin", self.remote) r = self._git("fetch", "-q", "origin", check=False) if r.returncode != 0: - raise SystemExit(f"km: fetch von {self.cfg.git_remote} fehlgeschlagen: {r.stderr.strip()[:300]}") - has_branch = self._git("rev-parse", "--verify", "-q", f"origin/{self.cfg.git_branch}", check=False).returncode == 0 + raise SystemExit(f"km: fetch von {self.remote} fehlgeschlagen: {r.stderr.strip()[:300]}") + has_branch = self._git("rev-parse", "--verify", "-q", f"origin/{self.branch}", check=False).returncode == 0 if has_branch: - r = self._git("checkout", "-q", "-B", self.cfg.git_branch, f"origin/{self.cfg.git_branch}", check=False) + r = self._git("checkout", "-q", "-B", self.branch, f"origin/{self.branch}", check=False) if r.returncode != 0: raise SystemExit(f"km: checkout kollidiert mit vorhandenen Dateien: {r.stderr.strip()[:300]}") # sonst: leeres Remote, der erste Push legt den Branch an self._git("config", "user.name", self.cfg.git_name) self._git("config", "user.email", self.cfg.git_email) - # Was die Oberflaeche fuer sich selbst ablegt, gehoert nicht in die Wahrheit. + if self.name != "ablage": + return + # Was die Oberflaeche fuer sich selbst ablegt, gehoert nicht in die + # Wahrheit; die Quellen haben ihr eigenes Remote und werden hier ignoriert. gi = a / ".gitignore" - wanted = ".silverbullet*\n_plug/\n" - if not gi.exists() or wanted not in gi.read_text(): - gi.write_text((gi.read_text() if gi.exists() else "") + wanted) + existing = gi.read_text() if gi.exists() else "" + # */doku/ sind die Quellen-Checkouts -- deren Wahrheit liegt im + # jeweiligen Remote (z. B. ahmann/info), nicht ein zweites Mal hier. + for line in (".silverbullet*", "_plug/", "*/doku/"): + if line not in existing.splitlines(): + existing += line + "\n" + gi.write_text(existing) def pull(self) -> None: - if not self.cfg.git_remote: + if not self.remote: return - r = self._git("pull", "--rebase", "--autostash", "origin", self.cfg.git_branch, check=False) + r = self._git("pull", "--rebase", "--autostash", "origin", self.branch, check=False) if r.returncode != 0 and "couldn't find remote ref" not in r.stderr: - print(f"km: pull: {r.stderr.strip()[:300]}", flush=True) + print(f"km: pull {self.name}: {r.stderr.strip()[:300]}", flush=True) def commit_all(self, message: str) -> bool: """Alles Geaenderte einliefern (auch Aenderungen aus SilverBullet). True, wenn ein Commit entstand.""" @@ -96,19 +113,19 @@ class Ablage: return True def push(self) -> None: - if not self.cfg.git_remote: + if not self.remote: return for _ in range(3): - r = self._git("push", "-u", "origin", self.cfg.git_branch, check=False) + r = self._git("push", "-u", "origin", self.branch, check=False) if r.returncode == 0: self.last_push_error = None self.last_push = time.time() return if not _REJECTED.search(r.stderr): self.last_push_error = r.stderr.strip()[:300] - print(f"km: push fehlgeschlagen: {self.last_push_error}", flush=True) + print(f"km: push {self.name} fehlgeschlagen: {self.last_push_error}", flush=True) return - self._git("pull", "--rebase", "origin", self.cfg.git_branch, check=False) + self._git("pull", "--rebase", "origin", self.branch, check=False) self.last_push_error = "push nach 3 Versuchen abgelehnt" print(f"km: {self.last_push_error}", flush=True) @@ -117,5 +134,13 @@ class Ablage: return r.stdout.strip() or None def status(self) -> dict: - return {"head": self.head(), "remote": bool(self.cfg.git_remote), + return {"head": self.head(), "remote": bool(self.remote), "letzter_push": self.last_push, "push_fehler": self.last_push_error} + + +def sources_of(cfg: Config) -> list[Ablage]: + """Die Quellen als Checkouts unter /doku/ in der Ablage: die + Oberflaeche sieht sie, km zieht sie je Takt und liefert Aenderungen aus + der Oberflaeche in ihr eigenes Remote ein.""" + return [Ablage(cfg, cfg.ablage / cfg.namespace / "doku" / name, url, branch, name=name) + for name, url, branch in cfg.sources] diff --git a/image/km/index.py b/image/km/index.py index 06c8224..2dabad1 100755 --- a/image/km/index.py +++ b/image/km/index.py @@ -110,17 +110,19 @@ def derive_title(body: str, fallback: str) -> str: return m.group(1).strip() if m else fallback -def doc_fields(rel: str, meta: dict[str, str], body: str, default_ns: str) -> dict: +def doc_fields(rel: str, meta: dict[str, str], body: str, default_ns: str, default_cls: str = "lan") -> dict: parts = rel.split("/") namespace = meta.get("namespace") or (parts[0] if len(parts) > 1 else default_ns) typ = meta.get("typ") or meta.get("type") or ("vorgang" if meta.get("quelle") == "gitea" else "doku") projekt = meta.get("projekt") or meta.get("project") if not projekt and typ == "vorgang" and meta.get("repo"): projekt = meta["repo"].split("/")[0] + if not projekt and len(parts) > 2 and parts[1] == "doku": + projekt = parts[2] # /doku//… (Quellen-Checkout) return { "namespace": namespace, "projekt": projekt or None, - "classification": meta.get("classification") or "lan", + "classification": meta.get("classification") or default_cls, "typ": typ, "zustand": meta.get("zustand") or meta.get("state"), "titel": meta.get("titel") or meta.get("title") or derive_title(body, rel), @@ -222,7 +224,7 @@ def sha256(data: bytes) -> str: def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str, - max_chars: int, overlap: int) -> bool: + max_chars: int, overlap: int, default_cls: str = "lan") -> bool: rel = f.relative_to(ablage).as_posix() raw = f.read_bytes() digest = sha256(raw) @@ -230,7 +232,7 @@ def index_file(con: sqlite3.Connection, ablage: Path, f: Path, default_ns: str, if row and row[0] == digest: return False meta, body = parse_frontmatter(raw.decode("utf-8", errors="replace")) - fields = doc_fields(rel, meta, body, default_ns) + fields = doc_fields(rel, meta, body, default_ns, default_cls) now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) with con: old_ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE path=?", (rel,))] diff --git a/image/km/main.py b/image/km/main.py index dc86f10..d1c7c34 100644 --- a/image/km/main.py +++ b/image/km/main.py @@ -7,7 +7,7 @@ import sys import threading from config import Config -from gitrepo import Ablage +from gitrepo import Ablage, sources_of from runner import Runner from server import Api, serve @@ -17,9 +17,12 @@ def main() -> None: ablage = Ablage(cfg) ablage.write_netrc() ablage.ensure() + for src in sources_of(cfg): + src.ensure() runner = Runner(cfg, ablage) print(f"km: namespace={cfg.namespace} ablage={cfg.ablage} remote={'ja' if cfg.git_remote else 'nein'} " - f"gitea={cfg.gitea_url or '-'} takt={cfg.interval}s embed={'an' if cfg.embed else 'aus'}", + f"gitea={cfg.gitea_url or '-'} quellen={','.join(n for n, _, _ in cfg.sources) or '-'} " + f"takt={cfg.interval}s embed={'an' if cfg.embed else 'aus'}", file=sys.stderr, flush=True) threading.Thread(target=runner.loop, name="takt", daemon=True).start() serve(cfg, Api(cfg, ablage, runner)) diff --git a/image/km/runner.py b/image/km/runner.py index c33e73f..195cce2 100644 --- a/image/km/runner.py +++ b/image/km/runner.py @@ -15,7 +15,7 @@ import traceback import index as km_index from config import Config -from gitrepo import Ablage +from gitrepo import Ablage, sources_of from ingest_gitea import Forge, ingest_repo, load_state, save_state @@ -23,6 +23,7 @@ class Runner: def __init__(self, cfg: Config, ablage: Ablage) -> None: self.cfg = cfg self.ablage = ablage + self.sources = sources_of(cfg) self.lock = threading.Lock() self.last: dict = {"zeit": None, "dauer": None, "fehler": None, "ergebnis": None} self._wake = threading.Event() @@ -36,6 +37,15 @@ class Runner: result: dict = {} try: self.ablage.pull() + # Quellen: erst Aenderungen aus der Oberflaeche in ihr Remote, + # dann ziehen -- die Wahrheit einer info-Seite bleibt in info. + for src in self.sources: + if src.commit_all("km: Änderungen aus der Oberfläche"): + src.push() + result[f"commit_{src.name}"] = src.head() + src.pull() + if src.last_push_error: + result[f"push_fehler_{src.name}"] = src.last_push_error result["ingest"] = self._ingest(full) result["index"] = self._index() if self.ablage.commit_all(self._message(result)): @@ -81,7 +91,7 @@ class Runner: con = km_index.connect(c.db) try: files = sorted(p for p in c.ablage.rglob("*.md") if not any(s.startswith(".") for s in p.parts)) - changed = sum(km_index.index_file(con, c.ablage, f, c.namespace, 600, 60) for f in files) + changed = sum(km_index.index_file(con, c.ablage, f, c.namespace, 600, 60, c.classification) for f in files) gone = km_index.remove_missing(con, c.ablage) out = {"dateien": len(files), "geaendert": changed, "entfernt": gone} if c.embed and changed: diff --git a/image/km/server.py b/image/km/server.py index 12c360d..23597c8 100644 --- a/image/km/server.py +++ b/image/km/server.py @@ -67,6 +67,7 @@ class Api: last = self.runner.last return {"ok": last.get("fehler") is None, "namespace": self.cfg.namespace, "dokumente": docs, "eingebettet": embedded, "git": self.ablage.status(), + "quellen": {s.name: s.status() for s in self.runner.sources}, "letzter_lauf": last} def search(self, a: _Args) -> list[dict]: