reposnap 0.6.4__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 (45) hide show
  1. reposnap-0.7.0/.github/workflows/release.yml +48 -0
  2. {reposnap-0.6.4 → reposnap-0.7.0}/PKG-INFO +46 -2
  3. {reposnap-0.6.4 → reposnap-0.7.0}/README.md +45 -1
  4. {reposnap-0.6.4 → reposnap-0.7.0}/pyproject.toml +2 -1
  5. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/controllers/project_controller.py +16 -4
  6. reposnap-0.7.0/reposnap/core/git_repo.py +100 -0
  7. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/interfaces/cli.py +6 -0
  8. reposnap-0.7.0/requirements-dev.lock +115 -0
  9. {reposnap-0.6.4 → reposnap-0.7.0}/requirements.lock +8 -0
  10. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_cli.py +48 -0
  11. reposnap-0.7.0/tests/reposnap/test_git_repo.py +211 -0
  12. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_project_controller.py +79 -0
  13. reposnap-0.6.4/.github/workflows/release.yml +0 -52
  14. reposnap-0.6.4/reposnap/core/git_repo.py +0 -32
  15. reposnap-0.6.4/requirements-dev.lock +0 -35
  16. reposnap-0.6.4/tests/reposnap/test_git_repo.py +0 -20
  17. {reposnap-0.6.4 → reposnap-0.7.0}/.coverage +0 -0
  18. {reposnap-0.6.4 → reposnap-0.7.0}/.github/workflows/python-package.yml +0 -0
  19. {reposnap-0.6.4 → reposnap-0.7.0}/.gitignore +0 -0
  20. {reposnap-0.6.4 → reposnap-0.7.0}/.pre-commit-config.yaml +0 -0
  21. {reposnap-0.6.4 → reposnap-0.7.0}/.python-version +0 -0
  22. {reposnap-0.6.4 → reposnap-0.7.0}/.vscode/launch.json +0 -0
  23. {reposnap-0.6.4 → reposnap-0.7.0}/CONTRIBUTING.md +0 -0
  24. {reposnap-0.6.4 → reposnap-0.7.0}/LICENSE +0 -0
  25. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/__init__.py +0 -0
  26. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/controllers/__init__.py +0 -0
  27. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/core/__init__.py +0 -0
  28. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/core/file_system.py +0 -0
  29. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/core/markdown_generator.py +0 -0
  30. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/interfaces/__init__.py +0 -0
  31. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/interfaces/gui.py +0 -0
  32. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/models/__init__.py +0 -0
  33. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/models/file_tree.py +0 -0
  34. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/utils/__init__.py +0 -0
  35. {reposnap-0.6.4 → reposnap-0.7.0}/reposnap/utils/path_utils.py +0 -0
  36. {reposnap-0.6.4 → reposnap-0.7.0}/tests/__init__.py +0 -0
  37. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/__init__.py +0 -0
  38. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_collected_tree.py +0 -0
  39. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_file_system.py +0 -0
  40. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_file_tree.py +0 -0
  41. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_gui.py +0 -0
  42. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_markdown_generator.py +0 -0
  43. {reposnap-0.6.4 → reposnap-0.7.0}/tests/reposnap/test_path_utils.py +0 -0
  44. {reposnap-0.6.4 → reposnap-0.7.0}/tests/resources/another_existing_file.py +0 -0
  45. {reposnap-0.6.4 → reposnap-0.7.0}/tests/resources/existing_file.py +0 -0
