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
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/
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
223 changes: 223 additions & 0 deletions app/commands/repl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
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.
Git-Mastery commands work with or without the 'gitmastery' prefix.
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 = 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."""
stripped = line.strip()
if stripped.lower().startswith("gitmastery "):
return stripped[len("gitmastery ") :].lstrip()
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)
)
Comment on lines +151 to +160
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 do_cd missing NotADirectoryError/OSError handler crashes the REPL

When a user runs cd somefile.txt (pointing to a regular file, not a directory), os.chdir() raises NotADirectoryError. This exception is not caught by the except FileNotFoundError or except PermissionError handlers at app/commands/repl.py:140-147, causing it to propagate uncaught through cmd.Cmd.cmdloop() and crash the entire REPL session with a traceback.

Root Cause

NotADirectoryError is a subclass of OSError but NOT a subclass of FileNotFoundError or PermissionError. The do_cd method only catches those two specific exceptions, missing NotADirectoryError and other possible OSError subclasses.

Notably, the _run_gitmastery_command method in the same file correctly catches the broader OSError at app/commands/repl.py:106:

except (FileNotFoundError, PermissionError, OSError) as e:

but do_cd does not follow the same pattern.

Impact: Any cd to a non-directory path (e.g., a regular file) or a path triggering other OSError variants (symlink loops, etc.) will crash the REPL, forcing the user to restart their session.

Suggested change
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)
)
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)
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

except OSError as e:
click.echo(
click.style(f"Cannot change directory: {e}", 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:
"""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(
click.style(
"\nAll other commands are passed to the shell.",
fg=ClickColor.BRIGHT_YELLOW,
)
)
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()
Comment on lines 161 to +164
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 sys.dont_write_bytecode not reset in finally block, stays True for entire REPL session on failure

If exec() at app/utils/gitmastery.py:160 raises an exception, the finally block at lines 161-164 cleans up sys.path and cached modules, but sys.dont_write_bytecode = False at line 166 is outside the try/finally and is never reached. In the REPL context (where the process persists across commands), this leaves sys.dont_write_bytecode = True for the remainder of the session.

Root Cause

The PR correctly added _clear_exercise_utils_modules() inside the finally block at line 164 to handle REPL persistence, but didn't move sys.dont_write_bytecode = False (line 166) into the same finally block. When exec(py_file, namespace) at line 160 throws, the exception propagates through the with tempfile.TemporaryDirectory() context and skips line 166 entirely.

In the normal CLI flow this was harmless because the process exits after each command. But now with the REPL (app/commands/repl.py), the process persists. A failed verify or download command that triggers this path will leave sys.dont_write_bytecode = True, suppressing .pyc generation for all subsequent operations in the session.

Impact: After a single load_file_as_namespace failure in the REPL, Python bytecode caching is disabled for the rest of the session, degrading performance for all subsequent imports.

Suggested change
finally:
sys.path.remove(tmpdir)
# Clean up cached modules again after execution
_clear_exercise_utils_modules()
finally:
sys.path.remove(tmpdir)
# Clean up cached modules again after execution
_clear_exercise_utils_modules()
sys.dont_write_bytecode = False
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

sys.dont_write_bytecode = False
return cls(namespace)

def execute_function(
Expand Down