git-reaper 0.2.0__tar.gz → 0.3.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.
- {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/ci.yml +14 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/CHANGELOG.md +59 -1
- {git_reaper-0.2.0 → git_reaper-0.3.0}/PKG-INFO +28 -6
- {git_reaper-0.2.0 → git_reaper-0.3.0}/README.md +27 -5
- {git_reaper-0.2.0 → git_reaper-0.3.0}/pyproject.toml +5 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/_version.py +2 -2
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/art.py +11 -6
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/cli.py +298 -0
- git_reaper-0.3.0/src/git_reaper/core/graveyard.py +89 -0
- git_reaper-0.3.0/src/git_reaper/core/history.py +372 -0
- git_reaper-0.3.0/src/git_reaper/core/hygiene.py +99 -0
- git_reaper-0.3.0/src/git_reaper/formatters/csvfmt.py +129 -0
- git_reaper-0.3.0/src/git_reaper/formatters/markdown.py +336 -0
- git_reaper-0.3.0/src/git_reaper/gitio/__init__.py +40 -0
- git_reaper-0.3.0/src/git_reaper/gitio/backend.py +145 -0
- git_reaper-0.3.0/src/git_reaper/gitio/gitpython_git.py +161 -0
- git_reaper-0.3.0/src/git_reaper/gitio/logparse.py +197 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/gitio/subprocess_git.py +70 -11
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/models.py +191 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/schemas.py +9 -0
- git_reaper-0.3.0/tests/conftest.py +202 -0
- git_reaper-0.3.0/tests/test_backend.py +40 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_cli.py +108 -0
- git_reaper-0.3.0/tests/test_gitpython_backend.py +97 -0
- git_reaper-0.3.0/tests/test_graveyard.py +61 -0
- git_reaper-0.3.0/tests/test_history.py +142 -0
- git_reaper-0.3.0/tests/test_hygiene.py +56 -0
- git_reaper-0.2.0/src/git_reaper/formatters/csvfmt.py +0 -45
- git_reaper-0.2.0/src/git_reaper/formatters/markdown.py +0 -130
- git_reaper-0.2.0/src/git_reaper/gitio/__init__.py +0 -11
- git_reaper-0.2.0/src/git_reaper/gitio/backend.py +0 -51
- git_reaper-0.2.0/tests/conftest.py +0 -76
- {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/docs.yml +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/publish.yml +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/.gitignore +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/.markdownlint.json +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/LICENSE +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/Makefile +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/api.md +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/commands.md +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/development.md +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/index.md +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/library.md +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/mkdocs.yml +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/__init__.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/__main__.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/cache.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/config.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/__init__.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/census.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/harvest.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/pack.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/provenance.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/pulse.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/scan.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/source.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/tree.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/unpack.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/formatters/__init__.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/formatters/jsonfmt.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/fsutil.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/ignore.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/theme.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_cache.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_census.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_fsutil.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_grimoire.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_harvest.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_ignore.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_pack.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_scan.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_schemas.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_tree.py +0 -0
- {git_reaper-0.2.0 → git_reaper-0.3.0}/uv.lock +0 -0
|
@@ -37,6 +37,20 @@ jobs:
|
|
|
37
37
|
- run: uv sync
|
|
38
38
|
- run: uv run pytest
|
|
39
39
|
|
|
40
|
+
# The base matrix runs without extras, so the GitPython parity tests skip
|
|
41
|
+
# there. This job installs the [git] extra so that backend is exercised.
|
|
42
|
+
test-gitpython:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
with:
|
|
47
|
+
fetch-depth: 0
|
|
48
|
+
- uses: astral-sh/setup-uv@v5
|
|
49
|
+
with:
|
|
50
|
+
python-version: "3.12"
|
|
51
|
+
- run: uv sync --extra git
|
|
52
|
+
- run: uv run --extra git pytest
|
|
53
|
+
|
|
40
54
|
docs:
|
|
41
55
|
runs-on: ubuntu-latest
|
|
42
56
|
steps:
|
|
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
Git Necromancy. Mining commit history: who, what, when, and what has died.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
#### Commands
|
|
17
|
+
|
|
18
|
+
- `reaper chronicle` - extract commit history (SHA, author, date, message,
|
|
19
|
+
files touched, insertions/deletions) to markdown, JSON, or CSV.
|
|
20
|
+
`--changelog` groups commits under the tag that heads their release range.
|
|
21
|
+
- `reaper souls` - contributor stats: commits, lines added/removed, first and
|
|
22
|
+
last seen, and a bus-factor estimate. `--heatmap` renders a day-of-week x
|
|
23
|
+
hour activity grid (bucketed by each commit's recorded timezone, so output
|
|
24
|
+
never depends on the machine) and flags the repo's "witching hour."
|
|
25
|
+
- `reaper haunt` - code churn and hotspots: files ranked by change frequency
|
|
26
|
+
and churn, the classic bug-risk proxy.
|
|
27
|
+
- `reaper autopsy <path>` - deep single-file examination: creation commit,
|
|
28
|
+
rename history (`--follow`, on by default), authors over time, churn
|
|
29
|
+
totals, and a blame-based line-age summary.
|
|
30
|
+
- `reaper graveyard` - every file that ever lived and died: path, date of
|
|
31
|
+
death, the fatal commit, and its author. Renamed-away paths count as deaths.
|
|
32
|
+
- `reaper resurrect <path>` - restore a dead file's last living bytes (read
|
|
33
|
+
from the parent of the commit that removed it) into the working tree or
|
|
34
|
+
`--out`. Absolute paths and `..` segments are refused, same as reanimate.
|
|
35
|
+
- `reaper ghosts` - branch hygiene: branches ranked by abandonment, with
|
|
36
|
+
merged, gone-upstream, and (past `--than 90d`) stale flags.
|
|
37
|
+
- `reaper rot` - staleness report: surviving files ranked by how long they
|
|
38
|
+
have gone untouched, ages derived from a single log pass.
|
|
39
|
+
- `reaper tombstone` - a stats card for demos and READMEs (born, age,
|
|
40
|
+
commits, souls, last words, witching hour) as ASCII tombstone art, or JSON.
|
|
41
|
+
|
|
42
|
+
#### Output and formats
|
|
43
|
+
|
|
44
|
+
- CSV output (`--format csv`) for `chronicle`, `souls`, `haunt`, `graveyard`,
|
|
45
|
+
`ghosts`, and `rot`.
|
|
46
|
+
|
|
47
|
+
#### Library and backends
|
|
48
|
+
|
|
49
|
+
- The git backend gained the history surface: `log` (with per-file numstat
|
|
50
|
+
churn), `file_log`, `rename_history`, `deleted_files`, `show_file`,
|
|
51
|
+
`branches`, and `tags`. Commit records use control-char field separators so
|
|
52
|
+
multiline messages can never break the parse, and `--no-renames` keeps churn
|
|
53
|
+
attribution stable.
|
|
54
|
+
- Optional GitPython backend behind the `git-reaper[git]` extra, selectable
|
|
55
|
+
with `GIT_REAPER_BACKEND=gitpython`. It shares command shapes and parsers
|
|
56
|
+
with the subprocess backend (`git_reaper.gitio.logparse`), so both return
|
|
57
|
+
byte-identical results.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- History commands run against a full clone: a full-depth fetch now
|
|
62
|
+
`--unshallow`s a previously shallow catacombs clone instead of silently
|
|
63
|
+
seeing only the tip commit.
|
|
64
|
+
- Non-ASCII paths in history output are no longer C-quoted (`core.quotepath`
|
|
65
|
+
is disabled on log commands), so they read as UTF-8 literals.
|
|
66
|
+
|
|
10
67
|
## [0.2.0] - 2026-07-02
|
|
11
68
|
|
|
12
69
|
Deeper Digging. The conjure/reanimate round trip, repo analysis, and the
|
|
@@ -114,6 +171,7 @@ library-first core.
|
|
|
114
171
|
- Test suite covering the CLI, harvest, tree, ignore matching, cache, and
|
|
115
172
|
schema export; CI workflow; mkdocs documentation site; Makefile.
|
|
116
173
|
|
|
117
|
-
[Unreleased]: https://github.com/jmcmeen/git-reaper/compare/v0.
|
|
174
|
+
[Unreleased]: https://github.com/jmcmeen/git-reaper/compare/v0.3.0...HEAD
|
|
175
|
+
[0.3.0]: https://github.com/jmcmeen/git-reaper/compare/v0.2.0...v0.3.0
|
|
118
176
|
[0.2.0]: https://github.com/jmcmeen/git-reaper/compare/v0.1.0...v0.2.0
|
|
119
177
|
[0.1.0]: https://github.com/jmcmeen/git-reaper/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: git-reaper
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A spooky utility for data mining git repositories (and any folder foolish enough to hold still).
|
|
5
5
|
Project-URL: Homepage, https://github.com/jmcmeen/git-reaper
|
|
6
6
|
Author: John McMeen
|
|
@@ -76,6 +76,8 @@ fallback if the REAPER DAW already owns the short one).
|
|
|
76
76
|
|
|
77
77
|
## Commands
|
|
78
78
|
|
|
79
|
+
### Reaping and packing
|
|
80
|
+
|
|
79
81
|
| Command | What it does |
|
|
80
82
|
| --- | --- |
|
|
81
83
|
| `harvest` | Gather files matching a pattern (default `*.md`) from a path or repo URL and concatenate them into one artifact with a provenance header and per-file dividers. |
|
|
@@ -89,6 +91,23 @@ fallback if the REAPER DAW already owns the short one).
|
|
|
89
91
|
| `pulse` | Signs-of-life check: git present, optional extras, cache health. |
|
|
90
92
|
| `banish` | Clear the catacombs (the clone cache). `--older-than 7d` for partial exorcisms. |
|
|
91
93
|
|
|
94
|
+
### Git necromancy (history mining)
|
|
95
|
+
|
|
96
|
+
| Command | What it does |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `chronicle` | Commit history (SHA, author, date, message, churn) to markdown, JSON, or CSV. `--changelog` groups commits by tag. |
|
|
99
|
+
| `souls` | Contributor stats: commits, lines added/removed, first/last seen, bus factor. `--heatmap` draws a day-of-week x hour activity grid and flags the repo's witching hour. |
|
|
100
|
+
| `haunt` | Code churn and hotspots: files ranked by change frequency and churn, the classic bug-risk proxy. |
|
|
101
|
+
| `autopsy <path>` | Deep single-file exam: creation commit, rename history (`--follow`), authors over time, churn, and a blame-based line-age summary. |
|
|
102
|
+
| `graveyard` | Every file that ever lived and died: path, date of death, the fatal commit, and its author. |
|
|
103
|
+
| `resurrect <path>` | Restore a dead file's last living bytes into the working tree or `--out`. Path traversal is refused outright. |
|
|
104
|
+
| `ghosts` | Branch hygiene: branches ranked by abandonment, with merged, gone-upstream, and (past `--than 90d`) stale flags. |
|
|
105
|
+
| `rot` | Staleness report: surviving files ranked by how long they have gone untouched. |
|
|
106
|
+
| `tombstone` | A stats card for demos and READMEs (born, age, commits, souls, last words, witching hour) as ASCII tombstone art, or JSON. |
|
|
107
|
+
|
|
108
|
+
History commands need real history, so remote sources are cloned full-depth
|
|
109
|
+
(a previously shallow catacombs clone is unshallowed automatically).
|
|
110
|
+
|
|
92
111
|
```sh
|
|
93
112
|
reaper harvest https://github.com/Textualize/rich --pattern "*.md" -o RICH.md
|
|
94
113
|
reaper conjure . --sha256 --split-tokens 100000 -o PACKED.md
|
|
@@ -98,6 +117,14 @@ reaper unfinished . --age
|
|
|
98
117
|
reaper cast nightly-pack
|
|
99
118
|
reaper tree . --format json | jq .file_count
|
|
100
119
|
reaper banish --older-than 7d
|
|
120
|
+
|
|
121
|
+
reaper chronicle . --changelog
|
|
122
|
+
reaper souls . --heatmap
|
|
123
|
+
reaper haunt . -n 20 --format json | jq '.hotspots[0]'
|
|
124
|
+
reaper autopsy src/git_reaper/cli.py
|
|
125
|
+
reaper graveyard . && reaper resurrect old/module.py --out risen/
|
|
126
|
+
reaper ghosts . --than 90d
|
|
127
|
+
reaper tombstone .
|
|
101
128
|
```
|
|
102
129
|
|
|
103
130
|
Recipes live in `.reaperrc` (or `[tool.reaper]` in pyproject.toml):
|
|
@@ -158,11 +185,6 @@ make run ARGS="tree ."
|
|
|
158
185
|
make build # sdist + wheel
|
|
159
186
|
```
|
|
160
187
|
|
|
161
|
-
The version comes from git tags (`hatch-vcs`); there is nothing to bump.
|
|
162
|
-
Publishing a GitHub release for a `vX.Y.Z` tag builds and uploads to PyPI
|
|
163
|
-
via trusted publishing, and docs deploy to GitHub Pages on every push to
|
|
164
|
-
`main`.
|
|
165
|
-
|
|
166
188
|
## License
|
|
167
189
|
|
|
168
190
|
MIT. Rest in peace.
|
|
@@ -18,6 +18,8 @@ fallback if the REAPER DAW already owns the short one).
|
|
|
18
18
|
|
|
19
19
|
## Commands
|
|
20
20
|
|
|
21
|
+
### Reaping and packing
|
|
22
|
+
|
|
21
23
|
| Command | What it does |
|
|
22
24
|
| --- | --- |
|
|
23
25
|
| `harvest` | Gather files matching a pattern (default `*.md`) from a path or repo URL and concatenate them into one artifact with a provenance header and per-file dividers. |
|
|
@@ -31,6 +33,23 @@ fallback if the REAPER DAW already owns the short one).
|
|
|
31
33
|
| `pulse` | Signs-of-life check: git present, optional extras, cache health. |
|
|
32
34
|
| `banish` | Clear the catacombs (the clone cache). `--older-than 7d` for partial exorcisms. |
|
|
33
35
|
|
|
36
|
+
### Git necromancy (history mining)
|
|
37
|
+
|
|
38
|
+
| Command | What it does |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `chronicle` | Commit history (SHA, author, date, message, churn) to markdown, JSON, or CSV. `--changelog` groups commits by tag. |
|
|
41
|
+
| `souls` | Contributor stats: commits, lines added/removed, first/last seen, bus factor. `--heatmap` draws a day-of-week x hour activity grid and flags the repo's witching hour. |
|
|
42
|
+
| `haunt` | Code churn and hotspots: files ranked by change frequency and churn, the classic bug-risk proxy. |
|
|
43
|
+
| `autopsy <path>` | Deep single-file exam: creation commit, rename history (`--follow`), authors over time, churn, and a blame-based line-age summary. |
|
|
44
|
+
| `graveyard` | Every file that ever lived and died: path, date of death, the fatal commit, and its author. |
|
|
45
|
+
| `resurrect <path>` | Restore a dead file's last living bytes into the working tree or `--out`. Path traversal is refused outright. |
|
|
46
|
+
| `ghosts` | Branch hygiene: branches ranked by abandonment, with merged, gone-upstream, and (past `--than 90d`) stale flags. |
|
|
47
|
+
| `rot` | Staleness report: surviving files ranked by how long they have gone untouched. |
|
|
48
|
+
| `tombstone` | A stats card for demos and READMEs (born, age, commits, souls, last words, witching hour) as ASCII tombstone art, or JSON. |
|
|
49
|
+
|
|
50
|
+
History commands need real history, so remote sources are cloned full-depth
|
|
51
|
+
(a previously shallow catacombs clone is unshallowed automatically).
|
|
52
|
+
|
|
34
53
|
```sh
|
|
35
54
|
reaper harvest https://github.com/Textualize/rich --pattern "*.md" -o RICH.md
|
|
36
55
|
reaper conjure . --sha256 --split-tokens 100000 -o PACKED.md
|
|
@@ -40,6 +59,14 @@ reaper unfinished . --age
|
|
|
40
59
|
reaper cast nightly-pack
|
|
41
60
|
reaper tree . --format json | jq .file_count
|
|
42
61
|
reaper banish --older-than 7d
|
|
62
|
+
|
|
63
|
+
reaper chronicle . --changelog
|
|
64
|
+
reaper souls . --heatmap
|
|
65
|
+
reaper haunt . -n 20 --format json | jq '.hotspots[0]'
|
|
66
|
+
reaper autopsy src/git_reaper/cli.py
|
|
67
|
+
reaper graveyard . && reaper resurrect old/module.py --out risen/
|
|
68
|
+
reaper ghosts . --than 90d
|
|
69
|
+
reaper tombstone .
|
|
43
70
|
```
|
|
44
71
|
|
|
45
72
|
Recipes live in `.reaperrc` (or `[tool.reaper]` in pyproject.toml):
|
|
@@ -100,11 +127,6 @@ make run ARGS="tree ."
|
|
|
100
127
|
make build # sdist + wheel
|
|
101
128
|
```
|
|
102
129
|
|
|
103
|
-
The version comes from git tags (`hatch-vcs`); there is nothing to bump.
|
|
104
|
-
Publishing a GitHub release for a `vX.Y.Z` tag builds and uploads to PyPI
|
|
105
|
-
via trusted publishing, and docs deploy to GitHub Pages on every push to
|
|
106
|
-
`main`.
|
|
107
|
-
|
|
108
130
|
## License
|
|
109
131
|
|
|
110
132
|
MIT. Rest in peace.
|
|
@@ -102,6 +102,11 @@ ignore_missing_imports = true
|
|
|
102
102
|
module = "tomli"
|
|
103
103
|
ignore_missing_imports = true
|
|
104
104
|
|
|
105
|
+
[[tool.mypy.overrides]]
|
|
106
|
+
# GitPython ships only in the [git] extra; the base type-check must not need it
|
|
107
|
+
module = "git.*"
|
|
108
|
+
ignore_missing_imports = true
|
|
109
|
+
|
|
105
110
|
[tool.pytest.ini_options]
|
|
106
111
|
testpaths = ["tests"]
|
|
107
112
|
addopts = "-q"
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -45,13 +45,18 @@ def banner(version: str, width: int = 80) -> str:
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def tombstone(lines: list[str]) -> str:
|
|
48
|
-
"""Render lines of text inside ASCII tombstone art.
|
|
49
|
-
|
|
48
|
+
"""Render lines of text inside ASCII tombstone art.
|
|
49
|
+
|
|
50
|
+
The frame is sized to the widest line so the right border always closes
|
|
51
|
+
flush, however long the epitaph runs.
|
|
52
|
+
"""
|
|
53
|
+
content = ["R I P", "", *lines]
|
|
54
|
+
inner = max((len(line) for line in content), default=0)
|
|
50
55
|
inner = max(inner, 11)
|
|
51
|
-
top = " " + "_" * inner
|
|
52
|
-
body = [f" /{' ' * inner}\\"]
|
|
53
|
-
body.extend(f" | {line.center(inner
|
|
54
|
-
body.append(" ___|" + "_" * inner + "|___")
|
|
56
|
+
top = " " + "_" * (inner + 2)
|
|
57
|
+
body = [f" /{' ' * (inner + 2)}\\"]
|
|
58
|
+
body.extend(f" | {line.center(inner)} |" for line in content)
|
|
59
|
+
body.append(" ___|" + "_" * (inner + 2) + "|___")
|
|
55
60
|
return "\n".join([top, *body])
|
|
56
61
|
|
|
57
62
|
|
|
@@ -21,7 +21,10 @@ from rich.table import Table
|
|
|
21
21
|
|
|
22
22
|
from git_reaper import __version__, art, cache, config, fsutil, schemas
|
|
23
23
|
from git_reaper.core import census as census_core
|
|
24
|
+
from git_reaper.core import graveyard as graveyard_core
|
|
24
25
|
from git_reaper.core import harvest as harvest_core
|
|
26
|
+
from git_reaper.core import history as history_core
|
|
27
|
+
from git_reaper.core import hygiene as hygiene_core
|
|
25
28
|
from git_reaper.core import pack as pack_core
|
|
26
29
|
from git_reaper.core import pulse as pulse_core
|
|
27
30
|
from git_reaper.core import scan as scan_core
|
|
@@ -67,6 +70,11 @@ def _invocation() -> str:
|
|
|
67
70
|
return "reaper " + " ".join(shlex.quote(a) for a in sys.argv[1:])
|
|
68
71
|
|
|
69
72
|
|
|
73
|
+
def _parse_days(text: str) -> int:
|
|
74
|
+
"""A haunting threshold like '90d' or '12h', floored to whole days."""
|
|
75
|
+
return int(cache.parse_age(text) // 86400)
|
|
76
|
+
|
|
77
|
+
|
|
70
78
|
def _emit(text: str, out: Path | None) -> None:
|
|
71
79
|
"""Write an artifact to --out or stdout. Chatter never comes here."""
|
|
72
80
|
if out:
|
|
@@ -510,6 +518,296 @@ def cast_cmd(
|
|
|
510
518
|
raise typer.Exit(code=code) from exc
|
|
511
519
|
|
|
512
520
|
|
|
521
|
+
# --------------------------------------------------------------------------
|
|
522
|
+
# git necromancy: chronicle, souls, haunt, autopsy, graveyard, resurrect,
|
|
523
|
+
# ghosts, rot, tombstone. History needs a full clone, so remote sources are
|
|
524
|
+
# fetched deep (depth=None), not shallow.
|
|
525
|
+
# --------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _resolve_history(source: str, ref: str | None) -> ResolvedSource:
|
|
529
|
+
return _resolve(source, ref=ref, depth=None)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _history_die(exc: GitError) -> typer.Exit:
|
|
533
|
+
return _die(str(exc), "`reaper pulse` checks git; history needs a real repo")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@app.command("chronicle")
|
|
537
|
+
def chronicle_cmd(
|
|
538
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
539
|
+
changelog: bool = typer.Option(False, "--changelog", help="Group commits by tag."),
|
|
540
|
+
max_count: int | None = typer.Option(
|
|
541
|
+
None, "--max-count", "-n", help="Only the newest N commits."
|
|
542
|
+
),
|
|
543
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
544
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
545
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
546
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Extract commit history to markdown, JSON, or CSV."""
|
|
549
|
+
if schema:
|
|
550
|
+
_print_schema("chronicle")
|
|
551
|
+
return
|
|
552
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
553
|
+
_banner()
|
|
554
|
+
resolved = _resolve_history(source, ref)
|
|
555
|
+
try:
|
|
556
|
+
result = history_core.chronicle(
|
|
557
|
+
resolved.repo, changelog=changelog, max_count=max_count, invoked=_invocation()
|
|
558
|
+
)
|
|
559
|
+
except GitError as exc:
|
|
560
|
+
raise _history_die(exc) from exc
|
|
561
|
+
_say("necro", f"transcribed {len(result.commits)} commits")
|
|
562
|
+
if fmt == "json":
|
|
563
|
+
_emit(jsonfmt.render(result), out)
|
|
564
|
+
elif fmt == "csv":
|
|
565
|
+
_emit(csvfmt.render_chronicle(result), out)
|
|
566
|
+
else:
|
|
567
|
+
_emit(markdown.render_chronicle(result), out)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@app.command("souls")
|
|
571
|
+
def souls_cmd(
|
|
572
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
573
|
+
heatmap: bool = typer.Option(False, "--heatmap", help="Add the activity heatmap."),
|
|
574
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
575
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
576
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
577
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Contributor stats, bus factor, and the witching hour."""
|
|
580
|
+
if schema:
|
|
581
|
+
_print_schema("souls")
|
|
582
|
+
return
|
|
583
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
584
|
+
_banner()
|
|
585
|
+
resolved = _resolve_history(source, ref)
|
|
586
|
+
try:
|
|
587
|
+
result = history_core.souls(resolved.repo, heatmap=heatmap, invoked=_invocation())
|
|
588
|
+
except GitError as exc:
|
|
589
|
+
raise _history_die(exc) from exc
|
|
590
|
+
_say("necro", f"counted {len(result.souls)} souls, bus factor {result.bus_factor}")
|
|
591
|
+
if result.witching_hour:
|
|
592
|
+
_say("eldritch", f"the witching hour is {result.witching_hour}")
|
|
593
|
+
if fmt == "json":
|
|
594
|
+
_emit(jsonfmt.render(result), out)
|
|
595
|
+
elif fmt == "csv":
|
|
596
|
+
_emit(csvfmt.render_souls(result), out)
|
|
597
|
+
else:
|
|
598
|
+
_emit(markdown.render_souls(result), out)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@app.command("haunt")
|
|
602
|
+
def haunt_cmd(
|
|
603
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
604
|
+
limit: int | None = typer.Option(None, "--limit", "-n", help="Only the top N hotspots."),
|
|
605
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
606
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
607
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
608
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Code churn and hotspots: the classic bug-risk proxy."""
|
|
611
|
+
if schema:
|
|
612
|
+
_print_schema("haunt")
|
|
613
|
+
return
|
|
614
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
615
|
+
_banner()
|
|
616
|
+
resolved = _resolve_history(source, ref)
|
|
617
|
+
try:
|
|
618
|
+
result = history_core.haunt(resolved.repo, limit=limit, invoked=_invocation())
|
|
619
|
+
except GitError as exc:
|
|
620
|
+
raise _history_die(exc) from exc
|
|
621
|
+
_say("necro", f"found {len(result.hotspots)} hotspots")
|
|
622
|
+
if fmt == "json":
|
|
623
|
+
_emit(jsonfmt.render(result), out)
|
|
624
|
+
elif fmt == "csv":
|
|
625
|
+
_emit(csvfmt.render_haunt(result), out)
|
|
626
|
+
else:
|
|
627
|
+
_emit(markdown.render_haunt(result), out)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@app.command("autopsy")
|
|
631
|
+
def autopsy_cmd(
|
|
632
|
+
path: str | None = typer.Argument(None, help="File to examine (relative to the repo)."),
|
|
633
|
+
source: str = typer.Option(".", "--source", "-s", help="Local path or repo URL."),
|
|
634
|
+
no_follow: bool = typer.Option(False, "--no-follow", help="Do not follow renames."),
|
|
635
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md or json."),
|
|
636
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
637
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
638
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
639
|
+
) -> None:
|
|
640
|
+
"""Deep single-file examination: birth, authors, churn, blame age."""
|
|
641
|
+
if schema:
|
|
642
|
+
_print_schema("autopsy")
|
|
643
|
+
return
|
|
644
|
+
if path is None:
|
|
645
|
+
raise _die("no file given", "pass the path of a file to examine")
|
|
646
|
+
_validate_format(fmt)
|
|
647
|
+
_banner()
|
|
648
|
+
resolved = _resolve_history(source, ref)
|
|
649
|
+
try:
|
|
650
|
+
result = history_core.autopsy(
|
|
651
|
+
resolved.repo, path, follow=not no_follow, invoked=_invocation()
|
|
652
|
+
)
|
|
653
|
+
except GitError as exc:
|
|
654
|
+
raise _history_die(exc) from exc
|
|
655
|
+
_say("necro", f"examined {result.path}: {result.commits} commits, {len(result.authors)} hands")
|
|
656
|
+
if fmt == "json":
|
|
657
|
+
_emit(jsonfmt.render(result), out)
|
|
658
|
+
else:
|
|
659
|
+
_emit(markdown.render_autopsy(result), out)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@app.command("graveyard")
|
|
663
|
+
def graveyard_cmd(
|
|
664
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
665
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
666
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
667
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
668
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
669
|
+
) -> None:
|
|
670
|
+
"""List every file that ever lived and died in the repo."""
|
|
671
|
+
if schema:
|
|
672
|
+
_print_schema("graveyard")
|
|
673
|
+
return
|
|
674
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
675
|
+
_banner()
|
|
676
|
+
resolved = _resolve_history(source, ref)
|
|
677
|
+
try:
|
|
678
|
+
result = graveyard_core.graveyard(resolved.repo, invoked=_invocation())
|
|
679
|
+
except GitError as exc:
|
|
680
|
+
raise _history_die(exc) from exc
|
|
681
|
+
_say("necro", f"counted {len(result.dead)} dead")
|
|
682
|
+
if fmt == "json":
|
|
683
|
+
_emit(jsonfmt.render(result), out)
|
|
684
|
+
elif fmt == "csv":
|
|
685
|
+
_emit(csvfmt.render_graveyard(result), out)
|
|
686
|
+
else:
|
|
687
|
+
_emit(markdown.render_graveyard(result), out)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
@app.command("resurrect")
|
|
691
|
+
def resurrect_cmd(
|
|
692
|
+
path: str | None = typer.Argument(None, help="Dead file to raise (its path in the repo)."),
|
|
693
|
+
source: str = typer.Option(".", "--source", "-s", help="Local path or repo URL."),
|
|
694
|
+
out: Path = typer.Option(
|
|
695
|
+
Path("."), "--out", "-o", help="Directory (keeps the path) or exact file target."
|
|
696
|
+
),
|
|
697
|
+
force: bool = typer.Option(False, "--force", help="Overwrite if the target exists."),
|
|
698
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
699
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
700
|
+
) -> None:
|
|
701
|
+
"""Restore a dead file from the graveyard into the working tree."""
|
|
702
|
+
if schema:
|
|
703
|
+
_print_schema("resurrect")
|
|
704
|
+
return
|
|
705
|
+
if path is None:
|
|
706
|
+
raise _die("no file given", "pass the path of a dead file; `reaper graveyard` lists them")
|
|
707
|
+
_banner()
|
|
708
|
+
resolved = _resolve_history(source, ref)
|
|
709
|
+
try:
|
|
710
|
+
result = graveyard_core.resurrect(resolved.repo, path, out, force=force)
|
|
711
|
+
except graveyard_core.ResurrectError as exc:
|
|
712
|
+
raise _die(str(exc), "`reaper graveyard` lists what can be raised") from exc
|
|
713
|
+
except GitError as exc:
|
|
714
|
+
raise _history_die(exc) from exc
|
|
715
|
+
_say("necro", f"raised {result.path} from {result.sha[:7]} into {result.out}")
|
|
716
|
+
_say("ash", "it walks again.")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@app.command("ghosts")
|
|
720
|
+
def ghosts_cmd(
|
|
721
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
722
|
+
than: str | None = typer.Option(
|
|
723
|
+
None, "--than", help="Flag branches idle longer than this (e.g. 90d)."
|
|
724
|
+
),
|
|
725
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
726
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
727
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
728
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
729
|
+
) -> None:
|
|
730
|
+
"""Branch hygiene: activity, merged-but-undeleted, gone remotes."""
|
|
731
|
+
if schema:
|
|
732
|
+
_print_schema("ghosts")
|
|
733
|
+
return
|
|
734
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
735
|
+
_banner()
|
|
736
|
+
try:
|
|
737
|
+
than_days = _parse_days(than) if than else None
|
|
738
|
+
except ValueError as exc:
|
|
739
|
+
raise _die(str(exc)) from exc
|
|
740
|
+
resolved = _resolve_history(source, ref)
|
|
741
|
+
try:
|
|
742
|
+
result = hygiene_core.ghosts(resolved.repo, than_days=than_days, invoked=_invocation())
|
|
743
|
+
except GitError as exc:
|
|
744
|
+
raise _history_die(exc) from exc
|
|
745
|
+
_say("necro", f"walked {len(result.branches)} branches")
|
|
746
|
+
if fmt == "json":
|
|
747
|
+
_emit(jsonfmt.render(result), out)
|
|
748
|
+
elif fmt == "csv":
|
|
749
|
+
_emit(csvfmt.render_ghosts(result), out)
|
|
750
|
+
else:
|
|
751
|
+
_emit(markdown.render_ghosts(result), out)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@app.command("rot")
|
|
755
|
+
def rot_cmd(
|
|
756
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
757
|
+
limit: int | None = typer.Option(None, "--limit", "-n", help="Only the top N stalest."),
|
|
758
|
+
exclude: list[str] = typer.Option([], "--exclude", "-x", help="Glob(s) to skip."),
|
|
759
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md, json, or csv."),
|
|
760
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
761
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
762
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
763
|
+
) -> None:
|
|
764
|
+
"""Staleness report: files untouched the longest."""
|
|
765
|
+
if schema:
|
|
766
|
+
_print_schema("rot")
|
|
767
|
+
return
|
|
768
|
+
_validate_format(fmt, ("md", "json", "csv"))
|
|
769
|
+
_banner()
|
|
770
|
+
resolved = _resolve_history(source, ref)
|
|
771
|
+
try:
|
|
772
|
+
result = hygiene_core.rot(
|
|
773
|
+
resolved.repo, limit=limit, excludes=exclude, invoked=_invocation()
|
|
774
|
+
)
|
|
775
|
+
except GitError as exc:
|
|
776
|
+
raise _history_die(exc) from exc
|
|
777
|
+
_say("necro", f"weighed {len(result.files)} files for rot")
|
|
778
|
+
if fmt == "json":
|
|
779
|
+
_emit(jsonfmt.render(result), out)
|
|
780
|
+
elif fmt == "csv":
|
|
781
|
+
_emit(csvfmt.render_rot(result), out)
|
|
782
|
+
else:
|
|
783
|
+
_emit(markdown.render_rot(result), out)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
@app.command("tombstone")
|
|
787
|
+
def tombstone_cmd(
|
|
788
|
+
source: str = typer.Argument(".", help="Local path or repo URL."),
|
|
789
|
+
fmt: str = typer.Option("md", "--format", "-f", help="md or json."),
|
|
790
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Output file (default stdout)."),
|
|
791
|
+
ref: str | None = typer.Option(None, "--ref", help="Branch, tag, or sha."),
|
|
792
|
+
schema: bool = typer.Option(False, "--schema", help="Print the JSON schema and exit."),
|
|
793
|
+
) -> None:
|
|
794
|
+
"""A stats card for demos and READMEs, in ASCII tombstone art."""
|
|
795
|
+
if schema:
|
|
796
|
+
_print_schema("tombstone")
|
|
797
|
+
return
|
|
798
|
+
_validate_format(fmt)
|
|
799
|
+
_banner()
|
|
800
|
+
resolved = _resolve_history(source, ref)
|
|
801
|
+
try:
|
|
802
|
+
result = history_core.tombstone(resolved.repo, invoked=_invocation())
|
|
803
|
+
except GitError as exc:
|
|
804
|
+
raise _history_die(exc) from exc
|
|
805
|
+
if fmt == "json":
|
|
806
|
+
_emit(jsonfmt.render(result), out)
|
|
807
|
+
else:
|
|
808
|
+
_emit(markdown.render_tombstone(result), out)
|
|
809
|
+
|
|
810
|
+
|
|
513
811
|
# --------------------------------------------------------------------------
|
|
514
812
|
# pulse (doctor)
|
|
515
813
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The graveyard and its resurrections.
|
|
2
|
+
|
|
3
|
+
`graveyard` lists every file that lived and died; `resurrect` reads a dead
|
|
4
|
+
file's last living bytes (from the parent of the commit that removed it) and
|
|
5
|
+
brings it back. Restores refuse absolute and traversal paths, same as
|
|
6
|
+
reanimate -- the zip-slip lesson does not care that the source is git.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from git_reaper.core.history import _require_repo
|
|
14
|
+
from git_reaper.core.provenance import make_provenance
|
|
15
|
+
from git_reaper.core.unpack import _unsafe
|
|
16
|
+
from git_reaper.gitio import DeadFileRecord, GitBackend, GitError, default_backend
|
|
17
|
+
from git_reaper.models import DeadFile, GraveyardResult, RepoRef, ResurrectResult
|
|
18
|
+
from git_reaper.schemas import artifact_schema
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResurrectError(ValueError):
|
|
22
|
+
"""A file could not be raised: absent, or an unsafe restore path."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def graveyard(
|
|
26
|
+
repo: RepoRef,
|
|
27
|
+
backend: GitBackend | None = None,
|
|
28
|
+
invoked: str = "reaper graveyard",
|
|
29
|
+
generated: str | None = None,
|
|
30
|
+
) -> GraveyardResult:
|
|
31
|
+
"""List files a commit removed that are not back in the working tree."""
|
|
32
|
+
backend = backend or default_backend()
|
|
33
|
+
root = _require_repo(repo, backend)
|
|
34
|
+
dead = [
|
|
35
|
+
DeadFile(path=rec.path, last_sha=rec.sha, died=rec.date, author=rec.author)
|
|
36
|
+
for rec in backend.deleted_files(root)
|
|
37
|
+
if not (root / rec.path).exists()
|
|
38
|
+
]
|
|
39
|
+
result = GraveyardResult(
|
|
40
|
+
provenance=make_provenance(artifact_schema("graveyard"), repo, invoked, generated),
|
|
41
|
+
dead=dead,
|
|
42
|
+
)
|
|
43
|
+
result.provenance.files = len(dead)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _find_death(backend: GitBackend, root: Path, rel_path: str) -> DeadFileRecord | None:
|
|
48
|
+
for rec in backend.deleted_files(root):
|
|
49
|
+
if rec.path == rel_path:
|
|
50
|
+
return rec
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resurrect(
|
|
55
|
+
repo: RepoRef,
|
|
56
|
+
rel_path: str,
|
|
57
|
+
out: Path,
|
|
58
|
+
force: bool = False,
|
|
59
|
+
backend: GitBackend | None = None,
|
|
60
|
+
) -> ResurrectResult:
|
|
61
|
+
"""Restore a dead file. `out` is a directory (the file keeps its path) or,
|
|
62
|
+
if it looks like a file target, the exact destination."""
|
|
63
|
+
backend = backend or default_backend()
|
|
64
|
+
root = _require_repo(repo, backend)
|
|
65
|
+
|
|
66
|
+
reason = _unsafe(rel_path)
|
|
67
|
+
if reason:
|
|
68
|
+
raise ResurrectError(f"refusing to resurrect {rel_path!r}: {reason}")
|
|
69
|
+
|
|
70
|
+
death = _find_death(backend, root, rel_path)
|
|
71
|
+
if death is None:
|
|
72
|
+
raise ResurrectError(f"{rel_path!r} is not in the graveyard (never deleted, or wrong path)")
|
|
73
|
+
|
|
74
|
+
# The fatal commit removed the file; its content still lives in the parent.
|
|
75
|
+
try:
|
|
76
|
+
content = backend.show_file(root, f"{death.sha}~1", rel_path)
|
|
77
|
+
except GitError as exc:
|
|
78
|
+
raise ResurrectError(str(exc)) from exc
|
|
79
|
+
if content is None:
|
|
80
|
+
raise ResurrectError(f"could not read {rel_path!r} at {death.sha[:7]}~1")
|
|
81
|
+
|
|
82
|
+
target = out / rel_path if out.is_dir() or not out.suffix else out
|
|
83
|
+
if target.exists() and not force:
|
|
84
|
+
raise ResurrectError(f"{target} already exists; use --force to overwrite")
|
|
85
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
target.write_bytes(content)
|
|
87
|
+
return ResurrectResult(
|
|
88
|
+
path=rel_path, sha=f"{death.sha}~1", out=str(target), size_bytes=len(content)
|
|
89
|
+
)
|