Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import click
import requests

from app.commands import check, download, progress, setup, verify
from app.commands import check, download, progress, repl, setup, verify
from app.commands.version import version
from app.utils.click import ClickColor, CliContextKey, warn
from app.utils.version import Version
Expand Down Expand Up @@ -53,7 +53,7 @@ def cli(ctx: click.Context, verbose: bool) -> None:


def start() -> None:
commands = [check, download, progress, setup, verify, version]
commands = [check, download, progress, repl, setup, verify, version]
for command in commands:
cli.add_command(command)
cli(obj={})
3 changes: 2 additions & 1 deletion app/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
__all__ = ["check", "download", "progress", "setup", "verify", "version"]
__all__ = ["check", "download", "progress", "repl", "setup", "verify", "version"]

from .check import check
from .download import download
from .progress.progress import progress
from .repl import repl
from .setup_folder import setup
from .verify import verify
from .version import version
217 changes: 217 additions & 0 deletions app/commands/repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import cmd
import os
import shlex
import subprocess
import sys
from typing import List

import click

from app.commands.check import check
from app.commands.download import download
from app.commands.progress.progress import progress
from app.commands.setup_folder import setup
from app.commands.verify import verify
from app.commands.version import version
from app.utils.click import CliContextKey, ClickColor
from app.utils.version import Version
from app.version import __version__


GITMASTERY_COMMANDS = {
"check": check,
"download": download,
"progress": progress,
"setup": setup,
"verify": verify,
"version": version,
}


class GitMasteryREPL(cmd.Cmd):
"""Interactive REPL for Git-Mastery commands."""

intro = click.style(
"\nWelcome to the Git-Mastery REPL!\n"
"Type 'help' for available commands, or 'exit' to quit.\n"
"Git-Mastery commands work with or without the 'gitmastery' prefix.\n"
"Shell commands are also supported.\n",
fg=ClickColor.BRIGHT_CYAN,
)

def __init__(self) -> None:
super().__init__()
self._update_prompt()

def _update_prompt(self) -> None:
"""Update prompt to show current directory."""
cwd = os.path.basename(os.getcwd()) or os.getcwd()
self.prompt = click.style(f"gitmastery [{cwd}]> ", fg=ClickColor.BRIGHT_GREEN)

def postcmd(self, stop: bool, line: str) -> bool:
"""Update prompt after each command."""
self._update_prompt()
return stop

def precmd(self, line: str) -> str:
"""Pre-process command line before execution."""
# Strip 'gitmastery' prefix if present
stripped = line.strip()
if stripped.startswith("gitmastery "):
return stripped[len("gitmastery ") :]
return line

def default(self, line: str) -> None:
"""Handle commands not recognized by cmd module."""
try:
parts = shlex.split(line)
except ValueError as e:
click.echo(click.style(f"Input error: {e}", fg=ClickColor.BRIGHT_RED))
return

if not parts:
return

command_name = parts[0]
args = parts[1:]

if command_name in GITMASTERY_COMMANDS:
self._run_gitmastery_command(command_name, args)
return

self._run_shell_command(line)

def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None:
"""Execute a gitmastery command."""
command = GITMASTERY_COMMANDS[command_name]
original_cwd = os.getcwd()
try:
ctx = command.make_context(command_name, args)
ctx.ensure_object(dict)
ctx.obj[CliContextKey.VERBOSE] = False
ctx.obj[CliContextKey.VERSION] = Version.parse_version_string(__version__)
with ctx:
command.invoke(ctx)
except click.ClickException as e:
e.show()
except click.Abort:
click.echo("Aborted.")
except SystemExit:
pass
except Exception as e:
click.echo(click.style(f"Error: {e}", fg=ClickColor.BRIGHT_RED))
finally:
try:
os.chdir(original_cwd)
except (FileNotFoundError, PermissionError, OSError) as e:
click.echo(
click.style(
f"Warning: Could not restore original directory: {e}",
fg=ClickColor.BRIGHT_YELLOW,
)
)

def _run_shell_command(self, line: str) -> None:
"""Execute a shell command via subprocess."""
try:
result = subprocess.run(line, shell=True)
if result.returncode != 0:
click.echo(
click.style(
f"Command exited with code {result.returncode}",
fg=ClickColor.BRIGHT_YELLOW,
)
)
except Exception as e:
click.echo(click.style(f"Shell error: {e}", fg=ClickColor.BRIGHT_RED))

