Skip to content

Commit c11b4b5

Browse files
fix: handle script terminations using e.g. ctrl + c (#127)
1 parent 94d3514 commit c11b4b5

File tree

3 files changed

+113
-34
lines changed

3 files changed

+113
-34
lines changed

cli.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ vi.mock('meow', () => ({
1919
}))
2020
}))
2121

22+
// Ensure process.exit is mocked to prevent accidental exits during CLI import
23+
let exitSpy
24+
25+
beforeEach(() => {
26+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
27+
throw new Error(`process.exit unexpectedly called with "${code}"`)
28+
})
29+
})
30+
31+
afterEach(() => {
32+
exitSpy.mockRestore()
33+
})
34+
2235
describe('CLI', () => {
2336
beforeEach(() => {
2437
vi.resetModules()

index.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import chalk from 'chalk'
33
import open from 'open'
44
import branch from 'git-branch'
55
import GitUrlParse from 'git-url-parse'
6-
import { exec, which } from 'shelljs'
6+
import shell from 'shelljs'
7+
8+
const { exec, which } = shell
79
const { exit } = process
810
const { log } = console
911

@@ -65,6 +67,26 @@ async function gitPushPR(options) {
6567

6668
const child = exec(gitPushStr, { async: true, silent: true })
6769

70+
// Accumulate stderr so we can parse a PR URL for unknown git hosts
71+
let stderrBuffer = ''
72+
73+
// Gracefully handle user aborts (e.g., Ctrl+C)
74+
let aborted = false
75+
const handleAbort = () => {
76+
if (aborted) return
77+
aborted = true
78+
try {
79+
if (child && typeof child.kill === 'function') {
80+
child.kill('SIGINT')
81+
}
82+
} catch {
83+
// Best-effort kill; ignore errors here
84+
}
85+
}
86+
process.once('SIGINT', handleAbort)
87+
process.once('SIGTERM', handleAbort)
88+
process.once('SIGHUP', handleAbort)
89+
6890
// Stream stdout in real-time
6991
child.stdout.on('data', (data) => {
7092
if (!options.silent && spinner) {
@@ -76,6 +98,7 @@ async function gitPushPR(options) {
7698

7799
// Stream stderr in real-time
78100
child.stderr.on('data', (data) => {
101+
stderrBuffer += data.toString()
79102
if (!options.silent && spinner) {
80103
spinner.clear()
81104
log(data.toString().trimEnd())
@@ -84,6 +107,22 @@ async function gitPushPR(options) {
84107
})
85108

86109
child.on('exit', async (code) => {
110+
// Clean up listeners on any exit
111+
process.off('SIGINT', handleAbort)
112+
process.off('SIGTERM', handleAbort)
113+
process.off('SIGHUP', handleAbort)
114+
115+
// If user aborted, show a friendly message and exit
116+
if (aborted) {
117+
if (!options.silent && spinner) {
118+
spinner.fail(chalk.yellow('[git-push-pr]: aborted by user'))
119+
} else {
120+
log(chalk.yellow('[git-push-pr]: aborted by user'))
121+
}
122+
// Do not forcefully exit; allow Node to exit naturally after cleanup
123+
return
124+
}
125+
87126
// 6. Stop if git push failed for some reason
88127
if (code !== 0) {
89128
if (!options.silent && spinner) {
@@ -108,7 +147,7 @@ async function gitPushPR(options) {
108147

109148
// 9. Create a Pull Request
110149
const { stdout: remoteUrl } = exec(`git remote get-url ${options.remote}`, { silent: true })
111-
const pullRequestUrl = getPullRequestUrl(remoteUrl, currentBranch, child.stderr || '')
150+
const pullRequestUrl = getPullRequestUrl(remoteUrl, currentBranch, stderrBuffer)
112151
await open(pullRequestUrl)
113152

114153
// 10. Mark PR creation as successful

index.test.js

Lines changed: 59 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,43 +17,54 @@ vi.mock('open', () => ({
1717
}))
1818

1919
vi.mock('shelljs', () => ({
20-
exec: vi.fn().mockImplementation((cmd, options, callback) => {
21-
if (cmd.includes('git remote get-url')) {
22-
return { stdout: 'https://github.com/user/repo.git' }
23-
}
20+
default: {
21+
exec: vi.fn().mockImplementation((cmd, options, callback) => {
22+
if (cmd.includes('git remote get-url')) {
23+
return { stdout: 'https://github.com/user/repo.git' }
24+
}
2425

25-
// Handle async exec for git push
26-
if (options && options.async) {
27-
const mockChild = {
28-
stdout: {
26+
// Handle async exec for git push
27+
if (options && options.async) {
28+
// Create a mock child process that can be killed and emits exit afterwards
29+
let killed = false
30+
const listeners = { exit: [] }
31+
const mockChild = {
32+
stdout: {
33+
on: vi.fn((event, handler) => {
34+
if (event === 'data') {
35+
setTimeout(() => handler(Buffer.from('Everything up-to-date\n')), 10)
36+
}
37+
})
38+
},
39+
stderr: {
40+
on: vi.fn((event, handler) => {
41+
// no stderr by default
42+
})
43+
},
2944
on: vi.fn((event, handler) => {
30-
if (event === 'data') {
31-
// Simulate some stdout data
32-
setTimeout(() => handler(Buffer.from('Everything up-to-date\n')), 10)
45+
if (event === 'exit') {
46+
listeners.exit.push(handler)
47+
// Simulate successful exit after a short delay unless killed
48+
setTimeout(() => {
49+
const code = killed ? 1 : 0
50+
listeners.exit.forEach((cb) => cb(code))
51+
}, 20)
3352
}
53+
}),
54+
kill: vi.fn(() => {
55+
killed = true
3456
})
35-
},
36-
stderr: {
37-
on: vi.fn((event, handler) => {
38-
// No stderr by default
39-
})
40-
},
41-
on: vi.fn((event, handler) => {
42-
if (event === 'exit') {
43-
// Simulate successful exit
44-
setTimeout(() => handler(0), 20)
45-
}
46-
})
57+
}
58+
return mockChild
4759
}
48-
return mockChild
49-
}
5060

51-
if (callback) {
52-
callback(0, 'success', '')
53-
}
54-
return { code: 0, stdout: 'success', stderr: '' }
55-
}),
56-
which: vi.fn().mockReturnValue(true)
61+
if (callback) {
62+
callback(0, 'success', '')
63+
}
64+
return { code: 0, stdout: 'success', stderr: '' }
65+
}),
66+
which: vi.fn().mockReturnValue(true)
67+
}
5768
}))
5869

5970
vi.mock('git-branch', () => ({
@@ -66,7 +77,8 @@ console.log = vi.fn()
6677
import ora from 'ora'
6778
import open from 'open'
6879
import gitPushPR, { getPullRequestUrl } from './index.js'
69-
import { exec, which } from 'shelljs'
80+
import shell from 'shelljs'
81+
const { exec, which } = shell
7082
import branch from 'git-branch'
7183

7284
let exitSpy
@@ -161,6 +173,21 @@ describe('gitPushPR', () => {
161173
await promise
162174
})
163175

176+
it('should gracefully abort on SIGINT (Ctrl+C)', async () => {
177+
// Start the process
178+
const promise = gitPushPR({ remote: 'origin' })
179+
180+
// Emit SIGINT shortly after to simulate Ctrl+C
181+
setTimeout(() => process.emit('SIGINT'), 5)
182+
183+
// Wait to allow the mocked child to exit and our handler to run
184+
await new Promise((resolve) => setTimeout(resolve, 200))
185+
186+
// Since we no longer call process.exit on abort, just assert no PR was opened and spinner failed path was taken
187+
expect(open).not.toHaveBeenCalled()
188+
await promise
189+
})
190+
164191
const expectExitWithError = async (fn) => {
165192
let error
166193
try {

0 commit comments

Comments
 (0)