reposnap 0.6.4__py3-none-any.whl → 0.7.0__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 ADDED
File without changes
File without changes
@@ -0,0 +1,259 @@
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 [])
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
+ self.changes_only: bool = getattr(args, "changes", False)
51
+ else:
52
+ self.args = None
53
+ self.input_paths = []
54
+ self.output_file = self.root_dir / "output.md"
55
+ self.structure_only = False
56
+ self.include_patterns = []
57
+ self.exclude_patterns = []
58
+ self.changes_only = False
59
+ self.file_tree: Optional[FileTree] = None
60
+ self.gitignore_patterns: List[str] = []
61
+ if self.root_dir:
62
+ self.gitignore_patterns = self._load_gitignore_patterns()
63
+
64
+ def _get_repo_root(self) -> Path:
65
+ """
66
+ Determine the repository root using Git if available,
67
+ otherwise use the current directory.
68
+ """
69
+ from git import Repo, InvalidGitRepositoryError
70
+
71
+ try:
72
+ repo = Repo(Path.cwd(), search_parent_directories=True)
73
+ return Path(repo.working_tree_dir).resolve()
74
+ except InvalidGitRepositoryError:
75
+ self.logger.warning(
76
+ "Not a git repository. Using current directory as root."
77
+ )
78
+ return Path.cwd().resolve()
79
+
80
+ def set_root_dir(self, root_dir: Path) -> None:
81
+ self.root_dir = root_dir.resolve()
82
+ self.gitignore_patterns = self._load_gitignore_patterns()
83
+
84
+ def get_file_tree(self) -> Optional[FileTree]:
85
+ return self.file_tree
86
+
87
+ def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
88
+ """Filter a list of file paths using include and exclude patterns."""
89
+
90
+ def adjust_patterns(patterns):
91
+ adjusted = []
92
+ for p in patterns:
93
+ if any(ch in p for ch in ["*", "?", "["]):
94
+ adjusted.append(p)
95
+ else:
96
+ adjusted.append(f"*{p}*")
97
+ return adjusted
98
+
99
+ if self.include_patterns:
100
+ inc = adjust_patterns(self.include_patterns)
101
+ spec_inc = pathspec.PathSpec.from_lines(
102
+ pathspec.patterns.GitWildMatchPattern, inc
103
+ )
104
+ files = [f for f in files if spec_inc.match_file(f.as_posix())]
105
+ if self.exclude_patterns:
106
+ exc = adjust_patterns(self.exclude_patterns)
107
+ spec_exc = pathspec.PathSpec.from_lines(
108
+ pathspec.patterns.GitWildMatchPattern, exc
109
+ )
110
+ files = [f for f in files if not spec_exc.match_file(f.as_posix())]
111
+ return files
112
+
113
+ def collect_file_tree(self) -> None:
114
+ if self.changes_only:
115
+ self.logger.info("Collecting uncommitted files from Git repository.")
116
+ else:
117
+ self.logger.info("Collecting files from Git tracked files if available.")
118
+ try:
119
+ from reposnap.core.git_repo import GitRepo
120
+
121
+ git_repo = GitRepo(self.root_dir)
122
+ if self.changes_only:
123
+ all_files = git_repo.get_uncommitted_files()
124
+ self.logger.info(
125
+ "Using only uncommitted files (staged, unstaged, untracked, stashed)."
126
+ )
127
+ else:
128
+ all_files = git_repo.get_git_files()
129
+ self.logger.info("Using all Git tracked files.")
130
+ self.logger.debug(f"Git files: {all_files}")
131
+ except Exception as e:
132
+ self.logger.warning(f"Error obtaining Git tracked files: {e}.")
133
+ all_files = []
134
+ # If Git returns an empty list but files exist on disk, fall back to filesystem scan.
135
+ if not all_files:
136
+ file_list = [p for p in self.root_dir.rglob("*") if p.is_file()]
137
+ if file_list:
138
+ self.logger.info(
139
+ "Git tracked files empty, using filesystem scan fallback."
140
+ )
141
+ all_files = []
142
+ for path in file_list:
143
+ try:
144
+ rel = path.relative_to(self.root_dir)
145
+ all_files.append(rel)
146
+ except ValueError:
147
+ continue
148
+ all_files = self._apply_include_exclude(all_files)
149
+ self.logger.debug(f"All files after applying include/exclude: {all_files}")
150
+ if self.input_paths:
151
+ trees = []
152
+ for input_path in self.input_paths:
153
+ subset = [
154
+ f
155
+ for f in all_files
156
+ if f == input_path
157
+ or list(f.parts[: len(input_path.parts)]) == list(input_path.parts)
158
+ ]
159
+ self.logger.debug(f"Files for input path '{input_path}': {subset}")
160
+ if subset:
161
+ tree = FileSystem(self.root_dir).build_tree_structure(subset)
162
+ trees.append(tree)
163
+ if trees:
164
+ merged_tree = self.merge_trees(trees)
165
+ else:
166
+ merged_tree = {}
167
+ else:
168
+ merged_tree = FileSystem(self.root_dir).build_tree_structure(all_files)
169
+ self.logger.info("Merged tree built from input paths.")
170
+ self.file_tree = FileTree(merged_tree)
171
+ self.logger.debug(f"Merged tree structure: {self.file_tree.structure}")
172
+
173
+ def merge_trees(self, trees: List[dict]) -> dict:
174
+ """Recursively merge a list of tree dictionaries."""
175
+ merged = {}
176
+ for tree in trees:
177
+ merged = self._merge_two_trees(merged, tree)
178
+ return merged
179
+
180
+ def _merge_two_trees(self, tree1: dict, tree2: dict) -> dict:
181
+ merged = dict(tree1)
182
+ for key, value in tree2.items():
183
+ if key in merged:
184
+ if isinstance(merged[key], dict) and isinstance(value, dict):
185
+ merged[key] = self._merge_two_trees(merged[key], value)
186
+ else:
187
+ merged[key] = value
188
+ else:
189
+ merged[key] = value
190
+ return merged
191
+
192
+ def apply_filters(self) -> None:
193
+ self.logger.info("Applying .gitignore filters to the merged tree.")
194
+ spec = pathspec.PathSpec.from_lines(
195
+ pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns
196
+ )
197
+ self.logger.debug(f".gitignore patterns: {self.gitignore_patterns}")
198
+ self.file_tree.filter_files(spec)
199
+
200
+ def generate_output(self) -> None:
201
+ self.logger.info("Starting Markdown generation.")
202
+ from reposnap.core.markdown_generator import MarkdownGenerator
203
+
204
+ markdown_generator = MarkdownGenerator(
205
+ root_dir=self.root_dir,
206
+ output_file=self.output_file,
207
+ structure_only=self.structure_only,
208
+ )
209
+ markdown_generator.generate_markdown(
210
+ self.file_tree.structure, self.file_tree.get_all_files()
211
+ )
212
+ self.logger.info(f"Markdown generated at {self.output_file}.")
213
+
214
+ def generate_output_from_selected(self, selected_files: set) -> None:
215
+ self.logger.info("Generating Markdown from selected files.")
216
+ pruned_tree = self.file_tree.prune_tree(selected_files)
217
+ from reposnap.core.markdown_generator import MarkdownGenerator
218
+
219
+ markdown_generator = MarkdownGenerator(
220
+ root_dir=self.root_dir,
221
+ output_file=self.output_file,
222
+ structure_only=False,
223
+ hide_untoggled=True,
224
+ )
225
+ markdown_generator.generate_markdown(
226
+ pruned_tree, [Path(f) for f in selected_files]
227
+ )
228
+ self.logger.info(f"Markdown generated at {self.output_file}.")
229
+
230
+ def run(self) -> None:
231
+ """Run the entire process: collect files, apply filters, and generate Markdown."""
232
+ self.collect_file_tree()
233
+ self.apply_filters()
234
+ self.generate_output()
235
+
236
+ def _load_gitignore_patterns(self) -> List[str]:
237
+ gitignore_path = self.root_dir / ".gitignore"
238
+ if not gitignore_path.exists():
239
+ for parent in self.root_dir.parents:
240
+ candidate = parent / ".gitignore"
241
+ if candidate.exists():
242
+ gitignore_path = candidate
243
+ break
244
+ else:
245
+ gitignore_path = None
246
+ if gitignore_path and gitignore_path.exists():
247
+ with gitignore_path.open("r") as gitignore:
248
+ patterns = [
249
+ line.strip()
250
+ for line in gitignore
251
+ if line.strip() and not line.strip().startswith("#")
252
+ ]
253
+ self.logger.debug(
254
+ f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}"
255
+ )
256
+ return patterns
257
+ else:
258
+ self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
259
+ 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,100 @@
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 []
33
+
34
+ def get_uncommitted_files(self) -> List[Path]:
35
+ """
36
+ Return every *working-copy* file that differs from HEAD - staged,
37
+ unstaged, untracked, plus everything referenced in `git stash list`.
38
+ Paths are *relative to* self.repo_path.
39
+ """
40
+ try:
41
+ repo: Repo = Repo(self.repo_path, search_parent_directories=True)
42
+ repo_root: Path = Path(repo.working_tree_dir).resolve()
43
+ paths: set = set()
44
+
45
+ # Staged changes (diff between index and HEAD)
46
+ for diff in repo.index.diff("HEAD"):
47
+ paths.add(diff.a_path or diff.b_path)
48
+
49
+ # Unstaged changes (diff between working tree and index)
50
+ for diff in repo.index.diff(None):
51
+ paths.add(diff.a_path or diff.b_path)
52
+
53
+ # Untracked files
54
+ paths.update(repo.untracked_files)
55
+
56
+ # Stash entries - with performance guard
57
+ try:
58
+ stash_refs = repo.git.stash("list", "--format=%gd").splitlines()
59
+ # Limit stash processing to prevent performance issues
60
+ max_stashes = 10
61
+ if len(stash_refs) > max_stashes:
62
+ self.logger.warning(
63
+ f"Large stash stack detected ({len(stash_refs)} entries). "
64
+ f"Processing only the first {max_stashes} stashes."
65
+ )
66
+ stash_refs = stash_refs[:max_stashes]
67
+
68
+ for ref in stash_refs:
69
+ if ref.strip(): # Skip empty lines
70
+ stash_files = repo.git.diff(
71
+ "--name-only", f"{ref}^1", ref
72
+ ).splitlines()
73
+ paths.update(stash_files)
74
+ except Exception as e:
75
+ self.logger.debug(f"Error processing stash entries: {e}")
76
+
77
+ # Convert to relative paths and filter existing files
78
+ relative_paths = []
79
+ for path_str in paths:
80
+ if path_str: # Skip empty strings
81
+ absolute_path = (repo_root / path_str).resolve()
82
+ try:
83
+ relative_path = absolute_path.relative_to(self.repo_path)
84
+ if absolute_path.is_file():
85
+ relative_paths.append(relative_path)
86
+ except ValueError:
87
+ # Log warning for paths outside repo root
88
+ self.logger.warning(
89
+ f"Path {path_str} is outside repository root {self.repo_path}. Skipping."
90
+ )
91
+ continue
92
+
93
+ # Return sorted, deduplicated list for deterministic output
94
+ result = sorted(set(relative_paths))
95
+ self.logger.debug(f"Uncommitted files from {repo_root}: {result}")
96
+ return result
97
+
98
+ except InvalidGitRepositoryError:
99
+ self.logger.error(f"Invalid Git repository at: {self.repo_path}")
100
+ 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,62 @@
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
+ parser.add_argument(
44
+ "-c",
45
+ "--changes",
46
+ action="store_true",
47
+ help="Use only files that are added/modified/untracked/stashed but not yet committed.",
48
+ )
49
+
50
+ args = parser.parse_args()
51
+
52
+ log_level = logging.DEBUG if args.debug else logging.INFO
53
+ logging.basicConfig(
54
+ level=log_level, format="%(asctime)s - %(levelname)s - %(message)s"
55
+ )
56
+
57
+ controller = ProjectController(args)
58
+ controller.run()
59
+
60
+
61
+ if __name__ == "__main__":
62
+ 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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.6.4
3
+ Version: 0.7.0
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -25,6 +25,7 @@ Description-Content-Type: text/markdown
25
25
  - **Structure Only Option**: The `--structure-only` flag can be used to generate the Markdown file with just the directory structure, omitting the contents of the files.
