3535 edac_write_line ( 'Usage: php tools/update-since-tags.php --version=<x.y.z> [options] ' );
3636 edac_write_line ( '' );
3737 edac_write_line ( 'Options: ' );
38- edac_write_line ( ' --root=<path> Root directory to scan (default: current working directory) ' );
38+ edac_write_line ( ' --root=<path> Root directory to scan (default: parent of the tools/ directory) ' );
3939 edac_write_line ( ' --placeholder=<value> Placeholder token after @since (default: x.x.x) ' );
4040 edac_write_line ( ' --changed-since-tag=<tag> Only scan tracked PHP files changed since this Git tag/ref ' );
4141 edac_write_line ( ' --changed-since-last-tag Only scan tracked PHP files changed since latest Git tag ' );
5151 exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
5252}
5353
54- $ root = $ opts ['root ' ] ?? dirname ( __DIR__ );
55- if ( ! is_string ( $ root ) || ! is_dir ( $ root ) ) {
56- edac_write_line ( 'Error: root directory does not exist: ' . (string ) $ root , STDERR );
54+ // getopt() returns false (not null) for optional params given without a value (e.g. --root).
55+ $ root_opt = $ opts ['root ' ] ?? dirname ( __DIR__ );
56+ if ( false === $ root_opt ) {
57+ edac_write_line ( 'Error: --root requires a value. ' , STDERR );
58+ exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
59+ }
60+ if ( ! is_string ( $ root_opt ) || ! is_dir ( $ root_opt ) ) {
61+ edac_write_line ( 'Error: root directory does not exist: ' . (string ) $ root_opt , STDERR );
62+ exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
63+ }
64+ // Use realpath() so filesystem-root paths like / or C:\ are preserved correctly.
65+ $ root = realpath ( $ root_opt );
66+ if ( false === $ root ) {
67+ edac_write_line ( 'Error: could not resolve root directory: ' . $ root_opt , STDERR );
5768 exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
5869}
59- $ root = rtrim ( (string ) $ root , DIRECTORY_SEPARATOR );
6070
61- $ placeholder = $ opts ['placeholder ' ] ?? 'x.x.x ' ;
62- if ( ! is_string ( $ placeholder ) || '' === trim ( $ placeholder ) ) {
71+ $ placeholder_opt = $ opts ['placeholder ' ] ?? 'x.x.x ' ;
72+ if ( false === $ placeholder_opt ) {
73+ edac_write_line ( 'Error: --placeholder requires a value. ' , STDERR );
74+ exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
75+ }
76+ if ( ! is_string ( $ placeholder_opt ) || '' === trim ( $ placeholder_opt ) ) {
6377 edac_write_line ( 'Error: --placeholder must be a non-empty string. ' , STDERR );
6478 exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
6579}
80+ $ placeholder = $ placeholder_opt ;
6681
67- $ changed_since_tag = $ opts ['changed-since-tag ' ] ?? null ;
82+ // getopt() returns false for optional params given without a value (e.g. --changed-since-tag).
83+ $ changed_since_tag_opt = $ opts ['changed-since-tag ' ] ?? null ;
84+ if ( false === $ changed_since_tag_opt ) {
85+ edac_write_line ( 'Error: --changed-since-tag requires a value. ' , STDERR );
86+ exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
87+ }
88+ $ changed_since_tag = $ changed_since_tag_opt ;
6889$ changed_since_last_tag = isset ( $ opts ['changed-since-last-tag ' ] );
6990$ dry_run = isset ( $ opts ['dry-run ' ] );
7091
7394 exit ( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
7495}
7596
97+ // Validate the supplied ref actually exists before proceeding.
98+ if ( null !== $ changed_since_tag ) {
99+ $ ref_check = trim ( edac_run_git ( $ root , 'rev-parse --verify ' . escapeshellarg ( (string ) $ changed_since_tag ) ) );
100+ if ( '' === $ ref_check ) {
101+ edac_write_line ( 'Error: invalid Git reference or tag: ' . (string ) $ changed_since_tag , STDERR );
102+ exit ( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
103+ }
104+ }
105+
76106if ( $ changed_since_last_tag ) {
77107 $ changed_since_tag = trim ( edac_run_git ( $ root , 'describe --tags --abbrev=0 ' ) );
78108 if ( '' === $ changed_since_tag ) {
147177 * @return string[]
148178 */
149179function edac_get_all_php_files ( string $ root , array $ excluded_dirs ): array {
150- $ files = [];
151- $ iterator = new RecursiveIteratorIterator (
152- new RecursiveDirectoryIterator ( $ root , RecursiveDirectoryIterator::SKIP_DOTS )
180+ $ files = [];
181+ $ directory_iterator = new RecursiveDirectoryIterator ( $ root , RecursiveDirectoryIterator::SKIP_DOTS );
182+
183+ // Prune excluded directories before recursing into them for efficiency.
184+ $ filter = new RecursiveCallbackFilterIterator (
185+ $ directory_iterator ,
186+ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
187+ static function ( SplFileInfo $ item , $ key , RecursiveCallbackFilterIterator $ iterator ) use ( $ root , $ excluded_dirs ): bool {
188+ if ( $ item ->isDir () ) {
189+ $ relative_path = ltrim ( substr ( $ item ->getPathname (), strlen ( $ root ) ), DIRECTORY_SEPARATOR );
190+ foreach ( $ excluded_dirs as $ excluded ) {
191+ if ( $ relative_path === $ excluded || 0 === strpos ( $ relative_path , $ excluded . DIRECTORY_SEPARATOR ) ) {
192+ return false ;
193+ }
194+ }
195+ }
196+
197+ return true ;
198+ }
153199 );
154200
201+ $ iterator = new RecursiveIteratorIterator ( $ filter );
202+
155203 foreach ( $ iterator as $ item ) {
156204 if ( ! $ item instanceof SplFileInfo || $ item ->isDir () ) {
157205 continue ;
158206 }
159207
160- $ path = $ item ->getPathname ();
161- $ relative_path = ltrim ( str_replace ( $ root , '' , $ path ), DIRECTORY_SEPARATOR );
162-
163- foreach ( $ excluded_dirs as $ excluded ) {
164- $ needle = $ excluded . DIRECTORY_SEPARATOR ;
165- if ( 0 === strpos ( $ relative_path , $ needle ) || false !== strpos ( $ relative_path , DIRECTORY_SEPARATOR . $ needle ) ) {
166- continue 2 ;
167- }
168- }
169-
170208 if ( 'php ' === strtolower ( (string ) $ item ->getExtension () ) ) {
171- $ files [] = $ path ;
209+ $ files [] = $ item -> getPathname () ;
172210 }
173211 }
174212
@@ -180,13 +218,21 @@ function edac_get_all_php_files( string $root, array $excluded_dirs ): array {
180218/**
181219 * Get tracked PHP files changed between a given ref and HEAD.
182220 *
221+ * Exits with EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR if the git command fails,
222+ * so a bad ref or non-repo root does not silently return an empty list.
223+ *
183224 * @param string $root Root directory.
184225 * @param string $ref Git ref/tag.
185226 * @return string[]
186227 */
187228function edac_get_changed_php_files_since_ref ( string $ root , string $ ref ): array {
188229 $ cmd = 'diff --name-only ' . escapeshellarg ( $ ref . '..HEAD ' ) . ' -- ' . escapeshellarg ( '*.php ' );
189- $ output = edac_run_git ( $ root , $ cmd );
230+ $ output = edac_run_git ( $ root , $ cmd , $ exit_code );
231+
232+ if ( 0 !== $ exit_code ) {
233+ edac_write_line ( "Error: git diff failed (exit {$ exit_code }) for ref: {$ ref }" , STDERR );
234+ exit ( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
235+ }
190236
191237 $ files = [];
192238 foreach ( preg_split ( '/\r?\n/ ' , trim ( $ output ) ) as $ line ) {
@@ -208,15 +254,17 @@ function edac_get_changed_php_files_since_ref( string $root, string $ref ): arra
208254/**
209255 * Run a Git command in the target root and return stdout.
210256 *
211- * @param string $root Root directory.
212- * @param string $args Git args.
257+ * @param string $root Root directory.
258+ * @param string $args Git args (already shell-escaped as needed).
259+ * @param int|null $exit_code Reference populated with the git process exit code.
213260 * @return string
214261 */
215- function edac_run_git ( string $ root , string $ args ): string {
216- $ cmd = 'git -C ' . escapeshellarg ( $ root ) . ' ' . $ args . ' 2>/dev/null ' ;
217- $ output = shell_exec ( $ cmd ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec
262+ function edac_run_git ( string $ root , string $ args , ?int &$ exit_code = null ): string {
263+ $ cmd = 'git -C ' . escapeshellarg ( $ root ) . ' ' . $ args ;
264+ $ output = [];
265+ exec ( $ cmd , $ output , $ exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
218266
219- return is_string ( $ output ) ? $ output : '' ;
267+ return implode ( "\n" , $ output ) ;
220268}
221269
222270/**
0 commit comments