reposnap 0.5.0__tar.gz → 0.6.2__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 (36) hide show
  1. {reposnap-0.5.0 → reposnap-0.6.2}/.gitignore +1 -0
  2. reposnap-0.6.2/.python-version +1 -0
  3. {reposnap-0.5.0 → reposnap-0.6.2}/PKG-INFO +6 -6
  4. {reposnap-0.5.0 → reposnap-0.6.2}/README.md +4 -4
  5. {reposnap-0.5.0 → reposnap-0.6.2}/pyproject.toml +1 -1
  6. reposnap-0.6.2/src/reposnap/controllers/project_controller.py +209 -0
  7. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/core/markdown_generator.py +5 -1
  8. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/interfaces/cli.py +9 -4
  9. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_project_controller.py +113 -108
  10. reposnap-0.5.0/.python-version +0 -1
  11. reposnap-0.5.0/src/reposnap/controllers/project_controller.py +0 -122
  12. {reposnap-0.5.0 → reposnap-0.6.2}/LICENSE +0 -0
  13. {reposnap-0.5.0 → reposnap-0.6.2}/requirements-dev.lock +0 -0
  14. {reposnap-0.5.0 → reposnap-0.6.2}/requirements.lock +0 -0
  15. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/__init__.py +0 -0
  16. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/controllers/__init__.py +0 -0
  17. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/core/__init__.py +0 -0
  18. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/core/file_system.py +0 -0
  19. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/core/git_repo.py +0 -0
  20. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/interfaces/__init__.py +0 -0
  21. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/interfaces/gui.py +0 -0
  22. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/models/__init__.py +0 -0
  23. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/models/file_tree.py +0 -0
  24. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/utils/__init__.py +0 -0
  25. {reposnap-0.5.0 → reposnap-0.6.2}/src/reposnap/utils/path_utils.py +0 -0
  26. {reposnap-0.5.0 → reposnap-0.6.2}/tests/__init__.py +0 -0
  27. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/__init__.py +0 -0
  28. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_cli.py +0 -0
  29. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_file_system.py +0 -0
  30. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_file_tree.py +0 -0
  31. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_git_repo.py +0 -0
  32. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_gui.py +0 -0
  33. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_markdown_generator.py +0 -0
  34. {reposnap-0.5.0 → reposnap-0.6.2}/tests/reposnap/test_path_utils.py +0 -0
  35. {reposnap-0.5.0 → reposnap-0.6.2}/tests/resources/another_existing_file.py +0 -0
  36. {reposnap-0.5.0 → reposnap-0.6.2}/tests/resources/existing_file.py +0 -0
@@ -11,3 +11,4 @@ wheels/
11
11
  .idea
12
12
  output.md
13
13
  pytest.ini
14
+ .envrc
@@ -0,0 +1 @@
1
+ 3.12.6
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.5.0
3
+ Version: 0.6.2
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -39,7 +39,7 @@ Alternatively, you can clone the repository and install the required dependencie
39
39
  ```bash
40
40
  git clone https://github.com/username/reposnap.git
41
41
  cd reposnap
42
- pip install -r requirements.txt
42
+ pip install -r requirements.lock
43
43
  ```
44
44
 
45
45
  ## Usage
@@ -49,10 +49,10 @@ pip install -r requirements.txt
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**:
@@ -26,7 +26,7 @@ Alternatively, you can clone the repository and install the required dependencie
26
26
  ```bash
27
27
  git clone https://github.com/username/reposnap.git
28
28
  cd reposnap
29
- pip install -r requirements.txt
29
+ pip install -r requirements.lock
30
30
  ```
31
31
 
32
32
  ## Usage
@@ -36,10 +36,10 @@ pip install -r requirements.txt
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.0"
3
+ version = "0.6.2"
4
4
  description = "Generate a Markdown file with all contents of your project"