26
26
  - **Gitignore Support**: Automatically respects `.gitignore` patterns to exclude files and directories.
27
27
  - **Include and Exclude Patterns**: Use `--include` and `--exclude` to specify patterns for files and directories to include or exclude.
28
+ - **Changes Only Mode**: Use `-c` or `--changes` to snapshot only uncommitted files (staged, unstaged, untracked, and stashed changes).
28
29
 
29
30
  ## Installation
30
31
 
@@ -49,7 +50,7 @@ pip install -r requirements.lock
49
50
  To use `reposnap` from the command line, run it with the following options:
50
51
 
51
52
  ```bash
52
- reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
53
+ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] [-c] paths [paths ...]
53
54
  ```
54
55
 
55
56
  - `paths`: One or more paths (files or directories) within the repository whose content and structure should be rendered.
@@ -59,6 +60,7 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
59
60
  - `--debug`: Enable debug-level logging.
60
61
  - `-i, --include`: File/folder patterns to include. For example, `-i "*.py"` includes only Python files.
61
62
  - `-e, --exclude`: File/folder patterns to exclude. For example, `-e "*.md"` excludes all Markdown files.
63
+ - `-c, --changes`: Use only files that are added/modified/untracked/stashed but not yet committed.
62
64
 
63
65
  #### Pattern Matching
