Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const useChangelog =
>()

const { pattern = {}, provider } = gitOptions
const repo = resolveRepoLink(gitOptions.repo, provider)
const mainRepo = resolveRepoLink(gitOptions.repo, provider)

return computed(() => {
if (frontmatter.value.changelog === false || !toValue(enabled))
Expand All @@ -58,6 +58,8 @@ export const useChangelog =
item,
)

const repo = item.submodule?.repoUrl ?? mainRepo

if (pattern.issue && repo) {
res.message = res.message.replace(
RE_ISSUE,
Expand Down
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 @@ -145,6 +146,10 @@ export const gitPlugin =
})
},

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

clientConfigFile: () =>
prepareClientConfigFile(app, { changelog, contributors }),
}
Expand Down
5 changes: 4 additions & 1 deletion plugins/development/plugin-git/src/node/resolveChangelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export const resolveChangelog = (
: commits

for (const commit of sliceCommits) {
const { hash, message, time, author, email, refs, coAuthors } = commit
const { hash, message, time, author, email, refs, coAuthors, submodule } =
commit
const tag = parseTagName(refs)
const contributor = getContributorInfo(
{ name: getUserNameWithNoreplyEmail(email) ?? author, email },
Expand All @@ -64,6 +65,8 @@ export const resolveChangelog = (

if (tag) resolved.tag = tag

if (submodule) resolved.submodule = submodule

result.push(resolved)
}

Expand Down
4 changes: 3 additions & 1 deletion plugins/development/plugin-git/src/node/typings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CoAuthorInfo } from '../shared/index.js'
import type { CoAuthorInfo, SubmoduleInfo } from '../shared/index.js'

export interface RawCommit {
/** File path */
Expand All @@ -20,6 +20,8 @@ export interface RawCommit {

/** The co-authors of the commit */
coAuthors: CoAuthorInfo[]

submodule: SubmoduleInfo | null
}

export interface MergedRawCommit extends Omit<RawCommit, 'filepath'> {
Expand Down
110 changes: 107 additions & 3 deletions plugins/development/plugin-git/src/node/utils/getCommits.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { spawn } from 'node:child_process'

import type { GitContributorInfo } from '../../shared/index.js'
import { path } from 'vuepress/utils'

import type { GitContributorInfo, SubmoduleInfo } from '../../shared/index.js'
import type { GitPluginOptions } from '../options.js'
import type { MergedRawCommit, RawCommit } from '../typings.js'
import { getRemoteUrl, normalizeRepoUrl } from './inferGitProvider.js'
import { logger } from './logger.js'

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

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 +84,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 +173,39 @@ 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 {
const absFilePath = path.isAbsolute(filepath)
? filepath
: path.resolve(cwd, filepath)

let repoRelativeFilePath = filepath
let submodule: SubmoduleInfo | null = null

if (gitRoot) {
repoRelativeFilePath = path.relative(gitRoot, absFilePath)

const isSubmodule =
gitRoot !== cwd && gitRoot.startsWith(`${cwd}${path.sep}`)

if (isSubmodule) {
submodule = {
repoUrl: normalizeRepoUrl(getRemoteUrl(gitRoot) || ''),
}
}
}

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 @@ -135,6 +233,7 @@ export const getRawCommits = async (
author,
email,
coAuthors: getCoAuthorsFromCommitBody(body),
submodule,
}
})
} catch (err) {
Expand Down Expand Up @@ -188,3 +287,8 @@ export const getCommits = async (

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

export const clearGitRepoRootCache = (): void => {
gitRepoRootResultCache.clear()
gitRepoRootTaskCache.clear()
}
26 changes: 26 additions & 0 deletions plugins/development/plugin-git/src/node/utils/inferGitProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,32 @@ export const getRemoteUrl = (cwd: string): string | null => {
}
}

/**
* Normalize git remote URL to clean HTTPS format
*
* 将 Git 远程 URL 规范化为干净的 HTTPS 格式
*
* @param url - The git remote URL / Git 远程 URL
* @returns Normalized HTTPS URL / 规范化后的 HTTPS URL
*
* @example
* normalizeRepoUrl('https://github.com/user/repo.git') // 'https://github.com/user/repo'
* normalizeRepoUrl('git@github.com:user/repo.git') // 'https://github.com/user/repo'
* normalizeRepoUrl('ssh://git@github.com/user/repo.git') // 'https://github.com/user/repo'
*/
export const normalizeRepoUrl = (url: string): string => {
const normalized = url.replace(/\.git$/, '')

const sshMatch = /^git@([^:]+):(.+)$/.exec(normalized)
if (sshMatch) return `https://${sshMatch[1]}/${sshMatch[2]}`

const sshProtocolMatch = /^ssh:\/\/git@([^/]+)\/(.+)$/.exec(normalized)
if (sshProtocolMatch)
return `https://${sshProtocolMatch[1]}/${sshProtocolMatch[2]}`

return normalized
}

/**
* Infer git provider from remote URL
*
Expand Down
25 changes: 25 additions & 0 deletions plugins/development/plugin-git/src/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ export interface GitContributorInfo {
url?: string
}

/**
* Submodule information
*
* 子模块信息
*/
export interface SubmoduleInfo {
/**
* Submodule repository URL
*
* 子模块仓库地址
*/
repoUrl?: string

/**
* Git provider
*
* Git 提供商
*/
provider?: KnownGitProvider | null

pattern?: GitUrlPattern
}

/**
* Git changelog information
*
Expand Down Expand Up @@ -133,6 +156,8 @@ export interface GitChangelogInfo {
* 提交协同作者列表
*/
coAuthors?: CoAuthorInfo[]

submodule?: SubmoduleInfo
}

/**
Expand Down