Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/development/plugin-git/src/node/gitPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { resolveContributors } from './resolveContributors.js'
import {
PLUGIN_NAME,
checkGitRepo,
clearGitRepoRootCache,
getCommits,
inferGitProvider,
injectGitOptions,
Expand Down Expand Up @@ -143,6 +144,10 @@ export const gitPlugin =
})
},

onPrepared: () => {
clearGitRepoRootCache()
},

clientConfigFile: () =>
prepareClientConfigFile(app, { changelog, contributors }),
}
Expand Down
99 changes: 97 additions & 2 deletions plugins/development/plugin-git/src/node/utils/getCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import { spawn } from 'node:child_process'
import type { GitContributorInfo } from '../../shared/index.js'
import type { GitPluginOptions } from '../options.js'
import type { MergedRawCommit, RawCommit } from '../typings.js'
import { path } from 'vuepress/utils'
import { logger } from './logger.js'

const INFO_SPLITTER = '[|]'
const COMMIT_SPLITTER = String.raw`\|/`
const RE_CO_AUTHOR = /^ *Co-authored-by: ?([^<]*)<([^>]*)> */gim

const gitRepoRootResultCache = new Map<string, string | null>()
const gitRepoRootTaskCache = new Map<string, Promise<string | null>>()

const getCoAuthorsFromCommitBody = (
body: string,
): Pick<GitContributorInfo, 'email' | 'name'>[] =>
Expand Down Expand Up @@ -78,6 +82,77 @@ const runGitLog = (args: string[], cwd: string): Promise<string> =>
})
})

/**
* Get git repository root directory for a given file path.
*
* This function runs `git rev-parse --show-toplevel` in the directory of the
* target file to determine the top-level directory of the git repository.
*
* @param filePath - File path (relative or absolute) whose repository root is requested
* @param cwd - Current working directory
* @returns Promise that resolves to normalized git root path, or null if not in a git repository
*/
const getGitRepoRoot = (
filePath: string,
cwd: string,
): Promise<string | null> => {
const absFilePath = path.isAbsolute(filePath)
? filePath
: path.resolve(cwd, filePath)

const dir = path.normalize(path.dirname(absFilePath))
const cachedResult = gitRepoRootResultCache.get(dir)

if (cachedResult !== undefined || gitRepoRootResultCache.has(dir))
return Promise.resolve(cachedResult ?? null)
Comment on lines +108 to +109
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache hit check is overly complex: gitRepoRootResultCache stores only string | null, so get(dir) returning undefined already means “not cached”. The extra || gitRepoRootResultCache.has(dir) branch is redundant and makes it harder to reason about caching null vs “missing”. Consider rewriting this to a single has(dir) check (or a single cachedResult !== undefined check) for clarity.

Suggested change
if (cachedResult !== undefined || gitRepoRootResultCache.has(dir))
return Promise.resolve(cachedResult ?? null)
if (cachedResult !== undefined) return Promise.resolve(cachedResult)

Copilot uses AI. Check for mistakes.

const cachedTask = gitRepoRootTaskCache.get(dir)
if (cachedTask) return cachedTask

const task = new Promise<string | null>((resolve) => {
const gitProcess = spawn('git', ['rev-parse', '--show-toplevel'], {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
})

let stdoutData = ''
let stderrData = ''

gitProcess.stdout.on('data', (chunk: Buffer) => {
stdoutData += chunk.toString('utf-8')
})

gitProcess.stderr.on('data', (chunk: Buffer) => {
stderrData += chunk.toString('utf-8')
})

gitProcess.on('error', (error) => {
logger.error(`Failed to spawn git rev-parse: ${error.message}`)
resolve(null)
})

gitProcess.on('close', (code) => {
if (code === 0) {
resolve(path.normalize(stdoutData.trim()))
} else {
logger.error(
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getGitRepoRoot() logs an error for any non-zero git rev-parse exit, but git rev-parse --show-toplevel can fail in expected situations (e.g. included files outside the repo / wrong directory). Logging at error level here can create noisy, duplicated output (this function then returns null and git log may fail and be logged again). Consider treating common non-repo failures as a non-error (return null silently or at debug level) and only surface an error once with full context when commit collection ultimately fails.

Suggested change
logger.error(
logger.debug(

Copilot uses AI. Check for mistakes.
`git rev-parse failed (code=${code}): ${stderrData.trim()}`,
)
resolve(null)
Comment on lines +140 to +143
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When git rev-parse --show-toplevel fails, the error log currently doesn’t include which directory it was executed in. Including dir (or the original filePath) in the logged message would make it much easier to diagnose submodule / monorepo edge cases, especially since failures may be intermittent depending on which file is being processed.

Copilot uses AI. Check for mistakes.
}
})
})

const cached = task.then((gitRoot) => {
gitRepoRootResultCache.set(dir, gitRoot)
gitRepoRootTaskCache.delete(dir)
return gitRoot
})
gitRepoRootTaskCache.set(dir, cached)

return cached
}

/**
* Get raw commits for a specific file
*
Expand All @@ -96,18 +171,33 @@ export const getRawCommits = async (
options: GitPluginOptions,
): Promise<RawCommit[]> => {
const format = getGitLogFormat(options)
const gitRoot = await getGitRepoRoot(filepath, cwd)

Comment on lines 175 to 177
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getGitRepoRoot() is executed for every file in getRawCommits(), which adds an extra git spawn per file and can noticeably slow down builds on large sites (especially since getCommits() runs for every page). Consider memoizing repo roots by directory (e.g. a Map keyed by dir/cwd) or computing the repo root once per getCommits() call and reusing it for all filepaths.

Copilot uses AI. Check for mistakes.
try {
let repoRelativeFilePath = filepath
if (gitRoot) {
// Resolve to absolute path first, then convert to repo-relative path
const absFilePath = path.isAbsolute(repoRelativeFilePath)
? repoRelativeFilePath
: path.resolve(cwd, repoRelativeFilePath)

repoRelativeFilePath = path.relative(gitRoot, absFilePath)
} else {
logger.warn(
`Failed to resolve git repo root for "${filepath}" under cwd "${cwd}", falling back to cwd; git history may be incomplete.`,
)
}

const stdout = await runGitLog(
[
'--max-count=-1',
`--format=${format}${COMMIT_SPLITTER}`,
'--date=unix',
'--follow',
'--',
filepath,
repoRelativeFilePath,
],
cwd,
gitRoot || cwd,
Comment on lines 175 to +208
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCommits() can now fetch commit histories from different git roots within a single call (because getGitRepoRoot() is computed per filepath). However, mergeRawCommits() still merges commits solely by hash, which assumes all commits come from the same repository. If filepaths ever span multiple repos (e.g. via gitInclude pointing outside a submodule), identical hashes across repos could be merged incorrectly. Consider either enforcing that all filepaths resolve to the same gitRoot (and warn/skip otherwise), or include the repo identity (e.g. gitRoot) in the merge key / raw commit data so merges remain correct across repos.

Copilot uses AI. Check for mistakes.
)

return stdout
Expand Down Expand Up @@ -188,3 +278,8 @@ export const getCommits = async (

return mergeRawCommits(rawCommits).sort((a, b) => b.time - a.time)
}

export const clearGitRepoRootCache = (): void => {
gitRepoRootResultCache.clear()
gitRepoRootTaskCache.clear()
}