64
66
 
@@ -71,6 +73,42 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
71
73
  - `-i "*.py"`: Includes only files ending with `.py`.
72
74
  - `-e "*.test.*"`: Excludes files with `.test.` in their names.
73
75
 
76
+ #### Only Snapshot Your Current Work
77
+
78
+ The `-c` or `--changes` flag allows you to generate documentation for only the files that have been modified but not yet committed. This includes:
79
+
80
+ - **Staged changes**: Files that have been added to the index with `git add`
81
+ - **Unstaged changes**: Files that have been modified but not yet staged
82
+ - **Untracked files**: New files that haven't been added to Git yet
83
+ - **Stashed changes**: Files that are stored in Git stash entries
84
+
85
+ This is particularly useful when you want to:
86
+ - Document only your current work-in-progress
87
+ - Create a snapshot of changes before committing
88
+ - Review what files you've been working on
89
+
90
+ **Examples**:
91
+
92
+ 1. **Generate documentation for only your uncommitted changes**:
93
+ ```bash
94
+ reposnap . -c
95
+ ```
96
+
97
+ 2. **Combine with structure-only for a quick overview**:
98
+ ```bash
99
+ reposnap . -c --structure-only
100
+ ```
101
+
102
+ 3. **Filter uncommitted changes by file type**:
103
+ ```bash
104
+ reposnap . -c -i "*.py"
105
+ ```
106
+
107
+ 4. **Exclude test files from uncommitted changes**:
108
+ ```bash
109
+ reposnap . -c -e "*test*"
110
+ ```
111
+
74
112
  #### Examples
