Spaces:
Sleeping
Sleeping
Download app/services/wiki_git.py from dillguill/knowledge-assistant: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/dillguill/knowledge-assistant/resolve/main/app/services/wiki_git.py
- Command line
-
hf download hf://spaces/dillguill/knowledge-assistant/app/services/wiki_git.py
-
curl -L -o wiki_git.py https://huggingface.co/spaces/dillguill/knowledge-assistant/resolve/main/app/services/wiki_git.py
15.4 kB
| """Git-native storage for wiki pages. | |
| A local git working tree under `<data_dir>/wiki_git/` holds one markdown file | |
| per page (`wiki/<folder-path>/<slug>.md`, frontmatter + body). All git access | |
| goes through plain `git` subprocess calls β no new pip dependency, `git` is | |
| already on the box for the Space's own deploy. This module knows nothing | |
| about SQLite; `app/db/wiki_store.py` orchestrates calls into it after its own | |
| transactions have already committed (see that module's `_sync_page_to_git`). | |
| """ | |
| import re | |
| import subprocess | |
| from pathlib import Path | |
| GIT_AUTHOR_NAME = "Knowledge Assistant" | |
| GIT_AUTHOR_EMAIL = "assistant@knowledge-assistant.local" | |
| # indirection point so tests can monkeypatch without shelling out for real, | |
| # matching the convention already used by app/services/sync.py's | |
| # _snapshot_download/_upload_folder. | |
| _run_git = subprocess.run | |
| class GitCommitError(RuntimeError): | |
| """Raised when a git subprocess operation fails.""" | |
| def _repo_dir(data_dir: str) -> Path: | |
| return Path(data_dir) / "wiki_git" | |
| def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess: | |
| try: | |
| return _run_git(["git", *args], cwd=str(cwd), capture_output=True, text=True) | |
| except OSError as exc: | |
| raise GitCommitError(str(exc)) from exc | |
| def _current_head(repo: Path) -> str | None: | |
| result = _git(["rev-parse", "HEAD"], repo) | |
| if result.returncode != 0: | |
| return None | |
| return result.stdout.strip() | |
| def ensure_repo(data_dir: str) -> Path: | |
| """Idempotently create/open the wiki git working tree with a fixed | |
| committer identity (no per-user git identity β see wiki-consideration.md).""" | |
| repo = _repo_dir(data_dir) | |
| repo.mkdir(parents=True, exist_ok=True) | |
| if not (repo / ".git").is_dir(): | |
| result = _git(["init", "-b", "main"], repo) | |
| if result.returncode != 0: | |
| raise GitCommitError(result.stderr or result.stdout) | |
| _git(["config", "user.name", GIT_AUTHOR_NAME], repo) | |
| _git(["config", "user.email", GIT_AUTHOR_EMAIL], repo) | |
| return repo | |
| # --- frontmatter --- | |
| _FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?(.*)\Z", re.DOTALL) | |
| def _quote(value: str) -> str: | |
| escaped = value.replace("\\", "\\\\").replace('"', '\\"') | |
| return f'"{escaped}"' | |
| def _unquote(value: str) -> str: | |
| if len(value) >= 2 and value[0] == '"' and value[-1] == '"': | |
| return value[1:-1].replace('\\"', '"').replace("\\\\", "\\") | |
| return value | |
| def render_frontmatter(title: str, slug: str, created_at: str, updated_at: str) -> str: | |
| lines = [ | |
| "---", | |
| f"title: {_quote(title)}", | |
| f"slug: {_quote(slug)}", | |
| f"created_at: {_quote(created_at)}", | |
| f"updated_at: {_quote(updated_at)}", | |
| "---", | |
| "", | |
| ] | |
| return "\n".join(lines) | |
| def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: | |
| """Parse a `---`-delimited frontmatter block this module itself wrote. | |
| Not a general YAML parser β we only ever read back what render_frontmatter | |
| wrote, so a small hand-rolled `key: "value"` parser is sufficient and | |
| avoids a new pip dependency for a schema this small. | |
| """ | |
| match = _FRONTMATTER_RE.match(text) | |
| if match is None: | |
| return {}, text | |
| block, body = match.groups() | |
| fields: dict[str, str] = {} | |
| for line in block.splitlines(): | |
| if not line.strip(): | |
| continue | |
| key, _, raw = line.partition(":") | |
| fields[key.strip()] = _unquote(raw.strip()) | |
| return fields, body | |
| # --- commit / delete --- | |
| def commit_page( | |
| *, | |
| data_dir: str, | |
| folder_path_parts: list[str], | |
| slug: str, | |
| title: str, | |
| content: str, | |
| created_at: str, | |
| updated_at: str, | |
| author: str, | |
| note: str = "", | |
| old_relative_path: str | None = None, | |
| ) -> tuple[str, str]: | |
| """Write (or relocate) a page's markdown file and commit it. | |
| No-op-safe: if the target file already holds identical frontmatter+content | |
| at the same path, returns the current HEAD sha without creating an empty | |
| commit β this is what lets a boot-time reconcile pass call this | |
| unconditionally without spamming history. | |
| """ | |
| repo = ensure_repo(data_dir) | |
| new_relative_path = "/".join(["wiki", *folder_path_parts, f"{slug}.md"]) | |
| full_path = repo / new_relative_path | |
| full_path.parent.mkdir(parents=True, exist_ok=True) | |
| file_text = render_frontmatter(title, slug, created_at, updated_at) + content | |
| unchanged = ( | |
| old_relative_path in (None, new_relative_path) | |
| and full_path.exists() | |
| and full_path.read_text(encoding="utf-8") == file_text | |
| ) | |
| if old_relative_path and old_relative_path != new_relative_path: | |
| old_full = repo / old_relative_path | |
| if old_full.exists(): | |
| old_full.unlink() | |
| full_path.write_text(file_text, encoding="utf-8") | |
| if unchanged: | |
| head = _current_head(repo) | |
| if head is not None: | |
| return head, new_relative_path | |
| add_result = _git(["add", "-A"], repo) | |
| if add_result.returncode != 0: | |
| raise GitCommitError(add_result.stderr or add_result.stdout) | |
| message = f"{note or f'Update {title}'}\n\nwiki-author: {author}\nwiki-slug: {slug}\n" | |
| commit_result = _git(["commit", "-m", message], repo) | |
| if commit_result.returncode != 0: | |
| combined = commit_result.stdout + commit_result.stderr | |
| if "nothing to commit" not in combined: | |
| raise GitCommitError(combined) | |
| sha = _current_head(repo) | |
| if sha is None: | |
| raise GitCommitError("commit succeeded but HEAD could not be resolved") | |
| return sha, new_relative_path | |
| def write_page_file( | |
| data_dir: str, | |
| folder_path_parts: list[str], | |
| slug: str, | |
| title: str, | |
| content: str, | |
| created_at: str, | |
| updated_at: str, | |
| ) -> str: | |
| """Write a page's frontmatter+content file without committing. Used by | |
| the one-time migration script, which stages every page before a single | |
| combined commit (see commit_all) rather than one commit per page.""" | |
| repo = ensure_repo(data_dir) | |
| relative_path = "/".join(["wiki", *folder_path_parts, f"{slug}.md"]) | |
| full_path = repo / relative_path | |
| full_path.parent.mkdir(parents=True, exist_ok=True) | |
| file_text = render_frontmatter(title, slug, created_at, updated_at) + content | |
| full_path.write_text(file_text, encoding="utf-8") | |
| return relative_path | |
| def commit_all(data_dir: str, message: str) -> str: | |
| """Stage and commit every currently-written file in one commit β used by | |
| the one-time migration script for its single combined migration commit.""" | |
| repo = ensure_repo(data_dir) | |
| add_result = _git(["add", "-A"], repo) | |
| if add_result.returncode != 0: | |
| raise GitCommitError(add_result.stderr or add_result.stdout) | |
| commit_result = _git(["commit", "-m", message], repo) | |
| if commit_result.returncode != 0: | |
| combined = commit_result.stdout + commit_result.stderr | |
| if "nothing to commit" not in combined: | |
| raise GitCommitError(combined) | |
| sha = _current_head(repo) | |
| if sha is None: | |
| raise GitCommitError("commit succeeded but HEAD could not be resolved") | |
| return sha | |
| def has_commits(data_dir: str) -> bool: | |
| """Whether the wiki git repo already has at least one commit β used by | |
| the migration script's idempotency guard.""" | |
| repo = ensure_repo(data_dir) | |
| return _current_head(repo) is not None | |
| def _extract_trailer(body: str, key: str) -> str | None: | |
| prefix = f"{key}: " | |
| for line in body.splitlines(): | |
| if line.startswith(prefix): | |
| return line[len(prefix):].strip() | |
| return None | |
| def log_for_page(data_dir: str, slug: str) -> list[dict]: | |
| """Commit history for a page, identified by its immutable slug (embedded | |
| in each commit's frontmatter), newest first. Each entry's "path" is the | |
| file's path AS OF that commit β a page's current path may differ if it's | |
| been moved since, and content_at_commit needs the historical path, not | |
| today's. | |
| Deliberately does NOT use git's --follow/rename-similarity detection. | |
| Two distinct pages with short, near-identical frontmatter (differing | |
| only in title/slug) can exceed git's default ~50% similarity threshold, | |
| making --follow spuriously treat one page's creation as a "rename" of a | |
| completely unrelated page β a real bug caught while building this | |
| (restoring page B could return page A's content). Slug identity is the | |
| correct correlation key instead: it's immutable and globally unique | |
| (see SCHEMA.md), embedded in the file itself, and unaffected by however | |
| similar or different two pages' content happens to look. | |
| """ | |
| repo = ensure_repo(data_dir) | |
| result = _git( | |
| ["log", "--name-only", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s"], | |
| repo, | |
| ) | |
| if result.returncode != 0: | |
| return [] | |
| entries: list[dict] = [] | |
| for record in result.stdout.split("\x1e"): | |
| if not record.strip(): | |
| continue | |
| header, _, filename_block = record.partition("\n\n") | |
| sha, _, rest = header.partition("\x1f") | |
| author_name, _, rest = rest.partition("\x1f") | |
| iso_date, _, subject = rest.partition("\x1f") | |
| # Page files only ever live under wiki/ (see commit_page) β a real | |
| # production repo's history also includes pre-existing whole-tree- | |
| # sync commits touching binary files (a sqlite db, PDFs) outside | |
| # wiki/ entirely, from before the wiki-git migration. `git show`ing | |
| # one of those and decoding it as UTF-8 text crashes outright, so | |
| # non-wiki paths must never be attempted in the first place. | |
| changed_paths = [ | |
| line for line in filename_block.splitlines() | |
| if line.strip() and line.startswith("wiki/") | |
| ] | |
| matched_path = None | |
| for path in changed_paths: | |
| show_result = _git(["show", f"{sha}:{path}"], repo) | |
| if show_result.returncode != 0: | |
| continue | |
| fields, _ = parse_frontmatter(show_result.stdout) | |
| if fields.get("slug") == slug: | |
| matched_path = path | |
| break | |
| if matched_path is None: | |
| continue | |
| body_result = _git(["show", "-s", "--format=%B", sha], repo) | |
| body = body_result.stdout if body_result.returncode == 0 else "" | |
| author = _extract_trailer(body, "wiki-author") or author_name | |
| entries.append({ | |
| "sha": sha, | |
| "author": author, | |
| "note": subject, | |
| "created_at": iso_date, | |
| "path": matched_path, | |
| }) | |
| return entries | |
| def content_at_commit(data_dir: str, path: str, sha: str) -> str | None: | |
| """Body content (frontmatter stripped) of a page's file as of a specific | |
| commit. `path` must be the file's path AS OF that commit β see | |
| log_for_page's "path" field. Returns None if the sha/path is unknown.""" | |
| repo = ensure_repo(data_dir) | |
| result = _git(["show", f"{sha}:{path}"], repo) | |
| if result.returncode != 0: | |
| return None | |
| _, body = parse_frontmatter(result.stdout) | |
| return body | |
| # --- remote sync (HF dataset repo) --- | |
| def remote_url(token: str, dataset_repo: str) -> str: | |
| """Authenticated HTTPS git remote URL for the HF dataset repo. | |
| Uses the real account username embedded in dataset_repo's "owner/name" | |
| form, not a placeholder β HF's git-auth docs | |
| (huggingface.co/blog/password-git-deprecation) are explicit that the | |
| username field must be the actual account name, confirmed directly | |
| against current docs rather than assumed. | |
| """ | |
| username = dataset_repo.split("/", 1)[0] | |
| return f"https://{username}:{token}@huggingface.co/datasets/{dataset_repo}" | |
| def ensure_remote(data_dir: str, url: str) -> None: | |
| """Idempotently point the wiki git repo's "origin" at `url`, updating it | |
| every call β a rotated HF_TOKEN changes the embedded credential, so this | |
| must not skip once "origin" already exists.""" | |
| repo = ensure_repo(data_dir) | |
| result = _git(["remote", "get-url", "origin"], repo) | |
| if result.returncode != 0: | |
| _git(["remote", "add", "origin", url], repo) | |
| else: | |
| _git(["remote", "set-url", "origin", url], repo) | |
| def push(data_dir: str) -> None: | |
| """Push local commits to whatever "origin" is currently configured to. | |
| No --force: a force-push would silently discard whatever the remote has | |
| that we don't. Instead, a rejected (non-fast-forward) push gets exactly | |
| one bounded retry β fetch, rebase local commits on top, push again β | |
| covering the residual case of a brief overlap between writers (e.g. two | |
| Space instances momentarily coexisting during a restart/redeploy) | |
| despite the single-writer invariant. Not a substitute for keeping | |
| logically independent write paths on separate branches (see sync.py) β | |
| just defense-in-depth for the one case separate branches don't remove. | |
| If the rebase itself conflicts, it's aborted cleanly and the failure is | |
| raised rather than papered over. | |
| """ | |
| repo = _repo_dir(data_dir) | |
| result = _git(["push", "origin", "main"], repo) | |
| if result.returncode == 0: | |
| return | |
| fetch_result = _git(["fetch", "origin"], repo) | |
| if fetch_result.returncode != 0: | |
| raise GitCommitError(result.stderr or result.stdout) | |
| rebase_result = _git(["rebase", "origin/main"], repo) | |
| if rebase_result.returncode != 0: | |
| _git(["rebase", "--abort"], repo) | |
| raise GitCommitError( | |
| f"push rejected and could not be auto-resolved: " | |
| f"{rebase_result.stderr or rebase_result.stdout}" | |
| ) | |
| retry_result = _git(["push", "origin", "main"], repo) | |
| if retry_result.returncode != 0: | |
| raise GitCommitError(retry_result.stderr or retry_result.stdout) | |
| def pull_or_clone(data_dir: str) -> None: | |
| """Fetch "origin" and hard-reset local main to it if the remote has any | |
| history; a brand-new empty remote is a no-op (first boot), mirroring | |
| sync.pull()'s existing tolerance for a not-yet-populated dataset repo.""" | |
| repo = _repo_dir(data_dir) | |
| fetch_result = _git(["fetch", "origin"], repo) | |
| if fetch_result.returncode != 0: | |
| raise GitCommitError(fetch_result.stderr or fetch_result.stdout) | |
| remote_head = _git(["rev-parse", "origin/main"], repo) | |
| if remote_head.returncode != 0: | |
| return | |
| reset_result = _git(["reset", "--hard", "origin/main"], repo) | |
| if reset_result.returncode != 0: | |
| raise GitCommitError(reset_result.stderr or reset_result.stdout) | |
| def delete_page_file(*, data_dir: str, relative_path: str, author: str, note: str = "") -> str: | |
| repo = ensure_repo(data_dir) | |
| full_path = repo / relative_path | |
| if full_path.exists(): | |
| full_path.unlink() | |
| add_result = _git(["add", "-A"], repo) | |
| if add_result.returncode != 0: | |
| raise GitCommitError(add_result.stderr or add_result.stdout) | |
| message = f"{note or 'Delete page'}\n\nwiki-author: {author}\n" | |
| commit_result = _git(["commit", "-m", message], repo) | |
| if commit_result.returncode != 0: | |
| combined = commit_result.stdout + commit_result.stderr | |
| if "nothing to commit" not in combined: | |
| raise GitCommitError(combined) | |
| sha = _current_head(repo) | |
| if sha is None: | |
| raise GitCommitError("commit succeeded but HEAD could not be resolved") | |
| return sha | |