1+ /**
2+ * Each page's version count, dates and history link, from the index
3+ * `site git-history` writes (`tools/site/src/git_history.rs`, run by
4+ * `site build` before Quartz). Which commits count, the bots and the sweeps
5+ * that do not, and the threading through renames all happen there.
6+ */
7+
8+ import fs from "fs"
19import path from "path"
210import type { Root } from "mdast"
311import type { VFile } from "vfile"
412import type { QuartzTransformerPlugin } from "../../quartz/plugins/types"
5- import { defaultBranch , git , originRemote , repoRoot , webUrl } from "../shared/git"
13+ import { repoRoot } from "../shared/git"
614import { warn } from "../shared/warn"
7- import { excludedKeepPaths , excludedShas , isExcluded } from "./excluded-commits"
815
916declare module "vfile" {
1017 interface DataMap {
@@ -13,186 +20,75 @@ declare module "vfile" {
1320 }
1421}
1522
16- type Commit = {
17- sha : string
18- date : Date
19- human : boolean
20- /** This commit created the file, under this name or one it was renamed from. */
21- added : boolean
22- }
23+ /** Where `site git-history` writes, relative to the repository root. */
24+ const GIT_HISTORY_INDEX = ".quartz/git-history.json"
2325
24- type Repo = {
25- base : string
26- branch : string
26+ interface PageHistory {
27+ versions : number
28+ created : string
29+ modified : string
30+ historyUrl ?: string
2731}
2832
29- function readRepo ( cwd : string ) : Repo | undefined {
30- const base = webUrl ( originRemote ( cwd ) ?? "" )
31- return base === undefined ? undefined : { base , branch : defaultBranch ( cwd ) }
33+ /** `pages` is null when the index was written without any git history to read. */
34+ interface Index {
35+ pages : Record < string , PageHistory > | null
3236}
3337
34- const encodePath = ( file : string ) => file . split ( "/" ) . map ( encodeURIComponent ) . join ( "/" )
35-
36- // CI pushes generated/maintenance commits as `github-actions[bot]` and Dependabot
37- // pushes as `dependabot[bot]`. Neither is an edit I made, so neither counts as a
38- // version or moves the "updated" date. My own GitHub noreply address lives on the
39- // same domain without the `[bot]` marker, so it still counts.
40- const isBotAuthor = ( email : string ) =>
41- email . endsWith ( "@users.noreply.github.com" ) && email . includes ( "[bot]" )
42-
43- // One `git log` for the entire repository, rather than one per file. `--name-status`
44- // with `-M` gives rename records, which is what threads a file back through its
45- // old names without a per-file `--follow`.
46- function readHistory ( cwd : string ) : Map < string , Commit [ ] > | undefined {
47- const output = git (
48- cwd ,
49- [ "log" , "--format=%x00%H%x1f%aI%x1f%ae" , "--name-status" , "-M" , "--no-show-signature" ] ,
50- 512 * 1024 * 1024 ,
51- )
52- if ( output === undefined ) return undefined
53-
54- const history = new Map < string , Commit [ ] > ( )
55- const renamedTo = new Map < string , string > ( )
56- const seen = new Set < string > ( )
57-
58- // git log is newest-first, so every rename is recorded before we reach the older
59- // commits that still refer to the previous name.
60- const currentName = ( file : string ) : string => {
61- const seen = new Set < string > ( )
62- while ( renamedTo . has ( file ) && ! seen . has ( file ) ) {
63- seen . add ( file )
64- file = renamedTo . get ( file ) !
65- }
66- return file
67- }
68-
69- for ( const block of output . split ( "\0" ) ) {
70- if ( block . trim ( ) === "" ) continue
71-
72- const [ header , ...entries ] = block . split ( "\n" )
73- const [ sha , authored , email ] = header . split ( "\x1f" )
74- if ( sha === undefined || authored === undefined || email === undefined ) continue
75-
76- const date = new Date ( authored )
77- if ( Number . isNaN ( date . getTime ( ) ) ) continue
78- const shared = { sha, date, human : ! isBotAuthor ( email ) }
79- seen . add ( sha )
80-
81- for ( const entry of entries ) {
82- if ( entry === "" ) continue
83-
84- // "M\tpath", "A\tpath", or "R100\told\tnew"
85- const fields = entry . split ( "\t" )
86- if ( fields . length < 2 ) continue
87-
88- let file : string
89- if ( fields [ 0 ] . startsWith ( "R" ) && fields . length >= 3 ) {
90- file = currentName ( fields [ 2 ] )
91- renamedTo . set ( fields [ 1 ] , file )
92- } else {
93- file = currentName ( fields [ 1 ] )
94- }
95-
96- // Per file rather than per commit: one commit adds some paths and merely
97- // touches others, and only the add means authorship of this page.
98- const record : Commit = { ...shared , added : fields [ 0 ] . startsWith ( "A" ) }
99-
100- const commits = history . get ( file )
101- if ( commits ) {
102- commits . push ( record )
103- } else {
104- history . set ( file , [ record ] )
105- }
106- }
107- }
108-
109- // A listed SHA that history no longer contains excludes nothing, silently.
110- // That happens after a rebase, or from a typo, and either way the list has
111- // rotted rather than done its job.
112- const strayPaths = excludedKeepPaths ( ) . filter ( ( file ) => ! history . has ( file ) )
113- if ( strayPaths . length > 0 ) {
114- warn (
115- `excluded-commits.ts keeps ${ strayPaths . length } path(s) no file has: ` +
116- strayPaths . join ( ", " ) ,
117- )
118- }
119-
120- const missing = excludedShas ( ) . filter ( ( sha ) => ! seen . has ( sha ) )
121- if ( missing . length > 0 ) {
122- warn (
123- `excluded-commits.ts lists ${ missing . length } commit(s) not in this history: ` +
124- missing . map ( ( sha ) => sha . slice ( 0 , 8 ) ) . join ( ", " ) ,
38+ function readIndex ( root : string ) : Index {
39+ const indexPath = path . join ( root , GIT_HISTORY_INDEX )
40+ if ( ! fs . existsSync ( indexPath ) ) {
41+ throw new Error (
42+ `${ indexPath } is missing; run \`site git-history\` (part of \`site build\`) first` ,
12543 )
12644 }
127-
128- return history
45+ return JSON . parse ( fs . readFileSync ( indexPath , "utf8" ) ) as Index
12946}
13047
131- // Quartz may parse in worker threads; keep one map per process.
132- const cached = new Map < string , Map < string , Commit [ ] > | undefined > ( )
133- const cachedRepos = new Map < string , Repo | undefined > ( )
48+ // Quartz may parse in worker threads; keep one index per process.
49+ const cached = new Map < string , Index > ( )
13450
13551export const GitHistory : QuartzTransformerPlugin = ( ) => ( {
13652 name : "GitHistory" ,
13753 markdownPlugins ( ctx ) {
138- const root = repoRoot ( ctx . argv . directory )
139- let history : Map < string , Commit [ ] > | undefined
140- let repo : Repo | undefined
54+ const contentDir = path . resolve ( ctx . argv . directory )
55+ const root = repoRoot ( contentDir ) ?? path . dirname ( contentDir )
14156
142- if ( root !== undefined ) {
143- if ( ! cached . has ( root ) ) {
144- cached . set ( root , readHistory ( root ) )
145- cachedRepos . set ( root , readRepo ( root ) )
146- }
147- history = cached . get ( root )
148- repo = cachedRepos . get ( root )
57+ let index = cached . get ( root )
58+ if ( index === undefined ) {
59+ index = readIndex ( root )
60+ cached . set ( root , index )
14961 }
150- if ( history === undefined ) {
62+ const pages = index . pages
63+ if ( pages === null ) {
15164 warn ( "no git history available, falling back to frontmatter dates" )
15265 }
15366
15467 return [
15568 ( ) => ( _tree : Root , file : VFile ) => {
156- if ( root === undefined || history === undefined ) return
69+ if ( pages === null ) return
15770
15871 const filePath = file . data . filePath
15972 if ( filePath === undefined ) return
16073
16174 const relative = path . relative ( root , path . resolve ( filePath ) ) . split ( path . sep ) . join ( "/" )
162-
163- const commits = history . get ( relative )
164- if ( commits === undefined || commits . length === 0 ) {
165- // A draft I haven't committed yet. Saying so beats silently dropping the
166- // count, which reads as a broken build while writing locally, and the
167- // dates from the frontmatter/filesystem fallback still stand.
75+ const page = pages [ relative ]
76+ if ( page === undefined ) {
77+ // A draft not committed yet. Saying so beats silently dropping the
78+ // count, which reads as a broken build while writing locally, and
79+ // the dates from the frontmatter/filesystem fallback still stand.
16880 file . data . versions = 0
16981 return
17082 }
17183
172- const mine = commits . filter ( ( commit ) => commit . human )
173- // Sweeps stay in `commits` and go on feeding the created date below;
174- // they just do not count as revisions of this page, or move its
175- // updated date to whenever the find-and-replace happened to run.
176- // A commit that created the page is authorship by definition, never a
177- // sweep, however many other files it swept on the way past. Without
178- // this a page written during a migration reports zero versions.
179- const edits = mine . filter ( ( commit ) => commit . added || ! isExcluded ( commit . sha , relative ) )
180- file . data . versions = edits . length
181-
182- if ( repo !== undefined ) {
183- file . data . historyUrl = `${ repo . base } /commits/${ repo . branch } /${ encodePath ( relative ) } `
184- }
84+ file . data . versions = page . versions
85+ if ( page . historyUrl !== undefined ) file . data . historyUrl = page . historyUrl
18586
186- // Pages that only automation has ever touched (generated indices) still
187- // deserve real dates, so fall back to the unfiltered history for those.
188- const dated = mine . length > 0 ? mine : commits
189- // A page every one of whose edits was a sweep has no meaningful
190- // "updated" left, so it falls back rather than reporting nothing.
191- const touched = edits . length > 0 ? edits : dated
87+ const modified = new Date ( page . modified )
19288 file . data . dates = {
193- created : dated [ dated . length - 1 ] . date ,
194- modified : touched [ 0 ] . date ,
195- published : file . data . dates ?. published ?? touched [ 0 ] . date ,
89+ created : new Date ( page . created ) ,
90+ modified,
91+ published : file . data . dates ?. published ?? modified ,
19692 }
19793 } ,
19894 ]
0 commit comments