From cf2c721d5bbcc459a6111c8ce70dd4bbf6c0b2e3 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sun, 8 Feb 2026 17:12:16 +0800 Subject: [PATCH 01/12] feat: Initial draft of REPL --- app/cli.py | 4 +- app/commands/__init__.py | 3 +- app/commands/repl.py | 193 +++++++++++++++++++++++++++++++++++++++ app/utils/gitmastery.py | 18 ++++ 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 app/commands/repl.py diff --git a/app/cli.py b/app/cli.py index f202821..f20ebb0 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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 @@ -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={}) diff --git a/app/commands/__init__.py b/app/commands/__init__.py index 1455c3e..7d59f09 100644 --- a/app/commands/__init__.py +++ b/app/commands/__init__.py @@ -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 diff --git a/app/commands/repl.py b/app/commands/repl.py new file mode 100644 index 0000000..f570f1b --- /dev/null +++ b/app/commands/repl.py @@ -0,0 +1,193 @@ +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 + + +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.""" + parts = shlex.split(line) + 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] + try: + # Initialize context with obj dict matching CLI setup + ctx = command.make_context(command_name, args) + ctx.ensure_object(dict) + ctx.obj[CliContextKey.VERBOSE] = False + 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)) + + 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("~") + 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(name, bold=True):20} {help_text}") + + click.echo( + click.style("\nBuilt-in Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN) + ) + click.echo(f" {click.style('help', bold=True):20} Show this help message") + click.echo(f" {click.style('exit', bold=True):20} Exit the REPL") + click.echo(f" {click.style('quit', bold=True):20} 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) diff --git a/app/utils/gitmastery.py b/app/utils/gitmastery.py index a2f5ff6..55d6fe0 100644 --- a/app/utils/gitmastery.py +++ b/app/utils/gitmastery.py @@ -126,6 +126,16 @@ 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 + # This is especially important in REPL context where modules persist + 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] + with tempfile.TemporaryDirectory() as tmpdir: package_root = os.path.join(tmpdir, "exercise_utils") os.makedirs(package_root, exist_ok=True) @@ -142,6 +152,14 @@ def load_file_as_namespace( exec(py_file, namespace) finally: sys.path.remove(tmpdir) + # Clean up cached modules again after execution + 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] sys.dont_write_bytecode = False return cls(namespace) From 7f9b54f97d9417b73e3a26dd56e1cda51296a2cf Mon Sep 17 00:00:00 2001 From: jia xin Date: Sun, 8 Feb 2026 17:21:02 +0800 Subject: [PATCH 02/12] fix: Save cwd to restore after command execution --- app/commands/repl.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index f570f1b..2bbb34d 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -77,8 +77,8 @@ def default(self, line: str) -> None: 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: - # Initialize context with obj dict matching CLI setup ctx = command.make_context(command_name, args) ctx.ensure_object(dict) ctx.obj[CliContextKey.VERBOSE] = False @@ -92,6 +92,8 @@ def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: pass except Exception as e: click.echo(click.style(f"Error: {e}", fg=ClickColor.BRIGHT_RED)) + finally: + os.chdir(original_cwd) def _run_shell_command(self, line: str) -> None: """Execute a shell command via subprocess.""" From 2155ff44993e7f45c9354bfdc23f6873479fb67c Mon Sep 17 00:00:00 2001 From: jia xin Date: Mon, 9 Feb 2026 13:35:45 +0800 Subject: [PATCH 03/12] Fix version command --- app/commands/repl.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/commands/repl.py b/app/commands/repl.py index 2bbb34d..e0f36ab 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -14,6 +14,8 @@ 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 = { @@ -82,6 +84,7 @@ def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: 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: From 0cf98fb76a8601c8454a10f4869d8fcc286e4ca8 Mon Sep 17 00:00:00 2001 From: jia xin Date: Wed, 25 Feb 2026 15:00:26 +0800 Subject: [PATCH 04/12] Address PR comments --- app/commands/repl.py | 17 +++++++++++++++-- app/utils/gitmastery.py | 32 +++++++++++++++++--------------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index e0f36ab..a09434d 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -63,7 +63,12 @@ def precmd(self, line: str) -> str: def default(self, line: str) -> None: """Handle commands not recognized by cmd module.""" - parts = shlex.split(line) + 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 @@ -96,7 +101,15 @@ def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: except Exception as e: click.echo(click.style(f"Error: {e}", fg=ClickColor.BRIGHT_RED)) finally: - os.chdir(original_cwd) + 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.""" diff --git a/app/utils/gitmastery.py b/app/utils/gitmastery.py index 55d6fe0..a70d00a 100644 --- a/app/utils/gitmastery.py +++ b/app/utils/gitmastery.py @@ -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. @@ -127,14 +142,7 @@ def load_file_as_namespace( namespace: Dict[str, Any] = {} # Clear any cached exercise_utils modules to ensure fresh imports - # This is especially important in REPL context where modules persist - 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] + _clear_exercise_utils_modules() with tempfile.TemporaryDirectory() as tmpdir: package_root = os.path.join(tmpdir, "exercise_utils") @@ -153,13 +161,7 @@ def load_file_as_namespace( finally: sys.path.remove(tmpdir) # Clean up cached modules again after execution - 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] + _clear_exercise_utils_modules() sys.dont_write_bytecode = False return cls(namespace) From cd826555d49ec68b507f6b6ee2376e166edfd30a Mon Sep 17 00:00:00 2001 From: jia xin Date: Wed, 25 Feb 2026 15:46:46 +0800 Subject: [PATCH 05/12] Fix styling issues and cd parsing --- app/commands/repl.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index a09434d..8019827 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -129,6 +129,12 @@ 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: @@ -171,14 +177,14 @@ def do_help(self, arg: str) -> bool: # type: ignore[override] ) for name, command in GITMASTERY_COMMANDS.items(): help_text = command.help or "No description available." - click.echo(f" {click.style(name, bold=True):20} {help_text}") + 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('help', bold=True):20} Show this help message") - click.echo(f" {click.style('exit', bold=True):20} Exit the REPL") - click.echo(f" {click.style('quit', bold=True):20} Exit the REPL") + 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( From ba119e9541c41f0a2afa1b347e6fe1245ba3fb59 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sun, 1 Mar 2026 20:02:19 +0800 Subject: [PATCH 06/12] Address code review comments --- .gitignore | 3 +++ app/commands/repl.py | 34 +++++++++++++--------------------- app/utils/gitmastery.py | 3 +-- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 4fef2be..6b69ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,6 @@ cython_debug/ gitmastery-exercises/ Gemfile.lock + +# AI settings +.claude/ diff --git a/app/commands/repl.py b/app/commands/repl.py index 8019827..9fa58b8 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -55,7 +55,6 @@ def postcmd(self, stop: bool, line: str) -> bool: 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 ") :] @@ -145,6 +144,10 @@ def do_cd(self, path: str) -> bool: 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, arg: str) -> bool: @@ -156,35 +159,24 @@ 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] + def do_help(self, arg: str) -> bool: """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." + 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) ) - 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") + 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( @@ -201,7 +193,7 @@ def emptyline(self) -> bool: # type: ignore[override] def do_EOF(self, arg: str) -> bool: """Handle Ctrl+D.""" - click.echo() # Print newline + click.echo() return self.do_exit(arg) diff --git a/app/utils/gitmastery.py b/app/utils/gitmastery.py index a70d00a..6a850c1 100644 --- a/app/utils/gitmastery.py +++ b/app/utils/gitmastery.py @@ -162,8 +162,7 @@ def load_file_as_namespace( sys.path.remove(tmpdir) # Clean up cached modules again after execution _clear_exercise_utils_modules() - - sys.dont_write_bytecode = False + sys.dont_write_bytecode = False return cls(namespace) def execute_function( From 282578e84df2350db9ada25340451f6d913f6740 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sun, 1 Mar 2026 20:32:15 +0800 Subject: [PATCH 07/12] Improve intro message banner --- app/commands/repl.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index 9fa58b8..f327301 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -31,11 +31,25 @@ 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( - "\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", + intro_msg, + bold=True, fg=ClickColor.BRIGHT_CYAN, ) @@ -56,8 +70,8 @@ def postcmd(self, stop: bool, line: str) -> bool: def precmd(self, line: str) -> str: """Pre-process command line before execution.""" stripped = line.strip() - if stripped.startswith("gitmastery "): - return stripped[len("gitmastery ") :] + if stripped.lower().startswith("gitmastery "): + return stripped[len("gitmastery ") :].lstrip() return line def default(self, line: str) -> None: @@ -187,7 +201,7 @@ def do_help(self, arg: str) -> bool: click.echo() return False - def emptyline(self) -> bool: # type: ignore[override] + def emptyline(self) -> bool: """Do nothing on empty line (don't repeat last command).""" return False From 1d90513a973809bf2066a38150dee8fc3b0cbf39 Mon Sep 17 00:00:00 2001 From: jia xin Date: Wed, 4 Mar 2026 17:17:21 +0800 Subject: [PATCH 08/12] Fix wrapping issue on long commands --- app/commands/repl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index f327301..603c834 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -60,7 +60,7 @@ def __init__(self) -> None: 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) + self.prompt = f"gitmastery [{cwd}]> " def postcmd(self, stop: bool, line: str) -> bool: """Update prompt after each command.""" From 6334e370bb43e09a499f564f25dcaac8db71a297 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 7 Mar 2026 14:55:23 +0800 Subject: [PATCH 09/12] Change method of invoking REPL --- app/cli.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/cli.py b/app/cli.py index f20ebb0..355dbb1 100644 --- a/app/cli.py +++ b/app/cli.py @@ -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: @@ -51,6 +55,9 @@ 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, repl, setup, verify, version] From de2c97aabd4c27e78c20125bdd53c3fc3fcd477c Mon Sep 17 00:00:00 2001 From: jia xin Date: Mon, 9 Mar 2026 13:02:25 +0800 Subject: [PATCH 10/12] Change command parsing --- app/commands/repl.py | 70 ++++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index 603c834..f2b9514 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -42,8 +42,8 @@ class GitMasteryREPL(cmd.Cmd): |___/ 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. +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. """ @@ -70,29 +70,43 @@ def postcmd(self, stop: bool, line: str) -> bool: 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() + if stripped.startswith("/"): + return "gitmastery " + stripped[1:] return line - def default(self, line: str) -> None: + def default(self, line: str) -> bool: """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 + return False if not parts: - return + return False command_name = parts[0] args = parts[1:] - if command_name in GITMASTERY_COMMANDS: - self._run_gitmastery_command(command_name, args) - return + if command_name.lower() == "gitmastery": + gitmastery_command = args[0] + if gitmastery_command in ("exit", "quit"): + return self.do_exit("") + elif gitmastery_command == "help": + return 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 False self._run_shell_command(line) + return False def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: """Execute a gitmastery command.""" @@ -127,14 +141,7 @@ def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: 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, - ) - ) + subprocess.run(line, shell=True) except Exception as e: click.echo(click.style(f"Shell error: {e}", fg=ClickColor.BRIGHT_RED)) @@ -164,40 +171,33 @@ def do_cd(self, path: str) -> bool: ) return False - def do_exit(self, arg: str) -> bool: + 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, arg: str) -> bool: + def do_quit(self, args: str) -> bool: """Exit the Git-Mastery REPL.""" - return self.do_exit(arg) + return self.do_exit(args) - def do_help(self, arg: str) -> bool: + 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(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"), + ("/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 @@ -205,10 +205,10 @@ def emptyline(self) -> bool: """Do nothing on empty line (don't repeat last command).""" return False - def do_EOF(self, arg: str) -> bool: + def do_EOF(self, _arg: str) -> bool: """Handle Ctrl+D.""" click.echo() - return self.do_exit(arg) + return self.do_exit(_arg) @click.command() From 72cc023d4503c2bda3914f92fc7afff5668fa543 Mon Sep 17 00:00:00 2001 From: jia xin Date: Wed, 11 Mar 2026 13:28:55 +0800 Subject: [PATCH 11/12] Fix type error --- app/commands/repl.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index f2b9514..1eeffbb 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -74,16 +74,16 @@ def precmd(self, line: str) -> str: return "gitmastery " + stripped[1:] return line - def default(self, line: str) -> bool: + 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 False + return if not parts: - return False + return command_name = parts[0] args = parts[1:] @@ -91,9 +91,9 @@ def default(self, line: str) -> bool: if command_name.lower() == "gitmastery": gitmastery_command = args[0] if gitmastery_command in ("exit", "quit"): - return self.do_exit("") + self.do_exit("") elif gitmastery_command == "help": - return self.do_help("") + self.do_help("") elif gitmastery_command in GITMASTERY_COMMANDS: self._run_gitmastery_command(gitmastery_command, args[1:]) else: @@ -103,10 +103,9 @@ def default(self, line: str) -> bool: fg=ClickColor.BRIGHT_RED, ) ) - return False + return self._run_shell_command(line) - return False def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: """Execute a gitmastery command.""" From 98455c6a895584a8c96292b7d42949c9dcab0069 Mon Sep 17 00:00:00 2001 From: jia xin Date: Wed, 11 Mar 2026 14:05:44 +0800 Subject: [PATCH 12/12] Fix exit and quit commands --- app/commands/repl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/commands/repl.py b/app/commands/repl.py index 1eeffbb..c12fdc6 100644 --- a/app/commands/repl.py +++ b/app/commands/repl.py @@ -91,7 +91,7 @@ def default(self, line: str) -> None: if command_name.lower() == "gitmastery": gitmastery_command = args[0] if gitmastery_command in ("exit", "quit"): - self.do_exit("") + return self.do_exit("") elif gitmastery_command == "help": self.do_help("") elif gitmastery_command in GITMASTERY_COMMANDS: