Repository: reposcope

File Tree:
└── .github/workflows/python-package.yml
└── .gitignore
└── LICENSE
└── README.md
└── context.txt
└── pyproject.toml
└── src/reposcope/__init__.py
└── src/reposcope/__main__.py
└── src/reposcope/cli.py
└── src/reposcope/core.py
└── src/reposcope/profiles.py
└── test.res
└── tests/test_cli.py
└── tests/test_profiles_integration.py
└── tests/test_profiles_unit.py
└── tests/test_reposcope.py

File Contents:

--- .github/workflows/python-package.yml ---
name: Python package

on:
  push:
    branches: [ "main" ]
    tags: [ "v*" ]
  pull_request:
    branches: [ "main" ]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest]
        python-version: ["3.9", "3.10", "3.11", "3.12"]

    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v5
      with:
        python-version: ${{ matrix.python-version }}
        
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -e ".[dev]"
        
    - name: Run tests
      run: |
        pytest

  publish:
    needs: test
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
    permissions:
      id-token: write
    
    steps:
    - uses: actions/checkout@v4
    
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: "3.10"
        
    - name: Build package
      run: |
        python -m pip install --upgrade pip
        pip install build
        python -m build
        
    - name: Publish to PyPI
      uses: pypa/gh-action-pypi-publish@release/v1

--- .gitignore ---
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.pytest_cache

# Virtual environments
.venv

# Git
.git


--- LICENSE ---
MIT License
Copyright (c) 2025 Aleksei Shevkoplias
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.

--- README.md ---
# RepoScope 🔍

Effortlessly generate a comprehensive context of your repository, perfect for sharing with AI assistants. Tired of copy-pasting individual files into tools like ChatGPT? RepoScope automates the process.

---

## Install

```bash
pip install reposcope
```

Python 3.9+ required. Linux only for now (Windows/macOS maybe later).

---

## Quick Start

Default mode - rely on your `.gitignore` to exclude unnecessary files:
```bash
reposcope -g
```

Or, explicitly select the files you want:
```bash
reposcope -i "src/*.py" "src/*.js"
```

You'll get a `context.txt` file with a neatly structured output:

```
Repository: my-project

File Tree:
└── src/main.py
└── src/utils.py
└── README.md

File Contents:

--- src/main.py ---
def main():
    print("Hello World!")
...
```

---

## Why RepoScope?

Managing large repositories can be overwhelming when you need to share or review specific sections. Copy-pasting is tedious and error-prone, especially when dealing with multiple files. RepoScope streamlines this by:

- **Saving Time:** Automates file collection into a single document.
- **Providing Flexibility:** Choose files to include or exclude based on your needs.
- **Ensuring Reproducibility:** Profiles let you save selection patterns for consistent results.

---

## Two Modes of Operation

### 1. **Exclude Mode**
Skip files or directories you don’t need:
```bash
reposcope -g  # use .gitignore
reposcope -x "*.log" "temp/*"  # specify gitignore-style patterns
reposcope -X exclude.txt  # use gitignore-style file
```
Use patterns or files to exclude unwanted files.

### 2. **Include Mode**
Explicitly select files you want to include:
```bash
reposcope -i "*.py" "src/*.js"
reposcope -I include.txt
```

**Important:** You **cannot** mix exclude and include patterns within a single command. Choose one mode per operation to avoid ambiguity and ensure consistent results.

---

## Profiles Management

Profiles allow you to save and reuse file selection patterns, making scanning efficient and repeatable.

### Why Use Profiles?

Imagine a scenario where you regularly need to include only Python source files and Markdown documents. Instead of retyping the patterns every time, you can create a profile:

```bash
reposcope profile create my_profile --mode include
reposcope profile add my_profile "*.py" "docs/*.md"
```

Now, simply use:
```bash
reposcope -p my_profile
```

### Profile Commands

#### Create a Profile
```bash
reposcope profile create my_profile --mode include
```
* **Modes**:
  * `include`: Only files matching patterns are included.
  * `exclude`: All files are included except those matching patterns.

#### Add Patterns
```bash
reposcope profile add my_profile "*.py" "src/*.js"
```

#### Remove Patterns
```bash
reposcope profile remove my_profile "*.py"
```

#### Import Patterns from a File
```bash
reposcope profile import my_profile patterns.txt
```
Supports `.gitignore` format with the `--gitignore` flag.

#### Export Patterns
```bash
reposcope profile export my_profile --gitignore
```

#### List Profiles
```bash
reposcope profile list_profiles
```

#### Show Profile Details
```bash
reposcope profile show my_profile
```

#### Delete a Profile
```bash
reposcope profile delete my_profile
```

---

## Command Line Options

| Short | Long                                | Description                             |
|-------|-------------------------------------|-----------------------------------------|
| -g    | --use-gitignore                     | Use .gitignore                          |
| -x    | --exclude, --ignore                 | Patterns to exclude                     |
| -X    | --exclude-file, --ignore-file       | File with exclude patterns              |
| -i    | --include                           | Patterns to include                     |
| -I    | --include-file                      | File with include patterns              |
| -o    | --output                            | Output file (default: context.txt)      |
| -d    | --dir                               | Repository directory                    |
| -v    | --verbose                           | Show debug logs                         |
| -p    | --profile                           | Use a saved profile                     |

---

## Development & Testing

1. **Install development dependencies**:
   ```bash
   pip install -e ".[dev]"
   ```

2. **Run the test suite**:
   ```bash
   pytest
   ```

3. **Testing Details**:
   - CLI commands (`reposcope`) and subcommands (like `profile create`) are fully tested.
   - Profiles are mocked to ensure accurate interactions.

---

## Examples

### Generate Context Using a Profile
```bash
reposcope -p my_profile -o output.txt
```

### Combine Exclude and Include Patterns (Not Allowed)
You **cannot** mix `-x` (exclude) and `-i` (include) options:
```bash
# Invalid usage
reposcope -g -x "*.log" -i "*.py"
```

Instead, stick to one mode per command.

### Mix Verbose Logging with File Output
```bash
reposcope -g -v -o repo_context.txt
```

---

## License

MIT. Do whatever.

---

## Contributing

If you spot bugs or have ideas, feel free to open an issue or PR. Help us make RepoScope even better!


--- context.txt ---


--- pyproject.toml ---
[project]
name = "reposcope"
version = "0.1.3"
description = "Collect repository files into a single document for easy sharing with AI assistants"
readme = "README.md"
authors = [
    { name = "AlekseiShevkoplias", email = "shevshelles@gmail.com" }
]
requires-python = ">=3.9"
dependencies = []
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "Topic :: Software Development :: Libraries :: Python Modules",
    "License :: OSI Approved :: MIT License",
    "Operating System :: POSIX :: Linux", 
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

[project.scripts]
reposcope = "reposcope.__main__:run_main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project.optional-dependencies]
dev = [
    "pytest>=8.3.4",
    "pytest-cov>=6.0.0",
]


--- src/reposcope/__init__.py ---
"""RepoScope - Repository content collector for LLM context."""

__version__ = "0.1.3"

from reposcope.core import RepoScope

