Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .bumpy/fix-add-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': patch
---

Fix `bumpy add` interactive prompt: distinguish skip vs none, respect existing bump files, fix prompt rendering, remove cascade prompt
101 changes: 27 additions & 74 deletions packages/bumpy/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import { log } from '../utils/logger.ts';
import { p, unwrap } from '../utils/clack.ts';
import { ensureDir, exists } from '../utils/fs.ts';
import { randomName, slugify } from '../utils/names.ts';
import { writeBumpFile } from '../core/bump-file.ts';
import { writeBumpFile, readBumpFiles, filterBranchBumpFiles } from '../core/bump-file.ts';
import picomatch from 'picomatch';
import { getBumpyDir, loadConfig, loadPackageConfig, matchGlob } from '../core/config.ts';
import { getBumpyDir, loadConfig, loadPackageConfig } from '../core/config.ts';
import { discoverPackages, discoverWorkspace } from '../core/workspace.ts';
import { findChangedPackages } from './check.ts';
import { DependencyGraph } from '../core/dep-graph.ts';
import { getChangedFiles } from '../core/git.ts';
import { bumpSelectPrompt } from '../prompts/bump-select.ts';
import type { BumpSelectItem } from '../prompts/bump-select.ts';
import type { BumpType, BumpTypeWithNone, BumpFileRelease, BumpFileReleaseCascade } from '../types.ts';
import type { BumpSelectItem, BumpLevel } from '../prompts/bump-select.ts';
import type { BumpTypeWithNone, BumpFileRelease } from '../types.ts';

