From 7a8f684a4fc599ad62fd96183fb31df087f15ccb Mon Sep 17 00:00:00 2001 From: "Edward Z. Yang" Date: Tue, 22 Apr 2025 20:19:41 +0800 Subject: [PATCH 1/3] Update [ghstack-poisoned] --- codemcp/main.py | 47 +++++++++++++++++++++++++ e2e/test_run_command.py | 77 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 e2e/test_run_command.py diff --git a/codemcp/main.py b/codemcp/main.py index ec431ea..db2d599 100644 --- a/codemcp/main.py +++ b/codemcp/main.py @@ -14,6 +14,7 @@ from starlette.applications import Starlette from starlette.routing import Mount +from .code_command import get_command_from_config from .common import normalize_file_path from .git_query import get_current_commit_hash from .tools.chmod import chmod @@ -861,6 +862,52 @@ def run() -> None: mcp.run() +@cli.command() +@click.argument("command", type=str, required=True) +@click.argument("args", nargs=-1, type=click.UNPROCESSED) +@click.option( + "--path", + type=click.Path(exists=True), + default=".", + help="Path to the project directory (default: current directory)", +) +def run(command: str, args: List[str], path: str) -> None: + """Run a command defined in codemcp.toml. + + COMMAND: The name of the command to run as defined in codemcp.toml + ARGS: Optional arguments to pass to the command + """ + import asyncio + from uuid import uuid4 + + # Configure logging + configure_logging() + + # Convert args tuple to a space-separated string for run_command + args_str = " ".join(args) if args else None + + # Generate a temporary chat ID for this command + chat_id = str(uuid4()) + + # Convert to absolute path if needed + project_dir = normalize_file_path(path) + + try: + # Check if command exists in config + command_list = get_command_from_config(project_dir, command) + if not command_list: + click.echo( + f"Error: Command '{command}' not found in codemcp.toml", err=True + ) + return + + # Run the command + result = asyncio.run(run_command(project_dir, command, args_str, chat_id)) + click.echo(result) + except Exception as e: + click.echo(f"Error running command: {e}", err=True) + + @cli.command() @click.option( "--host", diff --git a/e2e/test_run_command.py b/e2e/test_run_command.py new file mode 100644 index 0000000..dadf420 --- /dev/null +++ b/e2e/test_run_command.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +import subprocess +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from codemcp.main import cli + + +@pytest.fixture +def test_project(): + """Create a temporary directory with a codemcp.toml file for testing.""" + with tempfile.TemporaryDirectory() as tmp_dir: + # Create a codemcp.toml file with test commands + config_path = Path(tmp_dir) / "codemcp.toml" + with open(config_path, "w") as f: + f.write("""[commands] +echo = ["echo", "Hello from codemcp run!"] +echo_args = ["echo"] +invalid = [] +""") + + # Initialize a git repository + subprocess.run(["git", "init"], cwd=tmp_dir, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=tmp_dir, check=True + ) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=tmp_dir, check=True + ) + subprocess.run(["git", "add", "codemcp.toml"], cwd=tmp_dir, check=True) + subprocess.run( + ["git", "commit", "-m", "Initial commit"], cwd=tmp_dir, check=True + ) + + yield tmp_dir + + +def test_run_command_success(test_project): + """Test running a command successfully.""" + runner = CliRunner() + result = runner.invoke(cli, ["run", "echo", "--path", test_project]) + + assert result.exit_code == 0 + assert "Hello from codemcp run!" in result.output + assert "Code echo successful" in result.output + + +def test_run_command_with_args(test_project): + """Test running a command with arguments.""" + runner = CliRunner() + result = runner.invoke( + cli, ["run", "echo_args", "Test", "argument", "string", "--path", test_project] + ) + + assert result.exit_code == 0 + assert "Test argument string" in result.output + assert "Code echo_args successful" in result.output + + +def test_run_command_not_found(test_project): + """Test running a command that doesn't exist in config.""" + runner = CliRunner() + result = runner.invoke(cli, ["run", "nonexistent", "--path", test_project]) + + assert "Error: Command 'nonexistent' not found in codemcp.toml" in result.output + + +def test_run_command_empty_definition(test_project): + """Test running a command with an empty definition.""" + runner = CliRunner() + result = runner.invoke(cli, ["run", "invalid", "--path", test_project]) + + assert "Error: Command 'invalid' not found in codemcp.toml" in result.output From 4addc59ed27de96f2bd7fd600d258f71907c6c3f Mon Sep 17 00:00:00 2001 From: "Edward Z. Yang" Date: Tue, 22 Apr 2025 20:24:59 +0800 Subject: [PATCH 2/3] Update [ghstack-poisoned] --- codemcp/main.py | 97 ++++++++++++++++++++++++++++++++++++++--- e2e/test_run_command.py | 63 ++++++++++++++++++++++++-- 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/codemcp/main.py b/codemcp/main.py index db2d599..db5abd2 100644 --- a/codemcp/main.py +++ b/codemcp/main.py @@ -871,19 +871,25 @@ def run() -> None: default=".", help="Path to the project directory (default: current directory)", ) -def run(command: str, args: List[str], path: str) -> None: +@click.option( + "--no-stream", + is_flag=True, + help="Don't stream output to the terminal in real-time", +) +def run(command: str, args: List[str], path: str, no_stream: bool) -> None: """Run a command defined in codemcp.toml. COMMAND: The name of the command to run as defined in codemcp.toml ARGS: Optional arguments to pass to the command """ import asyncio + import subprocess from uuid import uuid4 # Configure logging configure_logging() - # Convert args tuple to a space-separated string for run_command + # Convert args tuple to a space-separated string args_str = " ".join(args) if args else None # Generate a temporary chat ID for this command @@ -901,9 +907,90 @@ def run(command: str, args: List[str], path: str) -> None: ) return - # Run the command - result = asyncio.run(run_command(project_dir, command, args_str, chat_id)) - click.echo(result) + if no_stream: + # Use the standard non-streaming implementation + result = asyncio.run(run_command(project_dir, command, args_str, chat_id)) + click.echo(result) + else: + # Check if directory is in a git repository and commit any pending changes + from .git import commit_changes, is_git_repository + + is_git_repo = asyncio.run(is_git_repository(project_dir)) + if is_git_repo: + logging.info(f"Committing any pending changes before {command}") + commit_result = asyncio.run( + commit_changes( + project_dir, + f"Snapshot before auto-{command}", + chat_id, + commit_all=True, + ) + ) + if not commit_result[0]: + logging.warning( + f"Failed to commit pending changes: {commit_result[1]}" + ) + + # Extend the command with arguments if provided + full_command = command_list.copy() + if args_str: + import shlex + + parsed_args = shlex.split(args_str) + full_command.extend(parsed_args) + + # Stream output to the terminal in real-time + click.echo(f"Running command: {' '.join(str(c) for c in full_command)}") + + # Run the command with live output streaming + try: + process = subprocess.Popen( + full_command, + cwd=project_dir, + stdout=None, # Use parent's stdout/stderr (the terminal) + stderr=None, + text=True, + bufsize=0, # Unbuffered + ) + + # Wait for the process to complete + exit_code = process.wait() + + # Check if command succeeded + if exit_code == 0: + # If it's a git repo, commit any changes made by the command + if is_git_repo: + from .code_command import check_for_changes + + has_changes = asyncio.run(check_for_changes(project_dir)) + if has_changes: + logging.info( + f"Changes detected after {command}, committing" + ) + success, commit_result_message = asyncio.run( + commit_changes( + project_dir, + f"Auto-commit {command} changes", + chat_id, + commit_all=True, + ) + ) + + if success: + click.echo( + f"\nCode {command} successful and changes committed." + ) + else: + click.echo( + f"\nCode {command} successful but failed to commit changes." + ) + click.echo(f"Commit error: {commit_result_message}") + else: + click.echo(f"\nCode {command} successful.") + else: + click.echo(f"\nCommand failed with exit code {exit_code}.") + except Exception as cmd_error: + click.echo(f"Error during command execution: {cmd_error}", err=True) except Exception as e: click.echo(f"Error running command: {e}", err=True) diff --git a/e2e/test_run_command.py b/e2e/test_run_command.py index dadf420..9508823 100644 --- a/e2e/test_run_command.py +++ b/e2e/test_run_command.py @@ -42,7 +42,7 @@ def test_project(): def test_run_command_success(test_project): """Test running a command successfully.""" runner = CliRunner() - result = runner.invoke(cli, ["run", "echo", "--path", test_project]) + result = runner.invoke(cli, ["run", "echo", "--path", test_project, "--no-stream"]) assert result.exit_code == 0 assert "Hello from codemcp run!" in result.output @@ -53,7 +53,17 @@ def test_run_command_with_args(test_project): """Test running a command with arguments.""" runner = CliRunner() result = runner.invoke( - cli, ["run", "echo_args", "Test", "argument", "string", "--path", test_project] + cli, + [ + "run", + "echo_args", + "Test", + "argument", + "string", + "--path", + test_project, + "--no-stream", + ], ) assert result.exit_code == 0 @@ -64,7 +74,9 @@ def test_run_command_with_args(test_project): def test_run_command_not_found(test_project): """Test running a command that doesn't exist in config.""" runner = CliRunner() - result = runner.invoke(cli, ["run", "nonexistent", "--path", test_project]) + result = runner.invoke( + cli, ["run", "nonexistent", "--path", test_project, "--no-stream"] + ) assert "Error: Command 'nonexistent' not found in codemcp.toml" in result.output @@ -72,6 +84,49 @@ def test_run_command_not_found(test_project): def test_run_command_empty_definition(test_project): """Test running a command with an empty definition.""" runner = CliRunner() - result = runner.invoke(cli, ["run", "invalid", "--path", test_project]) + result = runner.invoke( + cli, ["run", "invalid", "--path", test_project, "--no-stream"] + ) assert "Error: Command 'invalid' not found in codemcp.toml" in result.output + + +def test_run_command_stream_mode(test_project, monkeypatch): + """Test running a command with streaming mode.""" + import subprocess + from unittest.mock import MagicMock + + # Mock necessary asyncio functions to avoid actual repository operations + async def mock_is_git_repo(*args, **kwargs): + return False + + monkeypatch.setattr("codemcp.git.is_git_repository", mock_is_git_repo) + + # Create a mock for subprocess.Popen + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.wait.return_value = 0 + + # Keep track of Popen calls + popen_calls = [] + + def mock_popen(cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + return mock_process + + monkeypatch.setattr(subprocess, "Popen", mock_popen) + + # Run the command + runner = CliRunner() + runner.invoke(cli, ["run", "echo", "--path", test_project]) + + # Check that our command was executed with the right parameters + assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls) + + # Find the call for our echo command + for cmd, kwargs in popen_calls: + if cmd == ["echo", "Hello from codemcp run!"]: + # Verify streaming parameters + assert kwargs.get("stdout") is None + assert kwargs.get("stderr") is None + assert kwargs.get("bufsize") == 0 From fae6c599e810e9d5c19319485591ee7a3fd7de0a Mon Sep 17 00:00:00 2001 From: "Edward Z. Yang" Date: Tue, 22 Apr 2025 20:31:43 +0800 Subject: [PATCH 3/3] Update [ghstack-poisoned] --- codemcp/main.py | 14 ++++++- e2e/test_run_command.py | 81 ++++++++++++++++++++++++++++------------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/codemcp/main.py b/codemcp/main.py index db5abd2..cf155e2 100644 --- a/codemcp/main.py +++ b/codemcp/main.py @@ -953,8 +953,18 @@ def run(command: str, args: List[str], path: str, no_stream: bool) -> None: bufsize=0, # Unbuffered ) - # Wait for the process to complete - exit_code = process.wait() + try: + # Wait for the process to complete + exit_code = process.wait() + except KeyboardInterrupt: + # Handle Ctrl+C gracefully + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + click.echo("\nProcess terminated by user.") + return # Check if command succeeded if exit_code == 0: diff --git a/e2e/test_run_command.py b/e2e/test_run_command.py index 9508823..3d472c2 100644 --- a/e2e/test_run_command.py +++ b/e2e/test_run_command.py @@ -3,13 +3,32 @@ import subprocess import tempfile from pathlib import Path +from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner +import codemcp.git from codemcp.main import cli +# Create non-async mock functions to replace async ones +def mock_is_git_repository(*args, **kwargs): + return False + + +def mock_check_for_changes(*args, **kwargs): + return False + + +def mock_commit_changes(*args, **kwargs): + return (True, "Mock commit message") + + +# Patch the modules directly +codemcp.git.is_git_repository = mock_is_git_repository + + @pytest.fixture def test_project(): """Create a temporary directory with a codemcp.toml file for testing.""" @@ -91,16 +110,15 @@ def test_run_command_empty_definition(test_project): assert "Error: Command 'invalid' not found in codemcp.toml" in result.output -def test_run_command_stream_mode(test_project, monkeypatch): +@patch("codemcp.git.is_git_repository", mock_is_git_repository) +@patch("codemcp.code_command.check_for_changes", mock_check_for_changes) +@patch("codemcp.git.commit_changes", mock_commit_changes) +@patch( + "asyncio.run", lambda x: False +) # Mock asyncio.run to return False for all coroutines +def test_run_command_stream_mode(test_project): """Test running a command with streaming mode.""" import subprocess - from unittest.mock import MagicMock - - # Mock necessary asyncio functions to avoid actual repository operations - async def mock_is_git_repo(*args, **kwargs): - return False - - monkeypatch.setattr("codemcp.git.is_git_repository", mock_is_git_repo) # Create a mock for subprocess.Popen mock_process = MagicMock() @@ -110,23 +128,34 @@ async def mock_is_git_repo(*args, **kwargs): # Keep track of Popen calls popen_calls = [] - def mock_popen(cmd, **kwargs): - popen_calls.append((cmd, kwargs)) - return mock_process + # Create a safe replacement for Popen that won't leave hanging processes + original_popen = subprocess.Popen - monkeypatch.setattr(subprocess, "Popen", mock_popen) + def mock_popen(cmd, **kwargs): + if ( + isinstance(cmd, list) + and cmd[0] == "echo" + and "Hello from codemcp run!" in cmd + ): + popen_calls.append((cmd, kwargs)) + return mock_process + # For any other command, create a safe echo process with proper cleanup + return original_popen( + ["echo", "Test"], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) - # Run the command - runner = CliRunner() - runner.invoke(cli, ["run", "echo", "--path", test_project]) - - # Check that our command was executed with the right parameters - assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls) - - # Find the call for our echo command - for cmd, kwargs in popen_calls: - if cmd == ["echo", "Hello from codemcp run!"]: - # Verify streaming parameters - assert kwargs.get("stdout") is None - assert kwargs.get("stderr") is None - assert kwargs.get("bufsize") == 0 + with patch("subprocess.Popen", mock_popen): + # Run the command with isolated stdin/stdout to prevent interference + runner = CliRunner(mix_stderr=False) + runner.invoke(cli, ["run", "echo", "--path", test_project]) + + # Check that our command was executed with the right parameters + assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls) + + # Find the call for our echo command + for cmd, kwargs in popen_calls: + if cmd == ["echo", "Hello from codemcp run!"]: + # Verify streaming parameters + assert kwargs.get("stdout") is None + assert kwargs.get("stderr") is None + assert kwargs.get("bufsize") == 0