Skip to content

Commit 8c7459b

Browse files
authored
Merge pull request #578 from constructive-io/devin/1767847335-pgpm-slice-feature
feat(pgpm): add slice command for modularizing migration plans
2 parents 96a9219 + 22e4cda commit 8c7459b

13 files changed

Lines changed: 3312 additions & 6 deletions

File tree

docs/spec/SLICING.md

Lines changed: 1368 additions & 0 deletions
Large diffs are not rendered by default.

pgpm/cli/src/commands.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import upgrade from './commands/upgrade';
2424
import remove from './commands/remove';
2525
import renameCmd from './commands/rename';
2626
import revert from './commands/revert';
27+
import slice from './commands/slice';
2728
import tag from './commands/tag';
2829
import testPackages from './commands/test-packages';
2930
import verify from './commands/verify';
@@ -62,12 +63,13 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
6263
install: pgt(install),
6364
migrate: pgt(migrate),
6465
analyze: pgt(analyze),
65-
rename: pgt(renameCmd),
66-
'test-packages': pgt(testPackages),
67-
upgrade: pgt(upgrade),
68-
up: pgt(upgrade),
69-
cache,
70-
update: updateCmd
66+
rename: pgt(renameCmd),
67+
slice,
68+
'test-packages': pgt(testPackages),
69+
upgrade: pgt(upgrade),
70+
up: pgt(upgrade),
71+
cache,
72+
update: updateCmd
7173
};
7274
};
7375