__all__ = ["RepoScope"]

--- src/reposcope/__main__.py ---
"""Entry point for the reposcope command."""

from reposcope.cli import main

if __name__ == "__main__":
    main()

# This is needed for the entry point
def run_main():
    main()

--- src/reposcope/cli.py ---
#!/usr/bin/env python3
import argparse
import logging
import sys
from pathlib import Path
from reposcope.core import RepoScope
from reposcope.profiles import ProfileManager, ProfileError

def setup_logging(verbose: bool):
    """Configure logging to include log levels and output to stderr."""
    level = logging.DEBUG if verbose else logging.WARNING
    logging.basicConfig(
        level=level,
        format='%(levelname)s: %(message)s',  # Include log level names
        stream=sys.stderr,  # Explicitly output to stderr
        force=True  # Force reconfiguration to ensure stderr output
    )
    logging.debug("Verbose logging enabled")  # Add a debug log to ensure it's captured


def setup_parser():
    """Create and configure the argument parser."""
    parser = argparse.ArgumentParser(
        description="Generate repository context files for LLMs"
    )
    subparsers = parser.add_subparsers(dest='command')

    # Scan command (main functionality)
    scan = subparsers.add_parser('scan', help='Generate context file (default command)')
    _setup_scan_arguments(scan)
    scan.set_defaults(command='scan')  # Make 'scan' the default

    # Profile command group
    profile = subparsers.add_parser('profile', help='Manage profiles')
    profile_sub = profile.add_subparsers(dest='action', required=True)
    _setup_profile_arguments(profile_sub)

    return parser


def _setup_scan_arguments(parser):
    """Setup arguments for the main scan command."""
    parser.add_argument(
        "-d", "--dir", 
        default=".",
        help="Root directory of the repository"
    )
    parser.add_argument(
        "-o", "--output",
        default="context.txt",
        help="Output file path"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Show what's happening"
    )
    
    # Profile usage
    parser.add_argument(
        "-p", "--profile",
        help="Use saved profile"
    )

    # Basic operation modes
    parser.add_argument(
        "-g", "--use-gitignore",
        action="store_true",
        help="Use patterns from .gitignore file"
    )

    # Ignore-based selection
    parser.add_argument(
        "-x", "--exclude", "--ignore",
        dest="ignore",
        nargs="*",
        help="Specify patterns to exclude"
    )
    parser.add_argument(
        "-X", "--exclude-file", "--ignore-file",
        dest="ignore_file",
        help="Use patterns from specified exclude file"
    )

    # Include-based selection
    parser.add_argument(
        "-i", "--include",
        nargs="*",
        help="Specify patterns to include"
    )
    parser.add_argument(
        "-I", "--include-file",
        help="Use patterns from specified include file"
    )

def _setup_profile_arguments(subparsers):
    """Setup arguments for profile management commands."""
    # Create profile
    create = subparsers.add_parser('create', help='Create new profile')
    create.add_argument('name', help='Profile name')
    create.add_argument('--mode', choices=['include', 'exclude'], required=True,
                       help='Profile mode')

    # Delete profile
    delete = subparsers.add_parser('delete', help='Delete profile')
    delete.add_argument('name', help='Profile name')

    # List profiles
    subparsers.add_parser('list_profiles', help='List available profiles')

    # Show profile
    show = subparsers.add_parser('show', help='Show profile details')
    show.add_argument('name', help='Profile name')

    # Add patterns
    add = subparsers.add_parser('add', help='Add patterns to profile')
    add.add_argument('name', help='Profile name')
    add.add_argument('patterns', nargs='+', help='Patterns to add')

    # Remove patterns
    remove = subparsers.add_parser('remove', help='Remove patterns from profile')
    remove.add_argument('name', help='Profile name')
    remove.add_argument('patterns', nargs='+', help='Patterns to remove')

    # Import patterns
    import_cmd = subparsers.add_parser('import', help='Import patterns from file')
    import_cmd.add_argument('name', help='Profile name')
    import_cmd.add_argument('file', help='File to import patterns from')
    import_cmd.add_argument('--gitignore', action='store_true',
                           help='Parse as .gitignore format')

    # Export patterns
    export = subparsers.add_parser('export', help='Export patterns to stdout')
    export.add_argument('name', help='Profile name')
    export.add_argument('--gitignore', action='store_true',
                       help='Format as .gitignore')

def handle_scan(args, profile_mgr):
    """Handle the main scan command."""
    logger = logging.getLogger(__name__)
    logger.debug("Starting file collection with verbose logging enabled.")

    try:
        scope = RepoScope(args.dir)

        if args.profile:
            profile = profile_mgr.get(args.profile)
            if profile.mode == 'include':
                scope.use_include_patterns(profile.patterns)
            else:
                scope.use_gitignore()
                scope.use_ignore_patterns(profile.patterns)

        if args.include or args.include_file:
            if args.include_file:
                scope.use_include_file(args.include_file)
            if args.include:
                scope.use_include_patterns(args.include)
        else:
            if args.use_gitignore:
                scope.use_gitignore()
            if args.ignore_file:
                scope.use_ignore_file(args.ignore_file)
            if args.ignore:
                scope.use_ignore_patterns(args.ignore)

        scope.generate_context_file(args.output)
        print(f"Generated context file: {args.output}")

    except Exception as e:
        logger.error(f"Error: {e}")
        sys.exit(1)


def handle_profile(args, profile_mgr):
    """Handle profile management commands."""
    try:
        if args.action == 'create':
            profile = profile_mgr.create(args.name, args.mode)
            print(f"Created profile: {profile.summary()}")

        elif args.action == 'delete':
            profile_mgr.delete(args.name)
            print(f"Deleted profile: {args.name}")

        elif args.action == 'list_profiles':
            profiles = profile_mgr.get_profiles()
            if not profiles:
                print("No profiles found")
                return
            print("Available profiles:")
            for profile in profiles:
                print(f"  {profile.summary()}")

        elif args.action == 'show':
            profile = profile_mgr.get(args.name)
            print(profile.details())

        elif args.action == 'add':
            added = profile_mgr.add_patterns(args.name, args.patterns)
            if added:
                print(f"Added to {args.name}:")
                for pattern in added:
                    print(f"  {pattern}")
            else:
                print("No new patterns added (already exist)")

        elif args.action == 'remove':
            removed = profile_mgr.remove_patterns(args.name, args.patterns)
            if removed:
                print(f"Removed from {args.name}:")
                for pattern in removed:
                    print(f"  {pattern}")
            else:
                print("No patterns removed (not found)")

        elif args.action == 'import':
            added = profile_mgr.import_patterns(args.name, args.file, args.gitignore)
            if added:
                print(f"Imported to {args.name}:")
                for pattern in added:
                    print(f"  {pattern}")
            else:
                print("No new patterns imported (already exist)")

        elif args.action == 'export':
            print(profile_mgr.export_patterns(args.name, args.gitignore))

    except ProfileError as e:
        logging.error(str(e))
        sys.exit(1)