75
113
 
76
114
  1. **Generate a full project structure with file contents**:
@@ -103,6 +141,12 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
103
141
  reposnap my_project/ -e "gui"
104
142
  ```
105
143
 
144
+ 6. **Document only your current uncommitted work**:
145
+
146
+ ```bash
147
+ reposnap . -c
148
+ ```
149
+
106
150
  ### Graphical User Interface
107
151
 
108
152
  `reposnap` also provides a GUI for users who prefer an interactive interface.
@@ -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=RROOcb_FiEhFc9oshptPL8moH-5lkUrio7Lp0MqBqp0,10599
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=YVIbx-Y_MUbnn5Z4E2XBTJbG7Kawx5aUX2tg6vnocd0,4284
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=gL0gauEt_AkuRRr-p5YAeHeUPgvZ59lpZMqsopLjHas,1661
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.7.0.dist-info/METADATA,sha256=oJF5qQGPWc6aG2mnupOpiDrbJ1hWbApjDCP0tM-lB2Y,6768
16
+ reposnap-0.7.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
+ reposnap-0.7.0.dist-info/entry_points.txt,sha256=o3GyO7bpR0dujPCjsvvZMPv4pXNJlFwD49_pA1r5FOA,102
18
+ reposnap-0.7.0.dist-info/licenses/LICENSE,sha256=Aj7WCYBXi98pvi723HPn4GDRyjxToNWb3PC6j1_lnPk,1069
19
+ reposnap-0.7.0.dist-info/RECORD,,
@@ -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,,