chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:00:43 +08:00
commit e4dcfc49aa
1668 changed files with 324490 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import sys
def _ensure_replace_errors() -> None:
try:
if getattr(sys.stdout, "errors", None) != "replace":
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
_ensure_replace_errors()
def banner(title: str, lines: list[str] | tuple[str, ...] = ()) -> None:
print(f"== {title} ==")
for line in lines:
print(str(line))
def log_success(message: str) -> None:
print(f"[ok] {message}")
def log_error(message: str) -> None:
print(f"[error] {message}")
__all__ = ["banner", "log_error", "log_success"]
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python
"""Run Docker Compose with port mappings rendered from JSON settings.
Docker Compose cannot read ``data/user/settings/system.json`` directly for
host port interpolation. This wrapper renders a tiny compose env file from the
JSON settings and then invokes ``docker compose --env-file``. It intentionally
does not read or migrate the project-root ``.env`` file.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SETTINGS_DIR = PROJECT_ROOT / "data" / "user" / "settings"
DOCKER_ENV_PATH = SETTINGS_DIR / "docker.env"
DEFAULT_BACKEND_PORT = 8001
DEFAULT_FRONTEND_PORT = 3782
DEFAULT_POCKETBASE_PORT = 8090
def _read_json_object(path: Path) -> dict[str, Any]:
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return loaded if isinstance(loaded, dict) else {}
def _coerce_port(value: Any, default: int) -> int:
try:
port = int(str(value).strip())
except (TypeError, ValueError):
return default
return port if 1 <= port <= 65535 else default
def render_docker_env(
settings_dir: Path = SETTINGS_DIR,
output_path: Path = DOCKER_ENV_PATH,
) -> dict[str, str]:
"""Render compose interpolation vars from JSON settings only."""
system = _read_json_object(settings_dir / "system.json")
integrations = _read_json_object(settings_dir / "integrations.json")
values = {
"DEEPTUTOR_DOCKER_BACKEND_PORT": str(
_coerce_port(system.get("backend_port"), DEFAULT_BACKEND_PORT)
),
"DEEPTUTOR_DOCKER_FRONTEND_PORT": str(
_coerce_port(system.get("frontend_port"), DEFAULT_FRONTEND_PORT)
),
"DEEPTUTOR_DOCKER_POCKETBASE_PORT": str(
_coerce_port(integrations.get("pocketbase_port"), DEFAULT_POCKETBASE_PORT)
),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
lines = [
"# Auto-generated by scripts/docker_compose.py from data/user/settings/*.json.",
"# Do not edit manually; update system.json/integrations.json instead.",
]
lines.extend(f"{key}={value}" for key, value in values.items())
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return values
def _compose_command(args: list[str]) -> list[str]:
docker = shutil.which("docker")
if not docker:
raise SystemExit("docker was not found on PATH")
return [docker, "compose", "--env-file", str(DOCKER_ENV_PATH), *args]
def main(argv: list[str] | None = None) -> int:
args = list(argv if argv is not None else sys.argv[1:])
if not args:
args = ["up", "-d"]
values = render_docker_env()
print(
"Docker settings: "
f"backend={values['DEEPTUTOR_DOCKER_BACKEND_PORT']} "
f"frontend={values['DEEPTUTOR_DOCKER_FRONTEND_PORT']} "
f"pocketbase={values['DEEPTUTOR_DOCKER_POCKETBASE_PORT']}",
file=sys.stderr,
)
env = os.environ.copy()
# Keep Docker execution detached from host process overrides.
for key in (
"BACKEND_PORT",
"FRONTEND_PORT",
"POCKETBASE_PORT",
"AUTH_ENABLED",
"POCKETBASE_URL",
"NEXT_PUBLIC_API_BASE",
"NEXT_PUBLIC_API_BASE_EXTERNAL",
):
env.pop(key, None)
result = subprocess.run(_compose_command(args), cwd=str(PROJECT_ROOT), env=env, check=False)
return int(result.returncode)
if __name__ == "__main__":
raise SystemExit(main())
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
PocketBase collection bootstrap script.
Run this once after starting PocketBase for the first time:
python scripts/pb_setup.py
Requires integrations.pocketbase_url, integrations.pocketbase_admin_email, and
integrations.pocketbase_admin_password in data/user/settings/integrations.json.
Safe to re-run — existing collections are left untouched.
"""
from __future__ import annotations
from pathlib import Path
import sys
# Allow running from project root without installing the package.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from deeptutor.services.config import load_integrations_settings
_INTEGRATIONS = load_integrations_settings()
POCKETBASE_BASE_URL = str(_INTEGRATIONS["pocketbase_url"]).rstrip("/")
ADMIN_EMAIL = str(_INTEGRATIONS["pocketbase_admin_email"])
ADMIN_PASSWORD = str(_INTEGRATIONS["pocketbase_admin_password"])
def _require_env():
missing = []
if not POCKETBASE_BASE_URL:
missing.append("integrations.pocketbase_url")
if not ADMIN_EMAIL:
missing.append("integrations.pocketbase_admin_email")
if not ADMIN_PASSWORD:
missing.append("integrations.pocketbase_admin_password")
if missing:
print(f"ERROR: Missing required integration settings: {', '.join(missing)}")
print("Set them in data/user/settings/integrations.json before running this script.")
sys.exit(1)
def _get_client():
try:
from pocketbase import PocketBase # type: ignore[import]
except ImportError:
print("ERROR: pocketbase package not installed.")
print("Run: pip install pocketbase")
sys.exit(1)
pb = PocketBase(POCKETBASE_BASE_URL)
pb.admins.auth_with_password(ADMIN_EMAIL, ADMIN_PASSWORD)
return pb
def _existing_collections(pb) -> set[str]:
try:
collections = pb.collections.get_full_list()
return {c.name for c in collections}
except Exception:
return set()
def _create_if_missing(pb, name: str, schema: dict, existing: set[str]):
if name in existing:
print(f" skip {name} (already exists)")
return
try:
pb.collections.create(schema)
print(f" create {name}")
except Exception as exc:
print(f" ERROR creating {name}: {exc}")
def main():
_require_env()
print(f"Connecting to PocketBase at {POCKETBASE_BASE_URL} ...")
pb = _get_client()
print("Authenticated as admin.")
existing = _existing_collections(pb)
print(f"Found {len(existing)} existing collection(s): {sorted(existing) or '(none)'}\n")
# Access control is enforced in the application layer, not by PocketBase
# collection rules: the backend connects with a single admin-authenticated
# client (see services/pocketbase_client.py), which bypasses collection
# RBAC entirely, so the rules below stay empty by design. Per-user session
# isolation is implemented in PocketBaseSessionStore by stamping every
# session row with ``user_id`` and filtering every query by the current
# user. Do NOT rely on these listRule/viewRule strings for isolation.
collections = [
# ----------------------------------------------------------------
# sessions (``user_id`` populated + filtered by PocketBaseSessionStore)
# ----------------------------------------------------------------
{
"name": "sessions",
"type": "base",
"schema": [
{"name": "session_id", "type": "text", "required": True},
{"name": "user_id", "type": "text", "required": False},
{"name": "title", "type": "text", "required": False},
{"name": "compressed_summary", "type": "text", "required": False},
{"name": "summary_up_to_msg_id", "type": "number", "required": False},
{"name": "preferences_json", "type": "json", "required": False},
{"name": "capability", "type": "text", "required": False},
{"name": "status", "type": "text", "required": False},
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
},
# ----------------------------------------------------------------
# messages
# ----------------------------------------------------------------
{
"name": "messages",
"type": "base",
"schema": [
{"name": "session_id", "type": "text", "required": True},
{"name": "role", "type": "text", "required": True},
{"name": "content", "type": "text", "required": False},
{"name": "capability", "type": "text", "required": False},
{"name": "events_json", "type": "json", "required": False},
{"name": "attachments_json", "type": "json", "required": False},
{"name": "msg_created_at", "type": "number", "required": False},
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
},
# ----------------------------------------------------------------
# turns
# ----------------------------------------------------------------
{
"name": "turns",
"type": "base",
"schema": [
{"name": "turn_id", "type": "text", "required": True},
{"name": "session_id", "type": "text", "required": True},
{"name": "capability", "type": "text", "required": False},
{"name": "status", "type": "text", "required": False},
{"name": "error", "type": "text", "required": False},
{"name": "turn_created_at", "type": "number", "required": False},
{"name": "turn_updated_at", "type": "number", "required": False},
{"name": "finished_at", "type": "number", "required": False},
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
},
# ----------------------------------------------------------------
# turn_events
# ----------------------------------------------------------------
{
"name": "turn_events",
"type": "base",
"schema": [
{"name": "turn_id", "type": "text", "required": True},
{"name": "session_id", "type": "text", "required": False},
{"name": "seq", "type": "number", "required": True},
{"name": "type", "type": "text", "required": False},
{"name": "source", "type": "text", "required": False},
{"name": "stage", "type": "text", "required": False},
{"name": "content", "type": "text", "required": False},
{"name": "metadata_json", "type": "json", "required": False},
{"name": "event_timestamp", "type": "number", "required": False},
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
},
# ----------------------------------------------------------------
# knowledge_bases
# ----------------------------------------------------------------
{
"name": "knowledge_bases",
"type": "base",
"schema": [
{"name": "kb_name", "type": "text", "required": True},
{"name": "user_id", "type": "text", "required": False},
{"name": "description", "type": "text", "required": False},
{"name": "rag_provider", "type": "text", "required": False},
{"name": "needs_reindex", "type": "bool", "required": False},
{"name": "status", "type": "text", "required": False},
{"name": "kb_created_at", "type": "text", "required": False},
{
"name": "raw_files",
"type": "file",
"required": False,
"options": {"maxSelect": 99, "maxSize": 52428800},
},
],
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
},
]
print("Creating collections:")
for col in collections:
_create_if_missing(pb, col["name"], col, existing)
print("\nDone. PocketBase collections are ready.")
print(f"Open the admin panel at {POCKETBASE_BASE_URL}/_/ to view and configure collections.")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python
"""Prepare ``deeptutor_web`` package data from a Next.js standalone build."""
from __future__ import annotations
import argparse
from pathlib import Path
import shutil
import subprocess
PROJECT_ROOT = Path(__file__).resolve().parent.parent
WEB_DIR = PROJECT_ROOT / "web"
PACKAGE_DIR = PROJECT_ROOT / "deeptutor_web"
def _clean_package_dir(package_dir: Path) -> None:
package_dir.mkdir(parents=True, exist_ok=True)
init_file = package_dir / "__init__.py"
init_text = init_file.read_text(encoding="utf-8") if init_file.exists() else ""
for child in package_dir.iterdir():
if child.name == "__init__.py":
continue
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
if init_text:
init_file.write_text(init_text, encoding="utf-8")
def prepare_web_package(*, skip_build: bool = False) -> None:
if not skip_build:
subprocess.run(["npm", "run", "build"], cwd=WEB_DIR, check=True)
standalone = WEB_DIR / ".next" / "standalone"
static_dir = WEB_DIR / ".next" / "static"
public_dir = WEB_DIR / "public"
if not (standalone / "server.js").exists():
raise SystemExit(
"Missing web/.next/standalone/server.js. Run this script after `npm run build` "
"or omit --skip-build."
)
_clean_package_dir(PACKAGE_DIR)
shutil.copytree(standalone, PACKAGE_DIR, dirs_exist_ok=True)
if static_dir.exists():
shutil.copytree(static_dir, PACKAGE_DIR / ".next" / "static", dirs_exist_ok=True)
if public_dir.exists():
shutil.copytree(public_dir, PACKAGE_DIR / "public", dirs_exist_ok=True)
(PACKAGE_DIR / "BUILD_INFO").write_text(
"Generated by scripts/prepare_web_package.py from web/.next/standalone.\n",
encoding="utf-8",
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--skip-build",
action="store_true",
help="Copy an existing web/.next/standalone build without running npm.",
)
args = parser.parse_args()
prepare_web_package(skip_build=args.skip_build)
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
@echo off
REM DeepTutor Backend Startup Script
REM Activates virtual environment and starts the backend API server
REM Move to the project root (this script lives in scripts/)
cd /d "%~dp0.."
REM Set UTF-8 encoding for stdout to support emoji characters
set PYTHONIOENCODING=utf-8
set PYTHONUTF8=1
echo Activating Python virtual environment...
call .venv\Scripts\activate.bat
if errorlevel 1 (
echo ERROR: Failed to activate virtual environment. Make sure .venv exists.
pause
exit /b 1
)
echo Starting DeepTutor Backend Server...
echo Backend will be available at: http://localhost:8001
echo Press Ctrl+C to stop the server.
python -m deeptutor.api.run_server
pause
+12
View File
@@ -0,0 +1,12 @@
@echo off
REM DeepTutor Frontend Startup Script
REM Starts the frontend Next.js development server
REM Move to the project root (this script lives in scripts/)
cd /d "%~dp0.."
echo Starting DeepTutor Frontend...
echo Frontend will be available at: http://localhost:3782
echo Press Ctrl+C to stop the server.
cd web
npm run dev -- -p 3782
pause
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python
"""DeepTutor settings tour.
This script configures the runtime files under ``data/user/settings`` only.
It does not install Python packages, install Node dependencies, or start the
Web app. For day-to-day use prefer:
deeptutor init
deeptutor start
"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from deeptutor_cli.init_cmd import run_init # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create or update DeepTutor settings under data/user/settings.",
)
parser.add_argument(
"--cli",
action="store_true",
help="Configure for CLI-only use and skip Web port prompts.",
)
parser.add_argument(
"--home",
type=Path,
default=None,
help="Runtime workspace root. Defaults to the current directory.",
)
return parser
def main(argv: list[str] | None = None) -> None:
args = build_parser().parse_args(argv)
print("DeepTutor settings tour")
print("Writing configuration to data/user/settings; no dependencies will be installed.")
run_init(cli_only=args.cli, home=args.home)
if args.cli:
print("\nNext: deeptutor chat")
else:
print("\nNext: deeptutor start")
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python
"""Compatibility wrapper for ``deeptutor start``."""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from deeptutor.runtime.launcher import start # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Start DeepTutor Web.")
parser.add_argument(
"--home",
type=Path,
default=None,
help="Runtime workspace root. Defaults to the current directory.",
)
return parser
def main(argv: list[str] | None = None) -> None:
start(home=build_parser().parse_args(argv).home)
if __name__ == "__main__":
main()
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python
"""Safely update a local DeepTutor git checkout.
The updater is intentionally conservative:
1. Fetch the remote for the current branch.
2. Show the local-vs-remote gap and ask for confirmation.
3. Fast-forward pull only when the branch can be updated safely.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import subprocess
import sys
from typing import Sequence
PROJECT_ROOT = Path(__file__).resolve().parent.parent
class UpdateError(Exception):
"""Raised for user-actionable update failures."""
@dataclass(frozen=True)
class GitResult:
args: tuple[str, ...]
returncode: int
stdout: str
stderr: str
@dataclass(frozen=True)
class BranchTarget:
local_branch: str
remote: str
remote_branch: str
remote_ref: str
upstream: str | None
source: str
@property
def display_remote_ref(self) -> str:
return f"{self.remote}/{self.remote_branch}"
@dataclass(frozen=True)
class BranchGap:
local_sha: str
local_subject: str
remote_sha: str
remote_subject: str
ahead: int
behind: int
incoming_commits: list[str]
outgoing_commits: list[str]
diff_stat: str
dirty_entries: list[str]
@property
def is_up_to_date(self) -> bool:
return self.ahead == 0 and self.behind == 0
@property
def is_fast_forwardable(self) -> bool:
return self.ahead == 0 and self.behind > 0
@property
def is_diverged(self) -> bool:
return self.ahead > 0 and self.behind > 0
class Git:
def __init__(self, repo_root: Path) -> None:
self.repo_root = repo_root
def run(
self,
args: Sequence[str],
*,
check: bool = True,
timeout: int | None = 60,
) -> GitResult:
cmd = ["git", *args]
completed = subprocess.run(
cmd,
cwd=self.repo_root,
capture_output=True,
encoding="utf-8",
errors="replace",
text=True,
timeout=timeout,
)
result = GitResult(
args=tuple(args),
returncode=completed.returncode,
stdout=completed.stdout.strip(),
stderr=completed.stderr.strip(),
)
if check and result.returncode != 0:
message = result.stderr or result.stdout or f"git {' '.join(args)} failed"
raise UpdateError(message)
return result
def _print_section(title: str) -> None:
print()
print(f"== {title} ==")
def _print_list(title: str, items: list[str], *, empty: str) -> None:
print(f"{title}:")
if not items:
print(f" {empty}")
return
for item in items:
print(f" {item}")
def ensure_git_checkout(git: Git) -> None:
inside = git.run(["rev-parse", "--is-inside-work-tree"]).stdout
if inside != "true":
raise UpdateError("This directory is not inside a git checkout.")
def current_branch(git: Git) -> str:
branch = git.run(["branch", "--show-current"]).stdout
if not branch:
raise UpdateError(
"Detached HEAD detected. Please switch to a branch before running the updater."
)
return branch
def git_config(git: Git, key: str) -> str | None:
result = git.run(["config", "--get", key], check=False)
if result.returncode != 0:
return None
return result.stdout or None
def available_remotes(git: Git) -> list[str]:
remotes = git.run(["remote"], check=False).stdout.splitlines()
return [remote.strip() for remote in remotes if remote.strip()]
def normalize_merge_ref(merge_ref: str | None, fallback_branch: str) -> str:
if not merge_ref:
return fallback_branch
prefix = "refs/heads/"
if merge_ref.startswith(prefix):
return merge_ref[len(prefix) :]
return merge_ref
def resolve_branch_target(git: Git, branch: str) -> BranchTarget:
remote = git_config(git, f"branch.{branch}.remote")
merge_ref = git_config(git, f"branch.{branch}.merge")
if remote and remote != ".":
remote_branch = normalize_merge_ref(merge_ref, branch)
return BranchTarget(
local_branch=branch,
remote=remote,
remote_branch=remote_branch,
remote_ref=f"refs/remotes/{remote}/{remote_branch}",
upstream=f"{remote}/{remote_branch}",
source="configured upstream",
)
remotes = available_remotes(git)
if not remotes:
raise UpdateError("No git remote is configured for this checkout.")
selected_remote = "origin" if "origin" in remotes else remotes[0]
return BranchTarget(
local_branch=branch,
remote=selected_remote,
remote_branch=branch,
remote_ref=f"refs/remotes/{selected_remote}/{branch}",
upstream=None,
source=f"default remote '{selected_remote}' with matching branch name",
)
def fetch_remote(git: Git, target: BranchTarget) -> None:
print(f"Fetching latest refs from {target.remote} ...")
git.run(["fetch", "--prune", target.remote], timeout=None)
def verify_remote_branch(git: Git, target: BranchTarget) -> None:
result = git.run(
["rev-parse", "--verify", "--quiet", f"{target.remote_ref}^{{commit}}"],
check=False,
)
if result.returncode == 0:
return
raise UpdateError(
"Remote branch not found after fetch: "
f"{target.display_remote_ref}. Set the branch upstream or push it first."
)
def short_commit(git: Git, ref: str) -> tuple[str, str]:
sha = git.run(["rev-parse", "--short", ref]).stdout
subject = git.run(["log", "-1", "--format=%s", ref]).stdout
return sha, subject
def log_lines(git: Git, revision_range: str, limit: int = 8) -> list[str]:
result = git.run(
["log", "--oneline", "--decorate", f"--max-count={limit}", revision_range],
check=False,
)
if result.returncode != 0 or not result.stdout:
return []
return result.stdout.splitlines()
def tracked_dirty_entries(git: Git) -> list[str]:
git.run(["update-index", "-q", "--refresh"], check=False)
result = git.run(["status", "--porcelain=v1", "--untracked-files=no"], check=False)
if result.returncode != 0 or not result.stdout:
return []
return result.stdout.splitlines()
def analyze_gap(git: Git, target: BranchTarget) -> BranchGap:
local_sha, local_subject = short_commit(git, "HEAD")
remote_sha, remote_subject = short_commit(git, target.remote_ref)
counts = git.run(["rev-list", "--left-right", "--count", f"HEAD...{target.remote_ref}"])
ahead_str, behind_str = counts.stdout.split()
ahead = int(ahead_str)
behind = int(behind_str)
diff_stat = ""
if behind:
diff_stat = git.run(
["diff", "--stat", "--compact-summary", f"HEAD..{target.remote_ref}"]
).stdout
return BranchGap(
local_sha=local_sha,
local_subject=local_subject,
remote_sha=remote_sha,
remote_subject=remote_subject,
ahead=ahead,
behind=behind,
incoming_commits=log_lines(git, f"HEAD..{target.remote_ref}"),
outgoing_commits=log_lines(git, f"{target.remote_ref}..HEAD"),
diff_stat=diff_stat,
dirty_entries=tracked_dirty_entries(git),
)
def print_gap(target: BranchTarget, gap: BranchGap) -> None:
_print_section("Detected branch")
print(f"Local branch: {target.local_branch}")
print(f"Remote branch: {target.display_remote_ref}")
print(f"Selection: {target.source}")
if target.upstream:
print(f"Upstream: {target.upstream}")
else:
print("Upstream: not configured")
_print_section("Local vs remote")
print(f"Local HEAD: {gap.local_sha} {gap.local_subject}")
print(f"Remote HEAD: {gap.remote_sha} {gap.remote_subject}")
print(f"Gap: local is {gap.ahead} commit(s) ahead, {gap.behind} commit(s) behind")
_print_list(
"Incoming commits from remote",
gap.incoming_commits,
empty="none",
)
_print_list(
"Local commits not on remote",
gap.outgoing_commits,
empty="none",
)
if gap.diff_stat:
print("Incoming file summary:")
for line in gap.diff_stat.splitlines():
print(f" {line}")
if gap.dirty_entries:
_print_list(
"Tracked local changes",
gap.dirty_entries,
empty="none",
)
def confirm_update(assume_yes: bool, target: BranchTarget, gap: BranchGap) -> bool:
if assume_yes:
return True
if not sys.stdin.isatty():
print()
print("Confirmation required, but stdin is not interactive. Re-run with --yes to update.")
return False
print()
answer = input(
"Confirm this branch mapping and update "
f"{target.local_branch} from {target.display_remote_ref}? [y/N] "
).strip()
return answer.lower() in {"y", "yes"}
def ensure_safe_to_update(gap: BranchGap) -> None:
if gap.dirty_entries:
raise UpdateError(
"Tracked local changes are present. Commit or stash them before updating."
)
if gap.is_diverged:
raise UpdateError(
"Local and remote branches have diverged. Resolve manually, then rerun the updater."
)
if gap.ahead > 0 and gap.behind == 0:
raise UpdateError(
"Local branch has commits not present on the remote, and there are no remote "
"commits to pull."
)
if not gap.is_fast_forwardable:
raise UpdateError("This branch cannot be updated with a safe fast-forward pull.")
def dependency_hints(changed_files: list[str]) -> list[str]:
hints: list[str] = []
if any(path == "pyproject.toml" or path.startswith("requirements/") for path in changed_files):
hints.append("Backend dependencies changed: consider running python -m pip install -e .")
if any(
path in {"web/package.json", "web/package-lock.json", "web/pnpm-lock.yaml"}
or path == "web/yarn.lock"
for path in changed_files
):
hints.append("Frontend dependencies changed: consider running cd web && npm install")
return hints
def pull_updates(git: Git, target: BranchTarget) -> list[str]:
old_sha = git.run(["rev-parse", "HEAD"]).stdout
_print_section("Updating")
git.run(["pull", "--ff-only", target.remote, target.remote_branch], timeout=None)
new_sha = git.run(["rev-parse", "HEAD"]).stdout
if old_sha == new_sha:
return []
changed = git.run(["diff", "--name-only", f"{old_sha}..{new_sha}"]).stdout
return [line for line in changed.splitlines() if line.strip()]
def run_update(repo_root: Path, *, assume_yes: bool) -> int:
git = Git(repo_root)
ensure_git_checkout(git)
branch = current_branch(git)
target = resolve_branch_target(git, branch)
fetch_remote(git, target)
verify_remote_branch(git, target)
gap = analyze_gap(git, target)
print_gap(target, gap)
if gap.dirty_entries:
raise UpdateError(
"Tracked local changes are present. Commit or stash them before updating."
)
if gap.is_diverged:
raise UpdateError(
"Local and remote branches have diverged. Resolve manually, then rerun the updater."
)
if gap.is_up_to_date:
print()
print("Already up to date. No update is needed.")
return 0
if gap.ahead > 0 and gap.behind == 0:
print()
print("No remote commits to pull. Local branch is ahead of the remote.")
return 0
if not confirm_update(assume_yes, target, gap):
print("Update cancelled.")
return 0
ensure_safe_to_update(gap)
changed_files = pull_updates(git, target)
print()
print(f"Updated {target.local_branch} from {target.display_remote_ref}.")
if changed_files:
print(f"Changed files: {len(changed_files)}")
for hint in dependency_hints(changed_files):
print(f"Next step: {hint}")
print("Restart DeepTutor if it is currently running.")
return 0
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Fetch, review, and fast-forward update a local DeepTutor checkout."
)
parser.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip the interactive confirmation after printing the branch comparison.",
)
parser.add_argument(
"--repo",
type=Path,
default=PROJECT_ROOT,
help="Path to the git checkout to update. Defaults to this DeepTutor repository.",
)
return parser.parse_args(argv)
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
try:
return run_update(args.repo.resolve(), assume_yes=args.yes)
except KeyboardInterrupt:
print()
print("Update cancelled.")
return 130
except (OSError, subprocess.SubprocessError, UpdateError) as exc:
print()
print(f"Update failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())