5
5
  authors = [
6
6
  { name = "agoloborodko" }
@@ -0,0 +1,209 @@
1
+ # src/reposnap/controllers/project_controller.py
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from reposnap.core.file_system import FileSystem
6
+ from reposnap.core.markdown_generator import MarkdownGenerator
7
+ from reposnap.models.file_tree import FileTree
8
+ import pathspec
9
+ from typing import List, Optional
10
+
11
+ class ProjectController:
12
+ def __init__(self, args: Optional[object] = None):
13
+ self.logger = logging.getLogger(__name__)
14
+ if args:
15
+ # Support both new 'paths' (multiple paths) and legacy 'path'
16
+ if hasattr(args, 'paths'):
17
+ input_paths = [Path(p) for p in args.paths]
18
+ else:
19
+ input_paths = [Path(args.path)]
20
+ self.args = args
21
+ # Determine repository root (using provided paths’ common parent if available)
22
+ self.root_dir = self._get_repo_root().resolve()
23
+ # Convert provided paths to be relative to the repository root.
24
+ self.input_paths = []
25
+ for p in input_paths:
26
+ try:
27
+ candidate = (self.root_dir / p).resolve()
28
+ rel = candidate.relative_to(self.root_dir)
29
+ if rel != Path('.'):
30
+ self.input_paths.append(rel)
31
+ except ValueError:
32
+ self.logger.warning(f"Path {p} is not under repository root {self.root_dir}. Ignoring.")
33
+ self.output_file: Path = Path(args.output).resolve() if args.output else self.root_dir / 'output.md'
34
+ self.structure_only: bool = args.structure_only if hasattr(args, 'structure_only') else False
35
+ self.include_patterns: List[str] = args.include if hasattr(args, 'include') else []
36
+ self.exclude_patterns: List[str] = args.exclude if hasattr(args, 'exclude') else []
37
+ else:
38
+ self.root_dir = Path('.').resolve()
39
+ self.input_paths = []
40
+ self.output_file = Path('output.md').resolve()
41
+ self.structure_only = False
42
+ self.args = None
43
+ self.include_patterns = []
44
+ self.exclude_patterns = []
45
+ self.file_tree: Optional[FileTree] = None
46
+ self.gitignore_patterns: List[str] = []
47
+ if self.root_dir:
48
+ self.gitignore_patterns = self._load_gitignore_patterns()
49
+
50
+ def _get_repo_root(self) -> Path:
51
+ """
52
+ Determine the repository root. If arguments were provided and those paths exist,
53
+ use the common parent directory of all provided paths. Otherwise, fall back to the
54
+ git repository working tree directory (or current directory if not a git repo).
55
+ """
56
+ if self.args is not None:
57
+ candidate_paths = []
58
+ if hasattr(self.args, 'paths'):
59
+ for p in self.args.paths:
60
+ candidate = Path(p).resolve()
61
+ if candidate.exists():
62
+ candidate_paths.append(candidate)
63
+ elif hasattr(self.args, 'path'):
64
+ candidate = Path(self.args.path).resolve()
65
+ if candidate.exists():
66
+ candidate_paths.append(candidate)
67
+ if candidate_paths:
68
+ from os.path import commonpath
69
+ common = Path(commonpath([str(p) for p in candidate_paths]))
70
+ return common
71
+ from git import Repo, InvalidGitRepositoryError
72
+ try:
73
+ repo = Repo(Path.cwd(), search_parent_directories=True)
74
+ return Path(repo.working_tree_dir).resolve()
75
+ except InvalidGitRepositoryError:
76
+ self.logger.warning("Not a git repository. Using current directory as root.")
77
+ return Path.cwd().resolve()
78
+
79
+ def set_root_dir(self, root_dir: Path) -> None:
80
+ self.root_dir = root_dir.resolve()
81
+ self.gitignore_patterns = self._load_gitignore_patterns()
82
+
83
+ def get_file_tree(self) -> Optional[FileTree]:
84
+ return self.file_tree
85
+
86
+ def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
87
+ """Filter a list of file paths using include and exclude patterns."""
88
+ def adjust_patterns(patterns):
89
+ adjusted = []
90
+ for p in patterns:
91
+ if any(ch in p for ch in ['*', '?', '[']):
92
+ adjusted.append(p)
93
+ else:
94
+ adjusted.append(f'*{p}*')
95
+ return adjusted
96
+ if self.include_patterns:
97
+ inc = adjust_patterns(self.include_patterns)
98
+ spec_inc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, inc)
99
+ files = [f for f in files if spec_inc.match_file(f.as_posix())]
100
+ if self.exclude_patterns:
101
+ exc = adjust_patterns(self.exclude_patterns)
102
+ spec_exc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exc)
103
+ files = [f for f in files if not spec_exc.match_file(f.as_posix())]
104
+ return files
105
+
106
+ def collect_file_tree(self) -> None:
107
+ self.logger.info("Collecting files by walking the repository root.")
108
+ all_files = []
109
+ for path in self.root_dir.rglob("*"):
110
+ if path.is_file():
111
+ try:
112
+ rel = path.relative_to(self.root_dir)
113
+ all_files.append(rel)
114
+ except ValueError:
115
+ continue
116
+ # Apply include/exclude filtering.
117
+ all_files = self._apply_include_exclude(all_files)
118
+ self.logger.debug(f"All files after include/exclude filtering: {all_files}")
119
+ if self.input_paths:
120
+ trees = []
121
+ for input_path in self.input_paths:
122
+ subset = [
123
+ f for f in all_files
124
+ if f == input_path or f.parts[:len(input_path.parts)] == input_path.parts
125
+ ]
126
+ self.logger.debug(f"Files for input path '{input_path}': {subset}")
127
+ if subset:
128
+ tree = FileSystem(self.root_dir).build_tree_structure(subset)
129
+ trees.append(tree)
130
+ if trees:
131
+ merged_tree = self.merge_trees(trees)
132
+ else:
133
+ merged_tree = {}
134
+ else:
135
+ merged_tree = FileSystem(self.root_dir).build_tree_structure(all_files)
136
+ self.logger.info("Merged tree built from input paths.")
137
+ self.file_tree = FileTree(merged_tree)
138
+ self.logger.debug(f"Merged tree structure: {self.file_tree.structure}")
139
+
140
+ def merge_trees(self, trees: List[dict]) -> dict:
141
+ """Recursively merge a list of tree dictionaries."""
142
+ merged = {}
143
+ for tree in trees:
144
+ merged = self._merge_two_trees(merged, tree)
145
+ return merged
146
+
147
+ def _merge_two_trees(self, tree1: dict, tree2: dict) -> dict:
148
+ merged = dict(tree1)
149
+ for key, value in tree2.items():
150
+ if key in merged:
151
+ if isinstance(merged[key], dict) and isinstance(value, dict):
152
+ merged[key] = self._merge_two_trees(merged[key], value)
153
+ else:
154
+ merged[key] = value
155
+ else:
156
+ merged[key] = value
157
+ return merged
158
+
159
+ def apply_filters(self) -> None:
160
+ self.logger.info("Applying .gitignore filters to the merged tree.")
161
+ spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns)
162
+ self.logger.debug(f".gitignore patterns: {self.gitignore_patterns}")
163
+ self.file_tree.filter_files(spec)
164
+
165
+ def generate_output(self) -> None:
166
+ self.logger.info("Starting Markdown generation.")
167
+ markdown_generator = MarkdownGenerator(
168
+ root_dir=self.root_dir,
169
+ output_file=self.output_file,
170
+ structure_only=self.structure_only
171
+ )
172
+ markdown_generator.generate_markdown(self.file_tree.structure, self.file_tree.get_all_files())
173
+ self.logger.info(f"Markdown generated at {self.output_file}.")
174
+
175
+ def generate_output_from_selected(self, selected_files: set) -> None:
176
+ self.logger.info("Generating Markdown from selected files.")
177
+ pruned_tree = self.file_tree.prune_tree(selected_files)
178
+ markdown_generator = MarkdownGenerator(
179
+ root_dir=self.root_dir,
180
+ output_file=self.output_file,
181
+ structure_only=False,
182
+ hide_untoggled=True
183
+ )
184
+ markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
185
+ self.logger.info(f"Markdown generated at {self.output_file}.")
186
+
187
+ def run(self) -> None:
188
+ """Run the entire process: collect files, apply filters, and generate Markdown."""
189
+ self.collect_file_tree()
190
+ self.apply_filters()
191
+ self.generate_output()
192
+
193
+ def _load_gitignore_patterns(self) -> List[str]:
194
+ gitignore_path = self.root_dir / '.gitignore'
195
+ if not gitignore_path.exists():
196
+ for parent in self.root_dir.parents:
197
+ gitignore_path = parent / '.gitignore'
198
+ if gitignore_path.exists():
199
+ break
200
+ else:
201
+ gitignore_path = None
202
+ if gitignore_path and gitignore_path.exists():
203
+ with gitignore_path.open('r') as gitignore:
204
+ patterns = gitignore.readlines()
205
+ self.logger.debug(f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}")
206
+ return patterns
207
+ else:
208
+ self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
209
+ return []
@@ -58,7 +58,11 @@ class MarkdownGenerator:
58
58
  self.logger.debug(f"File not found: {file_path}. Skipping.")
