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 +0 -0
- reposnap/controllers/__init__.py +0 -0
- reposnap/controllers/project_controller.py +259 -0
- reposnap/core/__init__.py +0 -0
- reposnap/core/file_system.py +32 -0
- reposnap/core/git_repo.py +100 -0
- reposnap/core/markdown_generator.py +91 -0
- reposnap/interfaces/__init__.py +0 -0
- reposnap/interfaces/cli.py +62 -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.7.0.dist-info}/METADATA +46 -2
- reposnap-0.7.0.dist-info/RECORD +19 -0
- reposnap-0.6.4.dist-info/RECORD +0 -5
- {reposnap-0.6.4.dist-info → reposnap-0.7.0.dist-info}/WHEEL +0 -0
- {reposnap-0.6.4.dist-info → reposnap-0.7.0.dist-info}/entry_points.txt +0 -0
- {reposnap-0.6.4.dist-info → reposnap-0.7.0.dist-info}/licenses/LICENSE +0 -0
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.
|
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,,
|
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
|