├── tests ├── __init__.py ├── test_config.py ├── test_cli.py └── test_builder.py ├── demo.gif ├── pytest.ini ├── cli_tool_gptree ├── __init__.py ├── interactive.py ├── cli.py ├── builder.py └── config.py ├── .github └── workflows │ ├── issue-management │ └── build.yml ├── setup.py ├── run_tests.py ├── .gitignore ├── README.md └── License.txt /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Tests for GPTree CLI Tool -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/travisvn/gptree/HEAD/demo.gif -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --tb=short 7 | filterwarnings = 8 | ignore::DeprecationWarning 9 | ignore::PendingDeprecationWarning -------------------------------------------------------------------------------- /cli_tool_gptree/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | GPTree CLI Tool - A tool to provide LLM context for coding projects 3 | by combining project files into a single text file with directory tree structure. 4 | """ 5 | 6 | from .cli import main, CURRENT_VERSION 7 | from .config import DEFAULT_CONFIG, CONFIG_VERSION 8 | from .builder import SAFE_MODE_MAX_FILES, SAFE_MODE_MAX_LENGTH 9 | 10 | __version__ = CURRENT_VERSION 11 | __all__ = ['main', 'CURRENT_VERSION', 'DEFAULT_CONFIG', 'CONFIG_VERSION', 12 | 'SAFE_MODE_MAX_FILES', 'SAFE_MODE_MAX_LENGTH'] 13 | -------------------------------------------------------------------------------- /.github/workflows/issue-management: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 14 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." 19 | close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." 20 | days-before-pr-stale: -1 21 | days-before-pr-close: -1 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="gptree-cli", 5 | version="1.6.0", 6 | author="Travis Van Nimwegen", 7 | author_email="cli@travisvn.com", 8 | description="A CLI tool to provide LLM context for coding projects by combining project files into a single text file (or clipboard text) with directory tree structure", 9 | license="GPLv3", 10 | url="https://github.com/travisvn/gptree", 11 | long_description=open("README.md").read(), 12 | long_description_content_type="text/markdown", 13 | packages=find_packages(), # Automatically find the package 14 | install_requires=[ 15 | "pathspec", 16 | "pyperclip" 17 | ], 18 | extras_require={ 19 | "dev": [ 20 | "pytest>=6.0", 21 | "pytest-cov", 22 | ] 23 | }, 24 | entry_points={ 25 | "console_scripts": [ 26 | "gptree=cli_tool_gptree.cli:main" 27 | ] 28 | }, 29 | classifiers=[ 30 | "Programming Language :: Python :: 3", 31 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 32 | "Operating System :: OS Independent", 33 | ], 34 | python_requires='>=3.7', 35 | ) 36 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple test runner for GPTree CLI tool. 4 | This script runs all tests and provides a summary. 5 | """ 6 | 7 | import subprocess 8 | import sys 9 | import os 10 | 11 | def run_tests(): 12 | """Run all tests using pytest.""" 13 | print("Running GPTree CLI Tool Tests...") 14 | print("=" * 50) 15 | 16 | # Change to the project directory 17 | os.chdir(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | # Check if pytest-cov is available 20 | try: 21 | import pytest_cov 22 | coverage_available = True 23 | print("📊 Coverage reporting enabled") 24 | except ImportError: 25 | coverage_available = False 26 | print("ℹ️ Coverage reporting disabled (install pytest-cov for coverage)") 27 | 28 | # Build pytest command 29 | cmd = [sys.executable, "-m", "pytest", "tests/", "-v", "--tb=short"] 30 | 31 | if coverage_available: 32 | cmd.extend([ 33 | "--cov=cli_tool_gptree", 34 | "--cov-report=term-missing", 35 | "--cov-report=html:htmlcov" 36 | ]) 37 | 38 | try: 39 | result = subprocess.run(cmd, check=False) 40 | 41 | if result.returncode == 0: 42 | print("\n" + "=" * 50) 43 | print("✅ All tests passed!") 44 | if coverage_available: 45 | print("📊 Coverage report generated in htmlcov/") 46 | else: 47 | print("\n" + "=" * 50) 48 | print("❌ Some tests failed!") 49 | return False 50 | 51 | except FileNotFoundError: 52 | print("❌ pytest not found. Install it with:") 53 | print(" pip install pytest") 54 | print(" pip install pytest-cov # Optional, for coverage reporting") 55 | return False 56 | 57 | return result.returncode == 0 58 | 59 | def run_tests_simple(): 60 | """Run all tests using pytest (no coverage).""" 61 | print("Running GPTree CLI Tool Tests (Simple Mode)...") 62 | print("=" * 50) 63 | 64 | # Change to the project directory 65 | os.chdir(os.path.dirname(os.path.abspath(__file__))) 66 | 67 | # Simple pytest command 68 | cmd = [sys.executable, "-m", "pytest", "tests/", "-v"] 69 | 70 | try: 71 | result = subprocess.run(cmd, check=False) 72 | 73 | if result.returncode == 0: 74 | print("\n" + "=" * 50) 75 | print("✅ All tests passed!") 76 | else: 77 | print("\n" + "=" * 50) 78 | print("❌ Some tests failed!") 79 | return False 80 | 81 | except FileNotFoundError: 82 | print("❌ pytest not found. Install it with:") 83 | print(" pip install pytest") 84 | return False 85 | 86 | return result.returncode == 0 87 | 88 | if __name__ == "__main__": 89 | success = run_tests() 90 | sys.exit(0 if success else 1) -------------------------------------------------------------------------------- /cli_tool_gptree/interactive.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | 4 | def interactive_file_selector(file_list): 5 | """Interactive file selector with a scrollable list using curses.""" 6 | selected_files = set() 7 | display_limit = 15 # Number of files shown at a time 8 | 9 | def draw_menu(stdscr): 10 | curses.start_color() 11 | curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE) # Highlight color 12 | curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK) # Default color 13 | 14 | current_row = 0 15 | offset = 0 # For scrolling 16 | max_rows = len(file_list) 17 | 18 | while True: 19 | stdscr.clear() 20 | stdscr.addstr(0, 0, "Use ↑/↓/j/k to scroll, SPACE to toggle selection, 'a' to select all, ENTER to confirm, ESC to quit") 21 | stdscr.addstr(1, 0, "-" * 80) 22 | 23 | for idx in range(display_limit): 24 | file_idx = offset + idx 25 | if file_idx >= max_rows: 26 | break 27 | file = file_list[file_idx] 28 | prefix = "[X]" if file in selected_files else "[ ]" 29 | 30 | if file_idx == current_row: 31 | stdscr.attron(curses.color_pair(1)) 32 | stdscr.addstr(idx + 2, 0, f"{prefix} {file[:70]}") 33 | stdscr.attroff(curses.color_pair(1)) 34 | else: 35 | stdscr.attron(curses.color_pair(2)) 36 | stdscr.addstr(idx + 2, 0, f"{prefix} {file[:70]}") 37 | stdscr.attroff(curses.color_pair(2)) 38 | 39 | # Add ellipsis indicators if there are more files above or below 40 | if offset > 0: 41 | stdscr.addstr(2, 78, "↑") 42 | if offset + display_limit < max_rows: 43 | stdscr.addstr(display_limit + 1, 78, "↓") 44 | 45 | stdscr.addstr(display_limit + 3, 0, "-" * 80) 46 | stdscr.addstr(display_limit + 4, 0, f"Selected: {len(selected_files)} / {len(file_list)}") 47 | 48 | key = stdscr.getch() 49 | if key in (curses.KEY_DOWN, ord('j')): 50 | if current_row < max_rows - 1: 51 | current_row += 1 52 | if current_row >= offset + display_limit: 53 | offset += 1 54 | elif key in (curses.KEY_UP, ord('k')): 55 | if current_row > 0: 56 | current_row -= 1 57 | if current_row < offset: 58 | offset -= 1 59 | elif key == ord(' '): 60 | file = file_list[current_row] 61 | if file in selected_files: 62 | selected_files.remove(file) 63 | else: 64 | selected_files.add(file) 65 | elif key == ord('a'): 66 | if len(selected_files) < len(file_list): 67 | selected_files.update(file_list) 68 | else: 69 | selected_files.clear() 70 | elif key == 27: # ESC key 71 | raise SystemExit("Interactive mode canceled by user.") 72 | elif key == 10: # ENTER key 73 | break 74 | 75 | curses.wrapper(draw_menu) 76 | return selected_files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | __pycache__/ 171 | .env 172 | venv 173 | .venv 174 | 175 | combined_code.txt 176 | .combine_config 177 | 178 | .DS_Store 179 | .gptree_config 180 | gptree_output.txt 181 | 182 | .test_storage/ 183 | 184 | CLAUDE.md 185 | .gptreerc 186 | .internal -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release gptree CLI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Trigger on version tags like v1.0.0 7 | workflow_dispatch: # Allow manual triggering 8 | 9 | jobs: 10 | build: 11 | name: Build on ${{ matrix.os }} 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | steps: 18 | # Checkout code 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | # Set up Python 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.9' 27 | 28 | # Install dependencies 29 | - name: Install dependencies 30 | shell: bash 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pyinstaller pathspec pyperclip 34 | 35 | if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then 36 | sudo apt-get update && sudo apt-get install -y upx 37 | elif [ "${{ matrix.os }}" = "macos-latest" ]; then 38 | brew install upx 39 | fi 40 | 41 | # Build binary with PyInstaller and optimize 42 | - name: Build executable with PyInstaller 43 | shell: bash 44 | run: | 45 | if [ "${{ matrix.os }}" = "windows-latest" ]; then 46 | pyinstaller --onefile --console --name gptree.exe cli_tool_gptree/cli.py 47 | else 48 | # Dynamically find UPX path 49 | UPX_PATH=$(which upx || echo "") 50 | echo "Using UPX path: $UPX_PATH" 51 | 52 | if [ -n "$UPX_PATH" ]; then 53 | pyinstaller --onefile --console --upx-dir=$(dirname "$UPX_PATH") --name gptree cli_tool_gptree/cli.py 54 | else 55 | echo "UPX not found; skipping compression." 56 | pyinstaller --onefile --console --name gptree cli_tool_gptree/cli.py 57 | fi 58 | fi 59 | 60 | # Rename binaries for platform-specific names 61 | - name: Rename binaries for upload 62 | shell: bash 63 | run: | 64 | if [ "${{ matrix.os }}" = "macos-latest" ]; then 65 | mv dist/gptree dist/gptree-macos 66 | elif [ "${{ matrix.os }}" = "ubuntu-latest" ]; then 67 | mv dist/gptree dist/gptree-ubuntu 68 | elif [ "${{ matrix.os }}" = "windows-latest" ]; then 69 | mv dist/gptree.exe dist/gptree-windows.exe 70 | fi 71 | 72 | # Upload binary as artifact 73 | - name: Upload binary as artifact 74 | uses: actions/upload-artifact@v4 75 | with: 76 | name: gptree-${{ matrix.os }} 77 | path: | 78 | dist/gptree-macos 79 | dist/gptree-ubuntu 80 | dist/gptree-windows.exe 81 | 82 | release: 83 | name: Create Release 84 | needs: build # Waits for the build job to complete 85 | runs-on: ubuntu-latest 86 | permissions: 87 | contents: write # Grants permission to create a release and upload files 88 | steps: 89 | # Checkout repository 90 | - name: Checkout repository 91 | uses: actions/checkout@v4 92 | 93 | # Download all artifacts from the build job 94 | - name: Download all binaries 95 | uses: actions/download-artifact@v4 96 | with: 97 | path: artifacts 98 | 99 | # List downloaded files for debugging 100 | - name: List downloaded binaries 101 | run: ls -R artifacts 102 | 103 | # Create GitHub Release and upload binaries 104 | - name: Create GitHub Release 105 | uses: softprops/action-gh-release@v1 106 | with: 107 | files: artifacts/**/* # Match all files recursively in artifacts/ 108 | body: "Release of gptree CLI tool.\n\nDownload the appropriate binary for your operating system." 109 | tag_name: ${{ github.ref_name }} 110 | name: 'GPTree Release ${{ github.ref_name }}' 111 | env: 112 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | 114 | # Update Homebrew Tap Formula 115 | - name: Update Homebrew Tap Formula 116 | run: | 117 | # Clone the Homebrew tap repository 118 | git clone https://github.com/travisvn/homebrew-tap.git 119 | cd homebrew-tap 120 | 121 | # Define variables for version and compute SHA256 checksums 122 | VERSION=${{ github.ref_name }} 123 | MACOS_URL="https://github.com/travisvn/gptree/releases/download/${VERSION}/gptree-macos" 124 | LINUX_URL="https://github.com/travisvn/gptree/releases/download/${VERSION}/gptree-ubuntu" 125 | 126 | # Calculate SHA256 checksums 127 | MACOS_SHA=$(curl -L $MACOS_URL | shasum -a 256 | cut -d' ' -f1) 128 | LINUX_SHA=$(curl -L $LINUX_URL | shasum -a 256 | cut -d' ' -f1) 129 | 130 | echo "MacOS SHA256: $MACOS_SHA" 131 | echo "Linux SHA256: $LINUX_SHA" 132 | 133 | # Update the gptree.rb formula 134 | cat < Formula/gptree.rb 135 | class Gptree < Formula 136 | include Language::Python::Virtualenv 137 | 138 | desc "Project tree structure and file content aggregator for providing LLM context" 139 | homepage "https://github.com/travisvn/gptree" 140 | version "${VERSION}" 141 | license "GPLv3" 142 | 143 | depends_on "python" => :optional 144 | 145 | on_macos do 146 | url "${MACOS_URL}" 147 | sha256 "${MACOS_SHA}" 148 | end 149 | 150 | on_linux do 151 | url "${LINUX_URL}" 152 | sha256 "${LINUX_SHA}" 153 | end 154 | 155 | resource "pyperclip" do 156 | url "https://files.pythonhosted.org/packages/source/p/pyperclip/pyperclip-1.9.0.tar.gz" 157 | sha256 "b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310" 158 | end 159 | 160 | resource "pathspec" do 161 | url "https://files.pythonhosted.org/packages/source/p/pathspec/pathspec-0.12.1.tar.gz" 162 | sha256 "a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 163 | end 164 | 165 | def install 166 | python_path = \`which python3\`.chomp 167 | pip_path = \`which pip3\`.chomp 168 | 169 | if !python_path.empty? && !pip_path.empty? && system("#{python_path}", "--version") && system("#{pip_path}", "--version") 170 | opoo "Python and pip detected. Installing with pip." 171 | ENV.prepend_path "PATH", File.dirname(python_path) # Ensure the detected Python is prioritized 172 | virtualenv_install_with_resources 173 | else 174 | opoo "Python or pip not detected. Falling back to binary installation." 175 | install_binary 176 | end 177 | end 178 | 179 | def install_binary 180 | if OS.mac? 181 | bin.install "gptree-macos" => "gptree" 182 | elsif OS.linux? 183 | bin.install "gptree-ubuntu" => "gptree" 184 | end 185 | end 186 | 187 | test do 188 | assert_match "usage", shell_output("\#{bin}/gptree --help") 189 | end 190 | end 191 | EOF 192 | 193 | # Commit and push the changes 194 | git config user.name "github-actions" 195 | git config user.email "github-actions@github.com" 196 | git add Formula/gptree.rb 197 | git commit -m "Update gptree formula to version ${VERSION}" 198 | git push https://x-access-token:${{ secrets.HOMEBREW_TAP_TOKEN }}@github.com/travisvn/homebrew-tap.git main 199 | env: 200 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 201 | -------------------------------------------------------------------------------- /cli_tool_gptree/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | import readline 4 | 5 | from .config import ( 6 | load_global_config, load_or_create_config, parse_config, 7 | save_files_to_config, normalize_file_types, normalize_patterns, 8 | file_types_to_patterns, PROJECT_CONFIG_FILE 9 | ) 10 | from .builder import combine_files_with_structure, save_to_file, copy_to_clipboard, estimate_tokens 11 | 12 | CURRENT_VERSION = 'v1.6.0' 13 | 14 | 15 | def setup_autocomplete(): 16 | """Enable tab-completion for input prompts.""" 17 | def complete_path(text, state): 18 | line = readline.get_line_buffer() 19 | path = os.path.expanduser(line) 20 | options = [x for x in os.listdir(path or '.') if x.startswith(text)] 21 | if state < len(options): 22 | return options[state] 23 | return None 24 | 25 | readline.set_completer(complete_path) 26 | readline.parse_and_bind("tab: complete") 27 | 28 | 29 | def prompt_user_input(prompt, default): 30 | """Prompt user for input with a default value.""" 31 | user_input = input(f"{prompt} [{default}]: ").strip() 32 | return user_input if user_input else default 33 | 34 | 35 | def main(): 36 | setup_autocomplete() 37 | parser = argparse.ArgumentParser(description="Provide LLM context for coding projects by combining project files into a single text file (or clipboard text) with directory tree structure. Check out the new GUI version at https://gptree.dev!") 38 | parser.add_argument("path", nargs="?", default=None, help="Root directory of the project") 39 | parser.add_argument("-i", "--interactive", action="store_true", help="Select files interactively") 40 | parser.add_argument("--ignore-gitignore", action="store_true", help="Ignore .gitignore patterns") 41 | parser.add_argument("--include-file-types", help="Comma-separated list of file types to include (e.g., '.py,.js' or 'py,js'). Use '*' for all types") 42 | parser.add_argument("--exclude-file-types", help="Comma-separated list of file types to exclude (e.g., '.log,.tmp' or 'log,tmp')") 43 | parser.add_argument("--include-patterns", help="Advanced: Comma-separated list of glob patterns to include (e.g., 'src/**/*.py,**/*.js'). Overrides include-file-types if specified") 44 | parser.add_argument("--exclude-patterns", help="Advanced: Comma-separated list of glob patterns to exclude (e.g., '**/tests/**,**/*.log'). Combined with exclude-file-types") 45 | parser.add_argument("--output-file", help="Name of the output file") 46 | parser.add_argument("--output-file-locally", action="store_true", help="Save the output file in the current working directory") 47 | parser.add_argument("--no-config", "-nc", action="store_true", help="Disable creation or use of a configuration file") 48 | parser.add_argument("-c", "--copy", action="store_true", help="Copy the output to the clipboard") 49 | parser.add_argument("-p", "--previous", action="store_true", help="Use the previous file selection") 50 | parser.add_argument("-s", "--save", action="store_true", help="Save selected files to config") 51 | parser.add_argument("-n", "--line-numbers", action="store_true", help="Add line numbers to the output") 52 | parser.add_argument("--version", action="store_true", help="Returns the version of GPTree") 53 | parser.add_argument("--disable-safe-mode", "-dsm", action="store_true", help="Disable safe mode") 54 | parser.add_argument("--show-ignored-in-tree", action="store_true", help="Show ignored files in the directory tree") 55 | parser.add_argument("--show-default-ignored-in-tree", action="store_true", help="Show default ignored files in the directory tree (still respects gitignore)") 56 | 57 | args = parser.parse_args() 58 | 59 | if args.version: 60 | print(f"{CURRENT_VERSION}") 61 | return 62 | 63 | # Use the provided path, or prompt if no path was given 64 | if args.path is None: 65 | path = prompt_user_input("Enter the root directory of the project", ".") 66 | else: 67 | path = args.path 68 | 69 | # Load global configuration first 70 | config = load_global_config() 71 | 72 | # Load directory-level configuration unless --no-config is specified 73 | if not args.no_config: 74 | config_path = load_or_create_config(path) 75 | directory_config = parse_config(config_path) 76 | config.update(directory_config) 77 | 78 | # Override with CLI arguments if provided 79 | # File types (primary interface) 80 | if args.include_file_types: 81 | config["includeFileTypes"] = normalize_file_types(args.include_file_types) 82 | if args.exclude_file_types: 83 | config["excludeFileTypes"] = normalize_file_types(args.exclude_file_types) 84 | 85 | # Patterns (advanced interface) - these override file types if specified 86 | if args.include_patterns: 87 | config["includePatterns"] = normalize_patterns(args.include_patterns) 88 | if args.exclude_patterns: 89 | config["excludePatterns"] = normalize_patterns(args.exclude_patterns) 90 | 91 | # Determine final patterns to use 92 | # Priority: patterns > file types 93 | if config.get("includePatterns") and len(config["includePatterns"]) > 0: 94 | # Use patterns directly 95 | final_include_patterns = config["includePatterns"] 96 | else: 97 | # Convert file types to patterns 98 | final_include_patterns = file_types_to_patterns(config["includeFileTypes"]) 99 | 100 | # For exclude patterns, combine both file types and patterns 101 | final_exclude_patterns = [] 102 | 103 | # Add patterns from excludeFileTypes (converted to patterns) 104 | if config["excludeFileTypes"] and config["excludeFileTypes"] != []: 105 | final_exclude_patterns.extend(file_types_to_patterns(config["excludeFileTypes"])) 106 | 107 | # Add explicit exclude patterns 108 | if config.get("excludePatterns") and len(config["excludePatterns"]) > 0: 109 | final_exclude_patterns.extend(config["excludePatterns"]) 110 | 111 | if args.output_file: 112 | config["outputFile"] = args.output_file 113 | if args.output_file_locally: 114 | config["outputFileLocally"] = True 115 | if args.save: 116 | config["storeFilesChosen"] = True 117 | if args.line_numbers: 118 | config["lineNumbers"] = True 119 | if args.disable_safe_mode: 120 | config["safeMode"] = False 121 | if args.show_ignored_in_tree: 122 | config["showIgnoredInTree"] = True 123 | if args.show_default_ignored_in_tree: 124 | config["showDefaultIgnoredInTree"] = True 125 | 126 | # Determine output file path 127 | output_file = config["outputFile"] 128 | if not config["outputFileLocally"]: 129 | output_file = os.path.join(path, output_file) 130 | 131 | # Determine whether to use .gitignore based on config and CLI arguments 132 | use_gitignore = not args.ignore_gitignore and config["useGitIgnore"] 133 | 134 | try: 135 | print(f"Combining files in {path} into {output_file}...") 136 | 137 | previous_files = None 138 | if args.previous and not args.no_config: 139 | previous_files = config.get("previousFiles", []) 140 | if not previous_files: 141 | print("No previous file selection found.") 142 | return 143 | 144 | combined_content, selected_files = combine_files_with_structure( 145 | path, 146 | use_gitignore, 147 | interactive=args.interactive, 148 | previous_files=previous_files, 149 | safe_mode=config["safeMode"], 150 | line_numbers=config["lineNumbers"], 151 | show_ignored_in_tree=config["showIgnoredInTree"], 152 | show_default_ignored_in_tree=config["showDefaultIgnoredInTree"], 153 | include_patterns=final_include_patterns, 154 | exclude_patterns=final_exclude_patterns 155 | ) 156 | 157 | # Add token estimation 158 | estimated_tokens = estimate_tokens(combined_content) 159 | print(f"Estimated tokens: {estimated_tokens:,}") 160 | 161 | except SystemExit as e: 162 | print(str(e)) 163 | return 164 | 165 | # Save to file 166 | save_to_file(output_file, combined_content) 167 | 168 | # Copy to clipboard if requested 169 | if args.copy or config.get("copyToClipboard", False): 170 | copy_to_clipboard(combined_content) 171 | 172 | # Save selected files if requested 173 | if config["storeFilesChosen"] and not args.no_config and not args.previous: 174 | config_path = os.path.join(path, PROJECT_CONFIG_FILE) 175 | save_files_to_config(config_path, selected_files, path) 176 | 177 | print(f"Done! Combined content saved to {output_file}.") 178 | 179 | 180 | if __name__ == "__main__": 181 | main() -------------------------------------------------------------------------------- /cli_tool_gptree/builder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathspec 3 | import pyperclip 4 | 5 | # Safe mode constants 6 | SAFE_MODE_MAX_FILES = 30 7 | SAFE_MODE_MAX_LENGTH = 100_000 # ~25K tokens, reasonable for most LLMs 8 | 9 | # Global list of obvious files and directories to ignore 10 | DEFAULT_IGNORES = {".git", ".vscode", "__pycache__", ".DS_Store", ".idea", ".gitignore"} 11 | 12 | 13 | def create_pattern_spec(patterns): 14 | """Create a pathspec object from a list of glob patterns.""" 15 | if not patterns or patterns == ["*"]: 16 | return None 17 | return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, patterns) 18 | 19 | 20 | def matches_patterns(file_path, root_dir, pattern_spec): 21 | """Check if a file matches the given pattern spec.""" 22 | if pattern_spec is None: 23 | return True # No patterns means match all 24 | 25 | # Get relative path from root directory 26 | relative_path = os.path.relpath(file_path, root_dir) 27 | # Normalize path separators for cross-platform compatibility 28 | relative_path = relative_path.replace(os.sep, '/') 29 | 30 | return pattern_spec.match_file(relative_path) 31 | 32 | 33 | def generate_tree_structure(root_dir, gitignore_spec, show_ignored=False, show_default_ignored=False, include_patterns=None, exclude_patterns=None): 34 | """Generate a tree-like directory structure, mimicking the 'tree' command output 35 | with correct characters, indentation, and alphabetical ordering.""" 36 | tree_lines = ['.'] # Start with the root directory indicator 37 | file_list = [] 38 | 39 | # Create pattern specs for include/exclude filtering 40 | include_spec = create_pattern_spec(include_patterns) 41 | exclude_spec = create_pattern_spec(exclude_patterns) 42 | 43 | def _generate_tree(dir_path, indent_prefix): 44 | items = sorted(os.listdir(dir_path)) 45 | 46 | # Filter items based on ignore settings and patterns 47 | if not show_ignored: 48 | if show_default_ignored: 49 | # Only filter out gitignore items, but show default ignored items 50 | items = [item for item in items if not (gitignore_spec and 51 | gitignore_spec.match_file(os.path.relpath(os.path.join(dir_path, item), root_dir)))] 52 | else: 53 | # Filter out all ignored items 54 | items = [item for item in items 55 | if not is_ignored(os.path.join(dir_path, item), gitignore_spec, root_dir)] 56 | 57 | # Apply include/exclude pattern filtering 58 | if include_spec or exclude_spec: 59 | filtered_items = [] 60 | for item in items: 61 | item_path = os.path.join(dir_path, item) 62 | 63 | # Check include patterns (if specified, file must match at least one) 64 | if include_spec is not None: 65 | if not matches_patterns(item_path, root_dir, include_spec): 66 | # For directories, check if any child might match 67 | if os.path.isdir(item_path): 68 | # Check if any pattern could potentially match files in this directory 69 | dir_relative = os.path.relpath(item_path, root_dir).replace(os.sep, '/') 70 | # If any include pattern starts with this directory path, include it 71 | should_include_dir = any( 72 | pattern.startswith(dir_relative + '/') or 73 | pattern.startswith('**/') or 74 | '**' in pattern 75 | for pattern in include_patterns or [] 76 | ) 77 | if not should_include_dir: 78 | continue 79 | else: 80 | continue 81 | 82 | # Check exclude patterns (if file matches any, exclude it) 83 | if exclude_spec is not None and matches_patterns(item_path, root_dir, exclude_spec): 84 | continue 85 | 86 | filtered_items.append(item) 87 | items = filtered_items 88 | 89 | num_items = len(items) 90 | 91 | for index, item in enumerate(items): 92 | is_last_item = (index == num_items - 1) 93 | item_path = os.path.join(dir_path, item) 94 | is_directory = os.path.isdir(item_path) 95 | 96 | # Connector: └── for last item, ├── otherwise 97 | connector = '└── ' if is_last_item else '├── ' 98 | line_prefix = indent_prefix + connector 99 | item_display_name = item + "/" if is_directory else item # Append "/" for directories 100 | 101 | tree_lines.append(line_prefix + item_display_name) # No prepended "|" 102 | 103 | if is_directory: 104 | # Indentation for subdirectories: ' ' if last item, '│ ' otherwise 105 | new_indent_prefix = indent_prefix + (' ' if is_last_item else '│ ') 106 | _generate_tree(item_path, new_indent_prefix) 107 | elif os.path.isfile(item_path): 108 | file_list.append(item_path) 109 | 110 | _generate_tree(root_dir, '') 111 | 112 | return "\n".join(tree_lines), file_list 113 | 114 | 115 | def load_gitignore(start_dir): 116 | """Load patterns from .gitignore by searching in the start directory and its parents, 117 | using os.path.abspath for path handling.""" 118 | current_dir = os.path.abspath(start_dir) # Convert start_dir to absolute path 119 | 120 | while current_dir != os.path.dirname(current_dir): # Stop at the root directory 121 | gitignore_path = os.path.join(current_dir, ".gitignore") 122 | if os.path.exists(gitignore_path): 123 | print(f"Found .gitignore in: {current_dir}") 124 | with open(gitignore_path, "r", encoding="utf-8") as f: 125 | return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, f) 126 | current_dir = os.path.dirname(current_dir) # Move to parent directory (still absolute path) 127 | 128 | # Check for .gitignore in the root directory as a last resort 129 | root_gitignore_path = os.path.join(current_dir, ".gitignore") # current_dir is now root path 130 | if os.path.exists(root_gitignore_path): 131 | print(f"Found .gitignore in root directory: {current_dir}") 132 | with open(root_gitignore_path, "r", encoding="utf-8") as f: 133 | return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, f) 134 | 135 | return None # No .gitignore found 136 | 137 | 138 | def is_ignored(file_or_dir_path, gitignore_spec, root_dir): 139 | """Check if a file or directory is ignored by .gitignore or default ignores.""" 140 | # Normalize the path relative to the root directory 141 | relative_path = os.path.relpath(file_or_dir_path, root_dir) 142 | 143 | # Check if it's in the default ignore list (e.g., ".git", "__pycache__") 144 | if any(segment in DEFAULT_IGNORES for segment in relative_path.split(os.sep)): 145 | return True 146 | 147 | # Check against .gitignore if a spec is provided 148 | if gitignore_spec and gitignore_spec.match_file(relative_path): 149 | return True 150 | elif gitignore_spec and gitignore_spec.match_file(file_or_dir_path): 151 | return True 152 | 153 | return False 154 | 155 | 156 | def add_line_numbers(content): 157 | """Add line numbers to the content.""" 158 | lines = content.splitlines() 159 | if not lines and content == "": 160 | # Handle empty content - splitlines() returns [] for empty string 161 | return " 1 | " 162 | numbered_lines = [f"{i + 1:4d} | {line}" for i, line in enumerate(lines)] 163 | return "\n".join(numbered_lines) 164 | 165 | 166 | def combine_files_with_structure(root_dir, use_git_ignore, interactive=False, previous_files=None, 167 | safe_mode=True, line_numbers=False, show_ignored_in_tree=False, 168 | show_default_ignored_in_tree=False, include_patterns=None, exclude_patterns=None): 169 | """Combine file contents with directory structure.""" 170 | combined_content = [] 171 | 172 | gitignore_spec = load_gitignore(root_dir) if use_git_ignore else None 173 | tree_structure, file_list = generate_tree_structure( 174 | root_dir, 175 | gitignore_spec, 176 | show_ignored=show_ignored_in_tree, 177 | show_default_ignored=show_default_ignored_in_tree, 178 | include_patterns=include_patterns, 179 | exclude_patterns=exclude_patterns 180 | ) 181 | 182 | combined_content.append("# Project Directory Structure:") 183 | combined_content.append(tree_structure) 184 | combined_content.append("\n# BEGIN FILE CONTENTS") 185 | 186 | if previous_files: 187 | # Convert relative paths to absolute paths and verify they exist 188 | selected_files = set() 189 | for rel_path in previous_files: 190 | abs_path = os.path.abspath(os.path.join(root_dir, rel_path)) 191 | if os.path.exists(abs_path) and os.path.isfile(abs_path): 192 | selected_files.add(abs_path) 193 | else: 194 | print(f"Warning: Previously selected file not found: {rel_path}") 195 | 196 | if not selected_files: 197 | raise SystemExit("No valid files found from previous selection") 198 | elif interactive: 199 | # Import interactive function only when needed to avoid circular imports 200 | from .interactive import interactive_file_selector 201 | selected_files = interactive_file_selector(file_list) 202 | else: 203 | selected_files = set(file_list) 204 | 205 | # Add safe mode checks 206 | if safe_mode: 207 | if len(selected_files) > SAFE_MODE_MAX_FILES: 208 | raise SystemExit( 209 | f"Safe mode: Too many files selected ({len(selected_files)} > {SAFE_MODE_MAX_FILES})\n" 210 | "To override this limit, run the command again with --disable-safe-mode or -dsm" 211 | ) 212 | 213 | total_size = 0 214 | for file_path in selected_files: 215 | total_size += os.path.getsize(file_path) 216 | if total_size > SAFE_MODE_MAX_LENGTH: 217 | raise SystemExit( 218 | f"Safe mode: Combined file size too large (> {SAFE_MODE_MAX_LENGTH:,} bytes)\n" 219 | "To override this limit, run the command again with --disable-safe-mode or -dsm" 220 | ) 221 | 222 | # Combine contents of selected files 223 | for file_path in selected_files: 224 | try: 225 | with open(file_path, "r", encoding="utf-8") as f: 226 | content = f.read() 227 | 228 | # Add line numbers if requested 229 | if line_numbers: 230 | content = add_line_numbers(content) 231 | 232 | # Convert absolute path to relative path for display 233 | rel_path = os.path.relpath(file_path, root_dir) 234 | combined_content.append(f"\n# File: {rel_path}\n") 235 | combined_content.append(content) 236 | combined_content.append("\n# END FILE CONTENTS\n") 237 | except (UnicodeDecodeError, OSError) as e: 238 | print(f"Warning: Could not read file {file_path}: {e}") 239 | continue 240 | 241 | return "\n".join(combined_content), selected_files 242 | 243 | 244 | def save_to_file(output_path, content): 245 | """Save the combined content to a file.""" 246 | with open(output_path, "w", encoding="utf-8") as f: 247 | f.write(content) 248 | 249 | 250 | def copy_to_clipboard(content): 251 | """Copy the provided content to the clipboard.""" 252 | try: 253 | pyperclip.copy(content) 254 | print("Output copied to clipboard!") 255 | except pyperclip.PyperclipException as e: 256 | print(f"Failed to copy to clipboard: {e}") 257 | 258 | 259 | def estimate_tokens(text): 260 | """Provide a rough estimate of tokens in the text (using ~4 chars per token).""" 261 | return len(text) // 4 -------------------------------------------------------------------------------- /cli_tool_gptree/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import copy 3 | 4 | # Configuration constants 5 | CONFIG_VERSION = 3 # Increment this when config structure changes 6 | PROJECT_CONFIG_FILE = '.gptree_config' 7 | OUTPUT_FILE = 'gptree_output.txt' 8 | 9 | DEFAULT_CONFIG = { 10 | "version": CONFIG_VERSION, 11 | "useGitIgnore": True, 12 | "includeFileTypes": "*", # Primary: simple file extensions 13 | "excludeFileTypes": [], # Primary: simple file extensions 14 | "includePatterns": [], # Advanced: glob patterns (optional) 15 | "excludePatterns": [], # Advanced: glob patterns (optional) 16 | "outputFile": OUTPUT_FILE, 17 | "outputFileLocally": True, 18 | "copyToClipboard": False, 19 | "safeMode": True, 20 | "storeFilesChosen": True, 21 | "lineNumbers": False, 22 | "showIgnoredInTree": False, 23 | "showDefaultIgnoredInTree": False, 24 | } 25 | 26 | 27 | def normalize_file_types(file_types): 28 | """Normalize file types to ensure they have a leading dot and are valid.""" 29 | if file_types == "*": 30 | return "*" 31 | return [ 32 | f".{ft.strip().lstrip('.')}" for ft in file_types.split(",") if ft.strip() 33 | ] 34 | 35 | 36 | def normalize_patterns(patterns): 37 | """Normalize glob patterns to ensure they are properly formatted.""" 38 | if isinstance(patterns, str): 39 | if not patterns.strip(): # Empty string 40 | return [] 41 | # Split comma-separated patterns and strip whitespace 42 | patterns = [p.strip() for p in patterns.split(",") if p.strip()] 43 | 44 | if not isinstance(patterns, list): 45 | return [] 46 | 47 | # Remove empty patterns 48 | return [p for p in patterns if p.strip()] 49 | 50 | 51 | def file_types_to_patterns(file_types): 52 | """Convert file types to glob patterns.""" 53 | if file_types == "*": 54 | return ["*"] 55 | if isinstance(file_types, str): 56 | if not file_types.strip(): 57 | return ["*"] 58 | file_types = [ft.strip() for ft in file_types.split(",") if ft.strip()] 59 | if isinstance(file_types, list): 60 | return [f"**/*{ft}" if ft.startswith('.') else f"**/*.{ft}" for ft in file_types] 61 | return ["*"] 62 | 63 | 64 | def migrate_config(config, current_version, is_global=False): 65 | """Migrate config from older versions to current version.""" 66 | if "version" not in config: 67 | config["version"] = 0 68 | else: 69 | try: 70 | config["version"] = int(config["version"]) 71 | except ValueError: 72 | config["version"] = 0 # If version can't be parsed, assume oldest version 73 | 74 | while config["version"] < current_version: 75 | if config["version"] == 0: 76 | # Migrate from version 0 to 1 77 | if not is_global: 78 | config["previousFiles"] = config.get("previousFiles", []) 79 | config["version"] = 1 80 | print(f"Migrated {'global' if is_global else 'local'} config from version 0 to 1") 81 | elif config["version"] == 1: 82 | # Migrate from version 1 to 2 83 | config["showIgnoredInTree"] = False 84 | config["showDefaultIgnoredInTree"] = False 85 | config["version"] = 2 86 | print(f"Migrated {'global' if is_global else 'local'} config from version 1 to 2") 87 | elif config["version"] == 2: 88 | # Migrate from version 2 to 3 - Add patterns as advanced feature 89 | # Keep existing file types as-is (don't auto-convert) 90 | config["includePatterns"] = [] 91 | config["excludePatterns"] = [] 92 | config["version"] = 3 93 | print(f"Migrated {'global' if is_global else 'local'} config from version 2 to 3 (added pattern support)") 94 | # Add more elif blocks here for future versions 95 | 96 | return config 97 | 98 | 99 | def write_config(file_path, isGlobal=False): 100 | """Write or update configuration file.""" 101 | config = copy.deepcopy(DEFAULT_CONFIG) 102 | if not isGlobal: 103 | config["previousFiles"] = [] 104 | 105 | if os.path.exists(file_path): 106 | # Read existing config 107 | existing_config = {} 108 | with open(file_path, "r", encoding="utf-8") as f: 109 | for line in f: 110 | line = line.strip() 111 | if line and not line.startswith("#") and ":" in line: 112 | key, value = line.split(":", 1) 113 | existing_config[key.strip()] = value.strip() 114 | 115 | # Migrate if necessary 116 | existing_config = migrate_config(existing_config, CONFIG_VERSION, isGlobal) 117 | config.update(existing_config) 118 | 119 | # Write updated config 120 | with open(file_path, "w", encoding="utf-8") as f: 121 | f.write(f"# GPTree {'Global' if isGlobal else 'Local'} Config\n") 122 | f.write(f"version: {config['version']}\n\n") 123 | f.write("# Whether to use .gitignore\n") 124 | f.write(f"useGitIgnore: {str(config['useGitIgnore']).lower()}\n") 125 | f.write("# File types to include (e.g., .py,.js) - use * for all types\n") 126 | f.write(f"includeFileTypes: {config['includeFileTypes']}\n") 127 | f.write("# File types to exclude (e.g., .log,.tmp)\n") 128 | f.write(f"excludeFileTypes: {','.join(config['excludeFileTypes']) if isinstance(config['excludeFileTypes'], list) else config['excludeFileTypes']}\n") 129 | f.write("# Advanced: Glob patterns to include (e.g., src/**/*.py,**/*.js) - overrides includeFileTypes if specified\n") 130 | f.write(f"includePatterns: {','.join(config['includePatterns']) if isinstance(config['includePatterns'], list) else config['includePatterns']}\n") 131 | f.write("# Advanced: Glob patterns to exclude (e.g., **/tests/**,**/*.log) - combined with excludeFileTypes\n") 132 | f.write(f"excludePatterns: {','.join(config['excludePatterns']) if isinstance(config['excludePatterns'], list) else config['excludePatterns']}\n") 133 | f.write("# Output file name\n") 134 | f.write(f"outputFile: {config['outputFile']}\n") 135 | f.write("# Whether to output the file locally or relative to the project directory\n") 136 | f.write(f"outputFileLocally: {str(config['outputFileLocally']).lower()}\n") 137 | f.write("# Whether to copy the output to the clipboard\n") 138 | f.write(f"copyToClipboard: {str(config['copyToClipboard']).lower()}\n") 139 | f.write('# Whether to use safe mode (prevent overly large files from being combined)\n') 140 | f.write(f"safeMode: {str(config['safeMode']).lower()}\n") 141 | f.write("# Whether to store the files chosen in the config file (--save, -s)\n") 142 | f.write(f"storeFilesChosen: {str(config['storeFilesChosen']).lower()}\n") 143 | f.write("# Whether to include line numbers in the output (--line-numbers, -n)\n") 144 | f.write(f"lineNumbers: {str(config['lineNumbers']).lower()}\n") 145 | f.write("# Whether to show ignored files in the directory tree\n") 146 | f.write(f"showIgnoredInTree: {str(config['showIgnoredInTree']).lower()}\n") 147 | f.write("# Whether to show only default ignored files in the directory tree while still respecting gitignore\n") 148 | f.write(f"showDefaultIgnoredInTree: {str(config['showDefaultIgnoredInTree']).lower()}\n") 149 | if not isGlobal: 150 | f.write("# Previously selected files (when using the -s or --save flag previously)\n") 151 | 152 | current_previous_files = config.get('previousFiles', []) 153 | if isinstance(current_previous_files, list): 154 | # Convert the list to a comma-separated string 155 | current_previous_files = ','.join(current_previous_files) 156 | elif not isinstance(current_previous_files, str): 157 | # If it's not a list or a string, raise an error 158 | raise ValueError("Invalid type for 'previousFiles'. Expected a string or list.") 159 | 160 | # Now safely split the string 161 | split_previous_files = [f.strip() for f in current_previous_files.split(',')] 162 | 163 | f.write(f"previousFiles: {','.join(split_previous_files)}\n") 164 | 165 | if not os.path.exists(file_path): 166 | print(f"Created new {'global' if isGlobal else 'local'} config file at {file_path}") 167 | return file_path 168 | 169 | 170 | def load_or_create_config(root_dir): 171 | """Load or create a configuration file in the root directory.""" 172 | config_path = os.path.join(root_dir, PROJECT_CONFIG_FILE) 173 | return write_config(config_path, False) 174 | 175 | 176 | def parse_config(config_path): 177 | """Parse the configuration file for options and patterns.""" 178 | config = copy.deepcopy(DEFAULT_CONFIG) 179 | config["previousFiles"] = [] 180 | 181 | with open(config_path, "r", encoding="utf-8") as config_file: 182 | for line in config_file: 183 | line = line.strip() 184 | if line and not line.startswith("#") and ":" in line: 185 | key, value = line.split(":", 1) 186 | key = key.strip() 187 | value = value.strip() 188 | 189 | if key == "previousFiles": 190 | if value: # Only split if there's a value 191 | # Split on comma and filter out empty strings 192 | files = [f.strip() for f in value.split(",")] 193 | config["previousFiles"] = [f for f in files if f] 194 | elif key == "useGitIgnore": 195 | config["useGitIgnore"] = value.lower() == "true" 196 | elif key == "includeFileTypes": 197 | config["includeFileTypes"] = normalize_file_types(value) 198 | elif key == "excludeFileTypes": 199 | config["excludeFileTypes"] = normalize_file_types(value) 200 | elif key == "includePatterns": 201 | config["includePatterns"] = normalize_patterns(value) 202 | elif key == "excludePatterns": 203 | config["excludePatterns"] = normalize_patterns(value) 204 | elif key == "outputFile": 205 | config["outputFile"] = value 206 | elif key == "outputFileLocally": 207 | config["outputFileLocally"] = value.lower() == "true" 208 | elif key == "copyToClipboard": 209 | config["copyToClipboard"] = value.lower() == "true" 210 | elif key == "safeMode": 211 | config["safeMode"] = value.lower() == "true" 212 | elif key == "storeFilesChosen": 213 | config["storeFilesChosen"] = value.lower() == "true" 214 | elif key == "lineNumbers": 215 | config["lineNumbers"] = value.lower() == "true" 216 | elif key == "showIgnoredInTree": 217 | config["showIgnoredInTree"] = value.lower() == "true" 218 | elif key == "showDefaultIgnoredInTree": 219 | config["showDefaultIgnoredInTree"] = value.lower() == "true" 220 | 221 | return config 222 | 223 | 224 | def load_global_config(): 225 | """Load global configuration from ~/.gptreerc, or create it with defaults if it doesn't exist.""" 226 | global_config_path = os.path.expanduser("~/.gptreerc") 227 | 228 | result_config_path = write_config(global_config_path, isGlobal=True) 229 | return parse_config(result_config_path) 230 | 231 | 232 | def save_files_to_config(config_path, selected_files, root_dir): 233 | """Save selected files to config file.""" 234 | # Convert absolute paths to relative paths and ensure they're properly quoted 235 | relative_paths = [os.path.relpath(f, root_dir) for f in selected_files] 236 | 237 | # Read existing config 238 | with open(config_path, 'r') as f: 239 | lines = f.readlines() 240 | 241 | # Update or add previousFiles line - join with single comma, no spaces 242 | previous_files_line = f"previousFiles: {','.join(relative_paths)}\n" 243 | previous_files_found = False 244 | 245 | for i, line in enumerate(lines): 246 | if line.startswith("previousFiles:"): 247 | lines[i] = previous_files_line 248 | previous_files_found = True 249 | break 250 | 251 | if not previous_files_found: 252 | lines.append(previous_files_line) 253 | 254 | # Write back to config file 255 | with open(config_path, 'w') as f: 256 | f.writelines(lines) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gptree 🌳 2 | 3 | **A CLI tool to provide LLM context for coding projects by combining project files into a single text file with a directory tree structure.** 4 | 5 | ![GitHub stars](https://img.shields.io/github/stars/travisvn/gptree?style=social) 6 | ![PyPI - Version](https://img.shields.io/pypi/v/gptree-cli) 7 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/travisvn/gptree/.github%2Fworkflows%2Fbuild.yml) 8 | ![GitHub Release](https://img.shields.io/github/v/release/travisvn/gptree) 9 | ![GitHub last commit](https://img.shields.io/github/last-commit/travisvn/gptree?color=red) 10 | [![PyPI Downloads](https://static.pepy.tech/badge/gptree-cli)](https://pepy.tech/projects/gptree-cli) 11 | 12 | ## What is gptree? 13 | 14 | When working with Large Language Models (LLMs) to continue or debug your coding projects, providing the right context is key. `gptree` simplifies this by: 15 | 16 | 1. Generating a clear **directory tree structure** of your project. 17 | 2. Combining the **contents of relevant files** into a single output text file. 18 | 3. Allowing you to **select files interactively** to fine-tune which files are included. 19 | 4. Supporting both **simple file type filtering** and **advanced glob patterns** for maximum flexibility. 20 | 21 | The resulting file can easily be copied and pasted into LLM prompts to provide the model with the necessary context to assist you effectively. 22 | 23 | ![GPTree Demo](./demo.gif) 24 | 25 | ## Features 26 | 27 | - 🗂 **Tree Structure**: Includes a visual directory tree of your project. 28 | - ✅ **Smart File Selection**: Automatically excludes ignored files using `.gitignore` and common directories like `.git`, `__pycache__`, and `.vscode`. 29 | - 📁 **Simple File Types**: Easy filtering by file extensions (e.g., `.py,.js`) for quick setup. 30 | - 🎯 **Advanced Patterns**: Optional glob patterns for complex filtering (e.g., `src/**/*.py`, `!**/tests/**`). 31 | - 🎛 **Interactive Mode**: Select or deselect files interactively using arrow keys, with the ability to quit immediately by pressing `ESC`. 32 | - 🌍 **Global Config Support**: Define default settings in a `~/.gptreerc` file. 33 | - 🔧 **Directory-Specific Config**: Customize behavior for each project via a `.gptree_config` file. 34 | - 🎛 **CLI Overrides**: Fine-tune settings directly in the CLI for maximum control. 35 | - 📜 **Safe Mode**: Prevent overly large files from being combined by limiting file count and total size. 36 | - 📋 **Clipboard Support**: Automatically copy output to clipboard if desired. 37 | - 🛠 **Custom Configuration Management**: Define configurations that are auto-detected per project or globally. 38 | 39 | ## 🆕 GPTree GUI - Now Available! 🎉 40 | 41 | Experience gptree with a beautiful and efficient graphical interface! 42 | 43 | - **Lightweight & Fast**: Built with Rust for optimal performance. 44 | - **Cross-Platform**: Available on macOS, Windows, and Linux 45 | - **Learn More & Download**: Visit [gptree.dev](https://gptree.dev) 46 | - **Open Source**: Check out the code on [GitHub](https://github.com/travisvn/gptree-gui) 47 | 48 | ## Installation 49 | 50 | ### Install via `pipx` 📦 (Recommended) 51 | 52 | ```bash 53 | pipx install gptree-cli 54 | ``` 55 | 56 | [How to setup pipx](https://pipx.pypa.io/) 57 | 58 | ### Install via Homebrew 🍺 59 | 60 | ```bash 61 | brew tap travisvn/tap 62 | brew install gptree 63 | ``` 64 | 65 | Homebrew will attempt to install `gptree` using `pip3` and will fall back to binary installation otherwise 66 | 67 | ### Install via pip 🐍 68 | 69 | Alternatively, install `gptree` (`gptree-cli`) directly via [pip](https://pypi.org/project/gptree-cli/): 70 | 71 | ```bash 72 | pip install gptree-cli 73 | ``` 74 | 75 | > [!NOTE] 76 | > Performance is better when installing directly with Python (`pipx` or `pip`) 77 | > 78 | > The binary installation might take a second or two longer to start up (not a huge deal — just something to note) 79 | 80 | ## Usage 81 | 82 | Run `gptree` in your project directory: 83 | 84 | ```bash 85 | gptree 86 | ``` 87 | 88 | Or run it anywhere and define the relative path to your project 89 | 90 | ### Options 91 | 92 | | Flag | Description | 93 | | -------------------------------- | ------------------------------------------------------------------------------------------------------- | 94 | | `--interactive`, `-i` | Enable interactive file selection | 95 | | `--copy`, `-c` | Copy result directly to clipboard | 96 | | `--include-file-types` | Comma-separated list of file types to include (e.g., `.py,.js` or `py,js`). Use `*` for all types | 97 | | `--exclude-file-types` | Comma-separated list of file types to exclude (e.g., `.log,.tmp` or `log,tmp`) | 98 | | `--include-patterns` | **Advanced**: Glob patterns to include (e.g., `src/**/*.py,**/*.js`). Overrides include-file-types | 99 | | `--exclude-patterns` | **Advanced**: Glob patterns to exclude (e.g., `**/tests/**,**/*.log`). Combined with exclude-file-types | 100 | | `--output-file` | Specify the name of the output file | 101 | | `--output-file-locally` | Save the output file in the current working directory | 102 | | `--no-config`, `-nc` | Disable creation or use of configuration files | 103 | | `--ignore-gitignore` | Ignore `.gitignore` patterns | 104 | | `--disable-safe-mode`, `-dsm` | Disable safe mode checks for file count or size | 105 | | `--line-numbers`, `-n` | Add line numbers to the output | 106 | | `--previous`, `-p` | Use the previously saved file selection | 107 | | `--save`, `-s` | Save the selected files to the configuration | 108 | | `--show-ignored-in-tree` | Show all ignored files in the directory tree | 109 | | `--show-default-ignored-in-tree` | Show default ignored files in the tree while still respecting gitignore | 110 | | `--version` | Display the current version of GPTree | 111 | | `path` | (Optional) Root directory of your project. Defaults to `.` | 112 | 113 | ### Examples 114 | 115 | #### Simple File Type Filtering (Recommended for Most Users) 116 | 117 | Include only Python and JavaScript files: 118 | 119 | ```bash 120 | gptree --include-file-types .py,.js 121 | ``` 122 | 123 | Exclude log and temporary files: 124 | 125 | ```bash 126 | gptree --exclude-file-types .log,.tmp,.cache 127 | ``` 128 | 129 | Include specific types while excluding others: 130 | 131 | ```bash 132 | gptree --include-file-types .py,.js,.ts --exclude-file-types .test.py,.spec.js 133 | ``` 134 | 135 |
136 | 137 | 138 | ### Advanced Pattern Filtering (For Power Users) 139 | 140 | 141 | 142 | Include Python files from src directory but exclude test files: 143 | 144 | ```bash 145 | gptree --include-patterns "src/**/*.py" --exclude-patterns "**/test_*.py" 146 | ``` 147 | 148 | Complex filtering for a web project: 149 | 150 | ```bash 151 | gptree --include-patterns "src/**/*.{js,ts,jsx,tsx},styles/**/*.{css,scss}" --exclude-patterns "**/tests/**,**/*.test.*,**/*.spec.*" 152 | ``` 153 | 154 | Include all files except those in specific directories: 155 | 156 | ```bash 157 | gptree --exclude-patterns "node_modules/**,venv/**,.git/**" 158 | ``` 159 | 160 | #### Hybrid Approach (Best of Both Worlds) 161 | 162 | Use file types as primary filter with additional pattern exclusions: 163 | 164 | ```bash 165 | gptree --include-file-types .py,.js --exclude-patterns "**/tests/**,**/node_modules/**" 166 | ``` 167 | 168 | > **Note**: When both file types and patterns are specified for includes, patterns take precedence. For excludes, both file types and patterns are combined. 169 | 170 | #### Interactive Mode and Configuration 171 | 172 | Interactive file selection with pre-filtered files: 173 | 174 | ```bash 175 | gptree --interactive --include-file-types .py,.js 176 | ``` 177 | 178 | Save current selection to config: 179 | 180 | ```bash 181 | gptree --interactive --save 182 | ``` 183 | 184 | Re-use previously saved file selections and copy to clipboard: 185 | 186 | ```bash 187 | gptree --previous --copy 188 | ``` 189 | 190 | ## File Filtering: Simple vs Advanced 191 | 192 | GPTree offers two approaches for file filtering to accommodate different user needs: 193 | 194 | ### 🎯 Simple File Types (Recommended) 195 | 196 | **Use when**: You want to include/exclude files by extension quickly and easily. 197 | 198 | ```bash 199 | # Quick and easy - include Python and JavaScript files 200 | gptree --include-file-types .py,.js 201 | 202 | # Exclude common unwanted files 203 | gptree --exclude-file-types .log,.tmp,.pyc 204 | ``` 205 | 206 | **Config Example**: 207 | 208 | ```yaml 209 | includeFileTypes: .py,.js,.ts 210 | excludeFileTypes: .log,.tmp 211 | ``` 212 | 213 | ### 🚀 Advanced Glob Patterns 214 | 215 | **Use when**: You need precise control over directory structure and complex filtering. 216 | 217 | ```bash 218 | # Advanced - include specific directory patterns 219 | gptree --include-patterns "src/**/*.py,tests/**/*.py" --exclude-patterns "**/conftest.py" 220 | ``` 221 | 222 | **Config Example**: 223 | 224 | ```yaml 225 | includePatterns: src/**/*.py,**/*.js 226 | excludePatterns: **/tests/**,**/*.log 227 | ``` 228 | 229 | ### 🔄 Hybrid Usage 230 | 231 | You can combine both approaches! File types provide the base filter, while patterns add advanced rules: 232 | 233 | ```bash 234 | # Use file types as base, patterns for advanced exclusions 235 | gptree --include-file-types .py,.js --exclude-patterns "**/node_modules/**,**/tests/**" 236 | ``` 237 | 238 | ### Pattern Syntax Reference 239 | 240 | | Pattern | Description | 241 | | -------------- | ----------------------------------------------------- | 242 | | `*.py` | Files ending in `.py` in current directory | 243 | | `**/*.py` | All `.py` files recursively | 244 | | `src/**/*.js` | All `.js` files in `src` directory tree | 245 | | `**/tests/**` | All files in any `tests` directory | 246 | | `**/*.{js,ts}` | All JavaScript and TypeScript files | 247 | | `!**/tests/**` | Exclude any `tests` directories (in exclude patterns) | 248 | 249 |
250 | 251 | ## Configuration 252 | 253 | ### Global Config (`~/.gptreerc`) 254 | 255 | Define your global defaults in `~/.gptreerc` to avoid repetitive setup across projects. Example: 256 | 257 | ```yaml 258 | # ~/.gptreerc 259 | version: 3 260 | useGitIgnore: true 261 | includeFileTypes: .py,.js # Simple file types for quick filtering 262 | excludeFileTypes: .log,.tmp # Exclude unwanted file types 263 | includePatterns: # Advanced patterns (empty = use file types) 264 | excludePatterns: **/node_modules/**,**/__pycache__/** # Advanced exclusions 265 | outputFile: gptree_output.txt 266 | outputFileLocally: true 267 | copyToClipboard: false 268 | safeMode: true 269 | lineNumbers: false 270 | storeFilesChosen: true 271 | showIgnoredInTree: false 272 | showDefaultIgnoredInTree: false 273 | ``` 274 | 275 | This file is automatically created with default settings if it doesn't exist. 276 | 277 | ### Directory Config (`.gptree_config`) 278 | 279 | Customize settings for a specific project by adding a `.gptree_config` file to your project root. Example: 280 | 281 | ```yaml 282 | # .gptree_config 283 | version: 3 284 | useGitIgnore: false 285 | includeFileTypes: .py,.js # Primary file type filtering 286 | excludeFileTypes: .pyc,.log # Exclude file types 287 | includePatterns: # Empty = use file types above 288 | excludePatterns: **/tests/**,**/node_modules/** # Additional pattern exclusions 289 | outputFile: project_context.txt 290 | outputFileLocally: false 291 | copyToClipboard: true 292 | safeMode: false 293 | lineNumbers: false 294 | storeFilesChosen: false 295 | showIgnoredInTree: false 296 | showDefaultIgnoredInTree: true 297 | ``` 298 | 299 | ### Configuration Precedence 300 | 301 | Settings are applied in the following order (highest to lowest precedence): 302 | 303 | 1. **CLI Arguments**: Always override other settings. 304 | 2. **Directory Config**: Project-specific settings in `.gptree_config`. 305 | 3. **Global Config**: User-defined defaults in `~/.gptreerc`. 306 | 4. **Programmed Defaults**: Built-in defaults used if no other settings are provided. 307 | 308 | ### How File Types and Patterns Work Together 309 | 310 | 1. **Include Logic**: If `includePatterns` is specified and non-empty, it overrides `includeFileTypes`. Otherwise, `includeFileTypes` is converted to patterns internally. 311 | 312 | 2. **Exclude Logic**: Both `excludeFileTypes` and `excludePatterns` are combined - files matching either will be excluded. 313 | 314 | 3. **Migration**: Existing configurations are automatically preserved - no forced conversion from file types to patterns. 315 | 316 | ## Safe Mode 317 | 318 | To prevent overly large files from being combined, Safe Mode restricts: 319 | 320 | - The **total number of files** (default: 30). 321 | - The **combined file size** (default: ~25k tokens, ~100,000 bytes). 322 | 323 | Override Safe Mode with `--disable-safe-mode`. 324 | 325 | ## Interactive Mode 326 | 327 | In interactive mode, use the following controls: 328 | 329 | | Key | Action | 330 | | --------- | ------------------------------------ | 331 | | `↑/↓/j/k` | Navigate the file list | 332 | | `SPACE` | Toggle selection of the current file | 333 | | `a` | Select or deselect all files | 334 | | `ENTER` | Confirm the selection and proceed | 335 | | `ESC` | Quit the process immediately | 336 | 337 | ## Contributing 338 | 339 | Contributions are welcome! Please fork the repository and create a pull request for any improvements. 340 | 341 | ## License 342 | 343 | This project is licensed under GNU General Public License v3.0 (GPL-3.0). 344 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import pytest 4 | from unittest.mock import patch, mock_open 5 | 6 | from cli_tool_gptree.config import ( 7 | normalize_file_types, normalize_patterns, file_types_to_patterns, 8 | migrate_config, write_config, parse_config, 9 | load_global_config, save_files_to_config, CONFIG_VERSION, DEFAULT_CONFIG 10 | ) 11 | 12 | 13 | class TestNormalizeFileTypes: 14 | """Test file type normalization functionality.""" 15 | 16 | def test_normalize_file_types_with_asterisk(self): 17 | """Test that asterisk is returned as is.""" 18 | result = normalize_file_types("*") 19 | assert result == "*" 20 | 21 | def test_normalize_file_types_with_dots(self): 22 | """Test normalization of file types with dots.""" 23 | result = normalize_file_types(".py,.js,.txt") 24 | assert result == [".py", ".js", ".txt"] 25 | 26 | def test_normalize_file_types_without_dots(self): 27 | """Test normalization of file types without dots.""" 28 | result = normalize_file_types("py,js,txt") 29 | assert result == [".py", ".js", ".txt"] 30 | 31 | def test_normalize_file_types_mixed(self): 32 | """Test normalization of mixed file types.""" 33 | result = normalize_file_types(".py,js,.txt,md") 34 | assert result == [".py", ".js", ".txt", ".md"] 35 | 36 | def test_normalize_file_types_with_spaces(self): 37 | """Test normalization with spaces around file types.""" 38 | result = normalize_file_types(" .py , js , .txt ") 39 | assert result == [".py", ".js", ".txt"] 40 | 41 | def test_normalize_file_types_empty_values(self): 42 | """Test normalization with empty values.""" 43 | result = normalize_file_types(".py,,js,,.txt") 44 | assert result == [".py", ".js", ".txt"] 45 | 46 | 47 | class TestNormalizePatterns: 48 | """Test glob pattern normalization functionality.""" 49 | 50 | def test_normalize_patterns_with_comma_separated(self): 51 | """Test normalization of comma-separated patterns.""" 52 | result = normalize_patterns("src/**/*.py,**/*.js,tests/**/*.txt") 53 | assert result == ["src/**/*.py", "**/*.js", "tests/**/*.txt"] 54 | 55 | def test_normalize_patterns_list_input(self): 56 | """Test normalization with list input.""" 57 | result = normalize_patterns(["src/**/*.py", "**/*.js"]) 58 | assert result == ["src/**/*.py", "**/*.js"] 59 | 60 | def test_normalize_patterns_with_spaces(self): 61 | """Test normalization with spaces around patterns.""" 62 | result = normalize_patterns(" src/**/*.py , **/*.js , tests/**/*.txt ") 63 | assert result == ["src/**/*.py", "**/*.js", "tests/**/*.txt"] 64 | 65 | def test_normalize_patterns_empty_values(self): 66 | """Test normalization with empty values.""" 67 | result = normalize_patterns("src/**/*.py,,**/*.js,,tests/**/*.txt") 68 | assert result == ["src/**/*.py", "**/*.js", "tests/**/*.txt"] 69 | 70 | def test_normalize_patterns_invalid_input(self): 71 | """Test normalization with invalid input.""" 72 | result = normalize_patterns(123) # Invalid type 73 | assert result == [] 74 | 75 | def test_normalize_patterns_empty_string(self): 76 | """Test normalization with empty string.""" 77 | result = normalize_patterns("") 78 | assert result == [] 79 | 80 | 81 | class TestFileTypesToPatterns: 82 | """Test file type to pattern conversion functionality.""" 83 | 84 | def test_file_types_to_patterns_asterisk(self): 85 | """Test conversion of asterisk.""" 86 | result = file_types_to_patterns("*") 87 | assert result == ["*"] 88 | 89 | def test_file_types_to_patterns_list_with_dots(self): 90 | """Test conversion of file types list with dots.""" 91 | result = file_types_to_patterns([".py", ".js"]) 92 | assert result == ["**/*.py", "**/*.js"] 93 | 94 | def test_file_types_to_patterns_list_without_dots(self): 95 | """Test conversion of file types list without dots.""" 96 | result = file_types_to_patterns(["py", "js"]) 97 | assert result == ["**/*.py", "**/*.js"] 98 | 99 | def test_file_types_to_patterns_string(self): 100 | """Test conversion of file types string.""" 101 | result = file_types_to_patterns(".py,.js") 102 | assert result == ["**/*.py", "**/*.js"] 103 | 104 | def test_file_types_to_patterns_empty(self): 105 | """Test conversion of empty file types.""" 106 | result = file_types_to_patterns("") 107 | assert result == ["*"] 108 | 109 | def test_file_types_to_patterns_invalid(self): 110 | """Test conversion of invalid file types.""" 111 | result = file_types_to_patterns(123) 112 | assert result == ["*"] 113 | 114 | 115 | class TestMigrateConfig: 116 | """Test configuration migration functionality.""" 117 | 118 | def test_migrate_config_no_version(self): 119 | """Test migration when config has no version.""" 120 | config = {"useGitIgnore": True} 121 | result = migrate_config(config, CONFIG_VERSION) 122 | assert result["version"] == CONFIG_VERSION 123 | assert "previousFiles" in result 124 | 125 | def test_migrate_config_version_0_to_current(self): 126 | """Test migration from version 0 to current.""" 127 | config = {"version": "0", "useGitIgnore": True} 128 | result = migrate_config(config, CONFIG_VERSION) 129 | assert result["version"] == CONFIG_VERSION 130 | assert "previousFiles" in result 131 | assert "showIgnoredInTree" in result 132 | assert "showDefaultIgnoredInTree" in result 133 | 134 | def test_migrate_config_version_2_to_3_adds_patterns(self): 135 | """Test migration from version 2 to 3 adds pattern support without converting file types.""" 136 | config = { 137 | "version": "2", 138 | "useGitIgnore": True, 139 | "includeFileTypes": ".py,.js", 140 | "excludeFileTypes": ".log,.tmp" 141 | } 142 | result = migrate_config(config, CONFIG_VERSION) 143 | assert result["version"] == CONFIG_VERSION 144 | # File types should be preserved, not converted 145 | assert result["includeFileTypes"] == ".py,.js" 146 | assert result["excludeFileTypes"] == ".log,.tmp" 147 | # Patterns should be added as empty 148 | assert result["includePatterns"] == [] 149 | assert result["excludePatterns"] == [] 150 | 151 | def test_migrate_config_global(self): 152 | """Test migration for global config (no previousFiles).""" 153 | config = {"version": "0", "useGitIgnore": True} 154 | result = migrate_config(config, CONFIG_VERSION, is_global=True) 155 | assert result["version"] == CONFIG_VERSION 156 | assert "previousFiles" not in result 157 | 158 | def test_migrate_config_invalid_version(self): 159 | """Test migration with invalid version string.""" 160 | config = {"version": "invalid", "useGitIgnore": True} 161 | result = migrate_config(config, CONFIG_VERSION) 162 | assert result["version"] == CONFIG_VERSION 163 | 164 | def test_migrate_config_already_current(self): 165 | """Test migration when config is already current version.""" 166 | config = {"version": CONFIG_VERSION, "useGitIgnore": True} 167 | result = migrate_config(config, CONFIG_VERSION) 168 | assert result["version"] == CONFIG_VERSION 169 | 170 | 171 | class TestWriteConfig: 172 | """Test configuration file writing functionality.""" 173 | 174 | def test_write_config_new_file(self): 175 | """Test writing a new config file.""" 176 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 177 | temp_path = temp_file.name 178 | 179 | try: 180 | # Remove the file so we can test creating a new one 181 | os.unlink(temp_path) 182 | 183 | result_path = write_config(temp_path, isGlobal=False) 184 | assert result_path == temp_path 185 | assert os.path.exists(temp_path) 186 | 187 | # Verify the file contains expected content 188 | with open(temp_path, 'r') as f: 189 | content = f.read() 190 | assert "# GPTree Local Config" in content 191 | assert f"version: {CONFIG_VERSION}" in content 192 | assert "useGitIgnore: true" in content 193 | assert "includeFileTypes: *" in content 194 | assert "excludeFileTypes:" in content 195 | assert "includePatterns:" in content 196 | assert "excludePatterns:" in content 197 | assert "previousFiles:" in content 198 | finally: 199 | if os.path.exists(temp_path): 200 | os.unlink(temp_path) 201 | 202 | def test_write_config_global_file(self): 203 | """Test writing a global config file.""" 204 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 205 | temp_path = temp_file.name 206 | 207 | try: 208 | # Remove the file so we can test creating a new one 209 | os.unlink(temp_path) 210 | 211 | result_path = write_config(temp_path, isGlobal=True) 212 | assert result_path == temp_path 213 | 214 | # Verify the file contains expected content 215 | with open(temp_path, 'r') as f: 216 | content = f.read() 217 | assert "# GPTree Global Config" in content 218 | assert "previousFiles:" not in content 219 | finally: 220 | if os.path.exists(temp_path): 221 | os.unlink(temp_path) 222 | 223 | def test_write_config_existing_file(self): 224 | """Test updating an existing config file.""" 225 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 226 | temp_file.write("version: 0\nuseGitIgnore: false\n") 227 | temp_path = temp_file.name 228 | 229 | try: 230 | result_path = write_config(temp_path, isGlobal=False) 231 | assert result_path == temp_path 232 | 233 | # Verify the file was updated 234 | with open(temp_path, 'r') as f: 235 | content = f.read() 236 | assert f"version: {CONFIG_VERSION}" in content 237 | finally: 238 | if os.path.exists(temp_path): 239 | os.unlink(temp_path) 240 | 241 | 242 | class TestParseConfig: 243 | """Test configuration file parsing functionality.""" 244 | 245 | def test_parse_config_hybrid_file_types_and_patterns(self): 246 | """Test parsing a config file with both file types and patterns.""" 247 | config_content = """# GPTree Config 248 | version: 3 249 | useGitIgnore: true 250 | includeFileTypes: .py,.js 251 | excludeFileTypes: .log,.tmp 252 | includePatterns: src/**/*.ts 253 | excludePatterns: **/tests/** 254 | outputFile: test_output.txt 255 | outputFileLocally: false 256 | copyToClipboard: true 257 | safeMode: false 258 | storeFilesChosen: true 259 | lineNumbers: true 260 | showIgnoredInTree: true 261 | showDefaultIgnoredInTree: false 262 | previousFiles: file1.py,file2.js 263 | """ 264 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 265 | temp_file.write(config_content) 266 | temp_path = temp_file.name 267 | 268 | try: 269 | config = parse_config(temp_path) 270 | assert config["version"] == 3 271 | assert config["useGitIgnore"] is True 272 | assert config["includeFileTypes"] == [".py", ".js"] 273 | assert config["excludeFileTypes"] == [".log", ".tmp"] 274 | assert config["includePatterns"] == ["src/**/*.ts"] 275 | assert config["excludePatterns"] == ["**/tests/**"] 276 | assert config["outputFile"] == "test_output.txt" 277 | assert config["outputFileLocally"] is False 278 | assert config["copyToClipboard"] is True 279 | assert config["safeMode"] is False 280 | assert config["storeFilesChosen"] is True 281 | assert config["lineNumbers"] is True 282 | assert config["showIgnoredInTree"] is True 283 | assert config["showDefaultIgnoredInTree"] is False 284 | assert config["previousFiles"] == ["file1.py", "file2.js"] 285 | finally: 286 | os.unlink(temp_path) 287 | 288 | def test_parse_config_file_types_only(self): 289 | """Test parsing config with only file types (primary interface).""" 290 | config_content = """# GPTree Config 291 | version: 3 292 | includeFileTypes: .py,.js 293 | excludeFileTypes: .log,.tmp 294 | """ 295 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 296 | temp_file.write(config_content) 297 | temp_path = temp_file.name 298 | 299 | try: 300 | config = parse_config(temp_path) 301 | assert config["includeFileTypes"] == [".py", ".js"] 302 | assert config["excludeFileTypes"] == [".log", ".tmp"] 303 | assert config["includePatterns"] == [] # Should be empty 304 | assert config["excludePatterns"] == [] # Should be empty 305 | finally: 306 | os.unlink(temp_path) 307 | 308 | def test_parse_config_patterns_only(self): 309 | """Test parsing config with only patterns (advanced interface).""" 310 | config_content = """# GPTree Config 311 | version: 3 312 | includePatterns: src/**/*.py,**/*.js 313 | excludePatterns: **/tests/**,**/*.log 314 | """ 315 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 316 | temp_file.write(config_content) 317 | temp_path = temp_file.name 318 | 319 | try: 320 | config = parse_config(temp_path) 321 | assert config["includeFileTypes"] == "*" # Should be default 322 | assert config["excludeFileTypes"] == [] # Should be default 323 | assert config["includePatterns"] == ["src/**/*.py", "**/*.js"] 324 | assert config["excludePatterns"] == ["**/tests/**", "**/*.log"] 325 | finally: 326 | os.unlink(temp_path) 327 | 328 | def test_parse_config_empty_patterns(self): 329 | """Test parsing config with empty patterns.""" 330 | config_content = """# GPTree Config 331 | version: 3 332 | includeFileTypes: .py 333 | excludeFileTypes: .log 334 | includePatterns: 335 | excludePatterns: 336 | previousFiles: 337 | """ 338 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 339 | temp_file.write(config_content) 340 | temp_path = temp_file.name 341 | 342 | try: 343 | config = parse_config(temp_path) 344 | assert config["includeFileTypes"] == [".py"] 345 | assert config["excludeFileTypes"] == [".log"] 346 | assert config["includePatterns"] == [] # Empty patterns should be empty list 347 | assert config["excludePatterns"] == [] # Empty patterns should be empty list 348 | assert config["previousFiles"] == [] 349 | finally: 350 | os.unlink(temp_path) 351 | 352 | def test_parse_config_with_comments(self): 353 | """Test parsing config file with comments.""" 354 | config_content = """# GPTree Config 355 | # This is a comment 356 | version: 3 357 | # Another comment 358 | useGitIgnore: true 359 | """ 360 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 361 | temp_file.write(config_content) 362 | temp_path = temp_file.name 363 | 364 | try: 365 | config = parse_config(temp_path) 366 | assert config["version"] == 3 367 | assert config["useGitIgnore"] is True 368 | finally: 369 | os.unlink(temp_path) 370 | 371 | 372 | class TestLoadGlobalConfig: 373 | """Test global configuration loading functionality.""" 374 | 375 | @patch('cli_tool_gptree.config.write_config') 376 | @patch('cli_tool_gptree.config.parse_config') 377 | @patch('os.path.expanduser') 378 | def test_load_global_config(self, mock_expanduser, mock_parse_config, mock_write_config): 379 | """Test loading global configuration.""" 380 | mock_expanduser.return_value = "/home/user/.gptreerc" 381 | mock_write_config.return_value = "/home/user/.gptreerc" 382 | mock_parse_config.return_value = {"version": CONFIG_VERSION, "useGitIgnore": True} 383 | 384 | result = load_global_config() 385 | 386 | mock_expanduser.assert_called_once_with("~/.gptreerc") 387 | mock_write_config.assert_called_once_with("/home/user/.gptreerc", isGlobal=True) 388 | mock_parse_config.assert_called_once_with("/home/user/.gptreerc") 389 | assert result["version"] == CONFIG_VERSION 390 | 391 | 392 | class TestSaveFilesToConfig: 393 | """Test saving selected files to config functionality.""" 394 | 395 | def test_save_files_to_config_new_entry(self): 396 | """Test saving files to config when previousFiles doesn't exist.""" 397 | config_content = """# GPTree Config 398 | version: 3 399 | useGitIgnore: true 400 | """ 401 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 402 | temp_file.write(config_content) 403 | temp_path = temp_file.name 404 | 405 | try: 406 | selected_files = ["/project/file1.py", "/project/subdir/file2.js"] 407 | root_dir = "/project" 408 | 409 | save_files_to_config(temp_path, selected_files, root_dir) 410 | 411 | with open(temp_path, 'r') as f: 412 | content = f.read() 413 | assert "previousFiles: file1.py,subdir/file2.js" in content 414 | finally: 415 | os.unlink(temp_path) 416 | 417 | def test_save_files_to_config_update_existing(self): 418 | """Test updating existing previousFiles in config.""" 419 | config_content = """# GPTree Config 420 | version: 3 421 | useGitIgnore: true 422 | previousFiles: old_file.py 423 | """ 424 | with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.config') as temp_file: 425 | temp_file.write(config_content) 426 | temp_path = temp_file.name 427 | 428 | try: 429 | selected_files = ["/project/new_file1.py", "/project/new_file2.js"] 430 | root_dir = "/project" 431 | 432 | save_files_to_config(temp_path, selected_files, root_dir) 433 | 434 | with open(temp_path, 'r') as f: 435 | content = f.read() 436 | assert "previousFiles: new_file1.py,new_file2.js" in content 437 | assert "old_file.py" not in content 438 | finally: 439 | os.unlink(temp_path) -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import pytest 4 | from unittest.mock import patch, MagicMock 5 | from io import StringIO 6 | 7 | from cli_tool_gptree.cli import main, setup_autocomplete, prompt_user_input, CURRENT_VERSION 8 | 9 | 10 | class TestSetupAutocomplete: 11 | """Test autocomplete setup functionality.""" 12 | 13 | @patch('cli_tool_gptree.cli.readline.set_completer') 14 | @patch('cli_tool_gptree.cli.readline.parse_and_bind') 15 | def test_setup_autocomplete(self, mock_parse_and_bind, mock_set_completer): 16 | """Test that autocomplete is properly configured.""" 17 | setup_autocomplete() 18 | 19 | mock_set_completer.assert_called_once() 20 | mock_parse_and_bind.assert_called_once_with("tab: complete") 21 | 22 | 23 | class TestPromptUserInput: 24 | """Test user input prompting functionality.""" 25 | 26 | @patch('builtins.input', return_value='user_input') 27 | def test_prompt_user_input_with_value(self, mock_input): 28 | """Test prompting when user provides input.""" 29 | result = prompt_user_input("Enter value", "default") 30 | assert result == "user_input" 31 | mock_input.assert_called_once_with("Enter value [default]: ") 32 | 33 | @patch('builtins.input', return_value='') 34 | def test_prompt_user_input_default(self, mock_input): 35 | """Test prompting when user uses default.""" 36 | result = prompt_user_input("Enter value", "default") 37 | assert result == "default" 38 | mock_input.assert_called_once_with("Enter value [default]: ") 39 | 40 | @patch('builtins.input', return_value=' ') 41 | def test_prompt_user_input_whitespace(self, mock_input): 42 | """Test prompting when user enters only whitespace.""" 43 | result = prompt_user_input("Enter value", "default") 44 | assert result == "default" 45 | mock_input.assert_called_once_with("Enter value [default]: ") 46 | 47 | 48 | class TestMainFunction: 49 | """Test the main CLI function with various scenarios.""" 50 | 51 | def test_main_version_flag(self, capsys): 52 | """Test --version flag.""" 53 | with patch('sys.argv', ['gptree', '--version']): 54 | main() 55 | 56 | captured = capsys.readouterr() 57 | assert CURRENT_VERSION in captured.out 58 | 59 | @patch('cli_tool_gptree.cli.load_global_config') 60 | @patch('cli_tool_gptree.cli.load_or_create_config') 61 | @patch('cli_tool_gptree.cli.parse_config') 62 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 63 | @patch('cli_tool_gptree.cli.save_to_file') 64 | def test_main_basic_functionality(self, mock_save_to_file, mock_combine_files, 65 | mock_parse_config, mock_load_or_create_config, 66 | mock_load_global_config): 67 | """Test basic main function execution.""" 68 | # Setup mocks 69 | mock_load_global_config.return_value = { 70 | "outputFile": "test_output.txt", 71 | "outputFileLocally": True, 72 | "useGitIgnore": True, 73 | "safeMode": True, 74 | "lineNumbers": False, 75 | "showIgnoredInTree": False, 76 | "showDefaultIgnoredInTree": False, 77 | "copyToClipboard": False, 78 | "storeFilesChosen": False, 79 | "includeFileTypes": "*", 80 | "excludeFileTypes": [], 81 | "includePatterns": [], 82 | "excludePatterns": [] 83 | } 84 | mock_load_or_create_config.return_value = "/tmp/config" 85 | mock_parse_config.return_value = {} 86 | mock_combine_files.return_value = ("combined content", set()) 87 | 88 | with patch('sys.argv', ['gptree', '.']): 89 | with patch('builtins.print'): # Suppress output 90 | main() 91 | 92 | mock_combine_files.assert_called_once() 93 | mock_save_to_file.assert_called_once() 94 | 95 | @patch('cli_tool_gptree.cli.load_global_config') 96 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 97 | def test_main_no_config_flag(self, mock_combine_files, mock_load_global_config): 98 | """Test --no-config flag.""" 99 | mock_load_global_config.return_value = { 100 | "outputFile": "test_output.txt", 101 | "outputFileLocally": True, 102 | "useGitIgnore": True, 103 | "safeMode": True, 104 | "lineNumbers": False, 105 | "showIgnoredInTree": False, 106 | "showDefaultIgnoredInTree": False, 107 | "copyToClipboard": False, 108 | "storeFilesChosen": False, 109 | "includeFileTypes": "*", 110 | "excludeFileTypes": [], 111 | "includePatterns": [], 112 | "excludePatterns": [] 113 | } 114 | mock_combine_files.return_value = ("content", set()) 115 | 116 | with patch('sys.argv', ['gptree', '--no-config', '.']): 117 | with patch('builtins.print'): 118 | with patch('cli_tool_gptree.cli.save_to_file'): 119 | main() 120 | 121 | # Verify that load_or_create_config was not called due to --no-config 122 | with patch('cli_tool_gptree.cli.load_or_create_config') as mock_load_config: 123 | # Re-run to check that config loading is skipped 124 | pass 125 | 126 | @patch('cli_tool_gptree.cli.load_global_config') 127 | @patch('cli_tool_gptree.cli.load_or_create_config') 128 | @patch('cli_tool_gptree.cli.parse_config') 129 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 130 | @patch('cli_tool_gptree.cli.save_to_file') 131 | @patch('cli_tool_gptree.cli.copy_to_clipboard') 132 | def test_main_with_copy_flag(self, mock_copy_to_clipboard, mock_save_to_file, 133 | mock_combine_files, mock_parse_config, 134 | mock_load_or_create_config, mock_load_global_config): 135 | """Test --copy flag.""" 136 | mock_load_global_config.return_value = { 137 | "outputFile": "test_output.txt", 138 | "outputFileLocally": True, 139 | "useGitIgnore": True, 140 | "safeMode": True, 141 | "lineNumbers": False, 142 | "showIgnoredInTree": False, 143 | "showDefaultIgnoredInTree": False, 144 | "copyToClipboard": False, 145 | "storeFilesChosen": False, 146 | "includeFileTypes": "*", 147 | "excludeFileTypes": [], 148 | "includePatterns": [], 149 | "excludePatterns": [] 150 | } 151 | mock_load_or_create_config.return_value = "/tmp/config" 152 | mock_parse_config.return_value = {} 153 | mock_combine_files.return_value = ("test content", set()) 154 | 155 | with patch('sys.argv', ['gptree', '--copy', '.']): 156 | with patch('builtins.print'): 157 | main() 158 | 159 | mock_copy_to_clipboard.assert_called_once_with("test content") 160 | 161 | @patch('cli_tool_gptree.cli.load_global_config') 162 | @patch('cli_tool_gptree.cli.load_or_create_config') 163 | @patch('cli_tool_gptree.cli.parse_config') 164 | def test_main_previous_files_no_selection(self, mock_parse_config, 165 | mock_load_or_create_config, 166 | mock_load_global_config, capsys): 167 | """Test --previous flag when no previous selection exists.""" 168 | mock_load_global_config.return_value = { 169 | "outputFile": "test.txt", 170 | "outputFileLocally": True, 171 | "useGitIgnore": True, 172 | "safeMode": True, 173 | "lineNumbers": False, 174 | "showIgnoredInTree": False, 175 | "showDefaultIgnoredInTree": False, 176 | "copyToClipboard": False, 177 | "storeFilesChosen": False, 178 | "includeFileTypes": "*", 179 | "excludeFileTypes": [], 180 | "includePatterns": [], 181 | "excludePatterns": [] 182 | } 183 | mock_load_or_create_config.return_value = "/tmp/config" 184 | mock_parse_config.return_value = {"previousFiles": []} 185 | 186 | with patch('sys.argv', ['gptree', '--previous', '.']): 187 | main() 188 | 189 | captured = capsys.readouterr() 190 | assert "No previous file selection found." in captured.out 191 | 192 | @patch('cli_tool_gptree.cli.load_global_config') 193 | @patch('cli_tool_gptree.cli.load_or_create_config') 194 | @patch('cli_tool_gptree.cli.parse_config') 195 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 196 | @patch('cli_tool_gptree.cli.save_to_file') 197 | def test_main_file_types_arguments(self, mock_save_to_file, mock_combine_files, 198 | mock_parse_config, mock_load_or_create_config, 199 | mock_load_global_config): 200 | """Test file type CLI arguments (primary interface).""" 201 | mock_load_global_config.return_value = { 202 | "outputFile": "default.txt", 203 | "outputFileLocally": False, 204 | "useGitIgnore": True, 205 | "safeMode": True, 206 | "lineNumbers": False, 207 | "showIgnoredInTree": False, 208 | "showDefaultIgnoredInTree": False, 209 | "copyToClipboard": False, 210 | "storeFilesChosen": False, 211 | "includeFileTypes": "*", 212 | "excludeFileTypes": [], 213 | "includePatterns": [], 214 | "excludePatterns": [] 215 | } 216 | mock_load_or_create_config.return_value = "/tmp/config" 217 | mock_parse_config.return_value = {} 218 | mock_combine_files.return_value = ("content", set()) 219 | 220 | args = [ 221 | 'gptree', 222 | '--include-file-types', '.py,.js', 223 | '--exclude-file-types', '.log,.tmp', 224 | '.' 225 | ] 226 | 227 | with patch('sys.argv', args): 228 | with patch('builtins.print'): 229 | main() 230 | 231 | # Verify that patterns were converted from file types 232 | call_args = mock_combine_files.call_args 233 | assert call_args[1]['include_patterns'] == ['**/*.py', '**/*.js'] 234 | assert call_args[1]['exclude_patterns'] == ['**/*.log', '**/*.tmp'] 235 | 236 | @patch('cli_tool_gptree.cli.load_global_config') 237 | @patch('cli_tool_gptree.cli.load_or_create_config') 238 | @patch('cli_tool_gptree.cli.parse_config') 239 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 240 | @patch('cli_tool_gptree.cli.save_to_file') 241 | def test_main_pattern_arguments(self, mock_save_to_file, mock_combine_files, 242 | mock_parse_config, mock_load_or_create_config, 243 | mock_load_global_config): 244 | """Test glob pattern CLI arguments (advanced interface).""" 245 | mock_load_global_config.return_value = { 246 | "outputFile": "default.txt", 247 | "outputFileLocally": False, 248 | "useGitIgnore": True, 249 | "safeMode": True, 250 | "lineNumbers": False, 251 | "showIgnoredInTree": False, 252 | "showDefaultIgnoredInTree": False, 253 | "copyToClipboard": False, 254 | "storeFilesChosen": False, 255 | "includeFileTypes": "*", 256 | "excludeFileTypes": [], 257 | "includePatterns": [], 258 | "excludePatterns": [] 259 | } 260 | mock_load_or_create_config.return_value = "/tmp/config" 261 | mock_parse_config.return_value = {} 262 | mock_combine_files.return_value = ("content", set()) 263 | 264 | args = [ 265 | 'gptree', 266 | '--include-patterns', 'src/**/*.py,**/*.js', 267 | '--exclude-patterns', '**/tests/**,**/*.log', 268 | '--output-file', 'custom.txt', 269 | '--output-file-locally', 270 | '--line-numbers', 271 | '--disable-safe-mode', 272 | '--show-ignored-in-tree', 273 | '--show-default-ignored-in-tree', 274 | '.' 275 | ] 276 | 277 | with patch('sys.argv', args): 278 | with patch('builtins.print'): 279 | main() 280 | 281 | # Verify that combine_files_with_structure was called with pattern options 282 | call_args = mock_combine_files.call_args 283 | assert call_args[1]['safe_mode'] is False 284 | assert call_args[1]['line_numbers'] is True 285 | assert call_args[1]['show_ignored_in_tree'] is True 286 | assert call_args[1]['show_default_ignored_in_tree'] is True 287 | assert call_args[1]['include_patterns'] == ['src/**/*.py', '**/*.js'] 288 | assert call_args[1]['exclude_patterns'] == ['**/tests/**', '**/*.log'] 289 | 290 | @patch('cli_tool_gptree.cli.load_global_config') 291 | @patch('cli_tool_gptree.cli.load_or_create_config') 292 | @patch('cli_tool_gptree.cli.parse_config') 293 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 294 | @patch('cli_tool_gptree.cli.save_to_file') 295 | def test_main_hybrid_file_types_and_patterns(self, mock_save_to_file, mock_combine_files, 296 | mock_parse_config, mock_load_or_create_config, 297 | mock_load_global_config): 298 | """Test using both file types and patterns together (patterns should take precedence for include).""" 299 | mock_load_global_config.return_value = { 300 | "outputFile": "default.txt", 301 | "outputFileLocally": False, 302 | "useGitIgnore": True, 303 | "safeMode": True, 304 | "lineNumbers": False, 305 | "showIgnoredInTree": False, 306 | "showDefaultIgnoredInTree": False, 307 | "copyToClipboard": False, 308 | "storeFilesChosen": False, 309 | "includeFileTypes": "*", 310 | "excludeFileTypes": [], 311 | "includePatterns": [], 312 | "excludePatterns": [] 313 | } 314 | mock_load_or_create_config.return_value = "/tmp/config" 315 | mock_parse_config.return_value = {} 316 | mock_combine_files.return_value = ("content", set()) 317 | 318 | args = [ 319 | 'gptree', 320 | '--include-file-types', '.py,.js', # File types 321 | '--exclude-file-types', '.log', # File types 322 | '--include-patterns', 'src/**/*.ts', # Patterns (should override include file types) 323 | '--exclude-patterns', '**/tests/**', # Patterns (should combine with exclude file types) 324 | '.' 325 | ] 326 | 327 | with patch('sys.argv', args): 328 | with patch('builtins.print'): 329 | main() 330 | 331 | # Verify hybrid behavior 332 | call_args = mock_combine_files.call_args 333 | # Include patterns should override file types 334 | assert call_args[1]['include_patterns'] == ['src/**/*.ts'] 335 | # Exclude patterns should combine file types and patterns 336 | assert call_args[1]['exclude_patterns'] == ['**/*.log', '**/tests/**'] 337 | 338 | @patch('cli_tool_gptree.cli.load_global_config') 339 | @patch('cli_tool_gptree.cli.load_or_create_config') 340 | @patch('cli_tool_gptree.cli.parse_config') 341 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 342 | def test_main_system_exit_handling(self, mock_combine_files, mock_parse_config, 343 | mock_load_or_create_config, mock_load_global_config, 344 | capsys): 345 | """Test handling of SystemExit from combine_files_with_structure.""" 346 | mock_load_global_config.return_value = { 347 | "outputFile": "test.txt", 348 | "outputFileLocally": True, 349 | "useGitIgnore": True, 350 | "safeMode": True, 351 | "lineNumbers": False, 352 | "showIgnoredInTree": False, 353 | "showDefaultIgnoredInTree": False, 354 | "copyToClipboard": False, 355 | "storeFilesChosen": False, 356 | "includeFileTypes": "*", 357 | "excludeFileTypes": [], 358 | "includePatterns": [], 359 | "excludePatterns": [] 360 | } 361 | mock_load_or_create_config.return_value = "/tmp/config" 362 | mock_parse_config.return_value = {} 363 | mock_combine_files.side_effect = SystemExit("Test error message") 364 | 365 | with patch('sys.argv', ['gptree', '.']): 366 | main() 367 | 368 | captured = capsys.readouterr() 369 | assert "Test error message" in captured.out 370 | 371 | @patch('cli_tool_gptree.cli.load_global_config') 372 | @patch('cli_tool_gptree.cli.load_or_create_config') 373 | @patch('cli_tool_gptree.cli.parse_config') 374 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 375 | @patch('cli_tool_gptree.cli.save_to_file') 376 | @patch('cli_tool_gptree.cli.save_files_to_config') 377 | def test_main_save_files_to_config(self, mock_save_files_to_config, mock_save_to_file, 378 | mock_combine_files, mock_parse_config, 379 | mock_load_or_create_config, mock_load_global_config): 380 | """Test saving selected files to config.""" 381 | mock_load_global_config.return_value = { 382 | "outputFile": "test.txt", 383 | "outputFileLocally": True, 384 | "storeFilesChosen": True, 385 | "useGitIgnore": True, 386 | "safeMode": True, 387 | "lineNumbers": False, 388 | "showIgnoredInTree": False, 389 | "showDefaultIgnoredInTree": False, 390 | "copyToClipboard": False, 391 | "includeFileTypes": "*", 392 | "excludeFileTypes": [], 393 | "includePatterns": [], 394 | "excludePatterns": [] 395 | } 396 | mock_load_or_create_config.return_value = "/tmp/config" 397 | mock_parse_config.return_value = {} 398 | selected_files = {"/project/file1.py", "/project/file2.js"} 399 | mock_combine_files.return_value = ("content", selected_files) 400 | 401 | with patch('sys.argv', ['gptree', '.']): 402 | with patch('builtins.print'): 403 | main() 404 | 405 | mock_save_files_to_config.assert_called_once() 406 | 407 | @patch('cli_tool_gptree.cli.prompt_user_input') 408 | @patch('cli_tool_gptree.cli.load_global_config') 409 | @patch('cli_tool_gptree.cli.load_or_create_config') 410 | @patch('cli_tool_gptree.cli.parse_config') 411 | @patch('cli_tool_gptree.cli.combine_files_with_structure') 412 | @patch('cli_tool_gptree.cli.save_to_file') 413 | def test_main_prompt_for_path(self, mock_save_to_file, mock_combine_files, 414 | mock_parse_config, mock_load_or_create_config, 415 | mock_load_global_config, mock_prompt_user_input): 416 | """Test prompting for path when not provided.""" 417 | mock_prompt_user_input.return_value = "/custom/path" 418 | mock_load_global_config.return_value = { 419 | "outputFile": "test.txt", 420 | "outputFileLocally": True, 421 | "useGitIgnore": True, 422 | "safeMode": True, 423 | "lineNumbers": False, 424 | "showIgnoredInTree": False, 425 | "showDefaultIgnoredInTree": False, 426 | "copyToClipboard": False, 427 | "storeFilesChosen": False, 428 | "includeFileTypes": "*", 429 | "excludeFileTypes": [], 430 | "includePatterns": [], 431 | "excludePatterns": [] 432 | } 433 | mock_load_or_create_config.return_value = "/tmp/config" 434 | mock_parse_config.return_value = {} 435 | mock_combine_files.return_value = ("content", set()) 436 | 437 | with patch('sys.argv', ['gptree']): # No path argument 438 | with patch('builtins.print'): 439 | main() 440 | 441 | mock_prompt_user_input.assert_called_once_with( 442 | "Enter the root directory of the project", "." 443 | ) 444 | # Verify combine_files_with_structure was called with the custom path 445 | mock_combine_files.assert_called_once() 446 | call_args = mock_combine_files.call_args[0] 447 | assert call_args[0] == "/custom/path" -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import pytest 4 | import pathspec 5 | from unittest.mock import patch, mock_open, MagicMock 6 | 7 | from cli_tool_gptree.builder import ( 8 | generate_tree_structure, load_gitignore, is_ignored, add_line_numbers, 9 | combine_files_with_structure, save_to_file, copy_to_clipboard, estimate_tokens, 10 | create_pattern_spec, matches_patterns, DEFAULT_IGNORES, SAFE_MODE_MAX_FILES, SAFE_MODE_MAX_LENGTH 11 | ) 12 | 13 | 14 | class TestCreatePatternSpec: 15 | """Test pathspec creation from glob patterns.""" 16 | 17 | def test_create_pattern_spec_none_patterns(self): 18 | """Test pattern spec creation with None patterns.""" 19 | result = create_pattern_spec(None) 20 | assert result is None 21 | 22 | def test_create_pattern_spec_asterisk_pattern(self): 23 | """Test pattern spec creation with asterisk pattern.""" 24 | result = create_pattern_spec(["*"]) 25 | assert result is None 26 | 27 | def test_create_pattern_spec_valid_patterns(self): 28 | """Test pattern spec creation with valid patterns.""" 29 | patterns = ["src/**/*.py", "**/*.js"] 30 | result = create_pattern_spec(patterns) 31 | assert result is not None 32 | assert isinstance(result, pathspec.PathSpec) 33 | 34 | def test_create_pattern_spec_empty_list(self): 35 | """Test pattern spec creation with empty list.""" 36 | result = create_pattern_spec([]) 37 | assert result is None 38 | 39 | 40 | class TestMatchesPatterns: 41 | """Test pattern matching functionality.""" 42 | 43 | def test_matches_patterns_none_spec(self): 44 | """Test pattern matching with None spec (should match all).""" 45 | result = matches_patterns("/path/to/file.py", "/project", None) 46 | assert result is True 47 | 48 | def test_matches_patterns_valid_match(self): 49 | """Test pattern matching with valid match.""" 50 | with tempfile.TemporaryDirectory() as temp_dir: 51 | file_path = os.path.join(temp_dir, "src", "main.py") 52 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 53 | 54 | spec = pathspec.PathSpec.from_lines( 55 | pathspec.patterns.GitWildMatchPattern, ["src/**/*.py"] 56 | ) 57 | 58 | result = matches_patterns(file_path, temp_dir, spec) 59 | assert result is True 60 | 61 | def test_matches_patterns_no_match(self): 62 | """Test pattern matching with no match.""" 63 | with tempfile.TemporaryDirectory() as temp_dir: 64 | file_path = os.path.join(temp_dir, "src", "main.js") 65 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 66 | 67 | spec = pathspec.PathSpec.from_lines( 68 | pathspec.patterns.GitWildMatchPattern, ["**/*.py"] 69 | ) 70 | 71 | result = matches_patterns(file_path, temp_dir, spec) 72 | assert result is False 73 | 74 | 75 | class TestGenerateTreeStructure: 76 | """Test tree structure generation functionality.""" 77 | 78 | def test_generate_tree_structure_simple(self): 79 | """Test tree generation with a simple directory structure.""" 80 | with tempfile.TemporaryDirectory() as temp_dir: 81 | # Create test structure 82 | os.makedirs(os.path.join(temp_dir, "subdir")) 83 | with open(os.path.join(temp_dir, "file1.txt"), "w") as f: 84 | f.write("content1") 85 | with open(os.path.join(temp_dir, "subdir", "file2.txt"), "w") as f: 86 | f.write("content2") 87 | 88 | tree, files = generate_tree_structure(temp_dir, None) 89 | 90 | assert "." in tree 91 | assert "├── file1.txt" in tree 92 | assert "└── subdir/" in tree 93 | assert " └── file2.txt" in tree 94 | assert len(files) == 2 95 | 96 | def test_generate_tree_structure_with_gitignore(self): 97 | """Test tree generation respecting gitignore patterns.""" 98 | with tempfile.TemporaryDirectory() as temp_dir: 99 | # Create test structure 100 | with open(os.path.join(temp_dir, "file1.txt"), "w") as f: 101 | f.write("content1") 102 | with open(os.path.join(temp_dir, "ignored.log"), "w") as f: 103 | f.write("ignored content") 104 | 105 | # Create mock gitignore spec 106 | gitignore_spec = pathspec.PathSpec.from_lines( 107 | pathspec.patterns.GitWildMatchPattern, ["*.log"] 108 | ) 109 | 110 | tree, files = generate_tree_structure(temp_dir, gitignore_spec) 111 | 112 | assert "file1.txt" in tree 113 | assert "ignored.log" not in tree 114 | assert len(files) == 1 115 | 116 | def test_generate_tree_structure_with_include_patterns(self): 117 | """Test tree generation with include patterns.""" 118 | with tempfile.TemporaryDirectory() as temp_dir: 119 | # Create test structure 120 | with open(os.path.join(temp_dir, "file1.py"), "w") as f: 121 | f.write("python content") 122 | with open(os.path.join(temp_dir, "file2.js"), "w") as f: 123 | f.write("javascript content") 124 | with open(os.path.join(temp_dir, "file3.txt"), "w") as f: 125 | f.write("text content") 126 | 127 | # Only include Python files 128 | include_patterns = ["**/*.py"] 129 | tree, files = generate_tree_structure( 130 | temp_dir, None, include_patterns=include_patterns 131 | ) 132 | 133 | assert "file1.py" in tree 134 | assert "file2.js" not in tree 135 | assert "file3.txt" not in tree 136 | assert len(files) == 1 137 | 138 | def test_generate_tree_structure_with_exclude_patterns(self): 139 | """Test tree generation with exclude patterns.""" 140 | with tempfile.TemporaryDirectory() as temp_dir: 141 | # Create test structure 142 | with open(os.path.join(temp_dir, "file1.py"), "w") as f: 143 | f.write("python content") 144 | with open(os.path.join(temp_dir, "file2.js"), "w") as f: 145 | f.write("javascript content") 146 | with open(os.path.join(temp_dir, "test.log"), "w") as f: 147 | f.write("log content") 148 | 149 | # Exclude log files 150 | exclude_patterns = ["**/*.log"] 151 | tree, files = generate_tree_structure( 152 | temp_dir, None, exclude_patterns=exclude_patterns 153 | ) 154 | 155 | assert "file1.py" in tree 156 | assert "file2.js" in tree 157 | assert "test.log" not in tree 158 | assert len(files) == 2 159 | 160 | def test_generate_tree_structure_with_complex_patterns(self): 161 | """Test tree generation with complex include/exclude patterns.""" 162 | with tempfile.TemporaryDirectory() as temp_dir: 163 | # Create test structure 164 | src_dir = os.path.join(temp_dir, "src") 165 | tests_dir = os.path.join(temp_dir, "tests") 166 | os.makedirs(src_dir) 167 | os.makedirs(tests_dir) 168 | 169 | with open(os.path.join(src_dir, "main.py"), "w") as f: 170 | f.write("main python") 171 | with open(os.path.join(tests_dir, "test_main.py"), "w") as f: 172 | f.write("test python") 173 | with open(os.path.join(temp_dir, "README.md"), "w") as f: 174 | f.write("readme") 175 | 176 | # Include all Python files but exclude tests 177 | include_patterns = ["**/*.py"] 178 | exclude_patterns = ["**/tests/**"] 179 | 180 | tree, files = generate_tree_structure( 181 | temp_dir, None, 182 | include_patterns=include_patterns, 183 | exclude_patterns=exclude_patterns 184 | ) 185 | 186 | assert "src/" in tree 187 | assert "main.py" in tree 188 | assert "tests/" not in tree or "test_main.py" not in tree 189 | assert "README.md" not in tree 190 | assert len(files) == 1 191 | 192 | def test_generate_tree_structure_show_ignored(self): 193 | """Test tree generation showing ignored files.""" 194 | with tempfile.TemporaryDirectory() as temp_dir: 195 | # Create test structure including default ignored 196 | os.makedirs(os.path.join(temp_dir, ".git")) 197 | with open(os.path.join(temp_dir, "file1.txt"), "w") as f: 198 | f.write("content1") 199 | with open(os.path.join(temp_dir, ".git", "config"), "w") as f: 200 | f.write("git config") 201 | 202 | tree, files = generate_tree_structure(temp_dir, None, show_ignored=True) 203 | 204 | assert "file1.txt" in tree 205 | assert ".git/" in tree 206 | assert "config" in tree 207 | 208 | 209 | class TestLoadGitignore: 210 | """Test gitignore loading functionality.""" 211 | 212 | def test_load_gitignore_exists(self): 213 | """Test loading an existing .gitignore file.""" 214 | with tempfile.TemporaryDirectory() as temp_dir: 215 | gitignore_path = os.path.join(temp_dir, ".gitignore") 216 | with open(gitignore_path, "w") as f: 217 | f.write("*.log\n__pycache__/\n") 218 | 219 | with patch('builtins.print'): # Suppress print output 220 | spec = load_gitignore(temp_dir) 221 | 222 | assert spec is not None 223 | assert spec.match_file("test.log") 224 | assert spec.match_file("__pycache__/") 225 | assert not spec.match_file("test.txt") 226 | 227 | def test_load_gitignore_not_exists(self): 228 | """Test behavior when .gitignore doesn't exist.""" 229 | with tempfile.TemporaryDirectory() as temp_dir: 230 | spec = load_gitignore(temp_dir) 231 | assert spec is None 232 | 233 | def test_load_gitignore_parent_directory(self): 234 | """Test loading .gitignore from parent directory.""" 235 | with tempfile.TemporaryDirectory() as temp_dir: 236 | # Create .gitignore in parent 237 | gitignore_path = os.path.join(temp_dir, ".gitignore") 238 | with open(gitignore_path, "w") as f: 239 | f.write("*.log\n") 240 | 241 | # Create subdirectory 242 | subdir = os.path.join(temp_dir, "subdir") 243 | os.makedirs(subdir) 244 | 245 | with patch('builtins.print'): # Suppress print output 246 | spec = load_gitignore(subdir) 247 | 248 | assert spec is not None 249 | assert spec.match_file("test.log") 250 | 251 | 252 | class TestIsIgnored: 253 | """Test file/directory ignore checking functionality.""" 254 | 255 | def test_is_ignored_default_ignores(self): 256 | """Test that default ignored items are detected.""" 257 | with tempfile.TemporaryDirectory() as temp_dir: 258 | git_path = os.path.join(temp_dir, ".git") 259 | cache_path = os.path.join(temp_dir, "__pycache__") 260 | 261 | assert is_ignored(git_path, None, temp_dir) 262 | assert is_ignored(cache_path, None, temp_dir) 263 | 264 | def test_is_ignored_gitignore_spec(self): 265 | """Test that gitignore patterns are respected.""" 266 | with tempfile.TemporaryDirectory() as temp_dir: 267 | file_path = os.path.join(temp_dir, "test.log") 268 | 269 | gitignore_spec = pathspec.PathSpec.from_lines( 270 | pathspec.patterns.GitWildMatchPattern, ["*.log"] 271 | ) 272 | 273 | assert is_ignored(file_path, gitignore_spec, temp_dir) 274 | 275 | def test_is_ignored_not_ignored(self): 276 | """Test that non-ignored files are not flagged.""" 277 | with tempfile.TemporaryDirectory() as temp_dir: 278 | file_path = os.path.join(temp_dir, "test.txt") 279 | 280 | gitignore_spec = pathspec.PathSpec.from_lines( 281 | pathspec.patterns.GitWildMatchPattern, ["*.log"] 282 | ) 283 | 284 | assert not is_ignored(file_path, gitignore_spec, temp_dir) 285 | 286 | 287 | class TestAddLineNumbers: 288 | """Test line number addition functionality.""" 289 | 290 | def test_add_line_numbers_simple(self): 291 | """Test adding line numbers to simple content.""" 292 | content = "line 1\nline 2\nline 3" 293 | result = add_line_numbers(content) 294 | 295 | lines = result.split('\n') 296 | assert " 1 | line 1" in lines[0] 297 | assert " 2 | line 2" in lines[1] 298 | assert " 3 | line 3" in lines[2] 299 | 300 | def test_add_line_numbers_empty(self): 301 | """Test adding line numbers to empty content.""" 302 | content = "" 303 | result = add_line_numbers(content) 304 | assert " 1 | " in result 305 | 306 | def test_add_line_numbers_single_line(self): 307 | """Test adding line numbers to single line.""" 308 | content = "single line" 309 | result = add_line_numbers(content) 310 | assert " 1 | single line" in result 311 | 312 | 313 | class TestCombineFilesWithStructure: 314 | """Test file combination functionality.""" 315 | 316 | def test_combine_files_with_structure_basic(self): 317 | """Test basic file combination.""" 318 | with tempfile.TemporaryDirectory() as temp_dir: 319 | # Create test files 320 | file1_path = os.path.join(temp_dir, "file1.txt") 321 | file2_path = os.path.join(temp_dir, "file2.txt") 322 | 323 | with open(file1_path, "w") as f: 324 | f.write("content of file 1") 325 | with open(file2_path, "w") as f: 326 | f.write("content of file 2") 327 | 328 | content, selected_files = combine_files_with_structure( 329 | temp_dir, use_git_ignore=False, interactive=False 330 | ) 331 | 332 | assert "# Project Directory Structure:" in content 333 | assert "# File: file1.txt" in content 334 | assert "content of file 1" in content 335 | assert "# File: file2.txt" in content 336 | assert "content of file 2" in content 337 | assert len(selected_files) == 2 338 | 339 | def test_combine_files_with_structure_with_patterns(self): 340 | """Test file combination with include/exclude patterns.""" 341 | with tempfile.TemporaryDirectory() as temp_dir: 342 | # Create test files 343 | with open(os.path.join(temp_dir, "script.py"), "w") as f: 344 | f.write("python code") 345 | with open(os.path.join(temp_dir, "style.css"), "w") as f: 346 | f.write("css code") 347 | with open(os.path.join(temp_dir, "data.log"), "w") as f: 348 | f.write("log data") 349 | 350 | # Include only Python files, exclude log files 351 | include_patterns = ["**/*.py"] 352 | exclude_patterns = ["**/*.log"] 353 | 354 | content, selected_files = combine_files_with_structure( 355 | temp_dir, use_git_ignore=False, 356 | include_patterns=include_patterns, 357 | exclude_patterns=exclude_patterns 358 | ) 359 | 360 | assert "script.py" in content 361 | assert "python code" in content 362 | assert "style.css" not in content 363 | assert "data.log" not in content 364 | assert len(selected_files) == 1 365 | 366 | def test_combine_files_with_structure_safe_mode_files(self): 367 | """Test safe mode file count limit.""" 368 | with tempfile.TemporaryDirectory() as temp_dir: 369 | # Create more files than safe mode allows 370 | for i in range(SAFE_MODE_MAX_FILES + 1): 371 | with open(os.path.join(temp_dir, f"file{i}.txt"), "w") as f: 372 | f.write(f"content {i}") 373 | 374 | with pytest.raises(SystemExit) as excinfo: 375 | combine_files_with_structure( 376 | temp_dir, use_git_ignore=False, safe_mode=True 377 | ) 378 | 379 | assert "Safe mode: Too many files selected" in str(excinfo.value) 380 | 381 | def test_combine_files_with_structure_safe_mode_size(self): 382 | """Test safe mode file size limit.""" 383 | with tempfile.TemporaryDirectory() as temp_dir: 384 | # Create a large file that exceeds safe mode limit 385 | large_file_path = os.path.join(temp_dir, "large_file.txt") 386 | with open(large_file_path, "w") as f: 387 | f.write("x" * (SAFE_MODE_MAX_LENGTH + 1)) 388 | 389 | with pytest.raises(SystemExit) as excinfo: 390 | combine_files_with_structure( 391 | temp_dir, use_git_ignore=False, safe_mode=True 392 | ) 393 | 394 | assert "Safe mode: Combined file size too large" in str(excinfo.value) 395 | 396 | def test_combine_files_with_structure_line_numbers(self): 397 | """Test file combination with line numbers.""" 398 | with tempfile.TemporaryDirectory() as temp_dir: 399 | file_path = os.path.join(temp_dir, "file.txt") 400 | with open(file_path, "w") as f: 401 | f.write("line 1\nline 2") 402 | 403 | content, _ = combine_files_with_structure( 404 | temp_dir, use_git_ignore=False, line_numbers=True 405 | ) 406 | 407 | assert " 1 | line 1" in content 408 | assert " 2 | line 2" in content 409 | 410 | def test_combine_files_with_structure_previous_files(self): 411 | """Test file combination with previous file selection.""" 412 | with tempfile.TemporaryDirectory() as temp_dir: 413 | # Create test files 414 | file1_path = os.path.join(temp_dir, "file1.txt") 415 | file2_path = os.path.join(temp_dir, "file2.txt") 416 | 417 | with open(file1_path, "w") as f: 418 | f.write("content 1") 419 | with open(file2_path, "w") as f: 420 | f.write("content 2") 421 | 422 | # Use only file1 from previous selection 423 | previous_files = ["file1.txt"] 424 | 425 | content, selected_files = combine_files_with_structure( 426 | temp_dir, use_git_ignore=False, previous_files=previous_files 427 | ) 428 | 429 | assert "content 1" in content 430 | assert "content 2" not in content 431 | assert len(selected_files) == 1 432 | 433 | def test_combine_files_with_structure_invalid_previous_files(self): 434 | """Test behavior with invalid previous files.""" 435 | with tempfile.TemporaryDirectory() as temp_dir: 436 | previous_files = ["nonexistent.txt"] 437 | 438 | with pytest.raises(SystemExit) as excinfo: 439 | combine_files_with_structure( 440 | temp_dir, use_git_ignore=False, previous_files=previous_files 441 | ) 442 | 443 | assert "No valid files found from previous selection" in str(excinfo.value) 444 | 445 | 446 | class TestSaveToFile: 447 | """Test file saving functionality.""" 448 | 449 | def test_save_to_file(self): 450 | """Test saving content to a file.""" 451 | with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp_file: 452 | temp_path = temp_file.name 453 | 454 | try: 455 | os.unlink(temp_path) # Remove the file so we can test creation 456 | content = "test content\nmultiple lines" 457 | 458 | save_to_file(temp_path, content) 459 | 460 | assert os.path.exists(temp_path) 461 | with open(temp_path, 'r') as f: 462 | saved_content = f.read() 463 | assert saved_content == content 464 | finally: 465 | if os.path.exists(temp_path): 466 | os.unlink(temp_path) 467 | 468 | 469 | class TestCopyToClipboard: 470 | """Test clipboard functionality.""" 471 | 472 | @patch('cli_tool_gptree.builder.pyperclip.copy') 473 | @patch('builtins.print') 474 | def test_copy_to_clipboard_success(self, mock_print, mock_copy): 475 | """Test successful clipboard copy.""" 476 | content = "test content" 477 | copy_to_clipboard(content) 478 | 479 | mock_copy.assert_called_once_with(content) 480 | mock_print.assert_called_once_with("Output copied to clipboard!") 481 | 482 | @patch('cli_tool_gptree.builder.pyperclip.copy') 483 | @patch('builtins.print') 484 | def test_copy_to_clipboard_failure(self, mock_print, mock_copy): 485 | """Test clipboard copy failure.""" 486 | from cli_tool_gptree.builder import pyperclip 487 | mock_copy.side_effect = pyperclip.PyperclipException("Test error") 488 | 489 | content = "test content" 490 | copy_to_clipboard(content) 491 | 492 | mock_copy.assert_called_once_with(content) 493 | mock_print.assert_called_once_with("Failed to copy to clipboard: Test error") 494 | 495 | 496 | class TestEstimateTokens: 497 | """Test token estimation functionality.""" 498 | 499 | def test_estimate_tokens_simple(self): 500 | """Test token estimation with simple text.""" 501 | text = "hello world test" # 16 characters 502 | tokens = estimate_tokens(text) 503 | assert tokens == 4 # 16 / 4 504 | 505 | def test_estimate_tokens_empty(self): 506 | """Test token estimation with empty text.""" 507 | text = "" 508 | tokens = estimate_tokens(text) 509 | assert tokens == 0 510 | 511 | def test_estimate_tokens_large(self): 512 | """Test token estimation with larger text.""" 513 | text = "x" * 1000 # 1000 characters 514 | tokens = estimate_tokens(text) 515 | assert tokens == 250 # 1000 / 4 -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------