diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..555348c2bb6f8be319d08e7e4637483dada12599 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Architecture + +This is a Python plugin for the LLM CLI tool that adds a fragment loader for repository contents using Repomix. The plugin registers a single fragment loader that: + +1. Clones a git repository to a temporary directory +2. Runs repomix on the cloned repository to generate consolidated output +3. Returns the repomix output as a single LLM fragment +4. Cleans up the temporary directory + +**Core Components:** +- `llm_fragments_repomix.py`: Main plugin file with the fragment loader implementation +- `pyproject.toml`: Python packaging configuration with entry points for LLM + +## Development Commands + +**Build and Install:** +```bash +pip install -e . +``` + +**Testing:** +```bash +pytest tests/ # Run all tests +pytest tests/test_fragments_repomix.py # Run specific test file +``` + +**Package Building:** +```bash +python -m build +``` + +## Dependencies + +- **Runtime:** LLM framework, git command, repomix command +- **Development:** pytest (optional) +- **External:** Requires `repomix` to be installed globally via npm + +## Usage Pattern + +The plugin is used with LLM's fragment system: +```bash +llm -f repomix:https://git.sr.ht/~amolith/willow "Tell me about this project" +``` + +Supports https://, ssh://, and git@ repository URLs. + +### Arguments + +You can pass arguments to repomix using colon-separated syntax: + +```bash +# Basic compression +llm -f repomix:https://git.sr.ht/~amolith/willow:compress "Tell me about this project" + +# Include specific file patterns +llm -f repomix:https://git.sr.ht/~amolith/willow:include=*.py,*.md "Analyze the Python and documentation files" + +# Multiple arguments +llm -f repomix:https://git.sr.ht/~amolith/willow:compress:include=*.py:ignore=tests/ "Analyze Python files but skip tests" +``` + +Supported arguments: +- `compress` - Compress output to reduce token count +- `include=pattern` - Include files matching pattern (comma-separated) +- `ignore=pattern` - Ignore files matching pattern (comma-separated) +- `style=type` - Output style (xml, markdown, plain) +- `remove-comments` - Remove comments from code +- `remove-empty-lines` - Remove empty lines +- `output-show-line-numbers` - Add line numbers to output +- `no-file-summary` - Disable file summary section +- `no-directory-structure` - Disable directory structure section + +For a complete list of supported arguments, refer to the [Repomix documentation](https://github.com/yamadashy/repomix). \ No newline at end of file diff --git a/llm_fragments_repomix.py b/llm_fragments_repomix.py index b7694de6a70eb8500d365705e4d16f18ba05a038..4c29c399de4e508052e31d53d01bb5cd553ddadb 100644 --- a/llm_fragments_repomix.py +++ b/llm_fragments_repomix.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Dict, Tuple import llm import os import pathlib @@ -7,6 +7,162 @@ import tempfile import shutil +def parse_fragment_string(fragment_string: str) -> Tuple[str, Dict[str, str]]: + """ + Parse a fragment string into URL and arguments + + Format: url:arg1:arg2=value:arg3 + Returns: (url, {arg1: True, arg2: "value", arg3: True}) + """ + # Define known repomix flags to detect where arguments start + known_flags = { + "compress", "remove-comments", "remove-empty-lines", + "output-show-line-numbers", "no-file-summary", + "no-directory-structure", "no-files", + "include-empty-directories", "no-git-sort-by-changes", + "include-diffs", "no-gitignore", "no-default-patterns", + "no-security-check", "verbose", "quiet", "style" + } + + # Split on colons to get all parts + parts = fragment_string.split(":") + if len(parts) < 1: + raise ValueError("Invalid fragment string format") + + # Handle different URL formats + if fragment_string.startswith("https://"): + # Find where arguments start + # parts = ['https', '//github.com/user/repo', 'compress'] + arg_start_idx = None + for i, part in enumerate(parts): + if i <= 1: # Skip protocol parts (https, //domain/path) + continue + if "=" in part or part in known_flags: + arg_start_idx = i + break + + if arg_start_idx is None: + url = fragment_string + arg_parts = [] + else: + # Reconstruct URL by joining the parts before arguments + url_parts = parts[:arg_start_idx] + url = ":".join(url_parts) + arg_parts = parts[arg_start_idx:] + + elif fragment_string.startswith("ssh://"): + # Similar logic for SSH URLs + # parts = ['ssh', '//git@github.com', 'user/repo.git', 'compress'] + arg_start_idx = None + for i, part in enumerate(parts): + if i <= 1: # Skip protocol parts (ssh, //domain) + continue + if "=" in part or part in known_flags: + arg_start_idx = i + break + + if arg_start_idx is None: + url = fragment_string + arg_parts = [] + else: + url_parts = parts[:arg_start_idx] + url = ":".join(url_parts) + arg_parts = parts[arg_start_idx:] + + elif fragment_string.startswith("git@"): + # git@host:path format - need to be careful about colons + # Look for arguments after the repo path + arg_start_idx = None + + # Skip the first colon (after hostname) when looking for arguments + for i, part in enumerate(parts): + if i <= 1: # Skip git@host and first path part + continue + if "=" in part or part in known_flags: + arg_start_idx = i + break + + if arg_start_idx is None: + url = fragment_string + arg_parts = [] + else: + url_parts = parts[:arg_start_idx] + url = ":".join(url_parts) + arg_parts = parts[arg_start_idx:] + else: + # No protocol prefix, assume simple format + url = parts[0] + arg_parts = parts[1:] + + # Parse arguments + args = {} + for arg in arg_parts: + if arg and "=" in arg: + key, value = arg.split("=", 1) + args[key] = value + elif arg: + args[arg] = True + + return url, args + + +def build_repomix_command(repo_path: str, args: Dict[str, str]) -> List[str]: + """ + Build repomix command with arguments + + Args: + repo_path: Path to the repository + args: Dictionary of arguments + + Returns: + List of command parts + """ + cmd = ["repomix", "--stdout"] + + # Map of supported arguments to their command-line flags + supported_args = { + "compress": "--compress", + "style": "--style", + "include": "--include", + "ignore": "--ignore", + "remove-comments": "--remove-comments", + "remove-empty-lines": "--remove-empty-lines", + "output-show-line-numbers": "--output-show-line-numbers", + "no-file-summary": "--no-file-summary", + "no-directory-structure": "--no-directory-structure", + "no-files": "--no-files", + "header-text": "--header-text", + "instruction-file-path": "--instruction-file-path", + "include-empty-directories": "--include-empty-directories", + "no-git-sort-by-changes": "--no-git-sort-by-changes", + "include-diffs": "--include-diffs", + "no-gitignore": "--no-gitignore", + "no-default-patterns": "--no-default-patterns", + "no-security-check": "--no-security-check", + "token-count-encoding": "--token-count-encoding", + "top-files-len": "--top-files-len", + "verbose": "--verbose", + "quiet": "--quiet", + } + + # Add arguments to command + for arg, value in args.items(): + if arg in supported_args: + flag = supported_args[arg] + if value is True: + # Boolean flag + cmd.append(flag) + elif value and value != "": + # Value argument + cmd.extend([flag, value]) + # Skip empty string values + + # Add repository path + cmd.append(repo_path) + + return cmd + + @llm.hookimpl def register_fragment_loaders(register): register("repomix", repomix_loader) @@ -16,14 +172,21 @@ def repomix_loader(argument: str) -> List[llm.Fragment]: """ Load repository contents as fragments using Repomix - Argument is a git repository URL (https:// or ssh://) + Argument can be: + - A git repository URL: https://git.sr.ht/~amolith/willow + - URL with arguments: https://git.sr.ht/~amolith/willow:compress:include=*.py + Examples: repomix:https://git.sr.ht/~amolith/willow - repomix:ssh://git.sr.ht:~amolith/willow + repomix:ssh://git.sr.ht:~amolith/willow:compress + repomix:git@github.com:user/repo.git:include=*.ts,*.js:ignore=*.md """ - if not argument.startswith(("https://", "ssh://", "git@")): + # Parse the fragment string to extract URL and arguments + url, args = parse_fragment_string(argument) + + if not url.startswith(("https://", "ssh://", "git@")): raise ValueError( - f"Repository URL must start with https://, ssh://, or git@ - got: {argument}" + f"Repository URL must start with https://, ssh://, or git@ - got: {url}" ) # Check if repomix is available @@ -40,15 +203,18 @@ def repomix_loader(argument: str) -> List[llm.Fragment]: try: # Clone the repository subprocess.run( - ["git", "clone", "--depth=1", argument, str(repo_path)], + ["git", "clone", "--depth=1", url, str(repo_path)], check=True, capture_output=True, text=True, ) + # Build repomix command with arguments + repomix_cmd = build_repomix_command(str(repo_path), args) + # Run repomix on the cloned repository result = subprocess.run( - ["repomix", "--stdout", str(repo_path)], + repomix_cmd, check=True, capture_output=True, text=True, @@ -68,11 +234,11 @@ def repomix_loader(argument: str) -> List[llm.Fragment]: # Handle Git or repomix errors if "git" in str(e.cmd): raise ValueError( - f"Failed to clone repository {argument}: {e.stderr}" + f"Failed to clone repository {url}: {e.stderr}" ) elif "repomix" in str(e.cmd): raise ValueError( - f"Failed to run repomix on {argument}: {e.stderr}" + f"Failed to run repomix on {url}: {e.stderr}" ) else: raise ValueError( diff --git a/tests/test_fragments_repomix.py b/tests/test_fragments_repomix.py new file mode 100644 index 0000000000000000000000000000000000000000..ec85f9afc7057599144cc7b898dd91c970e46a75 --- /dev/null +++ b/tests/test_fragments_repomix.py @@ -0,0 +1,540 @@ +from llm_fragments_repomix import repomix_loader +import pytest +from unittest.mock import patch, Mock, MagicMock +import subprocess +import tempfile +import pathlib + + +class TestRepomixLoader: + """Test the repomix_loader function""" + + def test_invalid_url_formats(self): + """Test that invalid URL formats raise ValueError""" + invalid_urls = [ + "not-a-url", + "ftp://example.com/repo", + "file://local/path", + "http://example.com", # No path + "", # Empty string + ] + + for invalid_url in invalid_urls: + with pytest.raises(ValueError) as ex: + repomix_loader(invalid_url) + assert "Repository URL must start with https://, ssh://, or git@" in str(ex.value) + + @patch('llm_fragments_repomix.shutil.which') + def test_repomix_not_installed(self, mock_which): + """Test error when repomix is not installed""" + mock_which.return_value = None + + with pytest.raises(ValueError) as ex: + repomix_loader("https://github.com/user/repo") + assert "repomix command not found" in str(ex.value) + assert "https://github.com/yamadashy/repomix" in str(ex.value) + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_git_clone_failure(self, mock_run, mock_which): + """Test handling of git clone failures""" + mock_which.return_value = "/usr/bin/repomix" + + # Mock git clone failure + mock_run.side_effect = subprocess.CalledProcessError( + 1, ["git", "clone"], stderr="Repository not found" + ) + + with pytest.raises(ValueError) as ex: + repomix_loader("https://github.com/user/nonexistent") + assert "Failed to clone repository" in str(ex.value) + assert "Repository not found" in str(ex.value) + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_failure(self, mock_run, mock_which): + """Test handling of repomix execution failures""" + mock_which.return_value = "/usr/bin/repomix" + + # Mock successful git clone, failed repomix + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + raise subprocess.CalledProcessError( + 1, ["repomix"], stderr="Repomix error" + ) + + mock_run.side_effect = mock_run_side_effect + + with pytest.raises(ValueError) as ex: + repomix_loader("https://github.com/user/repo") + assert "Failed to run repomix" in str(ex.value) + assert "Repomix error" in str(ex.value) + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_successful_repomix_execution(self, mock_run, mock_which): + """Test successful repomix execution""" + mock_which.return_value = "/usr/bin/repomix" + + # Mock repomix output + repomix_output = """ +# Repository Structure + +## File: main.py +```python +print("Hello, World!") +``` + +## File: README.md +```markdown +# Test Project +This is a test project. +``` +""" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + return Mock(stdout=repomix_output, stderr="") + + mock_run.side_effect = mock_run_side_effect + + fragments = repomix_loader("https://github.com/user/repo") + + assert len(fragments) == 1 + assert str(fragments[0]) == repomix_output + assert fragments[0].source == "repomix:https://github.com/user/repo" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_different_url_formats(self, mock_run, mock_which): + """Test that different valid URL formats work""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + return Mock(stdout="test output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + valid_urls = [ + "https://github.com/user/repo", + "https://git.sr.ht/~user/repo", + "ssh://git@github.com:user/repo.git", + "git@github.com:user/repo.git", + ] + + for url in valid_urls: + fragments = repomix_loader(url) + assert len(fragments) == 1 + assert fragments[0].source == f"repomix:{url}" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_subprocess_calls(self, mock_run, mock_which): + """Test that subprocess calls are made correctly""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + return Mock(stdout="test output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + repo_url = "https://github.com/user/repo" + repomix_loader(repo_url) + + # Check git clone call + git_call = mock_run.call_args_list[0] + assert git_call[0][0][:3] == ["git", "clone", "--depth=1"] + assert git_call[0][0][3] == repo_url + assert git_call[1]["check"] is True + assert git_call[1]["capture_output"] is True + assert git_call[1]["text"] is True + + # Check repomix call + repomix_call = mock_run.call_args_list[1] + assert repomix_call[0][0][:2] == ["repomix", "--stdout"] + assert repomix_call[1]["check"] is True + assert repomix_call[1]["capture_output"] is True + assert repomix_call[1]["text"] is True + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_generic_exception_handling(self, mock_run, mock_which): + """Test handling of generic exceptions""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + raise OSError("Permission denied") + + mock_run.side_effect = mock_run_side_effect + + with pytest.raises(ValueError) as ex: + repomix_loader("https://github.com/user/repo") + assert "Error processing repository" in str(ex.value) + assert "Permission denied" in str(ex.value) + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + @patch('llm_fragments_repomix.tempfile.TemporaryDirectory') + def test_temporary_directory_cleanup(self, mock_tempdir, mock_run, mock_which): + """Test that temporary directory is properly cleaned up""" + mock_which.return_value = "/usr/bin/repomix" + + # Create a mock context manager for TemporaryDirectory + mock_context = MagicMock() + mock_context.__enter__.return_value = "/tmp/test_dir" + mock_context.__exit__.return_value = None + mock_tempdir.return_value = mock_context + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + return Mock(stdout="test output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + repomix_loader("https://github.com/user/repo") + + # Verify that the temporary directory context manager was used + mock_tempdir.assert_called_once() + mock_context.__enter__.assert_called_once() + mock_context.__exit__.assert_called_once() + + +class TestRepomixArgumentParsing: + """Test argument parsing for colon-separated options""" + + def test_parse_fragment_string_url_only(self): + """Test parsing URL without arguments""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo") + assert url == "https://github.com/user/repo" + assert args == {} + + def test_parse_fragment_string_with_compress(self): + """Test parsing URL with compress flag""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:compress") + assert url == "https://github.com/user/repo" + assert args == {"compress": True} + + def test_parse_fragment_string_with_include_patterns(self): + """Test parsing URL with include patterns""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:include=*.ts,*.js") + assert url == "https://github.com/user/repo" + assert args == {"include": "*.ts,*.js"} + + def test_parse_fragment_string_with_ignore_patterns(self): + """Test parsing URL with ignore patterns""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:ignore=*.log,tmp/") + assert url == "https://github.com/user/repo" + assert args == {"ignore": "*.log,tmp/"} + + def test_parse_fragment_string_multiple_args(self): + """Test parsing URL with multiple arguments""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:compress:include=*.py:ignore=tests/") + assert url == "https://github.com/user/repo" + assert args == { + "compress": True, + "include": "*.py", + "ignore": "tests/" + } + + def test_parse_fragment_string_complex_patterns(self): + """Test parsing with complex glob patterns""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:include=src/**/*.ts,**/*.md:ignore=**/*.test.ts,node_modules/") + assert url == "https://github.com/user/repo" + assert args == { + "include": "src/**/*.ts,**/*.md", + "ignore": "**/*.test.ts,node_modules/" + } + + def test_parse_fragment_string_boolean_flags(self): + """Test parsing various boolean flags""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:compress:remove-comments:remove-empty-lines") + assert url == "https://github.com/user/repo" + assert args == { + "compress": True, + "remove-comments": True, + "remove-empty-lines": True + } + + def test_parse_fragment_string_output_options(self): + """Test parsing output-related options""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:style=markdown:output-show-line-numbers") + assert url == "https://github.com/user/repo" + assert args == { + "style": "markdown", + "output-show-line-numbers": True + } + + def test_parse_fragment_string_ssh_url(self): + """Test parsing SSH URLs with arguments""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("git@github.com:user/repo.git:compress:include=*.py") + assert url == "git@github.com:user/repo.git" + assert args == { + "compress": True, + "include": "*.py" + } + + def test_parse_fragment_string_ssh_protocol_url(self): + """Test parsing SSH protocol URLs with arguments""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("ssh://git@github.com:user/repo.git:compress") + assert url == "ssh://git@github.com:user/repo.git" + assert args == {"compress": True} + + def test_parse_fragment_string_empty_args(self): + """Test parsing with empty argument values""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:include=") + assert url == "https://github.com/user/repo" + assert args == {"include": ""} + + def test_parse_fragment_string_duplicate_args(self): + """Test parsing with duplicate arguments (last one wins)""" + from llm_fragments_repomix import parse_fragment_string + + url, args = parse_fragment_string("https://github.com/user/repo:include=*.ts:include=*.js") + assert url == "https://github.com/user/repo" + assert args == {"include": "*.js"} + + def test_build_repomix_command_no_args(self): + """Test building repomix command without arguments""" + from llm_fragments_repomix import build_repomix_command + + cmd = build_repomix_command("/tmp/repo", {}) + assert cmd == ["repomix", "--stdout", "/tmp/repo"] + + def test_build_repomix_command_with_compress(self): + """Test building repomix command with compress flag""" + from llm_fragments_repomix import build_repomix_command + + cmd = build_repomix_command("/tmp/repo", {"compress": True}) + assert cmd == ["repomix", "--stdout", "--compress", "/tmp/repo"] + + def test_build_repomix_command_with_include(self): + """Test building repomix command with include patterns""" + from llm_fragments_repomix import build_repomix_command + + cmd = build_repomix_command("/tmp/repo", {"include": "*.ts,*.js"}) + assert cmd == ["repomix", "--stdout", "--include", "*.ts,*.js", "/tmp/repo"] + + def test_build_repomix_command_with_ignore(self): + """Test building repomix command with ignore patterns""" + from llm_fragments_repomix import build_repomix_command + + cmd = build_repomix_command("/tmp/repo", {"ignore": "*.log,tmp/"}) + assert cmd == ["repomix", "--stdout", "--ignore", "*.log,tmp/", "/tmp/repo"] + + def test_build_repomix_command_multiple_args(self): + """Test building repomix command with multiple arguments""" + from llm_fragments_repomix import build_repomix_command + + args = { + "compress": True, + "include": "*.py", + "ignore": "tests/", + "remove-comments": True + } + cmd = build_repomix_command("/tmp/repo", args) + expected = ["repomix", "--stdout", "--compress", "--include", "*.py", "--ignore", "tests/", "--remove-comments", "/tmp/repo"] + assert cmd == expected + + def test_build_repomix_command_with_style(self): + """Test building repomix command with style option""" + from llm_fragments_repomix import build_repomix_command + + cmd = build_repomix_command("/tmp/repo", {"style": "markdown"}) + assert cmd == ["repomix", "--stdout", "--style", "markdown", "/tmp/repo"] + + def test_build_repomix_command_boolean_flags(self): + """Test building repomix command with various boolean flags""" + from llm_fragments_repomix import build_repomix_command + + args = { + "compress": True, + "remove-comments": True, + "remove-empty-lines": True, + "output-show-line-numbers": True, + "no-file-summary": True + } + cmd = build_repomix_command("/tmp/repo", args) + expected = [ + "repomix", "--stdout", + "--compress", + "--remove-comments", + "--remove-empty-lines", + "--output-show-line-numbers", + "--no-file-summary", + "/tmp/repo" + ] + assert cmd == expected + + def test_build_repomix_command_unsupported_arg(self): + """Test that unsupported arguments are ignored""" + from llm_fragments_repomix import build_repomix_command + + args = { + "compress": True, + "unsupported-option": "value", + "include": "*.py" + } + cmd = build_repomix_command("/tmp/repo", args) + expected = ["repomix", "--stdout", "--compress", "--include", "*.py", "/tmp/repo"] + assert cmd == expected + + def test_build_repomix_command_empty_string_values(self): + """Test that empty string values are handled correctly""" + from llm_fragments_repomix import build_repomix_command + + args = { + "include": "", + "ignore": "*.log" + } + cmd = build_repomix_command("/tmp/repo", args) + # Empty include should be ignored, ignore should be included + expected = ["repomix", "--stdout", "--ignore", "*.log", "/tmp/repo"] + assert cmd == expected + + +class TestRepomixIntegrationWithArguments: + """Integration tests for repomix loader with arguments""" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_loader_with_compress(self, mock_run, mock_which): + """Test repomix loader with compress argument""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + # Verify compress flag is passed + assert "--compress" in cmd + return Mock(stdout="compressed output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + fragments = repomix_loader("https://github.com/user/repo:compress") + assert len(fragments) == 1 + assert str(fragments[0]) == "compressed output" + assert fragments[0].source == "repomix:https://github.com/user/repo:compress" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_loader_with_include_patterns(self, mock_run, mock_which): + """Test repomix loader with include patterns""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + # Verify include patterns are passed + assert "--include" in cmd + include_idx = cmd.index("--include") + assert cmd[include_idx + 1] == "*.ts,*.js" + return Mock(stdout="filtered output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + fragments = repomix_loader("https://github.com/user/repo:include=*.ts,*.js") + assert len(fragments) == 1 + assert str(fragments[0]) == "filtered output" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_loader_with_multiple_args(self, mock_run, mock_which): + """Test repomix loader with multiple arguments""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + # Verify multiple arguments are passed + assert "--compress" in cmd + assert "--include" in cmd + assert "--ignore" in cmd + include_idx = cmd.index("--include") + assert cmd[include_idx + 1] == "*.py" + ignore_idx = cmd.index("--ignore") + assert cmd[ignore_idx + 1] == "tests/" + return Mock(stdout="multi-arg output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + fragments = repomix_loader("https://github.com/user/repo:compress:include=*.py:ignore=tests/") + assert len(fragments) == 1 + assert str(fragments[0]) == "multi-arg output" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_loader_with_ssh_url_and_args(self, mock_run, mock_which): + """Test repomix loader with SSH URL and arguments""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + assert "--compress" in cmd + return Mock(stdout="ssh output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + fragments = repomix_loader("git@github.com:user/repo.git:compress") + assert len(fragments) == 1 + assert str(fragments[0]) == "ssh output" + assert fragments[0].source == "repomix:git@github.com:user/repo.git:compress" + + @patch('llm_fragments_repomix.shutil.which') + @patch('llm_fragments_repomix.subprocess.run') + def test_repomix_loader_preserves_original_source(self, mock_run, mock_which): + """Test that the original fragment string is preserved in source""" + mock_which.return_value = "/usr/bin/repomix" + + def mock_run_side_effect(cmd, **kwargs): + if cmd[0] == "git": + return Mock(stdout="", stderr="") + elif cmd[0] == "repomix": + return Mock(stdout="test output", stderr="") + + mock_run.side_effect = mock_run_side_effect + + original_string = "https://github.com/user/repo:compress:include=*.ts,*.js:ignore=*.md" + fragments = repomix_loader(original_string) + assert fragments[0].source == f"repomix:{original_string}" \ No newline at end of file