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. 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..ba3a22a 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 call.args and "mutually exclusive" in call.args[0] + ] + assert len(error_call) > 0 + def test_create_worktree_error(self, mocker): """Test creating worktree when WorktreeError occurs.""" mock_echo = mocker.patch("typer.echo") @@ -86,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 @@ -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 @@ -104,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 @@ -157,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 @@ -199,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 @@ -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..e33361e 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 @@ -53,7 +54,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 +66,45 @@ 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 with proper shell escaping + quoted_path = shlex.quote(str(worktree_path)) + commands = [f"cd {quoted_path}"] + if 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) + # 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""" tell application "iTerm" tell current window create tab with default profile tell current session - write text "cd {worktree_path}" + write text "{escaped_command}" end tell end tell end tell @@ -97,11 +114,99 @@ 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. + """ + # 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", "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", bash_cmd]), + ("konsole", ["--new-tab", "-e", "bash", "-c", bash_cmd]), + ("xfce4-terminal", ["--tab", "-e", "bash", "-c", bash_cmd]), + ("xterm", ["-e", "bash", "-c", bash_cmd]), + ] + + 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 +221,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