pgpm/cli/src/commands/slice.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { PgpmPackage, slicePlan, writeSliceResult, generateDryRunReport, SliceConfig, PatternSlice } from '@pgpmjs/core';
2+
import { getGitConfigInfo } from '@pgpmjs/types';
3+
import { CLIOptions, Inquirerer } from 'inquirerer';
4+
import { resolve } from 'path';
5+
import { readFileSync, existsSync } from 'fs';
6+
7+
const sliceUsageText = `
8+
Slice Command:
9+
10+
pgpm slice [OPTIONS]
11+
12+
Slice a large plan file into multiple modular packages based on folder structure
13+
or glob patterns.
14+
15+
Options:
16+
--help, -h Show this help message
17+
--plan <path> Path to source plan file (default: pgpm.plan in current module)
18+
--output <directory> Output directory for sliced packages (default: ./sliced)
19+
--strategy <type> Grouping strategy: 'folder' or 'pattern' (default: folder)
20+
--depth <number> Folder depth for package extraction (default: 1, folder strategy only)
21+
--prefix <string> Prefix to strip from paths (default: schemas, folder strategy only)
22+
--patterns <file> JSON file with pattern definitions (pattern strategy only)
23+
--default <name> Default package name for unmatched changes (default: core)
24+
--min-changes <number> Minimum changes per package (smaller packages are merged)
25+
--use-tags Use tags for cross-package dependencies
26+
--dry-run Show what would be created without writing files
27+
--overwrite Overwrite existing package directories
28+
--copy-files Copy SQL files from source to output packages
29+
--cwd <directory> Working directory (default: current directory)
30+
31+
Strategies:
32+
folder - Groups changes by folder depth (e.g., schemas/auth_public/* -> auth_public)
33+
pattern - Groups changes by glob patterns defined in a JSON file
34+
35+
Pattern File Format (for --patterns):
36+
{
37+
"slices": [
38+
{ "packageName": "auth", "patterns": ["schemas/*_auth_*/**", "schemas/*_tokens_*/**"] },
39+
{ "packageName": "users", "patterns": ["schemas/*_users_*/**", "schemas/*_emails_*/**"] }
40+
]
41+
}
42+
43+
Examples:
44+
pgpm slice Slice using folder strategy (default)
45+
pgpm slice --dry-run Preview slicing without writing files
46+
pgpm slice --depth 2 Use 2-level folder grouping
47+
pgpm slice --strategy pattern --patterns ./slices.json Use pattern-based slicing
48+
pgpm slice --output ./packages Output to specific directory
49+
pgpm slice --min-changes 10 Merge packages with fewer than 10 changes
50+
`;
51+
52+
export default async (
53+
argv: Partial<Record<string, any>>,
54+
prompter: Inquirerer,
55+
_options: CLIOptions
56+
) => {
57+
// Show usage if explicitly requested
58+
if (argv.help || argv.h) {
59+
console.log(sliceUsageText);
60+
process.exit(0);
61+
}
62+
63+
const { username, email } = getGitConfigInfo();
64+
const cwd = argv.cwd ?? process.cwd();
65+
const project = new PgpmPackage(cwd);
66+
67+
// Determine source plan file
68+
let sourcePlan: string;
69+
if (argv.plan) {
70+
sourcePlan = resolve(cwd, argv.plan);
71+
} else if (project.isInModule()) {
72+
sourcePlan = resolve(project.getModulePath()!, 'pgpm.plan');
73+
} else {
74+
// Prompt for plan file
75+
const { planPath } = await prompter.prompt(argv, [
76+
{
77+
type: 'text',
78+
name: 'planPath',
79+
message: 'Path to source plan file',
80+
default: 'pgpm.plan',
81+
required: true
82+
}
83+
]);
84+
sourcePlan = resolve(cwd, planPath);
85+
}
86+
87+
// Determine output directory
88+
const outputDir = argv.output
89+
? resolve(cwd, argv.output)
90+
: resolve(cwd, 'sliced');
91+
92+
// Determine strategy type
93+
const strategyType = argv.strategy ?? 'folder';
94+
95+
// Load pattern file if using pattern strategy
96+
let patternSlices: PatternSlice[] = [];
97+
if (strategyType === 'pattern') {
98+
if (!argv.patterns) {
99+
console.error('Error: --patterns <file> is required when using pattern strategy');
100+
process.exit(1);
101+
}
102+
const patternsPath = resolve(cwd, argv.patterns);
103+
if (!existsSync(patternsPath)) {
104+
console.error(`Error: Pattern file not found: ${patternsPath}`);
105+
process.exit(1);
106+
}
107+
try {
108+
const patternsContent = readFileSync(patternsPath, 'utf-8');
109+
const patternsData = JSON.parse(patternsContent);
110+
if (!patternsData.slices || !Array.isArray(patternsData.slices)) {
111+
console.error('Error: Pattern file must contain a "slices" array');
112+
process.exit(1);
113+
}
114+
patternSlices = patternsData.slices;
115+
} catch (err) {
116+
console.error(`Error parsing pattern file: ${(err as Error).message}`);
117+
process.exit(1);
118+
}
119+
}
120+
121+
// Get configuration options (only prompt for folder-specific options if using folder strategy)
122+
const folderQuestions = strategyType === 'folder' ? [
123+
{
124+
type: 'number' as const,
125+
name: 'depth',
126+
message: 'Folder depth for package extraction',
127+
default: 1,
128+
useDefault: true
129+
},
130+
{
131+
type: 'text' as const,
132+
name: 'prefix',
133+
message: 'Prefix to strip from paths',
134+
default: 'schemas',
135+
useDefault: true
136+
}
137+
] : [];
138+
139+
const { depth, prefix, defaultPackage, minChanges, useTags } = await prompter.prompt(argv, [
140+
...folderQuestions,
141+
{
142+
type: 'text' as const,
143+
name: 'defaultPackage',
144+
message: 'Default package name for unmatched changes',
145+
default: 'core',
146+
useDefault: true
147+
},
148+
{
149+
type: 'number' as const,
150+
name: 'minChanges',
151+
message: 'Minimum changes per package (0 to disable merging)',
152+
default: 0,
153+
useDefault: true
154+
},
155+
{
156+
type: 'confirm' as const,
157+
name: 'useTags',
158+
message: 'Use tags for cross-package dependencies?',
159+
default: false,
160+
useDefault: true
161+
}
162+
]);
163+
164+
// Build slice configuration based on strategy
165+
const config: SliceConfig = {
166+
sourcePlan,
167+
outputDir,
168+
strategy: strategyType === 'pattern'
169+
? { type: 'pattern', slices: patternSlices }
170+
: {
171+
type: 'folder',
172+
depth: argv.depth ?? depth ?? 1,
173+
prefixToStrip: argv.prefix ?? prefix ?? 'schemas'
174+
},
175+
defaultPackage: argv.default ?? defaultPackage ?? 'core',
176+
minChangesPerPackage: argv['min-changes'] ?? minChanges ?? 0,
177+
useTagsForCrossPackageDeps: argv['use-tags'] ?? useTags ?? false,
178+
author: `${username} <${email}>`
179+
};
180+
181+
console.log(`\nSlicing plan: ${sourcePlan}`);
182+
console.log(`Output directory: ${outputDir}`);
183+
if (config.strategy.type === 'folder') {
184+
console.log(`Strategy: folder-based (depth=${config.strategy.depth})`);
185+
} else {
186+
console.log(`Strategy: pattern-based (${patternSlices.length} slice definitions)`);
187+
}
188+
console.log('');
189+
190+
// Perform slicing
191+
const result = slicePlan(config);
192+
193+
// Handle dry run
194+
if (argv['dry-run'] || argv.dryRun) {
195+
const report = generateDryRunReport(result);
196+
console.log(report);
197+
prompter.close();
198+
return argv;
199+
}
200+
201+
// Show summary before writing
202+
console.log(`Found ${result.stats.totalChanges} changes`);
203+
console.log(`Creating ${result.stats.packagesCreated} packages`);
204+
console.log(`Cross-package dependency ratio: ${(result.stats.crossPackageRatio * 100).toFixed(1)}%`);
205+
console.log('');
206+
207+
// Show warnings
208+
if (result.warnings.length > 0) {
209+
console.log('Warnings:');
210+
for (const warning of result.warnings) {
211+
console.log(` [${warning.type}] ${warning.message}`);
212+
}
213+
console.log('');
214+
}
215+
216+
// Show deploy order
217+
console.log('Deploy order:');
218+
for (let i = 0; i < result.workspace.deployOrder.length; i++) {
219+
const pkg = result.workspace.deployOrder[i];
220+
const deps = result.workspace.dependencies[pkg] || [];
221+
const depStr = deps.length > 0 ? ` -> ${deps.join(', ')}` : '';
222+
console.log(` ${i + 1}. ${pkg}${depStr}`);
223+
}
224+
console.log('');
225+
226+
// Confirm before writing (unless --overwrite is specified)
227+
if (!argv.overwrite) {
228+
const confirmResult = await prompter.prompt({} as Record<string, unknown>, [
229+
{
230+
type: 'confirm',
231+
name: 'confirm',
232+
message: 'Proceed with writing packages?',
233+
default: true
234+
}
235+
]) as { confirm: boolean };
236+
237+
if (!confirmResult.confirm) {
238+
console.log('Aborted.');
239+
prompter.close();
240+
return argv;
241+
}
242+
}
243+
244+
// Determine source directory for copying files
245+
let sourceDir: string | undefined;
246+
if (argv['copy-files'] || argv.copyFiles) {
247+
if (project.isInModule()) {
248+
sourceDir = project.getModulePath();
249+
} else {
250+
// Use directory containing the plan file
251+
sourceDir = resolve(sourcePlan, '..');
252+
}
253+
}
254+
255+
// Write packages to disk
256+
writeSliceResult(result, {
257+
outputDir,
258+
overwrite: argv.overwrite ?? false,
259+
copySourceFiles: argv['copy-files'] ?? argv.copyFiles ?? false,
260+
sourceDir
261+
});
262+
263+
prompter.close();
264+
265+
console.log(`
266+
|||
267+
(o o)
268+
ooO--(_)--Ooo-
269+
270+
Sliced into ${result.stats.packagesCreated} packages!
271+
272+
Output: ${outputDir}
273+
`);
274+
275+
return argv;
276+
};

pgpm/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export { default as plan } from './commands/plan';
2424
export { default as remove } from './commands/remove';
2525
export { default as renameCmd } from './commands/rename';
2626
export { default as revert } from './commands/revert';
27+
export { default as slice } from './commands/slice';
2728
export { default as tag } from './commands/tag';
2829
export { default as testPackages } from './commands/test-packages';
2930
export { default as verify } from './commands/verify';

0 commit comments

Comments
 (0)