diff --git a/README.md b/README.md index 8d43758..d966715 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,15 @@ For other platforms and more installation options, visit: https://cli.github.com # Commands +## Output Modes + +Ghouls supports two output modes to suit different use cases: + +- **Default mode**: Shows essential information only - final results, summaries, and errors. Ideal for scripts and everyday use. +- **Verbose mode** (`-v` or `--verbose`): Shows detailed progress information including scanning progress, branch analysis details, and progress bars. Useful for debugging or understanding what the tool is doing. + +All commands support both output modes. Use the verbose flag when you need more insight into the cleanup process. + ## Delete remote branches Safely deletes remote branches that have been merged via pull requests. @@ -104,6 +113,13 @@ Run from within a git repository (auto-detects repo): ghouls remote --dry-run ``` +For detailed output including progress information, use the verbose flag: +```bash +ghouls remote --dry-run --verbose +# or +ghouls remote --dry-run -v +``` + The auto-detection feature works with both github.com and GitHub Enterprise repositories, automatically detecting the repository owner/name from the remote URL. Or specify a repository explicitly: @@ -111,6 +127,10 @@ Or specify a repository explicitly: ghouls remote --dry-run myorg/myrepo ``` +### Interactive Mode + +When running without `--dry-run` or `--force` flags, the remote command runs in interactive mode, allowing you to review and confirm each branch deletion. Press **Escape** at any time during the interactive selection to cancel the operation and return to the command prompt without making any changes. + ``` $ ghouls remote myorg/myrepo #1871 - Deleting remote: heads/fix/fe-nits @@ -129,11 +149,22 @@ Run from within a git repository (auto-detects repo): ghouls local --dry-run ``` +For detailed output including progress information, use the verbose flag: +```bash +ghouls local --dry-run --verbose +# or +ghouls local --dry-run -v +``` + Or specify a repository explicitly: ```bash ghouls local --dry-run myorg/myrepo ``` +### Interactive Mode + +When running without `--dry-run` or `--force` flags, the local command runs in interactive mode, allowing you to review and confirm each branch deletion. Press **Escape** at any time during the interactive selection to cancel the operation and return to the command prompt without making any changes. + ### Safety Features The `local` command includes several safety checks to prevent accidental deletion of important branches: @@ -184,11 +215,22 @@ Run from within a git repository (auto-detects repo): ghouls all --dry-run ``` +For detailed output including progress information, use the verbose flag: +```bash +ghouls all --dry-run --verbose +# or +ghouls all --dry-run -v +``` + Or specify a repository explicitly: ```bash ghouls all --dry-run myorg/myrepo ``` +### Interactive Mode + +When running without `--dry-run` or `--force` flags, the all command runs in interactive mode for both remote and local cleanup phases. Press **Escape** at any time during either interactive selection to cancel the current operation and return to the command prompt without making any changes. + ### Execution Order The command executes in two phases: diff --git a/src/commands/PruneAll.ts b/src/commands/PruneAll.ts index 8f57173..2861516 100644 --- a/src/commands/PruneAll.ts +++ b/src/commands/PruneAll.ts @@ -2,9 +2,22 @@ import type { CommandModule } from "yargs"; import { createOctokitPlus } from "../utils/createOctokitPlus.js"; import { getGitRemote } from "../utils/getGitRemote.js"; import { isGitRepository } from "../utils/localGitOperations.js"; +import { + output, + outputSection, + outputSuccess, + outputError, + outputWarning +} from "../utils/outputFormatter.js"; export const pruneAllCommand: CommandModule = { handler: async (args: any) => { + // Set output options based on CLI flags + const { setOutputOptions } = await import("../utils/outputFormatter.js"); + setOutputOptions({ + verbose: Boolean(args.verbose) + }); + let owner: string; let repo: string; @@ -31,7 +44,7 @@ export const pruneAllCommand: CommandModule = { // but the actual API calls are made within the individual command handlers createOctokitPlus(); - console.log("šŸš€ Starting combined branch cleanup...\n"); + output("šŸš€ Starting combined branch cleanup..."); let remoteSuccess = false; let localSuccess = false; @@ -39,7 +52,7 @@ export const pruneAllCommand: CommandModule = { let localError: Error | undefined; // Phase 1: Remote branch pruning - console.log("=== Phase 1: Remote Branch Cleanup ==="); + outputSection("Phase 1: Remote Branch Cleanup"); try { // Import dynamically to avoid circular dependencies const { prunePullRequestsCommand } = await import("./PrunePullRequests.js"); @@ -50,11 +63,11 @@ export const pruneAllCommand: CommandModule = { remoteSuccess = true; } catch (error) { remoteError = error instanceof Error ? error : new Error(String(error)); - console.error(`\nāŒ Remote cleanup failed: ${remoteError.message}`); + outputError(`āŒ Remote cleanup failed: ${remoteError.message}`); } // Phase 2: Local branch pruning (continue even if remote failed) - console.log("\n=== Phase 2: Local Branch Cleanup ==="); + outputSection("Phase 2: Local Branch Cleanup"); try { // Import dynamically to avoid circular dependencies const { pruneLocalBranchesCommand } = await import("./PruneLocalBranches.js"); @@ -65,24 +78,24 @@ export const pruneAllCommand: CommandModule = { localSuccess = true; } catch (error) { localError = error instanceof Error ? error : new Error(String(error)); - console.error(`\nāŒ Local cleanup failed: ${localError.message}`); + outputError(`āŒ Local cleanup failed: ${localError.message}`); } // Final summary - console.log("\n=== Combined Cleanup Summary ==="); - console.log(`Remote cleanup: ${remoteSuccess ? "āœ… Success" : "āŒ Failed"}`); - console.log(`Local cleanup: ${localSuccess ? "āœ… Success" : "āŒ Failed"}`); + outputSection("Combined Cleanup Summary"); + output(`Remote cleanup: ${remoteSuccess ? "āœ… Success" : "āŒ Failed"}`); + output(`Local cleanup: ${localSuccess ? "āœ… Success" : "āŒ Failed"}`); // Exit with error code if both operations failed if (!remoteSuccess && !localSuccess) { - console.error("\nāŒ Both cleanup operations failed!"); + outputError("āŒ Both cleanup operations failed!"); process.exit(1); } else if (!remoteSuccess || !localSuccess) { // Partial success - console.log("\nāš ļø Cleanup completed with some errors."); + outputWarning("Cleanup completed with some errors."); process.exit(0); } else { - console.log("\nāœ… All cleanup operations completed successfully!"); + outputSuccess("All cleanup operations completed successfully!"); } }, command: "all [--dry-run] [--force] [repo]", @@ -98,6 +111,12 @@ export const pruneAllCommand: CommandModule = { type: "boolean", description: "Skip interactive mode and delete all safe branches automatically" }) + .option("verbose", { + alias: "v", + type: "boolean", + description: "Show detailed output including progress information", + default: false + }) .positional("repo", { type: "string", coerce: (s: string | undefined) => { diff --git a/src/commands/PruneLocalBranches.test.ts b/src/commands/PruneLocalBranches.test.ts index e11dfd4..89200d7 100644 --- a/src/commands/PruneLocalBranches.test.ts +++ b/src/commands/PruneLocalBranches.test.ts @@ -338,10 +338,10 @@ describe('PruneLocalBranches', () => { const asyncGenerator = (async function* () {})(); (mockOctokitPlus.getPullRequests as any).mockImplementation(() => asyncGenerator); - await pruneLocalBranchesCommand.handler!({ dryRun: false, _: [], $0: 'ghouls' }); + await pruneLocalBranchesCommand.handler!({ dryRun: false, verbose: true, _: [], $0: 'ghouls' }); - expect(consoleLogSpy).toHaveBeenCalledWith('\nNo branches are safe to delete.'); - expect(consoleLogSpy).toHaveBeenCalledWith('\nSkipping unsafe branches:'); + expect(consoleLogSpy).toHaveBeenCalledWith('No local branches are safe to delete.'); + expect(consoleLogSpy).toHaveBeenCalledWith('\nSkipping 2 unsafe branches:'); expect(consoleLogSpy).toHaveBeenCalledWith(' - main (current branch)'); expect(consoleLogSpy).toHaveBeenCalledWith(' - develop (protected branch)'); }); @@ -373,8 +373,8 @@ describe('PruneLocalBranches', () => { expect(mockedDeleteLocalBranch).toHaveBeenCalledWith('feature-1'); expect(mockedDeleteLocalBranch).toHaveBeenCalledWith('feature-2'); - expect(consoleLogSpy).toHaveBeenCalledWith('Deleted: feature-1 (#1)'); - expect(consoleLogSpy).toHaveBeenCalledWith('Deleted: feature-2 (#2)'); + expect(consoleLogSpy).toHaveBeenCalledWith(' Deleted: feature-1 (#1)'); + expect(consoleLogSpy).toHaveBeenCalledWith(' Deleted: feature-2 (#2)'); }); it('should simulate deletion in dry-run mode', async () => { @@ -398,8 +398,8 @@ describe('PruneLocalBranches', () => { await pruneLocalBranchesCommand.handler!({ dryRun: true, _: [], $0: 'ghouls' }); expect(mockedDeleteLocalBranch).not.toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith('[DRY RUN] Would delete: feature-1 (#1)'); - expect(consoleLogSpy).toHaveBeenCalledWith(' Would delete: 1 branch'); + expect(consoleLogSpy).toHaveBeenCalledWith(' [DRY RUN] Would delete: feature-1 (#1)'); + expect(consoleLogSpy).toHaveBeenCalledWith(' Would delete: 1 local branch'); }); it('should handle branches without matching PRs', async () => { @@ -419,7 +419,7 @@ describe('PruneLocalBranches', () => { await pruneLocalBranchesCommand.handler!({ dryRun: true, _: [], $0: 'ghouls' }); - expect(consoleLogSpy).toHaveBeenCalledWith('[DRY RUN] Would delete: feature-no-pr (no PR)'); + expect(consoleLogSpy).toHaveBeenCalledWith(' [DRY RUN] Would delete: feature-no-pr (no PR)'); }); it('should handle deletion errors', async () => { diff --git a/src/commands/PruneLocalBranches.ts b/src/commands/PruneLocalBranches.ts index 1c32b0f..39c7a01 100644 --- a/src/commands/PruneLocalBranches.ts +++ b/src/commands/PruneLocalBranches.ts @@ -10,10 +10,23 @@ import { isGitRepository } from "../utils/localGitOperations.js"; import { filterSafeBranches } from "../utils/branchSafetyChecks.js"; -import inquirer from "inquirer"; +import { promptWithCancel } from "../utils/promptWithCancel.js"; +import { + output, + verboseOutput, + outputSummary, + outputError, + isVerbose +} from "../utils/outputFormatter.js"; export const pruneLocalBranchesCommand: CommandModule = { handler: async (args: any) => { + // Set output options based on CLI flags + const { setOutputOptions } = await import("../utils/outputFormatter.js"); + setOutputOptions({ + verbose: Boolean(args.verbose) + }); + let owner: string; let repo: string; @@ -59,6 +72,12 @@ export const pruneLocalBranchesCommand: CommandModule = { type: "boolean", description: "Skip interactive mode and delete all safe branches automatically" }) + .option("verbose", { + alias: "v", + type: "boolean", + description: "Show detailed output including progress information", + default: false + }) .positional("repo", { type: "string", coerce: (s: string | undefined) => { @@ -99,43 +118,41 @@ class PruneLocalBranches { ) {} public async perform() { - console.log(`\nScanning for local branches that can be safely deleted...`); + verboseOutput("Scanning for local branches that can be safely deleted..."); // Get all local branches const localBranches = getLocalBranches(); const currentBranch = getCurrentBranch(); - console.log(`Found ${localBranches.length} local branches`); + verboseOutput(`Found ${localBranches.length} local branches`); if (localBranches.length === 0) { - console.log("No local branches found."); + output("No local branches found."); return; } // Get merged PRs from GitHub - console.log("Fetching merged pull requests from GitHub..."); + verboseOutput("Fetching merged pull requests from GitHub..."); const mergedPRs = await this.getMergedPRsMap(); - console.log(`Found ${mergedPRs.size} merged pull requests`); + verboseOutput(`Found ${mergedPRs.size} merged pull requests`); // Filter branches for safety const branchAnalysis = filterSafeBranches(localBranches, currentBranch, mergedPRs); const safeBranches = branchAnalysis.filter(analysis => analysis.safetyCheck.safe); const unsafeBranches = branchAnalysis.filter(analysis => !analysis.safetyCheck.safe); - console.log(`\nBranch Analysis:`); - console.log(` Safe to delete: ${safeBranches.length}`); - console.log(` Unsafe to delete: ${unsafeBranches.length}`); + output(`Found ${safeBranches.length} local branch${safeBranches.length === 1 ? '' : 'es'} that can be safely deleted.`); - // Show unsafe branches and reasons + // Show unsafe branches and reasons in verbose mode if (unsafeBranches.length > 0) { - console.log(`\nSkipping unsafe branches:`); + verboseOutput(`\nSkipping ${unsafeBranches.length} unsafe branches:`); for (const { branch, safetyCheck } of unsafeBranches) { - console.log(` - ${branch.name} (${safetyCheck.reason})`); + verboseOutput(` - ${branch.name} (${safetyCheck.reason})`); } } if (safeBranches.length === 0) { - console.log("\nNo branches are safe to delete."); + output("No local branches are safe to delete."); return; } @@ -154,7 +171,7 @@ class PruneLocalBranches { }; }); - const { selectedBranches } = await inquirer.prompt([ + const result = await promptWithCancel<{ selectedBranches: string[] }>([ { type: 'checkbox', name: 'selectedBranches', @@ -164,24 +181,28 @@ class PruneLocalBranches { } ]); - if (selectedBranches.length === 0) { - console.log("\nNo branches selected for deletion."); + if (result === null) { + output("Operation cancelled by user"); + return; + } + + if (result.selectedBranches.length === 0) { + output("No branches selected for deletion."); return; } branchesToDelete = safeBranches.filter(({ branch }) => - selectedBranches.includes(branch.name) + result.selectedBranches.includes(branch.name) ); } // Show what will be deleted - console.log(`\n${this.dryRun ? 'Would delete' : 'Deleting'} ${branchesToDelete.length} branch${branchesToDelete.length === 1 ? '' : 'es'}:`); + output(`${this.dryRun ? 'Would delete' : 'Deleting'} ${branchesToDelete.length} local branch${branchesToDelete.length === 1 ? '' : 'es'}:`); - // Use progress bar only if we have a TTY, otherwise use simple logging - const isTTY = process.stderr.isTTY; + // Only show progress bar in verbose mode and if we have a TTY let bar: ProgressBar | null = null; - if (isTTY) { + if (isVerbose() && process.stderr.isTTY) { bar = new ProgressBar(":bar :branch (:current/:total)", { total: branchesToDelete.length, width: 30, @@ -205,7 +226,7 @@ class PruneLocalBranches { if (bar) { bar.interrupt(message); } else { - console.log(message); + output(` ${message}`); } } else { deleteLocalBranch(branch.name); @@ -213,7 +234,7 @@ class PruneLocalBranches { if (bar) { bar.interrupt(message); } else { - console.log(message); + output(` ${message}`); } } deletedCount++; @@ -222,7 +243,7 @@ class PruneLocalBranches { if (bar) { bar.interrupt(message); } else { - console.log(message); + outputError(` ${message}`); } errorCount++; } @@ -234,18 +255,20 @@ class PruneLocalBranches { } // Summary - console.log(`\nSummary:`); + const summaryItems: string[] = []; if (this.dryRun) { - console.log(` Would delete: ${deletedCount} branch${deletedCount === 1 ? '' : 'es'}`); + summaryItems.push(`Would delete: ${deletedCount} local branch${deletedCount === 1 ? '' : 'es'}`); } else { - console.log(` Successfully deleted: ${deletedCount} branch${deletedCount === 1 ? '' : 'es'}`); + summaryItems.push(`Successfully deleted: ${deletedCount} local branch${deletedCount === 1 ? '' : 'es'}`); } if (errorCount > 0) { - console.log(` Errors: ${errorCount}`); + summaryItems.push(`Errors: ${errorCount}`); } - console.log(` Skipped (unsafe): ${unsafeBranches.length}`); + summaryItems.push(`Skipped (unsafe): ${unsafeBranches.length}`); + + outputSummary(summaryItems); } private async getMergedPRsMap(): Promise> { diff --git a/src/commands/PrunePullRequests.ts b/src/commands/PrunePullRequests.ts index becc394..b783f52 100644 --- a/src/commands/PrunePullRequests.ts +++ b/src/commands/PrunePullRequests.ts @@ -4,10 +4,23 @@ import ProgressBar from "progress"; import { PullRequest, OctokitPlus } from "../OctokitPlus.js"; import { ownerAndRepoMatch } from "../utils/ownerAndRepoMatch.js"; import { getGitRemote } from "../utils/getGitRemote.js"; -import inquirer from "inquirer"; +import { promptWithCancel } from "../utils/promptWithCancel.js"; +import { + output, + verboseOutput, + outputSummary, + outputError, + isVerbose +} from "../utils/outputFormatter.js"; export const prunePullRequestsCommand: CommandModule = { handler: async (args: any) => { + // Set output options based on CLI flags + const { setOutputOptions } = await import("../utils/outputFormatter.js"); + setOutputOptions({ + verbose: Boolean(args.verbose) + }); + let owner: string; let repo: string; @@ -48,6 +61,12 @@ export const prunePullRequestsCommand: CommandModule = { type: "boolean", description: "Skip interactive mode and delete all merged branches automatically" }) + .option("verbose", { + alias: "v", + type: "boolean", + description: "Show detailed output including progress information", + default: false + }) .positional("repo", { type: "string", coerce: (s: string | undefined) => { @@ -93,17 +112,17 @@ class PrunePullRequest { ) {} public async perform() { - console.log("\nScanning for remote branches that can be safely deleted..."); + verboseOutput("Scanning for remote branches that can be safely deleted..."); // First collect all branches that can be deleted const branchesToDelete = await this.collectDeletableBranches(); if (branchesToDelete.length === 0) { - console.log("\nNo branches found that can be safely deleted."); + output("No remote branches found that can be safely deleted."); return; } - console.log(`Found ${branchesToDelete.length} branches that can be deleted.`); + output(`Found ${branchesToDelete.length} remote branch${branchesToDelete.length === 1 ? '' : 'es'} that can be deleted.`); // Get branches to delete based on mode let selectedBranches = branchesToDelete; @@ -119,7 +138,7 @@ class PrunePullRequest { }; }); - const { selected } = await inquirer.prompt([ + const result = await promptWithCancel<{ selected: string[] }>([ { type: 'checkbox', name: 'selected', @@ -129,58 +148,88 @@ class PrunePullRequest { } ]); - if (selected.length === 0) { - console.log("\nNo branches selected for deletion."); + if (result === null) { + output("Operation cancelled by user"); + return; + } + + if (result.selected.length === 0) { + output("No branches selected for deletion."); return; } selectedBranches = branchesToDelete.filter(({ ref }) => - selected.includes(ref) + result.selected.includes(ref) ); } // Delete selected branches - console.log(`\n${this.dryRun ? 'Would delete' : 'Deleting'} ${selectedBranches.length} branch${selectedBranches.length === 1 ? '' : 'es'}:`); + output(`${this.dryRun ? 'Would delete' : 'Deleting'} ${selectedBranches.length} remote branch${selectedBranches.length === 1 ? '' : 'es'}:`); - const bar = new ProgressBar(":bar :branch (:current/:total)", { - total: selectedBranches.length, - width: 30 - }); + // Only show progress bar in verbose mode + let bar: ProgressBar | null = null; + if (isVerbose()) { + bar = new ProgressBar(":bar :branch (:current/:total)", { + total: selectedBranches.length, + width: 30 + }); + } let deletedCount = 0; let errorCount = 0; for (const { ref, pr } of selectedBranches) { - bar.update(deletedCount + errorCount, { branch: `${ref} (#${pr.number})` }); + if (bar) { + bar.update(deletedCount + errorCount, { branch: `${ref} (#${pr.number})` }); + } try { if (this.dryRun) { - bar.interrupt(`[DRY RUN] Would delete: ${ref} (PR #${pr.number})`); + const message = `[DRY RUN] Would delete: ${ref} (PR #${pr.number})`; + if (bar) { + bar.interrupt(message); + } else { + output(` ${message}`); + } } else { await this.octokitPlus.deleteReference(pr.head); - bar.interrupt(`Deleted: ${ref} (PR #${pr.number})`); + const message = `Deleted: ${ref} (PR #${pr.number})`; + if (bar) { + bar.interrupt(message); + } else { + output(` ${message}`); + } } deletedCount++; } catch (error) { - bar.interrupt(`Error deleting ${ref}: ${error instanceof Error ? error.message : String(error)}`); + const message = `Error deleting ${ref}: ${error instanceof Error ? error.message : String(error)}`; + if (bar) { + bar.interrupt(message); + } else { + outputError(` ${message}`); + } errorCount++; } } - bar.update(selectedBranches.length, { branch: "" }); - bar.terminate(); + if (bar) { + bar.update(selectedBranches.length, { branch: "" }); + bar.terminate(); + } // Summary - console.log(`\nSummary:`); + const summaryItems: string[] = []; if (this.dryRun) { - console.log(` Would delete: ${deletedCount} branch${deletedCount === 1 ? '' : 'es'}`); + summaryItems.push(`Would delete: ${deletedCount} remote branch${deletedCount === 1 ? '' : 'es'}`); } else { - console.log(` Successfully deleted: ${deletedCount} branch${deletedCount === 1 ? '' : 'es'}`); + summaryItems.push(`Successfully deleted: ${deletedCount} remote branch${deletedCount === 1 ? '' : 'es'}`); } if (errorCount > 0) { - console.log(` Errors: ${errorCount}`); + summaryItems.push(`Errors: ${errorCount}`); } + + outputSummary(summaryItems); } private async collectDeletableBranches(): Promise { diff --git a/src/utils/outputFormatter.test.ts b/src/utils/outputFormatter.test.ts new file mode 100644 index 0000000..45f2c9c --- /dev/null +++ b/src/utils/outputFormatter.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + setOutputOptions, + getOutputOptions, + isVerbose, + output, + verboseOutput, + outputSection, + outputSummary, + verboseProgress, + outputError, + outputWarning, + outputSuccess +} from './outputFormatter.js'; + +// Mock console methods +const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); +const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + +describe('outputFormatter', () => { + beforeEach(() => { + // Reset output options to default before each test + setOutputOptions({ verbose: false }); + mockConsoleLog.mockClear(); + mockConsoleError.mockClear(); + }); + + describe('setOutputOptions and getOutputOptions', () => { + it('should set and get output options correctly', () => { + const options = { verbose: true }; + setOutputOptions(options); + expect(getOutputOptions()).toEqual(options); + }); + + it('should default to non-verbose mode', () => { + expect(getOutputOptions()).toEqual({ verbose: false }); + }); + }); + + describe('isVerbose', () => { + it('should return false by default', () => { + expect(isVerbose()).toBe(false); + }); + + it('should return true when verbose is enabled', () => { + setOutputOptions({ verbose: true }); + expect(isVerbose()).toBe(true); + }); + }); + + describe('output', () => { + it('should always output messages regardless of verbose mode', () => { + output('test message'); + expect(mockConsoleLog).toHaveBeenCalledWith('test message'); + + mockConsoleLog.mockClear(); + setOutputOptions({ verbose: true }); + output('verbose message'); + expect(mockConsoleLog).toHaveBeenCalledWith('verbose message'); + }); + }); + + describe('verboseOutput', () => { + it('should not output in non-verbose mode', () => { + verboseOutput('verbose message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + it('should output in verbose mode', () => { + setOutputOptions({ verbose: true }); + verboseOutput('verbose message'); + expect(mockConsoleLog).toHaveBeenCalledWith('verbose message'); + }); + }); + + describe('outputSection', () => { + it('should format section headers correctly', () => { + outputSection('Test Section'); + expect(mockConsoleLog).toHaveBeenCalledWith('\n=== Test Section ==='); + }); + + it('should always output sections regardless of verbose mode', () => { + outputSection('Section 1'); + expect(mockConsoleLog).toHaveBeenCalledWith('\n=== Section 1 ==='); + + mockConsoleLog.mockClear(); + setOutputOptions({ verbose: true }); + outputSection('Section 2'); + expect(mockConsoleLog).toHaveBeenCalledWith('\n=== Section 2 ==='); + }); + }); + + describe('outputSummary', () => { + it('should format summary items correctly', () => { + const items = ['Item 1', 'Item 2', 'Item 3']; + outputSummary(items); + + expect(mockConsoleLog).toHaveBeenCalledWith('\nSummary:'); + expect(mockConsoleLog).toHaveBeenCalledWith(' Item 1'); + expect(mockConsoleLog).toHaveBeenCalledWith(' Item 2'); + expect(mockConsoleLog).toHaveBeenCalledWith(' Item 3'); + }); + + it('should handle empty summary', () => { + outputSummary([]); + expect(mockConsoleLog).toHaveBeenCalledWith('\nSummary:'); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + }); + }); + + describe('verboseProgress', () => { + it('should not output in non-verbose mode', () => { + verboseProgress('progress message'); + expect(mockConsoleLog).not.toHaveBeenCalled(); + }); + + it('should output in verbose mode', () => { + setOutputOptions({ verbose: true }); + verboseProgress('progress message'); + expect(mockConsoleLog).toHaveBeenCalledWith('progress message'); + }); + }); + + describe('outputError', () => { + it('should output errors to stderr', () => { + outputError('error message'); + expect(mockConsoleError).toHaveBeenCalledWith('error message'); + }); + + it('should always output errors regardless of verbose mode', () => { + outputError('error 1'); + expect(mockConsoleError).toHaveBeenCalledWith('error 1'); + + mockConsoleError.mockClear(); + setOutputOptions({ verbose: true }); + outputError('error 2'); + expect(mockConsoleError).toHaveBeenCalledWith('error 2'); + }); + }); + + describe('outputWarning', () => { + it('should format warnings with emoji', () => { + outputWarning('warning message'); + expect(mockConsoleLog).toHaveBeenCalledWith('āš ļø warning message'); + }); + + it('should always output warnings regardless of verbose mode', () => { + outputWarning('warning 1'); + expect(mockConsoleLog).toHaveBeenCalledWith('āš ļø warning 1'); + + mockConsoleLog.mockClear(); + setOutputOptions({ verbose: true }); + outputWarning('warning 2'); + expect(mockConsoleLog).toHaveBeenCalledWith('āš ļø warning 2'); + }); + }); + + describe('outputSuccess', () => { + it('should format success messages with emoji', () => { + outputSuccess('success message'); + expect(mockConsoleLog).toHaveBeenCalledWith('āœ… success message'); + }); + + it('should always output success messages regardless of verbose mode', () => { + outputSuccess('success 1'); + expect(mockConsoleLog).toHaveBeenCalledWith('āœ… success 1'); + + mockConsoleLog.mockClear(); + setOutputOptions({ verbose: true }); + outputSuccess('success 2'); + expect(mockConsoleLog).toHaveBeenCalledWith('āœ… success 2'); + }); + }); + + describe('integration tests', () => { + it('should handle verbose and non-verbose outputs correctly in mixed scenarios', () => { + // Non-verbose mode + output('always shown'); + verboseOutput('hidden'); + outputError('error shown'); + + expect(mockConsoleLog).toHaveBeenCalledWith('always shown'); + expect(mockConsoleLog).not.toHaveBeenCalledWith('hidden'); + expect(mockConsoleError).toHaveBeenCalledWith('error shown'); + + // Switch to verbose mode + mockConsoleLog.mockClear(); + mockConsoleError.mockClear(); + setOutputOptions({ verbose: true }); + + output('still shown'); + verboseOutput('now shown'); + outputError('error still shown'); + + expect(mockConsoleLog).toHaveBeenCalledWith('still shown'); + expect(mockConsoleLog).toHaveBeenCalledWith('now shown'); + expect(mockConsoleError).toHaveBeenCalledWith('error still shown'); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/outputFormatter.ts b/src/utils/outputFormatter.ts new file mode 100644 index 0000000..8e40105 --- /dev/null +++ b/src/utils/outputFormatter.ts @@ -0,0 +1,93 @@ +/** + * Centralized output formatting utility for CLI commands. + * Manages verbose vs. quiet output modes and provides consistent formatting. + */ + +export interface OutputOptions { + verbose: boolean; +} + +let globalOutputOptions: OutputOptions = { verbose: false }; + +export function setOutputOptions(options: OutputOptions): void { + globalOutputOptions = options; +} + +export function getOutputOptions(): OutputOptions { + return globalOutputOptions; +} + +export function isVerbose(): boolean { + return globalOutputOptions.verbose; +} + +/** + * Always outputs the message regardless of verbose mode. + * Use for critical information, errors, and final summaries. + */ +export function output(message: string): void { + console.log(message); +} + +/** + * Only outputs in verbose mode. + * Use for detailed progress information, debugging details, and intermediate steps. + */ +export function verboseOutput(message: string): void { + if (globalOutputOptions.verbose) { + console.log(message); + } +} + +/** + * Outputs a section header with consistent formatting. + * Always shown regardless of verbose mode. + */ +export function outputSection(title: string): void { + console.log(`\n=== ${title} ===`); +} + +/** + * Outputs a summary with consistent formatting. + * Always shown regardless of verbose mode. + */ +export function outputSummary(items: string[]): void { + console.log('\nSummary:'); + for (const item of items) { + console.log(` ${item}`); + } +} + +/** + * Outputs progress information. + * Only shown in verbose mode. + */ +export function verboseProgress(message: string): void { + if (globalOutputOptions.verbose) { + console.log(message); + } +} + +/** + * Outputs error messages. + * Always shown regardless of verbose mode. + */ +export function outputError(message: string): void { + console.error(message); +} + +/** + * Outputs warning messages. + * Always shown regardless of verbose mode. + */ +export function outputWarning(message: string): void { + console.log(`āš ļø ${message}`); +} + +/** + * Outputs success messages. + * Always shown regardless of verbose mode. + */ +export function outputSuccess(message: string): void { + console.log(`āœ… ${message}`); +} \ No newline at end of file diff --git a/src/utils/promptWithCancel.test.ts b/src/utils/promptWithCancel.test.ts new file mode 100644 index 0000000..470042c --- /dev/null +++ b/src/utils/promptWithCancel.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import inquirer from 'inquirer'; +import readline from 'readline'; +import { promptWithCancel } from './promptWithCancel.js'; + +// Mock modules +vi.mock('inquirer'); +vi.mock('readline'); + +describe('promptWithCancel', () => { + let mockedInquirer: any; + let mockedReadline: any; + let mockStdin: any; + let mockStdout: any; + let keypressListener: ((str: string, key: any) => void) | null = null; + + beforeEach(() => { + mockedInquirer = vi.mocked(inquirer); + mockedReadline = vi.mocked(readline); + + // Mock stdin/stdout + mockStdin = { + isTTY: true, + isRaw: false, + setRawMode: vi.fn(), + on: vi.fn((event, handler) => { + if (event === 'keypress') { + keypressListener = handler; + } + }), + removeListener: vi.fn() + }; + + mockStdout = { + write: vi.fn() + }; + + // Replace process.stdin and process.stdout + vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as any); + vi.spyOn(process, 'stdout', 'get').mockReturnValue(mockStdout as any); + + // Mock readline.emitKeypressEvents + mockedReadline.emitKeypressEvents = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + keypressListener = null; + }); + + it('should return prompt answers when completed normally', async () => { + const mockAnswers = { selectedBranches: ['branch1', 'branch2'] }; + + mockedInquirer.prompt.mockResolvedValue(mockAnswers); + + const result = await promptWithCancel([ + { + type: 'checkbox', + name: 'selectedBranches', + message: 'Select branches:', + choices: ['branch1', 'branch2', 'branch3'] + } + ]); + + expect(result).toEqual(mockAnswers); + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(1); + expect(mockStdin.setRawMode).toHaveBeenCalledWith(true); + expect(mockStdin.removeListener).toHaveBeenCalledWith('keypress', keypressListener); + }); + + it('should return null when escape key is pressed', async () => { + // Setup promise that will never resolve (simulating ongoing prompt) + const promptPromise = new Promise(() => {}); + mockedInquirer.prompt.mockReturnValue(promptPromise); + + // Start the prompt + const resultPromise = promptWithCancel([ + { + type: 'checkbox', + name: 'selectedBranches', + message: 'Select branches:', + choices: ['branch1', 'branch2'] + } + ]); + + // Simulate escape key press after a small delay + await new Promise(resolve => setTimeout(resolve, 10)); + if (keypressListener) { + keypressListener('', { name: 'escape' }); + } + + const result = await resultPromise; + + expect(result).toBeNull(); + expect(mockStdout.write).toHaveBeenCalledWith('\n'); + expect(mockStdin.removeListener).toHaveBeenCalledWith('keypress', keypressListener); + }); + + it('should handle non-TTY environments', async () => { + mockStdin.isTTY = false; + const mockAnswers = { selectedBranches: ['branch1'] }; + + mockedInquirer.prompt.mockResolvedValue(mockAnswers); + + const result = await promptWithCancel([ + { + type: 'checkbox', + name: 'selectedBranches', + message: 'Select branches:', + choices: ['branch1'] + } + ]); + + expect(result).toEqual(mockAnswers); + expect(mockStdin.setRawMode).not.toHaveBeenCalled(); + }); + + it('should handle prompt errors gracefully', async () => { + const mockError = new Error('Some prompt error'); + mockedInquirer.prompt.mockRejectedValue(mockError); + + await expect(promptWithCancel([ + { + type: 'input', + name: 'test', + message: 'Test:' + } + ])).rejects.toThrow('Some prompt error'); + + expect(mockStdin.removeListener).toHaveBeenCalledWith('keypress', keypressListener); + }); + + it('should exit process on Ctrl+C (ExitPromptError)', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any); + + const ctrlCError = new Error('User force closed'); + ctrlCError.name = 'ExitPromptError'; + mockedInquirer.prompt.mockRejectedValue(ctrlCError); + + // Since process.exit is called, the promise won't resolve normally + // We just need to verify the side effects + promptWithCancel([ + { + type: 'input', + name: 'test', + message: 'Test:' + } + ]).catch(() => { + // Expected to throw since we mock process.exit + }); + + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockExit).toHaveBeenCalledWith(0); + expect(mockStdout.write).toHaveBeenCalledWith('\n'); + + mockExit.mockRestore(); + }); + + it('should not process escape key after prompt is resolved', async () => { + const mockAnswers = { test: 'value' }; + let resolvePrompt: ((value: any) => void) | null = null; + + // Create a controlled promise + const promptPromise = new Promise((resolve) => { + resolvePrompt = resolve; + }); + + mockedInquirer.prompt.mockReturnValue(promptPromise); + + const resultPromise = promptWithCancel([ + { + type: 'input', + name: 'test', + message: 'Test:' + } + ]); + + // Resolve the prompt first + if (resolvePrompt) { + (resolvePrompt as any)(mockAnswers); + } + + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + // Try to press escape after prompt resolved + if (keypressListener) { + keypressListener('', { name: 'escape' }); + } + + const result = await resultPromise; + + // Should return the answers, not null + expect(result).toEqual(mockAnswers); + expect(mockStdout.write).not.toHaveBeenCalled(); // No newline written for escape + }); + + it('should restore original raw mode state', async () => { + mockStdin.isRaw = true; // Start with raw mode enabled + const mockAnswers = { test: 'value' }; + + mockedInquirer.prompt.mockResolvedValue(mockAnswers); + + await promptWithCancel([ + { + type: 'input', + name: 'test', + message: 'Test:' + } + ]); + + // Should restore to original state (true) + expect(mockStdin.setRawMode).toHaveBeenCalledWith(true); + }); +}); \ No newline at end of file diff --git a/src/utils/promptWithCancel.ts b/src/utils/promptWithCancel.ts new file mode 100644 index 0000000..2702633 --- /dev/null +++ b/src/utils/promptWithCancel.ts @@ -0,0 +1,67 @@ +import inquirer from 'inquirer'; +import readline from 'readline'; + +/** + * Wrapper for inquirer prompts that adds escape key cancellation support. + * Returns null if the user presses escape, otherwise returns the prompt answers. + */ +export async function promptWithCancel( + questions: any +): Promise { + let cleanupFunction: (() => void) | undefined; + let promptResolved = false; + + const result = await new Promise((resolve, reject) => { + // Setup escape key handling + const originalRawMode = process.stdin.isRaw; + readline.emitKeypressEvents(process.stdin); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + + const keypressHandler = (_str: string, key: any) => { + if (key && key.name === 'escape' && !promptResolved) { + promptResolved = true; + // Emit newline to clean up the terminal display + process.stdout.write('\n'); + resolve(null); // Return null to indicate cancellation + } + }; + + process.stdin.on('keypress', keypressHandler); + + cleanupFunction = () => { + process.stdin.removeListener('keypress', keypressHandler); + if (process.stdin.isTTY && originalRawMode !== process.stdin.isRaw) { + process.stdin.setRawMode(originalRawMode); + } + }; + + // Start the actual prompt + inquirer.prompt(questions) + .then((answers) => { + if (!promptResolved) { + promptResolved = true; + resolve(answers as T); + } + }) + .catch((error) => { + if (!promptResolved) { + promptResolved = true; + // Handle Ctrl+C gracefully + if (error.name === 'ExitPromptError' || error.message?.includes('User force closed')) { + process.stdout.write('\n'); + process.exit(0); + } + reject(error); + } + }); + }).finally(() => { + if (cleanupFunction) { + cleanupFunction(); + } + }); + + return result; +} \ No newline at end of file