Files
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

435 lines
13 KiB
Python

#!/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())