Nimm weitere Git-Repos als Quellen in die Ablage — zuerst die info-Doku

KM_SOURCES=name=url@branch legt je Quelle einen Checkout unter
<ns>/doku/<name> 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:26:15 +02:00
co-authored by Claude Fable 5.1
parent f133cdd869
commit d63ffa1bec
9 changed files with 144 additions and 36 deletions
+2
View File
@@ -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 <ns>/doku/<name> (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.
+1
View File
@@ -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
+42
View File
@@ -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:
```
<ablage>/<ns>/doku/<name>/ ← z. B. familie/doku/info/ = Clone von ahmann/info
```
- SilverBullet sieht die Seiten wie alle anderen; der Index nimmt sie mit
(`typ: doku`, `projekt: <name>`, `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`.
+22
View File
@@ -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 <ns>/doku/<name>,
# 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):
+53 -28
View File
@@ -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 <ns>/quellen/<name> 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 <ns>/doku/<name> 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]
+6 -4
View File
@@ -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] # <ns>/doku/<name>/… (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,))]
+5 -2
View File
@@ -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))
+12 -2
View File
@@ -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:
+1
View File
@@ -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]: