reposnap 0.5.1__py3-none-any.whl → 0.6.2__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 +143 -56
- reposnap/interfaces/cli.py +9 -4
- {reposnap-0.5.1.dist-info → reposnap-0.6.2.dist-info}/METADATA +5 -5
- {reposnap-0.5.1.dist-info → reposnap-0.6.2.dist-info}/RECORD +7 -7
- {reposnap-0.5.1.dist-info → reposnap-0.6.2.dist-info}/WHEEL +1 -1
- {reposnap-0.5.1.dist-info → reposnap-0.6.2.dist-info}/entry_points.txt +0 -0
- {reposnap-0.5.1.dist-info → reposnap-0.6.2.dist-info}/licenses/LICENSE +0 -0
@@ -2,86 +2,169 @@
|
|
2
2
|
|
3
3
|
import logging
|
4
4
|
from pathlib import Path
|
5
|
-
from reposnap.core.git_repo import GitRepo
|
6
5
|
from reposnap.core.file_system import FileSystem
|
7
6
|
from reposnap.core.markdown_generator import MarkdownGenerator
|
8
7
|
from reposnap.models.file_tree import FileTree
|
9
8
|
import pathspec
|
10
9
|
from typing import List, Optional
|
11
10
|
|
12
|
-
|
13
11
|
class ProjectController:
|
14
12
|
def __init__(self, args: Optional[object] = None):
|
15
13
|
self.logger = logging.getLogger(__name__)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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 = []
|
20
45
|
self.file_tree: Optional[FileTree] = None
|
21
46
|
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
47
|
if self.root_dir:
|
25
48
|
self.gitignore_patterns = self._load_gitignore_patterns()
|
26
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
|
+
|
27
79
|
def set_root_dir(self, root_dir: Path) -> None:
|
28
|
-
self.root_dir = root_dir
|
80
|
+
self.root_dir = root_dir.resolve()
|
29
81
|
self.gitignore_patterns = self._load_gitignore_patterns()
|
30
82
|
|
31
83
|
def get_file_tree(self) -> Optional[FileTree]:
|
32
84
|
return self.file_tree
|
33
85
|
|
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
|
86
|
+
def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
|
87
|
+
"""Filter a list of file paths using include and exclude patterns."""
|
46
88
|
def adjust_patterns(patterns):
|
47
89
|
adjusted = []
|
48
|
-
for
|
49
|
-
if
|
50
|
-
adjusted.append(
|
90
|
+
for p in patterns:
|
91
|
+
if any(ch in p for ch in ['*', '?', '[']):
|
92
|
+
adjusted.append(p)
|
51
93
|
else:
|
52
|
-
adjusted.append(f'*{
|
94
|
+
adjusted.append(f'*{p}*')
|
53
95
|
return adjusted
|
54
|
-
|
55
|
-
# Apply include patterns
|
56
96
|
if self.include_patterns:
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
self.logger.debug(f"Git files after include patterns: {git_files}")
|
61
|
-
|
62
|
-
# Apply exclude 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())]
|
63
100
|
if self.exclude_patterns:
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
68
105
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
75
158
|
|
76
159
|
def apply_filters(self) -> None:
|
77
|
-
self.logger.info("Applying filters to the
|
78
|
-
spec
|
79
|
-
self.logger.debug(f"
|
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}")
|
80
163
|
self.file_tree.filter_files(spec)
|
81
164
|
|
82
165
|
def generate_output(self) -> None:
|
83
|
-
self.logger.info("Starting
|
84
|
-
markdown_generator
|
166
|
+
self.logger.info("Starting Markdown generation.")
|
167
|
+
markdown_generator = MarkdownGenerator(
|
85
168
|
root_dir=self.root_dir,
|
86
169
|
output_file=self.output_file,
|
87
170
|
structure_only=self.structure_only
|
@@ -90,10 +173,9 @@ class ProjectController:
|
|
90
173
|
self.logger.info(f"Markdown generated at {self.output_file}.")
|
91
174
|
|
92
175
|
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
|
176
|
+
self.logger.info("Generating Markdown from selected files.")
|
95
177
|
pruned_tree = self.file_tree.prune_tree(selected_files)
|
96
|
-
markdown_generator
|
178
|
+
markdown_generator = MarkdownGenerator(
|
97
179
|
root_dir=self.root_dir,
|
98
180
|
output_file=self.output_file,
|
99
181
|
structure_only=False,
|
@@ -102,8 +184,14 @@ class ProjectController:
|
|
102
184
|
markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
|
103
185
|
self.logger.info(f"Markdown generated at {self.output_file}.")
|
104
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
|
+
|
105
193
|
def _load_gitignore_patterns(self) -> List[str]:
|
106
|
-
gitignore_path
|
194
|
+
gitignore_path = self.root_dir / '.gitignore'
|
107
195
|
if not gitignore_path.exists():
|
108
196
|
for parent in self.root_dir.parents:
|
109
197
|
gitignore_path = parent / '.gitignore'
|
@@ -111,12 +199,11 @@ class ProjectController:
|
|
111
199
|
break
|
112
200
|
else:
|
113
201
|
gitignore_path = None
|
114
|
-
|
115
202
|
if gitignore_path and gitignore_path.exists():
|
116
203
|
with gitignore_path.open('r') as gitignore:
|
117
|
-
patterns
|
118
|
-
self.logger.debug(f"
|
204
|
+
patterns = gitignore.readlines()
|
205
|
+
self.logger.debug(f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}")
|
119
206
|
return patterns
|
120
207
|
else:
|
121
|
-
self.logger.debug(f"No .gitignore found starting from {self.root_dir}.
|
208
|
+
self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
|
122
209
|
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=[],
|
@@ -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**:
|
@@ -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=qYsZL-8ZX6sqTdmJtVRMFnv0yHIUGk_fROMZnitwiTo,9513
|
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
12
|
reposnap/models/file_tree.py,sha256=SQ1cKW066uh1F1BcF8AXuw4Q-l6rkybxjdJEcLFjewg,3052
|
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.2.dist-info/METADATA,sha256=nZ4w5dZc3i0dk2GmcLPWdoF5pj7CO2ttce6s6-JoIFo,5348
|
16
|
+
reposnap-0.6.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
17
|
+
reposnap-0.6.2.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
|
18
|
+
reposnap-0.6.2.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
|
19
|
+
reposnap-0.6.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|