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.
@@ -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
- 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
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 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
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 pattern in patterns:
49
- if '*' in pattern or '?' in pattern or '[' in pattern:
50
- adjusted.append(pattern)
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'*{pattern}*')
94
+ adjusted.append(f'*{p}*')
53
95
  return adjusted
54
-
55
- # Apply include patterns
56
96
  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
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
- 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}")
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
- 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}")
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 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}")
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 markdown generation.")
84
- markdown_generator: MarkdownGenerator = MarkdownGenerator(
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 markdown from selected files.")
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: MarkdownGenerator = MarkdownGenerator(
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: Path = self.root_dir / '.gitignore'
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: List[str] = gitignore.readlines()
118
- self.logger.debug(f"Patterns from .gitignore in {gitignore_path.parent}: {patterns}")
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}. Proceeding without patterns.")
208
+ self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
122
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(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=[],
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.5.1
3
+ Version: 0.6.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 ...]] 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**:
@@ -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=hDYNLE_OtusAZOb6sKyvSFtbc7j-DUuYcmFST7U5Dao,5638
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=tq9OfVE1NvUYrsdr2ewx6nBhG76k8E-yucwVgTo-LM8,1302
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.5.1.dist-info/METADATA,sha256=D6PPKKJAUGFYWK02v1_2WhvGVXUIFv_OoZBOlv9-AUw,5241
16
- reposnap-0.5.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
17
- reposnap-0.5.1.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
18
- reposnap-0.5.1.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
19
- reposnap-0.5.1.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.25.0
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any