def main(): 
    """ Entrypoint for the reposcope CLI. If the user has not specified a subcommand 
    ('scan' or 'profile'), this function forces 'scan' to become the default subcommand, 
    allowing top-level flags like -g or --exclude to work as intended. """
    parser = setup_parser()
    
    # If no arguments provided, or first argument is not a known command
    if len(sys.argv) == 1:
        sys.argv.append('scan')
    else:
        first_arg = sys.argv[1]
        if first_arg not in ['scan', 'profile', '-h', '--help']:
            sys.argv.insert(1, 'scan')

    args = parser.parse_args()
    
    # Always setup logging. For scan command, use verbose flag 
    # For profile commands, default to warning level
    verbose = hasattr(args, 'verbose') and args.verbose
    setup_logging(verbose)
    
    profile_mgr = ProfileManager()

    if args.command == 'scan':
        handle_scan(args, profile_mgr)
    elif args.command == 'profile':
        handle_profile(args, profile_mgr)

if __name__ == "__main__":
    main()

--- src/reposcope/core.py ---
import os
import logging
from pathlib import Path
from typing import List, Set
import fnmatch

logger = logging.getLogger(__name__)

class RepoScope:
    def __init__(self, root_dir: str):
        self.root_dir = Path(root_dir).resolve()
        self.patterns: Set[str] = set()
        self.is_include_mode = False
        logger.info(f"Initialized RepoScope with root directory: {self.root_dir}")

    def _process_gitignore_pattern(self, pattern: str) -> List[str]:
        """Process a single pattern according to .gitignore rules."""
        if not pattern or pattern.startswith('#'):
            return []

        patterns = []
        
        # Handle directory patterns
        if pattern.endswith('/'):
            pattern = pattern[:-1]
            # Add both with and without trailing slash
            patterns.extend([pattern, f"{pattern}/"])
        else:
            patterns.append(pattern)

        # If pattern doesn't start with /, add **/ variant to match in subdirectories
        if not pattern.startswith('/'):
            for p in patterns.copy():
                patterns.append(f"**/{p}")

        # If pattern starts with /, remove it as we're using relative paths
        patterns = [p[1:] if p.startswith('/') else p for p in patterns]

        return patterns

    def use_gitignore(self) -> 'RepoScope':
        """Load patterns from .gitignore file."""
        gitignore_path = self.root_dir / '.gitignore'
        if gitignore_path.exists():
            logger.info(f"Loading patterns from .gitignore: {gitignore_path}")
            self._load_patterns_from_file(gitignore_path)
        else:
            logger.warning(f"No .gitignore found in {self.root_dir}")
        return self

    def use_ignore_file(self, ignore_file: str) -> 'RepoScope':
        """Load patterns from specified ignore file using .gitignore rules."""
        ignore_path = Path(ignore_file)
        if ignore_path.exists():
            logger.info(f"Loading patterns from ignore file: {ignore_path}")
            self._load_patterns_from_file(ignore_path)
        else:
            logger.warning(f"Ignore file not found: {ignore_path}")
        return self

    def use_ignore_patterns(self, patterns: List[str]) -> 'RepoScope':
        """Add ignore patterns directly, processing them according to .gitignore rules."""
        if patterns:
            logger.info(f"Adding ignore patterns: {patterns}")
            for pattern in patterns:
                processed_patterns = self._process_gitignore_pattern(pattern)
                self.patterns.update(processed_patterns)
                logger.debug(f"Pattern '{pattern}' expanded to: {processed_patterns}")
        return self

    def use_include_file(self, include_file: str) -> 'RepoScope':
        """Switch to include mode and load patterns from include file using .gitignore rules."""
        self.is_include_mode = True
        self.patterns.clear()
        include_path = Path(include_file)
        if include_path.exists():
            logger.info(f"Loading include patterns from file: {include_path}")
            self._load_patterns_from_file(include_path)
        else:
            logger.warning(f"Include file not found: {include_path}")
        return self

    def use_include_patterns(self, patterns: List[str]) -> 'RepoScope':
        """Switch to include mode and use specified patterns, processing them according to .gitignore rules."""
        logger.info(f"Switching to include mode with patterns: {patterns}")
        self.is_include_mode = True
        self.patterns.clear()
        if patterns:
            for pattern in patterns:
                processed_patterns = self._process_gitignore_pattern(pattern)
                self.patterns.update(processed_patterns)
                logger.debug(f"Pattern '{pattern}' expanded to: {processed_patterns}")
        return self

    def _load_patterns_from_file(self, file_path: Path):
        """Load and process patterns from a file according to .gitignore rules."""
        patterns_before = len(self.patterns)
        with open(file_path, 'r') as f:
            for line in f:
                pattern = line.strip()
                processed_patterns = self._process_gitignore_pattern(pattern)
                if processed_patterns:
                    self.patterns.update(processed_patterns)
                    logger.debug(f"Pattern '{pattern}' expanded to: {processed_patterns}")
        
        patterns_added = len(self.patterns) - patterns_before
        logger.debug(f"Loaded {patterns_added} patterns from {file_path}")

    def _should_skip_directory(self, dir_path: Path) -> bool:
        """Check if directory should be skipped based on patterns."""
        if self.is_include_mode:
            return False

        rel_path = str(dir_path.relative_to(self.root_dir))
        if not rel_path:  # Root directory
            return False

        # Add trailing slash to match directory patterns
        rel_path_with_slash = f"{rel_path}/"
        
        for pattern in self.patterns:
            if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path_with_slash, pattern):
                logger.debug(f"Skipping directory {rel_path} (matched pattern: {pattern})")
                return True
        return False

    def _should_include_file(self, file_path: Path) -> bool:
        """Determine if a file should be included based on current mode and patterns."""
        rel_path = str(file_path.relative_to(self.root_dir))
        
        # Always skip .git directory
        if ".git/" in f"{rel_path}/":
            return False

        if self.is_include_mode:
            # Include mode: file must match at least one pattern
            should_include = any(fnmatch.fnmatch(rel_path, pattern) for pattern in self.patterns)
            logger.debug(f"Include mode - File {rel_path}: {'✓' if should_include else '✗'}")
            return should_include
        else:
            # Ignore mode: file must not match any pattern
            for pattern in self.patterns:
                if fnmatch.fnmatch(rel_path, pattern):
                    logger.debug(f"Ignore mode - File {rel_path}: ✗ (matched pattern: {pattern})")
                    return False
            logger.debug(f"Ignore mode - File {rel_path}: ✓")
            return True

    def collect_files(self) -> List[Path]:
        """Collect all files based on current configuration."""
        logger.info(f"Starting file collection in {'include' if self.is_include_mode else 'ignore'} mode")
        logger.info(f"Current patterns: {self.patterns}")
        
        included_files = []
        
        for root, dirs, files in os.walk(self.root_dir, topdown=True):
            root_path = Path(root)

            # Modify dirs in-place to skip ignored directories
            dirs[:] = [d for d in dirs if not self._should_skip_directory(root_path / d)]

            for file in files:
                file_path = root_path / file
                if self._should_include_file(file_path):
                    included_files.append(file_path)

        logger.info(f"Collected {len(included_files)} files")
        return included_files

    def generate_context_file(self, output_file: str):
        """Generate the context file with directory tree and file contents."""
        logger.info(f"Generating context file: {output_file}")
        files = self.collect_files()
        
        with open(output_file, 'w') as f:
            # Write root directory name
            f.write(f"Repository: {self.root_dir.name}\n\n")
            
            # Write file tree
            f.write("File Tree:\n")
            for file in sorted(files):
                rel_path = file.relative_to(self.root_dir)
                f.write(f"└── {rel_path}\n")
            f.write("\n")
            
            # Write file contents
            f.write("File Contents:\n")
            written_files = 0
            for file in sorted(files):
                rel_path = file.relative_to(self.root_dir)
                f.write(f"\n--- {rel_path} ---\n")
                try:
                    with open(file, 'r') as content_file:
                        f.write(content_file.read())
                    written_files += 1
                except UnicodeDecodeError:
                    f.write("[Binary file]\n")
                    logger.warning(f"Skipped binary file: {rel_path}")
                except Exception as e:
                    f.write(f"[Error reading file: {str(e)}]\n")
                    logger.error(f"Error reading file {rel_path}: {str(e)}")
                f.write("\n")
        
        logger.info(f"Successfully wrote {written_files} files to {output_file}")

