reposnap 0.6.4__py3-none-any.whl → 0.6.5__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/__init__.py +0 -0
- reposnap/controllers/__init__.py +0 -0
- reposnap/controllers/project_controller.py +247 -0
- reposnap/core/__init__.py +0 -0
- reposnap/core/file_system.py +32 -0
- reposnap/core/git_repo.py +32 -0
- reposnap/core/markdown_generator.py +91 -0
- reposnap/interfaces/__init__.py +0 -0
- reposnap/interfaces/cli.py +56 -0
- reposnap/interfaces/gui.py +158 -0
- reposnap/models/__init__.py +0 -0
- reposnap/models/file_tree.py +91 -0
- reposnap/utils/__init__.py +0 -0
- reposnap/utils/path_utils.py +15 -0
- {reposnap-0.6.4.dist-info → reposnap-0.6.5.dist-info}/METADATA +1 -1
- reposnap-0.6.5.dist-info/RECORD +19 -0
- reposnap-0.6.4.dist-info/RECORD +0 -5
- {reposnap-0.6.4.dist-info → reposnap-0.6.5.dist-info}/WHEEL +0 -0
- {reposnap-0.6.4.dist-info → reposnap-0.6.5.dist-info}/entry_points.txt +0 -0
- {reposnap-0.6.4.dist-info → reposnap-0.6.5.dist-info}/licenses/LICENSE +0 -0
reposnap/__init__.py
ADDED
File without changes
|
File without changes
|
@@ -0,0 +1,247 @@
|
|
1
|
+
import logging
|
2
|
+
from pathlib import Path
|
3
|
+
from reposnap.core.file_system import FileSystem
|
4
|
+
from reposnap.models.file_tree import FileTree
|
5
|
+
import pathspec
|
6
|
+
from typing import List, Optional
|
7
|
+
|
8
|
+
|
9
|
+
class ProjectController:
|
10
|
+
def __init__(self, args: Optional[object] = None):
|
11
|
+
self.logger = logging.getLogger(__name__)
|
12
|
+
# Always determine repository root using Git (or cwd)
|
13
|
+
self.root_dir = self._get_repo_root().resolve()
|
14
|
+
if args:
|
15
|
+
self.args = args
|
16
|
+
# Treat positional arguments as literal file/directory names.
|
17
|
+
input_paths = [
|
18
|
+
Path(p) for p in (args.paths if hasattr(args, "paths") else [args.path])
|
19
|
+
]
|
20
|
+
self.input_paths = []
|
21
|
+
for p in input_paths:
|
22
|
+
candidate = (self.root_dir / p).resolve()
|
23
|
+
if candidate.exists():
|
24
|
+
try:
|
25
|
+
rel = candidate.relative_to(self.root_dir)
|
26
|
+
if rel != Path("."):
|
27
|
+
self.input_paths.append(rel)
|
28
|
+
except ValueError:
|
29
|
+
self.logger.warning(
|
30
|
+
f"Path {p} is not under repository root {self.root_dir}. Ignoring."
|
31
|
+
)
|
32
|
+
else:
|
33
|
+
self.logger.warning(
|
34
|
+
f"Path {p} does not exist relative to repository root {self.root_dir}."
|
35
|
+
)
|
36
|
+
self.output_file: Path = (
|
37
|
+
Path(args.output).resolve()
|
38
|
+
if args.output
|
39
|
+
else self.root_dir / "output.md"
|
40
|
+
)
|
41
|
+
self.structure_only: bool = (
|
42
|
+
args.structure_only if hasattr(args, "structure_only") else False
|
43
|
+
)
|
44
|
+
self.include_patterns: List[str] = (
|
45
|
+
args.include if hasattr(args, "include") else []
|
46
|
+
)
|
47
|
+
self.exclude_patterns: List[str] = (
|
48
|
+
args.exclude if hasattr(args, "exclude") else []
|
49
|
+
)
|
50
|
+
else:
|
51
|
+
self.args = None
|
52
|
+
self.input_paths = []
|
53
|
+
self.output_file = self.root_dir / "output.md"
|
54
|
+
self.structure_only = False
|
55
|
+
self.include_patterns = []
|
56
|
+
self.exclude_patterns = []
|
57
|
+
self.file_tree: Optional[FileTree] = None
|
58
|
+
self.gitignore_patterns: List[str] = []
|
59
|
+
if self.root_dir:
|
60
|
+
self.gitignore_patterns = self._load_gitignore_patterns()
|
61
|
+
|
62
|
+
def _get_repo_root(self) -> Path:
|
63
|
+
"""
|
64
|
+
Determine the repository root using Git if available,
|
65
|
+
otherwise use the current directory.
|
66
|
+
"""
|
67
|
+
from git import Repo, InvalidGitRepositoryError
|
68
|
+
|
69
|
+
try:
|
70
|
+
repo = Repo(Path.cwd(), search_parent_directories=True)
|
71
|
+
return Path(repo.working_tree_dir).resolve()
|
72
|
+
except InvalidGitRepositoryError:
|
73
|
+
self.logger.warning(
|
74
|
+
"Not a git repository. Using current directory as root."
|
75
|
+
)
|
76
|
+
return Path.cwd().resolve()
|
77
|
+
|
78
|
+
def set_root_dir(self, root_dir: Path) -> None:
|
79
|
+
self.root_dir = root_dir.resolve()
|
80
|
+
self.gitignore_patterns = self._load_gitignore_patterns()
|
81
|
+
|
82
|
+
def get_file_tree(self) -> Optional[FileTree]:
|
83
|
+
return self.file_tree
|
84
|
+
|
85
|
+
def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
|
86
|
+
"""Filter a list of file paths using include and exclude patterns."""
|
87
|
+
|
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
|
+
|
97
|
+
if self.include_patterns:
|
98
|
+
inc = adjust_patterns(self.include_patterns)
|
99
|
+
spec_inc = pathspec.PathSpec.from_lines(
|
100
|
+
pathspec.patterns.GitWildMatchPattern, inc
|
101
|
+
)
|
102
|
+
files = [f for f in files if spec_inc.match_file(f.as_posix())]
|
103
|
+
if self.exclude_patterns:
|
104
|
+
exc = adjust_patterns(self.exclude_patterns)
|
105
|
+
spec_exc = pathspec.PathSpec.from_lines(
|
106
|
+
pathspec.patterns.GitWildMatchPattern, exc
|
107
|
+
)
|
108
|
+
files = [f for f in files if not spec_exc.match_file(f.as_posix())]
|
109
|
+
return files
|
110
|
+
|
111
|
+
def collect_file_tree(self) -> None:
|
112
|
+
self.logger.info("Collecting files from Git tracked files if available.")
|
113
|
+
try:
|
114
|
+
from reposnap.core.git_repo import GitRepo
|
115
|
+
|
116
|
+
git_repo = GitRepo(self.root_dir)
|
117
|
+
all_files = git_repo.get_git_files()
|
118
|
+
self.logger.debug(f"Git tracked files: {all_files}")
|
119
|
+
except Exception as e:
|
120
|
+
self.logger.warning(f"Error obtaining Git tracked files: {e}.")
|
121
|
+
all_files = []
|
122
|
+
# If Git returns an empty list but files exist on disk, fall back to filesystem scan.
|
123
|
+
if not all_files:
|
124
|
+
file_list = [p for p in self.root_dir.rglob("*") if p.is_file()]
|
125
|
+
if file_list:
|
126
|
+
self.logger.info(
|
127
|
+
"Git tracked files empty, using filesystem scan fallback."
|
128
|
+
)
|
129
|
+
all_files = []
|
130
|
+
for path in file_list:
|
131
|
+
try:
|
132
|
+
rel = path.relative_to(self.root_dir)
|
133
|
+
all_files.append(rel)
|
134
|
+
except ValueError:
|
135
|
+
continue
|
136
|
+
all_files = self._apply_include_exclude(all_files)
|
137
|
+
self.logger.debug(f"All files after applying include/exclude: {all_files}")
|
138
|
+
if self.input_paths:
|
139
|
+
trees = []
|
140
|
+
for input_path in self.input_paths:
|
141
|
+
subset = [
|
142
|
+
f
|
143
|
+
for f in all_files
|
144
|
+
if f == input_path
|
145
|
+
or list(f.parts[: len(input_path.parts)]) == list(input_path.parts)
|
146
|
+
]
|
147
|
+
self.logger.debug(f"Files for input path '{input_path}': {subset}")
|
148
|
+
if subset:
|
149
|
+
tree = FileSystem(self.root_dir).build_tree_structure(subset)
|
150
|
+
trees.append(tree)
|
151
|
+
if trees:
|
152
|
+
merged_tree = self.merge_trees(trees)
|
153
|
+
else:
|
154
|
+
merged_tree = {}
|
155
|
+
else:
|
156
|
+
merged_tree = FileSystem(self.root_dir).build_tree_structure(all_files)
|
157
|
+
self.logger.info("Merged tree built from input paths.")
|
158
|
+
self.file_tree = FileTree(merged_tree)
|
159
|
+
self.logger.debug(f"Merged tree structure: {self.file_tree.structure}")
|
160
|
+
|
161
|
+
def merge_trees(self, trees: List[dict]) -> dict:
|
162
|
+
"""Recursively merge a list of tree dictionaries."""
|
163
|
+
merged = {}
|
164
|
+
for tree in trees:
|
165
|
+
merged = self._merge_two_trees(merged, tree)
|
166
|
+
return merged
|
167
|
+
|
168
|
+
def _merge_two_trees(self, tree1: dict, tree2: dict) -> dict:
|
169
|
+
merged = dict(tree1)
|
170
|
+
for key, value in tree2.items():
|
171
|
+
if key in merged:
|
172
|
+
if isinstance(merged[key], dict) and isinstance(value, dict):
|
173
|
+
merged[key] = self._merge_two_trees(merged[key], value)
|
174
|
+
else:
|
175
|
+
merged[key] = value
|
176
|
+
else:
|
177
|
+
merged[key] = value
|
178
|
+
return merged
|
179
|
+
|
180
|
+
def apply_filters(self) -> None:
|
181
|
+
self.logger.info("Applying .gitignore filters to the merged tree.")
|
182
|
+
spec = pathspec.PathSpec.from_lines(
|
183
|
+
pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns
|
184
|
+
)
|
185
|
+
self.logger.debug(f".gitignore patterns: {self.gitignore_patterns}")
|
186
|
+
self.file_tree.filter_files(spec)
|
187
|
+
|
188
|
+
def generate_output(self) -> None:
|
189
|
+
self.logger.info("Starting Markdown generation.")
|
190
|
+
from reposnap.core.markdown_generator import MarkdownGenerator
|
191
|
+
|
192
|
+
markdown_generator = MarkdownGenerator(
|
193
|
+
root_dir=self.root_dir,
|
194
|
+
output_file=self.output_file,
|
195
|
+
structure_only=self.structure_only,
|
196
|
+
)
|
197
|
+
markdown_generator.generate_markdown(
|
198
|
+
self.file_tree.structure, self.file_tree.get_all_files()
|
199
|
+
)
|
200
|
+
self.logger.info(f"Markdown generated at {self.output_file}.")
|
201
|
+
|
202
|
+
def generate_output_from_selected(self, selected_files: set) -> None:
|
203
|
+
self.logger.info("Generating Markdown from selected files.")
|
204
|
+
pruned_tree = self.file_tree.prune_tree(selected_files)
|
205
|
+
from reposnap.core.markdown_generator import MarkdownGenerator
|
206
|
+
|
207
|
+
markdown_generator = MarkdownGenerator(
|
208
|
+
root_dir=self.root_dir,
|
209
|
+
output_file=self.output_file,
|
210
|
+
structure_only=False,
|
211
|
+
hide_untoggled=True,
|
212
|
+
)
|
213
|
+
markdown_generator.generate_markdown(
|
214
|
+
pruned_tree, [Path(f) for f in selected_files]
|
215
|
+
)
|
216
|
+
self.logger.info(f"Markdown generated at {self.output_file}.")
|
217
|
+
|
218
|
+
def run(self) -> None:
|
219
|
+
"""Run the entire process: collect files, apply filters, and generate Markdown."""
|
220
|
+
self.collect_file_tree()
|
221
|
+
self.apply_filters()
|
222
|
+
self.generate_output()
|
223
|
+
|
224
|
+
def _load_gitignore_patterns(self) -> List[str]:
|
225
|
+
gitignore_path = self.root_dir / ".gitignore"
|
226
|
+
if not gitignore_path.exists():
|
227
|
+
for parent in self.root_dir.parents:
|
228
|
+
candidate = parent / ".gitignore"
|
229
|
+
if candidate.exists():
|
230
|
+
gitignore_path = candidate
|
231
|
+
break
|
232
|
+
else:
|
233
|
+
gitignore_path = None
|
234
|
+
if gitignore_path and gitignore_path.exists():
|
235
|
+
with gitignore_path.open("r") as gitignore:
|
236
|
+
patterns = [
|
237
|
+
line.strip()
|
238
|
+
for line in gitignore
|
239
|
+
if line.strip() and not line.strip().startswith("#")
|
240
|
+
]
|
241
|
+
self.logger.debug(
|
242
|
+
f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}"
|
243
|
+
)
|
244
|
+
return patterns
|
245
|
+
else:
|
246
|
+
self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
|
247
|
+
return []
|
File without changes
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# src/reposnap/core/file_system.py
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import List, Dict, Any
|
6
|
+
|
7
|
+
|
8
|
+
class FileSystem:
|
9
|
+
def __init__(self, root_dir: Path):
|
10
|
+
self.root_dir: Path = root_dir.resolve()
|
11
|
+
self.logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
def build_tree_structure(self, files: List[Path]) -> Dict[str, Any]:
|
14
|
+
"""
|
15
|
+
Builds a hierarchical tree structure from the list of files.
|
16
|
+
|
17
|
+
Args:
|
18
|
+
files (List[Path]): List of file paths relative to root_dir.
|
19
|
+
|
20
|
+
Returns:
|
21
|
+
Dict[str, Any]: Nested dictionary representing the directory structure.
|
22
|
+
"""
|
23
|
+
tree: Dict[str, Any] = {}
|
24
|
+
self.logger.debug("Building tree structure.")
|
25
|
+
for relative_path in files:
|
26
|
+
parts = relative_path.parts
|
27
|
+
current_level = tree
|
28
|
+
for part in parts[:-1]:
|
29
|
+
current_level = current_level.setdefault(part, {})
|
30
|
+
current_level[parts[-1]] = None # Indicate a file node
|
31
|
+
self.logger.debug(f"Tree structure built: {tree}")
|
32
|
+
return tree
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# src/reposnap/core/git_repo.py
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from pathlib import Path
|
5
|
+
from git import Repo, InvalidGitRepositoryError
|
6
|
+
from typing import List
|
7
|
+
|
8
|
+
|
9
|
+
class GitRepo:
|
10
|
+
def __init__(self, repo_path: Path):
|
11
|
+
self.repo_path: Path = repo_path.resolve()
|
12
|
+
self.logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
def get_git_files(self) -> List[Path]:
|
15
|
+
try:
|
16
|
+
repo: Repo = Repo(self.repo_path, search_parent_directories=True)
|
17
|
+
repo_root: Path = Path(repo.working_tree_dir).resolve()
|
18
|
+
git_files: List[str] = repo.git.ls_files().splitlines()
|
19
|
+
self.logger.debug(f"Git files from {repo_root}: {git_files}")
|
20
|
+
git_files_relative: List[Path] = []
|
21
|
+
for f in git_files:
|
22
|
+
absolute_path: Path = (repo_root / f).resolve()
|
23
|
+
try:
|
24
|
+
relative_path: Path = absolute_path.relative_to(self.repo_path)
|
25
|
+
git_files_relative.append(relative_path)
|
26
|
+
except ValueError:
|
27
|
+
# Skip files not under root_dir
|
28
|
+
continue
|
29
|
+
return git_files_relative
|
30
|
+
except InvalidGitRepositoryError:
|
31
|
+
self.logger.error(f"Invalid Git repository at: {self.repo_path}")
|
32
|
+
return []
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# src/reposnap/core/markdown_generator.py ★ fully-rewritten file
|
2
|
+
import logging
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Dict, List, Any
|
5
|
+
|
6
|
+
from reposnap.utils.path_utils import format_tree
|
7
|
+
|
8
|
+
|
9
|
+
class MarkdownGenerator:
|
10
|
+
"""Render the collected file-tree into a single Markdown document."""
|
11
|
+
|
12
|
+
def __init__(
|
13
|
+
self,
|
14
|
+
root_dir: Path,
|
15
|
+
output_file: Path,
|
16
|
+
structure_only: bool = False,
|
17
|
+
hide_untoggled: bool = False,
|
18
|
+
):
|
19
|
+
self.root_dir = root_dir.resolve()
|
20
|
+
self.output_file = output_file.resolve()
|
21
|
+
self.structure_only = structure_only
|
22
|
+
self.hide_untoggled = hide_untoggled
|
23
|
+
self.logger = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
# --------------------------------------------------------------
|
26
|
+
# public API
|
27
|
+
# --------------------------------------------------------------
|
28
|
+
def generate_markdown(
|
29
|
+
self, tree_structure: Dict[str, Any], files: List[Path]
|
30
|
+
) -> None:
|
31
|
+
"""Write header (tree) and, unless *structure_only*, every file body."""
|
32
|
+
self._write_header(tree_structure)
|
33
|
+
if not self.structure_only:
|
34
|
+
self._write_file_contents(files)
|
35
|
+
|
36
|
+
# --------------------------------------------------------------
|
37
|
+
# helpers
|
38
|
+
# --------------------------------------------------------------
|
39
|
+
def _write_header(self, tree_structure: Dict[str, Any]) -> None:
|
40
|
+
"""Emit the *Project Structure* section."""
|
41
|
+
self.logger.debug("Writing Markdown header and project structure.")
|
42
|
+
try:
|
43
|
+
with self.output_file.open(mode="w", encoding="utf-8") as fh:
|
44
|
+
fh.write("# Project Structure\n\n```\n")
|
45
|
+
for line in format_tree(
|
46
|
+
tree_structure, hide_untoggled=self.hide_untoggled
|
47
|
+
):
|
48
|
+
fh.write(line)
|
49
|
+
fh.write("```\n\n")
|
50
|
+
except OSError as exc:
|
51
|
+
self.logger.error("Failed to write header: %s", exc)
|
52
|
+
raise
|
53
|
+
|
54
|
+
def _write_file_contents(self, files: List[Path]) -> None:
|
55
|
+
"""Append every file in *files* under its own fenced section."""
|
56
|
+
self.logger.debug("Writing file contents to Markdown.")
|
57
|
+
for rel_path in files:
|
58
|
+
abs_path = self.root_dir / rel_path
|
59
|
+
if not abs_path.exists(): # git had stale entry
|
60
|
+
self.logger.debug("File not found: %s -- skipping.", abs_path)
|
61
|
+
continue
|
62
|
+
try:
|
63
|
+
self._write_single_file(abs_path, rel_path.as_posix())
|
64
|
+
except UnicodeDecodeError as exc:
|
65
|
+
self.logger.error("Unicode error for %s: %s", abs_path, exc)
|
66
|
+
|
67
|
+
# --------------------------------------------------------------
|
68
|
+
# single-file writer
|
69
|
+
# --------------------------------------------------------------
|
70
|
+
def _write_single_file(self, file_path: Path, rel_str: str) -> None:
|
71
|
+
"""
|
72
|
+
Append one file.
|
73
|
+
|
74
|
+
We guarantee **one and only one** newline between the last character
|
75
|
+
of *content* and the closing code-fence so the output is stable and
|
76
|
+
deterministic (important for tests and downstream diff-tools).
|
77
|
+
"""
|
78
|
+
try:
|
79
|
+
with file_path.open(encoding="utf-8") as src:
|
80
|
+
content = src.read()
|
81
|
+
|
82
|
+
with self.output_file.open(mode="a", encoding="utf-8") as dst:
|
83
|
+
dst.write(f"## {rel_str}\n\n")
|
84
|
+
dst.write("```python\n" if file_path.suffix == ".py" else "```\n")
|
85
|
+
|
86
|
+
# normalise trailing EOL → exactly one '\n'
|
87
|
+
dst.write(content if content.endswith("\n") else f"{content}\n")
|
88
|
+
|
89
|
+
dst.write("```\n\n")
|
90
|
+
except OSError as exc:
|
91
|
+
self.logger.error("Error processing %s: %s", file_path, exc)
|
File without changes
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# src/reposnap/interfaces/cli.py
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import logging
|
5
|
+
from reposnap.controllers.project_controller import ProjectController
|
6
|
+
|
7
|
+
|
8
|
+
def main():
|
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
|
+
)
|
18
|
+
parser.add_argument(
|
19
|
+
"-o", "--output", help="Output Markdown file", default="output.md"
|
20
|
+
)
|
21
|
+
parser.add_argument(
|
22
|
+
"--structure-only",
|
23
|
+
action="store_true",
|
24
|
+
help="Only include the file structure without content.",
|
25
|
+
)
|
26
|
+
parser.add_argument(
|
27
|
+
"--debug", action="store_true", help="Enable debug-level logging."
|
28
|
+
)
|
29
|
+
parser.add_argument(
|
30
|
+
"-i",
|
31
|
+
"--include",
|
32
|
+
nargs="*",
|
33
|
+
default=[],
|
34
|
+
help="File/folder patterns to include.",
|
35
|
+
)
|
36
|
+
parser.add_argument(
|
37
|
+
"-e",
|
38
|
+
"--exclude",
|
39
|
+
nargs="*",
|
40
|
+
default=[],
|
41
|
+
help="File/folder patterns to exclude.",
|
42
|
+
)
|
43
|
+
|
44
|
+
args = parser.parse_args()
|
45
|
+
|
46
|
+
log_level = logging.DEBUG if args.debug else logging.INFO
|
47
|
+
logging.basicConfig(
|
48
|
+
level=log_level, format="%(asctime)s - %(levelname)s - %(message)s"
|
49
|
+
)
|
50
|
+
|
51
|
+
controller = ProjectController(args)
|
52
|
+
controller.run()
|
53
|
+
|
54
|
+
|
55
|
+
if __name__ == "__main__":
|
56
|
+
main()
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# src/reposnap/interfaces/gui.py
|
2
|
+
|
3
|
+
import urwid
|
4
|
+
from pathlib import Path
|
5
|
+
from reposnap.controllers.project_controller import ProjectController
|
6
|
+
|
7
|
+
|
8
|
+
class MyCheckBox(urwid.CheckBox):
|
9
|
+
def __init__(self, label, user_data=None, **kwargs):
|
10
|
+
super().__init__(label, **kwargs)
|
11
|
+
self.user_data = user_data
|
12
|
+
|
13
|
+
|
14
|
+
class RepoSnapGUI:
|
15
|
+
def __init__(self):
|
16
|
+
self.controller = ProjectController()
|
17
|
+
self.root_dir = Path(".").resolve()
|
18
|
+
self.file_tree = None
|
19
|
+
self.selected_files = set()
|
20
|
+
|
21
|
+
self.main_loop = None
|
22
|
+
self.build_main_menu()
|
23
|
+
|
24
|
+
def build_main_menu(self):
|
25
|
+
self.root_dir_edit = urwid.Edit(
|
26
|
+
("bold", "Root Directory: "), str(self.root_dir)
|
27
|
+
)
|
28
|
+
scan_button = urwid.Button("Scan", on_press=self.on_scan)
|
29
|
+
|
30
|
+
main_menu = urwid.Frame(
|
31
|
+
header=urwid.Text(("bold", "RepoSnap - Main Menu")),
|
32
|
+
body=urwid.Padding(
|
33
|
+
urwid.LineBox(
|
34
|
+
urwid.ListBox(urwid.SimpleFocusListWalker([self.root_dir_edit])),
|
35
|
+
title="Enter Root Directory",
|
36
|
+
),
|
37
|
+
left=2,
|
38
|
+
right=2,
|
39
|
+
),
|
40
|
+
footer=urwid.Padding(scan_button, align="center"),
|
41
|
+
)
|
42
|
+
|
43
|
+
self.main_widget = main_menu
|
44
|
+
|
45
|
+
def on_scan(self, button):
|
46
|
+
self.root_dir = Path(self.root_dir_edit.edit_text).resolve()
|
47
|
+
self.controller.set_root_dir(self.root_dir)
|
48
|
+
self.controller.collect_file_tree()
|
49
|
+
self.file_tree = self.controller.get_file_tree()
|
50
|
+
self.build_file_tree_menu()
|
51
|
+
|
52
|
+
def build_file_tree_menu(self):
|
53
|
+
tree_widgets = self.build_tree_widget(self.file_tree.structure)
|
54
|
+
tree_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(tree_widgets))
|
55
|
+
render_button = urwid.Button("Render", on_press=self.on_render)
|
56
|
+
|
57
|
+
tree_menu = urwid.Frame(
|
58
|
+
header=urwid.Text(("bold", f"File Tree of {self.root_dir}")),
|
59
|
+
body=urwid.LineBox(tree_listbox),
|
60
|
+
footer=urwid.Padding(render_button, align="center"),
|
61
|
+
)
|
62
|
+
|
63
|
+
self.main_widget = tree_menu
|
64
|
+
self.refresh()
|
65
|
+
|
66
|
+
def build_tree_widget(self, tree_structure, parent_path="", level=0):
|
67
|
+
widgets = []
|
68
|
+
for key, value in sorted(tree_structure.items()):
|
69
|
+
node_path = f"{parent_path}/{key}".lstrip("/")
|
70
|
+
checkbox = MyCheckBox(
|
71
|
+
key,
|
72
|
+
user_data={"path": node_path, "level": level},
|
73
|
+
state=False,
|
74
|
+
on_state_change=self.on_checkbox_change,
|
75
|
+
)
|
76
|
+
indented_checkbox = urwid.Padding(checkbox, left=4 * level)
|
77
|
+
widgets.append(indented_checkbox)
|
78
|
+
if isinstance(value, dict):
|
79
|
+
widgets.extend(
|
80
|
+
self.build_tree_widget(value, node_path, level=level + 1)
|
81
|
+
)
|
82
|
+
return widgets
|
83
|
+
|
84
|
+
def on_checkbox_change(self, checkbox, state):
|
85
|
+
user_data = checkbox.user_data
|
86
|
+
node_path = user_data["path"]
|
87
|
+
level = user_data["level"]
|
88
|
+
if state:
|
89
|
+
self.selected_files.add(node_path)
|
90
|
+
else:
|
91
|
+
self.selected_files.discard(node_path)
|
92
|
+
# Handle toggling all children
|
93
|
+
self.toggle_children(checkbox, state, level)
|
94
|
+
|
95
|
+
def toggle_children(self, checkbox, state, level):
|
96
|
+
listbox = self.main_widget.body.original_widget.body
|
97
|
+
walker = listbox
|
98
|
+
# Find the index of the Padding widget that contains the checkbox
|
99
|
+
idx = None
|
100
|
+
for i, widget in enumerate(walker):
|
101
|
+
if isinstance(widget, urwid.Padding) and widget.original_widget == checkbox:
|
102
|
+
idx = i
|
103
|
+
break
|
104
|
+
if idx is None:
|
105
|
+
return
|
106
|
+
idx += 1
|
107
|
+
while idx < len(walker):
|
108
|
+
widget = walker[idx]
|
109
|
+
if isinstance(widget, urwid.Padding):
|
110
|
+
checkbox_widget = widget.original_widget
|
111
|
+
widget_user_data = checkbox_widget.user_data
|
112
|
+
widget_level = widget_user_data["level"]
|
113
|
+
if widget_level > level:
|
114
|
+
checkbox_widget.set_state(state, do_callback=False)
|
115
|
+
node_path = widget_user_data["path"]
|
116
|
+
if state:
|
117
|
+
self.selected_files.add(node_path)
|
118
|
+
else:
|
119
|
+
self.selected_files.discard(node_path)
|
120
|
+
idx += 1
|
121
|
+
else:
|
122
|
+
break
|
123
|
+
else:
|
124
|
+
idx += 1
|
125
|
+
|
126
|
+
def on_render(self, button):
|
127
|
+
self.controller.generate_output_from_selected(self.selected_files)
|
128
|
+
message = urwid.Text(
|
129
|
+
("bold", f"Markdown generated at {self.controller.output_file}")
|
130
|
+
)
|
131
|
+
exit_button = urwid.Button("Exit", on_press=self.exit_program)
|
132
|
+
result_menu = urwid.Frame(
|
133
|
+
header=urwid.Text(("bold", "Success")),
|
134
|
+
body=urwid.Filler(message, valign="middle"),
|
135
|
+
footer=urwid.Padding(exit_button, align="center"),
|
136
|
+
)
|
137
|
+
self.main_widget = result_menu
|
138
|
+
self.refresh()
|
139
|
+
|
140
|
+
def refresh(self):
|
141
|
+
if self.main_loop:
|
142
|
+
self.main_loop.widget = self.main_widget
|
143
|
+
|
144
|
+
def exit_program(self, button):
|
145
|
+
raise urwid.ExitMainLoop()
|
146
|
+
|
147
|
+
def run(self):
|
148
|
+
self.main_loop = urwid.MainLoop(self.main_widget)
|
149
|
+
self.main_loop.run()
|
150
|
+
|
151
|
+
|
152
|
+
def main():
|
153
|
+
app = RepoSnapGUI()
|
154
|
+
app.run()
|
155
|
+
|
156
|
+
|
157
|
+
if __name__ == "__main__":
|
158
|
+
main()
|
File without changes
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# src/reposnap/models/file_tree.py
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Dict, List, Any
|
6
|
+
import pathspec
|
7
|
+
|
8
|
+
|
9
|
+
class FileTree:
|
10
|
+
def __init__(self, structure: Dict[str, Any]):
|
11
|
+
self.structure: Dict[str, Any] = structure
|
12
|
+
self.logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
def get_all_files(self) -> List[Path]:
|
15
|
+
"""
|
16
|
+
Recursively retrieve all file paths from the tree.
|
17
|
+
Returns:
|
18
|
+
List[Path]: List of file paths relative to root_dir.
|
19
|
+
"""
|
20
|
+
return self._extract_files(self.structure)
|
21
|
+
|
22
|
+
def _extract_files(
|
23
|
+
self, subtree: Dict[str, Any], path_prefix: str = ""
|
24
|
+
) -> List[Path]:
|
25
|
+
files: List[Path] = []
|
26
|
+
for key, value in subtree.items():
|
27
|
+
current_path: str = f"{path_prefix}/{key}".lstrip("/")
|
28
|
+
if isinstance(value, dict):
|
29
|
+
files.extend(self._extract_files(value, current_path))
|
30
|
+
else:
|
31
|
+
files.append(Path(current_path))
|
32
|
+
return files
|
33
|
+
|
34
|
+
def filter_files(self, spec: pathspec.PathSpec) -> None:
|
35
|
+
"""
|
36
|
+
Filters files in the tree structure based on the provided pathspec.
|
37
|
+
|
38
|
+
Args:
|
39
|
+
spec (pathspec.PathSpec): The pathspec for filtering files.
|
40
|
+
"""
|
41
|
+
self.logger.debug("Filtering files in the file tree.")
|
42
|
+
self.structure = self._filter_tree(self.structure, spec)
|
43
|
+
|
44
|
+
def _filter_tree(
|
45
|
+
self, subtree: Dict[str, Any], spec: pathspec.PathSpec, path_prefix: str = ""
|
46
|
+
) -> Dict[str, Any]:
|
47
|
+
filtered_subtree: Dict[str, Any] = {}
|
48
|
+
for key, value in subtree.items():
|
49
|
+
current_path: str = f"{path_prefix}/{key}".lstrip("/")
|
50
|
+
if isinstance(value, dict):
|
51
|
+
filtered_value: Dict[str, Any] = self._filter_tree(
|
52
|
+
value, spec, current_path
|
53
|
+
)
|
54
|
+
if filtered_value:
|
55
|
+
filtered_subtree[key] = filtered_value
|
56
|
+
else:
|
57
|
+
# Exclude the file if either the full path OR its basename matches a .gitignore pattern.
|
58
|
+
if not spec.match_file(current_path) and not spec.match_file(
|
59
|
+
Path(current_path).name
|
60
|
+
):
|
61
|
+
filtered_subtree[key] = value
|
62
|
+
return filtered_subtree
|
63
|
+
|
64
|
+
def prune_tree(self, selected_files: set) -> Dict[str, Any]:
|
65
|
+
"""
|
66
|
+
Prunes the tree to include only the selected files and their directories.
|
67
|
+
|
68
|
+
Args:
|
69
|
+
selected_files (set): Set of selected file paths.
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
Dict[str, Any]: Pruned tree structure.
|
73
|
+
"""
|
74
|
+
return self._prune_tree(self.structure, selected_files)
|
75
|
+
|
76
|
+
def _prune_tree(
|
77
|
+
self, subtree: Dict[str, Any], selected_files: set, path_prefix: str = ""
|
78
|
+
) -> Dict[str, Any]:
|
79
|
+
pruned_subtree: Dict[str, Any] = {}
|
80
|
+
for key, value in subtree.items():
|
81
|
+
current_path: str = f"{path_prefix}/{key}".lstrip("/")
|
82
|
+
if isinstance(value, dict):
|
83
|
+
pruned_value: Dict[str, Any] = self._prune_tree(
|
84
|
+
value, selected_files, current_path
|
85
|
+
)
|
86
|
+
if pruned_value:
|
87
|
+
pruned_subtree[key] = pruned_value
|
88
|
+
else:
|
89
|
+
if current_path in selected_files:
|
90
|
+
pruned_subtree[key] = value
|
91
|
+
return pruned_subtree
|
File without changes
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# src/reposnap/utils/path_utils.py
|
2
|
+
from typing import Dict, Generator, Any
|
3
|
+
|
4
|
+
|
5
|
+
def format_tree(
|
6
|
+
tree: Dict[str, Any], indent: str = "", hide_untoggled: bool = False
|
7
|
+
) -> Generator[str, None, None]:
|
8
|
+
for key, value in tree.items():
|
9
|
+
if value == "<hidden>":
|
10
|
+
yield f"{indent}<...>\n"
|
11
|
+
elif isinstance(value, dict):
|
12
|
+
yield f"{indent}{key}/\n"
|
13
|
+
yield from format_tree(value, indent + " ", hide_untoggled)
|
14
|
+
else:
|
15
|
+
yield f"{indent}{key}\n"
|
@@ -0,0 +1,19 @@
|
|
1
|
+
reposnap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
|
+
reposnap/controllers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
reposnap/controllers/project_controller.py,sha256=XranKRoWd1cRZ4cEXmN_YddO-GHREz9t2J0TUJ4mtgs,10053
|
4
|
+
reposnap/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
5
|
+
reposnap/core/file_system.py,sha256=82gwvmgrsWf63paMrIz-Z0eqIjbqt9_-vujdXlJJoFE,1074
|
6
|
+
reposnap/core/git_repo.py,sha256=I0AhB6XbABJ-oVGOSkVhSjFjSFfcm6f1VFHGTeuM4gE,1255
|
7
|
+
reposnap/core/markdown_generator.py,sha256=V6uEbxVSbCbxKN9ysTDKsIDvEGBxFutpOpyaZRXZUGw,3747
|
8
|
+
reposnap/interfaces/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
9
|
+
reposnap/interfaces/cli.py,sha256=qkbYAlgZzlxgW8ePIUwd8JSbvnBVSvde-VlVqzMDh7g,1470
|
10
|
+
reposnap/interfaces/gui.py,sha256=sTuQxjD1nPa9FpgfzOwi6VDO5QMMtDX-5CiEhbJJcs4,5429
|
11
|
+
reposnap/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
+
reposnap/models/file_tree.py,sha256=jGo_SizdFcOiDC1OOMz-tiijRN3iSD7ENh6Xw8S6OL0,3362
|
13
|
+
reposnap/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
14
|
+
reposnap/utils/path_utils.py,sha256=UrMe5cjspTf-4gjg2lzv6BgLwZ7S_1lLECQvDMDZO9Y,507
|
15
|
+
reposnap-0.6.5.dist-info/METADATA,sha256=V85e8nN5tNH_U-W_tludr_nG8CNvhGEDAX34Tz1r8JM,5348
|
16
|
+
reposnap-0.6.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
17
|
+
reposnap-0.6.5.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
|
18
|
+
reposnap-0.6.5.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
|
19
|
+
reposnap-0.6.5.dist-info/RECORD,,
|
reposnap-0.6.4.dist-info/RECORD
DELETED
@@ -1,5 +0,0 @@
|
|
1
|
-
reposnap-0.6.4.dist-info/METADATA,sha256=ZD55J-JVXgwC_ENrX8dmcIT5s3KlJqG26gPAzj74wlU,5348
|
2
|
-
reposnap-0.6.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
3
|
-
reposnap-0.6.4.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
|
4
|
-
reposnap-0.6.4.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
|
5
|
-
reposnap-0.6.4.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|