import { spawn } from 'node:child_process'; import { logger } from './logger.js'; const INFO_SPLITTER = '[|]'; const COMMIT_SPLITTER = '\\|/'; const RE_CO_AUTHOR = /^ *Co-authored-by: ?([^<]*)<([^>]*)> */gim; const getCoAuthorsFromCommitBody = (body) => body ? Array.from(body.matchAll(RE_CO_AUTHOR)).map(([, name, email]) => ({ name: name.trim(), email: email.trim(), })) : []; const getGitLogFormat = ({ contributors, changelog, }) => { if (!contributors && !changelog) // hash | author_date return ['%H', '%ad'].join(INFO_SPLITTER); if (contributors && !changelog) // hash | author_date | author_name | author_email | body return ['%H', '%ad', '%an', '%ae', '%b'].join(INFO_SPLITTER); // hash | author_date | author_name | author_email | body | subject | ref return ['%H', '%ad', '%an', '%ae', '%b', '%s', '%d'].join(INFO_SPLITTER); }; /** * Helper function to run git command using spawn and return stdout as a promise. * Rejects if the git command exits with a non-zero code. */ const runGitLog = (args, cwd) => new Promise((resolve, reject) => { const gitProcess = spawn('git', ['log', ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'], }); let stdoutData = ''; let stderrData = ''; gitProcess.stdout.on('data', (chunk) => { stdoutData += chunk.toString('utf8'); }); gitProcess.stderr.on('data', (chunk) => { stderrData += chunk.toString('utf8'); }); gitProcess.on('error', (error) => { reject(new Error(`Failed to spawn 'git log': ${error.message}`)); }); gitProcess.on('close', (code) => { if (code === 0) { resolve(stdoutData); } else { reject(new Error(`'git log' failed with exit code ${code}: ${stderrData.trim()}`)); } }); }); /** * Get raw commits for a specific file * * ${commit_hash} ${author_name} ${author_email} ${author_date} ${subject} ${ref} ${body} * * @see {@link https://git-scm.com/docs/pretty-formats | documentation} for details. */ export const getRawCommits = async (filepath, cwd, options) => { const format = getGitLogFormat(options); try { const stdout = await runGitLog([ '--max-count=-1', `--format=${format}${COMMIT_SPLITTER}`, '--date=unix', '--follow', '--', filepath, ], cwd); return stdout .slice(0, -1 - COMMIT_SPLITTER.length) .split(`${COMMIT_SPLITTER}\n`) .filter(Boolean) .map((rawString) => { const [hash, time, author = '', email = '', body = '', message = '', refs = '',] = rawString.split(INFO_SPLITTER).map((v) => v.trim()); return { filepath, hash, time: Number.parseInt(time, 10) * 1000, message, body, refs, author, email, coAuthors: getCoAuthorsFromCommitBody(body), }; }); } catch (error) { logger.error(`Failed to get commits for ${filepath} in ${cwd}:`, error); return []; } }; /** * Merge raw commits by hash * * 按哈希值合并原始提交记录 * * @param commits - Raw commits * * 原始提交记录 */ export const mergeRawCommits = (commits) => { const commitMap = new Map(); commits.forEach(({ filepath, ...commit }) => { const _commit = commitMap.get(commit.hash); if (_commit) _commit.filepaths.push(filepath); else commitMap.set(commit.hash, { ...commit, filepaths: [filepath] }); }); const result = Array.from(commitMap.values()); return result; }; /** * Get merged commits for multiple file paths * * 获取多个文件路径的合并提交记录 * * @param filepaths - File paths to get commits for * * 要获取提交记录的文件路径 * * @param cwd - Working directory * * 工作目录 * * @param options - Git plugin options * * Git 插件选项 */ export const getCommits = async (filepaths, cwd, options) => { const rawCommits = (await Promise.all(filepaths.map((filepath) => getRawCommits(filepath, cwd, options)))).flat(); return mergeRawCommits(rawCommits).sort((a, b) => b.time - a.time); };