diff --git a/README.md b/README.md index d2e6991..6168e4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ctxify ๐ŸŽ‰ **Turn Your Git Repo into a Clipboard-Ready Context Machine** -*Built mostly with the help of xAI's Grok modelโ€”AI-powered coding at its finest!* +*Vibe coded using xAI's based Grok model* ![GitHub release (latest by date)](https://img.shields.io/github/v/release/MQ37/ctxify?color=brightgreen) ![Code Checks](https://github.com/mq37/ctxify/actions/workflows/code_checks.yml/badge.svg) @@ -28,7 +28,9 @@ Ever wanted to: - ๐Ÿ“‹ **Clipboard Ready**: Copies the tree *and* file contents instantly. - ๐Ÿšซ **Smart Filtering**: Ignores non-code files (e.g., `uv.lock`, `.txt`) by default. - ๐Ÿ“ **Markdown Support**: Optionally include `.md` files with a flag. -- ๐ŸŽฎ **Interactive Mode**: Pick files with fuzzy tab autocompletion. +- ๐ŸŽฎ **Interactive Modes**: + - **Selection Mode**: Pick files with fuzzy tab autocompletion. + - **Exclusion Mode**: Exclude files or directories interactively. - ๐ŸŒณ **Structure-Only Mode**: Output just the tree, no contents. - ๐Ÿ“ **Token Count**: Estimates tokens (1 token โ‰ˆ 4 chars) for the full output. @@ -74,6 +76,10 @@ ctxify ```bash ctxify -i ``` +- `-e` / `--exclude`: Exclude files or directories interactively with tab autocompletion. + ```bash + ctxify -e + ``` - `-s` / `--structure`: Output only the project structure, no contents. ```bash ctxify -s diff --git a/pyproject.toml b/pyproject.toml index d93b2c3..a64a037 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ctxify" -version = "0.1.8" +version = "0.2.0" description = "A tool to print git repository files with tree structure" readme = "README.md" requires-python = ">=3.8" diff --git a/src/ctxify/__init__.py b/src/ctxify/__init__.py index c3bb296..7fd229a 100644 --- a/src/ctxify/__init__.py +++ b/src/ctxify/__init__.py @@ -1 +1 @@ -__version__ = '0.1.8' +__version__ = '0.2.0' diff --git a/src/ctxify/cli.py b/src/ctxify/cli.py index 5049a7c..e513ebe 100644 --- a/src/ctxify/cli.py +++ b/src/ctxify/cli.py @@ -1,13 +1,15 @@ +import sys + import click -from ctxify.main import ( - copy_to_clipboard, - interactive_file_selection, - print_git_contents, -) +from ctxify.content import print_git_contents +from ctxify.interactive import interactive_file_exclusion, interactive_file_selection +from ctxify.utils import GitRepositoryError, copy_to_clipboard + +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) -@click.command() +@click.command(context_settings=CONTEXT_SETTINGS) @click.argument('directory', default='.', type=click.Path(exists=True, file_okay=False)) @click.option( '--md', '-md', is_flag=True, help='Include README and other .md files in output' @@ -18,23 +20,36 @@ is_flag=True, help='Interactively select files to include with tab autocompletion', ) +@click.option( + '-e', + '--exclude', + is_flag=True, + help='Interactively select files to exclude with tab autocompletion', +) @click.option( '-s', '--structure', is_flag=True, help='Output only the project structure without file contents', ) -def main(directory: str, md: bool, interactive: bool, structure: bool) -> None: +def main( + directory: str, md: bool, interactive: bool, exclude: bool, structure: bool +) -> None: """A tool to print all tracked files in a git repository directory with tree structure and copy to clipboard.""" - output: str - if interactive: - output = interactive_file_selection(directory, include_md=md) - else: - output = print_git_contents( - root_dir=directory, include_md=md, structure_only=structure - ) - if copy_to_clipboard(output): - click.echo('Project context copied to clipboard!') + try: + output: str + if interactive: + output = interactive_file_selection(directory, include_md=md) + elif exclude: + output = interactive_file_exclusion(directory, include_md=md) + else: + output = print_git_contents( + root_dir=directory, include_md=md, structure_only=structure + ) + if copy_to_clipboard(output): + click.echo('Project context copied to clipboard!') + except GitRepositoryError: + sys.exit(1) if __name__ == '__main__': diff --git a/src/ctxify/content.py b/src/ctxify/content.py new file mode 100644 index 0000000..46548cb --- /dev/null +++ b/src/ctxify/content.py @@ -0,0 +1,71 @@ +from pathlib import Path +from typing import List, Optional + +from ctxify.utils import ( + GitRepositoryError, + check_git_repo, + estimate_tokens, + get_git_files, + print_filtered_tree, +) + + +def print_git_contents( + root_dir: str = '.', + include_md: bool = False, + structure_only: bool = False, + excluded_items: Optional[List[str]] = None, +) -> str: + """Build output for clipboard, print tree with all files and token count to stdout.""" + if not check_git_repo(root_dir): + print( + f'Error: {root_dir} is not within a git repository. This tool requires a git repository.' + ) + raise GitRepositoryError(f'{root_dir} is not within a git repository') + + output_lines: List[str] = [] + tree_lines: List[str] = [] + + target_dir = Path(root_dir).resolve() + errors, all_files, code_files = get_git_files(root_dir, include_md=include_md) + if errors: + tree_lines.extend(errors) + print('\n'.join(tree_lines)) + return '\n'.join(tree_lines) + + if excluded_items: + excluded_files = set() + for item in excluded_items: + if item in all_files: + excluded_files.add(item) + else: + excluded_files.update(f for f in all_files if f.startswith(item + '/')) + all_files = [f for f in all_files if f not in excluded_files] + code_files = [f for f in code_files if f not in excluded_files] + + tree_lines.append(f'\nFiles Included in Context (from {root_dir}):') + print_filtered_tree(all_files, tree_lines) + output_lines.extend(tree_lines) + + if not structure_only: + output_lines.append('\n' + '-' * 50 + '\n') + for file_path in code_files: + full_path = target_dir / file_path + if full_path.is_file(): + output_lines.append(f'{file_path}:') + try: + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + output_lines.append(content) + except Exception as e: + output_lines.append(f'Error reading file: {e}') + output_lines.append('') + + full_output = '\n'.join(output_lines) + token_count = estimate_tokens(full_output) + token_info = ( + f'\nApproximate token count: {token_count} (based on 1 token โ‰ˆ 4 chars)' + ) + tree_lines.append(token_info) + print('\n'.join(tree_lines)) + return full_output diff --git a/src/ctxify/interactive.py b/src/ctxify/interactive.py new file mode 100644 index 0000000..405575f --- /dev/null +++ b/src/ctxify/interactive.py @@ -0,0 +1,175 @@ +from pathlib import Path +from typing import List + +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import FuzzyWordCompleter + +from ctxify.utils import ( + GitRepositoryError, + check_git_repo, + estimate_tokens, + get_git_files, + print_filtered_tree, +) + + +def interactive_file_selection(root_dir: str = '.', include_md: bool = False) -> str: + """Interactively select files or directories to include with fuzzy tab autocompletion.""" + if not check_git_repo(root_dir): + print( + f'Error: {root_dir} is not within a git repository. This tool requires a git repository.' + ) + raise GitRepositoryError(f'{root_dir} is not within a git repository') + + output_lines: List[str] = [] + tree_lines: List[str] = [] + + errors, all_files, code_files = get_git_files(root_dir, include_md=include_md) + if errors: + tree_lines.extend(errors) + print('\n'.join(tree_lines)) + return '\n'.join(tree_lines) + + all_dirs = { + str(parent) + for f in all_files + for parent in Path(f).parents + if str(parent) != '.' + } + completion_options = sorted(all_files + list(all_dirs)) + + completer = FuzzyWordCompleter(completion_options) + session = PromptSession(completer=completer, complete_while_typing=True) + + tree_lines.append( + f'\nFiles and Directories Available in Context (from {root_dir}):' + ) + print_filtered_tree(all_files, tree_lines) + print('\n'.join(tree_lines)) + print('\nEnter file or directory paths to include (press Enter twice to finish):') + + selected_items: List[str] = [] + while True: + try: + input_path = session.prompt('> ') + if not input_path: + if not selected_items: + continue + break + if input_path in completion_options: + if input_path not in selected_items: + selected_items.append(input_path) + print(f'Added: {input_path}') + else: + print(f'Already added: {input_path}') + else: + print(f'Path not found: {input_path}') + except KeyboardInterrupt: + break + + output_lines.extend(tree_lines) + output_lines.append('\n' + '-' * 50 + '\n') + target_dir = Path(root_dir).resolve() + + for item_path in selected_items: + full_path = target_dir / item_path + if full_path.is_file(): + output_lines.append(f'{item_path}:') + try: + with open(full_path, 'r', encoding='utf-8') as f: + content = f.read() + output_lines.append(content) + except Exception as e: + output_lines.append(f'Error reading file: {e}') + output_lines.append('') + elif full_path.is_dir(): + dir_files = [ + f for f in all_files if f.startswith(item_path + '/') or f == item_path + ] + if not dir_files: + output_lines.append(f'No tracked files found in {item_path}') + continue + for file_path in dir_files: + full_file_path = target_dir / file_path + if full_file_path.is_file(): + output_lines.append(f'{file_path}:') + try: + with open(full_file_path, 'r', encoding='utf-8') as f: + content = f.read() + output_lines.append(content) + except Exception as e: + output_lines.append(f'Error reading file: {e}') + output_lines.append('') + + full_output = '\n'.join(output_lines) + token_count = estimate_tokens(full_output) + token_info = ( + f'\nApproximate token count: {token_count} (based on 1 token โ‰ˆ 4 chars)' + ) + tree_lines.append(token_info) + print('\n'.join(tree_lines[len(tree_lines) - 1 :])) + return full_output + + +def interactive_file_exclusion(root_dir: str = '.', include_md: bool = False) -> str: + """Interactively select files or directories to exclude with fuzzy tab autocompletion.""" + if not check_git_repo(root_dir): + print( + f'Error: {root_dir} is not within a git repository. This tool requires a git repository.' + ) + raise GitRepositoryError(f'{root_dir} is not within a git repository') + + tree_lines: List[str] = [] + + errors, all_files, code_files = get_git_files(root_dir, include_md=include_md) + if errors: + tree_lines.extend(errors) + print('\n'.join(tree_lines)) + return '\n'.join(tree_lines) + + all_dirs = { + str(parent) + for f in all_files + for parent in Path(f).parents + if str(parent) != '.' + } + completion_options = sorted(all_files + list(all_dirs)) + + completer = FuzzyWordCompleter(completion_options) + session = PromptSession(completer=completer, complete_while_typing=True) + + tree_lines.append( + f'\nFiles and Directories Available in Context (from {root_dir}):' + ) + print_filtered_tree(all_files, tree_lines) + print('\n'.join(tree_lines)) + print('\nEnter file or directory paths to exclude (press Enter twice to finish):') + + excluded_items: List[str] = [] + while True: + try: + input_path = session.prompt('> ') + if not input_path: + if not excluded_items: + continue + break + if input_path in completion_options: + if input_path not in excluded_items: + excluded_items.append(input_path) + print(f'Excluded: {input_path}') + else: + print(f'Already excluded: {input_path}') + else: + print(f'Path not found: {input_path}') + except KeyboardInterrupt: + break + + from .content import print_git_contents + + output = print_git_contents( + root_dir=root_dir, + include_md=include_md, + structure_only=False, + excluded_items=excluded_items, + ) + return output diff --git a/src/ctxify/main.py b/src/ctxify/main.py deleted file mode 100644 index 579da1c..0000000 --- a/src/ctxify/main.py +++ /dev/null @@ -1,325 +0,0 @@ -import platform -import subprocess -import sys -from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union - -from prompt_toolkit import PromptSession -from prompt_toolkit.completion import FuzzyWordCompleter - -# Files/extensions to skip (non-code files) for content inclusion -IGNORE_FILES = { - 'package-lock.json', - 'poetry.lock', - 'uv.lock', - 'Pipfile.lock', - 'yarn.lock', - '.gitignore', - '.gitattributes', - '.editorconfig', - '.prettierrc', - '.eslintrc', - 'LICENSE', - 'CHANGELOG', - 'CONTRIBUTING', - '.env', # Added to explicitly ignore .env files -} - -IGNORE_EXTENSIONS = { - '.json', - '.yaml', - '.yml', - '.toml', - '.txt', - '.log', - '.lock', -} - - -def check_git_repo(root_dir: str) -> bool: - """Check if the given directory is within a git repository.""" - try: - subprocess.check_output( - ['git', 'rev-parse', '--show-toplevel'], - text=True, - cwd=root_dir, - stderr=subprocess.STDOUT, - ) - return True - except subprocess.CalledProcessError: - return False - - -def print_filtered_tree( - files: List[str], output_lines: Optional[List[str]] = None -) -> List[str]: - """Builds a tree structure from a list of file paths""" - if output_lines is None: - output_lines = [] - tree: Dict[str, Union[None, Dict]] = {} - for file_path in files: - parts = file_path.split('/') - current = tree - for part in parts[:-1]: - if current is not None: - current = current.setdefault(part, {}) - if current is not None: - current[parts[-1]] = None - - def render_tree(node: Dict[str, Union[None, Dict]], prefix: str = '') -> None: - if not isinstance(node, dict): - return - items = sorted(node.keys()) - for i, item in enumerate(items): - is_last = i == len(items) - 1 - output_lines.append(f'{prefix}{"โ””โ”€โ”€ " if is_last else "โ”œโ”€โ”€ "}{item}') - next_node = node[item] - if isinstance(next_node, dict): - render_tree(next_node, prefix + (' ' if is_last else 'โ”‚ ')) - - render_tree(tree) - return output_lines - - -def get_git_files( - root_dir: str, include_md: bool = False -) -> Tuple[List[str], List[str], List[str]]: - """Get all tracked files from a specific directory within a git repo using git ls-files""" - target_dir = Path(root_dir).resolve() - try: - # Resolve the repo root - repo_root = Path( - subprocess.check_output( - ['git', 'rev-parse', '--show-toplevel'], text=True, cwd=target_dir - ).strip() - ) - - # Ensure the target directory is within the repo - if not str(target_dir).startswith(str(repo_root)): - return ( - [f'Error: Directory {root_dir} is outside the git repository'], - [], - [], - ) - - # Get all tracked files using git ls-files - all_files = subprocess.check_output( - ['git', 'ls-files'], cwd=repo_root, text=True - ).splitlines() - - # Filter files to those under the target directory - rel_path = ( - target_dir.relative_to(repo_root) if target_dir != repo_root else Path('.') - ) - rel_str = str(rel_path) - dir_files = [] - for f in all_files: - if rel_str == '.' or f.startswith(rel_str + '/') or f == rel_str: - if rel_str != '.' and f.startswith(rel_str + '/'): - dir_files.append(f[len(rel_str) + 1 :]) - else: - dir_files.append(f) - - # Filter code files - code_files = [ - f - for f in dir_files - if not ( - f in IGNORE_FILES - or any(f.endswith(ext) for ext in IGNORE_EXTENSIONS) - or (not include_md and (f.endswith('.md') or 'README' in f)) - ) - ] - return [], sorted(dir_files), sorted(code_files) - except subprocess.CalledProcessError as e: - return [f'Error accessing git repository: {e}'], [], [] - except Exception as e: - return [f'Error processing directory: {e}'], [], [] - - -def copy_to_clipboard(text: str) -> bool: - """Copy text to system clipboard using pbcopy (macOS) or xclip (Linux)""" - system = platform.system().lower() - try: - if system == 'darwin': # macOS - subprocess.run(['pbcopy'], input=text.encode('utf-8'), check=True) - elif system == 'linux': # Linux - subprocess.run( - ['xclip', '-selection', 'clipboard'], - input=text.encode('utf-8'), - check=True, - ) - else: - print(f'Warning: Clipboard operations not supported on {platform.system()}') - return False - return True - except subprocess.CalledProcessError: - cmd = 'pbcopy' if system == 'darwin' else 'xclip' - print(f'Warning: Failed to copy to clipboard ({cmd} error)') - return False - except FileNotFoundError: - if system == 'darwin': - print( - 'Warning: pbcopy not found. This is unexpected as it should be built into macOS' - ) - else: - print( - "Warning: xclip not installed. Install it with 'sudo apt install xclip'" - ) - return False - - -def estimate_tokens(text: str) -> int: - """Estimate token count using 1 token โ‰ˆ 4 characters""" - char_count = len(text) - return char_count // 4 - - -def interactive_file_selection(root_dir: str = '.', include_md: bool = False) -> str: - """Interactively select files or directories to include with fuzzy tab autocompletion""" - if not check_git_repo(root_dir): - print( - f'Error: {root_dir} is not within a git repository. This tool requires a git repository.' - ) - sys.exit(1) - - output_lines: List[str] = [] - tree_lines: List[str] = [] - - errors, all_files, code_files = get_git_files(root_dir, include_md=include_md) - if errors: - tree_lines.extend(errors) - print('\n'.join(tree_lines)) - return '\n'.join(tree_lines) - - # Add directories to completion options - all_dirs = set() - for f in all_files: - path = Path(f) - for parent in path.parents: - if str(parent) != '.': - all_dirs.add(str(parent)) - completion_options = sorted(all_files + list(all_dirs)) - - completer = FuzzyWordCompleter(completion_options) - session = PromptSession(completer=completer, complete_while_typing=True) - - tree_lines.append(f'\nFiles and Directories Available in Context (from {root_dir}):') - print_filtered_tree(all_files, tree_lines) - print('\n'.join(tree_lines)) - print('\nEnter file or directory paths to include (press Enter twice to finish):') - - selected_items: List[str] = [] - while True: - try: - input_path = session.prompt('> ') - if not input_path: - if not selected_items: - continue - break - if input_path in completion_options: - if input_path not in selected_items: - selected_items.append(input_path) - print(f'Added: {input_path}') - else: - print(f'Already added: {input_path}') - else: - print(f'Path not found: {input_path}') - except KeyboardInterrupt: - break - - output_lines.extend(tree_lines) - output_lines.append('\n' + '-' * 50 + '\n') - target_dir = Path(root_dir).resolve() - - for item_path in selected_items: - full_path = target_dir / item_path - if full_path.is_file(): - # Handle individual file - output_lines.append(f'{item_path}:') - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - output_lines.append(content) - except Exception as e: - output_lines.append(f'Error reading file: {e}') - output_lines.append('') - elif full_path.is_dir(): - # Handle directory - output_lines.append(f'\nDirectory {item_path} contents:') - dir_files = [f for f in all_files if f.startswith(item_path + '/') or f == item_path] - if not dir_files: - output_lines.append(f'No tracked files found in {item_path}') - continue - # Print directory structure - print_filtered_tree(dir_files, output_lines) - output_lines.append('\nFile contents:') - for file_path in dir_files: - full_file_path = target_dir / file_path - if full_file_path.is_file(): - output_lines.append(f'\n{file_path}:') - try: - with open(full_file_path, 'r', encoding='utf-8') as f: - content = f.read() - output_lines.append(content) - except Exception as e: - output_lines.append(f'Error reading file: {e}') - output_lines.append('') - - full_output = '\n'.join(output_lines) - token_count = estimate_tokens(full_output) - token_info = ( - f'\nApproximate token count: {token_count} (based on 1 token โ‰ˆ 4 chars)' - ) - tree_lines.append(token_info) - print('\n'.join(tree_lines[len(tree_lines) - 1 :])) - - return full_output - -def print_git_contents( - root_dir: str = '.', include_md: bool = False, structure_only: bool = False -) -> str: - """Build output for clipboard, print tree with all files and token count to stdout""" - if not check_git_repo(root_dir): - print( - f'Error: {root_dir} is not within a git repository. This tool requires a git repository.' - ) - sys.exit(1) - - output_lines: List[str] = [] - tree_lines: List[str] = [] - - target_dir = Path(root_dir).resolve() - errors, all_files, code_files = get_git_files(root_dir, include_md=include_md) - if errors: - tree_lines.extend(errors) - print('\n'.join(tree_lines)) - return '\n'.join(tree_lines) - - tree_lines.append(f'\nFiles Included in Context (from {root_dir}):') - print_filtered_tree(all_files, tree_lines) - output_lines.extend(tree_lines) - - if not structure_only: - output_lines.append('\n' + '-' * 50 + '\n') - for file_path in code_files: - full_path = target_dir / file_path - if full_path.is_file(): - output_lines.append(f'{file_path}:') - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - output_lines.append(content) - except Exception as e: - output_lines.append(f'Error reading file: {e}') - output_lines.append('') - - full_output = '\n'.join(output_lines) - token_count = estimate_tokens(full_output) - token_info = ( - f'\nApproximate token count: {token_count} (based on 1 token โ‰ˆ 4 chars)' - ) - tree_lines.append(token_info) - - print('\n'.join(tree_lines)) - return full_output diff --git a/src/ctxify/utils.py b/src/ctxify/utils.py new file mode 100644 index 0000000..a773711 --- /dev/null +++ b/src/ctxify/utils.py @@ -0,0 +1,168 @@ +import platform +import subprocess +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +# Files/extensions to skip (non-code files) for content inclusion +IGNORE_FILES = { + 'package-lock.json', + 'poetry.lock', + 'uv.lock', + 'Pipfile.lock', + 'yarn.lock', + '.gitignore', + '.gitattributes', + '.editorconfig', + '.prettierrc', + '.eslintrc', + 'LICENSE', + 'CHANGELOG', + 'CONTRIBUTING', + '.env', +} + +IGNORE_EXTENSIONS = { + '.json', + '.yaml', + '.yml', + '.toml', + '.txt', + '.log', + '.lock', +} + + +class GitRepositoryError(Exception): + """Raised when a directory is not within a Git repository.""" + + pass + + +def check_git_repo(root_dir: str) -> bool: + """Check if the given directory is within a git repository.""" + try: + subprocess.check_output( + ['git', 'rev-parse', '--show-toplevel'], + text=True, + cwd=root_dir, + stderr=subprocess.STDOUT, + ) + return True + except subprocess.CalledProcessError: + return False + + +def get_git_files( + root_dir: str, include_md: bool = False +) -> Tuple[List[str], List[str], List[str]]: + """Get all tracked files from a specific directory within a git repo using git ls-files.""" + target_dir = Path(root_dir).resolve() + try: + repo_root = Path( + subprocess.check_output( + ['git', 'rev-parse', '--show-toplevel'], text=True, cwd=target_dir + ).strip() + ) + if not str(target_dir).startswith(str(repo_root)): + return ( + [f'Error: Directory {root_dir} is outside the git repository'], + [], + [], + ) + all_files = subprocess.check_output( + ['git', 'ls-files'], cwd=repo_root, text=True + ).splitlines() + rel_path = ( + target_dir.relative_to(repo_root) if target_dir != repo_root else Path('.') + ) + rel_str = str(rel_path) + dir_files = [ + f[len(rel_str) + 1 :] + if rel_str != '.' and f.startswith(rel_str + '/') + else f + for f in all_files + if rel_str == '.' or f.startswith(rel_str + '/') or f == rel_str + ] + code_files = [ + f + for f in dir_files + if not ( + f in IGNORE_FILES + or any(f.endswith(ext) for ext in IGNORE_EXTENSIONS) + or (not include_md and (f.endswith('.md') or 'README' in f)) + ) + ] + return [], sorted(dir_files), sorted(code_files) + except subprocess.CalledProcessError as e: + return [f'Error accessing git repository: {e}'], [], [] + except Exception as e: + return [f'Error processing directory: {e}'], [], [] + + +def print_filtered_tree( + files: List[str], output_lines: Optional[List[str]] = None +) -> List[str]: + """Builds a tree structure from a list of file paths.""" + if output_lines is None: + output_lines = [] + tree: Dict[str, Union[None, Dict]] = {} + for file_path in files: + parts = file_path.split('/') + current = tree + for part in parts[:-1]: + if current is not None: + current = current.setdefault(part, {}) + if current is not None: + current[parts[-1]] = None + + def render_tree(node: Dict[str, Union[None, Dict]], prefix: str = '') -> None: + if not isinstance(node, dict): + return + items = sorted(node.keys()) + for i, item in enumerate(items): + is_last = i == len(items) - 1 + output_lines.append(f'{prefix}{"โ””โ”€โ”€ " if is_last else "โ”œโ”€โ”€ "}{item}') + next_node = node[item] + if isinstance(next_node, dict): + render_tree(next_node, prefix + (' ' if is_last else 'โ”‚ ')) + + render_tree(tree) + return output_lines + + +def estimate_tokens(text: str) -> int: + """Estimate token count using 1 token โ‰ˆ 4 characters.""" + char_count = len(text) + return char_count // 4 + + +def copy_to_clipboard(text: str) -> bool: + """Copy text to system clipboard using pbcopy (macOS) or xclip (Linux).""" + system = platform.system().lower() + try: + if system == 'darwin': # macOS + subprocess.run(['pbcopy'], input=text.encode('utf-8'), check=True) + elif system == 'linux': # Linux + subprocess.run( + ['xclip', '-selection', 'clipboard'], + input=text.encode('utf-8'), + check=True, + ) + else: + print(f'Warning: Clipboard operations not supported on {platform.system()}') + return False + return True + except subprocess.CalledProcessError: + cmd = 'pbcopy' if system == 'darwin' else 'xclip' + print(f'Warning: Failed to copy to clipboard ({cmd} error)') + return False + except FileNotFoundError: + if system == 'darwin': + print( + 'Warning: pbcopy not found. This is unexpected as it should be built into macOS' + ) + else: + print( + "Warning: xclip not installed. Install it with 'sudo apt install xclip'" + ) + return False diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a8c58f..a543b95 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -46,3 +46,19 @@ def test_cli_structure_only(): mock_print.assert_called_once_with( root_dir='.', include_md=False, structure_only=True ) + + +def test_cli_with_exclude_flag(): + runner = CliRunner() + with patch('ctxify.cli.copy_to_clipboard', return_value=True): + with patch('ctxify.cli.interactive_file_exclusion') as mock_exclude: + result = runner.invoke(main, ['.', '-e']) + assert result.exit_code == 0 + mock_exclude.assert_called_once_with('.', include_md=False) + + +def test_cli_help_with_h(): + runner = CliRunner() + result = runner.invoke(main, ['-h']) + assert result.exit_code == 0 + assert 'A tool to print all tracked files' in result.output diff --git a/tests/test_main.py b/tests/test_content.py similarity index 77% rename from tests/test_main.py rename to tests/test_content.py index 2846328..782d5aa 100644 --- a/tests/test_main.py +++ b/tests/test_content.py @@ -2,13 +2,13 @@ import sys from unittest.mock import MagicMock, patch -from ctxify.main import ( +from ctxify.content import print_git_contents +from ctxify.utils import ( check_git_repo, copy_to_clipboard, estimate_tokens, get_git_files, print_filtered_tree, - print_git_contents, ) @@ -79,11 +79,29 @@ def test_estimate_tokens(): def test_print_git_contents_structure_only(tmp_path, mocker): - mocker.patch('ctxify.main.check_git_repo', return_value=True) + mocker.patch('ctxify.content.check_git_repo', return_value=True) mocker.patch( - 'ctxify.main.get_git_files', return_value=([], ['file1.py'], ['file1.py']) + 'ctxify.content.get_git_files', + return_value=([], ['file1.py'], ['file1.py']), ) mocker.patch('sys.stdout', new_callable=lambda: sys.stdout) # Suppress print output = print_git_contents(str(tmp_path), structure_only=True) assert 'file1.py' in output assert 'โ””โ”€โ”€ file1.py' in output # Check tree structure + + +def test_print_git_contents_with_exclusions(tmp_path, mocker): + mocker.patch('ctxify.content.check_git_repo', return_value=True) + mocker.patch( + 'ctxify.content.get_git_files', + return_value=([], ['file1.py', 'file2.txt'], ['file1.py']), + ) + mocker.patch('sys.stdout', new_callable=lambda: sys.stdout) # Suppress print + output = print_git_contents( + str(tmp_path), + include_md=False, + structure_only=False, + excluded_items=['file1.py'], + ) + assert 'file1.py' not in output + assert 'file2.txt' in output diff --git a/uv.lock b/uv.lock index 9f2eef9..84149fd 100644 --- a/uv.lock +++ b/uv.lock @@ -25,7 +25,7 @@ wheels = [ [[package]] name = "ctxify" -version = "0.1.7" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "click" },