From 18bb9eb45d450ee63983ff031bebd03731ba9a98 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 3 Sep 2026 10:59:23 +0200 Subject: [PATCH 1/5] Add peek.php, a live output display for parallel jobs Long parallel runs currently interleave the output of every job into a single stream, so it is impossible to tell which repository produced which line, or which jobs are still running. peek.php gives each job its own lane in the terminal: a line that shows the job name, its latest output, and its elapsed time, replaced by a final status line when the job exits. Concurrency stays with the caller (xargs -P, parallel, make -j, &); peek only owns the display. Usage is two commands: php peek.php -- # run the display php peek.php run -n -- # wrap a single job The wrapper degrades to executing its command unchanged whenever the display is unavailable, so callers work either way. That covers platforms without unix domain datagram sockets (Windows), PHP older than 7.4, non-TTY output such as CI logs, and NO_PEEK=1 for opting out. phpcs gets targeted exclusions for the file: it is a single-file tool meant to be copied around as-is, so it mixes functions and classes, and it silences errors when probing optional platform features because "unsupported, fall back to a passthrough" is the intended handling. --- .maintenance/peek.php | 672 ++++++++++++++++++++++++++++++++++++++++++ phpcs.xml.dist | 19 ++ 2 files changed, 691 insertions(+) create mode 100755 .maintenance/peek.php diff --git a/.maintenance/peek.php b/.maintenance/peek.php new file mode 100755 index 0000000..ec99b9c --- /dev/null +++ b/.maintenance/peek.php @@ -0,0 +1,672 @@ +#!/usr/bin/env php + 0 && ( $now - $at ) < 2.0 ) { + return $size; + } + $at = $now; + $cols = (int) getenv( 'COLUMNS' ); + $rows = (int) getenv( 'LINES' ); + if ( $cols <= 0 || $rows <= 0 ) { + $out = @shell_exec( 'stty size < /dev/tty 2>/dev/null' ); + if ( $out && preg_match( '#^(\d+)\s+(\d+)#', trim( $out ), $m ) ) { + $rows = (int) $m[1]; + $cols = (int) $m[2]; + } + } + $size = array( $cols > 0 ? $cols : 80, $rows > 0 ? $rows : 24 ); + return $size; +} + +function peek_which( $bin ) { + if ( false !== strpos( $bin, '/' ) ) { + return is_executable( $bin ) ? $bin : null; + } + foreach ( explode( PATH_SEPARATOR, (string) getenv( 'PATH' ) ) as $dir ) { + if ( '' === $dir ) { + continue; + } + $candidate = $dir . '/' . $bin; + if ( is_file( $candidate ) && is_executable( $candidate ) ) { + return $candidate; + } + } + return null; +} + +/** + * Run a command unchanged, inheriting stdio, and return its exit code. + * Used whenever the display is unavailable so behavior stays identical. + */ +function peek_passthrough( array $cmd ) { + if ( function_exists( 'pcntl_exec' ) ) { + $bin = peek_which( $cmd[0] ); + if ( null !== $bin ) { + @pcntl_exec( $bin, array_slice( $cmd, 1 ) ); + // Falls through only if exec itself failed. + } + } + $spec = array( + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ); + if ( PHP_VERSION_ID >= 70400 ) { + $proc = @proc_open( $cmd, $spec, $pipes ); + } else { + $proc = @proc_open( implode( ' ', array_map( 'escapeshellarg', $cmd ) ), $spec, $pipes ); + } + if ( ! is_resource( $proc ) ) { + fwrite( STDERR, "peek: failed to run: {$cmd[0]}\n" ); + return 127; + } + return proc_close( $proc ); +} + +// -------------------------------------------------------------------------- +// client side: peek.php run / peek.php pipe +// -------------------------------------------------------------------------- + +/** + * Write side of the protocol. Never fatal: if the display is gone, we + * silently stop reporting rather than killing the job. + */ +class PeekFeed { + + private $sock = null; + private $lane; + + public function __construct( $lane, $name ) { + $this->lane = (string) $lane; + $path = getenv( 'PEEK_SOCK' ); + if ( $path ) { + $this->sock = @stream_socket_client( 'udg://' . $path, $errno, $errstr, 1 ); + if ( $this->sock ) { + $this->send( 'OPEN', $name ); + } + } + } + + public function send( $kind, $payload ) { + if ( ! $this->sock ) { + return; + } + $data = $kind . PEEK_SEP . $this->lane . PEEK_SEP . substr( $payload, 0, PEEK_MAXLINE ); + $sent = @stream_socket_sendto( $this->sock, $data ); + if ( false === $sent || $sent < 0 ) { + $this->sock = null; + } + } + + public function line( $raw ) { + $this->send( 'LINE', $raw ); + } + + public function close( $rc ) { + $this->send( 'EXIT', (string) $rc ); + } +} + +function peek_cmd_run( array $argv ) { + $name = null; + $rest = array(); + $help = false; + while ( $argv ) { + $arg = array_shift( $argv ); + if ( '-n' === $arg || '--name' === $arg ) { + $name = array_shift( $argv ); + } elseif ( '-h' === $arg || '--help' === $arg ) { + $help = true; + } elseif ( '--' === $arg ) { + $rest = $argv; + break; + } else { + $rest = array_merge( array( $arg ), $argv ); + break; + } + } + if ( $help || ! $rest ) { + fwrite( STDERR, "usage: peek.php run [-n NAME] -- COMMAND [ARGS...]\n" ); + return $help ? 0 : 2; + } + + // No display: become the command. This is what makes peek droppable + // into scripts that also run standalone. + if ( ! getenv( 'PEEK_SOCK' ) || ! peek_supported() ) { + return peek_passthrough( $rest ); + } + + $feed = new PeekFeed( getmypid(), null !== $name ? $name : implode( ' ', $rest ) ); + $spec = array( + 0 => STDIN, + 1 => array( 'pipe', 'w' ), + 2 => array( 'redirect', 1 ), + ); + $proc = @proc_open( $rest, $spec, $pipes ); + if ( ! is_resource( $proc ) ) { + $feed->close( 127 ); + fwrite( STDERR, "peek: failed to run: {$rest[0]}\n" ); + return 127; + } + while ( false !== ( $line = fgets( $pipes[1] ) ) ) { + $feed->line( rtrim( $line, "\n" ) ); + } + fclose( $pipes[1] ); + $rc = proc_close( $proc ); + $feed->close( $rc ); + return $rc; +} + +function peek_cmd_pipe( array $argv ) { + $name = 'stdin'; + while ( $argv ) { + $arg = array_shift( $argv ); + if ( '-n' === $arg || '--name' === $arg ) { + $name = array_shift( $argv ); + } + } + $feed = new PeekFeed( getmypid(), $name ); + while ( false !== ( $line = fgets( STDIN ) ) ) { + fwrite( STDOUT, $line ); // Stay a tee, so pipe mode drops into a pipeline. + fflush( STDOUT ); + $feed->line( rtrim( $line, "\n" ) ); + } + $feed->close( 0 ); + return 0; +} + +// -------------------------------------------------------------------------- +// server side: the display +// -------------------------------------------------------------------------- + +class PeekLane { + + public $name; + public $tail = array(); + public $start; + public $end = null; + public $rc = null; + public $committed = false; + + private $max; + + public function __construct( $name, $peek_lines ) { + $this->name = $name; + $this->max = max( 1, $peek_lines ); + $this->start = microtime( true ); + } + + public function add_line( $text ) { + $this->tail[] = $text; + if ( count( $this->tail ) > $this->max ) { + array_shift( $this->tail ); + } + } + + public function is_running() { + return null === $this->rc; + } + + public function header( $frame, $width, $color = true ) { + $spin = peek_spinner(); + if ( null === $this->rc ) { + $glyph = $spin[ $frame % count( $spin ) ]; + $tint = PEEK_CYAN; + $right = peek_dur( microtime( true ) - $this->start ); + $right_tint = PEEK_GREY; + } elseif ( 0 === $this->rc ) { + $glyph = '✔'; + $tint = PEEK_GREEN; + $right = peek_dur( $this->end - $this->start ); + $right_tint = PEEK_GREY; + } else { + $glyph = '✘'; + $tint = PEEK_RED; + $right = 'exit ' . $this->rc . ' · ' . peek_dur( $this->end - $this->start ); + $right_tint = PEEK_RED; + } + $right_len = peek_len( $right ); + $name_fit = peek_fit( $this->name, max( 0, $width - $right_len - 4 ) ); + $plain = $glyph . ' ' . $name_fit; + $pad = str_repeat( ' ', max( 1, $width - peek_len( $plain ) - $right_len ) ); + if ( ! $color ) { + return $plain . $pad . $right; + } + if ( null === $this->rc ) { + $styled_name = PEEK_BOLD . $name_fit . PEEK_RESET; + } elseif ( 0 === $this->rc ) { + $styled_name = $name_fit; + } else { + $styled_name = PEEK_RED . PEEK_BOLD . $name_fit . PEEK_RESET; + } + return $tint . PEEK_BOLD . $glyph . PEEK_RESET . ' ' . $styled_name + . $pad . $right_tint . $right . PEEK_RESET; + } +} + +class PeekDisplay { + + public $lanes = array(); + public $order = array(); + + private $peek_lines; + private $prev = 0; + private $frame = 0; + private $start; + + public function __construct( $peek_lines ) { + $this->peek_lines = $peek_lines; + $this->start = microtime( true ); + } + + public function event( $kind, $lane, $payload ) { + if ( 'OPEN' === $kind ) { + if ( ! isset( $this->lanes[ $lane ] ) ) { + $this->lanes[ $lane ] = new PeekLane( peek_clean( $payload ), $this->peek_lines ); + $this->order[] = $lane; + } + } elseif ( isset( $this->lanes[ $lane ] ) ) { + $ln = $this->lanes[ $lane ]; + if ( 'LINE' === $kind ) { + $text = peek_clean( $payload ); + if ( '' !== trim( $text ) ) { + $ln->add_line( $text ); + } + } elseif ( 'EXIT' === $kind ) { + $ln->rc = (int) $payload; + $ln->end = microtime( true ); + } + } + } + + public function counts() { + $running = 0; + $ok = 0; + $failed = 0; + foreach ( $this->order as $key ) { + $rc = $this->lanes[ $key ]->rc; + if ( null === $rc ) { + ++$running; + } elseif ( 0 === $rc ) { + ++$ok; + } else { + ++$failed; + } + } + return array( $running, $ok, $failed ); + } + + public function footer( $color = true ) { + list( $running, $ok, $failed ) = $this->counts(); + $elapsed = peek_dur( microtime( true ) - $this->start ); + if ( ! $color ) { + $bits = array(); + if ( $running ) { + $bits[] = $running . ' running'; + } + $bits[] = 'ok ' . $ok; + if ( $failed ) { + $bits[] = 'failed ' . $failed; + } + $bits[] = $elapsed; + return implode( ' · ', $bits ); + } + $sep = PEEK_GREY . ' · ' . PEEK_RESET; + $bits = array(); + if ( $running ) { + $bits[] = PEEK_CYAN . $running . ' running' . PEEK_RESET; + } + $bits[] = PEEK_GREEN . '✔ ' . $ok . PEEK_RESET; + if ( $failed ) { + $bits[] = PEEK_RED . PEEK_BOLD . '✘ ' . $failed . PEEK_RESET; + } + $bits[] = PEEK_GREY . $elapsed . PEEK_RESET; + return ' ' . PEEK_GREY . '─' . PEEK_RESET . ' ' . implode( $sep, $bits ); + } + + public function compose( $rows, $cols ) { + $commit = array(); + $live = array(); + $lanes = array(); + foreach ( $this->order as $key ) { + $lanes[] = $this->lanes[ $key ]; + } + foreach ( $lanes as $ln ) { + if ( null !== $ln->rc && ! $ln->committed ) { + $ln->committed = true; + $commit[] = $ln->header( $this->frame, $cols ); + // Keep the captured tail of a failed job on screen: it scrolls + // into history with the header, so the error context survives. + if ( 0 !== $ln->rc ) { + foreach ( $ln->tail as $text ) { + $commit[] = ' ' . PEEK_RED . '│' . PEEK_RESET . ' ' + . PEEK_GREY . peek_fit( $text, $cols - 4 ) . PEEK_RESET; + } + } + } + } + $pending = array(); + foreach ( $lanes as $ln ) { + if ( ! $ln->committed ) { + $pending[] = $ln; + } + } + $budget = max( 0, $rows - 3 ) - count( $pending ); + $active = array(); + foreach ( $pending as $ln ) { + if ( $ln->is_running() ) { + $active[] = $ln; + } + } + $share = ( $active && $budget > 0 ) + ? min( $this->peek_lines, intdiv( $budget, count( $active ) ) ) + : 0; + foreach ( $pending as $ln ) { + $live[] = $ln->header( $this->frame, $cols ); + if ( $ln->is_running() && $share ) { + foreach ( array_slice( $ln->tail, -$share ) as $text ) { + $live[] = ' ' . PEEK_GREY . '│' . PEEK_RESET . ' ' + . PEEK_DIM . peek_fit( $text, $cols - 4 ) . PEEK_RESET; + } + } + } + $live[] = $this->footer(); + return array( $commit, $live ); + } + + public function draw( $out ) { + list( $cols, $rows ) = peek_term_size(); + list( $commit, $live ) = $this->compose( $rows, $cols ); + $buf = $this->prev ? "\033[" . $this->prev . 'A' : ''; + foreach ( array_merge( $commit, $live ) as $line ) { + $buf .= "\033[2K" . $line . "\n"; + } + $buf .= "\033[J"; + fwrite( $out, $buf ); + fflush( $out ); + $this->prev = count( $live ); + ++$this->frame; + } + + public function summary() { + $lines = array(); + foreach ( $this->order as $key ) { + $lines[] = $this->lanes[ $key ]->header( 0, 80, false ); + } + $lines[] = $this->footer( false ); + return $lines; + } +} + +function peek_drain( $srv, PeekDisplay $disp ) { + while ( true ) { + $data = @stream_socket_recvfrom( $srv, 131072 ); + if ( false === $data || '' === $data || null === $data ) { + return; + } + $parts = explode( PEEK_SEP, $data, 3 ); + if ( 3 === count( $parts ) ) { + $disp->event( $parts[0], $parts[1], $parts[2] ); + } + } +} + +function peek_cmd_serve( array $argv ) { + $peek_lines = 6; + $fps = 12.5; + $help = false; + $rest = array(); + while ( $argv ) { + $arg = array_shift( $argv ); + if ( '--peek' === $arg ) { + $peek_lines = (int) array_shift( $argv ); + } elseif ( 0 === strpos( $arg, '--peek=' ) ) { + $peek_lines = (int) substr( $arg, 7 ); + } elseif ( '--fps' === $arg ) { + $fps = (float) array_shift( $argv ); + } elseif ( 0 === strpos( $arg, '--fps=' ) ) { + $fps = (float) substr( $arg, 6 ); + } elseif ( '-h' === $arg || '--help' === $arg ) { + $help = true; + } elseif ( '--' === $arg ) { + $rest = $argv; + break; + } else { + $rest = array_merge( array( $arg ), $argv ); + break; + } + } + if ( $help || ! $rest ) { + fwrite( STDERR, "usage: peek.php [--peek N] [--fps F] -- COMMAND [ARGS...]\n" ); + fwrite( STDERR, " peek.php run [-n NAME] -- COMMAND [ARGS...]\n" ); + fwrite( STDERR, " peek.php pipe [-n NAME]\n" ); + return $help ? 0 : 2; + } + + if ( ! peek_supported() ) { + return peek_passthrough( $rest ); + } + + $tmp = sys_get_temp_dir() . '/peek.' . getmypid() . '.' . substr( md5( uniqid( '', true ) ), 0, 6 ); + if ( ! @mkdir( $tmp, 0700, true ) ) { + return peek_passthrough( $rest ); + } + $sock_path = $tmp . '/sock'; + $srv = @stream_socket_server( 'udg://' . $sock_path, $errno, $errstr, STREAM_SERVER_BIND ); + if ( ! $srv ) { + @rmdir( $tmp ); + return peek_passthrough( $rest ); + } + stream_set_blocking( $srv, false ); + + $env = getenv(); + $env['PEEK_SOCK'] = $sock_path; + + $tty = function_exists( 'stream_isatty' ) && @stream_isatty( STDOUT ); + + // The driver's own output would fight the live region, so hold it back + // and replay it once the display tears down. + $spec = $tty + ? array( + 0 => STDIN, + 1 => array( 'pipe', 'w' ), + 2 => array( 'redirect', 1 ), + ) + : array( + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, + ); + $proc = @proc_open( $rest, $spec, $pipes, null, $env ); + if ( ! is_resource( $proc ) ) { + fclose( $srv ); + @unlink( $sock_path ); + @rmdir( $tmp ); + fwrite( STDERR, "peek: failed to run: {$rest[0]}\n" ); + return 127; + } + + $disp = new PeekDisplay( $peek_lines ); + $held = ''; + $restore = function () use ( $tty ) { + if ( $tty ) { + fwrite( STDOUT, "\033[?25h" ); + fflush( STDOUT ); + } + }; + if ( $tty ) { + fwrite( STDOUT, "\033[?25l" ); + register_shutdown_function( $restore ); + if ( function_exists( 'pcntl_async_signals' ) ) { + pcntl_async_signals( true ); + $on_signal = function () use ( $restore ) { + $restore(); + exit( 130 ); + }; + pcntl_signal( SIGINT, $on_signal ); + pcntl_signal( SIGTERM, $on_signal ); + } + stream_set_blocking( $pipes[1], false ); + } + + $frame_us = (int) ( 1000000 / max( $fps, 1 ) ); + $exit = null; + while ( true ) { + $status = proc_get_status( $proc ); + if ( ! $status['running'] && null === $exit ) { + $exit = $status['exitcode']; + } + $read = array( $srv ); + if ( $tty && is_resource( $pipes[1] ) && ! feof( $pipes[1] ) ) { + $read[] = $pipes[1]; + } + $write = null; + $except = null; + @stream_select( $read, $write, $except, 0, $frame_us ); + peek_drain( $srv, $disp ); + if ( $tty && is_resource( $pipes[1] ) ) { + while ( false !== ( $chunk = fread( $pipes[1], 65536 ) ) && '' !== $chunk ) { + $held .= $chunk; + } + } + if ( $tty ) { + $disp->draw( STDOUT ); + } + if ( null !== $exit ) { + break; + } + } + + usleep( 250000 ); // Drain late EXIT datagrams. + peek_drain( $srv, $disp ); + if ( $tty ) { + while ( is_resource( $pipes[1] ) && false !== ( $chunk = fread( $pipes[1], 65536 ) ) && '' !== $chunk ) { + $held .= $chunk; + } + $disp->draw( STDOUT ); + fclose( $pipes[1] ); + } + proc_close( $proc ); + fclose( $srv ); + @unlink( $sock_path ); + @rmdir( $tmp ); + $restore(); + + if ( $tty ) { + if ( '' !== $held ) { + fwrite( STDOUT, $held ); + fflush( STDOUT ); + } + } else { + foreach ( $disp->summary() as $line ) { + fwrite( STDOUT, $line . "\n" ); + } + } + return null === $exit ? 0 : $exit; +} + +function peek_main( array $argv ) { + array_shift( $argv ); + if ( isset( $argv[0] ) && 'run' === $argv[0] ) { + return peek_cmd_run( array_slice( $argv, 1 ) ); + } + if ( isset( $argv[0] ) && 'pipe' === $argv[0] ) { + return peek_cmd_pipe( array_slice( $argv, 1 ) ); + } + return peek_cmd_serve( $argv ); +} + +exit( peek_main( $argv ) ); diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 58dbf40..a6a976b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -52,4 +52,23 @@ */.maintenance/* + + + */.maintenance/peek.php + + + */.maintenance/peek.php + + + + + */.maintenance/peek.php + + + */.maintenance/peek.php + + From d4e34d209f36b168f4160b4fe37f7f99756fcf4f Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Thu, 3 Sep 2026 10:59:33 +0200 Subject: [PATCH 2/5] Sync all repositories in a single parallel pass `composer install` and `composer update` run clone-all-repositories.sh, which cloned missing repositories in one parallel pass and then refreshed every repository in a second one. The barrier between the two stages left cores idle: the refresh pass could not start until the slowest clone finished, and on a fresh checkout the whole refresh pass was wasted work because a freshly cloned repository is already up to date. sync-repository.sh collapses both stages into one task per repository: clone when the folder is missing, refresh when it is not. That lets the script run a single continuous parallel pass that keeps every slot busy until the last repository is done. The pass now renders through peek.php, so each repository gets its own line showing what it is doing and how long it has taken, instead of ~90 repositories interleaving their git output into one stream. Running behind that display means an interactive prompt would be overdrawn the moment it appeared and would hang the run waiting for input nobody can see. Git and ssh prompt on /dev/tty rather than stdin, so prompting is disabled outright and failures surface as visible errors in the job's own lane instead: - GIT_TERMINAL_PROMPT=0 stops git asking for credentials. - BatchMode=yes makes ssh fail instead of asking for a passphrase or host key confirmation; keys served by an ssh-agent keep working. It is only applied when GIT_SSH_COMMAND is not already customized. - GIT_MERGE_AUTOEDIT=no keeps a non-fast-forward pull from opening an editor. --- .maintenance/clone-all-repositories.sh | 51 ++++++++++++++++++-------- .maintenance/sync-repository.sh | 29 +++++++++++++++ 2 files changed, 64 insertions(+), 16 deletions(-) create mode 100755 .maintenance/sync-repository.sh diff --git a/.maintenance/clone-all-repositories.sh b/.maintenance/clone-all-repositories.sh index 33bf6b7..60328a8 100755 --- a/.maintenance/clone-all-repositories.sh +++ b/.maintenance/clone-all-repositories.sh @@ -3,6 +3,29 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export SCRIPT_DIR + +# peek.php renders a live lane of output per parallel job. Its use is +# optional: it degrades to a plain passthrough on platforms that cannot +# support the display (e.g. Windows) and can be disabled with NO_PEEK=1. +PEEK_PHP="${SCRIPT_DIR}/peek.php" +export PEEK_PHP + +# Parallel jobs run behind the peek display, where an interactive prompt is +# instantly overdrawn and would hang the run waiting for input nobody can +# see. Git and ssh prompt on /dev/tty (not stdin), so disable prompting +# entirely: failures then surface as visible errors in the job's lane. +# - GIT_TERMINAL_PROMPT=0: no credential/username prompts from git itself. +# - BatchMode=yes: ssh fails instead of asking for passphrases or host key +# confirmation (keys served by an ssh-agent keep working). Only set when +# GIT_SSH_COMMAND is not already customized. +# - GIT_MERGE_AUTOEDIT=no: a non-fast-forward pull keeps the default merge +# message instead of opening an editor. +export GIT_TERMINAL_PROMPT=0 +export GIT_MERGE_AUTOEDIT=no +if [[ -z "${GIT_SSH_COMMAND:-}" ]]; then + export GIT_SSH_COMMAND="ssh -oBatchMode=yes" +fi if ! command -v jq &>/dev/null; then echo "Required command 'jq' is not installed or not available in PATH." >&2 @@ -80,8 +103,11 @@ get_destination() { fi } -CLONE_LIST=() -UPDATE_FOLDERS=() +# One task per repository: sync-repository.sh clones missing folders and +# refreshes existing ones. Running a single parallel pass over all +# repositories keeps all ${CORES} slots busy for the whole run, instead of +# a clone stage and a refresh stage separated by a barrier. +TASK_LIST=() while IFS=$'\t' read -r name clone_url ssh_url; do if is_skipped "${name}"; then @@ -90,21 +116,14 @@ while IFS=$'\t' read -r name clone_url ssh_url; do destination=$(get_destination "${name}") - if [[ ! -d "${destination}" ]]; then - if [[ -n "${GITHUB_ACTION:-}" ]]; then - CLONE_LIST+=("${destination}"$'\t'"${clone_url}") - else - CLONE_LIST+=("${destination}"$'\t'"${ssh_url}") - fi + if [[ -n "${GITHUB_ACTION:-}" ]]; then + TASK_LIST+=("${destination}"$'\t'"${clone_url}") + else + TASK_LIST+=("${destination}"$'\t'"${ssh_url}") fi - - UPDATE_FOLDERS+=("${destination}") done < <(echo "${RESPONSE}" | jq -r '.[] | [.name, .clone_url, .ssh_url] | @tsv') -if [[ ${#CLONE_LIST[@]} -gt 0 ]]; then - printf '%s\n' "${CLONE_LIST[@]}" | xargs -n2 -P"${CORES}" bash "${SCRIPT_DIR}/clone-repository.sh" -fi - -if [[ ${#UPDATE_FOLDERS[@]} -gt 0 ]]; then - printf '%s\n' "${UPDATE_FOLDERS[@]}" | xargs -P"${CORES}" -I% php "${SCRIPT_DIR}/refresh-repository.php" % +if [[ ${#TASK_LIST[@]} -gt 0 ]]; then + printf '%s\n' "${TASK_LIST[@]}" | php "${PEEK_PHP}" -- xargs -n2 -P"${CORES}" \ + bash -c 'exec php "${PEEK_PHP}" run -n "$1" -- bash "${SCRIPT_DIR}/sync-repository.sh" "$1" "$2"' _ fi diff --git a/.maintenance/sync-repository.sh b/.maintenance/sync-repository.sh new file mode 100755 index 0000000..dad050c --- /dev/null +++ b/.maintenance/sync-repository.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Bring a single repository up to date: clone it if the folder is missing, +# refresh it otherwise. Freshly cloned repositories are already current, so +# they skip the refresh. This lets the caller run one continuous parallel +# pass over all repositories instead of a clone stage and a refresh stage +# separated by a barrier. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -lt 1 ]]; then + echo "Usage: sync-repository.sh []" >&2 + exit 1 +fi + +destination="$1" +clone_url="${2:-}" + +if [[ ! -d "${destination}" ]]; then + if [[ -z "${clone_url}" ]]; then + echo "Folder '${destination}' is missing and no clone URL was provided." >&2 + exit 1 + fi + exec bash "${SCRIPT_DIR}/clone-repository.sh" "${destination}" "${clone_url}" +fi + +exec php "${SCRIPT_DIR}/refresh-repository.php" "${destination}" From 151df5a3d0836d56e9e5e876d72922711778ab2c Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:19:29 +0200 Subject: [PATCH 3/5] Propagate git failures from refresh-repository.php The refresh helper ignored the exit status of both `git checkout` and `git pull` and always exited 0. Now that sync-repository.sh execs it and peek.php shows one status per repository, a failed refresh was rendered as a success and the git error was hidden. Exit with the failing git command's status instead, so the lane shows the failure and xargs and Composer see it too. --- .maintenance/refresh-repository.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.maintenance/refresh-repository.php b/.maintenance/refresh-repository.php index fa3d2c1..8d59bdd 100644 --- a/.maintenance/refresh-repository.php +++ b/.maintenance/refresh-repository.php @@ -10,7 +10,13 @@ printf( "--- Refreshing repository \033[32m{$repository}\033[0m ---\n" ); printf( "Switching to latest \033[33mdefault\033[0m branch...\n" ); -system( "git --git-dir={$path}/.git --work-tree={$path} checkout $(git --git-dir={$path}/.git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)" ); +system( "git --git-dir={$path}/.git --work-tree={$path} checkout $(git --git-dir={$path}/.git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)", $status ); +if ( 0 !== $status ) { + exit( $status ); +} printf( "Pulling latest changes...\n" ); -system( "git --git-dir={$path}/.git --work-tree={$path} pull" ); +system( "git --git-dir={$path}/.git --work-tree={$path} pull", $status ); +if ( 0 !== $status ) { + exit( $status ); +} From 3ef77fad7f5f5fa145b1e3d813bd48cf5b5ca773 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:20:32 +0200 Subject: [PATCH 4/5] Pass through unchanged when stdout is not a terminal peek_cmd_serve() only decided whether to draw after it had created the socket and exported PEEK_SOCK, so every job wrapper still piped its output into the display even when there was no terminal to show it on. Without a TTY the display never drew and the final summary printed one status line per job, so CI logs and piped runs lost all git output, including the error of a failed clone or pull. Check for a terminal before any setup and become the command unchanged when there is none, as the file header already promised. That makes the non-TTY spec, the plain header and footer variants and summary() dead code, so they go too. --- .maintenance/peek.php | 126 +++++++++++++++--------------------------- 1 file changed, 44 insertions(+), 82 deletions(-) diff --git a/.maintenance/peek.php b/.maintenance/peek.php index ec99b9c..a6ee4ad 100755 --- a/.maintenance/peek.php +++ b/.maintenance/peek.php @@ -14,8 +14,9 @@ * `peek.php run` becomes its command unchanged, so scripts work either way. * * Degrades to a plain passthrough when the platform cannot support the - * display (Windows, no unix domain datagram sockets, PHP < 7.4) or when - * NO_PEEK is set in the environment. + * display (Windows, no unix domain datagram sockets, PHP < 7.4), when + * stdout is not a terminal (CI logs, pipes) or when NO_PEEK is set in the + * environment. */ const PEEK_SEP = "\x1f"; @@ -300,7 +301,7 @@ public function is_running() { return null === $this->rc; } - public function header( $frame, $width, $color = true ) { + public function header( $frame, $width ) { $spin = peek_spinner(); if ( null === $this->rc ) { $glyph = $spin[ $frame % count( $spin ) ]; @@ -322,9 +323,6 @@ public function header( $frame, $width, $color = true ) { $name_fit = peek_fit( $this->name, max( 0, $width - $right_len - 4 ) ); $plain = $glyph . ' ' . $name_fit; $pad = str_repeat( ' ', max( 1, $width - peek_len( $plain ) - $right_len ) ); - if ( ! $color ) { - return $plain . $pad . $right; - } if ( null === $this->rc ) { $styled_name = PEEK_BOLD . $name_fit . PEEK_RESET; } elseif ( 0 === $this->rc ) { @@ -389,23 +387,11 @@ public function counts() { return array( $running, $ok, $failed ); } - public function footer( $color = true ) { + public function footer() { list( $running, $ok, $failed ) = $this->counts(); $elapsed = peek_dur( microtime( true ) - $this->start ); - if ( ! $color ) { - $bits = array(); - if ( $running ) { - $bits[] = $running . ' running'; - } - $bits[] = 'ok ' . $ok; - if ( $failed ) { - $bits[] = 'failed ' . $failed; - } - $bits[] = $elapsed; - return implode( ' · ', $bits ); - } - $sep = PEEK_GREY . ' · ' . PEEK_RESET; - $bits = array(); + $sep = PEEK_GREY . ' · ' . PEEK_RESET; + $bits = array(); if ( $running ) { $bits[] = PEEK_CYAN . $running . ' running' . PEEK_RESET; } @@ -480,15 +466,6 @@ public function draw( $out ) { $this->prev = count( $live ); ++$this->frame; } - - public function summary() { - $lines = array(); - foreach ( $this->order as $key ) { - $lines[] = $this->lanes[ $key ]->header( 0, 80, false ); - } - $lines[] = $this->footer( false ); - return $lines; - } } function peek_drain( $srv, PeekDisplay $disp ) { @@ -540,6 +517,13 @@ function peek_cmd_serve( array $argv ) { return peek_passthrough( $rest ); } + // Without a terminal there is nothing to draw on, and the wrappers would + // pipe every job's output into a display that never shows it. CI logs and + // pipes get the jobs' plain output instead. + if ( ! function_exists( 'stream_isatty' ) || ! @stream_isatty( STDOUT ) ) { + return peek_passthrough( $rest ); + } + $tmp = sys_get_temp_dir() . '/peek.' . getmypid() . '.' . substr( md5( uniqid( '', true ) ), 0, 6 ); if ( ! @mkdir( $tmp, 0700, true ) ) { return peek_passthrough( $rest ); @@ -555,21 +539,13 @@ function peek_cmd_serve( array $argv ) { $env = getenv(); $env['PEEK_SOCK'] = $sock_path; - $tty = function_exists( 'stream_isatty' ) && @stream_isatty( STDOUT ); - // The driver's own output would fight the live region, so hold it back // and replay it once the display tears down. - $spec = $tty - ? array( - 0 => STDIN, - 1 => array( 'pipe', 'w' ), - 2 => array( 'redirect', 1 ), - ) - : array( - 0 => STDIN, - 1 => STDOUT, - 2 => STDERR, - ); + $spec = array( + 0 => STDIN, + 1 => array( 'pipe', 'w' ), + 2 => array( 'redirect', 1 ), + ); $proc = @proc_open( $rest, $spec, $pipes, null, $env ); if ( ! is_resource( $proc ) ) { fclose( $srv ); @@ -581,26 +557,22 @@ function peek_cmd_serve( array $argv ) { $disp = new PeekDisplay( $peek_lines ); $held = ''; - $restore = function () use ( $tty ) { - if ( $tty ) { - fwrite( STDOUT, "\033[?25h" ); - fflush( STDOUT ); - } + $restore = function () { + fwrite( STDOUT, "\033[?25h" ); + fflush( STDOUT ); }; - if ( $tty ) { - fwrite( STDOUT, "\033[?25l" ); - register_shutdown_function( $restore ); - if ( function_exists( 'pcntl_async_signals' ) ) { - pcntl_async_signals( true ); - $on_signal = function () use ( $restore ) { - $restore(); - exit( 130 ); - }; - pcntl_signal( SIGINT, $on_signal ); - pcntl_signal( SIGTERM, $on_signal ); - } - stream_set_blocking( $pipes[1], false ); - } + fwrite( STDOUT, "\033[?25l" ); + register_shutdown_function( $restore ); + if ( function_exists( 'pcntl_async_signals' ) ) { + pcntl_async_signals( true ); + $on_signal = function () use ( $restore ) { + $restore(); + exit( 130 ); + }; + pcntl_signal( SIGINT, $on_signal ); + pcntl_signal( SIGTERM, $on_signal ); + } + stream_set_blocking( $pipes[1], false ); $frame_us = (int) ( 1000000 / max( $fps, 1 ) ); $exit = null; @@ -610,21 +582,19 @@ function peek_cmd_serve( array $argv ) { $exit = $status['exitcode']; } $read = array( $srv ); - if ( $tty && is_resource( $pipes[1] ) && ! feof( $pipes[1] ) ) { + if ( is_resource( $pipes[1] ) && ! feof( $pipes[1] ) ) { $read[] = $pipes[1]; } $write = null; $except = null; @stream_select( $read, $write, $except, 0, $frame_us ); peek_drain( $srv, $disp ); - if ( $tty && is_resource( $pipes[1] ) ) { + if ( is_resource( $pipes[1] ) ) { while ( false !== ( $chunk = fread( $pipes[1], 65536 ) ) && '' !== $chunk ) { $held .= $chunk; } } - if ( $tty ) { - $disp->draw( STDOUT ); - } + $disp->draw( STDOUT ); if ( null !== $exit ) { break; } @@ -632,28 +602,20 @@ function peek_cmd_serve( array $argv ) { usleep( 250000 ); // Drain late EXIT datagrams. peek_drain( $srv, $disp ); - if ( $tty ) { - while ( is_resource( $pipes[1] ) && false !== ( $chunk = fread( $pipes[1], 65536 ) ) && '' !== $chunk ) { - $held .= $chunk; - } - $disp->draw( STDOUT ); - fclose( $pipes[1] ); + while ( is_resource( $pipes[1] ) && false !== ( $chunk = fread( $pipes[1], 65536 ) ) && '' !== $chunk ) { + $held .= $chunk; } + $disp->draw( STDOUT ); + fclose( $pipes[1] ); proc_close( $proc ); fclose( $srv ); @unlink( $sock_path ); @rmdir( $tmp ); $restore(); - if ( $tty ) { - if ( '' !== $held ) { - fwrite( STDOUT, $held ); - fflush( STDOUT ); - } - } else { - foreach ( $disp->summary() as $line ) { - fwrite( STDOUT, $line . "\n" ); - } + if ( '' !== $held ) { + fwrite( STDOUT, $held ); + fflush( STDOUT ); } return null === $exit ? 0 : $exit; } From 671d04a89bb717404d4459317a0b61a192b651b6 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 14 Sep 2026 17:21:46 +0200 Subject: [PATCH 5/5] Let job output through when the display is not reachable PeekFeed never fails loudly: when the connect fails, or the display exits and removes its socket, the feed silently drops every line. The run wrapper only ever fed the job's stdout and stderr to the feed, so in that case the job ran with its output discarded and a failure left no trace. Fall back to a plain passthrough when the feed is not connected after construction, and write a line to stdout when the display stops accepting it mid-run, so output is never lost. --- .maintenance/peek.php | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/.maintenance/peek.php b/.maintenance/peek.php index a6ee4ad..2ceb0ed 100755 --- a/.maintenance/peek.php +++ b/.maintenance/peek.php @@ -162,8 +162,9 @@ function peek_passthrough( array $cmd ) { // -------------------------------------------------------------------------- /** - * Write side of the protocol. Never fatal: if the display is gone, we - * silently stop reporting rather than killing the job. + * Write side of the protocol. Never fatal: if the display is gone, we stop + * reporting rather than killing the job; connected() tells the caller so it + * can let the output through instead. */ class PeekFeed { @@ -181,19 +182,28 @@ public function __construct( $lane, $name ) { } } + public function connected() { + return null !== $this->sock; + } + + /** + * @return bool Whether the display received the datagram. + */ public function send( $kind, $payload ) { if ( ! $this->sock ) { - return; + return false; } $data = $kind . PEEK_SEP . $this->lane . PEEK_SEP . substr( $payload, 0, PEEK_MAXLINE ); $sent = @stream_socket_sendto( $this->sock, $data ); if ( false === $sent || $sent < 0 ) { $this->sock = null; + return false; } + return true; } public function line( $raw ) { - $this->send( 'LINE', $raw ); + return $this->send( 'LINE', $raw ); } public function close( $rc ) { @@ -231,6 +241,10 @@ function peek_cmd_run( array $argv ) { } $feed = new PeekFeed( getmypid(), null !== $name ? $name : implode( ' ', $rest ) ); + if ( ! $feed->connected() ) { + // The display is gone already: behave exactly like a plain passthrough. + return peek_passthrough( $rest ); + } $spec = array( 0 => STDIN, 1 => array( 'pipe', 'w' ), @@ -243,7 +257,11 @@ function peek_cmd_run( array $argv ) { return 127; } while ( false !== ( $line = fgets( $pipes[1] ) ) ) { - $feed->line( rtrim( $line, "\n" ) ); + if ( ! $feed->line( rtrim( $line, "\n" ) ) ) { + // The display went away mid-run: let the output through rather + // than dropping it. + @fwrite( STDOUT, $line ); + } } fclose( $pipes[1] ); $rc = proc_close( $proc );