11import type { EditableTreeNode } from 'unplugin-vue-router'
2- import type { GitLogFileEntry , GitLogOptions } from '../types'
2+ import type { Changelog , Contributor , GitLogFileEntry , GitLogOptions } from '../types'
33import path from 'node:path'
44import process from 'node:process'
55import consola from 'consola'
6+ import gravatar from 'gravatar'
7+ import md5 from 'md5'
68import fs from 'fs-extra'
7- import { getChangelog } from './changeLog '
8- import { getContributors } from './contributor '
9+ import { git } from '.'
10+ import { guessGitHubUsername } from '../utils '
911
1012export const destDir = path . resolve ( process . cwd ( ) , './public' )
1113// Only allow files from the user's working directory 'pages' folder
12- export const currentWorkingDirectory = `${ process . cwd ( ) } /pages`
13- let basePath : string
14+ export const currentWorkingDirectory = path . join ( process . cwd ( ) , 'pages' )
1415
15- export function setBasePath ( path : string ) {
16- basePath = path
16+ /**
17+ * basePath is resolved asynchronously via `git revparse`. Store the promise
18+ * so that callers can `await` it instead of racing against `setBasePath`.
19+ */
20+ let basePathPromise : Promise < string > | undefined
21+ let basePath : string | undefined
22+
23+ export function initBasePath ( ) {
24+ basePathPromise = git . revparse ( [ '--show-toplevel' ] ) . then ( ( result ) => {
25+ basePath = result . trim ( )
26+ return basePath
27+ } ) . catch ( ( error ) => {
28+ consola . warn ( 'valaxy-addon-git-log: Could not resolve git root, falling back to cwd.' , error )
29+ basePath = process . cwd ( )
30+ return basePath
31+ } )
32+ return basePathPromise
33+ }
34+
35+ export async function ensureBasePath ( ) : Promise < string > {
36+ if ( basePath )
37+ return basePath
38+ if ( basePathPromise )
39+ return basePathPromise
40+ return initBasePath ( )
41+ }
42+
43+ /** @deprecated Use ensureBasePath() instead */
44+ export function setBasePath ( p : string ) {
45+ basePath = p
1746}
1847
1948export function getBasePath ( ) {
2049 return basePath
2150}
2251
52+ /**
53+ * Pending route info collected during extendRoute, processed in batch later.
54+ */
55+ interface PendingRoute {
56+ route : EditableTreeNode
57+ filePath : string
58+ gitRelativePath : string
59+ }
60+
61+ let pendingRoutes : PendingRoute [ ] = [ ]
62+
63+ /**
64+ * Collect route info during extendRoute (fast, no git calls).
65+ * Actual git operations are deferred to `flushGitLogBatch`.
66+ */
2367export async function handleGitLogInfo ( options : GitLogOptions , route : EditableTreeNode ) {
2468 const strategy = options . contributor ?. strategy
2569 const isPrebuilt = strategy === 'prebuilt'
2670 const isBuildTime = strategy === 'build-time'
27- // const isRuntime = strategy === 'runtime'
2871
2972 const filePath = route . components . get ( 'default' )
3073 if ( ! filePath )
@@ -33,7 +76,17 @@ export async function handleGitLogInfo(options: GitLogOptions, route: EditableTr
3376 if ( ! route . meta . frontmatter . git_log )
3477 route . meta . frontmatter . git_log = { }
3578
36- const gitRelativePath = filePath . replace ( basePath , '' ) . substring ( 1 )
79+ // Ensure basePath is available before computing relative path
80+ let resolvedBase : string
81+ try {
82+ resolvedBase = await ensureBasePath ( )
83+ }
84+ catch {
85+ // Fallback: use cwd-relative path
86+ resolvedBase = process . cwd ( )
87+ }
88+
89+ const gitRelativePath = path . relative ( resolvedBase , filePath ) . split ( path . sep ) . join ( '/' )
3790 route . meta . frontmatter . git_log . path = gitRelativePath
3891
3992 if ( ! isPrebuilt && ! isBuildTime )
@@ -42,45 +95,232 @@ export async function handleGitLogInfo(options: GitLogOptions, route: EditableTr
4295 if ( ! filePath . startsWith ( currentWorkingDirectory ) )
4396 return
4497
98+ pendingRoutes . push ( { route, filePath, gitRelativePath } )
99+ }
100+
101+ /**
102+ * Batch-fetch contributors for all files in a single git command.
103+ * Returns a map of filePath -> Contributor[].
104+ */
105+ async function batchGetContributors ( resolvedBase : string , filePaths : string [ ] , options ?: GitLogOptions ) : Promise < Map < string , Contributor [ ] > > {
106+ const result = new Map < string , Contributor [ ] > ( )
107+ if ( ! filePaths . length )
108+ return result
109+
110+ const { contributor } = options || { }
111+
45112 try {
46- const contributors = await getContributors ( filePath , options )
47- const changeLog = await getChangelog ( process . env . CI ? 1000 : 100 , filePath )
113+ const gitArgs = [
114+ 'log' ,
115+ '--no-merges' ,
116+ '--pretty=format:---COMMIT_SEP---%an|%ae' ,
117+ '--name-only' ,
118+ ...( contributor ?. logArgs ? contributor . logArgs . trim ( ) . split ( / \s + / ) : [ ] ) ,
119+ '--' ,
120+ ...filePaths ,
121+ ]
48122
49- if ( isBuildTime ) {
50- route . meta . frontmatter . git_log . contributors = contributors
51- route . meta . frontmatter . git_log . changeLog = changeLog
52- }
123+ const raw = await git . raw ( gitArgs )
53124
54- if ( isPrebuilt && destDir ) {
55- const gitLogPath = path . join ( destDir , 'git-log.json' )
125+ // Parse: each block is "---COMMIT_SEP---author|email\nfile1\nfile2\n..."
126+ const blocks = raw . split ( '---COMMIT_SEP---' ) . filter ( Boolean )
56127
57- let existingData = { }
58- try {
59- if ( await fs . pathExists ( gitLogPath ) )
60- existingData = JSON . parse ( await fs . readFile ( gitLogPath , 'utf-8' ) )
128+ // fileContribMap: filePath -> { email -> Contributor }
129+ const fileContribMap = new Map < string , Record < string , Contributor > > ( )
130+
131+ for ( const block of blocks ) {
132+ const lines = block . trim ( ) . split ( '\n' )
133+ if ( ! lines . length )
134+ continue
135+
136+ const [ name , email ] = lines [ 0 ] . split ( '|' )
137+ if ( ! email )
138+ continue
139+
140+ const files = lines . slice ( 1 ) . filter ( Boolean )
141+ for ( const file of files ) {
142+ // Resolve to absolute path for matching
143+ const absPath = path . resolve ( resolvedBase , file )
144+ if ( ! fileContribMap . has ( absPath ) )
145+ fileContribMap . set ( absPath , { } )
146+
147+ const contribs = fileContribMap . get ( absPath ) !
148+ if ( ! contribs [ email ] ) {
149+ const githubUsername = guessGitHubUsername ( email )
150+ contribs [ email ] = {
151+ count : 0 ,
152+ name,
153+ email,
154+ avatar : githubUsername
155+ ? `https://github.com/${ githubUsername } .png`
156+ : gravatar . url ( email ) ,
157+ github : githubUsername ? `https://github.com/${ githubUsername } ` : null ,
158+ hash : md5 ( email ) ,
159+ }
160+ }
161+ contribs [ email ] . count ++
61162 }
62- catch ( error ) {
63- consola . error ( `valaxy-addon-git-log: Error reading existing git log file:` , error )
163+ }
164+
165+ for ( const [ fp , contribs ] of fileContribMap ) {
166+ result . set ( fp , Object . values ( contribs ) . sort ( ( a , b ) => b . count - a . count ) )
167+ }
168+ }
169+ catch ( e ) {
170+ consola . error ( 'valaxy-addon-git-log: Error batch-fetching contributors:' , e )
171+ }
172+
173+ return result
174+ }
175+
176+ /**
177+ * Batch-fetch changelogs for all files in a single git command.
178+ * Returns a map of filePath -> Changelog[].
179+ */
180+ async function batchGetChangelog ( resolvedBase : string , filePaths : string [ ] , maxCount : number ) : Promise < Map < string , Changelog [ ] > > {
181+ const result = new Map < string , Changelog [ ] > ( )
182+ if ( ! filePaths . length )
183+ return result
184+
185+ try {
186+ // Note: --max-count is omitted because with multiple pathspecs it limits
187+ // the *global* commit count, not per-file. We fetch all matching commits
188+ // and truncate each file's array to `maxCount` in JS below.
189+ const raw = await git . raw ( [
190+ 'log' ,
191+ '--name-only' ,
192+ '--pretty=format:---CL_SEP---%H|%an|%ae|%aI|%s|%b' ,
193+ '--' ,
194+ ...filePaths ,
195+ ] )
196+
197+ const blocks = raw . split ( '---CL_SEP---' ) . filter ( Boolean )
198+
199+ for ( const block of blocks ) {
200+ const lines = block . trim ( ) . split ( '\n' )
201+ if ( ! lines . length )
202+ continue
203+
204+ const headerLine = lines [ 0 ]
205+ const [ hash , authorName , authorEmail , date , ...rest ] = headerLine . split ( '|' )
206+ const message = rest . join ( '|' ) || ''
207+
208+ if (
209+ ! message . includes ( 'chore: release' )
210+ && ! message . includes ( '!' )
211+ && ! message . startsWith ( 'feat' )
212+ && ! message . startsWith ( 'fix' )
213+ ) {
214+ continue
64215 }
65216
66- const newData : GitLogFileEntry = {
67- ...existingData ,
68- [ gitRelativePath ] : {
69- contributors,
70- changeLog,
71- path : gitRelativePath ,
72- } ,
217+ const log : Changelog = {
218+ hash,
219+ date,
220+ message,
221+ refs : '' ,
222+ author_name : authorName ,
223+ author_email : authorEmail ,
224+ } as Changelog
225+
226+ if ( message . includes ( 'chore: release' ) ) {
227+ log . version = message . split ( ' ' ) [ 2 ] ?. trim ( )
73228 }
74229
75- await fs . mkdir ( path . dirname ( gitLogPath ) , { recursive : true } )
76- await fs . writeFile (
77- gitLogPath ,
78- JSON . stringify ( newData , null , 2 ) ,
79- 'utf-8' ,
80- )
230+ const files = lines . slice ( 1 ) . filter ( Boolean )
231+ for ( const file of files ) {
232+ const absPath = path . resolve ( resolvedBase , file )
233+ if ( ! result . has ( absPath ) )
234+ result . set ( absPath , [ ] )
235+ result . get ( absPath ) ! . push ( log )
236+ }
81237 }
82238 }
239+ catch ( e ) {
240+ consola . error ( 'valaxy-addon-git-log: Error batch-fetching changelogs:' , e )
241+ }
242+
243+ // Truncate each file's changelog to maxCount to match per-file semantics
244+ for ( const [ fp , logs ] of result ) {
245+ if ( logs . length > maxCount )
246+ result . set ( fp , logs . slice ( 0 , maxCount ) )
247+ }
248+
249+ return result
250+
251+ /**
252+ * Process all pending routes in batch: 2 git commands for ALL files
253+ * instead of 2 × N git commands (one per file).
254+ */
255+ export async function flushGitLogBatch ( options : GitLogOptions ) {
256+ if ( ! pendingRoutes . length )
257+ return
258+
259+ const routes = pendingRoutes
260+ pendingRoutes = [ ]
261+
262+ const strategy = options . contributor ?. strategy
263+ const isPrebuilt = strategy === 'prebuilt'
264+ const isBuildTime = strategy === 'build-time'
265+
266+ let resolvedBase : string
267+ try {
268+ resolvedBase = await ensureBasePath ( )
269+ }
83270 catch ( error ) {
84- consola . error ( `valaxy-addon-git-log: Error processing git log for ${ filePath } :` , error )
271+ consola . error ( 'valaxy-addon-git-log: Failed to resolve git root in flushGitLogBatch, skipping.' , error )
272+ return
273+ }
274+
275+ const filePaths = routes . map ( r => r . filePath )
276+ const maxCount = process . env . CI ? 1000 : 100
277+
278+ // 2 git commands for ALL files (instead of 2 × N)
279+ const [ contributorsMap , changelogMap ] = await Promise . all ( [
280+ batchGetContributors ( resolvedBase , filePaths , options ) ,
281+ batchGetChangelog ( resolvedBase , filePaths , maxCount ) ,
282+ ] )
283+
284+ // Write results for prebuilt strategy (single file write)
285+ let prebuiltData : GitLogFileEntry = { }
286+
287+ if ( isPrebuilt && destDir ) {
288+ const gitLogPath = path . join ( destDir , 'git-log.json' )
289+ try {
290+ if ( await fs . pathExists ( gitLogPath ) )
291+ prebuiltData = JSON . parse ( await fs . readFile ( gitLogPath , 'utf-8' ) )
292+ }
293+ catch ( error ) {
294+ consola . error ( 'valaxy-addon-git-log: Error reading existing git log file:' , error )
295+ }
296+ }
297+
298+ for ( const { route, filePath, gitRelativePath } of routes ) {
299+ const contributors = contributorsMap . get ( filePath ) || [ ]
300+ const changeLog = changelogMap . get ( filePath ) || [ ]
301+
302+ if ( isBuildTime ) {
303+ route . meta . frontmatter . git_log . contributors = contributors
304+ route . meta . frontmatter . git_log . changeLog = changeLog
305+ }
306+
307+ if ( isPrebuilt ) {
308+ prebuiltData [ gitRelativePath ] = {
309+ contributors,
310+ changeLog,
311+ path : gitRelativePath ,
312+ }
313+ }
314+ }
315+
316+ if ( isPrebuilt && destDir ) {
317+ const gitLogPath = path . join ( destDir , 'git-log.json' )
318+ try {
319+ await fs . mkdir ( path . dirname ( gitLogPath ) , { recursive : true } )
320+ await fs . writeFile ( gitLogPath , JSON . stringify ( prebuiltData , null , 2 ) , 'utf-8' )
321+ }
322+ catch ( error ) {
323+ consola . error ( 'valaxy-addon-git-log: Error writing git log file at' , gitLogPath , error )
324+ }
85325 }
86326}
0 commit comments