@@ -0,0 +1,48 @@
1
+ # .github/workflows/release.yml
2
+ name: Release to PyPI
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v[0-9]+.[0-9]+.[0-9]+*'
7
+
8
+ jobs:
9
+ ci: # <-- root job (no needs)
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: eifinger/setup-rye@v4
14
+ with: { enable-cache: true }
15
+ - run: rye sync
16
+ - run: rye lint
17
+ - run: rye test
18
+
19
+ publish:
20
+ needs: ci
21
+ runs-on: ubuntu-latest
22
+
23
+ # 1️⃣ Run in the same named environment you entered on PyPI
24
+ environment:
25
+ name: pypi # must match the “Environment name” field on PyPI
26
+
27
+ # 2️⃣ Give this job the right to request an OIDC token
28
+ permissions:
29
+ contents: read # keep least-privilege defaults
30
+ id-token: write # ★ mandatory for trusted publishing
31
+
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: eifinger/setup-rye@v4
35
+ with: { enable-cache: true }
36
+
37
+ # build + Twine check
38
+ - run: |
39
+ rye sync
40
+ rye build --clean
41
+ rye run twine check dist/* # Twine comes from Rye venv
42
+
43
+ # 3️⃣ Call the publish action **without** password/username
44
+ - name: Publish to PyPI/TestPyPI
45
+ uses: pypa/gh-action-pypi-publish@release/v1
46
+ with:
47
+ repository-url: ${{ steps.repo.outputs.index_url }} # keep if you need Test-PyPI for -rc tags
48
+ skip-existing: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.6.4
3
+ Version: 0.7.0
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -25,6 +25,7 @@ Description-Content-Type: text/markdown
25
25
  - **Structure Only Option**: The `--structure-only` flag can be used to generate the Markdown file with just the directory structure, omitting the contents of the files.
26
26
  - **Gitignore Support**: Automatically respects `.gitignore` patterns to exclude files and directories.
27
27
  - **Include and Exclude Patterns**: Use `--include` and `--exclude` to specify patterns for files and directories to include or exclude.
28
+ - **Changes Only Mode**: Use `-c` or `--changes` to snapshot only uncommitted files (staged, unstaged, untracked, and stashed changes).
28
29
 
29
30
  ## Installation
30
31
 
@@ -49,7 +50,7 @@ pip install -r requirements.lock
49
50
  To use `reposnap` from the command line, run it with the following options:
50
51
 
51
52
  ```bash
52
- reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] paths [paths ...]
53
+ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]] [-e EXCLUDE [EXCLUDE ...]] [-c] paths [paths ...]
53
54
  ```
54
55
 
55
56
  - `paths`: One or more paths (files or directories) within the repository whose content and structure should be rendered.
@@ -59,6 +60,7 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
59
60
  - `--debug`: Enable debug-level logging.
60
61
  - `-i, --include`: File/folder patterns to include. For example, `-i "*.py"` includes only Python files.
61
62
  - `-e, --exclude`: File/folder patterns to exclude. For example, `-e "*.md"` excludes all Markdown files.
63
+ - `-c, --changes`: Use only files that are added/modified/untracked/stashed but not yet committed.
62
64
 
63
65
  #### Pattern Matching
64
66
 
@@ -71,6 +73,42 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
71
73
  - `-i "*.py"`: Includes only files ending with `.py`.
72
74
  - `-e "*.test.*"`: Excludes files with `.test.` in their names.
73
75
 
76
+ #### Only Snapshot Your Current Work
77
+
78
+ The `-c` or `--changes` flag allows you to generate documentation for only the files that have been modified but not yet committed. This includes:
79
+
80
+ - **Staged changes**: Files that have been added to the index with `git add`
81
+ - **Unstaged changes**: Files that have been modified but not yet staged
82
+ - **Untracked files**: New files that haven't been added to Git yet
83
+ - **Stashed changes**: Files that are stored in Git stash entries
84
+
85
+ This is particularly useful when you want to:
86
+ - Document only your current work-in-progress
87
+ - Create a snapshot of changes before committing
88
+ - Review what files you've been working on
89
+
90
+ **Examples**:
91
+
92
+ 1. **Generate documentation for only your uncommitted changes**:
93
+ ```bash
94
+ reposnap . -c
95
+ ```
96
+
97
+ 2. **Combine with structure-only for a quick overview**:
98
+ ```bash
99
+ reposnap . -c --structure-only
100
+ ```
101
+
102
+ 3. **Filter uncommitted changes by file type**:
103
+ ```bash
104
+ reposnap . -c -i "*.py"
105
+ ```
106
+
107
+ 4. **Exclude test files from uncommitted changes**:
108
+ ```bash
109
+ reposnap . -c -e "*test*"
110
+ ```
111
+
74
112
  #### Examples
75
113
 
76
114
  1. **Generate a full project structure with file contents**:
@@ -103,6 +141,12 @@ reposnap [-h] [-o OUTPUT] [--structure-only] [--debug] [-i INCLUDE [INCLUDE ...]
103
141
  reposnap my_project/ -e "gui"
104
142
  ```
105
143
 
144
+ 6. **Document only your current uncommitted work**:
145
+
146
+ ```bash
147
+ reposnap . -c
148
+ ```
149
+
106
150
  ### Graphical User Interface
107
151
 
108
152
  `reposnap` also provides a GUI for users who prefer an interactive interface.
@@ -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.4"
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" }
@@ -24,6 +24,7 @@ dev-dependencies = [
24
24
  "pytest>=8.3.2",
25
25
  "pre-commit>=4.2.0",
26
26
  "pytest-cov>=6.2.1",
27
+ "twine>=6.1.0",
27
28
  ]
28
29
 
29
30
  [tool.hatch.metadata]
@@ -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
 
@@ -0,0 +1,115 @@
1
+ # generated by rye
2
+ # use `rye lock` or `rye sync` to update this lockfile
3
+ #
4
+ # last locked with the following flags:
5
+ # pre: false
6
+ # features: []
7
+ # all-features: false
8
+ # with-sources: false
9
+ # generate-hashes: false
10
+ # universal: false
11
+
12
+ -e file:.
13
+ certifi==2025.6.15
14
+ # via requests
15
+ cffi==1.17.1
16
+ # via cryptography
17
+ cfgv==3.4.0
18
+ # via pre-commit
19
+ charset-normalizer==3.4.2
20
+ # via requests
21
+ coverage==7.9.1
22
+ # via pytest-cov
23
+ cryptography==45.0.4
24
+ # via secretstorage
25
+ distlib==0.3.9
26
+ # via virtualenv
27
+ docutils==0.21.2
28
+ # via readme-renderer
29
+ filelock==3.18.0
30
+ # via virtualenv
31
+ gitdb==4.0.11
32
+ # via gitpython
33
+ gitpython==3.1.43
34
+ # via reposnap
35
+ id==1.5.0
36
+ # via twine
37
+ identify==2.6.12
38
+ # via pre-commit
39
+ idna==3.10
40
+ # via requests
41
+ iniconfig==2.0.0
42
+ # via pytest
43
+ jaraco-classes==3.4.0
44
+ # via keyring
45
+ jaraco-context==6.0.1
46
+ # via keyring
47
+ jaraco-functools==4.2.1
48
+ # via keyring
49
+ jeepney==0.9.0
50
+ # via keyring
51
+ # via secretstorage
52
+ keyring==25.6.0
53
+ # via twine
54
+ markdown-it-py==3.0.0
55
+ # via rich
56
+ mdurl==0.1.2
57
+ # via markdown-it-py
58
+ more-itertools==10.7.0
59
+ # via jaraco-classes
60
+ # via jaraco-functools
61
+ nh3==0.2.21
62
+ # via readme-renderer
63
+ nodeenv==1.9.1
64
+ # via pre-commit
65
+ packaging==25.0
66
+ # via pytest
67
+ # via twine
68
+ pathlib==1.0.1
69
+ # via reposnap
70
+ pathspec==0.12.1
71
+ # via reposnap
72
+ platformdirs==4.3.8
73
+ # via virtualenv
74
+ pluggy==1.5.0
75
+ # via pytest
76
+ # via pytest-cov
77
+ pre-commit==4.2.0
78
+ pycparser==2.22
79
+ # via cffi
80
+ pygments==2.19.2
81
+ # via readme-renderer
82
+ # via rich
83
+ pytest==8.3.2
84
+ # via pytest-cov
85
+ pytest-cov==6.2.1
86
+ pyyaml==6.0.2
87
+ # via pre-commit
88
+ readme-renderer==44.0
89
+ # via twine
90
+ requests==2.32.4
91
+ # via id
92
+ # via requests-toolbelt
93
+ # via twine
94
+ requests-toolbelt==1.0.0
95
+ # via twine
96
+ rfc3986==2.0.0
97
+ # via twine
98
+ rich==14.0.0
99
+ # via twine
100
+ secretstorage==3.3.3
101
+ # via keyring
102
+ smmap==5.0.1
103
+ # via gitdb
104
+ twine==6.1.0
105
+ typing-extensions==4.12.2
106
+ # via urwid
107
+ urllib3==2.5.0
108
+ # via requests
109
+ # via twine
110
+ urwid==2.6.15
111
+ # via reposnap
112
+ virtualenv==20.31.2
113
+ # via pre-commit
114
+ wcwidth==0.2.13
115
+ # via urwid
@@ -11,10 +11,18 @@
11
11
 
12
12
  -e file:.
13
13
  gitdb==4.0.11
14
+ # via gitpython
14
15
  gitpython==3.1.43
16
+ # via reposnap
15
17
  pathlib==1.0.1
18
+ # via reposnap
16
19
  pathspec==0.12.1
20
+ # via reposnap
17
21
  smmap==5.0.1
22
+ # via gitdb
18
23
  typing-extensions==4.12.2
24
+ # via urwid
19
25
  urwid==2.6.15
26
+ # via reposnap
20
27
  wcwidth==0.2.13
28
+ # via urwid
@@ -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,52 +0,0 @@
1
- name: Release to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v[0-9]+.[0-9]+.[0-9]+*' # any SemVer tag, eg v1.2.3 or v1.2.3-rc.1
7
-
8
- jobs:
9
- build-and-publish:
10
- runs-on: ubuntu-latest
11
-
12
- # Block publication unless the normal CI passed on this commit
13
- needs: build # <— refers to the existing job id in python-package.yml
14
-
15
- permissions:
16
- contents: read # principle of least privilege
17
- id-token: write # enable OIDC if you switch to trusted publishing later :contentReference[oaicite:1]{index=1}
18
-
19
- steps:
20
- - name: Checkout
21
- uses: actions/checkout@v4
22
-
23
- - name: Install Rye
24
- uses: eifinger/setup-rye@v4
25
- with:
26
- enable-cache: true
27
-
28
- - name: Build wheel & sdist
29
- run: |
30
- rye sync
31
- rye build --clean # Hatchling is the backend; Rye shells out to it :contentReference[oaicite:2]{index=2}
32
-
33
- - name: Verify metadata (optional)
34
- run: |
35
- rye run -e 'twine>=5' twine check dist/* :contentReference[oaicite:3]{index=3}
36
-
37
- # Select real PyPI vs TestPyPI
38
- - name: Set target repository
39
- id: repository
40
- run: |
41
- if [[ "${GITHUB_REF_NAME}" == *"-"* ]]; then
42
- echo "index_url=https://test.pypi.org/legacy/" >> "$GITHUB_OUTPUT"
43
- else
44
- echo "index_url=https://upload.pypi.org/legacy/" >> "$GITHUB_OUTPUT"
45
- fi
46
-
47
- - name: Publish
48
- uses: pypa/gh-action-pypi-publish@release/v1
49
- with:
50
- password: ${{ secrets.PYPI_API_TOKEN }}
51
- repository-url: ${{ steps.repository.outputs.index_url }}
52
- skip-existing: true # idempotent re-runs :contentReference[oaicite:4]{index=4}
@@ -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,35 +0,0 @@
1
- # generated by rye
2
- # use `rye lock` or `rye sync` to update this lockfile
3
- #
4
- # last locked with the following flags:
5
- # pre: false
6
- # features: []
7
- # all-features: false
8
- # with-sources: false
9
- # generate-hashes: false
10
- # universal: false
11
-
12
- -e file:.
13
- cfgv==3.4.0
14
- coverage==7.9.1
15
- distlib==0.3.9
16
- filelock==3.18.0
17
- gitdb==4.0.11
18
- gitpython==3.1.43
19
- identify==2.6.12
20
- iniconfig==2.0.0
21
- nodeenv==1.9.1
22
- packaging==25.0
23
- pathlib==1.0.1
24
- pathspec==0.12.1
25
- platformdirs==4.3.8
26
- pluggy==1.5.0
27
- pre-commit==4.2.0
28
- pytest==8.3.2
29
- pytest-cov==6.2.1
30
- pyyaml==6.0.2
31
- smmap==5.0.1
32
- typing-extensions==4.12.2
33
- urwid==2.6.15
34
- virtualenv==20.31.2
35
- wcwidth==0.2.13
@@ -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