mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
632 lines
22 KiB
Python
632 lines
22 KiB
Python
"""Supervisor git/reset/rescue/dependency operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shutil # noqa: F401
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from supervisor.state import (
|
|
append_jsonl, atomic_write_text, load_state, save_state, update_state, # noqa: F401
|
|
)
|
|
from ouroboros import config as _config
|
|
from ouroboros.utils import utc_now_iso # noqa: F401
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# REPO_DIR / DRIVE_ROOT are UNBOUND until init() (server bootstrap), a worker
|
|
# rebind or a test monkeypatch pins them as real module attributes. Until then
|
|
# attribute access resolves PER CALL from the OUROBOROS_* env via the config
|
|
# path SSOT (module __getattr__ below), so a process that imported this module
|
|
# before its path isolation applied still reads and writes its OWN roots —
|
|
# not a root baked at import time (issue #455 hermeticity class; the old
|
|
# hardcoded ~/Ouroboros default never read the env at all).
|
|
REPO_DIR: pathlib.Path
|
|
DRIVE_ROOT: pathlib.Path
|
|
REMOTE_URL: str = ""
|
|
BRANCH_DEV: str = "ouroboros"
|
|
BRANCH_STABLE: str = "ouroboros-stable"
|
|
MANAGED_REPO_META_NAME = "ouroboros-managed.json"
|
|
BOOTSTRAP_PIN_MARKER_NAME = "ouroboros-bootstrap-pending"
|
|
UPDATE_INTENT_MARKER_NAME = "ouroboros-update-intent.json"
|
|
OFFICIAL_UPDATE_REMOTE_URL = "https://github.com/razzant/ouroboros"
|
|
|
|
|
|
def __getattr__(name: str) -> Any:
|
|
if name == "REPO_DIR":
|
|
return _config.resolve_repo_dir()
|
|
if name == "DRIVE_ROOT":
|
|
return _config.resolve_data_dir()
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
|
|
|
|
def current_repo_dir() -> pathlib.Path:
|
|
"""The pinned REPO_DIR when init()/a rebind set it, else the env-resolved one."""
|
|
pinned = globals().get("REPO_DIR")
|
|
return pinned if pinned is not None else _config.resolve_repo_dir()
|
|
|
|
|
|
def current_drive_root() -> pathlib.Path:
|
|
"""The pinned DRIVE_ROOT when init()/a rebind set it, else the env-resolved one."""
|
|
pinned = globals().get("DRIVE_ROOT")
|
|
return pinned if pinned is not None else _config.resolve_data_dir()
|
|
|
|
|
|
def _guard_live_repo_destructive_git(cmd: List[str]) -> None:
|
|
if os.environ.get("OUROBOROS_ALLOW_LIVE_REPO_TESTS") == "1":
|
|
return
|
|
try:
|
|
live_repo = current_repo_dir().resolve(strict=False) == (
|
|
pathlib.Path.home() / "Ouroboros" / "repo"
|
|
).resolve(strict=False)
|
|
except OSError:
|
|
live_repo = False
|
|
if not (("PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules) and live_repo):
|
|
return
|
|
normalized = [str(part) for part in cmd]
|
|
is_reset_hard = normalized[:3] == ["git", "reset", "--hard"]
|
|
is_clean = normalized[:2] == ["git", "clean"]
|
|
if is_reset_hard or is_clean:
|
|
raise RuntimeError(
|
|
"Refusing to run destructive git reset/clean on the live Ouroboros repo from pytest. "
|
|
"Use an isolated repo fixture, or OUROBOROS_ALLOW_LIVE_REPO_TESTS=1 for an explicit live-repo test."
|
|
)
|
|
|
|
|
|
def init(repo_dir: pathlib.Path, drive_root: pathlib.Path, remote_url: str,
|
|
branch_dev: str = "ouroboros", branch_stable: str = "ouroboros-stable") -> None:
|
|
global REPO_DIR, DRIVE_ROOT, REMOTE_URL, BRANCH_DEV, BRANCH_STABLE
|
|
REPO_DIR = repo_dir
|
|
DRIVE_ROOT = drive_root
|
|
REMOTE_URL = remote_url
|
|
BRANCH_DEV = branch_dev
|
|
BRANCH_STABLE = branch_stable
|
|
|
|
|
|
def _git_dir() -> pathlib.Path:
|
|
return current_repo_dir() / ".git"
|
|
|
|
|
|
def _managed_repo_meta_path() -> pathlib.Path:
|
|
return _git_dir() / MANAGED_REPO_META_NAME
|
|
|
|
|
|
def _bootstrap_pin_marker_path() -> pathlib.Path:
|
|
return _git_dir() / BOOTSTRAP_PIN_MARKER_NAME
|
|
|
|
|
|
def _update_intent_marker_path() -> pathlib.Path:
|
|
return _git_dir() / UPDATE_INTENT_MARKER_NAME
|
|
|
|
|
|
def _read_managed_repo_meta() -> Dict[str, Any]:
|
|
path = _managed_repo_meta_path()
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def managed_branch_defaults(repo_dir: Optional[pathlib.Path] = None) -> Tuple[str, str]:
|
|
repo = repo_dir or current_repo_dir()
|
|
meta_path = repo / ".git" / MANAGED_REPO_META_NAME
|
|
if not meta_path.is_file():
|
|
return BRANCH_DEV, BRANCH_STABLE
|
|
try:
|
|
raw = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return BRANCH_DEV, BRANCH_STABLE
|
|
if not isinstance(raw, dict):
|
|
return BRANCH_DEV, BRANCH_STABLE
|
|
branch_dev = str(raw.get("managed_local_branch") or BRANCH_DEV).strip() or BRANCH_DEV
|
|
branch_stable = str(raw.get("managed_local_stable_branch") or BRANCH_STABLE).strip() or BRANCH_STABLE
|
|
return branch_dev, branch_stable
|
|
|
|
|
|
def _is_launcher_managed_repo() -> bool:
|
|
if str(os.environ.get("OUROBOROS_MANAGED_BY_LAUNCHER", "") or "").strip() == "1":
|
|
return True
|
|
return bool(_read_managed_repo_meta())
|
|
|
|
|
|
def _list_remotes(*, capture=None, warnings: Optional[List[str]] = None) -> List[str]:
|
|
capture_fn = capture or git_capture
|
|
rc, remotes, error = capture_fn(["git", "remote"])
|
|
if rc != 0:
|
|
if warnings is not None:
|
|
warnings.append(
|
|
f"remotes_error:{error or f'git remote exited {rc} without stderr'}"
|
|
)
|
|
return []
|
|
return [line.strip() for line in remotes.splitlines() if line.strip()]
|
|
|
|
|
|
def _has_remote(name: Optional[str] = None) -> bool:
|
|
remotes = _list_remotes()
|
|
if name is None:
|
|
return bool(remotes)
|
|
return name in remotes
|
|
|
|
|
|
def _managed_remote_name(meta: Optional[Dict[str, Any]] = None) -> str:
|
|
info = meta if meta is not None else _read_managed_repo_meta()
|
|
return str(info.get("managed_remote_name") or "managed").strip() or "managed"
|
|
|
|
|
|
def _managed_remote_branch_for(branch: str, meta: Optional[Dict[str, Any]] = None) -> str:
|
|
info = meta if meta is not None else _read_managed_repo_meta()
|
|
if branch == BRANCH_DEV:
|
|
return str(info.get("managed_remote_branch") or branch).strip()
|
|
if branch == BRANCH_STABLE:
|
|
return str(info.get("managed_remote_stable_branch") or branch).strip()
|
|
return branch
|
|
|
|
|
|
def _pin_to_bundle_sha_on_bootstrap(reason: str, managed_meta: Optional[Dict[str, Any]] = None) -> bool:
|
|
if str(reason or "").strip().lower() != "bootstrap":
|
|
return False
|
|
if not _bootstrap_pin_marker_path().exists():
|
|
return False
|
|
info = managed_meta if managed_meta is not None else _read_managed_repo_meta()
|
|
source_sha = str(info.get("source_sha") or "").strip()
|
|
if not source_sha:
|
|
return False
|
|
rc, head_sha, _ = git_capture(["git", "rev-parse", "HEAD"])
|
|
if rc != 0 or str(head_sha or "").strip() != source_sha:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _clear_bootstrap_pin_marker() -> None:
|
|
try:
|
|
_bootstrap_pin_marker_path().unlink()
|
|
except FileNotFoundError:
|
|
return
|
|
except Exception:
|
|
log.warning("Failed to clear bootstrap pin marker", exc_info=True)
|
|
|
|
|
|
def _read_update_intent() -> Dict[str, Any]:
|
|
path = _update_intent_marker_path()
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return raw if isinstance(raw, dict) else {}
|
|
|
|
|
|
def _write_update_intent(payload: Dict[str, Any]) -> None:
|
|
# Atomic: a torn marker would make the next restart silently skip the
|
|
# prepared managed update (reader fails closed on parse errors).
|
|
from ouroboros.utils import atomic_write_json
|
|
|
|
path = _update_intent_marker_path()
|
|
atomic_write_json(path, payload, trailing_newline=True)
|
|
|
|
|
|
def _clear_update_intent() -> bool:
|
|
try:
|
|
_update_intent_marker_path().unlink()
|
|
except FileNotFoundError:
|
|
return True
|
|
except Exception:
|
|
log.warning("Failed to clear update intent marker", exc_info=True)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _run_git_process_bounded(
|
|
cmd: List[str], *, timeout: float, cwd: Optional[pathlib.Path] = None,
|
|
env: Optional[Dict[str, str]] = None, text: bool = True,
|
|
input_data: Optional[Any] = None,
|
|
) -> Tuple[int, Any, Any]:
|
|
"""Run one short-lived Git process and terminate its tree on timeout.
|
|
|
|
``input_data`` (str when ``text=True``, bytes otherwise) is fed to the
|
|
process's stdin and the pipe is closed — the batch-stdin git plumbing
|
|
(e.g. ``git cat-file --batch``) rides the same timeout/kill semantics."""
|
|
from ouroboros.platform_layer import kill_process_tree, subprocess_new_group_kwargs
|
|
from ouroboros.tools.shell import _active_subprocesses, _subprocess_lock
|
|
|
|
limit = float(timeout)
|
|
empty = "" if text else b""
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(cwd or current_repo_dir()),
|
|
stdin=subprocess.PIPE if input_data is not None else None,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=text,
|
|
env=env,
|
|
**subprocess_new_group_kwargs(),
|
|
)
|
|
except OSError as exc:
|
|
detail = str(exc)
|
|
return 127, empty, detail if text else detail.encode("utf-8", "replace")
|
|
|
|
with _subprocess_lock:
|
|
_active_subprocesses.add(proc)
|
|
try:
|
|
try:
|
|
stdout, stderr = proc.communicate(input=input_data, timeout=limit)
|
|
except subprocess.TimeoutExpired as exc:
|
|
partial_error = getattr(exc, "stderr", None)
|
|
kill_process_tree(proc)
|
|
try:
|
|
_tail_out, tail_error = proc.communicate(timeout=10)
|
|
except Exception:
|
|
tail_error = None
|
|
raw_detail = tail_error or partial_error or empty
|
|
if isinstance(raw_detail, bytes):
|
|
detail = raw_detail.decode("utf-8", "replace").strip()
|
|
else:
|
|
detail = str(raw_detail or "").strip()
|
|
message = (
|
|
f"git process timed out after {limit:g}s and was terminated: "
|
|
f"{' '.join(cmd)}"
|
|
)
|
|
if detail:
|
|
message += f" ({detail})"
|
|
return (
|
|
FETCH_TIMEOUT_RC,
|
|
empty,
|
|
message if text else message.encode("utf-8", "replace"),
|
|
)
|
|
return (
|
|
int(proc.returncode if proc.returncode is not None else 1),
|
|
stdout if stdout is not None else empty,
|
|
stderr if stderr is not None else empty,
|
|
)
|
|
finally:
|
|
with _subprocess_lock:
|
|
_active_subprocesses.discard(proc)
|
|
|
|
|
|
def git_capture(cmd: List[str], *, timeout: Optional[float] = None) -> Tuple[int, str, str]:
|
|
# Same reason as utils.run_cmd: this stderr is PARSED (`_maybe_repair_git_index`
|
|
# matches English git diagnostics), so the operator's locale must not decide
|
|
# whether a repairable index error is recognised.
|
|
# ``timeout`` is None for existing non-rescue call sites: a bound here is a
|
|
# behavior change, so it stays opt-in rather than silently retiming them.
|
|
env = {**os.environ, "LC_ALL": "C", "LANG": "C"}
|
|
repo = current_repo_dir()
|
|
for _attempt in range(2):
|
|
if timeout is None:
|
|
result = subprocess.run(
|
|
cmd, cwd=str(repo), capture_output=True, text=True, env=env,
|
|
)
|
|
returncode, stdout, raw_stderr = (
|
|
result.returncode, result.stdout or "", result.stderr or "",
|
|
)
|
|
else:
|
|
returncode, stdout, raw_stderr = _run_git_process_bounded(
|
|
cmd, timeout=timeout, cwd=repo, env=env, text=True,
|
|
)
|
|
stderr = str(raw_stderr or "").strip()
|
|
if returncode == 0:
|
|
return returncode, str(stdout or "").strip(), stderr
|
|
if _maybe_repair_git_index(stderr, timeout=timeout):
|
|
continue
|
|
return returncode, str(stdout or "").strip(), stderr
|
|
return returncode, str(stdout or "").strip(), stderr
|
|
|
|
|
|
from supervisor import update_source as _update_source
|
|
|
|
FETCH_TIMEOUT_RC, _git_network_bounded = _update_source.FETCH_TIMEOUT_RC, _update_source._git_network_bounded
|
|
_managed_update_target, git_fetch_bounded = _update_source._managed_update_target, _update_source.git_fetch_bounded
|
|
|
|
def rescue_git_capture(cmd: List[str]) -> Tuple[int, str, str]:
|
|
"""Run ``git_capture`` under the configured rescue-only wall-clock bound.
|
|
|
|
A timeout returns through the normal nonzero-rc shape, which every caller
|
|
in the rescue graph already treats as a warning rather than a hard stop:
|
|
fail-open, never a stall.
|
|
"""
|
|
from ouroboros.update_channels import get_rescue_git_timeout_sec
|
|
|
|
return git_capture(cmd, timeout=get_rescue_git_timeout_sec())
|
|
|
|
|
|
def _resolve_managed_update_target(
|
|
remote_name: str, remote_branch: str, branch_ref: str, update_channel: str
|
|
) -> Tuple[str, str, str]:
|
|
return _update_source.resolve_managed_update_target(
|
|
remote_name,
|
|
remote_branch,
|
|
branch_ref,
|
|
update_channel=update_channel,
|
|
capture=git_capture,
|
|
)
|
|
|
|
|
|
def _stale_git_lock_paths(max_age_sec: float = 15.0) -> List[pathlib.Path]:
|
|
git_dir = current_repo_dir() / ".git"
|
|
if not git_dir.exists():
|
|
return []
|
|
candidates = [git_dir / "index.lock"]
|
|
stale_paths: List[pathlib.Path] = []
|
|
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
|
for path in candidates:
|
|
try:
|
|
age = now - path.stat().st_mtime
|
|
except FileNotFoundError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
if age >= max_age_sec:
|
|
stale_paths.append(path)
|
|
return stale_paths
|
|
|
|
|
|
def _maybe_repair_git_index(stderr: str, *, timeout: Optional[float] = None) -> bool:
|
|
error_text = str(stderr or "")
|
|
error_lower = error_text.lower()
|
|
repaired = False
|
|
|
|
if "index.lock" in error_lower:
|
|
for lock_path in _stale_git_lock_paths():
|
|
try:
|
|
lock_path.unlink()
|
|
repaired = True
|
|
log.warning("Removed stale git lock: %s", lock_path)
|
|
except Exception:
|
|
log.warning("Failed to remove stale git lock: %s", lock_path, exc_info=True)
|
|
|
|
corrupt_markers = (
|
|
"index file smaller than expected",
|
|
"index file corrupt",
|
|
"fatal: .git/index:",
|
|
)
|
|
if not any(marker in error_lower for marker in corrupt_markers):
|
|
return repaired
|
|
|
|
repo = current_repo_dir()
|
|
git_dir = repo / ".git"
|
|
if not git_dir.exists():
|
|
return repaired
|
|
|
|
index_path = git_dir / "index"
|
|
if index_path.exists():
|
|
backup_path = git_dir / f"index.corrupt.{uuid.uuid4().hex[:8]}.bak"
|
|
try:
|
|
index_path.replace(backup_path)
|
|
repaired = True
|
|
log.warning("Backed up corrupt git index to %s", backup_path)
|
|
except Exception:
|
|
log.warning("Failed to back up corrupt git index %s", index_path, exc_info=True)
|
|
return repaired
|
|
|
|
rebuild_cmd = ["git", "reset", "--mixed", "HEAD"]
|
|
rebuild_env = {**os.environ, "LC_ALL": "C", "LANG": "C"}
|
|
if timeout is None:
|
|
rebuild = subprocess.run(
|
|
rebuild_cmd,
|
|
cwd=str(repo),
|
|
capture_output=True,
|
|
text=True,
|
|
env=rebuild_env,
|
|
)
|
|
rebuild_rc = rebuild.returncode
|
|
rebuild_error = (rebuild.stderr or "").strip() or (rebuild.stdout or "").strip()
|
|
else:
|
|
rebuild_rc, rebuild_stdout, rebuild_stderr = _run_git_process_bounded(
|
|
rebuild_cmd, timeout=timeout, cwd=repo, env=rebuild_env, text=True,
|
|
)
|
|
rebuild_error = str(rebuild_stderr or "").strip() or str(rebuild_stdout or "").strip()
|
|
if rebuild_rc == 0:
|
|
log.warning("Rebuilt git index after corruption in %s", repo)
|
|
return True
|
|
|
|
log.warning(
|
|
"Failed to rebuild git index after corruption: %s",
|
|
rebuild_error,
|
|
)
|
|
return repaired
|
|
|
|
|
|
_REPO_GITIGNORE = """\
|
|
# Secrets
|
|
.env
|
|
.env.*
|
|
*.key
|
|
*.pem
|
|
|
|
# IDE
|
|
.cursor/
|
|
.vscode/
|
|
.idea/
|
|
|
|
# Python bytecode
|
|
__pycache__/
|
|
*.pyc
|
|
*.pyo
|
|
*.egg-info/
|
|
|
|
# Build artifacts
|
|
dist/
|
|
build/
|
|
.pytest_cache/
|
|
.mypy_cache/
|
|
|
|
# Native / binary artifacts (PyInstaller, compiled extensions)
|
|
*.so
|
|
*.dylib
|
|
*.dll
|
|
*.dist-info/
|
|
base_library.zip
|
|
|
|
# OS
|
|
.DS_Store
|
|
Thumbs.db
|
|
|
|
# Release artifacts
|
|
.create_release.py
|
|
.release_notes.md
|
|
repo.bundle
|
|
repo_bundle_manifest.json
|
|
python-standalone/
|
|
"""
|
|
|
|
|
|
def _ensure_repo_gitignore(repo_dir: pathlib.Path = None) -> None:
|
|
"""Write .gitignore if missing before any git add -A."""
|
|
target = repo_dir or current_repo_dir()
|
|
gi = target / ".gitignore"
|
|
if not gi.exists():
|
|
gi.write_text(_REPO_GITIGNORE, encoding="utf-8")
|
|
|
|
|
|
def _ensure_git_identity() -> None:
|
|
"""Ensure repo-local git identity exists for local commits/tags."""
|
|
git_capture(["git", "config", "user.name", "Ouroboros"])
|
|
git_capture(["git", "config", "user.email", "ouroboros@local.mac"])
|
|
|
|
|
|
def _ensure_local_version_tag() -> None:
|
|
"""Create the current VERSION tag locally when a local-only repo has none."""
|
|
version_path = current_repo_dir() / "VERSION"
|
|
if not version_path.exists():
|
|
return
|
|
|
|
version = version_path.read_text(encoding="utf-8").strip().lstrip("v")
|
|
if not re.match(r"^\d+\.\d+\.\d+(?:-?(?:rc|alpha|beta|a|b)\.?\d+)?$", version, re.IGNORECASE):
|
|
return
|
|
|
|
tag_name = f"v{version}"
|
|
rc, tag_match, err = git_capture(["git", "tag", "-l", tag_name])
|
|
if rc != 0:
|
|
log.warning("Failed to check local tag %s: %s", tag_name, err)
|
|
return
|
|
if tag_match.strip():
|
|
return
|
|
|
|
rc, all_tags, err = git_capture(["git", "tag", "-l"])
|
|
if rc != 0:
|
|
log.warning("Failed to list local tags: %s", err)
|
|
return
|
|
if any(t.strip() for t in all_tags.splitlines()):
|
|
return
|
|
|
|
rc, head_sha, err = git_capture(["git", "rev-parse", "HEAD"])
|
|
if rc != 0 or not head_sha:
|
|
log.warning("Cannot create local version tag %s without HEAD: %s", tag_name, err)
|
|
return
|
|
|
|
_ensure_git_identity()
|
|
rc, _, err = git_capture(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"])
|
|
if rc != 0:
|
|
log.warning("Failed to create local version tag %s: %s", tag_name, err)
|
|
return
|
|
|
|
log.info("Created local-only version tag %s at %s", tag_name, head_sha[:8])
|
|
|
|
|
|
def ensure_repo_present() -> None:
|
|
repo = current_repo_dir()
|
|
if not (repo / ".git").exists():
|
|
if _is_launcher_managed_repo():
|
|
raise RuntimeError(
|
|
"Launcher-managed repo is missing .git metadata. "
|
|
"The launcher bootstrap must recreate REPO_DIR from the embedded repo bundle."
|
|
)
|
|
# REPO_DIR is live code: initialize in place, never remove it.
|
|
repo.mkdir(parents=True, exist_ok=True)
|
|
_ensure_repo_gitignore()
|
|
import dulwich.repo
|
|
dulwich.repo.Repo.init(str(repo))
|
|
|
|
_ensure_git_identity()
|
|
|
|
rc, _, _ = git_capture(["git", "status", "--porcelain"])
|
|
if rc == 0:
|
|
subprocess.run(["git", "add", "-A"], cwd=str(repo), check=True)
|
|
subprocess.run(["git", "commit", "-m", "Initial commit from bundle"], cwd=str(repo), check=False)
|
|
|
|
subprocess.run(["git", "branch", "-M", BRANCH_DEV], cwd=str(repo), check=False)
|
|
subprocess.run(["git", "branch", BRANCH_STABLE], cwd=str(repo), check=False)
|
|
|
|
if not _is_launcher_managed_repo():
|
|
_ensure_local_version_tag()
|
|
|
|
|
|
# Owner recovery surface lives in supervisor/update_recovery.py; re-exported because
|
|
# callers/tests address it through the git_ops facade (cycle-free: update_recovery
|
|
# imports git_ops only inside functions).
|
|
from supervisor.update_recovery import promote_branch_exact, rollback_to_version # noqa: E402,F401
|
|
# --- G1 split re-exports (v7 module-size discipline) -----------------------
|
|
# The rescue/sync-state surface lives in supervisor/git_ops_rescue.py (G1
|
|
# split); re-exported because callers/tests address it through the git_ops
|
|
# facade (cycle-free: the leaf imports git_ops only at call time through its
|
|
# _go() handle).
|
|
from supervisor.git_ops_rescue import ( # noqa: E402,F401
|
|
_atomic_write_bytes,
|
|
_collect_repo_sync_state,
|
|
_copy_untracked_for_rescue,
|
|
_create_rescue_snapshot,
|
|
_link_rescue_to_evolution_transaction,
|
|
_rescue_untracked_incomplete,
|
|
rescue_before_destructive_rollback,
|
|
rescue_into_tx,
|
|
)
|
|
|
|
|
|
# The checkout/reset admission, dependency-sync and import-test surface lives
|
|
# in supervisor/git_ops_reset.py (G1 split); re-exported because callers/tests
|
|
# address it through the git_ops facade (cycle-free: the leaf imports git_ops
|
|
# only at call time through its _go() handle).
|
|
from supervisor.git_ops_reset import ( # noqa: E402,F401
|
|
_admission_gate_for_unsynced_tree,
|
|
_compute_ref_ahead_count,
|
|
_preserve_branch_for_official_reset,
|
|
_ref_points_at_ref,
|
|
_run_git_resilient,
|
|
checkout_and_reset,
|
|
import_test,
|
|
preserve_local_ref_branch,
|
|
safe_restart,
|
|
sync_runtime_dependencies,
|
|
)
|
|
|
|
|
|
# The managed-update status surface lives in supervisor/git_ops_updates.py
|
|
# (G1 split); re-exported because callers/tests address it through the git_ops
|
|
# facade (cycle-free: the leaf imports git_ops only at call time through its
|
|
# _go() handle).
|
|
from supervisor.git_ops_updates import ( # noqa: E402,F401
|
|
_public_repo_url,
|
|
compute_managed_update_status,
|
|
ensure_official_update_remote,
|
|
list_commits,
|
|
list_official_update_tags,
|
|
list_versions,
|
|
managed_update_remote_url,
|
|
prepare_managed_update,
|
|
)
|
|
|
|
|
|
# The personal persistence remote (`origin`) surface lives in
|
|
# supervisor/git_ops_remotes.py (G1 split); re-exported because callers/tests
|
|
# address it through the git_ops facade (cycle-free: the leaf imports git_ops
|
|
# only at call time through its _go() handle).
|
|
from supervisor.git_ops_remotes import ( # noqa: E402,F401
|
|
_configure_credential_helper,
|
|
configure_personal_remote,
|
|
configure_remote,
|
|
push_to_remote,
|
|
)
|