mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
perf(core): Lazy-load handlebars, fast-xml-builder, and @clack/prompts to reduce startup cost
Defer importing three expensive modules from CLI startup to actual usage: - handlebars (~25ms): loaded in outputGenerate.ts on first template compile - fast-xml-builder (~3ms): loaded in generateParsableXmlOutput on demand - @clack/prompts (~16ms): loaded in migrationAction/skillPrompts when needed - packSkill module chain: lazy-loaded via dynamic import in packager.ts These modules were previously loaded eagerly in the import chain (defaultAction → packager → outputGenerate → handlebars) even though they're only needed late in the pipeline during output generation. Benchmark results (NODE_DISABLE_COMPILE_CACHE=1, 10 runs each): - Baseline median: 947ms - After median: 873ms - Improvement: 74ms (7.8%) The improvement is most significant on first run, CI/CD, Docker containers, and Node.js 20 (which lacks compile cache). With warm compile cache the savings are ~32ms in the import chain, with some modules (packSkill, @clack/prompts, fast-xml-builder) completely avoided on default runs. Import chain measurement: 139ms → 107ms (32ms faster startup) Output is byte-for-byte identical — no functional changes. https://claude.ai/code/session_0156PKHfb5NcTVQxfZBW6nAn
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as prompts from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { getGlobalDirectory } from '../../config/globalDirectory.js';
|
||||
import { logger } from '../../shared/logger.js';
|
||||
@@ -108,11 +107,12 @@ const migrateFile = async (
|
||||
|
||||
const exists = await fileExists(newPath);
|
||||
if (exists) {
|
||||
const shouldOverwrite = await prompts.confirm({
|
||||
const p = await import('@clack/prompts');
|
||||
const shouldOverwrite = await p.confirm({
|
||||
message: `${description} already exists at ${newPath}. Do you want to overwrite it?`,
|
||||
});
|
||||
|
||||
if (prompts.isCancel(shouldOverwrite) || !shouldOverwrite) {
|
||||
if (p.isCancel(shouldOverwrite) || !shouldOverwrite) {
|
||||
logger.info(`Skipping migration of ${description}`);
|
||||
return false;
|
||||
}
|
||||
@@ -244,12 +244,16 @@ export const runMigrationAction = async (rootDir: string): Promise<MigrationResu
|
||||
if (hasOldGlobalConfig) items.push('global configuration');
|
||||
migrationMessage += `${items.join(' and ')}. Would you like to migrate to ${pc.green('Repomix')}?`;
|
||||
|
||||
// Lazy-load @clack/prompts (~16ms) — only needed when old Repopack files
|
||||
// are detected, which is rare after initial migration.
|
||||
const p = await import('@clack/prompts');
|
||||
|
||||
// Confirm migration with user
|
||||
const shouldMigrate = await prompts.confirm({
|
||||
const shouldMigrate = await p.confirm({
|
||||
message: migrationMessage,
|
||||
});
|
||||
|
||||
if (prompts.isCancel(shouldMigrate) || !shouldMigrate) {
|
||||
if (p.isCancel(shouldMigrate) || !shouldMigrate) {
|
||||
logger.info('Migration cancelled.');
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as prompts from '@clack/prompts';
|
||||
import pc from 'picocolors';
|
||||
import { OperationCancelledError, RepomixError } from '../../shared/errorHandle.js';
|
||||
import { getDisplayPath } from '../cliReport.js';
|
||||
@@ -13,8 +12,8 @@ export interface SkillPromptResult {
|
||||
skillDir: string;
|
||||
}
|
||||
|
||||
const onCancelOperation = (): never => {
|
||||
prompts.cancel('Skill generation cancelled.');
|
||||
const onCancelOperation = (cancelFn: (message?: string) => void): never => {
|
||||
cancelFn('Skill generation cancelled.');
|
||||
throw new OperationCancelledError('Skill generation cancelled');
|
||||
};
|
||||
|
||||
@@ -31,19 +30,29 @@ export const getSkillBaseDir = (cwd: string, location: SkillLocation): string =>
|
||||
/**
|
||||
* Prompt user for skill location and handle overwrite confirmation.
|
||||
*/
|
||||
// Lazy-load @clack/prompts (~16ms) to build default deps.
|
||||
// Only called in production; tests always pass deps directly.
|
||||
const createPromptDeps = async () => {
|
||||
const p = await import('@clack/prompts');
|
||||
return {
|
||||
select: p.select,
|
||||
confirm: p.confirm,
|
||||
isCancel: p.isCancel,
|
||||
cancel: p.cancel,
|
||||
access: fs.access,
|
||||
rm: fs.rm,
|
||||
};
|
||||
};
|
||||
|
||||
export const promptSkillLocation = async (
|
||||
skillName: string,
|
||||
cwd: string,
|
||||
deps = {
|
||||
select: prompts.select,
|
||||
confirm: prompts.confirm,
|
||||
isCancel: prompts.isCancel,
|
||||
access: fs.access,
|
||||
rm: fs.rm,
|
||||
},
|
||||
deps = {} as Awaited<ReturnType<typeof createPromptDeps>>,
|
||||
): Promise<SkillPromptResult> => {
|
||||
// Resolve deps: use provided test deps or lazy-load defaults
|
||||
const resolvedDeps = Object.keys(deps).length > 0 ? deps : await createPromptDeps();
|
||||
// Step 1: Ask for skill location
|
||||
const location = await deps.select({
|
||||
const location = await resolvedDeps.select({
|
||||
message: 'Where would you like to save the skill?',
|
||||
options: [
|
||||
{
|
||||
@@ -60,8 +69,8 @@ export const promptSkillLocation = async (
|
||||
initialValue: 'personal' as SkillLocation,
|
||||
});
|
||||
|
||||
if (deps.isCancel(location)) {
|
||||
onCancelOperation();
|
||||
if (resolvedDeps.isCancel(location)) {
|
||||
onCancelOperation(resolvedDeps.cancel);
|
||||
}
|
||||
|
||||
const skillDir = path.join(getSkillBaseDir(cwd, location as SkillLocation), skillName);
|
||||
@@ -69,7 +78,7 @@ export const promptSkillLocation = async (
|
||||
// Step 2: Check if directory exists and ask for overwrite
|
||||
let dirExists = false;
|
||||
try {
|
||||
await deps.access(skillDir);
|
||||
await resolvedDeps.access(skillDir);
|
||||
dirExists = true;
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
@@ -77,16 +86,16 @@ export const promptSkillLocation = async (
|
||||
|
||||
if (dirExists) {
|
||||
const displayPath = getDisplayPath(skillDir, cwd);
|
||||
const overwrite = await deps.confirm({
|
||||
const overwrite = await resolvedDeps.confirm({
|
||||
message: `Skill directory already exists. Do you want to overwrite it?\n${pc.dim(`path: ${displayPath}`)}`,
|
||||
});
|
||||
|
||||
if (deps.isCancel(overwrite) || !overwrite) {
|
||||
onCancelOperation();
|
||||
if (resolvedDeps.isCancel(overwrite) || !overwrite) {
|
||||
onCancelOperation(resolvedDeps.cancel);
|
||||
}
|
||||
|
||||
// Remove existing directory before regeneration
|
||||
await deps.rm(skillDir, { recursive: true, force: true });
|
||||
await resolvedDeps.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import XMLBuilder from 'fast-xml-builder';
|
||||
import Handlebars from 'handlebars';
|
||||
import type { RepomixConfigMerged } from '../../config/configSchema.js';
|
||||
import { RepomixError } from '../../shared/errorHandle.js';
|
||||
import { listDirectories, listFiles, searchFiles } from '../file/fileSearch.js';
|
||||
@@ -22,11 +20,28 @@ import {
|
||||
import { getMarkdownTemplate } from './outputStyles/markdownStyle.js';
|
||||
import { getPlainTemplate } from './outputStyles/plainStyle.js';
|
||||
import { getXmlTemplate } from './outputStyles/xmlStyle.js';
|
||||
import { registerHandlebarsHelpers } from './outputStyleUtils.js';
|
||||
|
||||
// Lazy-load Handlebars to defer its ~25ms import cost until output generation.
|
||||
// The module is loaded eagerly in the import chain (packager → produceOutput →
|
||||
// outputGenerate) but only needed late in the pipeline during template rendering.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Handlebars type is complex and only used internally
|
||||
let handlebarsPromise: Promise<any> | undefined;
|
||||
const getHandlebars = () => {
|
||||
if (!handlebarsPromise) {
|
||||
handlebarsPromise = import('handlebars').then((mod) => {
|
||||
registerHandlebarsHelpers(mod.default);
|
||||
return mod.default;
|
||||
});
|
||||
}
|
||||
return handlebarsPromise;
|
||||
};
|
||||
|
||||
// Cache for compiled Handlebars templates to avoid recompilation on every call
|
||||
const compiledTemplateCache = new Map<string, Handlebars.TemplateDelegate>();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Handlebars TemplateDelegate type requires the full module
|
||||
const compiledTemplateCache = new Map<string, any>();
|
||||
|
||||
const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => {
|
||||
const getCompiledTemplate = async (style: string) => {
|
||||
const cached = compiledTemplateCache.get(style);
|
||||
if (cached) {
|
||||
return cached;
|
||||
@@ -47,6 +62,7 @@ const getCompiledTemplate = (style: string): Handlebars.TemplateDelegate => {
|
||||
throw new RepomixError(`Unsupported output style for handlebars template: ${style}`);
|
||||
}
|
||||
|
||||
const Handlebars = await getHandlebars();
|
||||
const compiled = Handlebars.compile(template);
|
||||
compiledTemplateCache.set(style, compiled);
|
||||
return compiled;
|
||||
@@ -123,7 +139,9 @@ export const createRenderContext = (outputGeneratorContext: OutputGeneratorConte
|
||||
};
|
||||
|
||||
const generateParsableXmlOutput = async (renderContext: RenderContext): Promise<string> => {
|
||||
const xmlBuilder = new XMLBuilder({ ignoreAttributes: false });
|
||||
// Lazy-load fast-xml-builder (~3ms) — only used for parsable XML output (non-default)
|
||||
const FastXmlBuilder = (await import('fast-xml-builder')).default;
|
||||
const xmlBuilder = new FastXmlBuilder({ ignoreAttributes: false });
|
||||
const xmlDocument = {
|
||||
repomix: {
|
||||
file_summary: renderContext.fileSummaryEnabled
|
||||
@@ -237,7 +255,7 @@ const generateHandlebarOutput = async (
|
||||
processedFiles?: ProcessedFile[],
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const compiledTemplate = getCompiledTemplate(config.output.style);
|
||||
const compiledTemplate = await getCompiledTemplate(config.output.style);
|
||||
return `${compiledTemplate(renderContext).trim()}\n`;
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError && error.message === 'Invalid string length') {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Shared utilities for output style generation.
|
||||
*/
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
/**
|
||||
* Map of file extensions to syntax highlighting language names.
|
||||
@@ -228,14 +227,19 @@ let handlebarsHelpersRegistered = false;
|
||||
|
||||
/**
|
||||
* Register common Handlebars helpers for output generation.
|
||||
* Accepts a Handlebars instance to avoid requiring a top-level import of handlebars,
|
||||
* which would add ~25ms to the module import chain on every CLI startup.
|
||||
* This function is idempotent - calling it multiple times has no effect.
|
||||
*/
|
||||
export const registerHandlebarsHelpers = (): void => {
|
||||
export const registerHandlebarsHelpers = (handlebarsInstance: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Handlebars registerHelper callback signature is loosely typed
|
||||
registerHelper: (name: string, fn: (...args: any[]) => any) => void;
|
||||
}): void => {
|
||||
if (handlebarsHelpersRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
Handlebars.registerHelper('getFileExtension', (filePath: string) => {
|
||||
handlebarsInstance.registerHelper('getFileExtension', (filePath: string) => {
|
||||
return getLanguageFromFilePath(filePath);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { registerHandlebarsHelpers } from '../outputStyleUtils.js';
|
||||
|
||||
// Register Handlebars helpers (idempotent)
|
||||
registerHandlebarsHelpers();
|
||||
|
||||
export const getMarkdownTemplate = () => {
|
||||
return /* md */ `
|
||||
{{#if fileSummaryEnabled}}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { prefetchFileChangeCounts } from './output/outputSort.js';
|
||||
import { produceOutput } from './packager/produceOutput.js';
|
||||
import type { SuspiciousFileResult } from './security/securityCheck.js';
|
||||
import { validateFileSafety } from './security/validateFileSafety.js';
|
||||
import { packSkill } from './skill/packSkill.js';
|
||||
import type { PackSkillParams } from './skill/packSkill.js';
|
||||
|
||||
export interface PackResult {
|
||||
totalFiles: number;
|
||||
@@ -48,7 +48,13 @@ const defaultDeps = {
|
||||
sortPaths,
|
||||
getGitDiffs,
|
||||
getGitLogs,
|
||||
packSkill,
|
||||
// Lazy-load packSkill to defer importing the skill module chain
|
||||
// (skillSectionGenerators, skillStyle → Handlebars), which adds ~25ms
|
||||
// to module loading. Only used when --skill-generate is active (non-default).
|
||||
packSkill: async (params: PackSkillParams) => {
|
||||
const { packSkill } = await import('./skill/packSkill.js');
|
||||
return packSkill(params);
|
||||
},
|
||||
prefetchFileChangeCounts,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { generateTreeStringWithLineCounts } from '../file/fileTreeGenerate.js';
|
||||
import type { RenderContext } from '../output/outputGeneratorTypes.js';
|
||||
import { registerHandlebarsHelpers } from '../output/outputStyleUtils.js';
|
||||
|
||||
// Register Handlebars helpers (idempotent)
|
||||
registerHandlebarsHelpers();
|
||||
// Register Handlebars helpers (idempotent).
|
||||
// This module's Handlebars import cost is only paid when --skill-generate
|
||||
// is active, because packager.ts lazy-loads packSkill via dynamic import.
|
||||
registerHandlebarsHelpers(Handlebars);
|
||||
|
||||
/**
|
||||
* Generates the summary section for skill output.
|
||||
|
||||
@@ -20,6 +20,7 @@ const createMockDeps = (overrides: {
|
||||
select: vi.fn().mockResolvedValue(overrides.selectValue),
|
||||
confirm: vi.fn().mockResolvedValue(overrides.confirmValue),
|
||||
isCancel: overrides.isCancelFn as (value: unknown) => value is symbol,
|
||||
cancel: vi.fn(),
|
||||
access: overrides.accessRejects
|
||||
? vi.fn().mockRejectedValue(new Error('ENOENT'))
|
||||
: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Handlebars from 'handlebars';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getMarkdownTemplate } from '../../../../src/core/output/outputStyles/markdownStyle.js';
|
||||
import { registerHandlebarsHelpers } from '../../../../src/core/output/outputStyleUtils.js';
|
||||
|
||||
// Register helpers that are now lazy-loaded in production code
|
||||
registerHandlebarsHelpers(Handlebars);
|
||||
|
||||
describe('markdownStyle', () => {
|
||||
describe('getMarkdownTemplate', () => {
|
||||
|
||||
Reference in New Issue
Block a user