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