reposnap 0.6.3__tar.gz → 0.6.5__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 (49) hide show
  1. reposnap-0.6.5/.coverage +0 -0
  2. reposnap-0.6.5/.github/workflows/python-package.yml +32 -0
  3. reposnap-0.6.5/.github/workflows/release.yml +48 -0
  4. reposnap-0.6.5/.pre-commit-config.yaml +47 -0
  5. reposnap-0.6.5/CONTRIBUTING.md +155 -0
  6. {reposnap-0.6.3 → reposnap-0.6.5}/PKG-INFO +1 -1
  7. {reposnap-0.6.3 → reposnap-0.6.5}/pyproject.toml +10 -2
  8. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/controllers/project_controller.py +74 -26
  9. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/core/git_repo.py +0 -1
  10. reposnap-0.6.5/reposnap/core/markdown_generator.py +91 -0
  11. reposnap-0.6.5/reposnap/interfaces/cli.py +56 -0
  12. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/interfaces/gui.py +29 -26
  13. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/models/file_tree.py +21 -9
  14. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/utils/path_utils.py +5 -3
  15. reposnap-0.6.5/requirements-dev.lock +115 -0
  16. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_cli.py +25 -25
  17. reposnap-0.6.5/tests/reposnap/test_collected_tree.py +149 -0
  18. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_file_system.py +16 -15
  19. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_file_tree.py +5 -15
  20. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_git_repo.py +6 -6
  21. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_gui.py +27 -25
  22. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/test_markdown_generator.py +31 -21
  23. reposnap-0.6.5/tests/reposnap/test_path_utils.py +11 -0
  24. reposnap-0.6.5/tests/reposnap/test_project_controller.py +352 -0
  25. reposnap-0.6.5/tests/resources/another_existing_file.py +1 -0
  26. reposnap-0.6.5/tests/resources/existing_file.py +1 -0
  27. reposnap-0.6.3/requirements-dev.lock +0 -35
  28. reposnap-0.6.3/src/reposnap/core/markdown_generator.py +0 -79
  29. reposnap-0.6.3/src/reposnap/interfaces/cli.py +0 -37
  30. reposnap-0.6.3/tests/reposnap/test_collected_tree.py +0 -133
  31. reposnap-0.6.3/tests/reposnap/test_path_utils.py +0 -15
  32. reposnap-0.6.3/tests/reposnap/test_project_controller.py +0 -309
  33. reposnap-0.6.3/tests/resources/another_existing_file.py +0 -1
  34. reposnap-0.6.3/tests/resources/existing_file.py +0 -1
  35. {reposnap-0.6.3 → reposnap-0.6.5}/.gitignore +0 -0
  36. {reposnap-0.6.3 → reposnap-0.6.5}/.python-version +0 -0
  37. {reposnap-0.6.3 → reposnap-0.6.5}/.vscode/launch.json +0 -0
  38. {reposnap-0.6.3 → reposnap-0.6.5}/LICENSE +0 -0
  39. {reposnap-0.6.3 → reposnap-0.6.5}/README.md +0 -0
  40. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/__init__.py +0 -0
  41. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/controllers/__init__.py +0 -0
  42. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/core/__init__.py +0 -0
  43. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/core/file_system.py +0 -0
  44. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/interfaces/__init__.py +0 -0
  45. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/models/__init__.py +0 -0
  46. {reposnap-0.6.3/src → reposnap-0.6.5}/reposnap/utils/__init__.py +0 -0
  47. {reposnap-0.6.3 → reposnap-0.6.5}/requirements.lock +0 -0
  48. {reposnap-0.6.3 → reposnap-0.6.5}/tests/__init__.py +0 -0
  49. {reposnap-0.6.3 → reposnap-0.6.5}/tests/reposnap/__init__.py +0 -0
