reposnap 0.5.1__py3-none-any.whl → 0.6.3__py3-none-any.whl
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/controllers/project_controller.py +138 -61
- reposnap/interfaces/cli.py +9 -4
- reposnap/models/file_tree.py +2 -1
- {reposnap-0.5.1.dist-info → reposnap-0.6.3.dist-info}/METADATA +5 -5
- {reposnap-0.5.1.dist-info → reposnap-0.6.3.dist-info}/RECORD +8 -8
- {reposnap-0.5.1.dist-info → reposnap-0.6.3.dist-info}/WHEEL +1 -1
- {reposnap-0.5.1.dist-info → reposnap-0.6.3.dist-info}/entry_points.txt +0 -0
- {reposnap-0.5.1.dist-info → reposnap-0.6.3.dist-info}/licenses/LICENSE +0 -0
@@ -1,87 +1,158 @@
|
|
1
|
-
# src/reposnap/controllers/project_controller.py
|
2
|
-
|
3
1
|
import logging
|
4
2
|
from pathlib import Path
|
5
|
-
from reposnap.core.git_repo import GitRepo
|
6
3
|
from reposnap.core.file_system import FileSystem
|
7
|
-
from reposnap.core.markdown_generator import MarkdownGenerator
|
8
4
|
from reposnap.models.file_tree import FileTree
|
9
5
|
import pathspec
|
10
6
|
from typing import List, Optional
|
11
7
|
|
12
|
-
|
13
8
|
class ProjectController:
|
14
9
|
def __init__(self, args: Optional[object] = None):
|
15
10
|
self.logger = logging.getLogger(__name__)
|
16
|
-
|
17
|
-
self.
|
18
|
-
|
19
|
-
|
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 = []
|
20
40
|
self.file_tree: Optional[FileTree] = None
|
21
41
|
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
42
|
if self.root_dir:
|
25
43
|
self.gitignore_patterns = self._load_gitignore_patterns()
|
26
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
|
+
|
27
58
|
def set_root_dir(self, root_dir: Path) -> None:
|
28
|
-
self.root_dir = root_dir
|
59
|
+
self.root_dir = root_dir.resolve()
|
29
60
|
self.gitignore_patterns = self._load_gitignore_patterns()
|
30
61
|
|
31
62
|
def get_file_tree(self) -> Optional[FileTree]:
|
32
63
|
return self.file_tree
|
33
64
|
|
34
|
-
def
|
35
|
-
|
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
|
65
|
+
def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
|
66
|
+
"""Filter a list of file paths using include and exclude patterns."""
|
46
67
|
def adjust_patterns(patterns):
|
47
68
|
adjusted = []
|
48
|
-
for
|
49
|
-
if
|
50
|
-
adjusted.append(
|
69
|
+
for p in patterns:
|
70
|
+
if any(ch in p for ch in ['*', '?', '[']):
|
71
|
+
adjusted.append(p)
|
51
72
|
else:
|
52
|
-
adjusted.append(f'*{
|
73
|
+
adjusted.append(f'*{p}*')
|
53
74
|
return adjusted
|
54
|
-
|
55
|
-
# Apply include patterns
|
56
75
|
if self.include_patterns:
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
self.logger.debug(f"Git files after include patterns: {git_files}")
|
61
|
-
|
62
|
-
# Apply exclude 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())]
|
63
79
|
if self.exclude_patterns:
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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)
|
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
|
72
84
|
|
73
|
-
|
74
|
-
self.logger.
|
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
|
75
145
|
|
76
146
|
def apply_filters(self) -> None:
|
77
|
-
self.logger.info("Applying filters to the
|
78
|
-
spec
|
79
|
-
self.logger.debug(f"
|
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}")
|
80
150
|
self.file_tree.filter_files(spec)
|
81
151
|
|
82
152
|
def generate_output(self) -> None:
|
83
|
-
self.logger.info("Starting
|
84
|
-
markdown_generator
|
153
|
+
self.logger.info("Starting Markdown generation.")
|
154
|
+
from reposnap.core.markdown_generator import MarkdownGenerator
|
155
|
+
markdown_generator = MarkdownGenerator(
|
85
156
|
root_dir=self.root_dir,
|
86
157
|
output_file=self.output_file,
|
87
158
|
structure_only=self.structure_only
|
@@ -90,10 +161,10 @@ class ProjectController:
|
|
90
161
|
self.logger.info(f"Markdown generated at {self.output_file}.")
|
91
162
|
|
92
163
|
def generate_output_from_selected(self, selected_files: set) -> None:
|
93
|
-
self.logger.info("Generating
|
94
|
-
# Build a pruned tree structure based on selected files
|
164
|
+
self.logger.info("Generating Markdown from selected files.")
|
95
165
|
pruned_tree = self.file_tree.prune_tree(selected_files)
|
96
|
-
markdown_generator
|
166
|
+
from reposnap.core.markdown_generator import MarkdownGenerator
|
167
|
+
markdown_generator = MarkdownGenerator(
|
97
168
|
root_dir=self.root_dir,
|
98
169
|
output_file=self.output_file,
|
99
170
|
structure_only=False,
|
@@ -102,21 +173,27 @@ class ProjectController:
|
|
102
173
|
markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
|
103
174
|
self.logger.info(f"Markdown generated at {self.output_file}.")
|
104
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
|
+
|
105
182
|
def _load_gitignore_patterns(self) -> List[str]:
|
106
|
-
gitignore_path
|
183
|
+
gitignore_path = self.root_dir / '.gitignore'
|
107
184
|
if not gitignore_path.exists():
|
108
185
|
for parent in self.root_dir.parents:
|
109
|
-
|
110
|
-
if
|
186
|
+
candidate = parent / '.gitignore'
|
187
|
+
if candidate.exists():
|
188
|
+
gitignore_path = candidate
|
111
189
|
break
|
112
190
|
else:
|
113
191
|
gitignore_path = None
|
114
|
-
|
115
192
|
if gitignore_path and gitignore_path.exists():
|
116
193
|
with gitignore_path.open('r') as gitignore:
|
117
|
-
patterns
|
118
|
-
self.logger.debug(f"
|
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}")
|
119
196
|
return patterns
|
120
197
|
else:
|
121
|
-
self.logger.debug(f"No .gitignore found starting from {self.root_dir}.
|
198
|
+
self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
|
122
199
|
return []
|
reposnap/interfaces/cli.py
CHANGED
@@ -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=[],
|
reposnap/models/file_tree.py
CHANGED
@@ -48,7 +48,8 @@ class FileTree:
|
|
48
48
|
if filtered_value:
|
49
49
|
filtered_subtree[key] = filtered_value
|
50
50
|
else:
|
51
|
-
if
|
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
|
|
@@ -1,6 +1,6 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: reposnap
|
3
|
-
Version: 0.
|
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 ...]]
|
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**:
|
@@ -1,19 +1,19 @@
|
|
1
1
|
reposnap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
reposnap/controllers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
reposnap/controllers/project_controller.py,sha256=
|
3
|
+
reposnap/controllers/project_controller.py,sha256=u3agILanSms5Gx4D5e6EWhHrb6B08saz5udct8yVS-s,9353
|
4
4
|
reposnap/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
5
|
reposnap/core/file_system.py,sha256=82gwvmgrsWf63paMrIz-Z0eqIjbqt9_-vujdXlJJoFE,1074
|
6
6
|
reposnap/core/git_repo.py,sha256=2u_ILkV-Ur7qr1WHmHM2yg44Ggft61RsdbZLsZaQ5NU,1256
|
7
7
|
reposnap/core/markdown_generator.py,sha256=Ld6ix4gzkLJJyeUoWHwhpbAf3DvEC5E0S1DykYnLGnQ,3297
|
8
8
|
reposnap/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
|
-
reposnap/interfaces/cli.py,sha256=
|
9
|
+
reposnap/interfaces/cli.py,sha256=JzTNDibzuRRmnWg-gBfKJ2tSlh-NYSL_3q6J-Erjrr8,1374
|
10
10
|
reposnap/interfaces/gui.py,sha256=pzWQbW55gBNZu4tXRdBFic39upGtYxew91FSiEvalj0,5421
|
11
11
|
reposnap/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
reposnap/models/file_tree.py,sha256=
|
12
|
+
reposnap/models/file_tree.py,sha256=0WcSDbFH5pSZHyWxWtmz-FF4_ELnZ3Byz2iXN4Tpijw,3206
|
13
13
|
reposnap/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
14
14
|
reposnap/utils/path_utils.py,sha256=7072816LCP8Q8XBydn0iknmfrObPO_-2rFqpbAvPrjY,501
|
15
|
-
reposnap-0.
|
16
|
-
reposnap-0.
|
17
|
-
reposnap-0.
|
18
|
-
reposnap-0.
|
19
|
-
reposnap-0.
|
15
|
+
reposnap-0.6.3.dist-info/METADATA,sha256=wPRE4NKJuzwMKTyYD8e2nPUYUNjwM81kCEsVz18yWTY,5348
|
16
|
+
reposnap-0.6.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
17
|
+
reposnap-0.6.3.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
|
18
|
+
reposnap-0.6.3.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
|
19
|
+
reposnap-0.6.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|