interface AddOptions {
packages?: string; // "pkg-a:minor,pkg-b:patch"
Expand All @@ -23,12 +22,6 @@ interface AddOptions {
none?: boolean;
}

const CASCADE_CHOICES: { label: string; value: BumpType }[] = [
{ label: 'patch', value: 'patch' },
{ label: 'minor', value: 'minor' },
{ label: 'major', value: 'major' },
];

export async function addCommand(rootDir: string, opts: AddOptions): Promise<void> {
const config = await loadConfig(rootDir);
const bumpyDir = getBumpyDir(rootDir);
Expand Down Expand Up @@ -86,8 +79,6 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
p.intro(pc.bgCyan(pc.black(' bumpy add ')));

const pkgs = await discoverPackages(rootDir, config);
const depGraph = new DependencyGraph(pkgs);

if (pkgs.size === 0) {
p.cancel('No managed packages found in this workspace.');
process.exit(1);
Expand Down Expand Up @@ -117,12 +108,29 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
}
}

// Load existing bump files on this branch to avoid re-defaulting already-covered packages
const { bumpFiles: allBumpFiles } = await readBumpFiles(rootDir);
const { branchBumpFiles } = filterBranchBumpFiles(allBumpFiles, changedFiles, rootDir);
const alreadyCoveredPackages = new Map<string, BumpLevel>();
for (const bf of branchBumpFiles) {
for (const release of bf.releases) {
alreadyCoveredPackages.set(release.name, release.type === 'none' ? 'none' : release.type);
}
}

// Build items for the bump select prompt
const bumpSelectItems: BumpSelectItem[] = [...pkgs.values()].map((pkg) => ({
name: pkg.name,
version: pkg.version,
changed: changedPackageNames.has(pkg.name),
}));
const bumpSelectItems: BumpSelectItem[] = [...pkgs.values()].map((pkg) => {
const item: BumpSelectItem = {
name: pkg.name,
version: pkg.version,
changed: changedPackageNames.has(pkg.name),
};
// If already covered by an existing bump file, default to skip
if (alreadyCoveredPackages.has(pkg.name)) {
item.initialLevel = 'skip';
}
return item;
});

const bumpSelectResult = await bumpSelectPrompt(bumpSelectItems);
if (typeof bumpSelectResult === 'symbol') {
Expand All @@ -136,62 +144,7 @@ export async function addCommand(rootDir: string, opts: AddOptions): Promise<voi
process.exit(0);
}

releases = [];
for (const { name, type: bumpType } of bumpSelections) {
const release: BumpFileRelease = { name, type: bumpType };

// Offer cascade options if the package has dependents
{
const dependents = depGraph.getDependents(name);
const pkg = pkgs.get(name)!;
const cascadeTargets = pkg.bumpy?.cascadeTo;

if (dependents.length > 0 || cascadeTargets) {
const wantCascade = unwrap(
await p.confirm({
message: `${pc.cyan(name)} has ${pc.bold(String(dependents.length))} dependents. Specify explicit cascades?`,
initialValue: false,
}),
);

if (wantCascade) {
const allTargets = new Set<string>();
for (const d of dependents) allTargets.add(d.name);
if (cascadeTargets) {
for (const pattern of Object.keys(cascadeTargets)) {
for (const [pName] of pkgs) {
if (matchGlob(pName, pattern)) allTargets.add(pName);
}
}
}

const cascadeSelected = unwrap(
await p.multiselect<string>({
message: 'Which packages should cascade?',
options: [...allTargets].map((n) => ({ label: n, value: n })),
required: false,
}),
);

if (cascadeSelected.length > 0) {
const cascadeBump = unwrap(
await p.select<BumpType>({
message: 'Cascade bump type',
options: CASCADE_CHOICES,
}),
);
const cascade: Record<string, BumpType> = {};
for (const target of cascadeSelected) {
cascade[target] = cascadeBump;
}
(release as BumpFileReleaseCascade).cascade = cascade;
}
}
}
}

releases.push(release);
}
releases = bumpSelections.map(({ name, type }) => ({ name, type }) as BumpFileRelease);

summary = unwrap(
await p.text({
Expand Down
49 changes: 30 additions & 19 deletions packages/bumpy/src/prompts/bump-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import * as readline from 'node:readline';
import pc from 'picocolors';
import type { BumpTypeWithNone } from '../types.ts';

export type BumpLevel = BumpTypeWithNone | 'none';
/** 'skip' = not included in bump file at all, 'none' = explicitly included with type none */
export type BumpLevel = 'skip' | BumpTypeWithNone;

const LEVELS: BumpLevel[] = ['none', 'patch', 'minor', 'major'];
const LEVELS: BumpLevel[] = ['skip', 'none', 'patch', 'minor', 'major'];

export interface BumpSelectItem {
name: string;
version: string;
changed: boolean;
/** Pre-set level (e.g. from an existing bump file on the branch) */
initialLevel?: BumpLevel;
}

export interface BumpSelectResult {
Expand All @@ -33,7 +36,9 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel

// State
let cursor = 0;
const levels: BumpLevel[] = items.map((item) => (item.changed ? 'patch' : 'none'));
const levels: BumpLevel[] = items.map((item) =>
item.initialLevel !== undefined ? item.initialLevel : item.changed ? 'patch' : 'skip',
);

return new Promise<BumpSelectResult[] | symbol>((resolve) => {
const { stdin, stdout } = process;
Expand All @@ -55,9 +60,9 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel

if (final) {
lines.push(`${pc.green('◇')} Bump levels selected`);
const selected = displayOrder.filter(({ idx }) => levels[idx] !== 'none');
const selected = displayOrder.filter(({ idx }) => levels[idx] !== 'skip');
if (selected.length === 0) {
lines.push(`${pc.dim('│')} ${pc.dim('(none)')}`);
lines.push(`${pc.dim('│')} ${pc.dim('(none selected)')}`);
} else {
for (const { item, idx } of selected) {
lines.push(`${pc.dim('│')} ${pc.cyan(item.name)} ${pc.dim('→')} ${pc.bold(levels[idx])}`);
Expand All @@ -67,7 +72,7 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
} else {
lines.push(`${pc.cyan('◆')} Select bump levels`);
lines.push(`${pc.dim('│')} ${pc.dim('↑/↓ navigate · ←/→ change level · enter to confirm')}`);
lines.push(`${pc.dim('│')} ${pc.dim('0 clear current · x clear all · r reset all to defaults')}`);
lines.push(`${pc.dim('│')} ${pc.dim('0 skip current · x skip all · r reset all to defaults')}`);
lines.push(pc.dim('│'));

let displayIdx = 0;
Expand All @@ -92,7 +97,7 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
}

lines.push(pc.dim('│'));
const selectedCount = levels.filter((l) => l !== 'none').length;
const selectedCount = levels.filter((l) => l !== 'skip').length;
lines.push(`${pc.dim('│')} ${pc.dim(`${selectedCount} package${selectedCount !== 1 ? 's' : ''} selected`)}`);
lines.push(`${pc.dim('└')}`);
}
Expand All @@ -103,6 +108,7 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
}

function cleanup() {
stdin.removeListener('keypress', onKeypress);
rl.close();
stdout.write('\x1B[?25h'); // Show cursor
if (stdin.isTTY) stdin.setRawMode(false);
Expand All @@ -114,12 +120,15 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
resolve(result);
}

// Enable keypress events before setting up listeners
readline.emitKeypressEvents(stdin, rl);

if (stdin.isTTY) stdin.setRawMode(true);
stdin.resume();

render();

stdin.on('keypress', (_str: string | undefined, key: readline.Key) => {
function onKeypress(_str: string | undefined, key: readline.Key) {
if (!key) return;

if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
Expand All @@ -138,7 +147,7 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
if (key.name === 'return') {
const results: BumpSelectResult[] = [];
for (let i = 0; i < items.length; i++) {
if (levels[i] !== 'none') {
if (levels[i] !== 'skip') {
results.push({ name: items[i]!.name, type: levels[i] as BumpTypeWithNone });
}
}
Expand All @@ -163,26 +172,26 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
levels[entry.idx] = LEVELS[currentLevel - 1]!;
}
} else if (_str === '0' || key.name === 'backspace') {
// Set current item to none
// Set current item to skip (not included)
const entry = displayOrder[cursor]!;
levels[entry.idx] = 'none';
levels[entry.idx] = 'skip';
} else if (_str === 'r') {
// Reset all to defaults (changed=patch, unchanged=none)
// Reset all to defaults
for (let i = 0; i < items.length; i++) {
levels[i] = items[i]!.changed ? 'patch' : 'none';
levels[i] =
items[i]!.initialLevel !== undefined ? items[i]!.initialLevel! : items[i]!.changed ? 'patch' : 'skip';
}
} else if (_str === 'x') {
// Clear all — set everything to none
// Clear all — set everything to skip
for (let i = 0; i < items.length; i++) {
levels[i] = 'none';
levels[i] = 'skip';
}
}

render();
});
}

// Enable keypress events
readline.emitKeypressEvents(stdin, rl);
stdin.on('keypress', onKeypress);
});
}

Expand All @@ -198,7 +207,8 @@ function formatRow(item: BumpSelectItem, level: BumpLevel, focused: boolean): st

function formatLevel(level: BumpLevel, focused: boolean): string {
if (!focused) {
if (level === 'none') return pc.dim('·');
if (level === 'skip') return pc.dim('·');
if (level === 'none') return pc.dim('none');
if (level === 'major') return pc.red(level);
if (level === 'minor') return pc.yellow(level);
return pc.green(level);
Expand All @@ -207,6 +217,7 @@ function formatLevel(level: BumpLevel, focused: boolean): string {
// Show the level selector when focused
const parts = LEVELS.map((l) => {
if (l === level) {
if (l === 'skip') return pc.bold(pc.dim('[skip]'));
if (l === 'none') return pc.bold(pc.dim('[none]'));
if (l === 'major') return pc.bold(pc.red(`[${l}]`));
if (l === 'minor') return pc.bold(pc.yellow(`[${l}]`));
Expand Down