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
435 lines
13 KiB
Python
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())
|