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.
@@ -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
- 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
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 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
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 pattern in patterns:
49
- if '*' in pattern or '?' in pattern or '[' in pattern:
50
- adjusted.append(pattern)
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'*{pattern}*')
73
+ adjusted.append(f'*{p}*')
53
74
  return adjusted
54
-
55
- # Apply include patterns
56
75
  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
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
- 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)
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
- self.file_tree = FileTree(tree_structure)
74
- self.logger.debug(f"Tree structure: {self.file_tree.structure}")
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 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}")
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 markdown generation.")
84
- markdown_generator: MarkdownGenerator = MarkdownGenerator(
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 markdown from selected files.")
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: MarkdownGenerator = MarkdownGenerator(
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: Path = self.root_dir / '.gitignore'
183
+ gitignore_path = self.root_dir / '.gitignore'
107
184
  if not gitignore_path.exists():
108
185
  for parent in self.root_dir.parents:
109
- gitignore_path = parent / '.gitignore'
110
- if gitignore_path.exists():
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: List[str] = gitignore.readlines()
118
- self.logger.debug(f"Patterns from .gitignore in {gitignore_path.parent}: {patterns}")
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}. Proceeding without patterns.")
198
+ self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
122
199
  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=[],
@@ -48,7 +48,8 @@ class FileTree:
48
48
  if filtered_value:
49
49
  filtered_subtree[key] = filtered_value
50
50
  else:
51
- if not spec.match_file(current_path):
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.5.1
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 ...]] 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=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=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
- reposnap/models/file_tree.py,sha256=SQ1cKW066uh1F1BcF8AXuw4Q-l6rkybxjdJEcLFjewg,3052
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.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.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,,
@@ -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