Skip to content

Commit ac8f644

Browse files
committed
Read page history from git in Rust
`site git-history` (tools/site/src/git_history.rs, run by `site build` before Quartz) does what the git-history plugin did in Node: one git log for the repository, every file threaded back through its renames, bot commits and the hand-made sweeps left out of the version count and the updated date, and a warning for an excluded SHA or kept path history no longer has. It writes each page's versions, created and modified dates and history link to .quartz/git-history.json, and the plugin only reads that. Over all 63 content pages the index matches what the plugin computed. The sweep list moves with it, from excluded-commits.ts to EXCLUDED, and its logic gets the tests it never had. webUrl in quartz-local/shared/git.ts had no other caller and goes too.
1 parent 0a58466 commit ac8f644

6 files changed

Lines changed: 580 additions & 317 deletions

File tree

‎quartz-local/git-history/excluded-commits.ts‎

Lines changed: 0 additions & 150 deletions
This file was deleted.

‎quartz-local/git-history/index.ts‎

Lines changed: 47 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
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"
19
import path from "path"
210
import type { Root } from "mdast"
311
import type { VFile } from "vfile"
412
import type { QuartzTransformerPlugin } from "../../quartz/plugins/types"
5-
import { defaultBranch, git, originRemote, repoRoot, webUrl } from "../shared/git"
13+
import { repoRoot } from "../shared/git"
614
import { warn } from "../shared/warn"
7-
import { excludedKeepPaths, excludedShas, isExcluded } from "./excluded-commits"
815

916
declare 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

13551
export 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

Comments
 (0)