Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,6 @@ cython_debug/
gitmastery-exercises/

Gemfile.lock

# AI settings
.claude/
13 changes: 10 additions & 3 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 All @@ -21,7 +21,11 @@ def invoke(self, ctx: click.Context) -> None:
CONTEXT_SETTINGS = {"max_content_width": 120}


@click.group(cls=LoggingGroup, context_settings=CONTEXT_SETTINGS)
@click.group(
cls=LoggingGroup,
context_settings=CONTEXT_SETTINGS,
invoke_without_command=True,
)
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
Expand Down Expand Up @@ -51,9 +55,12 @@ def cli(ctx: click.Context, verbose: bool) -> None:
f"Follow the update guide here: {click.style('https://git-mastery.org/companion-app/index.html#updating-the-git-mastery-app', bold=True)}"
)

if ctx.invoked_subcommand is None and not ctx.resilient_parsing:
ctx.invoke(repl)


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
222 changes: 222 additions & 0 deletions app/commands/repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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_msg = r"""
_____ _ _ ___ ___ _
| __ (_) | | \/ | | |
| | \/_| |_| . . | __ _ ___| |_ ___ _ __ _ _
| | __| | __| |\/| |/ _` / __| __/ _ \ '__| | | |
| |_\ \ | |_| | | | (_| \__ \ || __/ | | |_| |
\____/_|\__\_| |_/\__,_|___/\__\___|_| \__, |
__/ |
|___/

Welcome to the Git-Mastery REPL!
Type '/help' for available commands, or '/exit' to quit.
Use /command to run Git-Mastery commands (e.g. /verify), or 'gitmastery command'.
Shell commands are also supported.
"""

intro = click.style(
intro_msg,
bold=True,
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 = f"gitmastery [{cwd}]> "

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."""
stripped = line.strip()
if stripped.startswith("/"):
return "gitmastery " + stripped[1:]
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.lower() == "gitmastery":
gitmastery_command = args[0]
if gitmastery_command in ("exit", "quit"):
return self.do_exit("")
elif gitmastery_command == "help":
self.do_help("")
elif gitmastery_command in GITMASTERY_COMMANDS:
self._run_gitmastery_command(gitmastery_command, args[1:])
else:
click.echo(
click.style(
f"Unknown Git-Mastery command: {gitmastery_command}",
fg=ClickColor.BRIGHT_RED,
)
)
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:
subprocess.run(line, shell=True)
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)
)
except OSError as e:
click.echo(
click.style(f"Cannot change directory: {e}", fg=ClickColor.BRIGHT_RED)
)
return False

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

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

def do_help(self, args: str) -> bool:
"""Show help for commands."""
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.").strip()
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)
)
for name, desc in [
("/help", "Show this help message"),
("/exit", "Exit the REPL"),
("/quit", "Exit the REPL"),
]:
click.echo(f" {click.style(f'{name:<20}', bold=True)} {desc}")
click.echo()
return False

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

def do_EOF(self, _arg: str) -> bool:
"""Handle Ctrl+D."""
click.echo()
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)
23 changes: 21 additions & 2 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,8 +160,9 @@ def load_file_as_namespace(
exec(py_file, namespace)
finally:
sys.path.remove(tmpdir)

sys.dont_write_bytecode = False
# Clean up cached modules again after execution
_clear_exercise_utils_modules()
sys.dont_write_bytecode = False
return cls(namespace)

def execute_function(
Expand Down