diff --git a/README.md b/README.md index 8d43758..0756882 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,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 @@ -134,6 +138,10 @@ Or specify a repository explicitly: 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: @@ -189,6 +197,10 @@ Or specify a repository explicitly: 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/PruneLocalBranches.ts b/src/commands/PruneLocalBranches.ts index 1c32b0f..99e06d1 100644 --- a/src/commands/PruneLocalBranches.ts +++ b/src/commands/PruneLocalBranches.ts @@ -10,7 +10,7 @@ import { isGitRepository } from "../utils/localGitOperations.js"; import { filterSafeBranches } from "../utils/branchSafetyChecks.js"; -import inquirer from "inquirer"; +import { promptWithCancel } from "../utils/promptWithCancel.js"; export const pruneLocalBranchesCommand: CommandModule = { handler: async (args: any) => { @@ -154,7 +154,7 @@ class PruneLocalBranches { }; }); - const { selectedBranches } = await inquirer.prompt([ + const result = await promptWithCancel<{ selectedBranches: string[] }>([ { type: 'checkbox', name: 'selectedBranches', @@ -164,13 +164,18 @@ class PruneLocalBranches { } ]); - if (selectedBranches.length === 0) { + if (result === null) { + console.log("\nOperation cancelled by user"); + return; + } + + if (result.selectedBranches.length === 0) { console.log("\nNo branches selected for deletion."); return; } branchesToDelete = safeBranches.filter(({ branch }) => - selectedBranches.includes(branch.name) + result.selectedBranches.includes(branch.name) ); } diff --git a/src/commands/PrunePullRequests.ts b/src/commands/PrunePullRequests.ts index becc394..3e197be 100644 --- a/src/commands/PrunePullRequests.ts +++ b/src/commands/PrunePullRequests.ts @@ -4,7 +4,7 @@ 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"; export const prunePullRequestsCommand: CommandModule = { handler: async (args: any) => { @@ -119,7 +119,7 @@ class PrunePullRequest { }; }); - const { selected } = await inquirer.prompt([ + const result = await promptWithCancel<{ selected: string[] }>([ { type: 'checkbox', name: 'selected', @@ -129,13 +129,18 @@ class PrunePullRequest { } ]); - if (selected.length === 0) { + if (result === null) { + console.log("\nOperation cancelled by user"); + return; + } + + if (result.selected.length === 0) { console.log("\nNo branches selected for deletion."); return; } selectedBranches = branchesToDelete.filter(({ ref }) => - selected.includes(ref) + result.selected.includes(ref) ); } 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