git-backstory 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/bin/git-backstory.js +3 -0
- package/llms.txt +27 -0
- package/package.json +53 -4
- package/skills/git-backstory/SKILL.md +28 -0
- package/src/analyze.js +170 -0
- package/src/cli.js +153 -0
- package/src/git.js +147 -0
- package/src/render.js +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amado / ByteCraft Technologies LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# git-backstory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/git-backstory)
|
|
4
|
+
[](https://github.com/hexmillionaire/git-backstory/actions/workflows/ci.yml)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**The backstory of a file, in one page, for whoever has to change it next.**
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx git-backstory src/billing/invoice.ts
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`git blame` tells you who. `git log` tells you when. Neither tells you that the rounding change was reverted twice, that this file never changes without `invoice.test.ts`, or that someone wrote "must be applied before rounding, otherwise totals drift" in a commit body three years ago. git-backstory reads the history and writes that down in a page of markdown.
|
|
15
|
+
|
|
16
|
+
Zero dependencies. Runs `git log` and `git blame` only. Never writes to your repo, never touches the network, no API key.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Who it's for
|
|
21
|
+
|
|
22
|
+
**Coding agents.** Claude Code, Cursor, Codex and friends read the current code and nothing else. They don't know a change was tried and reverted, so they try it again. Run git-backstory on the file before editing and paste the brief in, or install the [skill](#let-your-agent-run-it) and let the agent do it.
|
|
23
|
+
|
|
24
|
+
**You, on a file you didn't write.** Before touching the scary file, read its brief. Takes a second to generate and a minute to read.
|
|
25
|
+
|
|
26
|
+
**Reviewers.** `git-backstory path/to/file:120-160` on the lines a PR touches tells you whether those lines have a history of trouble.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
Run on the navbar of a Next.js app:
|
|
33
|
+
|
|
34
|
+
```markdown
|
|
35
|
+
# History brief: `src/components/navbar.tsx`
|
|
36
|
+
|
|
37
|
+
22 commits · first 2026-03-26 (7 months ago) · last 2026-10-08 (10 days ago) · 2 authors · 139 lines now
|
|
38
|
+
|
|
39
|
+
## Be careful
|
|
40
|
+
|
|
41
|
+
- Fix-shaped commits (8): `41a3ae1` "feat: fix reading on a phone"; `2722d44` "fix: use Clerk
|
|
42
|
+
useUser() for UI auth gating to break auth loop"; `b24e8cc` "fix: resolve Convex-Clerk auth
|
|
43
|
+
integration and add logout button"; …
|
|
44
|
+
|
|
45
|
+
## Reasons on record
|
|
46
|
+
|
|
47
|
+
- Geist fonts from node_modules so builds never fetch Google Fonts — `a77be23`, 2026-10-08
|
|
48
|
+
|
|
49
|
+
## Usually changes together with
|
|
50
|
+
|
|
51
|
+
- `src/app/bible/[book]/[chapter]/chapter-reader.tsx` — 10 of the sampled commits (45%)
|
|
52
|
+
- `src/app/layout.tsx` — 9 of the sampled commits (41%)
|
|
53
|
+
- `src/app/login/page.tsx` — 7 of the sampled commits (32%)
|
|
54
|
+
|
|
55
|
+
If you change this file, check whether those need the same change.
|
|
56
|
+
|
|
57
|
+
## Who to ask
|
|
58
|
+
|
|
59
|
+
Current lines by author: hexmillionaire 65%, Amado 35%.
|
|
60
|
+
|
|
61
|
+
## Timeline
|
|
62
|
+
|
|
63
|
+
- 2026-10-08 `a77be23` Extract Vibe Stack: Next.js + Convex + Clerk starter kit — Amado
|
|
64
|
+
- 2026-08-31 `41a3ae1` feat: fix reading on a phone — hexmillionaire
|
|
65
|
+
- 2026-03-28 `2722d44` fix: use Clerk useUser() for UI auth gating to break auth loop — hexmillionaire
|
|
66
|
+
…
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
When the remote is on GitHub, GitLab or Bitbucket, hashes and `#123` references become links.
|
|
70
|
+
|
|
71
|
+
## What it looks for
|
|
72
|
+
|
|
73
|
+
| Section | Source | Shows up when |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| **Be careful** | Reverts (`This reverts commit…` or `Revert "…"`), fix-shaped subjects, bursts of commits around a fix | There is a revert, a fix, or a cluster of attempts |
|
|
76
|
+
| **Reasons on record** | Commit bodies: sentences with *because*, *must*, *never*, *otherwise*, *so that*… | An author explained a constraint |
|
|
77
|
+
| **Usually changes together with** | Files changed in the same commits, ≥30% of the time | The file has coupling worth knowing about |
|
|
78
|
+
| **Referenced issues and PRs** | `#123` / `GH-123` in subjects and bodies | Any are present |
|
|
79
|
+
| **Who to ask** | `git blame` ownership of the current lines | Always, for tracked files |
|
|
80
|
+
| **Timeline** | Notable commits plus the most recent ones, newest first | Always |
|
|
81
|
+
|
|
82
|
+
Sections with nothing to say are omitted, so a quiet file gets a quiet brief.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx git-backstory <path> # brief for a file
|
|
90
|
+
npx git-backstory <path>:<line> # one line
|
|
91
|
+
npx git-backstory <path>:<from>-<to> # a line range (uses git log -L)
|
|
92
|
+
npx git-backstory <path> --json # the facts as JSON
|
|
93
|
+
npx git-backstory <path> --out BRIEF.md
|
|
94
|
+
npx git-backstory <path> --since "6 months ago"
|
|
95
|
+
npx git-backstory <path> --max 500 # sample more than 200 commits
|
|
96
|
+
npx git-backstory <path> --no-links # plain hashes
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Requires Node 18+ and git. Paths can be relative to your current directory or absolute; the brief is for the repository that contains them. Renames are followed, and the brief lists the file's previous paths.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Let your agent run it
|
|
104
|
+
|
|
105
|
+
**Claude Code:** install the skill once; the agent then runs a brief before editing files with history.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
mkdir -p ~/.claude/skills/git-backstory
|
|
109
|
+
curl -fsSL https://raw.githubusercontent.com/hexmillionaire/git-backstory/main/skills/git-backstory/SKILL.md \
|
|
110
|
+
-o ~/.claude/skills/git-backstory/SKILL.md
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**Anything else:** add a line to your `CLAUDE.md`, `AGENTS.md` or rules file:
|
|
114
|
+
|
|
115
|
+
> Before changing a file with more than a handful of commits, run `npx -y git-backstory <path>` and read the "Be careful" and "Usually changes together with" sections.
|
|
116
|
+
|
|
117
|
+
Agents looking for a summary of this tool can read [`llms.txt`](llms.txt).
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Why
|
|
122
|
+
|
|
123
|
+
The most expensive bugs in a codebase are the ones that were already fixed once. The fix is in the history, the reason is in a commit body, and nobody reads either before changing the code, least of all an agent that only sees the working tree. git-backstory makes that history cheap to read: one command, one page, the parts that matter first.
|
|
124
|
+
|
|
125
|
+
It is deliberately heuristic. "Fix-shaped" means the subject says fix, regression, revert, broke or similar; "reasons" means sentences that sound like constraints. It will miss some and over-include others. Read the brief like a colleague's notes, not a verdict.
|
|
126
|
+
|
|
127
|
+
## Safety
|
|
128
|
+
|
|
129
|
+
Runs `git log`, `git blame`, `git show`, `git ls-files`, `git rev-parse` and `git remote get-url`. All reads. It never executes project code and never makes network requests. Commit messages and author names from the repository appear in the output, so review a brief before sharing it outside the team. See [SECURITY.md](SECURITY.md).
|
|
130
|
+
|
|
131
|
+
## Related
|
|
132
|
+
|
|
133
|
+
- [claude-md-generator](https://github.com/hexmillionaire/claude-md-generator) — `npx claude-md-generator` drafts the CLAUDE.md your agent reads every session.
|
|
134
|
+
- [Agent Lanes](https://github.com/hexmillionaire/agent-lanes) — keep an agent's task inside its declared file scope, then hand it off.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
package/llms.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# git-backstory
|
|
2
|
+
|
|
3
|
+
> Zero-dependency Node CLI that writes a one-page markdown history brief for a single file (or line range) from `git log` and `git blame`: reverts, fix bursts, constraints quoted from commit bodies, files that usually change together, line ownership and a short timeline. Built so coding agents stop re-making changes that were already reverted.
|
|
4
|
+
|
|
5
|
+
Run in any git repo, no install: `npx -y git-backstory <path>`. Node 18+ and git. Read-only: never writes to the repo, never uses the network, no API key.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
- `npx -y git-backstory src/file.ts` — brief for a file (follows renames)
|
|
10
|
+
- `npx -y git-backstory src/file.ts:120-160` — brief for a line range
|
|
11
|
+
- `npx -y git-backstory src/file.ts --json` — the same facts as JSON
|
|
12
|
+
- `--since <date>`, `--max <n>`, `--out <file>`, `--no-links`
|
|
13
|
+
|
|
14
|
+
## When an agent should run it
|
|
15
|
+
|
|
16
|
+
Before editing a file that has more than a handful of commits, or any file the task calls risky. Read "Be careful" (reverts, fixes, bursts) and "Usually changes together with" (coupling) first; if a revert is listed, open both commits before attempting a similar change.
|
|
17
|
+
|
|
18
|
+
## Output sections
|
|
19
|
+
|
|
20
|
+
Be careful · Reasons on record · Usually changes together with · Referenced issues and PRs · Who to ask · Timeline. Empty sections are omitted.
|
|
21
|
+
|
|
22
|
+
## Docs
|
|
23
|
+
|
|
24
|
+
- README: https://github.com/hexmillionaire/git-backstory#readme
|
|
25
|
+
- Agent skill: https://github.com/hexmillionaire/git-backstory/blob/main/skills/git-backstory/SKILL.md
|
|
26
|
+
- Security: https://github.com/hexmillionaire/git-backstory/blob/main/SECURITY.md
|
|
27
|
+
- npm: https://www.npmjs.com/package/git-backstory
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-backstory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The backstory of a file, in one page, for whoever has to change it next: reverts, fix bursts, reasons from commit bodies, co-changed files and owners, from git log and git blame. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-backstory": "bin/git-backstory.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"llms.txt",
|
|
15
|
+
"skills"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/analyze.test.js test/cli.test.js",
|
|
22
|
+
"try": "node bin/git-backstory.js src/cli.js"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"git",
|
|
26
|
+
"git-log",
|
|
27
|
+
"git-blame",
|
|
28
|
+
"history",
|
|
29
|
+
"code-archaeology",
|
|
30
|
+
"ai-coding",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"cursor",
|
|
33
|
+
"codex",
|
|
34
|
+
"copilot",
|
|
35
|
+
"agents",
|
|
36
|
+
"context-engineering",
|
|
37
|
+
"cli",
|
|
38
|
+
"developer-tools",
|
|
39
|
+
"zero-dependencies",
|
|
40
|
+
"backstory"
|
|
41
|
+
],
|
|
42
|
+
"author": "Amado (hexmillionaire)",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/hexmillionaire/git-backstory.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/hexmillionaire/git-backstory#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/hexmillionaire/git-backstory/issues"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: git-backstory
|
|
3
|
+
description: Run a one-page git backstory on a file before changing it — reverts, fix bursts, reasons from commit bodies, files that change together. Use before editing any file with real history, when a task says "careful", or when a change you are about to make feels like it may have been tried before.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# git-backstory
|
|
7
|
+
|
|
8
|
+
A file's history says what not to do. This skill reads it in one command before you edit.
|
|
9
|
+
|
|
10
|
+
## When
|
|
11
|
+
|
|
12
|
+
- Before editing a file with more than ~5 commits, or one the user calls fragile, legacy or "the scary one".
|
|
13
|
+
- Before re-implementing something that looks like it should already exist.
|
|
14
|
+
- When a PR touches lines you don't understand: brief the range.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. Run `npx -y git-backstory <path>` (or `<path>:<from>-<to>` for a range). It needs git and Node 18+; nothing is installed into the repo.
|
|
19
|
+
2. Read **Be careful** first. If a revert is listed, open both commits (`git show <hash>`) and understand why before attempting anything similar. If there is a fix burst, assume the area is subtle.
|
|
20
|
+
3. Read **Usually changes together with**. When you change this file, check each listed file for the same change, and mention the ones you deliberately left alone.
|
|
21
|
+
4. Read **Reasons on record**. These are constraints the authors wrote down; treat them as requirements unless the user overrides them.
|
|
22
|
+
5. Make the change. In your summary to the user, cite the relevant brief lines in one sentence ("the rounding change was reverted in `b8bc09f`, so I kept rounding out of `price()`").
|
|
23
|
+
|
|
24
|
+
## Notes
|
|
25
|
+
|
|
26
|
+
- Heuristic by design: "fix-shaped" means the subject says fix/regression/revert/broke; "reasons" means sentences with because/must/never/otherwise. Use judgment.
|
|
27
|
+
- Pass `--json` when you want to filter the facts yourself.
|
|
28
|
+
- Commit messages and author names appear in the output; don't paste a brief from a private repo into a public place.
|
package/src/analyze.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Pure: commit list in, facts out. No git here, so this is easy to test.
|
|
2
|
+
|
|
3
|
+
const REVERT_RE = /^Revert\s+"(.+)"/i;
|
|
4
|
+
const REVERTS_HASH_RE = /This reverts commit ([0-9a-f]{7,40})/i;
|
|
5
|
+
const ISSUE_RE = /(?:^|[\s(])(?:#|GH-)(\d+)\b/g;
|
|
6
|
+
const FIX_RE = /\b(fix|fixes|fixed|bug|regression|hotfix|revert|crash|broke|broken|workaround|incident|rollback|flaky)\b/i;
|
|
7
|
+
const CONSTRAINT_RE = /\b(must|never|always|do not|don't|because|otherwise|so that|required|invariant|careful|note:|important|gotcha|subtle)\b/i;
|
|
8
|
+
|
|
9
|
+
export function analyze(commits, { file, blame = null, lineCount = null, now = new Date() } = {}) {
|
|
10
|
+
const selfPaths = new Set([file, ...renameTrail(commits, file)]);
|
|
11
|
+
const byHash = new Map(commits.map((c) => [c.hash, c]));
|
|
12
|
+
const byShort = new Map(commits.map((c) => [c.short, c]));
|
|
13
|
+
|
|
14
|
+
// Reverts: either "This reverts commit X" or Revert "<subject>".
|
|
15
|
+
const reverts = [];
|
|
16
|
+
const revertedHashes = new Set();
|
|
17
|
+
for (const c of commits) {
|
|
18
|
+
const h = c.body.match(REVERTS_HASH_RE)?.[1];
|
|
19
|
+
let target = null;
|
|
20
|
+
if (h) target = byHash.get(h) ?? [...byHash.values()].find((x) => x.hash.startsWith(h)) ?? byShort.get(h) ?? null;
|
|
21
|
+
const s = c.subject.match(REVERT_RE)?.[1];
|
|
22
|
+
if (!target && s) target = commits.find((x) => x !== c && x.subject === s) ?? null;
|
|
23
|
+
if (h || s) {
|
|
24
|
+
reverts.push({ revert: c, target });
|
|
25
|
+
if (target) revertedHashes.add(target.hash);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fix-shaped commits: the places where something went wrong before.
|
|
30
|
+
const fixes = commits.filter((c) => !revertedHashes.has(c.hash) && !REVERT_RE.test(c.subject) && FIX_RE.test(c.subject));
|
|
31
|
+
|
|
32
|
+
// Issue/PR references.
|
|
33
|
+
const refs = new Map(); // number -> commits
|
|
34
|
+
for (const c of commits) {
|
|
35
|
+
for (const m of `${c.subject}\n${c.body}`.matchAll(ISSUE_RE)) {
|
|
36
|
+
const n = m[1];
|
|
37
|
+
if (!refs.has(n)) refs.set(n, []);
|
|
38
|
+
refs.get(n).push(c);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Co-changed files: what else moves when this file moves.
|
|
43
|
+
const co = new Map();
|
|
44
|
+
let withFiles = 0;
|
|
45
|
+
for (const c of commits) {
|
|
46
|
+
if (!c.files.length) continue;
|
|
47
|
+
withFiles += 1;
|
|
48
|
+
for (const f of c.files) {
|
|
49
|
+
if (selfPaths.has(f)) continue;
|
|
50
|
+
co.set(f, (co.get(f) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const coChanged = [...co.entries()]
|
|
54
|
+
.filter(([, n]) => n >= 2 && withFiles >= 3)
|
|
55
|
+
.map(([path, n]) => ({ path, count: n, share: n / withFiles }))
|
|
56
|
+
.filter((x) => x.share >= 0.3)
|
|
57
|
+
.sort((a, b) => b.count - a.count || a.path.localeCompare(b.path))
|
|
58
|
+
.slice(0, 5);
|
|
59
|
+
|
|
60
|
+
// Churn: bursts of activity (3+ commits within 7 days) read as "this was hard".
|
|
61
|
+
const sorted = [...commits].sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
62
|
+
const bursts = [];
|
|
63
|
+
let run = [];
|
|
64
|
+
for (const c of sorted) {
|
|
65
|
+
if (run.length && new Date(c.date) - new Date(run[run.length - 1].date) > 7 * 86400e3) {
|
|
66
|
+
if (run.length >= 3) bursts.push(run);
|
|
67
|
+
run = [];
|
|
68
|
+
}
|
|
69
|
+
run.push(c);
|
|
70
|
+
}
|
|
71
|
+
if (run.length >= 3) bursts.push(run);
|
|
72
|
+
// A burst only matters when it reads as struggle, not normal early development:
|
|
73
|
+
// it contains a fix or revert, or an older file suddenly got 4+ commits in 3 days.
|
|
74
|
+
const ageDaysAll = sorted.length ? (now - new Date(sorted[0].date)) / 86400e3 : 0;
|
|
75
|
+
const isFixy = (c) => FIX_RE.test(c.subject) || REVERT_RE.test(c.subject) || REVERTS_HASH_RE.test(c.body);
|
|
76
|
+
const notableBursts = bursts.filter((b) => {
|
|
77
|
+
if (sorted[0] && b[0].hash === sorted[0].hash) return false; // the initial build-out is not a warning
|
|
78
|
+
const span = (new Date(b[b.length - 1].date) - new Date(b[0].date)) / 86400e3;
|
|
79
|
+
return b.some(isFixy) || (ageDaysAll > 30 && b.length >= 4 && span <= 3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Authors.
|
|
83
|
+
const authorCount = new Map();
|
|
84
|
+
for (const c of commits) authorCount.set(c.author, (authorCount.get(c.author) ?? 0) + 1);
|
|
85
|
+
const authors = [...authorCount.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, count }));
|
|
86
|
+
|
|
87
|
+
// Lines worth surfacing from bodies: reasons, constraints, warnings.
|
|
88
|
+
const notes = [];
|
|
89
|
+
for (const c of commits) {
|
|
90
|
+
if (revertedHashes.has(c.hash)) continue;
|
|
91
|
+
for (const raw of unwrap(c.body)) {
|
|
92
|
+
const line = raw.replace(/^[-*>\s]+/, "").trim();
|
|
93
|
+
if (line.length < 20 || line.length > 240 || /[,:;]$/.test(line)) continue;
|
|
94
|
+
if (/^(signed-off-by|co-authored-by|reviewed-by|see-also|refs?:|closes|fixes #|resolves|claude-session|https?:\/\/)/i.test(line)) continue;
|
|
95
|
+
if (CONSTRAINT_RE.test(line)) notes.push({ commit: c, line });
|
|
96
|
+
if (notes.length >= 12) break;
|
|
97
|
+
}
|
|
98
|
+
if (notes.length >= 12) break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const first = sorted[0] ?? null;
|
|
102
|
+
const last = sorted[sorted.length - 1] ?? null;
|
|
103
|
+
const ageDays = first ? Math.round((now - new Date(first.date)) / 86400e3) : null;
|
|
104
|
+
const quietDays = last ? Math.round((now - new Date(last.date)) / 86400e3) : null;
|
|
105
|
+
|
|
106
|
+
// Blame: who owns the current lines, and how much is from one commit.
|
|
107
|
+
let ownership = null;
|
|
108
|
+
if (blame && blame.total > 0) {
|
|
109
|
+
const topAuthors = [...blame.byAuthor.entries()]
|
|
110
|
+
.sort((a, b) => b[1] - a[1])
|
|
111
|
+
.slice(0, 3)
|
|
112
|
+
.map(([name, lines]) => ({ name, lines, share: lines / blame.total }));
|
|
113
|
+
const topCommits = [...blame.byCommit.entries()]
|
|
114
|
+
.sort((a, b) => b[1] - a[1])
|
|
115
|
+
.slice(0, 3)
|
|
116
|
+
.map(([hash, lines]) => ({ commit: byHash.get(hash) ?? { hash, short: hash.slice(0, 7), subject: "(outside the sampled history)" }, lines, share: lines / blame.total }));
|
|
117
|
+
ownership = { total: blame.total, topAuthors, topCommits };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
file,
|
|
122
|
+
lineCount,
|
|
123
|
+
commitCount: commits.length,
|
|
124
|
+
allCommits: commits,
|
|
125
|
+
first,
|
|
126
|
+
last,
|
|
127
|
+
ageDays,
|
|
128
|
+
quietDays,
|
|
129
|
+
authors,
|
|
130
|
+
reverts,
|
|
131
|
+
fixes,
|
|
132
|
+
refs: [...refs.entries()].map(([number, cs]) => ({ number, commits: cs })).sort((a, b) => Number(b.number) - Number(a.number)),
|
|
133
|
+
coChanged,
|
|
134
|
+
bursts: notableBursts,
|
|
135
|
+
notes,
|
|
136
|
+
ownership,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Re-join hard-wrapped paragraphs; list items stay separate. */
|
|
141
|
+
export function unwrap(body) {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const para of body.split(/\n\s*\n/)) {
|
|
144
|
+
let cur = "";
|
|
145
|
+
for (const line of para.split("\n")) {
|
|
146
|
+
const t = line.trim();
|
|
147
|
+
if (!t) continue;
|
|
148
|
+
if (/^([-*•]|\d+[.)])\s/.test(t) || !cur) {
|
|
149
|
+
if (cur) out.push(cur);
|
|
150
|
+
cur = t;
|
|
151
|
+
} else cur += " " + t;
|
|
152
|
+
}
|
|
153
|
+
if (cur) out.push(cur);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Paths this file lived at before, newest first, from rename records. */
|
|
159
|
+
export function renameTrail(commits, file) {
|
|
160
|
+
const trail = [];
|
|
161
|
+
let current = file;
|
|
162
|
+
for (const c of commits) {
|
|
163
|
+
const r = c.renames?.find((x) => x.to === current);
|
|
164
|
+
if (r) {
|
|
165
|
+
trail.push(r.from);
|
|
166
|
+
current = r.from;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return trail;
|
|
170
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { resolve, relative, sep, isAbsolute } from "node:path";
|
|
2
|
+
import { existsSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { analyze, renameTrail } from "./analyze.js";
|
|
5
|
+
import { render } from "./render.js";
|
|
6
|
+
import { GitError, blame, fileLog, isTracked, lineCount, rangeLog, remoteWebUrl, repoRoot } from "./git.js";
|
|
7
|
+
|
|
8
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
9
|
+
|
|
10
|
+
export const HELP = `git-backstory ${version} — the backstory of a file, in one page, for whoever has to change it next.
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
git-backstory <path> brief for a file
|
|
14
|
+
git-backstory <path>:<line> brief for one line
|
|
15
|
+
git-backstory <path>:<from>-<to> brief for a line range
|
|
16
|
+
git-backstory <path> --json the facts as JSON instead of markdown
|
|
17
|
+
|
|
18
|
+
Options
|
|
19
|
+
--max <n> commits to sample (default 200)
|
|
20
|
+
--since <date> only commits after this date, e.g. 2025-01-01 or "6 months ago"
|
|
21
|
+
--out <file> write the brief to a file instead of stdout
|
|
22
|
+
--no-links plain hashes, no web links
|
|
23
|
+
--version, --help
|
|
24
|
+
|
|
25
|
+
Reads git only (log, blame, show). Never writes to the repository, never
|
|
26
|
+
touches the network. Zero dependencies.`;
|
|
27
|
+
|
|
28
|
+
export function parseArgs(argv) {
|
|
29
|
+
const o = { target: null, json: false, max: 200, since: null, out: null, links: true, help: false, version: false };
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
33
|
+
else if (a === "--version" || a === "-v") o.version = true;
|
|
34
|
+
else if (a === "--json") o.json = true;
|
|
35
|
+
else if (a === "--no-links") o.links = false;
|
|
36
|
+
else if (a === "--max") o.max = Number(argv[++i]);
|
|
37
|
+
else if (a.startsWith("--max=")) o.max = Number(a.slice(6));
|
|
38
|
+
else if (a === "--since") o.since = argv[++i];
|
|
39
|
+
else if (a.startsWith("--since=")) o.since = a.slice(8);
|
|
40
|
+
else if (a === "--out") o.out = argv[++i];
|
|
41
|
+
else if (a.startsWith("--out=")) o.out = a.slice(6);
|
|
42
|
+
else if (a.startsWith("-")) throw new UsageError(`Unknown option: ${a}`);
|
|
43
|
+
else if (o.target) throw new UsageError("Only one path at a time.");
|
|
44
|
+
else o.target = a;
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isInteger(o.max) || o.max < 1) throw new UsageError("--max must be a positive integer.");
|
|
47
|
+
return o;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class UsageError extends Error {}
|
|
51
|
+
|
|
52
|
+
/** Real path of the deepest existing ancestor, with the missing tail re-attached. */
|
|
53
|
+
function canonical(p) {
|
|
54
|
+
const missing = [];
|
|
55
|
+
let cur = p;
|
|
56
|
+
while (!existsSync(cur)) {
|
|
57
|
+
const parent = resolve(cur, "..");
|
|
58
|
+
if (parent === cur) return p;
|
|
59
|
+
missing.unshift(cur.slice(parent.length).replace(/^[\\/]/, ""));
|
|
60
|
+
cur = parent;
|
|
61
|
+
}
|
|
62
|
+
return resolve(realpathSync.native(cur), ...missing);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** "src/a.ts:10-20" -> { file, range } ; Windows drive letters are left alone. */
|
|
66
|
+
export function splitTarget(target) {
|
|
67
|
+
const m = target.match(/^(.*?):(\d+)(?:-(\d+))?$/);
|
|
68
|
+
if (!m || /^[A-Za-z]:$/.test(m[1])) return { file: target, range: null };
|
|
69
|
+
const start = Number(m[2]);
|
|
70
|
+
const end = m[3] ? Number(m[3]) : start;
|
|
71
|
+
if (start < 1 || end < start) throw new UsageError(`Bad line range: ${target}`);
|
|
72
|
+
return { file: m[1], range: { start, end } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function brief(targetArg, opts, cwd = process.cwd()) {
|
|
76
|
+
const { file: rawFile, range } = splitTarget(targetArg);
|
|
77
|
+
// Compare canonical paths: macOS puts temp dirs behind a /var -> /private/var
|
|
78
|
+
// symlink and Windows hands out 8.3 short names, while git reports real paths.
|
|
79
|
+
const abs = canonical(resolve(cwd, rawFile));
|
|
80
|
+
const root = canonical(repoRoot(existsSync(abs) && statSync(abs).isDirectory() ? abs : resolve(abs, "..")));
|
|
81
|
+
let file = relative(root, abs).split(sep).join("/");
|
|
82
|
+
if (!file || file.startsWith("..") || isAbsolute(file)) throw new UsageError(`${rawFile} is outside the repository at ${root}.`);
|
|
83
|
+
|
|
84
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) throw new UsageError(`${rawFile} is a directory. git-backstory works on one file.`);
|
|
85
|
+
if (!isTracked(root, file) && !existsSync(abs)) {
|
|
86
|
+
// Allow briefs on deleted files: the log still exists.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const commits = range ? rangeLog(root, file, range.start, range.end, { max: opts.max }) : fileLog(root, file, { max: opts.max, since: opts.since });
|
|
90
|
+
const b = isTracked(root, file) ? blame(root, file, range ? { start: range.start, end: range.end } : {}) : null;
|
|
91
|
+
const lines = isTracked(root, file) ? lineCount(root, file) : null;
|
|
92
|
+
const facts = analyze(commits, { file, blame: b, lineCount: lines });
|
|
93
|
+
facts.sampleLimit = opts.max;
|
|
94
|
+
const previousPaths = range ? [] : renameTrail(commits, file);
|
|
95
|
+
const webUrl = opts.links ? remoteWebUrl(root) : null;
|
|
96
|
+
return { facts, markdown: render(facts, { webUrl, range, previousPaths }), root, file, range };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function run(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
100
|
+
let opts;
|
|
101
|
+
try {
|
|
102
|
+
opts = parseArgs(argv);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e instanceof UsageError) {
|
|
105
|
+
io.stderr.write(`${e.message}\n\n${HELP}\n`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
if (opts.version) return io.stdout.write(`${version}\n`), 0;
|
|
111
|
+
if (opts.help || !opts.target) return io.stdout.write(`${HELP}\n`), opts.help ? 0 : 2;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const { facts, markdown } = brief(opts.target, opts);
|
|
115
|
+
const out = opts.json ? JSON.stringify(toJson(facts), null, 2) + "\n" : markdown;
|
|
116
|
+
if (opts.out) {
|
|
117
|
+
writeFileSync(opts.out, out);
|
|
118
|
+
io.stderr.write(`Wrote ${opts.out}\n`);
|
|
119
|
+
} else io.stdout.write(out);
|
|
120
|
+
return 0;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e instanceof UsageError || e instanceof GitError) {
|
|
123
|
+
io.stderr.write(`git-backstory: ${e.message}\n`);
|
|
124
|
+
return e.code ?? 2;
|
|
125
|
+
}
|
|
126
|
+
throw e;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toJson(f) {
|
|
131
|
+
const c = (x) => x && { hash: x.hash, short: x.short, author: x.author, date: x.date, subject: x.subject };
|
|
132
|
+
return {
|
|
133
|
+
file: f.file,
|
|
134
|
+
lineCount: f.lineCount,
|
|
135
|
+
commitCount: f.commitCount,
|
|
136
|
+
first: c(f.first),
|
|
137
|
+
last: c(f.last),
|
|
138
|
+
ageDays: f.ageDays,
|
|
139
|
+
quietDays: f.quietDays,
|
|
140
|
+
authors: f.authors,
|
|
141
|
+
reverts: f.reverts.map((r) => ({ revert: c(r.revert), target: c(r.target) })),
|
|
142
|
+
fixes: f.fixes.map(c),
|
|
143
|
+
refs: f.refs.map((r) => ({ number: r.number, commits: r.commits.map((x) => x.short) })),
|
|
144
|
+
coChanged: f.coChanged,
|
|
145
|
+
bursts: f.bursts.map((b) => b.map(c)),
|
|
146
|
+
notes: f.notes.map((n) => ({ line: n.line, commit: n.commit.short })),
|
|
147
|
+
ownership: f.ownership && {
|
|
148
|
+
total: f.ownership.total,
|
|
149
|
+
topAuthors: f.ownership.topAuthors,
|
|
150
|
+
topCommits: f.ownership.topCommits.map((x) => ({ commit: c(x.commit), lines: x.lines, share: x.share })),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Thin, read-only wrapper around the git CLI. Every command here is a read:
|
|
2
|
+
// log, blame, show, rev-parse, ls-files, remote. Nothing writes to the repo.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export class GitError extends Error {
|
|
7
|
+
constructor(message, code = 1) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "GitError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function git(cwd, args, { allowFail = false } = {}) {
|
|
15
|
+
const r = spawnSync(
|
|
16
|
+
"git",
|
|
17
|
+
["-c", "core.quotepath=off", "-c", "log.showSignature=false", "-c", "color.ui=never", ...args],
|
|
18
|
+
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
|
|
19
|
+
);
|
|
20
|
+
if (r.error) {
|
|
21
|
+
if (r.error.code === "ENOENT") throw new GitError("git is not installed or not on PATH.", 2);
|
|
22
|
+
throw new GitError(r.error.message, 2);
|
|
23
|
+
}
|
|
24
|
+
if (r.status !== 0 && !allowFail) throw new GitError((r.stderr || "git failed").trim(), 1);
|
|
25
|
+
return r.stdout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function repoRoot(cwd) {
|
|
29
|
+
const out = git(cwd, ["rev-parse", "--show-toplevel"], { allowFail: true }).trim();
|
|
30
|
+
if (!out) throw new GitError(`Not inside a git repository: ${cwd}`, 1);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Web base URL for the origin remote on GitHub/GitLab/Bitbucket, or null. */
|
|
35
|
+
export function remoteWebUrl(cwd) {
|
|
36
|
+
const url = git(cwd, ["remote", "get-url", "origin"], { allowFail: true }).trim();
|
|
37
|
+
if (!url) return null;
|
|
38
|
+
const m = url.match(/(?:[@/])(github\.com|gitlab\.com|bitbucket\.org)[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
return `https://${m[1].toLowerCase()}/${m[2]}/${m[3]}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Unit/record separators keep multi-line bodies unambiguous.
|
|
44
|
+
const US = "\u001f";
|
|
45
|
+
const RS = "\u001e";
|
|
46
|
+
const FORMAT = ["%H", "%h", "%an", "%aI", "%P", "%s", "%b"].join(US) + RS;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Commits touching `file` (following renames), newest first.
|
|
50
|
+
* Each: { hash, short, author, date, parents[], subject, body, files[] }
|
|
51
|
+
* `files` lists every path changed in that commit, for co-change analysis.
|
|
52
|
+
*/
|
|
53
|
+
export function fileLog(cwd, file, { max = 200, since = null } = {}) {
|
|
54
|
+
const args = ["log", `--max-count=${max}`, "--follow"];
|
|
55
|
+
if (since) args.push(`--since=${since}`);
|
|
56
|
+
const commits = parseLog(git(cwd, ["log", ...args.slice(1), `--format=${FORMAT}`, "--", file], { allowFail: true }));
|
|
57
|
+
if (!commits.length) return commits;
|
|
58
|
+
// A pathspec limits --name-status to the followed file, so list every
|
|
59
|
+
// changed path in a second pass over exactly these commits.
|
|
60
|
+
const status = git(cwd, ["log", "--no-walk=unsorted", "--name-status", "--format=%x01%H", ...commits.map((c) => c.hash)], { allowFail: true });
|
|
61
|
+
const byHash = new Map();
|
|
62
|
+
for (const chunk of status.split("\u0001")) {
|
|
63
|
+
const lines = chunk.split("\n").map((s) => s.trimEnd()).filter(Boolean);
|
|
64
|
+
if (!lines.length) continue;
|
|
65
|
+
const files = [];
|
|
66
|
+
const renames = [];
|
|
67
|
+
for (const l of lines.slice(1)) {
|
|
68
|
+
const parts = l.split("\t");
|
|
69
|
+
const code = parts[0];
|
|
70
|
+
if (/^R/.test(code) && parts.length >= 3) {
|
|
71
|
+
renames.push({ from: parts[1], to: parts[2] });
|
|
72
|
+
files.push(parts[2]);
|
|
73
|
+
} else if (parts[1]) files.push(parts[parts.length - 1]);
|
|
74
|
+
}
|
|
75
|
+
byHash.set(lines[0], { files, renames });
|
|
76
|
+
}
|
|
77
|
+
for (const c of commits) {
|
|
78
|
+
const e = byHash.get(c.hash) ?? { files: [], renames: [] };
|
|
79
|
+
c.files = e.files;
|
|
80
|
+
c.renames = e.renames;
|
|
81
|
+
}
|
|
82
|
+
return commits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Commits that touched a line range, via `git log -L`. git forbids --follow with -L. */
|
|
86
|
+
export function rangeLog(cwd, file, start, end, { max = 50 } = {}) {
|
|
87
|
+
const out = git(cwd, ["log", `--max-count=${max}`, "--no-patch", `-L${start},${end}:${file}`, `--format=${FORMAT}`], {
|
|
88
|
+
allowFail: true,
|
|
89
|
+
});
|
|
90
|
+
return parseLog(out).map((c) => ({ ...c, files: [], renames: [] }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseLog(out) {
|
|
94
|
+
const commits = [];
|
|
95
|
+
for (const chunk of out.split(RS)) {
|
|
96
|
+
const text = chunk.replace(/^\s+/, "");
|
|
97
|
+
if (!text) continue;
|
|
98
|
+
const [hash, short, author, date, parents, subject, body = ""] = text.split(US);
|
|
99
|
+
if (!/^[0-9a-f]{40}$/.test(hash)) continue;
|
|
100
|
+
commits.push({
|
|
101
|
+
hash,
|
|
102
|
+
short,
|
|
103
|
+
author,
|
|
104
|
+
date,
|
|
105
|
+
parents: parents ? parents.split(" ").filter(Boolean) : [],
|
|
106
|
+
subject: subject ?? "",
|
|
107
|
+
body: body.trim(),
|
|
108
|
+
files: [],
|
|
109
|
+
renames: [],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return commits;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Blame summary: how many current lines each commit and each author owns. */
|
|
116
|
+
export function blame(cwd, file, { start = null, end = null } = {}) {
|
|
117
|
+
const args = ["blame", "--line-porcelain"];
|
|
118
|
+
if (start != null) args.push("-L", `${start},${end ?? start}`);
|
|
119
|
+
args.push("--", file);
|
|
120
|
+
const out = git(cwd, args, { allowFail: true });
|
|
121
|
+
const byCommit = new Map();
|
|
122
|
+
const byAuthor = new Map();
|
|
123
|
+
let total = 0;
|
|
124
|
+
let current = null;
|
|
125
|
+
for (const line of out.split("\n")) {
|
|
126
|
+
const head = line.match(/^([0-9a-f]{40}) \d+ \d+(?: \d+)?$/);
|
|
127
|
+
if (head) {
|
|
128
|
+
current = head[1];
|
|
129
|
+
total += 1;
|
|
130
|
+
byCommit.set(current, (byCommit.get(current) ?? 0) + 1);
|
|
131
|
+
} else if (current && line.startsWith("author ")) {
|
|
132
|
+
const a = line.slice(7);
|
|
133
|
+
byAuthor.set(a, (byAuthor.get(a) ?? 0) + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { total, byCommit, byAuthor };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isTracked(cwd, file) {
|
|
140
|
+
return git(cwd, ["ls-files", "--error-unmatch", "--", file], { allowFail: true }).trim().length > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function lineCount(cwd, file) {
|
|
144
|
+
const out = git(cwd, ["show", `HEAD:${file}`], { allowFail: true });
|
|
145
|
+
if (!out) return null;
|
|
146
|
+
return out.split("\n").length - (out.endsWith("\n") ? 1 : 0);
|
|
147
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Pure: facts in, markdown out. Short on purpose — an agent reads all of it.
|
|
2
|
+
|
|
3
|
+
export function render(a, { webUrl = null, range = null, previousPaths = [] } = {}) {
|
|
4
|
+
const L = [];
|
|
5
|
+
const link = (c) => (webUrl ? `[\`${c.short}\`](${webUrl}/commit/${c.hash})` : `\`${c.short}\``);
|
|
6
|
+
const issue = (n) => (webUrl ? `[#${n}](${webUrl}/issues/${n})` : `#${n}`);
|
|
7
|
+
const when = (c) => c.date.slice(0, 10);
|
|
8
|
+
const subj = (c) => c.subject.replace(/\s+/g, " ").trim();
|
|
9
|
+
const title = range ? `${a.file}:${range.start}${range.end !== range.start ? `-${range.end}` : ""}` : a.file;
|
|
10
|
+
|
|
11
|
+
L.push(`# History brief: \`${title}\``, "");
|
|
12
|
+
|
|
13
|
+
if (a.commitCount === 0) {
|
|
14
|
+
L.push(range ? "No commits touched this range." : "No history for this path. Is it tracked, and is the path relative to the repository root?");
|
|
15
|
+
return L.join("\n") + "\n";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Summary line.
|
|
19
|
+
const parts = [];
|
|
20
|
+
parts.push(`${a.commitCount} commit${a.commitCount === 1 ? "" : "s"}`);
|
|
21
|
+
const ago = (d) => (d < 1 ? "today" : `${humanDays(d)} ago`);
|
|
22
|
+
if (a.ageDays != null) parts.push(`first ${when(a.first)} (${ago(a.ageDays)})`);
|
|
23
|
+
if (a.quietDays != null) parts.push(`last ${when(a.last)} (${ago(a.quietDays)})`);
|
|
24
|
+
parts.push(`${a.authors.length} author${a.authors.length === 1 ? "" : "s"}`);
|
|
25
|
+
if (a.lineCount != null && !range) parts.push(`${a.lineCount} lines now`);
|
|
26
|
+
L.push(parts.join(" · "), "");
|
|
27
|
+
if (previousPaths.length) L.push(`Previously at: ${previousPaths.map((p) => `\`${p}\``).join(", ")}`, "");
|
|
28
|
+
|
|
29
|
+
// What to be careful about: the section that earns the file.
|
|
30
|
+
const careful = [];
|
|
31
|
+
for (const { revert, target } of a.reverts) {
|
|
32
|
+
careful.push(
|
|
33
|
+
target
|
|
34
|
+
? `${link(revert)} (${when(revert)}) reverted ${link(target)} "${subj(target)}". Whatever that change did was not safe here; read both before trying it again.`
|
|
35
|
+
: `${link(revert)} (${when(revert)}) is a revert: "${subj(revert)}".`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
for (const b of a.bursts.slice(0, 2)) {
|
|
39
|
+
const from = when(b[0]);
|
|
40
|
+
const to = when(b[b.length - 1]);
|
|
41
|
+
const shown = b.slice(0, 5).map(link).join(", ") + (b.length > 5 ? `, +${b.length - 5} more` : "");
|
|
42
|
+
careful.push(`${b.length} commits between ${from} and ${to} (${shown}): several attempts in a short window, so the first one didn't hold.`);
|
|
43
|
+
}
|
|
44
|
+
if (a.fixes.length) {
|
|
45
|
+
const shown = a.fixes.slice(0, 5);
|
|
46
|
+
careful.push(`Fix-shaped commits (${a.fixes.length}): ${shown.map((c) => `${link(c)} "${subj(c)}"`).join("; ")}${a.fixes.length > shown.length ? "; …" : ""}.`);
|
|
47
|
+
}
|
|
48
|
+
if (careful.length) {
|
|
49
|
+
L.push("## Be careful", "");
|
|
50
|
+
for (const c of careful) L.push(`- ${c}`);
|
|
51
|
+
L.push("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Reasons the authors wrote down.
|
|
55
|
+
if (a.notes.length) {
|
|
56
|
+
L.push("## Reasons on record", "");
|
|
57
|
+
for (const { commit, line } of a.notes.slice(0, 8)) L.push(`- ${line} — ${link(commit)}, ${when(commit)}`);
|
|
58
|
+
L.push("");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Co-change coupling.
|
|
62
|
+
if (a.coChanged.length) {
|
|
63
|
+
L.push("## Usually changes together with", "");
|
|
64
|
+
for (const x of a.coChanged) L.push(`- \`${x.path}\` — ${x.count} of the sampled commits (${Math.round(x.share * 100)}%)`);
|
|
65
|
+
L.push("", "If you change this file, check whether those need the same change.", "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Issues / PRs.
|
|
69
|
+
if (a.refs.length) {
|
|
70
|
+
const shown = a.refs.slice(0, 10);
|
|
71
|
+
L.push("## Referenced issues and PRs", "", shown.map((r) => `${issue(r.number)} (${r.commits.length})`).join(", "), "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ownership.
|
|
75
|
+
if (a.ownership) {
|
|
76
|
+
const o = a.ownership;
|
|
77
|
+
const people = o.topAuthors.map((x) => `${x.name} ${Math.round(x.share * 100)}%`).join(", ");
|
|
78
|
+
L.push("## Who to ask", "", `Current lines by author: ${people}.`);
|
|
79
|
+
const big = o.topCommits[0];
|
|
80
|
+
if (big && big.share >= 0.5) L.push(`${Math.round(big.share * 100)}% of the current lines come from one commit: ${link(big.commit)} "${subj(big.commit)}".`);
|
|
81
|
+
L.push("");
|
|
82
|
+
} else if (a.authors.length) {
|
|
83
|
+
L.push("## Who to ask", "", a.authors.slice(0, 3).map((x) => `${x.name} (${x.count})`).join(", "), "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Timeline, newest first, compact.
|
|
87
|
+
L.push("## Timeline", "");
|
|
88
|
+
const tl = [...a.reverts.map((r) => r.revert), ...a.fixes, a.first, a.last]
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.filter((c, i, arr) => arr.findIndex((x) => x.hash === c.hash) === i)
|
|
91
|
+
.sort((x, y) => new Date(y.date) - new Date(x.date))
|
|
92
|
+
.slice(0, 12);
|
|
93
|
+
// Fill with recent commits if the notable ones are few.
|
|
94
|
+
if (tl.length < 8) {
|
|
95
|
+
const recent = [...a.allCommits ?? []].slice(0, 8 - tl.length);
|
|
96
|
+
for (const c of recent) if (!tl.some((x) => x.hash === c.hash)) tl.push(c);
|
|
97
|
+
tl.sort((x, y) => new Date(y.date) - new Date(x.date));
|
|
98
|
+
}
|
|
99
|
+
for (const c of tl) L.push(`- ${when(c)} ${link(c)} ${subj(c)} — ${c.author}`);
|
|
100
|
+
L.push("");
|
|
101
|
+
|
|
102
|
+
const sampled = a.sampleLimit && a.commitCount >= a.sampleLimit ? ` Sampled the latest ${a.sampleLimit} commits; pass --max for more.` : "";
|
|
103
|
+
L.push(`_Generated by git-backstory from \`git log\` and \`git blame\`.${sampled}_`);
|
|
104
|
+
return L.join("\n") + "\n";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function humanDays(d) {
|
|
108
|
+
if (d < 1) return "today";
|
|
109
|
+
if (d < 14) return `${d} day${d === 1 ? "" : "s"}`;
|
|
110
|
+
if (d < 60) return `${Math.round(d / 7)} weeks`;
|
|
111
|
+
if (d < 730) return `${Math.round(d / 30)} months`;
|
|
112
|
+
return `${(d / 365).toFixed(1)} years`;
|
|
113
|
+
}
|