reposnap 0.5.1__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.
- {reposnap-0.5.1 → reposnap-0.6.2}/.gitignore +1 -0
- reposnap-0.6.2/.python-version +1 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/PKG-INFO +5 -5
- {reposnap-0.5.1 → reposnap-0.6.2}/README.md +3 -3
- {reposnap-0.5.1 → reposnap-0.6.2}/pyproject.toml +1 -1
- {reposnap-0.5.1 → reposnap-0.6.2}/requirements-dev.lock +11 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/requirements.lock +8 -0
- reposnap-0.6.2/src/reposnap/controllers/project_controller.py +209 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/interfaces/cli.py +9 -4
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_project_controller.py +113 -108
- reposnap-0.5.1/.python-version +0 -1
- reposnap-0.5.1/src/reposnap/controllers/project_controller.py +0 -122
- {reposnap-0.5.1 → reposnap-0.6.2}/LICENSE +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/controllers/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/core/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/core/file_system.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/core/git_repo.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/core/markdown_generator.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/interfaces/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/interfaces/gui.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/models/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/models/file_tree.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/utils/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/src/reposnap/utils/path_utils.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/__init__.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_cli.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_file_system.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_file_tree.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_git_repo.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_gui.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_markdown_generator.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/reposnap/test_path_utils.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/resources/another_existing_file.py +0 -0
- {reposnap-0.5.1 → reposnap-0.6.2}/tests/resources/existing_file.py +0 -0
@@ -0,0 +1 @@
|
|
1
|
+
3.12.6
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: reposnap
|
3
|
-
Version: 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
|
@@ -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 ...]]
|
52
|
+
reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
|
53
53
|
```
|
54
54
|
|
55
|
-
- `
|
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
|
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 ...]]
|
39
|
+
reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
|
40
40
|
```
|
41
41
|
|
42
|
-
- `
|
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
|
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**:
|
@@ -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,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 []
|
@@ -6,14 +6,19 @@ from reposnap.controllers.project_controller import ProjectController
|
|
6
6
|
|
7
7
|
|
8
8
|
def main():
|
9
|
-
parser = argparse.ArgumentParser(
|
10
|
-
|
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
|
-
|
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
|
-
|
52
|
-
|
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
|
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
|
62
|
+
# The .pyc file should be filtered out by .gitignore
|
70
63
|
assert 'Compiled code' not in output_content
|
71
64
|
|
72
65
|
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
99
|
-
|
86
|
+
controller = ProjectController(args)
|
87
|
+
controller.run()
|
100
88
|
|
101
|
-
|
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
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
|
229
|
+
collected.append(current_path)
|
293
230
|
traverse(node, current_path)
|
294
231
|
else:
|
295
|
-
|
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'),
|
239
|
+
os.path.join('src', 'foo_module', 'file2.py'),
|
304
240
|
]
|
241
|
+
assert sorted(collected) == sorted(expected)
|
305
242
|
|
306
|
-
|
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
|
reposnap-0.5.1/.python-version
DELETED
@@ -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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|