reposnap 0.6.5__tar.gz → 0.7.0__tar.gz
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-0.6.5 → reposnap-0.7.0}/PKG-INFO +46 -2
- {reposnap-0.6.5 → reposnap-0.7.0}/README.md +45 -1
- {reposnap-0.6.5 → reposnap-0.7.0}/pyproject.toml +1 -1
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/controllers/project_controller.py +16 -4
- reposnap-0.7.0/reposnap/core/git_repo.py +100 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/cli.py +6 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_cli.py +48 -0
- reposnap-0.7.0/tests/reposnap/test_git_repo.py +211 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_project_controller.py +79 -0
- reposnap-0.6.5/reposnap/core/git_repo.py +0 -32
- reposnap-0.6.5/tests/reposnap/test_git_repo.py +0 -20
- {reposnap-0.6.5 → reposnap-0.7.0}/.coverage +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.github/workflows/python-package.yml +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.github/workflows/release.yml +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.gitignore +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.pre-commit-config.yaml +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.python-version +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/.vscode/launch.json +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/CONTRIBUTING.md +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/LICENSE +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/controllers/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/file_system.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/markdown_generator.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/gui.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/models/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/models/file_tree.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/utils/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/utils/path_utils.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/requirements-dev.lock +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/requirements.lock +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/__init__.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_collected_tree.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_file_system.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_file_tree.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_gui.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_markdown_generator.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_path_utils.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/resources/another_existing_file.py +0 -0
- {reposnap-0.6.5 → reposnap-0.7.0}/tests/resources/existing_file.py +0 -0
@@ -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.
|
@@ -12,6 +12,7 @@
|
|
12
12
|
- **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.
|
13
13
|
- **Gitignore Support**: Automatically respects `.gitignore` patterns to exclude files and directories.
|
14
14
|
- **Include and Exclude Patterns**: Use `--include` and `--exclude` to specify patterns for files and directories to include or exclude.
|
15
|
+
- **Changes Only Mode**: Use `-c` or `--changes` to snapshot only uncommitted files (staged, unstaged, untracked, and stashed changes).
|
15
16
|
|
16
17
|
## Installation
|
17
18
|
|
@@ -36,7 +37,7 @@ pip install -r requirements.lock
|
|
36
37
|
To use `reposnap` from the command line, run it with the following options:
|
37
38
|
|
38
39
|
```bash
|
39
|
-
reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
|
40
|
+
reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] [-c] paths [paths ...]
|
40
41
|
```
|
41
42
|
|
42
43
|
- `paths`: One or more paths (files or directories) within the repository whose content and structure should be rendered.
|
@@ -46,6 +47,7 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
|
|
46
47
|
- `--debug`: Enable debug-level logging.
|
47
48
|
- `-i, --include`: File/folder patterns to include. For example, `-i "*.py"` includes only Python files.
|
48
49
|
- `-e, --exclude`: File/folder patterns to exclude. For example, `-e "*.md"` excludes all Markdown files.
|
50
|
+
- `-c, --changes`: Use only files that are added/modified/untracked/stashed but not yet committed.
|
49
51
|
|
50
52
|
#### Pattern Matching
|
51
53
|
|
@@ -58,6 +60,42 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
|
|
58
60
|
- `-i "*.py"`: Includes only files ending with `.py`.
|
59
61
|
- `-e "*.test.*"`: Excludes files with `.test.` in their names.
|
60
62
|
|
63
|
+
#### Only Snapshot Your Current Work
|
64
|
+
|
65
|
+
The `-c` or `--changes` flag allows you to generate documentation for only the files that have been modified but not yet committed. This includes:
|
66
|
+
|
67
|
+
- **Staged changes**: Files that have been added to the index with `git add`
|
68
|
+
- **Unstaged changes**: Files that have been modified but not yet staged
|
69
|
+
- **Untracked files**: New files that haven't been added to Git yet
|
70
|
+
- **Stashed changes**: Files that are stored in Git stash entries
|
71
|
+
|
72
|
+
This is particularly useful when you want to:
|
73
|
+
- Document only your current work-in-progress
|
74
|
+
- Create a snapshot of changes before committing
|
75
|
+
- Review what files you've been working on
|
76
|
+
|
77
|
+
**Examples**:
|
78
|
+
|
79
|
+
1. **Generate documentation for only your uncommitted changes**:
|
80
|
+
```bash
|
81
|
+
reposnap . -c
|
82
|
+
```
|
83
|
+
|
84
|
+
2. **Combine with structure-only for a quick overview**:
|
85
|
+
```bash
|
86
|
+
reposnap . -c --structure-only
|
87
|
+
```
|
88
|
+
|
89
|
+
3. **Filter uncommitted changes by file type**:
|
90
|
+
```bash
|
91
|
+
reposnap . -c -i "*.py"
|
92
|
+
```
|
93
|
+
|
94
|
+
4. **Exclude test files from uncommitted changes**:
|
95
|
+
```bash
|
96
|
+
reposnap . -c -e "*test*"
|
97
|
+
```
|
98
|
+
|
61
99
|
#### Examples
|
62
100
|
|
63
101
|
1. **Generate a full project structure with file contents**:
|
@@ -90,6 +128,12 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
|
|
90
128
|
reposnap my_project/ -e "gui"
|
91
129
|
```
|
92
130
|
|
131
|
+
6. **Document only your current uncommitted work**:
|
132
|
+
|
133
|
+
```bash
|
134
|
+
reposnap . -c
|
135
|
+
```
|
136
|
+
|
93
137
|
### Graphical User Interface
|
94
138
|
|
95
139
|
`reposnap` also provides a GUI for users who prefer an interactive interface.
|
@@ -15,7 +15,7 @@ class ProjectController:
|
|
15
15
|
self.args = args
|
16
16
|
# Treat positional arguments as literal file/directory names.
|
17
17
|
input_paths = [
|
18
|
-
Path(p) for p in (args.paths if hasattr(args, "paths") else [
|
18
|
+
Path(p) for p in (args.paths if hasattr(args, "paths") else [])
|
19
19
|
]
|
20
20
|
self.input_paths = []
|
21
21
|
for p in input_paths:
|
@@ -47,6 +47,7 @@ class ProjectController:
|
|
47
47
|
self.exclude_patterns: List[str] = (
|
48
48
|
args.exclude if hasattr(args, "exclude") else []
|
49
49
|
)
|
50
|
+
self.changes_only: bool = getattr(args, "changes", False)
|
50
51
|
else:
|
51
52
|
self.args = None
|
52
53
|
self.input_paths = []
|
@@ -54,6 +55,7 @@ class ProjectController:
|
|
54
55
|
self.structure_only = False
|
55
56
|
self.include_patterns = []
|
56
57
|
self.exclude_patterns = []
|
58
|
+
self.changes_only = False
|
57
59
|
self.file_tree: Optional[FileTree] = None
|
58
60
|
self.gitignore_patterns: List[str] = []
|
59
61
|
if self.root_dir:
|
@@ -109,13 +111,23 @@ class ProjectController:
|
|
109
111
|
return files
|
110
112
|
|
111
113
|
def collect_file_tree(self) -> None:
|
112
|
-
|
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.")
|
113
118
|
try:
|
114
119
|
from reposnap.core.git_repo import GitRepo
|
115
120
|
|
116
121
|
git_repo = GitRepo(self.root_dir)
|
117
|
-
|
118
|
-
|
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}")
|
119
131
|
except Exception as e:
|
120
132
|
self.logger.warning(f"Error obtaining Git tracked files: {e}.")
|
121
133
|
all_files = []
|
@@ -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 []
|
@@ -40,6 +40,12 @@ def main():
|
|
40
40
|
default=[],
|
41
41
|
help="File/folder patterns to exclude.",
|
42
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
|
+
)
|
43
49
|
|
44
50
|
args = parser.parse_args()
|
45
51
|
|
@@ -78,3 +78,51 @@ def test_cli(mock_controller, temp_dir):
|
|
78
78
|
main()
|
79
79
|
|
80
80
|
mock_controller_instance.run.assert_called_once()
|
81
|
+
|
82
|
+
|
83
|
+
@patch("reposnap.interfaces.cli.ProjectController")
|
84
|
+
def test_cli_with_changes_flag(mock_controller, temp_dir):
|
85
|
+
"""Test that the --changes flag is properly parsed and passed to controller."""
|
86
|
+
mock_controller_instance = MagicMock()
|
87
|
+
mock_controller.return_value = mock_controller_instance
|
88
|
+
|
89
|
+
with patch("sys.argv", ["cli.py", str(temp_dir), "--changes"]):
|
90
|
+
main()
|
91
|
+
|
92
|
+
# Verify controller was called with args containing changes=True
|
93
|
+
mock_controller.assert_called_once()
|
94
|
+
args = mock_controller.call_args[0][0]
|
95
|
+
assert args.changes is True
|
96
|
+
mock_controller_instance.run.assert_called_once()
|
97
|
+
|
98
|
+
|
99
|
+
@patch("reposnap.interfaces.cli.ProjectController")
|
100
|
+
def test_cli_with_changes_short_flag(mock_controller, temp_dir):
|
101
|
+
"""Test that the -c flag is properly parsed and passed to controller."""
|
102
|
+
mock_controller_instance = MagicMock()
|
103
|
+
mock_controller.return_value = mock_controller_instance
|
104
|
+
|
105
|
+
with patch("sys.argv", ["cli.py", str(temp_dir), "-c"]):
|
106
|
+
main()
|
107
|
+
|
108
|
+
# Verify controller was called with args containing changes=True
|
109
|
+
mock_controller.assert_called_once()
|
110
|
+
args = mock_controller.call_args[0][0]
|
111
|
+
assert args.changes is True
|
112
|
+
mock_controller_instance.run.assert_called_once()
|
113
|
+
|
114
|
+
|
115
|
+
@patch("reposnap.interfaces.cli.ProjectController")
|
116
|
+
def test_cli_without_changes_flag(mock_controller, temp_dir):
|
117
|
+
"""Test that changes defaults to False when flag is not provided."""
|
118
|
+
mock_controller_instance = MagicMock()
|
119
|
+
mock_controller.return_value = mock_controller_instance
|
120
|
+
|
121
|
+
with patch("sys.argv", ["cli.py", str(temp_dir)]):
|
122
|
+
main()
|
123
|
+
|
124
|
+
# Verify controller was called with args containing changes=False (default)
|
125
|
+
mock_controller.assert_called_once()
|
126
|
+
args = mock_controller.call_args[0][0]
|
127
|
+
assert args.changes is False
|
128
|
+
mock_controller_instance.run.assert_called_once()
|
@@ -0,0 +1,211 @@
|
|
1
|
+
# tests/reposnap/test_git_repo.py
|
2
|
+
|
3
|
+
from unittest.mock import patch, MagicMock
|
4
|
+
from reposnap.core.git_repo import GitRepo
|
5
|
+
from pathlib import Path
|
6
|
+
|
7
|
+
|
8
|
+
@patch("reposnap.core.git_repo.Repo")
|
9
|
+
def test_get_git_files(mock_repo):
|
10
|
+
mock_repo_instance = MagicMock()
|
11
|
+
mock_repo_instance.git.ls_files.return_value = "file1.py\nsubdir/file2.py"
|
12
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
13
|
+
mock_repo.return_value = mock_repo_instance
|
14
|
+
|
15
|
+
git_repo = GitRepo(Path("/path/to/repo/subdir"))
|
16
|
+
files = git_repo.get_git_files()
|
17
|
+
|
18
|
+
expected_files = [Path("file2.py")]
|
19
|
+
|
20
|
+
assert files == expected_files
|
21
|
+
|
22
|
+
|
23
|
+
@patch("reposnap.core.git_repo.Repo")
|
24
|
+
@patch("pathlib.Path.is_file")
|
25
|
+
def test_get_uncommitted_files_staged_and_unstaged(mock_is_file, mock_repo):
|
26
|
+
"""Test get_uncommitted_files with staged and unstaged changes."""
|
27
|
+
mock_repo_instance = MagicMock()
|
28
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
29
|
+
|
30
|
+
# Mock staged changes
|
31
|
+
staged_diff = MagicMock()
|
32
|
+
staged_diff.a_path = "staged_file.py"
|
33
|
+
staged_diff.b_path = None
|
34
|
+
|
35
|
+
# Mock unstaged changes
|
36
|
+
unstaged_diff = MagicMock()
|
37
|
+
unstaged_diff.a_path = "unstaged_file.py"
|
38
|
+
unstaged_diff.b_path = None
|
39
|
+
|
40
|
+
mock_repo_instance.index.diff.side_effect = lambda ref: {
|
41
|
+
"HEAD": [staged_diff],
|
42
|
+
None: [unstaged_diff],
|
43
|
+
}[ref]
|
44
|
+
|
45
|
+
# Mock untracked files
|
46
|
+
mock_repo_instance.untracked_files = ["untracked_file.py"]
|
47
|
+
|
48
|
+
# Mock stash (empty)
|
49
|
+
mock_repo_instance.git.stash.return_value = ""
|
50
|
+
|
51
|
+
# Mock file existence
|
52
|
+
mock_is_file.return_value = True
|
53
|
+
|
54
|
+
mock_repo.return_value = mock_repo_instance
|
55
|
+
|
56
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
57
|
+
files = git_repo.get_uncommitted_files()
|
58
|
+
|
59
|
+
expected_files = [
|
60
|
+
Path("staged_file.py"),
|
61
|
+
Path("unstaged_file.py"),
|
62
|
+
Path("untracked_file.py"),
|
63
|
+
]
|
64
|
+
|
65
|
+
# Compare sorted lists since the method now returns sorted output
|
66
|
+
assert files == sorted(expected_files)
|
67
|
+
|
68
|
+
|
69
|
+
@patch("reposnap.core.git_repo.Repo")
|
70
|
+
@patch("pathlib.Path.is_file")
|
71
|
+
def test_get_uncommitted_files_with_stash(mock_is_file, mock_repo):
|
72
|
+
"""Test get_uncommitted_files with stash entries."""
|
73
|
+
mock_repo_instance = MagicMock()
|
74
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
75
|
+
|
76
|
+
# Mock no staged/unstaged changes
|
77
|
+
mock_repo_instance.index.diff.return_value = []
|
78
|
+
|
79
|
+
# Mock untracked files
|
80
|
+
mock_repo_instance.untracked_files = []
|
81
|
+
|
82
|
+
# 1) Mock stash list
|
83
|
+
mock_repo_instance.git.stash.return_value = "stash@{0}\nstash@{1}"
|
84
|
+
|
85
|
+
# 2) Mock diff calls to get stash file names
|
86
|
+
mock_repo_instance.git.diff.side_effect = lambda *args: {
|
87
|
+
("--name-only", "stash@{0}^1", "stash@{0}"): "stash_file1.py\nstash_file2.py",
|
88
|
+
("--name-only", "stash@{1}^1", "stash@{1}"): "stash_file3.py",
|
89
|
+
}[args]
|
90
|
+
|
91
|
+
# Mock file existence
|
92
|
+
mock_is_file.return_value = True
|
93
|
+
|
94
|
+
mock_repo.return_value = mock_repo_instance
|
95
|
+
|
96
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
97
|
+
files = git_repo.get_uncommitted_files()
|
98
|
+
|
99
|
+
expected_files = [
|
100
|
+
Path("stash_file1.py"),
|
101
|
+
Path("stash_file2.py"),
|
102
|
+
Path("stash_file3.py"),
|
103
|
+
]
|
104
|
+
|
105
|
+
# Compare sorted lists since the method now returns sorted output
|
106
|
+
assert files == sorted(expected_files)
|
107
|
+
|
108
|
+
|
109
|
+
@patch("reposnap.core.git_repo.Repo")
|
110
|
+
def test_get_uncommitted_files_empty_working_tree(mock_repo):
|
111
|
+
"""Test get_uncommitted_files with no changes."""
|
112
|
+
mock_repo_instance = MagicMock()
|
113
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
114
|
+
|
115
|
+
# Mock no changes
|
116
|
+
mock_repo_instance.index.diff.return_value = []
|
117
|
+
mock_repo_instance.untracked_files = []
|
118
|
+
mock_repo_instance.git.stash.return_value = ""
|
119
|
+
|
120
|
+
mock_repo.return_value = mock_repo_instance
|
121
|
+
|
122
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
123
|
+
files = git_repo.get_uncommitted_files()
|
124
|
+
|
125
|
+
assert files == []
|
126
|
+
|
127
|
+
|
128
|
+
@patch("reposnap.core.git_repo.Repo")
|
129
|
+
def test_get_uncommitted_files_invalid_repo(mock_repo):
|
130
|
+
"""Test get_uncommitted_files with invalid repository."""
|
131
|
+
from git import InvalidGitRepositoryError
|
132
|
+
|
133
|
+
mock_repo.side_effect = InvalidGitRepositoryError("Not a git repo")
|
134
|
+
|
135
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
136
|
+
files = git_repo.get_uncommitted_files()
|
137
|
+
|
138
|
+
assert files == []
|
139
|
+
|
140
|
+
|
141
|
+
@patch("reposnap.core.git_repo.Repo")
|
142
|
+
@patch("pathlib.Path.is_file")
|
143
|
+
def test_get_uncommitted_files_stash_error_handling(mock_is_file, mock_repo):
|
144
|
+
"""Test get_uncommitted_files handles stash errors gracefully."""
|
145
|
+
mock_repo_instance = MagicMock()
|
146
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
147
|
+
|
148
|
+
# Mock staged changes
|
149
|
+
staged_diff = MagicMock()
|
150
|
+
staged_diff.a_path = "staged_file.py"
|
151
|
+
staged_diff.b_path = None
|
152
|
+
mock_repo_instance.index.diff.return_value = [staged_diff]
|
153
|
+
|
154
|
+
# Mock untracked files
|
155
|
+
mock_repo_instance.untracked_files = []
|
156
|
+
|
157
|
+
# Mock stash error
|
158
|
+
mock_repo_instance.git.stash.side_effect = Exception("Stash error")
|
159
|
+
|
160
|
+
# Mock file existence
|
161
|
+
mock_is_file.return_value = True
|
162
|
+
|
163
|
+
mock_repo.return_value = mock_repo_instance
|
164
|
+
|
165
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
166
|
+
files = git_repo.get_uncommitted_files()
|
167
|
+
|
168
|
+
# Should still return staged changes even if stash fails
|
169
|
+
expected_files = [Path("staged_file.py")]
|
170
|
+
assert files == expected_files
|
171
|
+
|
172
|
+
|
173
|
+
@patch("reposnap.core.git_repo.Repo")
|
174
|
+
@patch("pathlib.Path.is_file")
|
175
|
+
def test_get_uncommitted_files_stash_limit(mock_is_file, mock_repo):
|
176
|
+
"""Test get_uncommitted_files respects the stash limit."""
|
177
|
+
mock_repo_instance = MagicMock()
|
178
|
+
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
179
|
+
|
180
|
+
# Mock no staged/unstaged changes
|
181
|
+
mock_repo_instance.index.diff.return_value = []
|
182
|
+
|
183
|
+
# Mock untracked files
|
184
|
+
mock_repo_instance.untracked_files = []
|
185
|
+
|
186
|
+
# Mock many stash entries (more than the limit of 10)
|
187
|
+
many_stashes = "\n".join([f"stash@{{{i}}}" for i in range(15)])
|
188
|
+
mock_repo_instance.git.stash.return_value = many_stashes
|
189
|
+
|
190
|
+
# Mock diff calls for the first 10 stashes (the limit)
|
191
|
+
def mock_diff(*args):
|
192
|
+
if args[0] == "--name-only" and len(args) == 3:
|
193
|
+
stash_ref = args[2] # e.g., 'stash@{0}'
|
194
|
+
stash_num = int(stash_ref.split("{")[1].split("}")[0])
|
195
|
+
if stash_num < 10: # Only the first 10 should be processed
|
196
|
+
return f"stash_file_{stash_num}.py"
|
197
|
+
return ""
|
198
|
+
|
199
|
+
mock_repo_instance.git.diff.side_effect = mock_diff
|
200
|
+
|
201
|
+
# Mock file existence
|
202
|
+
mock_is_file.return_value = True
|
203
|
+
|
204
|
+
mock_repo.return_value = mock_repo_instance
|
205
|
+
|
206
|
+
git_repo = GitRepo(Path("/path/to/repo"))
|
207
|
+
files = git_repo.get_uncommitted_files()
|
208
|
+
|
209
|
+
# Should only process the first 10 stashes
|
210
|
+
# The exact number depends on how many unique files are in those stashes
|
211
|
+
assert len(files) >= 0 # At minimum, should not crash
|
@@ -350,3 +350,82 @@ def test_project_controller_multiple_paths():
|
|
350
350
|
assert "LICENSE" not in tree
|
351
351
|
assert "tests" not in tree
|
352
352
|
assert "extras" not in tree
|
353
|
+
|
354
|
+
|
355
|
+
def test_project_controller_changes_only():
|
356
|
+
"""Test that changes_only flag calls get_uncommitted_files instead of get_git_files."""
|
357
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
358
|
+
structure = {
|
359
|
+
"file1.py": 'print("File 1")',
|
360
|
+
"file2.py": 'print("File 2")',
|
361
|
+
}
|
362
|
+
create_directory_structure(temp_dir, structure)
|
363
|
+
args = type(
|
364
|
+
"Args",
|
365
|
+
(object,),
|
366
|
+
{
|
367
|
+
"paths": [temp_dir],
|
368
|
+
"output": os.path.join(temp_dir, "output.md"),
|
369
|
+
"structure_only": False,
|
370
|
+
"debug": False,
|
371
|
+
"include": [],
|
372
|
+
"exclude": [],
|
373
|
+
"changes": True,
|
374
|
+
},
|
375
|
+
)
|
376
|
+
with patch(
|
377
|
+
"reposnap.controllers.project_controller.ProjectController._get_repo_root",
|
378
|
+
return_value=Path(temp_dir),
|
379
|
+
):
|
380
|
+
with patch("reposnap.core.git_repo.GitRepo") as MockGitRepo:
|
381
|
+
mock_git_repo = MagicMock()
|
382
|
+
mock_git_repo.get_uncommitted_files.return_value = [Path("file1.py")]
|
383
|
+
MockGitRepo.return_value = mock_git_repo
|
384
|
+
|
385
|
+
controller = ProjectController(args)
|
386
|
+
controller.collect_file_tree()
|
387
|
+
|
388
|
+
# Verify get_uncommitted_files was called instead of get_git_files
|
389
|
+
mock_git_repo.get_uncommitted_files.assert_called_once()
|
390
|
+
mock_git_repo.get_git_files.assert_not_called()
|
391
|
+
|
392
|
+
|
393
|
+
def test_project_controller_changes_only_false():
|
394
|
+
"""Test that when changes_only is False, get_git_files is called."""
|
395
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
396
|
+
structure = {
|
397
|
+
"file1.py": 'print("File 1")',
|
398
|
+
"file2.py": 'print("File 2")',
|
399
|
+
}
|
400
|
+
create_directory_structure(temp_dir, structure)
|
401
|
+
args = type(
|
402
|
+
"Args",
|
403
|
+
(object,),
|
404
|
+
{
|
405
|
+
"paths": [temp_dir],
|
406
|
+
"output": os.path.join(temp_dir, "output.md"),
|
407
|
+
"structure_only": False,
|
408
|
+
"debug": False,
|
409
|
+
"include": [],
|
410
|
+
"exclude": [],
|
411
|
+
"changes": False,
|
412
|
+
},
|
413
|
+
)
|
414
|
+
with patch(
|
415
|
+
"reposnap.controllers.project_controller.ProjectController._get_repo_root",
|
416
|
+
return_value=Path(temp_dir),
|
417
|
+
):
|
418
|
+
with patch("reposnap.core.git_repo.GitRepo") as MockGitRepo:
|
419
|
+
mock_git_repo = MagicMock()
|
420
|
+
mock_git_repo.get_git_files.return_value = [
|
421
|
+
Path("file1.py"),
|
422
|
+
Path("file2.py"),
|
423
|
+
]
|
424
|
+
MockGitRepo.return_value = mock_git_repo
|
425
|
+
|
426
|
+
controller = ProjectController(args)
|
427
|
+
controller.collect_file_tree()
|
428
|
+
|
429
|
+
# Verify get_git_files was called instead of get_uncommitted_files
|
430
|
+
mock_git_repo.get_git_files.assert_called_once()
|
431
|
+
mock_git_repo.get_uncommitted_files.assert_not_called()
|
@@ -1,32 +0,0 @@
|
|
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 []
|
@@ -1,20 +0,0 @@
|
|
1
|
-
# tests/reposnap/test_git_repo.py
|
2
|
-
|
3
|
-
from unittest.mock import patch, MagicMock
|
4
|
-
from reposnap.core.git_repo import GitRepo
|
5
|
-
from pathlib import Path
|
6
|
-
|
7
|
-
|
8
|
-
@patch("reposnap.core.git_repo.Repo")
|
9
|
-
def test_get_git_files(mock_repo):
|
10
|
-
mock_repo_instance = MagicMock()
|
11
|
-
mock_repo_instance.git.ls_files.return_value = "file1.py\nsubdir/file2.py"
|
12
|
-
mock_repo_instance.working_tree_dir = "/path/to/repo"
|
13
|
-
mock_repo.return_value = mock_repo_instance
|
14
|
-
|
15
|
-
git_repo = GitRepo(Path("/path/to/repo/subdir"))
|
16
|
-
files = git_repo.get_git_files()
|
17
|
-
|
18
|
-
expected_files = [Path("file2.py")]
|
19
|
-
|
20
|
-
assert files == expected_files
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|