--- src/reposcope/profiles.py ---
#!/usr/bin/env python3
import json
from pathlib import Path
import logging

logger = logging.getLogger(__name__)

class ProfileError(Exception):
    """Base exception for profile-related errors."""
    pass

class Profile:
    """A profile that stores include/exclude patterns."""
    def __init__(self, name: str, mode: str, patterns: list[str] = None):
        if mode not in ('include', 'exclude'):
            raise ProfileError(f"Invalid mode: {mode}. Must be 'include' or 'exclude'")
        self.name = name
        self.mode = mode
        self.patterns = patterns or []

    def add_patterns(self, patterns: list[str]) -> list[str]:
        """Add new patterns to the profile. Returns list of actually added patterns."""
        new_patterns = [p for p in patterns if p not in self.patterns]
        if new_patterns:
            self.patterns.extend(new_patterns)
        return new_patterns

    def remove_patterns(self, patterns: list[str]) -> list[str]:
        """Remove patterns from the profile. Returns list of actually removed patterns."""
        removed = []
        for pattern in patterns:
            if pattern in self.patterns:
                self.patterns.remove(pattern)
                removed.append(pattern)
        return removed

    def to_dict(self) -> dict:
        """Convert profile to dictionary for JSON storage."""
        return {
            "mode": self.mode,
            "patterns": self.patterns
        }

    @classmethod
    def from_dict(cls, name: str, data: dict) -> 'Profile':
        """Create profile from JSON data."""
        return cls(
            name=name,
            mode=data["mode"],
            patterns=data.get("patterns", [])
        )

    def summary(self) -> str:
        """Get a short summary of the profile."""
        return f"{self.name} ({self.mode}, {len(self.patterns)} patterns)"

    def details(self) -> str:
        """Get detailed information about the profile."""
        header = f"{self.name} ({self.mode} mode)"
        if not self.patterns:
            return f"{header}\n  No patterns defined"
        patterns = '\n  '.join(self.patterns)
        return f"{header}\n  {patterns}"


class ProfileManager:
    """Manages profiles stored in JSON format and keeps them in memory."""
    def __init__(self):
        self.config_dir = Path.home() / ".config" / "reposcope"
        self.profiles_file = self.config_dir / "profiles.json"
        self._ensure_config_dir()
        self._profiles = self._load_profiles()

    def _ensure_config_dir(self):
        self.config_dir.mkdir(parents=True, exist_ok=True)
        if not self.profiles_file.exists():
            self._save_profiles({})

    def _load_profiles(self):
        try:
            data = json.loads(self.profiles_file.read_text())
            return {name: Profile.from_dict(name, profile_data) for name, profile_data in data.items()}
        except json.JSONDecodeError as e:
            raise ProfileError(f"Invalid profiles file format: {e}")
        except Exception as e:
            raise ProfileError(f"Failed to load profiles: {e}")

    def _save_profiles(self, data=None):
        try:
            if data is None:
                data = {name: profile.to_dict() for name, profile in self._profiles.items()}
            self.profiles_file.write_text(json.dumps(data, indent=2))
        except Exception as e:
            raise ProfileError(f"Failed to save profiles: {e}")

    def create(self, name, mode):
        """Create a new profile."""
        if name in self._profiles:
            raise ProfileError(f"Profile '{name}' already exists")
        profile = Profile(name=name, mode=mode)
        self._profiles[name] = profile
        self._save_profiles()
        return profile

    def get(self, name):
        """Retrieve a profile by name."""
        if name not in self._profiles:
            raise ProfileError(f"Profile '{name}' not found")
        return self._profiles[name]

    def delete(self, name):
        """Delete a profile by name."""
        if name not in self._profiles:
            raise ProfileError(f"Profile '{name}' not found")
        del self._profiles[name]
        self._save_profiles()

    def get_profiles(self):
        """List all profiles."""
        return list(self._profiles.values())

    def add_patterns(self, name, patterns):
        """Add patterns to a profile."""
        profile = self.get(name)
        added = profile.add_patterns(patterns)
        if added:
            self._save_profiles()
        return added

    def remove_patterns(self, name, patterns):
        """Remove patterns from a profile."""
        profile = self.get(name)
        removed = profile.remove_patterns(patterns)
        if removed:
            self._save_profiles()
        return removed

    def export_patterns(self, name, gitignore=False):
        """Export patterns from a profile."""
        profile = self.get(name)
        header = f"# Profile: {profile.name} ({profile.mode} mode)\n"
        patterns_list = profile.patterns
        
        # If gitignore is True, add trailing slash to directory patterns
        if gitignore:
            patterns_list = [p + '/' if not p.endswith('/') and '/' in p else p for p in patterns_list]
        
        return header + "\n".join(patterns_list)

    def import_patterns(self, name, file_path, gitignore=False):
        """Import patterns from a file."""
        path = Path(file_path)
        if not path.exists():
            raise ProfileError(f"File not found: {file_path}")
        
        with open(path) as f:
            # Filter out comments and blank lines
            patterns = [line.strip() for line in f if line.strip() and not line.startswith('#')]
            
            # If gitignore is True, remove trailing slash from patterns
            if gitignore:
                patterns = [pattern.rstrip('/') for pattern in patterns]
        
        return self.add_patterns(name, patterns)

--- test.res ---
============================= test session starts ==============================
platform linux -- Python 3.10.12, pytest-8.3.4, pluggy-1.5.0
rootdir: /home/user920/Documents/projects/reposcope
configfile: pyproject.toml
plugins: anyio-4.6.0, cov-6.0.0
collected 35 items

tests/test_cli.py ..............                                         [ 40%]
tests/test_profiles_integration.py ...                                   [ 48%]
tests/test_profiles_unit.py ......                                       [ 65%]
tests/test_reposcope.py ............                                     [100%]

============================== 35 passed in 0.16s ==============================


