From b6709b7128250def4c31449b2223057fbb870396 Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 16:03:21 +0200 Subject: [PATCH 1/6] feat!: add Claude integration and refactor CLI interface - Remove Mode enum and --mode flag from CLI - Add standalone --ide flag to open worktree in specified IDE - Add --claude flag to automatically start Claude session - Make --ide and --claude mutually exclusive - Add copy_claude_settings() to copy .claude/settings.local.json to new worktrees - Add launch_claude() function to start Claude in terminal - Extend Linux support for terminal launching (gnome-terminal, konsole, xfce4-terminal, xterm) - Update all tests to reflect new CLI interface - Remove handle_mode() function BREAKING CHANGE: The --mode flag has been removed. Use --ide for IDE launching instead of --mode ide. The --mode terminal option has been replaced with separate functionality. --- specs/ez-leaf.md | 20 ------ tests/test_cli.py | 59 +++++++++++------- tests/test_launchers.py | 74 +++++++++++++++------- tests/test_worktree.py | 3 + wt/cli.py | 47 ++++++++------ wt/launchers.py | 135 +++++++++++++++++++++++++++++++--------- wt/worktree.py | 24 +++++++ 7 files changed, 245 insertions(+), 117 deletions(-) delete mode 100644 specs/ez-leaf.md diff --git a/specs/ez-leaf.md b/specs/ez-leaf.md deleted file mode 100644 index 97c88e3..0000000 --- a/specs/ez-leaf.md +++ /dev/null @@ -1,20 +0,0 @@ -# ez-leaf - a simple python executable script to manage git-worktrees efficiently - -## Overview -`ez-leaf` is a lightweight Python script designed to simplify the management of Git worktrees. It provides an easy-to-use command-line interface for creating, listing, and deleting worktrees, making it ideal for developers who frequently work with multiple branches. - -## Features -- **Create Worktrees**: Quickly create new worktrees for different branches or commits. -- **List Worktrees**: Display a list of all existing worktrees in the repository. -- **Delete Worktrees**: Safely remove worktrees that are no longer needed. -- **Easy Integration**: Can be easily integrated into existing workflows and scripts. -- **Cross-Platform**: Works on any system with Python and Git installed. -- **Lightweight**: Minimal dependencies, making it easy to install and use. -- **User-Friendly**: Simple command-line interface with clear commands and options. -- **Error Handling**: Provides informative error messages to help troubleshoot issues. -- **Documentation**: Comprehensive documentation to help users get started quickly. -- **Open Source**: Available on GitHub for contributions and improvements. -- **Customizable**: Users can modify the script to fit their specific needs. -- **No Installation Required**: Can be run directly without the need for installation. -- **Scriptable**: Can be easily scripted for automation in CI/CD pipelines. -- **Configurable**: Supports lauching a code editor in the new worktree directory, or a new iTerme2 tab on macOS. \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index a1a9eb5..17800ac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -32,47 +32,58 @@ def test_version_option(self): class TestCLICreate: """Tests for create command.""" - def test_create_worktree_default_mode(self, mocker): - """Test creating worktree with default mode.""" + def test_create_worktree_default(self, mocker): + """Test creating worktree without any flags.""" mock_create = mocker.patch( "wt.cli.create_worktree", return_value=Path("/path/to/worktree") ) - mock_handle_mode = mocker.patch("wt.cli.handle_mode") + mock_print = mocker.patch("builtins.print") result = runner.invoke(app, ["create", "feature-x"]) assert result.exit_code == 0 mock_create.assert_called_once_with("feature-x") - mock_handle_mode.assert_called_once_with( - "none", Path("/path/to/worktree"), None - ) + mock_print.assert_called_once_with("Worktree created at: /path/to/worktree") - def test_create_worktree_terminal_mode(self, mocker): - """Test creating worktree with terminal mode.""" + def test_create_worktree_with_ide(self, mocker): + """Test creating worktree with --ide flag.""" mocker.patch("wt.cli.create_worktree", return_value=Path("/path/to/worktree")) - mock_handle_mode = mocker.patch("wt.cli.handle_mode") + mock_launch_ide = mocker.patch("wt.cli.launch_ide") - result = runner.invoke(app, ["create", "feature-x", "--mode", "terminal"]) + result = runner.invoke(app, ["create", "feature-x", "--ide", "code"]) assert result.exit_code == 0 - mock_handle_mode.assert_called_once_with( - "terminal", Path("/path/to/worktree"), None - ) + mock_launch_ide.assert_called_once_with(Path("/path/to/worktree"), "code") - def test_create_worktree_ide_mode(self, mocker): - """Test creating worktree with IDE mode.""" + def test_create_worktree_with_claude(self, mocker): + """Test creating worktree with --claude flag.""" mocker.patch("wt.cli.create_worktree", return_value=Path("/path/to/worktree")) - mock_handle_mode = mocker.patch("wt.cli.handle_mode") + mock_launch_claude = mocker.patch("wt.cli.launch_claude") - result = runner.invoke( - app, ["create", "feature-x", "--mode", "ide", "--ide", "code"] - ) + result = runner.invoke(app, ["create", "feature-x", "--claude"]) assert result.exit_code == 0 - mock_handle_mode.assert_called_once_with( - "ide", Path("/path/to/worktree"), "code" + mock_launch_claude.assert_called_once_with(Path("/path/to/worktree")) + + def test_create_worktree_ide_and_claude_exclusive(self, mocker): + """Test that --ide and --claude are mutually exclusive.""" + mock_echo = mocker.patch("typer.echo") + mocker.patch("wt.cli.create_worktree", return_value=Path("/path/to/worktree")) + + result = runner.invoke( + app, ["create", "feature-x", "--ide", "code", "--claude"] ) + assert result.exit_code == 1 + # Verify error message was echoed + mock_echo.assert_called() + error_call = [ + call + for call in mock_echo.call_args_list + if "mutually exclusive" in str(call) + ] + assert len(error_call) > 0 + def test_create_worktree_error(self, mocker): """Test creating worktree when WorktreeError occurs.""" mock_echo = mocker.patch("typer.echo") @@ -94,9 +105,9 @@ def test_create_worktree_launcher_error(self, mocker): """Test creating worktree when LauncherError occurs.""" mock_echo = mocker.patch("typer.echo") mocker.patch("wt.cli.create_worktree", return_value=Path("/path/to/worktree")) - mocker.patch("wt.cli.handle_mode", side_effect=LauncherError("Launcher error")) + mocker.patch("wt.cli.launch_ide", side_effect=LauncherError("Launcher error")) - result = runner.invoke(app, ["create", "feature-x", "--mode", "ide"]) + result = runner.invoke(app, ["create", "feature-x", "--ide", "code"]) assert result.exit_code == 1 # Verify error message was echoed @@ -224,8 +235,8 @@ def test_create_help(self): assert result.exit_code == 0 assert "Create a new git worktree" in output - assert "--mode" in output assert "--ide" in output + assert "--claude" in output def test_list_help(self): """Test list command help.""" diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 13357c8..cc09c10 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -9,9 +9,9 @@ LauncherError, launch_ide, launch_terminal, + launch_claude, _launch_iterm2, _command_exists, - handle_mode, ) @@ -103,6 +103,15 @@ def test_launch_terminal_macos(self, mocker, tmp_path): mock_launch_iterm2.assert_called_once_with(tmp_path) + def test_launch_terminal_linux(self, mocker, tmp_path): + """Test launching terminal on Linux.""" + mocker.patch("platform.system", return_value="Linux") + mock_launch_linux = mocker.patch("wt.launchers._launch_linux_terminal") + + launch_terminal(tmp_path) + + mock_launch_linux.assert_called_once_with(tmp_path) + def test_launch_terminal_unsupported_platform(self, mocker, tmp_path): """Test launching terminal on unsupported platform.""" mocker.patch("platform.system", return_value="Windows") @@ -127,6 +136,20 @@ def test_launch_iterm2_success(self, mocker, tmp_path): assert str(tmp_path) in args[2] mock_print.assert_called_once() + def test_launch_iterm2_with_command(self, mocker, tmp_path): + """Test launching iTerm2 with a command.""" + mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + mock_print = mocker.patch("builtins.print") + + _launch_iterm2(tmp_path, command="claude") + + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert args[0] == "osascript" + assert str(tmp_path) in args[2] + assert "claude" in args[2] + mock_print.assert_called_once() + def test_launch_iterm2_failure(self, mocker, tmp_path): """Test launching iTerm2 when command fails.""" mocker.patch( @@ -138,35 +161,40 @@ def test_launch_iterm2_failure(self, mocker, tmp_path): _launch_iterm2(tmp_path) -class TestHandleMode: - """Tests for handle_mode function.""" +class TestLaunchClaude: + """Tests for launch_claude function.""" - def test_handle_mode_none(self, mocker, tmp_path): - """Test handle_mode with mode='none'.""" - mock_print = mocker.patch("builtins.print") + def test_launch_claude_macos(self, mocker, tmp_path): + """Test launching Claude on macOS.""" + mocker.patch("wt.launchers._command_exists", return_value=True) + mocker.patch("platform.system", return_value="Darwin") + mock_launch_iterm2 = mocker.patch("wt.launchers._launch_iterm2") - handle_mode("none", tmp_path) + launch_claude(tmp_path) - mock_print.assert_called_once() - assert "Worktree created at" in mock_print.call_args[0][0] + mock_launch_iterm2.assert_called_once_with(tmp_path, command="claude") - def test_handle_mode_terminal(self, mocker, tmp_path): - """Test handle_mode with mode='terminal'.""" - mock_launch_terminal = mocker.patch("wt.launchers.launch_terminal") + def test_launch_claude_linux(self, mocker, tmp_path): + """Test launching Claude on Linux.""" + mocker.patch("wt.launchers._command_exists", return_value=True) + mocker.patch("platform.system", return_value="Linux") + mock_launch_linux = mocker.patch("wt.launchers._launch_linux_terminal") - handle_mode("terminal", tmp_path) + launch_claude(tmp_path) - mock_launch_terminal.assert_called_once_with(tmp_path) + mock_launch_linux.assert_called_once_with(tmp_path, command="claude") - def test_handle_mode_ide(self, mocker, tmp_path): - """Test handle_mode with mode='ide'.""" - mock_launch_ide = mocker.patch("wt.launchers.launch_ide") + def test_launch_claude_not_installed(self, mocker, tmp_path): + """Test launching Claude when CLI is not installed.""" + mocker.patch("wt.launchers._command_exists", return_value=False) - handle_mode("ide", tmp_path, "code") + with pytest.raises(LauncherError, match="Claude CLI not found"): + launch_claude(tmp_path) - mock_launch_ide.assert_called_once_with(tmp_path, "code") + def test_launch_claude_unsupported_platform(self, mocker, tmp_path): + """Test launching Claude on unsupported platform.""" + mocker.patch("wt.launchers._command_exists", return_value=True) + mocker.patch("platform.system", return_value="Windows") - def test_handle_mode_unknown(self, tmp_path): - """Test handle_mode with unknown mode.""" - with pytest.raises(LauncherError, match="Unknown mode"): - handle_mode("invalid", tmp_path) + with pytest.raises(LauncherError, match="not supported on Windows"): + launch_claude(tmp_path) diff --git a/tests/test_worktree.py b/tests/test_worktree.py index 255acb7..b9b316f 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -121,6 +121,7 @@ def test_create_worktree_new_branch(self, mocker): "wt.worktree.branch_exists", return_value=False ) mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + mock_copy_claude = mocker.patch("wt.worktree.copy_claude_settings") result = create_worktree("feature-x") @@ -132,6 +133,7 @@ def test_create_worktree_new_branch(self, mocker): assert args[:3] == ["git", "worktree", "add"] assert "-b" in args assert "feature-x" in args + mock_copy_claude.assert_called_once() def test_create_worktree_existing_branch(self, mocker): """Test creating worktree with an existing branch.""" @@ -142,6 +144,7 @@ def test_create_worktree_existing_branch(self, mocker): ) mocker.patch("wt.worktree.branch_exists", return_value=True) mock_run = mocker.patch("subprocess.run", return_value=Mock(returncode=0)) + mocker.patch("wt.worktree.copy_claude_settings") result = create_worktree("main") diff --git a/wt/cli.py b/wt/cli.py index d93a4b9..2eb7ec8 100644 --- a/wt/cli.py +++ b/wt/cli.py @@ -1,7 +1,6 @@ """Command-line interface for git-worktree-cli.""" from typing import Optional -from enum import Enum import typer from typing_extensions import Annotated @@ -13,15 +12,7 @@ list_worktrees, delete_worktree, ) -from .launchers import LauncherError, handle_mode - - -class Mode(str, Enum): - """Operation modes for worktree creation.""" - - NONE = "none" - TERMINAL = "terminal" - IDE = "ide" +from .launchers import LauncherError, launch_ide, launch_claude app = typer.Typer( @@ -51,15 +42,18 @@ def main( @app.command() def create( branch: Annotated[str, typer.Argument(help="Branch name to create worktree for")], - mode: Annotated[ - Mode, typer.Option(help="Operation mode after creating worktree") - ] = Mode.NONE, ide: Annotated[ Optional[str], typer.Option( - help="IDE executable name (e.g., code, pycharm, cursor). Used when mode=ide." + help="IDE executable name (e.g., code, pycharm, cursor). Opens worktree in IDE." ), ] = None, + claude: Annotated[ + bool, + typer.Option( + help="Start a Claude session in the new worktree. Mutually exclusive with --ide." + ), + ] = False, ): """Create a new git worktree for BRANCH. @@ -72,20 +66,33 @@ def create( wt create feature-x \b - # Create and open in terminal - wt create feature-x --mode terminal + # Create and open in VS Code + wt create feature-x --ide code \b - # Create and open in VS Code - wt create feature-x --mode ide --ide code + # Create and start Claude session + wt create feature-x --claude \b # Create and open in default IDE - wt create feature-x --mode ide + wt create feature-x --ide """ + # Check for mutually exclusive options + if ide and claude: + typer.echo("Error: --ide and --claude are mutually exclusive.", err=True) + raise typer.Exit(code=1) + try: worktree_path = create_worktree(branch) - handle_mode(mode.value, worktree_path, ide) + + # Handle post-creation actions + if claude: + launch_claude(worktree_path) + elif ide: + launch_ide(worktree_path, ide) + else: + print(f"Worktree created at: {worktree_path}") + except (WorktreeError, LauncherError) as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(code=1) diff --git a/wt/launchers.py b/wt/launchers.py index c80f351..6a13da0 100644 --- a/wt/launchers.py +++ b/wt/launchers.py @@ -53,7 +53,7 @@ def launch_ide(worktree_path: Path, ide_executable: Optional[str] = None) -> Non def launch_terminal(worktree_path: Path) -> None: """Launch a terminal in the worktree directory. - Currently supports iTerm2 on macOS. + Supports iTerm2 on macOS and common terminals on Linux. Args: worktree_path: The path to the worktree. @@ -65,29 +65,37 @@ def launch_terminal(worktree_path: Path) -> None: if system == "Darwin": # macOS _launch_iterm2(worktree_path) + elif system == "Linux": + _launch_linux_terminal(worktree_path) else: raise LauncherError( f"Terminal launching is not supported on {system}. " - f"Currently only macOS (iTerm2) is supported." + f"Currently only macOS and Linux are supported." ) -def _launch_iterm2(worktree_path: Path) -> None: +def _launch_iterm2(worktree_path: Path, command: Optional[str] = None) -> None: """Launch a new iTerm2 tab in the worktree directory. Args: worktree_path: The path to the worktree. + command: Optional command to run after cd. If None, just opens terminal. Raises: LauncherError: If launching iTerm2 fails. """ - # AppleScript to open a new iTerm2 tab and cd to the worktree path + # Build commands to execute + commands = [f"cd {worktree_path}"] + if command: + commands.append(command) + + # AppleScript to open a new iTerm2 tab and execute commands applescript = f""" tell application "iTerm" tell current window create tab with default profile tell current session - write text "cd {worktree_path}" + write text "{'; '.join(commands)}" end tell end tell end tell @@ -97,11 +105,102 @@ def _launch_iterm2(worktree_path: Path) -> None: subprocess.run( ["osascript", "-e", applescript], check=True, capture_output=True, text=True ) - print(f"Opened new iTerm2 tab at {worktree_path}") + action = "Started Claude session" if command else "Opened new iTerm2 tab" + print(f"{action} at {worktree_path}") except subprocess.CalledProcessError as e: raise LauncherError(f"Failed to launch iTerm2: {e.stderr}") from e +def _launch_linux_terminal(worktree_path: Path, command: Optional[str] = None) -> None: + """Launch a new terminal tab/window in the worktree directory on Linux. + + Args: + worktree_path: The path to the worktree. + command: Optional command to run after cd. If None, just opens terminal. + + Raises: + LauncherError: If launching terminal fails. + """ + # Try common Linux terminals in order of preference + terminals = [ + ("gnome-terminal", ["--tab", "--working-directory", str(worktree_path)]), + ("konsole", ["--new-tab", "--workdir", str(worktree_path)]), + ("xfce4-terminal", ["--tab", "--working-directory", str(worktree_path)]), + ("xterm", ["-e", f"cd {worktree_path} && bash"]), + ] + + if command: + # If command is provided, we need to execute it after cd + terminals = [ + ( + "gnome-terminal", + ["--tab", "--", "bash", "-c", f"cd {worktree_path} && {command}"], + ), + ( + "konsole", + ["--new-tab", "-e", "bash", "-c", f"cd {worktree_path} && {command}"], + ), + ( + "xfce4-terminal", + ["--tab", "-e", f"bash -c 'cd {worktree_path} && {command}'"], + ), + ("xterm", ["-e", f"cd {worktree_path} && {command}"]), + ] + + for terminal, args in terminals: + if _command_exists(terminal): + try: + subprocess.run( + [terminal] + args, + check=True, + capture_output=True, + ) + action = ( + "Started Claude session" if command else "Opened new terminal tab" + ) + print(f"{action} at {worktree_path}") + return + except subprocess.CalledProcessError: + continue + + raise LauncherError( + "No supported terminal found. " + "Please install gnome-terminal, konsole, xfce4-terminal, or xterm." + ) + + +def launch_claude(worktree_path: Path) -> None: + """Launch a Claude session in the worktree directory. + + Opens a new terminal tab/window and starts a Claude session. + Supports iTerm2 on macOS and common terminals on Linux. + + Args: + worktree_path: The path to the worktree. + + Raises: + LauncherError: If launching Claude fails or platform is not supported. + """ + # Check if claude command exists + if not _command_exists("claude"): + raise LauncherError( + "Claude CLI not found. " + "Please install it from https://github.com/anthropics/claude-code" + ) + + system = platform.system() + + if system == "Darwin": # macOS + _launch_iterm2(worktree_path, command="claude") + elif system == "Linux": + _launch_linux_terminal(worktree_path, command="claude") + else: + raise LauncherError( + f"Claude launching is not supported on {system}. " + f"Currently only macOS and Linux are supported." + ) + + def _command_exists(command: str) -> bool: """Check if a command exists in PATH. @@ -116,27 +215,3 @@ def _command_exists(command: str) -> bool: return True except subprocess.CalledProcessError: return False - - -def handle_mode( - mode: str, worktree_path: Path, ide_executable: Optional[str] = None -) -> None: - """Handle the specified mode after worktree creation. - - Args: - mode: The mode ('none', 'terminal', or 'ide'). - worktree_path: The path to the created worktree. - ide_executable: Optional IDE executable name (used when mode='ide'). - - Raises: - LauncherError: If mode handling fails. - """ - if mode == "none": - # Do nothing, just print the path - print(f"Worktree created at: {worktree_path}") - elif mode == "terminal": - launch_terminal(worktree_path) - elif mode == "ide": - launch_ide(worktree_path, ide_executable) - else: - raise LauncherError(f"Unknown mode: {mode}") diff --git a/wt/worktree.py b/wt/worktree.py index 95a97c2..38c39e7 100644 --- a/wt/worktree.py +++ b/wt/worktree.py @@ -1,5 +1,6 @@ """Core git worktree operations.""" +import shutil import subprocess from pathlib import Path from typing import Optional @@ -109,6 +110,26 @@ def branch_exists(branch: str) -> bool: return False +def copy_claude_settings(worktree_path: Path) -> None: + """Copy Claude settings from repository root to new worktree. + + Copies .claude/settings.local.json if it exists in the repository root. + + Args: + worktree_path: The path to the worktree. + """ + repo_root = get_repo_root() + source_settings = repo_root / ".claude" / "settings.local.json" + + if source_settings.exists(): + target_claude_dir = worktree_path / ".claude" + target_claude_dir.mkdir(parents=True, exist_ok=True) + + target_settings = target_claude_dir / "settings.local.json" + shutil.copy2(source_settings, target_settings) + print(f"Copied Claude settings to {target_settings}") + + def create_worktree(branch: str, path: Optional[Path] = None) -> Path: """Create a new git worktree. @@ -149,6 +170,9 @@ def create_worktree(branch: str, path: Optional[Path] = None) -> Path: text=True, ) + # Copy Claude settings if they exist + copy_claude_settings(path) + return path.absolute() except subprocess.CalledProcessError as e: raise WorktreeError(f"Failed to create worktree: {e.stderr}") from e From 373681a47ac65f3f505d77cc96fe48a048a057c1 Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 16:11:06 +0200 Subject: [PATCH 2/6] chore: add MIT License --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64743e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 cebidhem + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 30def8c3d9df2722a076cd005d0dc3b6626bd34b Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 17:54:35 +0200 Subject: [PATCH 3/6] fix: add shell escaping to prevent command injection vulnerabilities - Import shlex module for proper shell escaping - Use shlex.quote() for worktree_path in all terminal commands - Use shlex.quote() for command parameter in terminal launchers - Properly escape AppleScript strings to prevent injection - Fixes security issues identified by GitHub Copilot review This prevents command injection if worktree paths contain special characters like spaces, quotes, semicolons, or other shell metacharacters. --- wt/launchers.py | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/wt/launchers.py b/wt/launchers.py index 6a13da0..4a5ebab 100644 --- a/wt/launchers.py +++ b/wt/launchers.py @@ -1,6 +1,7 @@ """Launchers for opening worktrees in IDEs or terminal.""" import platform +import shlex import subprocess from pathlib import Path from typing import Optional @@ -84,10 +85,17 @@ def _launch_iterm2(worktree_path: Path, command: Optional[str] = None) -> None: Raises: LauncherError: If launching iTerm2 fails. """ - # Build commands to execute - commands = [f"cd {worktree_path}"] + # Build commands to execute with proper shell escaping + quoted_path = shlex.quote(str(worktree_path)) + commands = [f"cd {quoted_path}"] if command: - commands.append(command) + # Command is "claude" which is safe, but quote it anyway for consistency + commands.append(shlex.quote(command)) + + # Join commands and escape for AppleScript string context + shell_command = "; ".join(commands) + # Escape backslashes and quotes for AppleScript string + escaped_command = shell_command.replace("\\", "\\\\").replace('"', '\\"') # AppleScript to open a new iTerm2 tab and execute commands applescript = f""" @@ -95,7 +103,7 @@ def _launch_iterm2(worktree_path: Path, command: Optional[str] = None) -> None: tell current window create tab with default profile tell current session - write text "{'; '.join(commands)}" + write text "{escaped_command}" end tell end tell end tell @@ -121,30 +129,27 @@ def _launch_linux_terminal(worktree_path: Path, command: Optional[str] = None) - Raises: LauncherError: If launching terminal fails. """ + # Properly escape path and command for shell + quoted_path = shlex.quote(str(worktree_path)) + # Try common Linux terminals in order of preference terminals = [ ("gnome-terminal", ["--tab", "--working-directory", str(worktree_path)]), ("konsole", ["--new-tab", "--workdir", str(worktree_path)]), ("xfce4-terminal", ["--tab", "--working-directory", str(worktree_path)]), - ("xterm", ["-e", f"cd {worktree_path} && bash"]), + ("xterm", ["-e", "bash", "-c", f"cd {quoted_path} && bash"]), ] if command: # If command is provided, we need to execute it after cd + quoted_command = shlex.quote(command) + bash_cmd = f"cd {quoted_path} && {quoted_command}" + terminals = [ - ( - "gnome-terminal", - ["--tab", "--", "bash", "-c", f"cd {worktree_path} && {command}"], - ), - ( - "konsole", - ["--new-tab", "-e", "bash", "-c", f"cd {worktree_path} && {command}"], - ), - ( - "xfce4-terminal", - ["--tab", "-e", f"bash -c 'cd {worktree_path} && {command}'"], - ), - ("xterm", ["-e", f"cd {worktree_path} && {command}"]), + ("gnome-terminal", ["--tab", "--", "bash", "-c", bash_cmd]), + ("konsole", ["--new-tab", "-e", "bash", "-c", bash_cmd]), + ("xfce4-terminal", ["--tab", "-e", f"bash -c {shlex.quote(bash_cmd)}"]), + ("xterm", ["-e", "bash", "-c", bash_cmd]), ] for terminal, args in terminals: From d52ca156ffc63c274ffbcd8f958c52ff61d14d51 Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 18:00:29 +0200 Subject: [PATCH 4/6] fix: improve code consistency and comment clarity - Update comment to reflect generic command parameter (not just 'claude') - Standardize xfce4-terminal argument passing to match other terminals - Pass arguments as separate list items instead of manual string construction Addresses GitHub Copilot review feedback for better maintainability. --- wt/launchers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wt/launchers.py b/wt/launchers.py index 4a5ebab..be8a54a 100644 --- a/wt/launchers.py +++ b/wt/launchers.py @@ -89,7 +89,7 @@ def _launch_iterm2(worktree_path: Path, command: Optional[str] = None) -> None: quoted_path = shlex.quote(str(worktree_path)) commands = [f"cd {quoted_path}"] if command: - # Command is "claude" which is safe, but quote it anyway for consistency + # Quote command to ensure security and prevent shell injection commands.append(shlex.quote(command)) # Join commands and escape for AppleScript string context @@ -148,7 +148,7 @@ def _launch_linux_terminal(worktree_path: Path, command: Optional[str] = None) - terminals = [ ("gnome-terminal", ["--tab", "--", "bash", "-c", bash_cmd]), ("konsole", ["--new-tab", "-e", "bash", "-c", bash_cmd]), - ("xfce4-terminal", ["--tab", "-e", f"bash -c {shlex.quote(bash_cmd)}"]), + ("xfce4-terminal", ["--tab", "-e", "bash", "-c", bash_cmd]), ("xterm", ["-e", "bash", "-c", bash_cmd]), ] From 1b91e99c699ec26b2461699544a28277d09b15b1 Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 18:13:50 +0200 Subject: [PATCH 5/6] fix: remove double-quoting of command in iTerm2 launcher iTerm2's 'write text' types command literally into the terminal shell. Quoting the command with shlex.quote() causes it to be typed as 'claude' (with quotes) which the shell won't execute properly. Since the command value is controlled by our code (always 'claude'), not user input, it's safe to pass it unquoted. The path is still properly quoted for security. Fixes GitHub Copilot review comment about double-quoting on line 93. --- wt/launchers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wt/launchers.py b/wt/launchers.py index be8a54a..e33361e 100644 --- a/wt/launchers.py +++ b/wt/launchers.py @@ -89,8 +89,9 @@ def _launch_iterm2(worktree_path: Path, command: Optional[str] = None) -> None: quoted_path = shlex.quote(str(worktree_path)) commands = [f"cd {quoted_path}"] if command: - # Quote command to ensure security and prevent shell injection - commands.append(shlex.quote(command)) + # Don't quote command here - iTerm's "write text" types literally into shell + # The command value is controlled by our code (e.g., "claude"), not user input + commands.append(command) # Join commands and escape for AppleScript string context shell_command = "; ".join(commands) From 5f01cfabb68db5c623c9377ef8bde00abb5ae8a4 Mon Sep 17 00:00:00 2001 From: Mehdi Bechiri Date: Wed, 1 Oct 2025 18:28:11 +0200 Subject: [PATCH 6/6] test: improve test assertions to be more robust Replace brittle str(call) checks with explicit call.args[0] access. This makes assertions less dependent on internal call object representation and more directly verify the actual arguments passed to mocked functions. Applied consistently across all error message verification tests. Addresses GitHub Copilot review feedback on test quality. --- tests/test_cli.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 17800ac..ba3a22a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -80,7 +80,7 @@ def test_create_worktree_ide_and_claude_exclusive(self, mocker): error_call = [ call for call in mock_echo.call_args_list - if "mutually exclusive" in str(call) + if call.args and "mutually exclusive" in call.args[0] ] assert len(error_call) > 0 @@ -97,7 +97,7 @@ def test_create_worktree_error(self, mocker): error_call = [ call for call in mock_echo.call_args_list - if "Error: Test error" in str(call) + if call.args and "Error: Test error" in call.args[0] ] assert len(error_call) > 0 @@ -115,7 +115,7 @@ def test_create_worktree_launcher_error(self, mocker): error_call = [ call for call in mock_echo.call_args_list - if "Error: Launcher error" in str(call) + if call.args and "Error: Launcher error" in call.args[0] ] assert len(error_call) > 0 @@ -168,7 +168,7 @@ def test_list_worktrees_error(self, mocker): error_call = [ call for call in mock_echo.call_args_list - if "Error: List error" in str(call) + if call.args and "Error: List error" in call.args[0] ] assert len(error_call) > 0 @@ -210,7 +210,7 @@ def test_delete_worktree_error(self, mocker): error_call = [ call for call in mock_echo.call_args_list - if "Error: Delete error" in str(call) + if call.args and "Error: Delete error" in call.args[0] ] assert len(error_call) > 0