reposnap 0.5.1__tar.gz → 0.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {reposnap-0.5.1 → reposnap-0.6.3}/.gitignore +4 -0
  2. reposnap-0.6.3/.python-version +1 -0
  3. reposnap-0.6.3/.vscode/launch.json +13 -0
  4. {reposnap-0.5.1 → reposnap-0.6.3}/PKG-INFO +5 -5
  5. {reposnap-0.5.1 → reposnap-0.6.3}/README.md +3 -3
  6. {reposnap-0.5.1 → reposnap-0.6.3}/pyproject.toml +1 -1
  7. {reposnap-0.5.1 → reposnap-0.6.3}/requirements-dev.lock +11 -0
  8. {reposnap-0.5.1 → reposnap-0.6.3}/requirements.lock +8 -0
  9. reposnap-0.6.3/src/reposnap/controllers/project_controller.py +199 -0
  10. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/interfaces/cli.py +9 -4
  11. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/models/file_tree.py +2 -1
  12. reposnap-0.6.3/tests/reposnap/test_collected_tree.py +133 -0
  13. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_project_controller.py +112 -109
  14. reposnap-0.5.1/.python-version +0 -1
  15. reposnap-0.5.1/src/reposnap/controllers/project_controller.py +0 -122
  16. {reposnap-0.5.1 → reposnap-0.6.3}/LICENSE +0 -0
  17. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/__init__.py +0 -0
  18. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/controllers/__init__.py +0 -0
  19. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/core/__init__.py +0 -0
  20. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/core/file_system.py +0 -0
  21. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/core/git_repo.py +0 -0
  22. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/core/markdown_generator.py +0 -0
  23. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/interfaces/__init__.py +0 -0
  24. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/interfaces/gui.py +0 -0
  25. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/models/__init__.py +0 -0
  26. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/utils/__init__.py +0 -0
  27. {reposnap-0.5.1 → reposnap-0.6.3}/src/reposnap/utils/path_utils.py +0 -0
  28. {reposnap-0.5.1 → reposnap-0.6.3}/tests/__init__.py +0 -0
  29. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/__init__.py +0 -0
  30. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_cli.py +0 -0
  31. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_file_system.py +0 -0
  32. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_file_tree.py +0 -0
  33. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_git_repo.py +0 -0
  34. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_gui.py +0 -0
  35. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_markdown_generator.py +0 -0
  36. {reposnap-0.5.1 → reposnap-0.6.3}/tests/reposnap/test_path_utils.py +0 -0
  37. {reposnap-0.5.1 → reposnap-0.6.3}/tests/resources/another_existing_file.py +0 -0
  38. {reposnap-0.5.1 → reposnap-0.6.3}/tests/resources/existing_file.py +0 -0
@@ -11,3 +11,7 @@ wheels/
11
11
  .idea
12
12
  output.md
13
13
  pytest.ini
14
+ .envrc
15
+
16
+ # pytest
17
+ .pytest_cache/
@@ -0,0 +1 @@
1
+ 3.12.6
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Python Debugger: Run reposnap",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "module": "reposnap.interfaces.cli",
9
+ "console": "integratedTerminal",
10
+ "args": ["src/reposnap/controllers", "src/reposnap/interfaces", "README.md", "tests/reposnap"]
11
+ }
12
+ ]
13
+ }
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.5.1
3
+ Version: 0.6.3
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -49,10 +49,10 @@ pip install -r requirements.lock
49
49
  To use `reposnap` from the command line, run it with the following options:
50
50
 
51
51
  ```bash
52
- reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] path
52
+ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
53
53
  ```
54
54
 
55
- - `path`: Path to the Git repository or subdirectory.
55
+ - `paths`: One or more paths (files or directories) within the repository whose content and structure should be rendered.
56
56
  - `-h, --help`: Show help message and exit.
57
57
  - `-o, --output`: The name of the output Markdown file. Defaults to `output.md`.
58
58
  - `--structure-only`: Generate a Markdown file that includes only the project structure, without file contents.
@@ -94,7 +94,7 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
94
94
  4. **Generate a Markdown file excluding certain files and directories**:
95
95
 
96
96
  ```bash
97
- reposnap my_project/ -e "tests" -e "*.md"
97
+ reposnap my_project_folder my_project_folder_2 -e "tests" -e "*.md"
98
98
  ```
99
99
 
100
100
  5. **Exclude files and directories containing a substring**:
@@ -36,10 +36,10 @@ pip install -r requirements.lock
36
36
  To use `reposnap` from the command line, run it with the following options:
37
37
 
38
38
  ```bash
39
- reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] path
39
+ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
40
40
  ```
41
41
 
42
- - `path`: Path to the Git repository or subdirectory.
42
+ - `paths`: One or more paths (files or directories) within the repository whose content and structure should be rendered.
43
43
  - `-h, --help`: Show help message and exit.
44
44
  - `-o, --output`: The name of the output Markdown file. Defaults to `output.md`.
45
45
  - `--structure-only`: Generate a Markdown file that includes only the project structure, without file contents.
@@ -81,7 +81,7 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
81
81
  4. **Generate a Markdown file excluding certain files and directories**:
82
82
 
