Skip to content

Commit c156670

Browse files
CopilotswissspidyCopilot
authored
Add restart command and --watch option to wp shell (#77)
* Initial plan * Add restart command to shell REPL Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Add --watch option for automatic shell restart on file changes Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Add exit command and tests Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Fix PHPCS alignment and PHPStan issues Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * Fix alignment * Fix alignment * Update src/WP_CLI/Shell/REPL.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Implement process restart using pcntl_exec to fully reload code Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * PHPStan fixes * Simplify script path detection and use runner config for preserved args Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> * PHPStan fix * Fix restart check running after semicolon append Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Pascal Birchler <pascalb@google.com>
1 parent cce3e64 commit c156670

4 files changed

Lines changed: 279 additions & 5 deletions

File tree

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
}
1313
],
1414
"require": {
15-
"wp-cli/wp-cli": "^2.12"
15+
"wp-cli/wp-cli": "^2.13"
1616
},
1717
"require-dev": {
1818
"wp-cli/wp-cli-tests": "^5"

features/shell.feature

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,43 @@ Feature: WordPress REPL
6161
"""
6262
And STDERR should be empty
6363
64+
Scenario: Restart shell
65+
Given a WP install
66+
And a session file:
67+
"""
68+
$a = 1;
69+
restart
70+
$b = 2;
71+
"""
72+
73+
When I run `wp shell --basic < session`
74+
Then STDOUT should contain:
75+
"""
76+
Restarting shell...
77+
"""
78+
And STDOUT should contain:
79+
"""
80+
=> int(2)
81+
"""
82+
83+
Scenario: Exit shell
84+
Given a WP install
85+
And a session file:
86+
"""
87+
$a = 1;
88+
exit
89+
"""
90+
91+
When I run `wp shell --basic < session`
92+
Then STDOUT should contain:
93+
"""
94+
=> int(1)
95+
"""
96+
And STDOUT should not contain:
97+
"""
98+
exit
99+
"""
100+
64101
Scenario: Use SHELL environment variable as fallback for bash
65102
Given a WP install
66103

src/Shell_Command.php

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,21 @@ class Shell_Command extends WP_CLI_Command {
1313
* is loaded, you have access to all the functions, classes and globals
1414
* that you can use within a WordPress plugin, for example.
1515
*
16+
* The `restart` command reloads the shell by spawning a new PHP process,
17+
* allowing modified code to be fully reloaded. Note that this requires
18+
* the `pcntl_exec()` function. If not available, the shell restarts
19+
* in-process, which resets variables but doesn't reload PHP files.
20+
*
1621
* ## OPTIONS
1722
*
1823
* [--basic]
1924
* : Force the use of WP-CLI's built-in PHP REPL, even if the Boris or
2025
* PsySH PHP REPLs are available.
2126
*
27+
* [--watch=<path>]
28+
* : Watch a file or directory for changes and automatically restart the shell.
29+
* Only works with the built-in REPL (--basic).
30+
*
2231
* [--hook=<hook>]
2332
* : Ensure that a specific WordPress action hook has fired before starting the shell.
2433
* This validates that the preconditions associated with that hook are met.
@@ -34,10 +43,32 @@ class Shell_Command extends WP_CLI_Command {
3443
* wp> get_bloginfo( 'name' );
3544
* => string(6) "WP-CLI"
3645
*
46+
* # Restart the shell to reload code changes.
47+
* $ wp shell
48+
* wp> restart
49+
* Restarting shell in new process...
50+
* wp>
51+
*
52+
* # Watch a directory for changes and auto-restart.
53+
* $ wp shell --watch=wp-content/plugins/my-plugin
54+
* wp> // Make changes to files in the plugin directory
55+
* Detected changes in wp-content/plugins/my-plugin, restarting shell...
56+
* wp>
57+
*
3758
* # Start a shell, ensuring the 'init' hook has already fired.
3859
* $ wp shell --hook=init
60+
*
61+
* @param string[] $_ Positional arguments. Unused.
62+
* @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
3963
*/
4064
public function __invoke( $_, $assoc_args ) {
65+
$watch_path = Utils\get_flag_value( $assoc_args, 'watch', false );
66+
67+
if ( $watch_path && ! Utils\get_flag_value( $assoc_args, 'basic' ) ) {
68+
WP_CLI::warning( 'The --watch option only works with the built-in REPL. Enabling --basic mode.' );
69+
$assoc_args['basic'] = true;
70+
}
71+
4172
$hook = Utils\get_flag_value( $assoc_args, 'hook', '' );
4273

4374
// No hook specified, start immediately.
@@ -67,9 +98,16 @@ public function __invoke( $_, $assoc_args ) {
6798
/**
6899
* Start the shell REPL.
69100
*
70-
* @param array<string,bool|string> $assoc_args Associative arguments.
101+
* @param array{basic?: bool, watch?: string} $assoc_args Associative arguments.
71102
*/
72103
private function start_shell( $assoc_args ) {
104+
$watch_path = Utils\get_flag_value( $assoc_args, 'watch', '' );
105+
106+
if ( $watch_path && ! Utils\get_flag_value( $assoc_args, 'basic' ) ) {
107+
WP_CLI::warning( 'The --watch option only works with the built-in REPL. Enabling --basic mode.' );
108+
$assoc_args['basic'] = true;
109+
}
110+
73111
$class = WP_CLI\Shell\REPL::class;
74112

75113
$implementations = array(
@@ -98,8 +136,116 @@ private function start_shell( $assoc_args ) {
98136
/**
99137
* @var class-string<WP_CLI\Shell\REPL> $class
100138
*/
101-
$repl = new $class( 'wp> ' );
102-
$repl->start();
139+
if ( $watch_path ) {
140+
$watch_path = $this->resolve_watch_path( $watch_path );
141+
}
142+
143+
do {
144+
$repl = new $class( 'wp> ' );
145+
if ( $watch_path ) {
146+
$repl->set_watch_path( $watch_path );
147+
}
148+
$exit_code = $repl->start();
149+
150+
// If restart requested, exec a new PHP process to reload all code
151+
if ( WP_CLI\Shell\REPL::EXIT_CODE_RESTART === $exit_code ) {
152+
$this->restart_process( $assoc_args );
153+
// If restart_process() returns, pcntl_exec is not available, continue in-process
154+
}
155+
} while ( WP_CLI\Shell\REPL::EXIT_CODE_RESTART === $exit_code );
156+
}
157+
}
158+
159+
/**
160+
* Resolve and validate the watch path.
161+
*
162+
* @param string $path Path to watch.
163+
* @return string|never Absolute path to watch.
164+
*/
165+
private function resolve_watch_path( $path ) {
166+
if ( ! file_exists( $path ) ) {
167+
WP_CLI::error( "Watch path does not exist: {$path}" );
168+
}
169+
170+
$realpath = realpath( $path );
171+
if ( false === $realpath ) {
172+
WP_CLI::error( "Could not resolve watch path: {$path}" );
173+
}
174+
175+
return $realpath;
176+
}
177+
178+
/**
179+
* Restart the shell by spawning a new PHP process.
180+
*
181+
* This replaces the current process with a new one to fully reload all code.
182+
* Falls back to in-process restart if pcntl_exec is not available.
183+
*
184+
* @param array{basic?: bool, watch?: string} $assoc_args Command arguments to preserve.
185+
*/
186+
private function restart_process( $assoc_args ) {
187+
/**
188+
* @var array{0?: string} $argv
189+
*/
190+
global $argv;
191+
192+
// Check if pcntl_exec is available
193+
if ( ! function_exists( 'pcntl_exec' ) ) {
194+
WP_CLI::debug( 'pcntl_exec not available, falling back to in-process restart', 'shell' );
195+
return;
103196
}
197+
198+
// Build the command to restart wp shell with the same arguments
199+
$php_binary = Utils\get_php_binary();
200+
201+
/**
202+
* @var array{argv: array{0?: string}} $_SERVER
203+
*/
204+
205+
// Get the WP-CLI script path
206+
$wp_cli_script = null;
207+
if ( isset( $argv[0] ) ) {
208+
$wp_cli_script = $argv[0];
209+
} elseif ( isset( $_SERVER['argv'][0] ) ) {
210+
$wp_cli_script = $_SERVER['argv'][0];
211+
}
212+
213+
if ( ! $wp_cli_script ) {
214+
WP_CLI::debug( 'Could not determine WP-CLI script path, falling back to in-process restart', 'shell' );
215+
return;
216+
}
217+
218+
// Build arguments array
219+
$args = array( $php_binary, $wp_cli_script, 'shell' );
220+
221+
if ( Utils\get_flag_value( $assoc_args, 'basic' ) ) {
222+
$args[] = '--basic';
223+
}
224+
225+
$watch_path = Utils\get_flag_value( $assoc_args, 'watch', false );
226+
if ( $watch_path ) {
227+
$args[] = '--watch=' . $watch_path;
228+
}
229+
230+
// Add global config values to preserve the environment after restart
231+
$config = WP_CLI::get_runner()->config;
232+
if ( isset( $config['path'] ) ) {
233+
$args[] = '--path=' . $config['path'];
234+
}
235+
if ( isset( $config['user'] ) ) {
236+
$args[] = '--user=' . $config['user'];
237+
}
238+
if ( isset( $config['url'] ) ) {
239+
$args[] = '--url=' . $config['url'];
240+
}
241+
242+
WP_CLI::log( 'Restarting shell in new process...' );
243+
244+
// Replace the current process with a new one
245+
// Note: pcntl_exec does not return on success
246+
pcntl_exec( $php_binary, array_slice( $args, 1 ) );
247+
248+
// If we reach here, exec failed
249+
WP_CLI::warning( 'Failed to restart process, falling back to in-process restart' );
104250
}
105251
}

src/WP_CLI/Shell/REPL.php

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,52 @@ class REPL {
1010

1111
private $history_file;
1212

13+
private $watch_path;
14+
15+
private $watch_mtime;
16+
17+
const EXIT_CODE_RESTART = 10;
18+
1319
public function __construct( $prompt ) {
1420
$this->prompt = $prompt;
1521

1622
$this->set_history_file();
1723
}
1824

25+
/**
26+
* Set a path to watch for changes.
27+
*
28+
* @param string $path Path to watch for changes.
29+
*/
30+
public function set_watch_path( $path ) {
31+
$this->watch_path = $path;
32+
$this->watch_mtime = $this->get_recursive_mtime( $path );
33+
}
34+
1935
public function start() {
20-
// @phpstan-ignore while.alwaysTrue
2136
while ( true ) {
37+
// Check for file changes if watching
38+
if ( $this->watch_path && $this->has_changes() ) {
39+
WP_CLI::log( "Detected changes in {$this->watch_path}, restarting shell..." );
40+
return self::EXIT_CODE_RESTART;
41+
}
2242
$__repl_input_line = $this->prompt();
2343

2444
if ( '' === $__repl_input_line ) {
2545
continue;
2646
}
2747

48+
// Check for special exit command
49+
if ( 'exit' === trim( $__repl_input_line ) ) {
50+
return 0;
51+
}
52+
53+
// Check for special restart command
54+
if ( 'restart' === trim( $__repl_input_line ) ) {
55+
WP_CLI::log( 'Restarting shell...' );
56+
return self::EXIT_CODE_RESTART;
57+
}
58+
2859
$__repl_input_line = rtrim( $__repl_input_line, ';' ) . ';';
2960

3061
if ( self::starts_with( self::non_expressions(), $__repl_input_line ) ) {
@@ -213,4 +244,64 @@ private function set_history_file() {
213244
private static function starts_with( $tokens, $line ) {
214245
return preg_match( "/^($tokens)[\(\s]+/", $line );
215246
}
247+
248+
/**
249+
* Check if the watched path has changes.
250+
*
251+
* @return bool True if changes detected, false otherwise.
252+
*/
253+
private function has_changes() {
254+
if ( ! $this->watch_path ) {
255+
return false;
256+
}
257+
258+
$current_mtime = $this->get_recursive_mtime( $this->watch_path );
259+
return $current_mtime !== $this->watch_mtime;
260+
}
261+
262+
/**
263+
* Get the most recent modification time for a path recursively.
264+
*
265+
* @param string $path Path to check.
266+
* @return int Most recent modification time.
267+
*/
268+
private function get_recursive_mtime( $path ) {
269+
$mtime = 0;
270+
271+
if ( is_file( $path ) ) {
272+
$file_mtime = filemtime( $path );
273+
return false !== $file_mtime ? $file_mtime : 0;
274+
}
275+
276+
if ( is_dir( $path ) ) {
277+
$dir_mtime = filemtime( $path );
278+
$mtime = false !== $dir_mtime ? $dir_mtime : 0;
279+
280+
try {
281+
$iterator = new \RecursiveIteratorIterator(
282+
new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ),
283+
\RecursiveIteratorIterator::SELF_FIRST
284+
);
285+
286+
foreach ( $iterator as $file ) {
287+
/** @var \SplFileInfo $file */
288+
$file_mtime = $file->getMTime();
289+
if ( $file_mtime > $mtime ) {
290+
$mtime = $file_mtime;
291+
}
292+
}
293+
} catch ( \UnexpectedValueException $e ) {
294+
// Handle unreadable directories/files gracefully.
295+
WP_CLI::warning(
296+
sprintf(
297+
'Could not read path "%s" while checking for changes: %s',
298+
$path,
299+
$e->getMessage()
300+
)
301+
);
302+
}
303+
}
304+
305+
return $mtime;
306+
}
216307
}

0 commit comments

Comments
 (0)