--- tests/test_cli.py ---
#!/usr/bin/env python3
import pytest
import sys
import os
import tempfile
import argparse
from pathlib import Path
from unittest.mock import patch, Mock, call

from reposcope.cli import (
    setup_parser, 
    main, 
    handle_scan, 
    handle_profile,
    setup_logging
)
from reposcope.profiles import ProfileManager, ProfileError

class TestCLIParser:
    @pytest.fixture
    def parser(self):
        """Create a parser for testing."""
        return setup_parser()

    def test_default_scan_arguments(self, parser):
        """Test default arguments for scan command."""
        # Parse minimal scan command
        args = parser.parse_args(['scan'])
        
        assert args.command == 'scan'
        assert args.dir == '.'
        assert args.output == 'context.txt'
        assert not args.verbose
        assert not args.use_gitignore
        assert args.ignore is None
        assert args.ignore_file is None
        assert args.include is None
        assert args.include_file is None

    def test_scan_with_all_options(self, parser):
        """Test scan command with all possible options."""
        args = parser.parse_args([
            'scan', 
            '-d', '/path/to/repo', 
            '-o', 'custom_context.txt', 
            '-v', 
            '-g', 
            '-x', '*.log', 'build/', 
            '-X', 'exclude.txt', 
            '-i', '*.py', 'src/', 
            '-I', 'include.txt',
            '-p', 'my_profile'
        ])

        assert args.command == 'scan'
        assert args.dir == '/path/to/repo'
        assert args.output == 'custom_context.txt'
        assert args.verbose
        assert args.use_gitignore
        assert args.ignore == ['*.log', 'build/']
        assert args.ignore_file == 'exclude.txt'
        assert args.include == ['*.py', 'src/']
        assert args.include_file == 'include.txt'
        assert args.profile == 'my_profile'

    def test_profile_create_command(self, parser):
        """Test profile create command with required arguments."""
        args = parser.parse_args(['profile', 'create', 'test_profile', '--mode', 'include'])
        
        assert args.command == 'profile'
        assert args.action == 'create'
        assert args.name == 'test_profile'
        assert args.mode == 'include'

    def test_profile_create_mode_choices(self, parser):
        """Test profile create command mode choices."""
        # Valid modes should pass
        parser.parse_args(['profile', 'create', 'test_profile', '--mode', 'include'])
        parser.parse_args(['profile', 'create', 'test_profile', '--mode', 'exclude'])

        # Invalid mode should raise an error
        with pytest.raises(SystemExit):
            parser.parse_args(['profile', 'create', 'test_profile', '--mode', 'invalid'])

    def test_profile_subcommands(self, parser):
        """Test various profile subcommands."""
        # Delete profile
        args = parser.parse_args(['profile', 'delete', 'test_profile'])
        assert args.command == 'profile'
        assert args.action == 'delete'
        assert args.name == 'test_profile'

        # List profiles
        args = parser.parse_args(['profile', 'list_profiles'])
        assert args.command == 'profile'
        assert args.action == 'list_profiles'

        # Show profile
        args = parser.parse_args(['profile', 'show', 'test_profile'])
        assert args.command == 'profile'
        assert args.action == 'show'
        assert args.name == 'test_profile'

        # Add patterns
        args = parser.parse_args(['profile', 'add', 'test_profile', '*.py', 'src/'])
        assert args.command == 'profile'
        assert args.action == 'add'
        assert args.name == 'test_profile'
        assert args.patterns == ['*.py', 'src/']

        # Remove patterns
        args = parser.parse_args(['profile', 'remove', 'test_profile', '*.log', 'build/'])
        assert args.command == 'profile'
        assert args.action == 'remove'
        assert args.name == 'test_profile'
        assert args.patterns == ['*.log', 'build/']

        # Import patterns
        args = parser.parse_args(['profile', 'import', 'test_profile', 'patterns.txt', '--gitignore'])
        assert args.command == 'profile'
        assert args.action == 'import'
        assert args.name == 'test_profile'
        assert args.file == 'patterns.txt'
        assert args.gitignore

        # Export patterns
        args = parser.parse_args(['profile', 'export', 'test_profile', '--gitignore'])
        assert args.command == 'profile'
        assert args.action == 'export'
        assert args.name == 'test_profile'
        assert args.gitignore