Binary file
@@ -0,0 +1,32 @@
1
+ name: CI (Rye)
2
+
3
+ on:
4
+ push:
5
+ branches: ["main"]
6
+ pull_request:
7
+ branches: ["main"]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Install Rye
18
+ uses: eifinger/setup-rye@v4
19
+ with:
20
+ enable-cache: true
21
+
22
+ - name: Sync dependencies & install package
23
+ run: |
24
+ rye sync
25
+
26
+ - name: Lint
27
+ run: |
28
+ rye lint
29
+
30
+ - name: Test
31
+ run: |
32
+ rye test
@@ -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
@@ -0,0 +1,47 @@
1
+ # Pre‑commit configuration for **RepoSnap**
2
+ # Install hooks with:
3
+ # rye add --dev pre-commit
4
+ # pre-commit install
5
+ #
6
+ # Hooks are kept minimal and fast; they rely on Rye’s built‑in commands.
7
+
8
+ repos:
9
+ # Generic housekeeping hooks
10
+ - repo: https://github.com/pre-commit/pre-commit-hooks
11
+ rev: v4.5.0
12
+ hooks:
13
+ - id: end-of-file-fixer
14
+ - id: trailing-whitespace
15
+ - id: check-yaml
16
+ - id: check-added-large-files
17
+ - id: check-merge-conflict
18
+
19
+ # Project‑specific commands driven by Rye
20
+ - repo: local
21
+ hooks:
22
+ - id: rye-fmt
23
+ name: rye fmt --check
24
+ entry: rye
25
+ language: system
26
+ args: ["fmt", "--check"]
27
+ pass_filenames: false
28
+
29
+ - id: rye-lint
30
+ name: rye lint
31
+ entry: rye
32
+ language: system
33
+ args: ["lint"]
34
+ pass_filenames: false
35
+
36
+ - id: rye-test
37
+ name: rye test (quick)
38
+ entry: rye
39
+ language: system
40
+ args: ["test", "-q"]
41
+ pass_filenames: false
42
+ always_run: true
43
+ # Skip if only docs/markdown changed
44
+ files: "^(src/|tests/)"
45
+
46
+ # Run hooks only on commit; skip CI or manual stages unless invoked.
47
+ default_stages: [commit]
@@ -0,0 +1,155 @@
1
+ # Contributing to **RepoSnap**
2
+
3
+ ---
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Getting Started](#getting-started)
8
+ 2. [Development Workflow](#development-workflow)
9
+ 3. [Coding Standards](#coding-standards)
10
+ 4. [Testing Guidelines](#testing-guidelines)
11
+ 5. [Pull‑Request Checklist](#pull-request-checklist)
12
+ 6. [Release Process](#release-process)
13
+ 7. [Security & Responsible Disclosure](#security--responsible-disclosure)
14
+ 8. [Code of Conduct](#code-of-conduct)
15
+
16
+ ---
17
+
18
+ ## Getting Started
19
+
20
+ ### 1. Fork & Clone
21
+
22
+ ```bash
23
+ git clone https://github.com/<your-handle>/reposnap.git
24
+ cd reposnap
25
+ ```
26
+
27
+ ### 2. Python 3.12 Environment
28
+
29
+ We pin to **Python 3.12.6** (see `.python-version`). If you are not using Rye’s built‑in virtual‑env management, create one manually:
30
+
31
+ ```bash
32
+ python -m venv .venv
33
+ source .venv/bin/activate
34
+ ```
35
+
36
+ ### 3. Install Dependencies
37
+
38
+ **Using Rye (recommended)**
39
+
40
+ ```bash
41
+ rye sync # installs runtime + dev deps and refreshes lockfiles
42
+ ```
43
+
44
+ *Fallback — vanilla tooling only*
45
+
46
+ ```bash
47
+ pip install -e .[dev] # editable install with dev extras (pytest, etc.)
48
+ ```
49
+
50
+ ### 4. Optional Pre‑Commit Hooks
51
+
52
+ Automate formatting and linting on every commit with [pre‑commit](https://pre-commit.com/):
53
+
54
+ ```bash
55
+ pre-commit install # install hooks into .git
56
+ ```
57
+
58
+ The default hook runs `rye fmt --check` and `rye lint` before each commit. Skip this step if you prefer manual control.
59
+
60
+ ---
61
+
62
+ ## Development Workflow
63
+
64
+ 1. **Create a branch**
65
+
66
+ * Feature → `feat/<short-slug>`
67
+ * Bugfix → `fix/<issue-number>`
68
+ 2. **Make focused changes** inside `src/` (and matching tests under `tests/`).
69
+ 3. **Run the full test suite**: `rye test -q`.
70
+ 4. **Format & lint**: `rye fmt` then `rye lint --fix` (to auto‑apply safe fixes).
71
+ 5. **Commit, push, and open a Pull Request**.
72
+
73
+ > CI mirrors these steps. A failing check blocks merging.
74
+
75
+ ### Architectural Boundaries
76
+
77
+ ```
78
+ interfaces ─┐ (CLI, GUI)
79
+ controllers ─┼─> orchestrate use cases
80
+ core ─┼─> pure, reusable logic (no side‑effects)
81
+ models ─┘ data structures (dumb, typed)
82
+ ```
83
+
84
+ * **Never import from a higher layer.**
85
+ * Keep `utils/` minimal — prefer domain‑specific modules in `core/`.
86
+
87
+ ---
88
+
89
+ ## Coding Standards
90
+
91
+ RepoSnap relies on **Rye’s built‑in helpers** for code quality:
92
+
93
+ | Command | Description |
94
+ | ---------- | --------------------------------------------- |
95
+ | `rye fmt` | Formats code (powered under‑the‑hood by Ruff) |
96
+ | `rye lint` | Lints and can auto‑fix issues with `--fix` |
97
+
98
+ Guidelines:
99
+
100
+ * Follow **PEP 8**; `rye lint` will flag deviations.
101
+ * All public functions **must** be type‑hinted.
102
+ * Use **Google‑style** docstrings (`Args:`, `Returns:`).
103
+ * Log via `logging`; avoid `print()` in library code.
104
+ * Prefer `pathlib.Path` over `os.path`.
105
+
106
+ ---
107
+
108
+ ## Testing Guidelines
109
+
110
+ * Framework: **pytest**.
111
+ * Mirror `src/` structure under `tests/`.
112
+ * **Minimum coverage:** 90 %. New code should raise the bar.
113
+ * Write *unit tests* for pure functions and *integration tests* for I/O flows (e.g., CLI interaction).
114
+ * Measure coverage locally: `pytest --cov=reposnap`.
115
+
116
+ ---
117
+
118
+ ## Pull‑Request Checklist
119
+
120
+ Before requesting review, confirm:
121
+
122
+ * [ ] Tests pass (`rye test -q`) and coverage ≥ 90 %.
123
+ * [ ] `rye fmt --check` and `rye lint` report no issues.
124
+ * [ ] Docstrings & docs updated where relevant.
125
+ * [ ] No new circular dependencies (`pipdeptree --warn`).
126
+ * [ ] CI is green.
127
+ * [ ] PR description explains **why** the change is needed.
128
+ * [ ] Commits follow Conventional Commits.
129
+
130
+ At least one core maintainer must approve before merging. We use **Squash & Merge** to keep history linear.
131
+
132
+ ---
133
+
134
+ ## Release Process
135
+
136
+ 1. Merge PRs into `main`.
137
+ 2. Bump version manually in [pyproject.toml](pyproject.toml).
138
+ 3. Tag release: `git tag vX.Y.Z && git push --tags`.
139
+ 4. CI publishes to PyPI.
140
+
141
+ We adhere to **Semantic Versioning 2.0.0**.
142
+
143
+ ---
144
+
145
+ ## Security & Responsible Disclosure
146
+
147
+ If you discover a vulnerability, email [maintainer@example.com](mailto:maintainer@example.com) **privately**. We will coordinate a fix and issue a CVE if appropriate.
148
+
149
+ ---
150
+
151
+ ## Code of Conduct
152
+
153
+ Participation in this project is covered by the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
154
+
155
+ ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reposnap
3
- Version: 0.6.3
3
+ Version: 0.6.5
4
4
  Summary: Generate a Markdown file with all contents of your project
5
5
  Author: agoloborodko
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reposnap"
3
- version = "0.6.3"
3
+ version = "0.6.5"
4
4
  description = "Generate a Markdown file with all contents of your project"
5
5
  authors = [
6
6
  { name = "agoloborodko" }
@@ -22,13 +22,21 @@ build-backend = "hatchling.build"
22
22
  managed = true
23
23
  dev-dependencies = [
24
24
  "pytest>=8.3.2",
25
+ "pre-commit>=4.2.0",
26
+ "pytest-cov>=6.2.1",
27
+ "twine>=6.1.0",
25
28
  ]
26
29
 
27
30
  [tool.hatch.metadata]
28
31
  allow-direct-references = true
29
32
 
33
+ [tool.hatch.build]
34
+ sources = ["src"]
35
+ dev-mode-dirs = ["src"]
36
+
30
37
  [tool.hatch.build.targets.wheel]
31
- packages = ["src/reposnap"]
38
+ packages = ["reposnap"]
39
+ include = ["reposnap"]
32
40
 
33
41
  [project.scripts]
34
42
  reposnap = "reposnap.interfaces.cli:main"
@@ -5,6 +5,7 @@ from reposnap.models.file_tree import FileTree
5
5
  import pathspec
6
6
  from typing import List, Optional
7
7
 
8
+
8
9
  class ProjectController:
9
10
  def __init__(self, args: Optional[object] = None):
10
11
  self.logger = logging.getLogger(__name__)
@@ -13,27 +14,43 @@ class ProjectController:
13
14
  if args:
14
15
  self.args = args
15
16
  # Treat positional arguments as literal file/directory names.
16
- input_paths = [Path(p) for p in (args.paths if hasattr(args, 'paths') else [args.path])]
17
+ input_paths = [
18
+ Path(p) for p in (args.paths if hasattr(args, "paths") else [args.path])
19
+ ]
17
20
  self.input_paths = []
18
21
  for p in input_paths:
19
22
  candidate = (self.root_dir / p).resolve()
20
23
  if candidate.exists():
21
24
  try:
22
25
  rel = candidate.relative_to(self.root_dir)
23
- if rel != Path('.'):
26
+ if rel != Path("."):
24
27
  self.input_paths.append(rel)
25
28
  except ValueError:
26
- self.logger.warning(f"Path {p} is not under repository root {self.root_dir}. Ignoring.")
29
+ self.logger.warning(
30
+ f"Path {p} is not under repository root {self.root_dir}. Ignoring."
31
+ )
27
32
  else:
28
- self.logger.warning(f"Path {p} does not exist relative to repository root {self.root_dir}.")
29
- self.output_file: Path = Path(args.output).resolve() if args.output else self.root_dir / 'output.md'
30
- self.structure_only: bool = args.structure_only if hasattr(args, 'structure_only') else False
31
- self.include_patterns: List[str] = args.include if hasattr(args, 'include') else []
32
- self.exclude_patterns: List[str] = args.exclude if hasattr(args, 'exclude') else []
33
+ self.logger.warning(
34
+ f"Path {p} does not exist relative to repository root {self.root_dir}."
35
+ )
36
+ self.output_file: Path = (
37
+ Path(args.output).resolve()
38
+ if args.output
39
+ else self.root_dir / "output.md"
40
+ )
41
+ self.structure_only: bool = (
42
+ args.structure_only if hasattr(args, "structure_only") else False
43
+ )
44
+ self.include_patterns: List[str] = (
45
+ args.include if hasattr(args, "include") else []
46
+ )
47
+ self.exclude_patterns: List[str] = (
48
+ args.exclude if hasattr(args, "exclude") else []
49
+ )
33
50
  else:
34
51
  self.args = None
35
52
  self.input_paths = []
36
- self.output_file = self.root_dir / 'output.md'
53
+ self.output_file = self.root_dir / "output.md"
37
54
  self.structure_only = False
38
55
  self.include_patterns = []
39
56
  self.exclude_patterns = []
@@ -48,11 +65,14 @@ class ProjectController:
48
65
  otherwise use the current directory.
49
66
  """
50
67
  from git import Repo, InvalidGitRepositoryError
68
+
51
69
  try:
52
70
  repo = Repo(Path.cwd(), search_parent_directories=True)
53
71
  return Path(repo.working_tree_dir).resolve()
54
72
  except InvalidGitRepositoryError:
55
- self.logger.warning("Not a git repository. Using current directory as root.")
73
+ self.logger.warning(
74
+ "Not a git repository. Using current directory as root."
75
+ )
56
76
  return Path.cwd().resolve()
57
77
 
58
78
  def set_root_dir(self, root_dir: Path) -> None:
@@ -64,21 +84,27 @@ class ProjectController:
64
84
 
65
85
  def _apply_include_exclude(self, files: List[Path]) -> List[Path]:
66
86
  """Filter a list of file paths using include and exclude patterns."""
87
+
67
88
  def adjust_patterns(patterns):
68
89
  adjusted = []
69
90
  for p in patterns:
70
- if any(ch in p for ch in ['*', '?', '[']):
91
+ if any(ch in p for ch in ["*", "?", "["]):
71
92
  adjusted.append(p)
72
93
  else:
73
- adjusted.append(f'*{p}*')
94
+ adjusted.append(f"*{p}*")
74
95
  return adjusted
96
+
75
97
  if self.include_patterns:
76
98
  inc = adjust_patterns(self.include_patterns)
77
- spec_inc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, inc)
99
+ spec_inc = pathspec.PathSpec.from_lines(
100
+ pathspec.patterns.GitWildMatchPattern, inc
101
+ )
78
102
  files = [f for f in files if spec_inc.match_file(f.as_posix())]
79
103
  if self.exclude_patterns:
80
104
  exc = adjust_patterns(self.exclude_patterns)
81
- spec_exc = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, exc)
105
+ spec_exc = pathspec.PathSpec.from_lines(
106
+ pathspec.patterns.GitWildMatchPattern, exc
107
+ )
82
108
  files = [f for f in files if not spec_exc.match_file(f.as_posix())]
83
109
  return files
84
110
 
@@ -86,6 +112,7 @@ class ProjectController:
86
112
  self.logger.info("Collecting files from Git tracked files if available.")
87
113
  try:
88
114
  from reposnap.core.git_repo import GitRepo
115
+
89
116
  git_repo = GitRepo(self.root_dir)
90
117
  all_files = git_repo.get_git_files()
91
118
  self.logger.debug(f"Git tracked files: {all_files}")
@@ -96,7 +123,9 @@ class ProjectController:
96
123
  if not all_files:
97
124
  file_list = [p for p in self.root_dir.rglob("*") if p.is_file()]
98
125
  if file_list:
99
- self.logger.info("Git tracked files empty, using filesystem scan fallback.")
126
+ self.logger.info(
127
+ "Git tracked files empty, using filesystem scan fallback."
128
+ )
100
129
  all_files = []
101
130
  for path in file_list:
102
131
  try:
@@ -109,7 +138,12 @@ class ProjectController:
109
138
  if self.input_paths:
110
139
  trees = []
111
140
  for input_path in self.input_paths:
112
- subset = [f for f in all_files if f == input_path or list(f.parts[:len(input_path.parts)]) == list(input_path.parts)]
141
+ subset = [
142
+ f
143
+ for f in all_files
144
+ if f == input_path
145
+ or list(f.parts[: len(input_path.parts)]) == list(input_path.parts)
146
+ ]
113
147
  self.logger.debug(f"Files for input path '{input_path}': {subset}")
114
148
  if subset:
115
149
  tree = FileSystem(self.root_dir).build_tree_structure(subset)
@@ -145,32 +179,40 @@ class ProjectController:
145
179
 
146
180
  def apply_filters(self) -> None:
147
181
  self.logger.info("Applying .gitignore filters to the merged tree.")
148
- spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns)
182
+ spec = pathspec.PathSpec.from_lines(
183
+ pathspec.patterns.GitWildMatchPattern, self.gitignore_patterns
184
+ )
149
185
  self.logger.debug(f".gitignore patterns: {self.gitignore_patterns}")
150
186
  self.file_tree.filter_files(spec)
151
187
 
152
188
  def generate_output(self) -> None:
153
189
  self.logger.info("Starting Markdown generation.")
154
190
  from reposnap.core.markdown_generator import MarkdownGenerator
191
+
155
192
  markdown_generator = MarkdownGenerator(
156
193
  root_dir=self.root_dir,
157
194
  output_file=self.output_file,
158
- structure_only=self.structure_only
195
+ structure_only=self.structure_only,
196
+ )
197
+ markdown_generator.generate_markdown(
198
+ self.file_tree.structure, self.file_tree.get_all_files()
159
199
  )
160
- markdown_generator.generate_markdown(self.file_tree.structure, self.file_tree.get_all_files())
161
200
  self.logger.info(f"Markdown generated at {self.output_file}.")
162
201
 
163
202
  def generate_output_from_selected(self, selected_files: set) -> None:
164
203
  self.logger.info("Generating Markdown from selected files.")
165
204
  pruned_tree = self.file_tree.prune_tree(selected_files)
166
205
  from reposnap.core.markdown_generator import MarkdownGenerator
206
+
167
207
  markdown_generator = MarkdownGenerator(
168
208
  root_dir=self.root_dir,
169
209
  output_file=self.output_file,
170
210
  structure_only=False,
171
- hide_untoggled=True
211
+ hide_untoggled=True,
212
+ )
213
+ markdown_generator.generate_markdown(
214
+ pruned_tree, [Path(f) for f in selected_files]
172
215
  )
173
- markdown_generator.generate_markdown(pruned_tree, [Path(f) for f in selected_files])
174
216
  self.logger.info(f"Markdown generated at {self.output_file}.")
175
217
 
176
218
  def run(self) -> None:
@@ -180,19 +222,25 @@ class ProjectController:
180
222
  self.generate_output()
181
223
 
182
224
  def _load_gitignore_patterns(self) -> List[str]:
183
- gitignore_path = self.root_dir / '.gitignore'
225
+ gitignore_path = self.root_dir / ".gitignore"
184
226
  if not gitignore_path.exists():
185
227
  for parent in self.root_dir.parents:
186
- candidate = parent / '.gitignore'
228
+ candidate = parent / ".gitignore"
187
229
  if candidate.exists():
188
230
  gitignore_path = candidate
189
231
  break
190
232
  else:
191
233
  gitignore_path = None
192
234
  if gitignore_path and gitignore_path.exists():
193
- with gitignore_path.open('r') as gitignore:
194
- patterns = [line.strip() for line in gitignore if line.strip() and not line.strip().startswith('#')]
195
- self.logger.debug(f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}")
235
+ with gitignore_path.open("r") as gitignore:
236
+ patterns = [
237
+ line.strip()
238
+ for line in gitignore
239
+ if line.strip() and not line.strip().startswith("#")
240
+ ]
241
+ self.logger.debug(
242
+ f"Loaded .gitignore patterns from {gitignore_path.parent}: {patterns}"
243
+ )
196
244
  return patterns
197
245
  else:
198
246
  self.logger.debug(f"No .gitignore found starting from {self.root_dir}.")
@@ -30,4 +30,3 @@ class GitRepo:
30
30
  except InvalidGitRepositoryError:
31
31
  self.logger.error(f"Invalid Git repository at: {self.repo_path}")
32
32
  return []
33
-
@@ -0,0 +1,91 @@
1
+ # src/reposnap/core/markdown_generator.py ★ fully-rewritten file
2
+ import logging
3
+ from pathlib import Path
4
+ from typing import Dict, List, Any
5
+
6
+ from reposnap.utils.path_utils import format_tree
7
+
8
+
9
+ class MarkdownGenerator:
10
+ """Render the collected file-tree into a single Markdown document."""
11
+
12
+ def __init__(
13
+ self,
14
+ root_dir: Path,
15
+ output_file: Path,
16
+ structure_only: bool = False,
17
+ hide_untoggled: bool = False,
18
+ ):
19
+ self.root_dir = root_dir.resolve()
20
+ self.output_file = output_file.resolve()
21
+ self.structure_only = structure_only
22
+ self.hide_untoggled = hide_untoggled
23
+ self.logger = logging.getLogger(__name__)
24
+
25
+ # --------------------------------------------------------------
26
+ # public API
27
+ # --------------------------------------------------------------
28
+ def generate_markdown(
29
+ self, tree_structure: Dict[str, Any], files: List[Path]
30
+ ) -> None:
31
+ """Write header (tree) and, unless *structure_only*, every file body."""
32
+ self._write_header(tree_structure)
33
+ if not self.structure_only:
34
+ self._write_file_contents(files)
35
+
36
+ # --------------------------------------------------------------
37
+ # helpers
38
+ # --------------------------------------------------------------
39
+ def _write_header(self, tree_structure: Dict[str, Any]) -> None:
40
+ """Emit the *Project Structure* section."""
41
+ self.logger.debug("Writing Markdown header and project structure.")
42
+ try:
43
+ with self.output_file.open(mode="w", encoding="utf-8") as fh:
44
+ fh.write("# Project Structure\n\n```\n")
45
+ for line in format_tree(
46
+ tree_structure, hide_untoggled=self.hide_untoggled
47
+ ):
48
+ fh.write(line)
49
+ fh.write("```\n\n")
50
+ except OSError as exc:
51
+ self.logger.error("Failed to write header: %s", exc)
52
+ raise
53
+
54
+ def _write_file_contents(self, files: List[Path]) -> None:
55
+ """Append every file in *files* under its own fenced section."""
56
+ self.logger.debug("Writing file contents to Markdown.")
57
+ for rel_path in files:
58
+ abs_path = self.root_dir / rel_path
59
+ if not abs_path.exists(): # git had stale entry
60
+ self.logger.debug("File not found: %s -- skipping.", abs_path)
61
+ continue
62
+ try:
63
+ self._write_single_file(abs_path, rel_path.as_posix())
64
+ except UnicodeDecodeError as exc:
65
+ self.logger.error("Unicode error for %s: %s", abs_path, exc)
66
+
67
+ # --------------------------------------------------------------
68
+ # single-file writer
69
+ # --------------------------------------------------------------
70
+ def _write_single_file(self, file_path: Path, rel_str: str) -> None:
71
+ """
72
+ Append one file.
73
+
74
+ We guarantee **one and only one** newline between the last character
75
+ of *content* and the closing code-fence so the output is stable and
76
+ deterministic (important for tests and downstream diff-tools).
77
+ """
78
+ try:
79
+ with file_path.open(encoding="utf-8") as src:
80
+ content = src.read()
81
+
82
+ with self.output_file.open(mode="a", encoding="utf-8") as dst:
83
+ dst.write(f"## {rel_str}\n\n")
84
+ dst.write("```python\n" if file_path.suffix == ".py" else "```\n")
85
+
86
+ # normalise trailing EOL → exactly one '\n'
87
+ dst.write(content if content.endswith("\n") else f"{content}\n")
88
+
89
+ dst.write("```\n\n")
90
+ except OSError as exc:
91
+ self.logger.error("Error processing %s: %s", file_path, exc)