diff --git a/examples/example_repo_snapshot_2024-06-08-09-56-58-UTC.txt b/examples/example_repo_snapshot_2024-06-08-09-56-58-UTC.txt new file mode 100644 index 0000000..ac2a4c3 --- /dev/null +++ b/examples/example_repo_snapshot_2024-06-08-09-56-58-UTC.txt @@ -0,0 +1,414 @@ +Directory: repo-to-text + +Directory Structure: +``` +. +├── .gitignore +├── LICENSE +├── README.md +├── repo_to_text +│   ├── repo_to_text/__init__.py +│   └── repo_to_text/main.py +├── requirements.txt +├── setup.py +└── tests + ├── tests/__init__.py + └── tests/test_main.py +``` + +Contents of LICENSE: +``` +MIT License + +Copyright (c) 2024 Kirill Markin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +``` + +Contents of requirements.txt: +``` +setuptools==70.0.0 +pathspec==0.12.1 +pytest==8.2.2 +argparse==1.4.0 +pyperclip==1.8.2 + +``` + +Contents of README.md: +``` +# repo-to-text + +`repo-to-text` is an open-source project that converts the structure and contents of a directory (repository) into a single text file. By executing a simple command in the terminal, this tool generates a text representation of the directory, including the output of the `tree` command and the contents of each file, formatted for easy reading and sharing. + +## Features + +- Generates a text representation of a directory's structure. +- Includes the output of the `tree` command. +- Saves the contents of each file, encapsulated in markdown code blocks. +- Copies the generated text representation to the clipboard for easy sharing. +- Easy to install and use via `pip` and Homebrew. + +## Installation + +### Using pip + +To install `repo-to-text` via pip, run the following command: + +```bash +pip install git+https://github.com/yourusername/repo-to-text.git +``` + +### Using Homebrew + +To install `repo-to-text` via Homebrew, run the following command: + +```bash +brew install yourusername/repo-to-text +``` + +### Install Locally + +To install `repo-to-text` locally for development, follow these steps: + +1. Clone the repository: + + ```bash + git clone https://github.com/yourusername/repo-to-text.git + cd repo-to-text + ``` + +2. Install the package locally: + + ```bash + pip install -e . + ``` + +## Usage + +After installation, you can use the `repo-to-text` command in your terminal. Navigate to the directory you want to convert and run: + +```bash +repo-to-text +``` + +This will create a file named repo_snapshot.txt in the current directory with the text representation of the repository. The contents of this file will also be copied to your clipboard for easy sharing. + +## Enabling Debug Logging + +By default, repo-to-text runs with INFO logging level. To enable DEBUG logging, use the --debug flag: + +```bash +repo-to-text --debug +``` + +## Example Output + +The generated text file will include the directory structure and contents of each file. For example: + +``` +. +├── README.md +├── repo_to_text +│ ├── __init__.py +│ └── main.py +├── requirements.txt +├── setup.py +└── tests + ├── __init__.py + └── test_main.py + +README.md +``` +``` +# Contents of README.md +... +``` +``` +# Contents of repo_to_text/__init__.py +... +``` +... + +## Running Tests + +To run the tests, use the following command: + +```bash +pytest +``` + +Make sure you have `pytest` installed. If not, you can install it using: + +```bash +pip install pytest +``` + +## Uninstall Locally + +To uninstall the locally installed package, run the following command from the directory where the repository is located: + +```bash +pip uninstall repo-to-text +``` + +## Contributing + +Contributions are welcome! If you have any suggestions or find a bug, please open an issue or submit a pull request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contact + +For any inquiries or feedback, please contact [yourname](mailto:youremail@example.com). + +``` + +Contents of setup.py: +``` +from setuptools import setup, find_packages + +with open('requirements.txt') as f: + required = f.read().splitlines() + +setup( + name='repo-to-text', + version='0.1', + packages=find_packages(), + install_requires=required, + entry_points={ + 'console_scripts': [ + 'repo-to-text=repo_to_text.main:main', + ], + }, +) +``` + +Contents of tests/__init__.py: +``` + +``` + +Contents of tests/test_main.py: +``` +import os +import subprocess +import pytest +import time + +def test_repo_to_text(): + # Remove any existing snapshot files to avoid conflicts + for file in os.listdir('.'): + if file.startswith('repo_snapshot_') and file.endswith('.txt'): + os.remove(file) + + # Run the repo-to-text command + result = subprocess.run(['repo-to-text'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + # Assert that the command ran without errors + assert result.returncode == 0, f"Command failed with error: {result.stderr.decode('utf-8')}" + + # Check for the existence of the new snapshot file + snapshot_files = [f for f in os.listdir('.') if f.startswith('repo_snapshot_') and f.endswith('.txt')] + assert len(snapshot_files) == 1, "No snapshot file created or multiple files created" + + # Verify that the snapshot file is not empty + with open(snapshot_files[0], 'r') as f: + content = f.read() + assert len(content) > 0, "Snapshot file is empty" + + # Clean up the generated snapshot file + os.remove(snapshot_files[0]) + +if __name__ == "__main__": + pytest.main() + +``` + +Contents of repo_to_text/__init__.py: +``` + +``` + +Contents of repo_to_text/main.py: +``` +import os +import subprocess +import pathspec +import logging +import argparse +from datetime import datetime +import pyperclip + +def setup_logging(debug=False): + logging_level = logging.DEBUG if debug else logging.INFO + logging.basicConfig(level=logging_level, format='%(asctime)s - %(levelname)s - %(message)s') + +def get_tree_structure(path='.', gitignore_spec=None) -> str: + logging.debug(f'Generating tree structure for path: {path}') + result = subprocess.run(['tree', '-a', '-f', '--noreport', path], stdout=subprocess.PIPE) + tree_output = result.stdout.decode('utf-8') + logging.debug(f'Tree output generated: {tree_output}') + + if not gitignore_spec: + logging.debug('No .gitignore specification found') + return tree_output + + logging.debug('Filtering tree output based on .gitignore specification') + filtered_lines = [] + for line in tree_output.splitlines(): + parts = line.strip().split() + if parts: + full_path = parts[-1] + relative_path = os.path.relpath(full_path, path) + if not gitignore_spec.match_file(relative_path) and not is_ignored_path(relative_path): + filtered_lines.append(line.replace('./', '', 1)) + + logging.debug('Tree structure filtering complete') + return '\n'.join(filtered_lines) + +def load_gitignore(path='.'): + gitignore_path = os.path.join(path, '.gitignore') + if os.path.exists(gitignore_path): + logging.debug(f'Loading .gitignore from path: {gitignore_path}') + with open(gitignore_path, 'r') as f: + return pathspec.PathSpec.from_lines('gitwildmatch', f) + logging.debug('.gitignore not found') + return None + +def is_ignored_path(file_path: str) -> bool: + ignored_dirs = ['.git'] + ignored_files_prefix = ['repo_snapshot_'] + is_ignored_dir = any(ignored in file_path for ignored in ignored_dirs) + is_ignored_file = any(file_path.startswith(prefix) for prefix in ignored_files_prefix) + result = is_ignored_dir or is_ignored_file + if result: + logging.debug(f'Path ignored: {file_path}') + return result + +def remove_empty_dirs(tree_output: str, path='.') -> str: + logging.debug('Removing empty directories from tree output') + lines = tree_output.splitlines() + non_empty_dirs = set() + filtered_lines = [] + + for line in lines: + parts = line.strip().split() + if parts: + full_path = parts[-1] + if os.path.isdir(full_path) and not any(os.path.isfile(os.path.join(full_path, f)) for f in os.listdir(full_path)): + logging.debug(f'Directory is empty and will be removed: {full_path}') + continue + non_empty_dirs.add(os.path.dirname(full_path)) + filtered_lines.append(line) + + final_lines = [] + for line in filtered_lines: + parts = line.strip().split() + if parts: + full_path = parts[-1] + if os.path.isdir(full_path) and full_path not in non_empty_dirs: + logging.debug(f'Directory is empty and will be removed: {full_path}') + continue + final_lines.append(line) + + logging.debug('Empty directory removal complete') + return '\n'.join(final_lines) + +def save_repo_to_text(path='.', output_dir=None) -> str: + logging.debug(f'Starting to save repo structure to text for path: {path}') + gitignore_spec = load_gitignore(path) + tree_structure = get_tree_structure(path, gitignore_spec) + tree_structure = remove_empty_dirs(tree_structure, path) + + # Add timestamp to the output file name with a descriptive name + timestamp = datetime.utcnow().strftime('%Y-%m-%d-%H-%M-%S-UTC') + output_file = f'repo_snapshot_{timestamp}.txt' + + # Determine the full path to the output file + if output_dir: + if not os.path.exists(output_dir): + os.makedirs(output_dir) + output_file = os.path.join(output_dir, output_file) + + with open(output_file, 'w') as file: + project_name = os.path.basename(os.path.abspath(path)) + file.write(f'Directory: {project_name}\n\n') + file.write('Directory Structure:\n') + file.write('```\n.\n') + + # Insert .gitignore if it exists + if os.path.exists(os.path.join(path, '.gitignore')): + file.write('├── .gitignore\n') + + file.write(tree_structure + '\n' + '```\n') + logging.debug('Tree structure written to file') + + for root, _, files in os.walk(path): + for filename in files: + file_path = os.path.join(root, filename) + relative_path = os.path.relpath(file_path, path) + + if is_ignored_path(file_path) or (gitignore_spec and gitignore_spec.match_file(relative_path)): + continue + + relative_path = relative_path.replace('./', '', 1) + + file.write(f'\nContents of {relative_path}:\n') + file.write('```\n') + try: + with open(file_path, 'r', encoding='utf-8') as f: + file.write(f.read()) + except UnicodeDecodeError: + logging.error(f'Could not decode file contents: {file_path}') + file.write('[Could not decode file contents]\n') + file.write('\n```\n') + + file.write('\n') + logging.debug('Repository contents written to file') + + # Read the contents of the generated file + with open(output_file, 'r') as file: + repo_text = file.read() + + # Copy the contents to the clipboard + pyperclip.copy(repo_text) + logging.debug('Repository structure and contents copied to clipboard') + + return output_file + +def main(): + parser = argparse.ArgumentParser(description='Convert repository structure and contents to text') + parser.add_argument('--debug', action='store_true', help='Enable debug logging') + parser.add_argument('--output-dir', type=str, help='Directory to save the output file') + args = parser.parse_args() + + setup_logging(debug=args.debug) + logging.debug('repo-to-text script started') + save_repo_to_text(output_dir=args.output_dir) + logging.debug('repo-to-text script finished') + +if __name__ == '__main__': + main() + +``` +