class TestCLIFunctionality:
    @pytest.fixture
    def temp_repo(self):
        """Create a temporary repository for testing."""
        with tempfile.TemporaryDirectory() as temp_dir:
            repo_dir = Path(temp_dir)

            # Create directory structure
            (repo_dir / "src").mkdir()
            (repo_dir / "docs").mkdir()
            (repo_dir / "build").mkdir()

            # Create test files
            (repo_dir / "src" / "main.py").write_text("print('main')")
            (repo_dir / "src" / "utils.py").write_text("print('utils')")
            (repo_dir / "docs" / "README.md").write_text("# Documentation")
            (repo_dir / "build" / "output.log").write_text("build output")
            
            (repo_dir / ".gitignore").write_text("build/\n*.log")

            yield repo_dir

    @pytest.fixture
    def mock_profile_manager(self):
        """Fixture for creating a mock ProfileManager."""
        return Mock(spec=ProfileManager)

    def test_logging_setup(self, capsys):
        """Test logging setup in different verbosity modes."""
        # Verbose mode
        setup_logging(True)
        import logging
        logger = logging.getLogger()
        assert logger.level == logging.DEBUG

        # Non-verbose mode
        setup_logging(False)
        assert logger.level == logging.WARNING

    def test_main_no_arguments(self, monkeypatch):
        """Test main function when no arguments are provided."""
        # Mock sys.argv and parse_args to prevent actual parsing
        with patch('sys.argv', ['reposcope']):
            with patch('argparse.ArgumentParser.parse_args') as mock_parse:
                with patch('reposcope.cli.handle_scan') as mock_handle_scan:
                    # Simulate parsing arguments
                    mock_parse.return_value = Mock(command='scan', verbose=False)
                    
                    # Call main
                    main()
                    
                    # Verify that scan was called with inserted 'scan' argument
                    mock_parse.assert_called_once()
                    mock_handle_scan.assert_called_once()

    def test_main_mixed_arguments(self, monkeypatch):
        """Test main function with mixed/unknown arguments."""
        test_cases = [
            # Unknown first argument should insert 'scan'
            ['reposcope', '-g'],
            ['reposcope', '--output', 'custom.txt'],
            ['reposcope', '-x', '*.log'],
        ]

        for argv in test_cases:
            with patch('sys.argv', argv):
                with patch('argparse.ArgumentParser.parse_args') as mock_parse:
                    with patch('reposcope.cli.handle_scan') as mock_handle_scan:
                        # Simulate parsing arguments
                        mock_parse.return_value = Mock(command='scan', verbose=False)
                        
                        # Call main
                        main()
                        
                        # Verify that scan was called with inserted argument
                        mock_parse.assert_called_once()
                        mock_handle_scan.assert_called_once()

    def test_handle_scan_error_handling(self, mock_profile_manager, temp_repo):
        """Test error handling in handle_scan function."""
        # Prepare arguments
        args = Mock(
            dir=str(temp_repo),
            output='context.txt',
            profile=None,
            use_gitignore=False,
            include=None,
            include_file=None,
            ignore=None,
            ignore_file=None
        )

        # Simulate an error in file generation
        with patch('reposcope.core.RepoScope.generate_context_file') as mock_generate:
            mock_generate.side_effect = Exception("Test error")
            
            with pytest.raises(SystemExit):
                handle_scan(args, mock_profile_manager)

    def test_handle_profile_error_handling(self, mock_profile_manager):
        """Test error handling in handle_profile function."""
        # Simulate various profile errors
        error_test_cases = [
            # Create profile that already exists
            {
                'action': 'create', 
                'name': 'duplicate_profile', 
                'mode': 'include',
                'error': ProfileError("Profile already exists")
            },
            # Delete non-existent profile
            {
                'action': 'delete', 
                'name': 'non_existent', 
                'error': ProfileError("Profile not found")
            },
            # Add patterns to non-existent profile
            {
                'action': 'add', 
                'name': 'non_existent', 
                'patterns': ['*.py'], 
                'error': ProfileError("Profile not found")
            }
        ]

        for case in error_test_cases:
            # Prepare mock arguments
            args = Mock(
                action=case['action'],
                name=case.get('name'),
                patterns=case.get('patterns', []),
                mode=case.get('mode')
            )

            # Configure mock to raise the specific error
            mock_profile_manager.create.side_effect = case['error']
            mock_profile_manager.delete.side_effect = case['error']
            mock_profile_manager.add_patterns.side_effect = case['error']

            # Test that the error is logged and system exits
            with pytest.raises(SystemExit):
                handle_profile(args, mock_profile_manager)

    @pytest.mark.parametrize("mode", ["include", "exclude"])
    def test_handle_profile_commands(self, mock_profile_manager, mode):
        """
        Test 'create', 'delete', 'add', 'remove' subcommands in handle_profile
        by letting the real parser set up the arguments.
        """
        from reposcope.cli import setup_parser

        test_cases = [
            {
                "action": "create",
                "setup_method": "create",
                "cli_args": ["profile", "create", "test_profile", "--mode", mode],
                "expect_call": ("test_profile", mode),
                "output_check": f"Created profile: test_profile ({mode}, 0 patterns)"
            },
            {
                "action": "delete",
                "setup_method": "delete",
                "cli_args": ["profile", "delete", "test_profile"],
                "expect_call": ("test_profile",),
                "output_check": "Deleted profile: test_profile"
            },
            {
                "action": "add",
                "setup_method": "add_patterns",
                "cli_args": ["profile", "add", "test_profile", "*.py", "src/"],
                "expect_call": ("test_profile", ["*.py", "src/"]),
                "output_check": "Added to test_profile:"
            },
            {
                "action": "remove",
                "setup_method": "remove_patterns",
                "cli_args": ["profile", "remove", "test_profile", "*.log"],
                "expect_call": ("test_profile", ["*.log"]),
                "output_check": "Removed from test_profile:"
            }
        ]

        for case in test_cases:
            mock_profile_manager.reset_mock()

            # Parse real CLI args so we get the same attribute names/values
            parser = setup_parser()
            parsed_args = parser.parse_args(case["cli_args"])
            setup_method = getattr(mock_profile_manager, case["setup_method"])

            # Mock return values for create/add/remove if needed
            if case["action"] == "create":
                mock_profile = Mock()
                mock_profile.name = "test_profile"
                mock_profile.mode = mode
                mock_profile.summary.return_value = f"test_profile ({mode}, 0 patterns)"
                setup_method.return_value = mock_profile
            elif case["action"] in ["add", "remove"]:
                setup_method.return_value = case["expect_call"][1]  # e.g. ["*.py", "src/"]

            # Invoke handle_profile with actual parsed_args
            with patch("builtins.print") as mock_print:
                handle_profile(parsed_args, mock_profile_manager)

            # Verify the mock method call
            setup_method.assert_called_once_with(*case["expect_call"])

            # Check console output
            assert any(
                case["output_check"] in call[0][0]
                for call in mock_print.call_args_list
            )


@pytest.mark.parametrize("mode,patterns", [
    ('include', ['*.py', 'src/']),
    ('exclude', ['*.log', 'build/'])
])
def test_profile_edge_cases(mode, patterns):
    """Test edge cases for profile creation and manipulation."""
    manager = ProfileManager()

    # Create profile
    profile = manager.create(f'test_{mode}_profile', mode)
    assert profile.name == f'test_{mode}_profile'
    assert profile.mode == mode
    assert profile.patterns == []

    # Add patterns
    added = manager.add_patterns(profile.name, patterns)
    assert added == patterns
    assert profile.patterns == patterns

    # Attempt to add duplicate patterns
    duplicate_added = manager.add_patterns(profile.name, patterns)
    assert duplicate_added == []

    # Remove patterns
    removed = manager.remove_patterns(profile.name, [patterns[0]])
    assert removed == [patterns[0]]
    assert patterns[1] in profile.patterns

    # Attempt to remove non-existent pattern
    non_exist_removed = manager.remove_patterns(profile.name, ['non_existent'])
    assert non_exist_removed == []

    # Export patterns
    exported = manager.export_patterns(profile.name)
    assert f"{profile.name} ({mode} mode)" in exported

    # Cleanup
    manager.delete(profile.name)
    with pytest.raises(ProfileError):
        manager.get(profile.name)

--- tests/test_profiles_integration.py ---
import pytest
import tempfile
from pathlib import Path
from reposcope.core import RepoScope
from reposcope.profiles import ProfileManager

@pytest.fixture
def temp_repo():
    """Create a temporary repository with test files."""
    with tempfile.TemporaryDirectory() as temp_dir:
        repo_dir = Path(temp_dir)

        # Create directory structure
        (repo_dir / "src").mkdir()
        (repo_dir / "docs").mkdir()
        (repo_dir / "build").mkdir()

        # Create test files
        (repo_dir / "src" / "main.py").write_text("print('main')")
        (repo_dir / "src" / "utils.py").write_text("print('utils')")
        (repo_dir / "docs" / "README.md").write_text("# Documentation")
        (repo_dir / "build" / "output.log").write_text("build output")

        yield repo_dir

@pytest.fixture
def profile_manager(monkeypatch):
    """Fixture for a clean ProfileManager with isolated config directory."""
    temp_dir = tempfile.TemporaryDirectory()

    def mock_ensure_config_dir(self):
        self.config_dir = Path(temp_dir.name)
        self.profiles_file = self.config_dir / "profiles.json"
        self.config_dir.mkdir(parents=True, exist_ok=True)
        if not self.profiles_file.exists():
            self.profiles_file.write_text("{}")

    # Patch the `_ensure_config_dir` method
    monkeypatch.setattr(ProfileManager, "_ensure_config_dir", mock_ensure_config_dir)

    # Instantiate the ProfileManager
    manager = ProfileManager()
    yield manager

    # Cleanup temporary directory
    temp_dir.cleanup()



