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.
Files changed (43) hide show
  1. {reposnap-0.6.5 → reposnap-0.7.0}/PKG-INFO +46 -2
  2. {reposnap-0.6.5 → reposnap-0.7.0}/README.md +45 -1
  3. {reposnap-0.6.5 → reposnap-0.7.0}/pyproject.toml +1 -1
  4. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/controllers/project_controller.py +16 -4
  5. reposnap-0.7.0/reposnap/core/git_repo.py +100 -0
  6. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/cli.py +6 -0
  7. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_cli.py +48 -0
  8. reposnap-0.7.0/tests/reposnap/test_git_repo.py +211 -0
  9. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_project_controller.py +79 -0
  10. reposnap-0.6.5/reposnap/core/git_repo.py +0 -32
  11. reposnap-0.6.5/tests/reposnap/test_git_repo.py +0 -20
  12. {reposnap-0.6.5 → reposnap-0.7.0}/.coverage +0 -0
  13. {reposnap-0.6.5 → reposnap-0.7.0}/.github/workflows/python-package.yml +0 -0
  14. {reposnap-0.6.5 → reposnap-0.7.0}/.github/workflows/release.yml +0 -0
  15. {reposnap-0.6.5 → reposnap-0.7.0}/.gitignore +0 -0
  16. {reposnap-0.6.5 → reposnap-0.7.0}/.pre-commit-config.yaml +0 -0
  17. {reposnap-0.6.5 → reposnap-0.7.0}/.python-version +0 -0
  18. {reposnap-0.6.5 → reposnap-0.7.0}/.vscode/launch.json +0 -0
  19. {reposnap-0.6.5 → reposnap-0.7.0}/CONTRIBUTING.md +0 -0
  20. {reposnap-0.6.5 → reposnap-0.7.0}/LICENSE +0 -0
  21. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/__init__.py +0 -0
  22. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/controllers/__init__.py +0 -0
  23. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/__init__.py +0 -0
  24. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/file_system.py +0 -0
  25. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/core/markdown_generator.py +0 -0
  26. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/__init__.py +0 -0
  27. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/interfaces/gui.py +0 -0
  28. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/models/__init__.py +0 -0
  29. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/models/file_tree.py +0 -0
  30. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/utils/__init__.py +0 -0
  31. {reposnap-0.6.5 → reposnap-0.7.0}/reposnap/utils/path_utils.py +0 -0
  32. {reposnap-0.6.5 → reposnap-0.7.0}/requirements-dev.lock +0 -0
  33. {reposnap-0.6.5 → reposnap-0.7.0}/requirements.lock +0 -0
  34. {reposnap-0.6.5 → reposnap-0.7.0}/tests/__init__.py +0 -0
  35. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/__init__.py +0 -0
  36. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_collected_tree.py +0 -0
  37. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_file_system.py +0 -0
  38. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_file_tree.py +0 -0
  39. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_gui.py +0 -0
  40. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_markdown_generator.py +0 -0
  41. {reposnap-0.6.5 → reposnap-0.7.0}/tests/reposnap/test_path_utils.py +0 -0
  42. {reposnap-0.6.5 → reposnap-0.7.0}/tests/resources/another_existing_file.py +0 -0
  43. {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.6.5
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reposnap"
3
- version = "0.6.5"
3
+ version = "0.7.0"
4
4
  description = "Generate a Markdown file with all contents of your project"
5
5
  authors = [
6
6
  { name = "agoloborodko" }
@@ -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 [args.path])
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
- self.logger.info("Collecting files from Git tracked files if available.")
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
- all_files = git_repo.get_git_files()
118
- self.logger.debug(f"Git tracked files: {all_files}")
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