83
83
  ```bash
84
- reposnap my_project/ -e "tests" -e "*.md"
84
+ reposnap my_project_folder my_project_folder_2 -e "tests" -e "*.md"
85
85
  ```
86
86
 
87
87
  5. **Exclude files and directories containing a substring**:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reposnap"
3
- version = "0.5.1"
3
+ version = "0.6.3"
4
4
  description = "Generate a Markdown file with all contents of your project"
5
5
  authors = [
6
6
  { name = "agoloborodko" }
@@ -11,14 +11,25 @@
11
11
 
12
12
  -e file:.
13
13
  gitdb==4.0.11
14
+ # via gitpython
14
15
  gitpython==3.1.43
16
+ # via reposnap
15
17
  iniconfig==2.0.0
18
+ # via pytest
16
19
  packaging==24.1
20
+ # via pytest
17
21
  pathlib==1.0.1
22
+ # via reposnap
18
23
  pathspec==0.12.1
24
+ # via reposnap
19
25
  pluggy==1.5.0
26
+ # via pytest
20
27
  pytest==8.3.2
21
28
  smmap==5.0.1
29
+ # via gitdb
22
30
  typing-extensions==4.12.2
31
+ # via urwid
23
32
  urwid==2.6.15
33
+ # via reposnap
24
34
  wcwidth==0.2.13
35
+ # via urwid
@@ -11,10 +11,18 @@
11
11
 
12
12
  -e file:.
13
13
  gitdb==4.0.11
14
+ # via gitpython
14
15
  gitpython==3.1.43
16
+ # via reposnap
15
17
  pathlib==1.0.1
18
+ # via reposnap
16
19
  pathspec==0.12.1
20
+ # via reposnap
17
21
  smmap==5.0.1
22
+ # via gitdb
18
23
  typing-extensions==4.12.2
24
+ # via urwid
19
25
  urwid==2.6.15
26
+ # via reposnap
20
27
  wcwidth==0.2.13
28
+ # via urwid
@@ -0,0 +1,199 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from reposnap.core.file_system import FileSystem
4
+ from reposnap.models.file_tree import FileTree
5
+ import pathspec
6
+ from typing import List, Optional
7
+
8
+ class ProjectController:
9
+ def __init__(self, args: Optional[object] = None):
10
+ self.logger = logging.getLogger(__name__)
11
+ # Always determine repository root using Git (or cwd)
12
+ self.root_dir = self._get_repo_root().resolve()
13
+ if args:
14
+ self.args = args
15
+ # Treat positional arguments as literal file/directory names.
16
+ input_paths = [Path(p) for p in (args.paths if hasattr(args, 'paths') else [args.path])]
17
+ self.input_paths = []
18
+ for p in input_paths:
19
+ candidate = (self.root_dir / p).resolve()
20
+ if candidate.exists():
21
+ try:
22
+ rel = candidate.relative_to(self.root_dir)
23
+ if rel != Path('.'):
24
+ self.input_paths.append(rel)
25
+ except ValueError:
26
+ self.logger.warning(f"Path {p} is not under repository root {self.root_dir}. Ignoring.")
27
+ else:
28
+ self.logger.warning(f"Path {p} does not exist relative to repository root {self.root_dir}.")
29
+ self.output_file: Path = Path(args.output).resolve() if args.output else self.root_dir / 'output.md'
30
+ self.structure_only: bool = args.structure_only if hasattr(args, 'structure_only') else False
31
+ self.include_patterns: List[str] = args.include if hasattr(args, 'include') else []
32
+ self.exclude_patterns: List[str] = args.exclude if hasattr(args, 'exclude') else []
33
+ else:
34
+ self.args = None
35
+ self.input_paths = []
36
+ self.output_file = self.root_dir / 'output.md'
37
+ self.structure_only = False
38
+ self.include_patterns = []
39
+ self.exclude_patterns = []
40
+ self.file_tree: Optional[FileTree] = None
41
+ self.gitignore_patterns: List[str] = []
42
+ if self.root_dir:
43
+ self.gitignore_patterns = self._load_gitignore_patterns()
44
+
45
+ def _get_repo_root(self) -> Path:
46
+ """
47
+ Determine the repository root using Git if available,
48
+ otherwise use the current directory.
49
+ """
50
+ from git import Repo, InvalidGitRepositoryError
51
+ try:
52
+ repo = Repo(Path.cwd(), search_parent_directories=True)
53
+ return Path(repo.working_tree_dir).resolve()
54
+ except InvalidGitRepositoryError:
55
+ self.logger.warning("Not a git repository. Using current directory as root.")
56
+ return Path.cwd().resolve()
57
+
58
+ def set_root_dir(self, root_dir: Path) -> None:
59
+ self.root_dir = root_dir.resolve()
60
+ self.gitignore_patterns = self._load_gitignore_patterns()
61
+
62
+ def get_file_tree(self) -> Optional[FileTree]:
63
+ return self.file_tree
64
+
65
+ def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
66
+ """Filter a list of file paths using include and exclude patterns."""
67
+ def adjust_patterns(patterns):
68
+ adjusted = []
69
+ for p in patterns:
70
+ if any(ch in p for ch in ['*', '?', '[']):
71
+ adjusted.append(p)
72
+ else:
73
+ adjusted.append(f'*{p}*')
74
+ return adjusted
75
+ if self.include_patterns:
76
+ inc = adjust_patterns(self.include_patterns)
77
+ spec_inc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, inc)
78
+ files = [f for f in files if spec_inc.match_file(f.as_posix())]
79
+ if self.exclude_patterns:
80
+ exc = adjust_patterns(self.exclude_patterns)
81
+ spec_exc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exc)
82
+ files = [f for f in files if not spec_exc.match_file(f.as_posix())]
83
+ return files
84
+
85
+ def collect_file_tree(self) -> None:
86
+ self.logger.info("Collecting files from Git tracked files if available.")
87
+ try:
88
+ from reposnap.core.git_repo import GitRepo
89
+ git_repo = GitRepo(self.root_dir)
90
+ all_files = git_repo.get_git_files()
91
+ self.logger.debug(f"Git tracked files: {all_files}")
92
+ except Exception as e:
93
+ self.logger.warning(f"Error obtaining Git tracked files: {e}.")
94
+ all_files = []
95
+ # If Git returns an empty list but files exist on disk, fall back to filesystem scan.
96
+ if not all_files:
97
+ file_list = [p for p in self.root_dir.rglob("*") if p.is_file()]
98
+ if file_list:
99
+ self.logger.info("Git tracked files empty, using filesystem scan fallback.")
100
+ all_files = []
101
+ for path in file_list:
102
+ try:
103
+ rel = path.relative_to(self.root_dir)
104
+ all_files.append(rel)
105
+ except ValueError:
106
+ continue
107
+ all_files = self._apply_include_exclude(all_files)
108
+ self.logger.debug(f"All files after applying include/exclude: {all_files}")
109
+ if self.input_paths:
110
+ trees = []
111
+ for input_path in self.input_paths:
112
+ subset = [f for f in all_files if f == input_path or list(f.parts[:len(input_path.parts)]) == list(input_path.parts)]
113
+ self.logger.debug(f"Files for input path '{input_path}': {subset}")
114
+ if subset:
115
+ tree = FileSystem(self.root_dir).build_tree_structure(subset)
116
+ trees.append(tree)
117
+ if trees:
118
+ merged_tree = self.merge_trees(trees)
119
+ else:
120
+ merged_tree = {}
121
+ else:
122
+ merged_tree = FileSystem(self.root_dir).build_tree_structure(all_files)
123
+ self.logger.info("Merged tree built from input paths.")
124
+ self.file_tree = FileTree(merged_tree)
125
+ self.logger.debug(f"Merged tree structure: {self.file_tree.structure}")
126
+
127
+ def merge_trees(self, trees: List[dict]) -> dict:
128
+ """Recursively merge a list of tree dictionaries."""
129
+ merged = {}
130
+ for tree in trees:
131
+ merged = self._merge_two_trees(merged, tree)
132
+ return merged
133
+
134
+ def _merge_two_trees(self, tree1: dict, tree2: dict) -> dict:
135
+ merged = dict(tree1)
136
+ for key, value in tree2.items():
137
+ if key in merged:
138
+ if isinstance(merged[key], dict) and isinstance(value, dict):
139
+ merged[key] = self._merge_two_trees(merged[key], value)
140
+ else:
141
+ merged[key] = value
142
+ else:
143
+ merged[key] = value
144
+ return merged
145
+
146
+ def apply_filters(self) -> None:
147
+ self.logger.info("Applying .gitignore filters to the merged tree.")
148
+ spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns)
149
+ self.logger.debug(f".gitignore patterns: {self.gitignore_patterns}")
150
+ self.file_tree.filter_files(spec)
151
+
152
+ def generate_output(self) -> None:
153
+ self.logger.info("Starting Markdown generation.")
154
+ from reposnap.core.markdown_generator import MarkdownGenerator
155
+ markdown_generator = MarkdownGenerator(
156
+ root_dir=self.root_dir,
157
+ output_file=self.output_file,
158
+ structure_only=self.structure_only
159
+ )
160
+ markdown_generator.generate_markdown(self.file_tree.structure, self.file_tree.get_all_files())
161
+ self.logger.info(f"Markdown generated at {self.output_file}.")
162
+
163
+ def generate_output_from_selected(self, selected_files: set) -> None:
164
+ self.logger.info("Generating Markdown from selected files.")
165
+ pruned_tree = self.file_tree.prune_tree(selected_files)
166
+ from reposnap.core.markdown_generator import MarkdownGenerator
167
+ markdown_generator = MarkdownGenerator(
168
+ root_dir=self.root_dir,
169
+ output_file=self.output_file,
170
+ structure_only=False,
171
+ hide_untoggled=True
172
+ )
173
+ markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
174
+ self.logger.info(f"Markdown generated at {self.output_file}.")
175
+
176
+ def run(self) -> None:
177
+ """Run the entire process: collect files, apply filters, and generate Markdown."""
178
+ self.collect_file_tree()
179
+ self.apply_filters()
180
+ self.generate_output()
181
+
182
+ def _load_gitignore_patterns(self) -> List[str]:
183
+ gitignore_path = self.root_dir / '.gitignore'
184
+ if not gitignore_path.exists():
185
+ for parent in self.root_dir.parents:
186
+ candidate = parent / '.gitignore'
187
+ if candidate.exists():
188
+ gitignore_path = candidate
189
+ break
190
+ else:
191
+ gitignore_path = None
192
+ if gitignore_path and gitignore_path.exists():
193
+ with gitignore_path.open('r') as gitignore:
194
+ patterns = [line.strip() for line in gitignore if line.strip() and not line.strip().startswith('#')]
195
+ self.logger.debug(f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}")
196
+ return patterns
197
+ else:
198
+ self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
199
+ return []
@@ -6,14 +6,19 @@ from reposnap.controllers.project_controller import ProjectController
6
6
 
