reposnap 0.6.3__tar.gz → 0.6.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reposnap-0.6.4/.coverage +0 -0
- reposnap-0.6.4/.github/workflows/python-package.yml +32 -0
- reposnap-0.6.4/.github/workflows/release.yml +52 -0
- reposnap-0.6.4/.pre-commit-config.yaml +47 -0
- reposnap-0.6.4/CONTRIBUTING.md +155 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/PKG-INFO +1 -1
- {reposnap-0.6.3 → reposnap-0.6.4}/pyproject.toml +9 -2
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/controllers/project_controller.py +74 -26
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/core/git_repo.py +0 -1
- reposnap-0.6.4/reposnap/core/markdown_generator.py +91 -0
- reposnap-0.6.4/reposnap/interfaces/cli.py +56 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/interfaces/gui.py +29 -26
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/models/file_tree.py +21 -9
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/utils/path_utils.py +5 -3
- {reposnap-0.6.3 → reposnap-0.6.4}/requirements-dev.lock +12 -12
- {reposnap-0.6.3 → reposnap-0.6.4}/requirements.lock +0 -8
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_cli.py +25 -25
- reposnap-0.6.4/tests/reposnap/test_collected_tree.py +149 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_file_system.py +16 -15
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_file_tree.py +5 -15
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_git_repo.py +6 -6
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_gui.py +27 -25
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/test_markdown_generator.py +31 -21
- reposnap-0.6.4/tests/reposnap/test_path_utils.py +11 -0
- reposnap-0.6.4/tests/reposnap/test_project_controller.py +352 -0
- reposnap-0.6.4/tests/resources/another_existing_file.py +1 -0
- reposnap-0.6.4/tests/resources/existing_file.py +1 -0
- reposnap-0.6.3/src/reposnap/core/markdown_generator.py +0 -79
- reposnap-0.6.3/src/reposnap/interfaces/cli.py +0 -37
- reposnap-0.6.3/tests/reposnap/test_collected_tree.py +0 -133
- reposnap-0.6.3/tests/reposnap/test_path_utils.py +0 -15
- reposnap-0.6.3/tests/reposnap/test_project_controller.py +0 -309
- reposnap-0.6.3/tests/resources/another_existing_file.py +0 -1
- reposnap-0.6.3/tests/resources/existing_file.py +0 -1
- {reposnap-0.6.3 → reposnap-0.6.4}/.gitignore +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/.python-version +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/.vscode/launch.json +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/LICENSE +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/README.md +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/__init__.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/controllers/__init__.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/core/__init__.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/core/file_system.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/interfaces/__init__.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/models/__init__.py +0 -0
- {reposnap-0.6.3/src → reposnap-0.6.4}/reposnap/utils/__init__.py +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/__init__.py +0 -0
- {reposnap-0.6.3 → reposnap-0.6.4}/tests/reposnap/__init__.py +0 -0
reposnap-0.6.4/.coverage
ADDED
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,52 @@
|
|
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}
|
@@ -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
|
[project]
|
2
2
|
name = "reposnap"
|
3
|
-
version = "0.6.
|
3
|
+
version = "0.6.4"
|
4
4
|
description = "Generate a Markdown file with all contents of your project"
|
5
5
|
authors = [
|
6
6
|
{ name = "agoloborodko" }
|
@@ -22,13 +22,20 @@ 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",
|
25
27
|
]
|
26
28
|
|
27
29
|
[tool.hatch.metadata]
|
28
30
|
allow-direct-references = true
|
29
31
|
|
32
|
+
[tool.hatch.build]
|
33
|
+
sources = ["src"]
|
34
|
+
dev-mode-dirs = ["src"]
|
35
|
+
|
30
36
|
[tool.hatch.build.targets.wheel]
|
31
|
-
packages = ["
|
37
|
+
packages = ["reposnap"]
|
38
|
+
include = ["reposnap"]
|
32
39
|
|
33
40
|
[project.scripts]
|
34
41
|
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 = [
|
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(
|
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(
|
29
|
-
|
30
|
-
|
31
|
-
self.
|
32
|
-
|
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 /
|
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(
|
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
|
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(
|
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(
|
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(
|
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 = [
|
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(
|
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 /
|
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 /
|
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(
|
194
|
-
patterns = [
|
195
|
-
|
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}.")
|
@@ -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)
|