Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 9 additions & 4 deletions src/commands/PruneLocalBranches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -154,7 +154,7 @@ class PruneLocalBranches {
};
});

const { selectedBranches } = await inquirer.prompt([
const result = await promptWithCancel<{ selectedBranches: string[] }>([
{
type: 'checkbox',
name: 'selectedBranches',
Expand All @@ -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)
);
}

Expand Down
13 changes: 9 additions & 4 deletions src/commands/PrunePullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -119,7 +119,7 @@ class PrunePullRequest {
};
});

const { selected } = await inquirer.prompt([
const result = await promptWithCancel<{ selected: string[] }>([
{
type: 'checkbox',
name: 'selected',
Expand All @@ -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)
);
}

Expand Down
217 changes: 217 additions & 0 deletions src/utils/promptWithCancel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 67 additions & 0 deletions src/utils/promptWithCancel.ts
Original file line number Diff line number Diff line change
@@ -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<T = any>(
questions: any
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont uses any. fix this to be proper types.

): Promise<T | null> {
let cleanupFunction: (() => void) | undefined;
let promptResolved = false;

const result = await new Promise<T | null>((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;
}