def test_profile_integration_include(temp_repo, profile_manager):
    """Test using an include profile with RepoScope."""
    profile = profile_manager.create("include_profile", "include")
    profile_manager.add_patterns("include_profile", ["src/*.py", "docs/*.md"])

    scope = RepoScope(temp_repo)
    scope.use_include_patterns(profile.patterns)

    collected_files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}

    assert "src/main.py" in collected_files
    assert "src/utils.py" in collected_files
    assert "docs/README.md" in collected_files

    # Files not in include patterns should be excluded
    assert "build/output.log" not in collected_files

def test_profile_integration_exclude(temp_repo, profile_manager):
    """Test using an exclude profile with RepoScope."""
    profile = profile_manager.create("exclude_profile", "exclude")
    profile_manager.add_patterns("exclude_profile", ["build/*", "*.log"])

    scope = RepoScope(temp_repo)
    scope.use_ignore_patterns(profile.patterns)

    collected_files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}

    # Excluded files should not be present
    assert "build/output.log" not in collected_files

    # Included files
    assert "src/main.py" in collected_files
    assert "src/utils.py" in collected_files
    assert "docs/README.md" in collected_files

def test_profile_integration_gitignore_and_profile(temp_repo, profile_manager):
    """Test combining .gitignore and an exclude profile."""
    gitignore_file = temp_repo / ".gitignore"
    gitignore_file.write_text("*.log\n")

    profile = profile_manager.create("exclude_profile", "exclude")
    profile_manager.add_patterns("exclude_profile", ["docs/*"])

    scope = RepoScope(temp_repo)
    scope.use_gitignore()
    scope.use_ignore_patterns(profile.patterns)

    collected_files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}

    # Excluded by gitignore and profile
    assert "build/output.log" not in collected_files
    assert "docs/README.md" not in collected_files

    # Included files
    assert "src/main.py" in collected_files
    assert "src/utils.py" in collected_files


--- tests/test_profiles_unit.py ---
import pytest
import tempfile
import os
from pathlib import Path
from reposcope.profiles import ProfileManager, ProfileError

def create_temp_file(content):
    """Helper function to create a temporary file with given content."""
    temp_file = tempfile.NamedTemporaryFile(delete=False, mode="w")
    temp_file.write(content)
    temp_file.close()
    return temp_file.name

@pytest.fixture
def profile_manager(monkeypatch):
    """Fixture for a clean ProfileManager with isolated config directory."""
    temp_dir = tempfile.TemporaryDirectory()

    def mock_ensure_config_dir(self):
        self.config_dir = Path(temp_dir.name)
        self.profiles_file = self.config_dir / "profiles.json"
        self.config_dir.mkdir(parents=True, exist_ok=True)
        if not self.profiles_file.exists():
            self.profiles_file.write_text("{}")

    # Patch the `_ensure_config_dir` method
    monkeypatch.setattr(ProfileManager, "_ensure_config_dir", mock_ensure_config_dir)

    # Instantiate the ProfileManager
    manager = ProfileManager()
    yield manager

    # Cleanup temporary directory
    temp_dir.cleanup()



def test_create_profile(profile_manager):
    """Test creating a new profile."""
    profile = profile_manager.create("test_profile", "include")
    assert profile.name == "test_profile"
    assert profile.mode == "include"
    assert profile.patterns == []

    # Ensure profile is saved and retrievable
    retrieved = profile_manager.get("test_profile")
    assert retrieved.name == "test_profile"
    assert retrieved.mode == "include"


def test_add_remove_patterns(profile_manager):
    """Test adding and removing patterns from a profile."""
    profile = profile_manager.create("test_profile", "exclude")

    # Add patterns
    added = profile_manager.add_patterns("test_profile", ["*.py", "docs/*"])
    assert added == ["*.py", "docs/*"]
    assert profile.patterns == ["*.py", "docs/*"]

    # Remove patterns
    removed = profile_manager.remove_patterns("test_profile", ["docs/*"])
    assert removed == ["docs/*"]
    assert profile.patterns == ["*.py"]


def test_import_patterns(profile_manager):
    """Test importing patterns from a file."""
    profile = profile_manager.create("test_profile", "include")

    temp_file = create_temp_file("*.md\nlogs/*\n")
    added = profile_manager.import_patterns("test_profile", temp_file, gitignore=False)

    assert added == ["*.md", "logs/*"]
    assert profile.patterns == ["*.md", "logs/*"]

    os.unlink(temp_file)


def test_export_patterns(profile_manager):
    """Test exporting patterns from a profile."""
    profile = profile_manager.create("test_profile", "exclude")
    profile_manager.add_patterns("test_profile", ["*.log", "cache/*"])

    exported = profile_manager.export_patterns("test_profile", gitignore=True)
    assert "# Profile: test_profile (exclude mode)" in exported
    assert "*.log" in exported
    assert "cache/*" in exported


def test_delete_profile(profile_manager):
    """Test deleting a profile."""
    profile_manager.create("test_profile", "include")

    profile_manager.delete("test_profile")

    with pytest.raises(ProfileError, match="Profile 'test_profile' not found"):
        profile_manager.get("test_profile")


def test_list_profiles(profile_manager):
    """Test listing all profiles."""
    profile_manager.create("profile1", "include")
    profile_manager.create("profile2", "exclude")

    profiles = profile_manager.get_profiles()
    assert len(profiles) == 2
    assert profiles[0].name == "profile1"
    assert profiles[1].name == "profile2"


--- tests/test_reposcope.py ---
import pytest
import os
import logging
from pathlib import Path
import tempfile
import shutil
from reposcope.core import RepoScope
from reposcope.cli import main
import sys
from unittest.mock import patch

@pytest.fixture
def temp_repo():
    """Create a temporary repository with test files."""
    with tempfile.TemporaryDirectory() as temp_dir:
        repo_dir = Path(temp_dir)
        
        # Create directory structure
        (repo_dir / "src").mkdir()
        (repo_dir / "docs").mkdir()
        (repo_dir / "tests").mkdir()
        (repo_dir / "build").mkdir()
        (repo_dir / ".git").mkdir()
        (repo_dir / "__pycache__").mkdir()
        
        # Create test files
        (repo_dir / "src" / "main.py").write_text("print('main')")
        (repo_dir / "src" / "utils.py").write_text("print('utils')")
        (repo_dir / "docs" / "README.md").write_text("# Documentation")
        (repo_dir / "tests" / "test_main.py").write_text("def test_main(): pass")
        (repo_dir / "build" / "output.txt").write_text("build output")
        (repo_dir / "__pycache__" / "main.cpython-39.pyc").write_text("cached")
        (repo_dir / ".gitignore").write_text("\n".join([
            "build/",
            "__pycache__/",
            "*.pyc",
        ]))
        
        yield repo_dir

@pytest.fixture
def temp_ignore_file():
    """Create a temporary ignore file."""
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
        f.write("\n".join([
            "tests/",
            "*.md",
        ]))
        return Path(f.name)

