feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""Docs-push service (phase 59, task 03).
|
||||
|
||||
Commits a document into the configured docs repo and pushes it to the
|
||||
configured branch. **D3 (owner-locked, 2026-08-31): push only** — the
|
||||
flow ends at the push to the branch named in ``.env``
|
||||
(``BOR_DOCS_REPO`` + ``BOR_DOCS_BRANCH``); no PR is ever created or
|
||||
attempted (no ``gh``, no PR URL, no URL construction, no
|
||||
GitHub-specific logic). The remote is **generic** — a local path,
|
||||
``https://``, ``ssh://``, any host — and git is invoked exclusively
|
||||
through :func:`scripts.git_sync.run_git` (A11: stdlib ``subprocess``
|
||||
only, no new packages): :func:`push_document` is the only caller of
|
||||
git for this feature besides :mod:`scripts.git_sync` itself.
|
||||
|
||||
The push is a plain ``git push origin <branch>`` — git 2.55 no longer
|
||||
accepts the ``--ff-only`` flag on ``push`` — and a push without
|
||||
``-f`` / a ``+`` refspec already refuses non-fast-forward updates
|
||||
(client-side, against the remote's live refs), so a
|
||||
concurrently-advanced remote fails loudly: never a force-push, never
|
||||
a merge. Every failure raises :class:`DocsPushError` carrying git's
|
||||
stderr (the ``GitSyncError`` style), and the checkout under
|
||||
``work_dir`` is left as-is for inspection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.git_sync import GitSyncError, run_git
|
||||
|
||||
__all__ = ["DocsPushError", "push_document"]
|
||||
|
||||
# Fixed per-invocation commit identity (phase 59 ASSUMPTION) — passed
|
||||
# with ``-c`` on the commit itself, so the push never relies on the
|
||||
# machine's global git config. ``commit.gpgsign=false`` keeps a
|
||||
# headless server from ever prompting for a GPG pinentry (this repo's
|
||||
# own no-GPG-signing policy).
|
||||
_COMMIT_NAME = "Brain of Reese"
|
||||
_COMMIT_EMAIL = "bor@local"
|
||||
|
||||
|
||||
class DocsPushError(RuntimeError):
|
||||
"""A docs push failed (or git is missing); carries git's stderr."""
|
||||
|
||||
|
||||
def _refuse_unsafe_rel_path(rel_path: str) -> None:
|
||||
"""Defensively re-assert the upstream path guard-rails (task 02).
|
||||
|
||||
The file is written inside the docs checkout, so a blank path, an
|
||||
absolute path, or any ``.``/``..`` component is refused here as
|
||||
well — even though the drafts API already guard-railed the value.
|
||||
"""
|
||||
rel = Path(rel_path)
|
||||
if not rel_path.strip() or rel.is_absolute() or any(part in (".", "..") for part in rel.parts):
|
||||
raise DocsPushError(f"refusing unsafe rel_path: {rel_path!r}")
|
||||
|
||||
|
||||
def push_document(
|
||||
repo: str,
|
||||
base_branch: str,
|
||||
branch: str,
|
||||
work_dir: str,
|
||||
rel_path: str,
|
||||
content: str,
|
||||
commit_message: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Commit *content* at *rel_path* and push (ff-only) to *branch*.
|
||||
|
||||
Step sequence (each step via :func:`scripts.git_sync.run_git`;
|
||||
``cwd=work_dir`` once the checkout exists):
|
||||
|
||||
1. Ensure the checkout: without ``.git`` →
|
||||
``git clone --depth 1 --branch <base_branch> <repo> <work_dir>``
|
||||
(the base branch is explicit — the remote's default may differ).
|
||||
2. ``git fetch --depth 1 origin <base_branch>`` — re-sync the base
|
||||
before every push. The fetched base tip (``FETCH_HEAD``) is
|
||||
captured immediately — a later *failed* fetch clears
|
||||
``FETCH_HEAD``.
|
||||
3. ``git fetch --depth 100 origin <branch>`` — **failure is
|
||||
expected** while the branch does not exist on the remote yet
|
||||
(swallowed and continued); on success the fetched branch tip is
|
||||
captured the same way.
|
||||
4. Attach the local branch:
|
||||
|
||||
- a local branch left by a previous push → ``git checkout
|
||||
<branch>`` — keep its own history, so a
|
||||
concurrently-advanced remote then fails the push loudly in
|
||||
step 7 instead of being silently re-based or merged;
|
||||
- otherwise ``git checkout -B <branch>`` onto the captured
|
||||
branch tip (re-attach onto the previously pushed branch — its
|
||||
history, so the push can fast-forward) or, on first push,
|
||||
onto the captured base tip (a new branch from the fresh
|
||||
base). The captured shas are used instead of symbolic refs:
|
||||
git's local transport (a local-path remote) does not create
|
||||
``refs/remotes/origin/<branch>`` for a newly fetched branch,
|
||||
and a failed fetch clears ``FETCH_HEAD``.
|
||||
5. Write the file (parent dirs created;
|
||||
:func:`_refuse_unsafe_rel_path` re-asserts the parts check
|
||||
defensively).
|
||||
6. ``git add -- <rel_path>`` + ``commit -m <commit_message>`` with
|
||||
the fixed per-invocation identity (``-c user.name`` /
|
||||
``-c user.email`` — no reliance on global git config).
|
||||
7. ``git push origin <branch>`` — creates the remote branch on
|
||||
first push; a concurrently-advanced remote is refused
|
||||
non-fast-forward (never a force-push, never a merge).
|
||||
8. ``sha = git rev-parse HEAD``; return ``(branch, sha)``.
|
||||
|
||||
Raises :class:`DocsPushError` (git's stderr in the message) on any
|
||||
failure — the checkout is left as-is for inspection.
|
||||
"""
|
||||
_refuse_unsafe_rel_path(rel_path)
|
||||
work = Path(work_dir).expanduser()
|
||||
try:
|
||||
if not (work / ".git").exists():
|
||||
work.parent.mkdir(parents=True, exist_ok=True)
|
||||
run_git(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
base_branch,
|
||||
repo,
|
||||
str(work),
|
||||
],
|
||||
cwd=work.parent,
|
||||
)
|
||||
run_git(["git", "fetch", "--depth", "1", "origin", base_branch], cwd=work)
|
||||
# Capture the fetched base tip now — a *failed* fetch clears
|
||||
# FETCH_HEAD, so the symbolic ref cannot be reused later.
|
||||
base_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
|
||||
try:
|
||||
run_git(["git", "fetch", "--depth", "100", "origin", branch], cwd=work)
|
||||
branch_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
|
||||
except GitSyncError:
|
||||
branch_sha = None # the branch does not exist on the remote yet
|
||||
if run_git(["git", "branch", "--list", branch], cwd=work).strip():
|
||||
run_git(["git", "checkout", branch], cwd=work)
|
||||
else:
|
||||
anchor = branch_sha if branch_sha is not None else base_sha
|
||||
run_git(["git", "checkout", "-B", branch, anchor], cwd=work)
|
||||
target = work / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
run_git(["git", "add", "--", rel_path], cwd=work)
|
||||
run_git(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"user.name={_COMMIT_NAME}",
|
||||
"-c",
|
||||
f"user.email={_COMMIT_EMAIL}",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
commit_message,
|
||||
],
|
||||
cwd=work,
|
||||
)
|
||||
run_git(["git", "push", "origin", branch], cwd=work)
|
||||
sha = run_git(["git", "rev-parse", "HEAD"], cwd=work).strip()
|
||||
except GitSyncError as err:
|
||||
raise DocsPushError(str(err)) from err
|
||||
return branch, sha
|
||||
Reference in New Issue
Block a user