Skip to content

Commit 612c458

Browse files
authored
fix: git log parsing when there are empty commits in history (#4965)
1 parent bce0b53 commit 612c458

File tree

1 file changed

+104
-56
lines changed

1 file changed

+104
-56
lines changed

src/node/utils/getGitTimestamp.ts

Lines changed: 104 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,106 @@ import { spawn, sync } from 'cross-spawn'
22
import _debug from 'debug'
33
import fs from 'node:fs'
44
import path from 'node:path'
5+
import { Transform, type TransformCallback } from 'node:stream'
56
import { slash } from '../shared'
67

78
const debug = _debug('vitepress:git')
89
const cache = new Map<string, number>()
910

1011
const RS = 0x1e
1112
const NUL = 0x00
13+
const LF = 0x0a
14+
15+
interface GitLogRecord {
16+
ts: number
17+
files: string[]
18+
}
19+
20+
type State = 'READ_TS' | 'READ_FILE'
21+
22+
class GitLogParser extends Transform {
23+
#state: State = 'READ_TS'
24+
#tsBytes: number[] = []
25+
#fileBytes: number[] = []
26+
#files: string[] = []
27+
28+
constructor() {
29+
super({ readableObjectMode: true })
30+
}
31+
32+
override _transform(
33+
chunk: Buffer,
34+
_enc: BufferEncoding,
35+
cb: TransformCallback
36+
): void {
37+
try {
38+
for (let i = 0; i < chunk.length; i++) {
39+
const b = chunk[i] === LF ? NUL : chunk[i] // treat LF as NUL
40+
41+
switch (this.#state) {
42+
case 'READ_TS': {
43+
if (b === RS) {
44+
// ignore
45+
} else if (b === NUL) {
46+
this.#state = 'READ_FILE'
47+
} else {
48+
this.#tsBytes.push(b)
49+
}
50+
break
51+
}
52+
53+
case 'READ_FILE': {
54+
if (b === RS) {
55+
this.#emitRecord()
56+
} else if (b === NUL) {
57+
if (this.#fileBytes.length > 0) {
58+
this.#files.push(Buffer.from(this.#fileBytes).toString('utf8'))
59+
this.#fileBytes.length = 0
60+
}
61+
} else {
62+
this.#fileBytes.push(b)
63+
}
64+
break
65+
}
66+
}
67+
}
68+
69+
cb()
70+
} catch (err) {
71+
cb(err as Error)
72+
}
73+
}
74+
75+
override _flush(cb: TransformCallback): void {
76+
try {
77+
if (this.#state === 'READ_FILE') {
78+
if (this.#fileBytes.length > 0) {
79+
throw new Error('GitLogParser: unexpected EOF while reading filename')
80+
} else {
81+
this.#emitRecord()
82+
}
83+
}
84+
85+
cb()
86+
} catch (err) {
87+
cb(err as Error)
88+
}
89+
}
90+
91+
#emitRecord(): void {
92+
const ts = Buffer.from(this.#tsBytes).toString('utf8')
93+
const rec: GitLogRecord = {
94+
ts: Number.parseInt(ts, 10) * 1000,
95+
files: this.#files.slice()
96+
}
97+
if (rec.ts > 0 && rec.files.length > 0) this.push(rec)
98+
99+
this.#tsBytes.length = 0
100+
this.#fileBytes.length = 0
101+
this.#files.length = 0
102+
this.#state = 'READ_TS'
103+
}
104+
}
12105

13106
export async function cacheAllGitTimestamps(
14107
root: string,
@@ -28,64 +121,19 @@ export async function cacheAllGitTimestamps(
28121
]
29122

30123
return new Promise((resolve, reject) => {
31-
const out = new Map<string, number>()
124+
cache.clear()
32125
const child = spawn('git', args, { cwd: root })
33126

34-
let buf = Buffer.alloc(0)
35-
child.stdout.on('data', (chunk: Buffer<ArrayBuffer>) => {
36-
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk
37-
38-
let scanFrom = 0
39-
let ts = 0
40-
41-
while (true) {
42-
if (ts === 0) {
43-
const rs = buf.indexOf(RS, scanFrom)
44-
if (rs === -1) break
45-
scanFrom = rs + 1
46-
47-
const nul = buf.indexOf(NUL, scanFrom)
48-
if (nul === -1) break
49-
scanFrom = nul + 2 // skip LF after NUL
50-
51-
const tsSec = buf.toString('utf8', rs + 1, nul)
52-
ts = Number.parseInt(tsSec, 10) * 1000
127+
child.stdout
128+
.pipe(new GitLogParser())
129+
.on('data', (rec: GitLogRecord) => {
130+
for (const file of rec.files) {
131+
const slashed = slash(path.resolve(gitRoot, file))
132+
if (!cache.has(slashed)) cache.set(slashed, rec.ts)
53133
}
54-
55-
let nextNul
56-
while (true) {
57-
nextNul = buf.indexOf(NUL, scanFrom)
58-
if (nextNul === -1) break
59-
60-
// double NUL, move to next record
61-
if (nextNul === scanFrom) {
62-
scanFrom += 1
63-
ts = 0
64-
break
65-
}
66-
67-
const file = buf.toString('utf8', scanFrom, nextNul)
68-
if (file && !out.has(file)) out.set(file, ts)
69-
scanFrom = nextNul + 1
70-
}
71-
72-
if (nextNul === -1) break
73-
}
74-
75-
if (scanFrom > 0) buf = buf.subarray(scanFrom)
76-
})
77-
78-
child.on('close', async () => {
79-
cache.clear()
80-
81-
for (const [file, ts] of out) {
82-
const abs = path.resolve(gitRoot, file)
83-
if (fs.existsSync(abs)) cache.set(slash(abs), ts)
84-
}
85-
86-
out.clear()
87-
resolve()
88-
})
134+
})
135+
.on('error', reject)
136+
.on('end', resolve)
89137

90138
child.on('error', reject)
91139
})
@@ -103,7 +151,7 @@ export async function getGitTimestamp(file: string): Promise<number> {
103151
return new Promise((resolve, reject) => {
104152
const child = spawn(
105153
'git',
106-
['log', '-1', '--pretty=%at', path.basename(file)],
154+
['log', '-1', '--pretty=%at', '--', path.basename(file)],
107155
{ cwd: path.dirname(file) }
108156
)
109157

0 commit comments

Comments
 (0)