def test_gitignore_basic(temp_repo):
    """Test basic .gitignore functionality."""
    scope = RepoScope(temp_repo)
    scope.use_gitignore()
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should include
    assert "src/main.py" in files
    assert "src/utils.py" in files
    assert "docs/README.md" in files
    
    # Should exclude
    assert "build/output.txt" not in files
    assert "__pycache__/main.cpython-39.pyc" not in files

def test_extra_ignore_file(temp_repo, temp_ignore_file):
    """Test using an additional ignore file."""
    scope = RepoScope(temp_repo)
    scope.use_ignore_file(str(temp_ignore_file))
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should include
    assert "src/main.py" in files
    assert "src/utils.py" in files
    
    # Should exclude based on ignore file
    assert "tests/test_main.py" not in files
    assert "docs/README.md" not in files

def test_command_line_ignore(temp_repo):
    """Test ignore patterns from command line."""
    scope = RepoScope(temp_repo)
    scope.use_ignore_patterns(["*.py", "docs/"])
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should exclude
    assert "src/main.py" not in files
    assert "docs/README.md" not in files
    
    # Should include
    assert "build/output.txt" in files

def test_include_patterns(temp_repo):
    """Test include patterns."""
    scope = RepoScope(temp_repo)
    scope.use_include_patterns(["src/*.py"])
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should include only Python files in src
    assert "src/main.py" in files
    assert "src/utils.py" in files
    
    # Should exclude everything else
    assert "docs/README.md" not in files
    assert "tests/test_main.py" not in files
    assert "build/output.txt" not in files

def test_include_file(temp_repo):
    """Test include patterns from file."""
    include_file = temp_repo / "include.txt"
    include_file.write_text("src/*.py\ndocs/*.md")
    
    scope = RepoScope(temp_repo)
    scope.use_include_file(str(include_file))
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should include only specified patterns
    assert "src/main.py" in files
    assert "docs/README.md" in files
    
    # Should exclude everything else
    assert "tests/test_main.py" not in files
    assert "build/output.txt" not in files

def test_combining_gitignore_and_extra_ignore(temp_repo, temp_ignore_file):
    """Test combining .gitignore and extra ignore file."""
    scope = RepoScope(temp_repo)
    scope.use_gitignore()
    scope.use_ignore_file(str(temp_ignore_file))
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should exclude from both .gitignore and extra ignore
    assert "build/output.txt" not in files  # from .gitignore
    assert "docs/README.md" not in files    # from extra ignore
    assert "tests/test_main.py" not in files  # from extra ignore

def test_include_overrides_ignore(temp_repo):
    """Test that include mode overrides any ignore patterns."""
    scope = RepoScope(temp_repo)
    scope.use_gitignore()  # This should be ignored once we switch to include mode
    scope.use_include_patterns(["build/*"])  # This should take precedence
    files = {str(f.relative_to(temp_repo)) for f in scope.collect_files()}
    
    # Should include only build files, despite being in .gitignore
    assert "build/output.txt" in files
    assert len(files) == 1

def test_nonexistent_files(temp_repo):
    """Test handling of nonexistent ignore/include files."""
    scope = RepoScope(temp_repo)
    
    # Test ignore mode with nonexistent file
    scope.use_ignore_file("nonexistent.txt")
    files_ignore = scope.collect_files()
    # In ignore mode with no patterns, should include all files
    assert len(files_ignore) > 0
    
    # Create new scope for include mode test
    scope = RepoScope(temp_repo)
    scope.use_include_file("also_nonexistent.txt")
    files_include = scope.collect_files()
    # In include mode with no patterns, should include no files
    assert len(files_include) == 0

def test_empty_patterns(temp_repo):
    """Test handling of empty pattern lists."""
    scope = RepoScope(temp_repo)
    
    # Empty ignore patterns should include everything
    scope.use_ignore_patterns([])
    files1 = set(scope.collect_files())
    assert len(files1) > 0
    
    # Empty include patterns should include nothing
    scope.use_include_patterns([])
    files2 = set(scope.collect_files())
    assert len(files2) == 0

def test_cli_short_arguments(temp_repo, capsys):
    """Test CLI with short argument versions."""
    # Save current working directory
    original_cwd = os.getcwd()
    try:
        # Change to temp_repo directory for tests
        os.chdir(temp_repo)

        # Test -g (--use-gitignore)
        with patch.object(sys, 'argv', ['reposcope', '-g']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test -i (--include)
        with patch.object(sys, 'argv', ['reposcope', '-i', '*.py']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test -o (--output)
        output_file = "test_output.txt"
        with patch.object(sys, 'argv', ['reposcope', '-g', '-o', output_file]):
            main()
            captured = capsys.readouterr()
            assert f"Generated context file: {output_file}" in captured.out
            assert os.path.exists(output_file)

        # Test -v (--verbose)
        with patch.object(sys, 'argv', ['reposcope', '-g', '-v']):
            main()
            captured = capsys.readouterr()
            assert "DEBUG" in captured.err  # Check for debug output in stderr

        # Test -d (--dir)
        with patch.object(sys, 'argv', ['reposcope', '-d', str(temp_repo), '-g']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test -X (--exclude-file/--ignore-file)
        ignore_file = temp_repo / "custom_ignore.txt"
        ignore_file.write_text("*.pyc")
        with patch.object(sys, 'argv', ['reposcope', '-X', str(ignore_file)]):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test -x (--exclude/--ignore)
        with patch.object(sys, 'argv', ['reposcope', '-x', '*.pyc', '*.pyo']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test -I (--include-file)
        include_file = temp_repo / "include.txt"
        include_file.write_text("*.py")
        with patch.object(sys, 'argv', ['reposcope', '-I', str(include_file)]):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

    finally:
        # Restore original working directory
        os.chdir(original_cwd)

def test_cli_aliases(temp_repo, capsys):
    """Test command line argument aliases."""
    original_cwd = os.getcwd()
    try:
        os.chdir(temp_repo)

        # Test --exclude alias for --ignore
        with patch.object(sys, 'argv', ['reposcope', '--exclude', '*.pyc']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Test --exclude-file alias for --ignore-file
        ignore_file = temp_repo / "custom_ignore.txt"
        ignore_file.write_text("*.pyc")
        with patch.object(sys, 'argv', ['reposcope', '--exclude-file', str(ignore_file)]):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

    finally:
        os.chdir(original_cwd)

def test_cli_mixed_arguments(temp_repo, capsys):
    """Test mixing different argument versions."""
    original_cwd = os.getcwd()
    try:
        os.chdir(temp_repo)

        # Mix short and long arguments
        with patch.object(sys, 'argv', ['reposcope', '-g', '--output', 'out.txt']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: out.txt" in captured.out

        # Mix exclude and ignore
        with patch.object(sys, 'argv', ['reposcope', '--exclude', '*.pyc', '--ignore', '*.pyo']):
            main()
            captured = capsys.readouterr()
            assert "Generated context file: context.txt" in captured.out

        # Mix verbose with different argument styles
        with patch.object(sys, 'argv', ['reposcope', '--use-gitignore', '-v']):
            main()
            captured = capsys.readouterr()
            assert "DEBUG" in captured.err

    finally:
        os.chdir(original_cwd)
