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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user