7
7
 
8
8
  def main():
9
- parser = argparse.ArgumentParser(description='Generate a Markdown representation of a Git repository.')
10
- parser.add_argument('path', nargs='?', default='.', help='Path to the Git repository or subdirectory.')
9
+ parser = argparse.ArgumentParser(
10
+ description='Generate a Markdown representation of a Git repository.'
11
+ )
12
+ # Changed positional argument to allow one or more paths.
13
+ parser.add_argument(
14
+ 'paths',
15
+ nargs='+',
16
+ help='One or more paths (files or directories) to include in the Markdown output.'
17
+ )
11
18
  parser.add_argument('-o', '--output', help='Output Markdown file', default='output.md')
12
19
  parser.add_argument('--structure-only', action='store_true',
13
20
  help='Only include the file structure without content.')
14
21
  parser.add_argument('--debug', action='store_true', help='Enable debug-level logging.')
15
-
16
- # New arguments for include and exclude patterns
17
22
  parser.add_argument('-i', '--include', nargs='*', default=[],
18
23
  help='File/folder patterns to include.')
19
24
  parser.add_argument('-e', '--exclude', nargs='*', default=[],
@@ -48,7 +48,8 @@ class FileTree:
48
48
  if filtered_value:
49
49
  filtered_subtree[key] = filtered_value
50
50
  else:
51
- if not spec.match_file(current_path):
51
+ # Exclude the file if either the full path OR its basename matches a .gitignore pattern.
52
+ if not spec.match_file(current_path) and not spec.match_file(Path(current_path).name):
52
53
  filtered_subtree[key] = value
53
54
  return filtered_subtree
54
55
 
@@ -0,0 +1,133 @@
1
+ # tests/reposnap/test_collected_tree.py
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ import pytest
7
+ from reposnap.controllers.project_controller import ProjectController
8
+ from unittest.mock import patch
9
+
10
+ def create_directory_structure(base_dir: str, structure: dict):
11
+ """
12
+ Recursively creates directories and files based on the provided structure.
13
+ """
14
+ for name, content in structure.items():
15
+ path = os.path.join(base_dir, name)
16
+ if isinstance(content, dict):
17
+ os.makedirs(path, exist_ok=True)
18
+ create_directory_structure(path, content)
19
+ else:
20
+ with open(path, 'w') as f:
21
+ f.write(content)
22
+
23
+ def traverse_tree(tree: dict, path=''):
24
+ files = []
25
+ for name, node in tree.items():
26
+ current_path = os.path.join(path, name)
27
+ if isinstance(node, dict):
28
+ files.extend(traverse_tree(node, current_path))
29
+ else:
30
+ files.append(current_path)
31
+ return files
32
+
33
+ def test_collect_tree_all_files():
34
+ with tempfile.TemporaryDirectory() as temp_dir:
35
+ structure = {
36
+ 'src': {
37
+ 'module': {
38
+ 'a.py': 'print("a")',
39
+ 'b.txt': 'text',
40
+ }
41
+ },
42
+ 'tests': {
43
+ 'test_a.py': 'print("test")'
44
+ },
45
+ 'README.md': '# Readme'
46
+ }
47
+ create_directory_structure(temp_dir, structure)
48
+ args = type('Args', (object,), {
49
+ 'path': temp_dir,
50
+ 'output': os.path.join(temp_dir, 'output.md'),
51
+ 'structure_only': True,
52
+ 'debug': False,
53
+ 'include': [],
54
+ 'exclude': []
55
+ })
56
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
57
+ controller = ProjectController(args)
58
+ controller.collect_file_tree()
59
+ collected = traverse_tree(controller.file_tree.structure)
60
+ expected = [
61
+ 'README.md',
62
+ os.path.join('src', 'module', 'a.py'),
63
+ os.path.join('src', 'module', 'b.txt'),
64
+ os.path.join('tests', 'test_a.py')
65
+ ]
66
+ assert sorted(collected) == sorted(expected)
67
+
68
+ def test_collect_tree_literal_path():
69
+ with tempfile.TemporaryDirectory() as temp_dir:
70
+ structure = {
71
+ 'src': {
72
+ 'module': {
73
+ 'a.py': 'print("a")',
74
+ 'b.txt': 'text',
75
+ }
76
+ },
77
+ 'tests': {
78
+ 'test_a.py': 'print("test")'
79
+ },
80
+ 'README.md': '# Readme'
81
+ }
82
+ create_directory_structure(temp_dir, structure)
83
+ # Request only the 'src' directory.
84
+ args = type('Args', (object,), {
85
+ 'paths': ['src'],
86
+ 'output': os.path.join(temp_dir, 'output.md'),
87
+ 'structure_only': True,
88
+ 'debug': False,
89
+ 'include': [],
90
+ 'exclude': []
91
+ })
92
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
93
+ controller = ProjectController(args)
94
+ controller.collect_file_tree()
95
+ collected = traverse_tree(controller.file_tree.structure)
96
+ expected = [
97
+ os.path.join('src', 'module', 'a.py'),
98
+ os.path.join('src', 'module', 'b.txt')
99
+ ]
100
+ assert sorted(collected) == sorted(expected)
101
+
102
+ def test_collect_tree_multiple_paths():
103
+ with tempfile.TemporaryDirectory() as temp_dir:
104
+ structure = {
105
+ 'src': {
106
+ 'module': {
107
+ 'a.py': 'print("a")',
108
+ }
109
+ },
110
+ 'tests': {
111
+ 'test_a.py': 'print("test")'
112
+ },
113
+ 'README.md': '# Readme'
114
+ }
115
+ create_directory_structure(temp_dir, structure)
116
+ # Request multiple literal paths.
117
+ args = type('Args', (object,), {
118
+ 'paths': ['README.md', 'tests'],
119
+ 'output': os.path.join(temp_dir, 'output.md'),
120
+ 'structure_only': True,
121
+ 'debug': False,
122
+ 'include': [],
123
+ 'exclude': []
124
+ })
125
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
126
+ controller = ProjectController(args)
127
+ controller.collect_file_tree()
128
+ collected = traverse_tree(controller.file_tree.structure)
129
+ expected = [
130
+ 'README.md',
131
+ os.path.join('tests', 'test_a.py')
132
+ ]
133
+ assert sorted(collected) == sorted(expected)
@@ -14,6 +14,9 @@ def create_file(file_path: str, content: str = ''):
14
14
 
15
15
 
16
16
  def create_directory_structure(base_dir: str, structure: dict):
17
+ """
18
+ Recursively creates directories and files based on the provided structure.
19
+ """
17
20
  for name, content in structure.items():
18
21
  path = os.path.join(base_dir, name)
19
22
  if isinstance(content, dict):
@@ -26,8 +29,8 @@ def create_directory_structure(base_dir: str, structure: dict):
26
29
  def test_project_controller_includes_py_files():
27
30
  with tempfile.TemporaryDirectory() as temp_dir:
28
31
  gitignore_content = """
29
- *.py[oc]
30
- """
32
+ *.py[oc]
33
+ """
31
34
  structure = {
32
35
  'src': {
33
36
  'module': {
@@ -38,7 +41,6 @@ def test_project_controller_includes_py_files():
38
41
  },
39
42
  '.gitignore': gitignore_content,
40
43
  }
41
-
42
44
  create_directory_structure(temp_dir, structure)
43
45
 
44
46
  args = type('Args', (object,), {
@@ -47,58 +49,47 @@ def test_project_controller_includes_py_files():
47
49
  'structure_only': False,
48
50
  'debug': False
49
51
  })
50
-
51
- with patch('reposnap.controllers.project_controller.GitRepo') as MockGitRepo:
52
- mock_git_repo_instance = MockGitRepo.return_value
53
- mock_git_repo_instance.get_git_files.return_value = [
54
- Path('src/module/file1.py'),
55
- Path('src/module/file2.py'),
56
- Path('.gitignore')
57
- ]
58
-
52
+ # Force repository root to be our temporary directory.
53
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
59
54
  controller = ProjectController(args)
60
55
  controller.run()
61
56
 
62
- # Read the output file
63
57
  with open(args.output, 'r') as f:
64
58
  output_content = f.read()
65
59
 
66
- # Check that contents of .py files are included
60
+ # Check that the contents of the Python files are included
67
61
  assert 'print("File 1")' in output_content
68
62
  assert 'print("File 2")' in output_content
69
- # .pyc files should be ignored
63
+ # The .pyc file should be filtered out by .gitignore
70
64
  assert 'Compiled code' not in output_content
71
65
 
72
66
 
73
- @patch('reposnap.controllers.project_controller.MarkdownGenerator')
74
- @patch('reposnap.controllers.project_controller.FileSystem')
75
- @patch('reposnap.controllers.project_controller.GitRepo')
76
- def test_project_controller_run(mock_git_repo, mock_file_system, mock_markdown_generator):
77
- # Setup mocks
78
- mock_git_repo_instance = MagicMock()
79
- mock_file_system_instance = MagicMock()
80
- mock_markdown_generator_instance = MagicMock()
81
-
82
- mock_git_repo.return_value = mock_git_repo_instance
83
- mock_file_system.return_value = mock_file_system_instance
84
- mock_markdown_generator.return_value = mock_markdown_generator_instance
85
-
86
- # Use Path objects instead of strings
87
- mock_git_repo_instance.get_git_files.return_value = [Path('file1.py'), Path('file2.py')]
88
- mock_file_system_instance.build_tree_structure.return_value = {'dir': {'file1.py': 'file1.py'}}
89
-
90
- args = MagicMock()
91
- args.path = 'root_dir'
92
- args.output = 'output.md'
93
- args.structure_only = False
94
- args.include = []
95
- args.exclude = []
96
- args.debug = False # Add if necessary
67
+ def test_project_controller_run():
68
+ with tempfile.TemporaryDirectory() as temp_dir:
69
+ structure = {
70
+ 'file1.txt': 'content',
71
+ 'file2.py': 'print("hello")',
72
+ }
73
+ create_directory_structure(temp_dir, structure)
74
+ args = type('Args', (object,), {
75
+ 'path': temp_dir,
76
+ 'output': os.path.join(temp_dir, 'output.md'),
77
+ 'structure_only': False,
78
+ 'debug': False,
79
+ 'include': [],
80
+ 'exclude': []
81
+ })
82
+ # Patch _get_repo_root to force temp_dir as repo root.
83
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
84
+ # Patch the MarkdownGenerator in its actual module.
85
+ with patch('reposnap.core.markdown_generator.MarkdownGenerator') as MockMarkdownGenerator:
86
+ mock_instance = MagicMock()
87
+ MockMarkdownGenerator.return_value = mock_instance
97
88
 
98
- controller = ProjectController(args)
99
- controller.run()
89
+ controller = ProjectController(args)
90
+ controller.run()
100
91
 
101
- mock_markdown_generator_instance.generate_markdown.assert_called_once()
92
+ mock_instance.generate_markdown.assert_called_once()
102
93
 
103
94
 
104
95
  def test_include_pattern():
@@ -118,9 +109,7 @@ def test_include_pattern():
118
109
  'setup.py': 'setup code',
119
110
  'notes.txt': 'Some notes',
120
111
  }
121
-
122
112
  create_directory_structure(temp_dir, structure)
123
-
124
113
  args = type('Args', (object,), {
125
114
  'path': temp_dir,
126
115
  'output': os.path.join(temp_dir, 'output.md'),
@@ -129,25 +118,11 @@ def test_include_pattern():
129
118
  'include': ['*.py'],
130
119
  'exclude': []
131
120
  })
132
-
133
- # Mock the GitRepo class
134
- with patch('reposnap.controllers.project_controller.GitRepo') as MockGitRepo:
135
- mock_git_repo_instance = MockGitRepo.return_value
136
-
137
- # Collect all files under temp_dir
138
- all_files = []
139
- for root, dirs, files in os.walk(temp_dir):
140
- for name in files:
141
- file_path = Path(root) / name
142
- rel_path = file_path.relative_to(temp_dir)
143
- all_files.append(rel_path)
144
-
145
- mock_git_repo_instance.get_git_files.return_value = all_files
146
-
121
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
147
122
  controller = ProjectController(args)
148
123
  controller.collect_file_tree()
149
124
 
150
- # Get the list of files included in the tree
125
+ # Traverse the merged tree and collect file paths.
151
126
  included_files = []
152
127
 
153
128
  def traverse(tree, path=''):
@@ -165,7 +140,6 @@ def test_include_pattern():
165
140
  os.path.join('src', 'module', 'submodule', 'file3.py'),
166
141
  'setup.py',
167
142
  ]
168
-
169
143
  assert sorted(included_files) == sorted(expected_files)
170
144
 
171
145
 
@@ -186,9 +160,7 @@ def test_exclude_pattern():
186
160
  'setup.py': 'setup code',
187
161
  'notes.txt': 'Some notes',
188
162
  }
189
-
190
163
  create_directory_structure(temp_dir, structure)
191
-
192
164
  args = type('Args', (object,), {
193
165
  'path': temp_dir,
194
166
  'output': os.path.join(temp_dir, 'output.md'),
@@ -197,25 +169,10 @@ def test_exclude_pattern():
197
169
  'include': [],
198
170
  'exclude': ['*.md', '*.txt']
199
171
  })
200
-
201
- with patch('reposnap.controllers.project_controller.GitRepo') as MockGitRepo:
202
- mock_git_repo_instance = MockGitRepo.return_value
203
-
204
- # Collect all files under temp_dir
205
- all_files = []
206
- for root, dirs, files in os.walk(temp_dir):
207
- for name in files:
208
- file_path = Path(root) / name
209
- rel_path = file_path.relative_to(temp_dir)
210
- all_files.append(rel_path)
211
-
212
- mock_git_repo_instance.get_git_files.return_value = all_files
213
-
172
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
214
173
  controller = ProjectController(args)
215
174
  controller.collect_file_tree()
216
-
217
175
  included_files = []
218
-
219
176
  def traverse(tree, path=''):
220
177
  for name, node in tree.items():
221
178
  current_path = os.path.join(path, name)
@@ -223,18 +180,14 @@ def test_exclude_pattern():
223
180
  traverse(node, current_path)
224
181
  else:
225
182
  included_files.append(current_path)
226
-
227
183
  traverse(controller.file_tree.structure)
228
-
229
184
  expected_files = [
230
185
  os.path.join('src', 'module', 'file1.py'),
231
186
  os.path.join('src', 'module', 'submodule', 'file3.py'),
232
187
  'setup.py',
233
188
  ]
234
-
235
189
  assert sorted(included_files) == sorted(expected_files)
236
190
 
237
-
238
191
  def test_include_and_exclude_patterns():
239
192
  with tempfile.TemporaryDirectory() as temp_dir:
240
193
  structure = {
@@ -255,9 +208,7 @@ def test_include_and_exclude_patterns():
255
208
  'setup.py': 'setup code',
256
209
  'notes.txt': 'Some notes',
257
210
  }
258
-
259
211
  create_directory_structure(temp_dir, structure)
260
-
261
212
  args = type('Args', (object,), {
262
213
  'path': temp_dir,
263
214
  'output': os.path.join(temp_dir, 'output.md'),
@@ -266,41 +217,93 @@ def test_include_and_exclude_patterns():
266
217
  'include': ['*foo*'],
267
218
  'exclude': ['*submodule*']
268
219
  })
269
-
270
- with patch('reposnap.controllers.project_controller.GitRepo') as MockGitRepo:
271
- mock_git_repo_instance = MockGitRepo.return_value
272
-
273
- # Collect all files under temp_dir
274
- all_files = []
275
- for root, dirs, files in os.walk(temp_dir):
276
- for name in files:
277
- file_path = Path(root) / name
278
- rel_path = file_path.relative_to(temp_dir)
279
- all_files.append(rel_path)
280
-
281
- mock_git_repo_instance.get_git_files.return_value = all_files
282
-
220
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
283
221
  controller = ProjectController(args)
284
222
  controller.collect_file_tree()
285
-
286
- included_files = []
287
-
223
+ collected = []
288
224
  def traverse(tree, path=''):
289
225
  for name, node in tree.items():
290
226
  current_path = os.path.join(path, name)
291
227
  if isinstance(node, dict):
292
- included_files.append(current_path)
228
+ collected.append(current_path)
293
229
  traverse(node, current_path)
294
230
  else:
295
- included_files.append(current_path)
296
-
231
+ collected.append(current_path)
297
232
  traverse(controller.file_tree.structure)
298
-
299
- expected_files = [
233
+ expected = [
300
234
  os.path.join('src'),
301
235
  os.path.join('src', 'foo_module'),
302
236
  os.path.join('src', 'foo_module', 'foo_file1.py'),
303
- os.path.join('src', 'foo_module', 'file2.py'), # Include this file
237
+ os.path.join('src', 'foo_module', 'file2.py'),
304
238
  ]
239
+ assert sorted(collected) == sorted(expected)
305
240
 
306
- assert sorted(included_files) == sorted(expected_files)
241
+
242
+ def test_project_controller_multiple_paths():
243
+ with tempfile.TemporaryDirectory() as temp_dir:
244
+ # Define a structure similar to the provided example.
245
+ structure = {
246
+ 'README.md': 'Project README content',
247
+ 'pyproject.toml': 'project configuration',
248
+ 'LICENSE': 'MIT License',
249
+ 'src': {
250
+ 'reposnap': {
251
+ '__init__.py': '',
252
+ 'controllers': {
253
+ '__init__.py': '',
254
+ 'project_controller.py': 'print("controller")',
255
+ },
256
+ 'core': {
257
+ '__init__.py': '',
258
+ 'file_system.py': 'print("filesystem")',
259
+ 'git_repo.py': 'print("git")',
260
+ 'markdown_generator.py': 'print("markdown")',
261
+ },
262
+ 'interfaces': {
263
+ '__init__.py': '',
264
+ 'cli.py': 'print("cli")',
265
+ 'gui.py': 'print("gui")',
266
+ },
267
+ 'models': {
268
+ '__init__.py': '',
269
+ 'file_tree.py': 'print("file tree")',
270
+ },
271
+ 'utils': {
272
+ '__init__.py': '',
273
+ 'path_utils.py': 'print("path utils")',
274
+ },
275
+ },
276
+ },
277
+ 'tests': {
278
+ '__init__.py': '',
279
+ 'some_test.py': 'print("test")'
280
+ },
281
+ 'extras': {
282
+ 'notes.txt': 'Some notes'
283
+ }
284
+ }
285
+ create_directory_structure(temp_dir, structure)
286
+
287
+ # Create args with multiple paths.
288
+ args = type('Args', (object,), {
289
+ 'paths': ['README.md', 'src', 'pyproject.toml'],
290
+ 'output': os.path.join(temp_dir, 'output.md'),
291
+ 'structure_only': True,
292
+ 'debug': False,
293
+ 'include': [],
294
+ 'exclude': []
295
+ })
296
+ # Patch _get_repo_root to return our temp_dir.
297
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
298
+ controller = ProjectController(args)
299
+ controller.collect_file_tree()
300
+
301
+ tree = controller.file_tree.structure
302
+ # The merged tree should only include keys for the provided paths.
303
+ assert 'README.md' in tree
304
+ assert 'pyproject.toml' in tree
305
+ assert 'src' in tree
306
+ # Keys that are not part of the requested paths (like LICENSE, tests, extras) should be absent.
307
+ assert 'LICENSE' not in tree
308
+ assert 'tests' not in tree
309
+ assert 'extras' not in tree
@@ -1 +0,0 @@
1
- 3.12.4
@@ -1,122 +0,0 @@
1
- # src/reposnap/controllers/project_controller.py
2
-
3
- import logging
4
- from pathlib import Path
5
- from reposnap.core.git_repo import GitRepo
6
- from reposnap.core.file_system import FileSystem
7
- from reposnap.core.markdown_generator import MarkdownGenerator
8
- from reposnap.models.file_tree import FileTree
9
- import pathspec
10
- from typing import List, Optional
11
-
12
-
13
- class ProjectController:
14
- def __init__(self, args: Optional[object] = None):
15
- self.logger = logging.getLogger(__name__)
16
- self.root_dir: Path = Path(args.path).resolve() if args else Path('.').resolve()
17
- self.output_file: Path = Path(args.output).resolve() if args else Path('output.md').resolve()
18
- self.structure_only: bool = args.structure_only if args else False
19
- self.args: object = args
20
- self.file_tree: Optional[FileTree] = None
21
- self.gitignore_patterns: List[str] = []
22
- self.include_patterns: List[str] = args.include if args and hasattr(args, 'include') else []
23
- self.exclude_patterns: List[str] = args.exclude if args and hasattr(args, 'exclude') else []
24
- if self.root_dir:
25
- self.gitignore_patterns = self._load_gitignore_patterns()
26
-
27
- def set_root_dir(self, root_dir: Path) -> None:
28
- self.root_dir = root_dir
29
- self.gitignore_patterns = self._load_gitignore_patterns()
30
-
31
- def get_file_tree(self) -> Optional[FileTree]:
32
- return self.file_tree
33
-
34
- def run(self) -> None:
35
- self.collect_file_tree()
36
- self.apply_filters()
37
- self.generate_output()
38
-
39
- def collect_file_tree(self) -> None:
40
- self.logger.info("Collecting git files.")
41
- git_repo: GitRepo = GitRepo(self.root_dir)
42
- git_files: List[Path] = git_repo.get_git_files()
43
- self.logger.debug(f"Git files before filtering: {git_files}")
44
-
45
- # Adjust patterns
46
- def adjust_patterns(patterns):
47
- adjusted = []
48
- for pattern in patterns:
49
- if '*' in pattern or '?' in pattern or '[' in pattern:
50
- adjusted.append(pattern)
51
- else:
52
- adjusted.append(f'*{pattern}*')
53
- return adjusted
54
-
55
- # Apply include patterns
56
- if self.include_patterns:
57
- adjusted_include_patterns = adjust_patterns(self.include_patterns)
58
- include_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, adjusted_include_patterns)
59
- git_files = [f for f in git_files if include_spec.match_file(f.as_posix())]
60
- self.logger.debug(f"Git files after include patterns: {git_files}")
61
-
62
- # Apply exclude patterns
63
- if self.exclude_patterns:
64
- adjusted_exclude_patterns = adjust_patterns(self.exclude_patterns)
65
- exclude_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, adjusted_exclude_patterns)
66
- git_files = [f for f in git_files if not exclude_spec.match_file(f.as_posix())]
67
- self.logger.debug(f"Git files after exclude patterns: {git_files}")
68
-
69
- self.logger.info("Building tree structure.")
70
- file_system: FileSystem = FileSystem(self.root_dir)
71
- tree_structure: dict = file_system.build_tree_structure(git_files)
72
-
73
- self.file_tree = FileTree(tree_structure)
74
- self.logger.debug(f"Tree structure: {self.file_tree.structure}")
75
-
76
- def apply_filters(self) -> None:
77
- self.logger.info("Applying filters to the file tree.")
78
- spec: pathspec.PathSpec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns)
79
- self.logger.debug(f"Filter patterns: {self.gitignore_patterns}")
80
- self.file_tree.filter_files(spec)
81
-
82
- def generate_output(self) -> None:
83
- self.logger.info("Starting markdown generation.")
84
- markdown_generator: MarkdownGenerator = MarkdownGenerator(
85
- root_dir=self.root_dir,
86
- output_file=self.output_file,
87
- structure_only=self.structure_only
88
- )
89
- markdown_generator.generate_markdown(self.file_tree.structure, self.file_tree.get_all_files())
90
- self.logger.info(f"Markdown generated at {self.output_file}.")
91
-
92
- def generate_output_from_selected(self, selected_files: set) -> None:
93
- self.logger.info("Generating markdown from selected files.")
94
- # Build a pruned tree structure based on selected files
95
- pruned_tree = self.file_tree.prune_tree(selected_files)
96
- markdown_generator: MarkdownGenerator = MarkdownGenerator(
97
- root_dir=self.root_dir,
98
- output_file=self.output_file,
99
- structure_only=False,
100
- hide_untoggled=True
101
- )
102
- markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
103
- self.logger.info(f"Markdown generated at {self.output_file}.")
104
-
105
- def _load_gitignore_patterns(self) -> List[str]:
106
- gitignore_path: Path = self.root_dir / '.gitignore'
107
- if not gitignore_path.exists():
108
- for parent in self.root_dir.parents:
109
- gitignore_path = parent / '.gitignore'
110
- if gitignore_path.exists():
111
- break
112
- else:
113
- gitignore_path = None
114
-
115
- if gitignore_path and gitignore_path.exists():
116
- with gitignore_path.open('r') as gitignore:
117
- patterns: List[str] = gitignore.readlines()
118
- self.logger.debug(f"Patterns from .gitignore in {gitignore_path.parent}: {patterns}")
119
- return patterns
120
- else:
121
- self.logger.debug(f"No .gitignore found starting from {self.root_dir}. Proceeding without patterns.")
122
- return []
File without changes
File without changes