59
59
  continue
60
60
 
61
- self._write_file_content(file_path, relative_path.as_posix())
61
+ try:
62
+ self._write_file_content(file_path, relative_path.as_posix())
63
+ except UnicodeDecodeError as e:
64
+ self.logger.error(f"UnicodeDecodeError for file {file_path}: {e}")
65
+
62
66
 
63
67
  def _write_file_content(self, file_path: Path, relative_path: str) -> None:
64
68
  """
@@ -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=[],
@@ -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,), {
@@ -48,57 +50,43 @@ def test_project_controller_includes_py_files():
48
50
  'debug': False
49
51
  })
50
52
 
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
- ]
53
+ controller = ProjectController(args)
54
+ controller.run()
58
55
 
59
- controller = ProjectController(args)
60
- controller.run()
61
-
62
- # Read the output file
63
56
  with open(args.output, 'r') as f:
64
57
  output_content = f.read()
65
58
 
66
- # Check that contents of .py files are included
59
+ # Check that the contents of the Python files are included
67
60
  assert 'print("File 1")' in output_content
68
61
  assert 'print("File 2")' in output_content
69
- # .pyc files should be ignored
62
+ # The .pyc file should be filtered out by .gitignore
70
63
  assert 'Compiled code' not in output_content
71
64
 
72
65
 
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
66
+ def test_project_controller_run():
67
+ # This test patches only MarkdownGenerator to verify that generate_markdown is called.
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
+ with patch('reposnap.controllers.project_controller.MarkdownGenerator') as MockMarkdownGenerator:
83
+ mock_instance = MagicMock()
84
+ MockMarkdownGenerator.return_value = mock_instance
97
85
 
98
- controller = ProjectController(args)
99
- controller.run()
86
+ controller = ProjectController(args)
87
+ controller.run()
100
88
 
101
- mock_markdown_generator_instance.generate_markdown.assert_called_once()
89
+ mock_instance.generate_markdown.assert_called_once()
102
90
 
103
91
 
104
92
  def test_include_pattern():
@@ -118,9 +106,7 @@ def test_include_pattern():
118
106
  'setup.py': 'setup code',
119
107
  'notes.txt': 'Some notes',
120
108
  }
121
-
122
109
  create_directory_structure(temp_dir, structure)
123
-
124
110
  args = type('Args', (object,), {
125
111
  'path': temp_dir,
126
112
  'output': os.path.join(temp_dir, 'output.md'),
@@ -129,25 +115,10 @@ def test_include_pattern():
129
115
  'include': ['*.py'],
130
116
  'exclude': []
131
117
  })
118
+ controller = ProjectController(args)
119
+ controller.collect_file_tree()
132
120
 
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
-
147
- controller = ProjectController(args)
148
- controller.collect_file_tree()
149
-
150
- # Get the list of files included in the tree
121
+ # Traverse the merged tree and collect file paths.
151
122
  included_files = []
152
123
 
153
124
  def traverse(tree, path=''):
@@ -165,7 +136,6 @@ def test_include_pattern():
165
136
  os.path.join('src', 'module', 'submodule', 'file3.py'),
166
137
  'setup.py',
167
138
  ]
168
-
169
139
  assert sorted(included_files) == sorted(expected_files)
170
140
 
171
141
 
@@ -186,9 +156,7 @@ def test_exclude_pattern():
186
156
  'setup.py': 'setup code',
187
157
  'notes.txt': 'Some notes',
188
158
  }
189
-
190
159
  create_directory_structure(temp_dir, structure)
191
-
192
160
  args = type('Args', (object,), {
193
161
  'path': temp_dir,
194
162
  'output': os.path.join(temp_dir, 'output.md'),
@@ -197,22 +165,8 @@ def test_exclude_pattern():
197
165
  'include': [],
198
166
  'exclude': ['*.md', '*.txt']
199
167
  })
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
-
214
- controller = ProjectController(args)
215
- controller.collect_file_tree()
168
+ controller = ProjectController(args)
169
+ controller.collect_file_tree()
216
170
 
217
171
  included_files = []
218
172
 
@@ -231,7 +185,6 @@ def test_exclude_pattern():
231
185
  os.path.join('src', 'module', 'submodule', 'file3.py'),
232
186
  'setup.py',
233
187
  ]
234
-
235
188
  assert sorted(included_files) == sorted(expected_files)
236
189
 
237
190
 
@@ -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,95 @@ def test_include_and_exclude_patterns():
266
217
  'include': ['*foo*'],
267
218
  'exclude': ['*submodule*']
268
219
  })
220
+ controller = ProjectController(args)
221
+ controller.collect_file_tree()
269
222
 
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
-
283
- controller = ProjectController(args)
284
- controller.collect_file_tree()
285
-
286
- included_files = []
223
+ collected = []
287
224
 
288
225
  def traverse(tree, path=''):
289
226
  for name, node in tree.items():
290
227
  current_path = os.path.join(path, name)
291
228
  if isinstance(node, dict):
292
- included_files.append(current_path)
229
+ collected.append(current_path)
293
230
  traverse(node, current_path)
294
231
  else:
295
- included_files.append(current_path)
232
+ collected.append(current_path)
296
233
 
297
234
  traverse(controller.file_tree.structure)
298
-
299
- expected_files = [
235
+ expected = [
300
236
  os.path.join('src'),
301
237
  os.path.join('src', 'foo_module'),
302
238
  os.path.join('src', 'foo_module', 'foo_file1.py'),
303
- os.path.join('src', 'foo_module', 'file2.py'), # Include this file
239
+ os.path.join('src', 'foo_module', 'file2.py'),
304
240
  ]
241
+ assert sorted(collected) == sorted(expected)
305
242
 
306
- assert sorted(included_files) == sorted(expected_files)
243
+
244
+ def test_project_controller_multiple_paths():
245
+ with tempfile.TemporaryDirectory() as temp_dir:
246
+ # Define a structure similar to the provided example.
247
+ structure = {
248
+ 'README.md': 'Project README content',
249
+ 'pyproject.toml': 'project configuration',
250
+ 'LICENSE': 'MIT License',
251
+ 'src': {
252
+ 'reposnap': {
253
+ '__init__.py': '',
254
+ 'controllers': {
255
+ '__init__.py': '',
256
+ 'project_controller.py': 'print("controller")',
257
+ },
258
+ 'core': {
259
+ '__init__.py': '',
260
+ 'file_system.py': 'print("filesystem")',
261
+ 'git_repo.py': 'print("git")',
262
+ 'markdown_generator.py': 'print("markdown")',
263
+ },
264
+ 'interfaces': {
265
+ '__init__.py': '',
266
+ 'cli.py': 'print("cli")',
267
+ 'gui.py': 'print("gui")',
268
+ },
269
+ 'models': {
270
+ '__init__.py': '',
271
+ 'file_tree.py': 'print("file tree")',
272
+ },
273
+ 'utils': {
274
+ '__init__.py': '',
275
+ 'path_utils.py': 'print("path utils")',
276
+ },
277
+ },
278
+ },
279
+ 'tests': {
280
+ '__init__.py': '',
281
+ 'some_test.py': 'print("test")'
282
+ },
283
+ 'extras': {
284
+ 'notes.txt': 'Some notes'
285
+ }
286
+ }
287
+ create_directory_structure(temp_dir, structure)
288
+
289
+ # Create args with multiple paths.
290
+ args = type('Args', (object,), {
291
+ 'paths': ['README.md', 'src', 'pyproject.toml'],
292
+ 'output': os.path.join(temp_dir, 'output.md'),
293
+ 'structure_only': True,
294
+ 'debug': False,
295
+ 'include': [],
296
+ 'exclude': []
297
+ })
298
+ # Patch _get_repo_root to return our temp_dir.
299
+ with patch('reposnap.controllers.project_controller.ProjectController._get_repo_root', return_value=Path(temp_dir)):
300
+ controller = ProjectController(args)
301
+ controller.collect_file_tree()
302
+
303
+ tree = controller.file_tree.structure
304
+ # The merged tree should only include keys for the provided paths.
305
+ assert 'README.md' in tree
306
+ assert 'pyproject.toml' in tree
307
+ assert 'src' in tree
308
+ # Keys that are not part of the requested paths (like LICENSE, tests, extras) should be absent.
309
+ assert 'LICENSE' not in tree
310
+ assert 'tests' not in tree
311
+ 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
File without changes
File without changes