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.
Files changed (74) hide show
  1. {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/ci.yml +14 -0
  2. {git_reaper-0.2.0 → git_reaper-0.3.0}/CHANGELOG.md +59 -1
  3. {git_reaper-0.2.0 → git_reaper-0.3.0}/PKG-INFO +28 -6
  4. {git_reaper-0.2.0 → git_reaper-0.3.0}/README.md +27 -5
  5. {git_reaper-0.2.0 → git_reaper-0.3.0}/pyproject.toml +5 -0
  6. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/_version.py +2 -2
  7. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/art.py +11 -6
  8. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/cli.py +298 -0
  9. git_reaper-0.3.0/src/git_reaper/core/graveyard.py +89 -0
  10. git_reaper-0.3.0/src/git_reaper/core/history.py +372 -0
  11. git_reaper-0.3.0/src/git_reaper/core/hygiene.py +99 -0
  12. git_reaper-0.3.0/src/git_reaper/formatters/csvfmt.py +129 -0
  13. git_reaper-0.3.0/src/git_reaper/formatters/markdown.py +336 -0
  14. git_reaper-0.3.0/src/git_reaper/gitio/__init__.py +40 -0
  15. git_reaper-0.3.0/src/git_reaper/gitio/backend.py +145 -0
  16. git_reaper-0.3.0/src/git_reaper/gitio/gitpython_git.py +161 -0
  17. git_reaper-0.3.0/src/git_reaper/gitio/logparse.py +197 -0
  18. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/gitio/subprocess_git.py +70 -11
  19. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/models.py +191 -0
  20. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/schemas.py +9 -0
  21. git_reaper-0.3.0/tests/conftest.py +202 -0
  22. git_reaper-0.3.0/tests/test_backend.py +40 -0
  23. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_cli.py +108 -0
  24. git_reaper-0.3.0/tests/test_gitpython_backend.py +97 -0
  25. git_reaper-0.3.0/tests/test_graveyard.py +61 -0
  26. git_reaper-0.3.0/tests/test_history.py +142 -0
  27. git_reaper-0.3.0/tests/test_hygiene.py +56 -0
  28. git_reaper-0.2.0/src/git_reaper/formatters/csvfmt.py +0 -45
  29. git_reaper-0.2.0/src/git_reaper/formatters/markdown.py +0 -130
  30. git_reaper-0.2.0/src/git_reaper/gitio/__init__.py +0 -11
  31. git_reaper-0.2.0/src/git_reaper/gitio/backend.py +0 -51
  32. git_reaper-0.2.0/tests/conftest.py +0 -76
  33. {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/docs.yml +0 -0
  34. {git_reaper-0.2.0 → git_reaper-0.3.0}/.github/workflows/publish.yml +0 -0
  35. {git_reaper-0.2.0 → git_reaper-0.3.0}/.gitignore +0 -0
  36. {git_reaper-0.2.0 → git_reaper-0.3.0}/.markdownlint.json +0 -0
  37. {git_reaper-0.2.0 → git_reaper-0.3.0}/LICENSE +0 -0
  38. {git_reaper-0.2.0 → git_reaper-0.3.0}/Makefile +0 -0
  39. {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/api.md +0 -0
  40. {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/commands.md +0 -0
  41. {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/development.md +0 -0
  42. {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/index.md +0 -0
  43. {git_reaper-0.2.0 → git_reaper-0.3.0}/docs/library.md +0 -0
  44. {git_reaper-0.2.0 → git_reaper-0.3.0}/mkdocs.yml +0 -0
  45. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/__init__.py +0 -0
  46. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/__main__.py +0 -0
  47. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/cache.py +0 -0
  48. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/config.py +0 -0
  49. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/__init__.py +0 -0
  50. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/census.py +0 -0
  51. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/harvest.py +0 -0
  52. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/pack.py +0 -0
  53. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/provenance.py +0 -0
  54. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/pulse.py +0 -0
  55. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/scan.py +0 -0
  56. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/source.py +0 -0
  57. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/tree.py +0 -0
  58. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/core/unpack.py +0 -0
  59. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/formatters/__init__.py +0 -0
  60. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/formatters/jsonfmt.py +0 -0
  61. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/fsutil.py +0 -0
  62. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/ignore.py +0 -0
  63. {git_reaper-0.2.0 → git_reaper-0.3.0}/src/git_reaper/theme.py +0 -0
  64. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_cache.py +0 -0
  65. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_census.py +0 -0
  66. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_fsutil.py +0 -0
  67. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_grimoire.py +0 -0
  68. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_harvest.py +0 -0
  69. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_ignore.py +0 -0
  70. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_pack.py +0 -0
  71. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_scan.py +0 -0
  72. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_schemas.py +0 -0
  73. {git_reaper-0.2.0 → git_reaper-0.3.0}/tests/test_tree.py +0 -0
  74. {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.2.0...HEAD
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.2.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.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 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
- inner = max(len(line) for line in lines) if lines else 0
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 - 2)} |" for line in ["R I P", "", *lines])
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
+ )