def do_cd(self, path: str) -> bool:
"""Change directory."""
if not path:
path = os.path.expanduser("~")
else:
try:
parts = shlex.split(path)
path = parts[0] if parts else ""
except ValueError:
pass
try:
os.chdir(os.path.expanduser(path))
except FileNotFoundError:
click.echo(
click.style(f"Directory not found: {path}", fg=ClickColor.BRIGHT_RED)
)
except PermissionError:
click.echo(
click.style(f"Permission denied: {path}", fg=ClickColor.BRIGHT_RED)
)
return False

def do_exit(self, arg: str) -> bool:
"""Exit the Git-Mastery REPL."""
click.echo(click.style("Goodbye!", fg=ClickColor.BRIGHT_CYAN))
return True

def do_quit(self, arg: str) -> bool:
"""Exit the Git-Mastery REPL."""
return self.do_exit(arg)

def do_help(self, arg: str) -> bool: # type: ignore[override]
"""Show help for commands."""
if arg:
# Check if it's a gitmastery command
if arg in GITMASTERY_COMMANDS:
command = GITMASTERY_COMMANDS[arg]
click.echo(f"\n{arg}: {command.help or 'No description available.'}\n")
# Show command usage
with click.Context(command) as ctx:
click.echo(command.get_help(ctx))
return False
# Fall back to cmd module's help
super().do_help(arg)
return False

# Show general help
click.echo(
click.style("\nGit-Mastery Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN)
)
for name, command in GITMASTERY_COMMANDS.items():
help_text = command.help or "No description available."
click.echo(f" {click.style(f'{name:<20}', bold=True)} {help_text}")

click.echo(
click.style("\nBuilt-in Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN)
)
click.echo(f" {click.style(f'{'help':<20}', bold=True)} Show this help message")
click.echo(f" {click.style(f'{'exit':<20}', bold=True)} Exit the REPL")
click.echo(f" {click.style(f'{'quit':<20}', bold=True)} Exit the REPL")

click.echo(
click.style(
"\nAll other commands are passed to the shell.",
fg=ClickColor.BRIGHT_YELLOW,
)
)
click.echo()
return False

def emptyline(self) -> bool: # type: ignore[override]
"""Do nothing on empty line (don't repeat last command)."""
return False

def do_EOF(self, arg: str) -> bool:
"""Handle Ctrl+D."""
click.echo() # Print newline
return self.do_exit(arg)


@click.command()
def repl() -> None:
"""Start an interactive REPL session."""
repl_instance = GitMasteryREPL()

try:
repl_instance.cmdloop()
except KeyboardInterrupt:
click.echo(click.style("\nInterrupted. Goodbye!", fg=ClickColor.BRIGHT_CYAN))
sys.exit(0)
20 changes: 20 additions & 0 deletions app/utils/gitmastery.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@
]


def _clear_exercise_utils_modules() -> None:
"""Clear cached exercise_utils modules from sys.modules.

This is especially important in REPL context where modules persist
between command invocations.
"""
modules_to_remove = [
key
for key in sys.modules
if key == "exercise_utils" or key.startswith("exercise_utils.")
]
for mod in modules_to_remove:
del sys.modules[mod]


class ExercisesRepo:
def __init__(self) -> None:
"""Creates a sparse clone of the exercises repository.
Expand Down Expand Up @@ -126,6 +141,9 @@ def load_file_as_namespace(
py_file = exercises_repo.fetch_file_contents(file_path, False)
namespace: Dict[str, Any] = {}

# Clear any cached exercise_utils modules to ensure fresh imports
_clear_exercise_utils_modules()

with tempfile.TemporaryDirectory() as tmpdir:
package_root = os.path.join(tmpdir, "exercise_utils")
os.makedirs(package_root, exist_ok=True)
Expand All @@ -142,6 +160,8 @@ def load_file_as_namespace(
exec(py_file, namespace)
finally:
sys.path.remove(tmpdir)
# Clean up cached modules again after execution
_clear_exercise_utils_modules()

sys.dont_write_bytecode = False
return cls(namespace)
Expand Down