diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 0000000000..5d7552229f --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,21 @@ +FROM php:8.4-cli + +ARG UID=1000 +ARG GID=1000 + +RUN apt-get update && \ + apt-get install -y git default-jre-headless + +WORKDIR /var/www + +ADD https://api.github.com/repos/php/phd/git/refs/heads/master version-phd.json +ADD https://api.github.com/repos/php/docbook-cs/git/refs/heads/main version-docbook-cs.json + +RUN echo 'memory_limit = 512M' >> /usr/local/etc/php/conf.d/local.ini + +RUN chown $UID:$GID /var/www + +USER $UID:$GID + +RUN git clone --depth 1 https://github.com/php/phd.git && \ + git clone --depth 1 https://github.com/php/docbook-cs.git diff --git a/.gitignore b/.gitignore index 8e5220c8b2..4c694b86af 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ fileModHistory.php # A plece for all temporary or generated files (idempotent build) temp/ + +# Docker dev build stamp +.docker/built diff --git a/dev.php b/dev.php new file mode 100755 index 0000000000..eafbf2bf19 --- /dev/null +++ b/dev.php @@ -0,0 +1,17 @@ +#!/usr/bin/env php +run(array_slice($argv, 1))); diff --git a/docbookcs.dev.xml b/docbookcs.dev.xml new file mode 100644 index 0000000000..b6405720b3 --- /dev/null +++ b/docbookcs.dev.xml @@ -0,0 +1,44 @@ + + + + + + @LANG@ + doc-base + + + + + + + + + + + + . + + + + extensions.ent + language-defs.ent + language-snippets.ent + entities/ + ../doc-base/entities/ + ../doc-base/temp/manual.ent + ../doc-base/temp/entities.ent + ../doc-base/temp/file-entities.ent + ../doc-base/temp/file-entities + + + + output/* + + + diff --git a/scripts/dev/Application.php b/scripts/dev/Application.php new file mode 100644 index 0000000000..e978f3eb61 --- /dev/null +++ b/scripts/dev/Application.php @@ -0,0 +1,182 @@ + $args Command line arguments, without argv[0]. + */ + public function run(array $args): int + { + if (!$this->requirePhpVersion(self::MINIMUM_PHP_VERSION)) { + return 1; + } + + $options = new Options(); + $command = $this->parse($args, $options); + + if ($command === null) { + return 1; + } + + if ($command === 'help') { + return (new HelpCommand())->execute($options); + } + + $subcommand = null; + + if ($command === 'docker') { + $subcommand = array_shift($options->args); + + if (!in_array($subcommand, ['build', 'shell'], true)) { + fwrite(STDERR, "Usage: php dev.php docker (see: php dev.php help)\n"); + return 1; + } + + $options->docker = true; + } + + if ($command === 'render') { + $subcommand = array_shift($options->args); + + if (!in_array($subcommand, ['xhtml', 'php'], true)) { + fwrite(STDERR, "Usage: php dev.php render (see: php dev.php help)\n"); + return 1; + } + + $options->format = $subcommand; + } + + if ($command === 'cs') { + $subcommand = array_shift($options->args); + + if (!in_array($subcommand, ['lint', 'fix'], true)) { + fwrite(STDERR, "Usage: php dev.php cs (see: php dev.php help)\n"); + return 1; + } + } + + $runner = new ProcessRunner(); + $dockerAvailable = $runner->runQuiet(['docker', 'version', '--format', '{{.Server.Version}}']) === 0; + + if ($options->docker === true && !$dockerAvailable) { + fwrite(STDERR, "error: Docker requested but the docker command is not available.\n"); + return 1; + } + + $workspace = new Workspace($this->basedir, $runner, $options->assumeYes); + $environment = ($options->docker ?? $dockerAvailable) + ? new DockerEnvironment($workspace, $runner) + : new LocalEnvironment($workspace, $runner); + + $configure = new ConfigureCommand($workspace, $environment); + + switch ($command) { + case 'pull': + return (new PullCommand($workspace, $environment))->execute($options); + case 'configure': + return $configure->execute($options); + case 'render': + return (new RenderCommand($workspace, $environment, $configure))->execute($options); + case 'cs': + return (new LintCommand($workspace, $environment, $configure, fix: $subcommand === 'fix')) + ->execute($options); + case 'serve': + return (new ServeCommand($workspace, $environment))->execute($options); + case 'docker': + return $subcommand === 'build' + ? (new BuildCommand($environment))->execute($options) + : (new ShellCommand($environment))->execute($options); + } + + fwrite(STDERR, "Unknown command: $command (see: php dev.php help)\n"); + + return 1; + } + + /** @param list $args */ + private function parse(array $args, Options $options): ?string + { + $command = null; + + foreach ($args as $arg) { + if (preg_match('/^--lang=(.+)$/', $arg, $m)) { + $options->lang = $m[1]; + continue; + } + + if (preg_match('/^--port=(\d+)$/', $arg, $m)) { + $options->port = (int) $m[1]; + continue; + } + + if ($arg === '--docker') { + $options->docker = true; + continue; + } + + if ($arg === '--no-docker') { + $options->docker = false; + continue; + } + + if ($arg === '--yes' || $arg === '-y') { + $options->assumeYes = true; + continue; + } + + if ($command === null) { + if ($arg[0] !== '-') { + $command = $arg; + continue; + } + + if ($arg === '-h' || $arg === '--help') { + $command = 'help'; + continue; + } + + $fileName = $_SERVER['SCRIPT_FILENAME']; + fwrite(STDERR, "Unknown option: $arg (see: php $fileName help)\n"); + return null; + } + + $options->args[] = $arg; + } + + return $command ?? 'help'; + } + + private function requirePhpVersion(int $minimum): bool + { + if (PHP_VERSION_ID >= $minimum) { + return true; + } + + $need = sprintf('%d.%d', intdiv($minimum, 10000), intdiv($minimum % 10000, 100)); + fwrite(STDERR, "error: dev.php requires PHP $need+ without Docker (this is PHP " + . PHP_VERSION . "). Install Docker or a newer PHP.\n"); + + return false; + } +} diff --git a/scripts/dev/Command/BuildCommand.php b/scripts/dev/Command/BuildCommand.php new file mode 100644 index 0000000000..eae7883836 --- /dev/null +++ b/scripts/dev/Command/BuildCommand.php @@ -0,0 +1,20 @@ +environment->buildImage(); + } +} diff --git a/scripts/dev/Command/Command.php b/scripts/dev/Command/Command.php new file mode 100644 index 0000000000..e3e0866727 --- /dev/null +++ b/scripts/dev/Command/Command.php @@ -0,0 +1,12 @@ +lang; + + if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) { + return 1; + } + + $this->workspace->pullSideRepos($lang); + + $args = array_merge([ + ($this->workspace->isBaseLang($lang) ? '--with-base-lang=' : '--with-lang=') . $lang, + '--enable-xml-details', + '--disable-libxml-check', + '--redirect-stderr-to-stdout', + ], $options->args); + + return $this->environment->configure($lang, $args); + } +} diff --git a/scripts/dev/Command/HelpCommand.php b/scripts/dev/Command/HelpCommand.php new file mode 100644 index 0000000000..55ea9ce31f --- /dev/null +++ b/scripts/dev/Command/HelpCommand.php @@ -0,0 +1,50 @@ + [options] [extra arguments] + + Commands: + pull Clone missing sibling repositories and update existing ones + configure Assemble and validate the manual, without rendering + render xhtml configure + render the chunked XHTML manual to /output + render php configure + render the web (PHP) version to /output + cs lint Run docbook-cs; extra arguments are passed through (paths, --wide) + cs fix Same as cs lint, with --fix: rewrite violations that have fixers + serve Serve /output over HTTP + docker build Build the Docker image + docker shell Interactive shell inside the container + + Options: + --lang=XX Language to operate on (default: en) + --port=NNNN Port for serve (default: 8080) + --docker Force Docker mode (default: used when available) + --no-docker Force local mode + --yes, -y Clone missing repositories without asking for confirmation + + Any other argument after the command is passed through: to configure.php + for configure/render (e.g. --enable-xml-details), and to + docbook-cs for cs lint/cs fix (e.g. reference/datetime --wide). + + HELP; + + return 0; + } +} diff --git a/scripts/dev/Command/LintCommand.php b/scripts/dev/Command/LintCommand.php new file mode 100644 index 0000000000..b9ea6346bb --- /dev/null +++ b/scripts/dev/Command/LintCommand.php @@ -0,0 +1,57 @@ +lang; + + if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) { + return 1; + } + + if ($this->configure->execute($options) !== 0) { + echo "\nconfigure reported problems (see above); linting anyway.\n\n"; + } + + $langdir = $this->workspace->langDir($lang); + $args = $options->args; + + if ($this->fix) { + array_unshift($args, '--fix'); + } + + if (!file_exists("$langdir/docbookcs.xml")) { + $template = $this->workspace->getDocbookcsConfig(); + + if ($template === null) { + return 1; + } + + $config = "$langdir/.docbookcs.dev.xml"; + file_put_contents($config, str_replace('@LANG@', $lang, $template)); + register_shutdown_function(static function () use ($config): void { + @unlink($config); + }); + array_unshift($args, '--config=.docbookcs.dev.xml'); + } + + return $this->environment->lint($lang, $args); + } +} diff --git a/scripts/dev/Command/PullCommand.php b/scripts/dev/Command/PullCommand.php new file mode 100644 index 0000000000..8992f6a209 --- /dev/null +++ b/scripts/dev/Command/PullCommand.php @@ -0,0 +1,32 @@ +environment->canMapDirectoryNames(); + + if (!$this->workspace->ensureLangRepos($options->lang, $mapNames)) { + return 1; + } + + $this->workspace->pullSideRepos($options->lang, verbose: true); + $this->environment->refreshTools(); + + return 0; + } +} diff --git a/scripts/dev/Command/RenderCommand.php b/scripts/dev/Command/RenderCommand.php new file mode 100644 index 0000000000..b594fdc339 --- /dev/null +++ b/scripts/dev/Command/RenderCommand.php @@ -0,0 +1,43 @@ +configure->execute($options); + + if ($ret !== 0) { + return $ret; + } + + $lang = $options->lang; + $format = $options->format; + + // PhD never cleans its output directory, so files from removed or + // renamed pages would linger forever. Remove utput tree before rendering. + $stale = $this->workspace->langDir($lang) . '/output/' + . ($format === 'php' ? 'php-web' : 'php-chunked-xhtml'); + + if (is_dir($stale)) { + echo "Removing previous $stale\n"; + $this->workspace->removeTree($stale); + } + + return $this->environment->render($lang, $format); + } +} diff --git a/scripts/dev/Command/ServeCommand.php b/scripts/dev/Command/ServeCommand.php new file mode 100644 index 0000000000..83b5a768a2 --- /dev/null +++ b/scripts/dev/Command/ServeCommand.php @@ -0,0 +1,37 @@ +lang; + $output = $this->workspace->langDir($lang) . '/output'; + + if (!is_dir($output)) { + echo "Note: $output does not exist yet; run \"php dev.php render xhtml --lang=$lang\" first.\n"; + } + + // PhD renders each format into its own subdirectory of output/. + // Serve the chunked XHTML tree directly, so http://localhost:/ + // lands on its index page instead of a 404. + $subdir = is_dir("$output/php-chunked-xhtml") ? '/php-chunked-xhtml' : ''; + + echo "Serving the $lang manual at http://localhost:{$options->port}/ (Ctrl-C to stop)\n"; + + return $this->environment->serve($lang, $options->port, $subdir); + } +} diff --git a/scripts/dev/Command/ShellCommand.php b/scripts/dev/Command/ShellCommand.php new file mode 100644 index 0000000000..f7f9e60ea5 --- /dev/null +++ b/scripts/dev/Command/ShellCommand.php @@ -0,0 +1,20 @@ +environment->shell($options->lang); + } +} diff --git a/scripts/dev/Environment/DockerEnvironment.php b/scripts/dev/Environment/DockerEnvironment.php new file mode 100644 index 0000000000..d1859708e5 --- /dev/null +++ b/scripts/dev/Environment/DockerEnvironment.php @@ -0,0 +1,240 @@ +ensureImage()) { + return 1; + } + + return $this->dockerRun($lang, array_merge(['php', 'doc-base/configure.php'], $args)); + } + + public function render(string $lang, string $format): int + { + if (!$this->ensureImage()) { + return 1; + } + + return $this->dockerRun($lang, [ + 'php', + 'phd/render.php', + '--docbook', + 'doc-base/.manual.xml', + '--output=/var/www/' . $lang . '/output', + '--package', + 'PHP', + '--format', + $format, + ]); + } + + public function lint(string $lang, array $args): int + { + if (!$this->ensureImage()) { + return 1; + } + + return $this->dockerRun( + $lang, + array_merge(['php', '/var/www/docbook-cs/bin/docbook-cs'], $args), + [ + '-e', + 'GIT_CONFIG_COUNT=1', + '-e', + 'GIT_CONFIG_KEY_0=safe.directory', + '-e', + 'GIT_CONFIG_VALUE_0=*', + ], + "/var/www/$lang" + ); + } + + public function serve(string $lang, int $port, string $subdir): int + { + if (!$this->ensureImage()) { + return 1; + } + + // Inside the container the server must bind 0.0.0.0 to be reachable + // through the published port; the host side stays localhost-only. + return $this->dockerRun( + $lang, + ['php', '-S', "0.0.0.0:$port", '-t', "/var/www/$lang/output$subdir"], + ['-p', "127.0.0.1:$port:$port"] + ); + } + + public function shell(string $lang): int + { + if (!$this->ensureImage()) { + return 1; + } + + return $this->dockerRun($lang, ['bash'], ['-it']); + } + + public function buildImage(): int + { + return $this->build() ? 0 : 1; + } + + public function refreshTools(): void + { + // Host checkouts are mounted over the image's copies, so only a + // rebuild can update tools that ship inside the image. + if ($this->usesBakedTools()) { + $this->ensureImage(); + } + } + + private function ensureImage(): bool + { + if (!$this->imageReady) { + $this->imageReady = $this->prepareImage(); + } + + return $this->imageReady; + } + + private function prepareImage(): bool + { + $dockerfile = $this->workspace->basedir() . '/.docker/Dockerfile'; + $stamp = $this->workspace->basedir() . '/.docker/built'; + + $id = $this->runner->output(['docker', 'image', 'inspect', '--format', '{{.Id}}', self::IMAGE]); + + $usable = $id !== null + && file_exists($stamp) + && filemtime($stamp) >= filemtime($dockerfile); + + if (!$usable) { + return $this->build(); + } + + if (!$this->usesBakedTools()) { + return true; + } + + if (!$this->build(quiet: true)) { + echo "Could not refresh the doc-dev image; using the current one.\n"; + return true; + } + + if ($this->runner->output(['docker', 'image', 'inspect', '--format', '{{.Id}}', self::IMAGE]) !== $id) { + echo "Updated phd/docbook-cs (rebuilt the doc-dev image).\n"; + } + + return true; + } + + private function usesBakedTools(): bool + { + $root = $this->workspace->rootdir(); + + return !is_dir("$root/phd") || !is_dir("$root/docbook-cs"); + } + + private function build(bool $quiet = false): bool + { + $cmd = ['docker', 'build']; + $ids = $this->unixIds(); + + if ($ids !== null) { + array_push($cmd, '--build-arg', 'UID=' . $ids[0]); + array_push($cmd, '--build-arg', 'GID=' . $ids[1]); + } + + array_push($cmd, '-t', self::IMAGE, $this->workspace->basedir() . '/.docker'); + + $status = $quiet ? $this->runner->runQuiet($cmd) : $this->runner->run($cmd); + + if ($status !== 0) { + return false; + } + + touch($this->workspace->basedir() . '/.docker/built'); + + return true; + } + + private function mounts(string $lang): array + { + $root = $this->workspace->rootdir(); + $mounts = [realpath($this->workspace->basedir()) => '/var/www/doc-base']; + + $mounts[$this->workspace->langDir($lang)] = "/var/www/$lang"; + + if (!$this->workspace->isBaseLang($lang)) { + $mounts[$this->workspace->langDir('en')] = '/var/www/en'; + } + + foreach (['phd', 'docbook-cs'] as $tool) { + if (is_dir("$root/$tool")) { + $mounts[realpath("$root/$tool")] = "/var/www/$tool"; + } + } + + return $mounts; + } + + /** + * @param list $inner Command to run inside the container. + * @param list $extra Extra docker run arguments. + */ + private function dockerRun(string $lang, array $inner, array $extra = [], string $workdir = '/var/www'): int + { + // --init: without it the command runs as PID 1, which ignores + // SIGINT, so Ctrl-C would leave the container running forever. + $cmd = ['docker', 'run', '--rm', '--init']; + + foreach ($this->mounts($lang) as $host => $container) { + array_push($cmd, '-v', "$host:$container"); + } + + array_push($cmd, '-w', $workdir); + $ids = $this->unixIds(); + + if ($ids !== null) { + array_push($cmd, '-u', $ids[0] . ':' . $ids[1]); + } + + $cmd = array_merge($cmd, $extra); + $cmd[] = self::IMAGE; + + return $this->runner->run(array_merge($cmd, $inner)); + } + + /** @return array{int, int}|null */ + private function unixIds(): ?array + { + if (function_exists('posix_getuid')) { + return [posix_getuid(), posix_getgid()]; + } + + return null; + } +} diff --git a/scripts/dev/Environment/Environment.php b/scripts/dev/Environment/Environment.php new file mode 100644 index 0000000000..998ec006ab --- /dev/null +++ b/scripts/dev/Environment/Environment.php @@ -0,0 +1,35 @@ + checkout can be presented under its language + * name without touching the filesystem (Docker mounts can). + */ + public function canMapDirectoryNames(): bool; + + /** @param list $args configure.php arguments */ + public function configure(string $lang, array $args): int; + + public function render(string $lang, string $format): int; + + /** @param list $args docbook-cs arguments; runs in the language directory */ + public function lint(string $lang, array $args): int; + + /** @param string $subdir Path inside /output to use as web root, or "". */ + public function serve(string $lang, int $port, string $subdir): int; + + public function shell(string $lang): int; + + public function buildImage(): int; + + public function refreshTools(): void; +} diff --git a/scripts/dev/Environment/LocalEnvironment.php b/scripts/dev/Environment/LocalEnvironment.php new file mode 100644 index 0000000000..8e98663de8 --- /dev/null +++ b/scripts/dev/Environment/LocalEnvironment.php @@ -0,0 +1,97 @@ +runner->run( + array_merge([PHP_BINARY, $this->workspace->basedir() . '/configure.php'], $args), + $this->workspace->rootdir() + ); + } + + public function render(string $lang, string $format): int + { + $root = $this->workspace->rootdir(); + + if (!$this->workspace->ensureRepo("$root/phd", 'https://github.com/php/phd.git')) { + return 1; + } + + return $this->runner->run([ + PHP_BINARY, + "$root/phd/render.php", + '--docbook', + $this->workspace->basedir() . '/.manual.xml', + '--output=' . $this->workspace->langDir($lang) . '/output', + '--package', + 'PHP', + '--format', + $format, + ], $root); + } + + public function lint(string $lang, array $args): int + { + $csdir = $this->workspace->rootdir() . '/docbook-cs'; + + if (!$this->workspace->ensureRepo($csdir, 'https://github.com/php/docbook-cs.git')) { + return 1; + } + + return $this->runner->run( + array_merge([PHP_BINARY, "$csdir/bin/docbook-cs"], $args), + $this->workspace->langDir($lang), + ); + } + + public function serve(string $lang, int $port, string $subdir): int + { + return $this->runner->run([ + PHP_BINARY, + '-S', + "localhost:$port", + '-t', + $this->workspace->langDir($lang) . '/output' . $subdir, + ]); + } + + public function shell(string $lang): int + { + fwrite(STDERR, "error: docker shell requires Docker.\n"); + + return 1; + } + + public function buildImage(): int + { + fwrite(STDERR, "error: docker build requires Docker.\n"); + + return 1; + } + + public function refreshTools(): void + { + // Intentionally left empty, Workspace::pullSideRepos() already updates those. + } +} diff --git a/scripts/dev/Options.php b/scripts/dev/Options.php new file mode 100644 index 0000000000..6aa0fd43d6 --- /dev/null +++ b/scripts/dev/Options.php @@ -0,0 +1,21 @@ + */ + public array $args = []; +} diff --git a/scripts/dev/ProcessRunner.php b/scripts/dev/ProcessRunner.php new file mode 100644 index 0000000000..c5017aa190 --- /dev/null +++ b/scripts/dev/ProcessRunner.php @@ -0,0 +1,63 @@ + $cmd + * @param array $env Extra environment variables. + */ + public function run(array $cmd, ?string $cwd = null, array $env = []): int + { + $envp = $env === [] ? null : array_merge(getenv(), $env); + $proc = @proc_open($cmd, [STDIN, STDOUT, STDERR], $pipes, $cwd, $envp); + + if (!is_resource($proc)) { + fwrite(STDERR, "error: failed to execute {$cmd[0]}.\n"); + return 127; + } + + return proc_close($proc); + } + + /** @param list $cmd */ + public function output(array $cmd, ?string $cwd = null): ?string + { + $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']]; + $proc = @proc_open($cmd, $spec, $pipes, $cwd); + + if (!is_resource($proc)) { + return null; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[2]); + + return proc_close($proc) === 0 ? (string) $stdout : null; + } + + /** @param list $cmd */ + public function runQuiet(array $cmd): int + { + $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']]; + $proc = @proc_open($cmd, $spec, $pipes); + + if (!is_resource($proc)) { + return 127; /* command not found */ + } + + fclose($pipes[0]); + stream_get_contents($pipes[1]); + fclose($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[2]); + + return proc_close($proc); + } +} diff --git a/scripts/dev/Workspace.php b/scripts/dev/Workspace.php new file mode 100644 index 0000000000..6c167e8d05 --- /dev/null +++ b/scripts/dev/Workspace.php @@ -0,0 +1,213 @@ +basedir; + } + + public function rootdir(): string + { + return dirname($this->basedir); + } + + public function isBaseLang(string $lang): bool + { + return in_array($lang, ['extensions', 'en'], true); + } + + public function langDir(string $lang): string + { + $root = $this->rootdir(); + + if (is_dir("$root/$lang")) { + return realpath("$root/$lang"); + } + + return realpath("$root/doc-$lang") ?: "$root/$lang"; + } + + public function ensureRepo(string $dir, string $url): bool + { + if (is_dir($dir)) { + return true; + } + + if (!$this->confirm("Clone $url\n into $dir?")) { + fwrite(STDERR, "error: cannot continue without $dir.\n"); + return false; + } + + return $this->runner->run(['git', 'clone', $url, $dir]) === 0; + } + + /** + * @param bool $mapNames Whether the environment can present a + * doc- checkout under its language name + * (Docker mounts can, local builds cannot). + */ + public function ensureLang(string $lang, bool $mapNames): bool + { + $root = $this->rootdir(); + + if (is_dir("$root/$lang")) { + return true; + } + + if (is_dir("$root/doc-$lang")) { + if ($mapNames) { + return true; + } + + if (@symlink("doc-$lang", "$root/$lang")) { + echo "Created symlink $lang -> doc-$lang\n"; + return true; + } + + fwrite(STDERR, "error: found $root/doc-$lang but could not create a '$lang' " + . "symlink next to it; rename the directory to '$lang' or use Docker.\n"); + return false; + } + + $repo = 'doc-' . strtolower($lang); + + return $this->ensureRepo("$root/$lang", "https://github.com/php/$repo.git"); + } + + public function ensureLangRepos(string $lang, bool $mapNames): bool + { + if (!$this->isBaseLang($lang) && !$this->ensureLang('en', $mapNames)) { + return false; + } + + if ($lang === 'en') { + return $this->ensureLang('en', $mapNames); + } + + return $this->ensureLang($lang, $mapNames); + } + + public function pullSideRepos(string $lang, bool $verbose = false): void + { + $root = $this->rootdir(); + $repos = [$this->basedir, $this->langDir('en')]; + + if ($lang !== 'en') { + $repos[] = $this->langDir($lang); + } + + foreach (['phd', 'docbook-cs'] as $tool) { + if (is_dir("$root/$tool")) { + $repos[] = realpath("$root/$tool"); + } + } + + foreach ($repos as $repo) { + $this->pullRepo($repo, $verbose); + } + } + + private function pullRepo(string $dir, bool $verbose): void + { + if (!is_dir("$dir/.git")) { + return; + } + + $name = basename($dir); + $branch = trim((string) $this->runner->output(['git', '-C', $dir, 'rev-parse', '--abbrev-ref', 'HEAD'])); + + if (!in_array($branch, ['master', 'main'], true)) { + if ($verbose) { + echo "Not updating $name: on branch '$branch'.\n"; + } + + return; + } + + $before = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']); + + if ($this->runner->run(['git', '-C', $dir, 'pull', '--ff-only', '--quiet', 'origin', $branch]) !== 0) { + echo "Could not update $name; continuing with the current checkout.\n"; + return; + } + + $after = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']); + + if ($before !== $after) { + echo "Updated $name.\n"; + } elseif ($verbose) { + echo "$name is up to date.\n"; + } + } + + public function removeTree(string $dir): void + { + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($items as $item) { + if ($item->isDir() && !$item->isLink()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($dir); + } + + public function getDocbookcsConfig(): ?string + { + $template = $this->basedir . '/docbookcs.dev.xml'; + $config = @file_get_contents($template); + + if ($config === false) { + fwrite(STDERR, "error: cannot read $template.\n"); + return null; + } + + return $config; + } + + private function confirm(string $question): bool + { + if ($this->assumeYes) { + return true; + } + + if (!stream_isatty(STDIN)) { + fwrite(STDERR, "error: confirmation needed but there is no terminal; re-run with --yes.\n"); + return false; + } + + echo $question . ' [Y/n] '; + $line = fgets(STDIN); + + // EOF (Ctrl-D, closed stdin) is not consent. + if ($line === false) { + echo "\n"; + return false; + } + + $answer = strtolower(trim($line)); + return in_array($answer, ['', 'y', 'yes']); + } +}