Skip to content

Commit 690e655

Browse files
jaapiolinawolf
andauthored
[FEAT] Redirect generator based on git changes. (#989)
This is a prove of concept PR, showcasing how we could possibly generate a list of redirects. This is not complete and should be evaluated carefully. The purpose of the new command is to be able to automatically create directs for file that have been removed. For now we only use the git history, but we might want to expand this. For example to use the perma links used within most documentation. To test this command you have to build the docker file for your local development. Using `make docker-build` . After that the command can be tested on you local machine. ``` docker run -i --rm --user 1000 -v${PWD}:/project typo3-docs:local create-redirects-from-git -b 3eb3cfc ``` Where `3eb3cfc` is a short sha of the project you are processing. In my example I used: https://github.com/TYPO3-Documentation/TYPO3CMS-Guide-HowToDocument The output of the command is now written to a file: `redirects.nginx.conf` ``` # Nginx redirects for moved files in Documentation # Generated on: 2025-06-10 20:22:48 location = /HowToAddTranslation { return 301 /Advanced/HowToAddTranslation; } location = /GeneralConventions/ReviewInformation { return 301 /Advanced/ReviewInformation; } location = /About { return 301 /Basics/About; } location = /BasicPrinciples { return 301 /Basics/BasicPrinciples; } location = /GeneralConventions/CodingGuidelines { return 301 /Basics/GeneralConventions/CodingGuidelines; } location = /GeneralConventions/ContentStyleGuide { return 301 /Basics/GeneralConventions/ContentStyleGuide; } location = /GeneralConventions/Format { return 301 /Basics/GeneralConventions/Format; } location = /GeneralConventions/Glossary { return 301 /Basics/GeneralConventions/Glossary; } location = /GeneralConventions/GuidelinesForImages { return 301 /Basics/GeneralConventions/GuidelinesForImages; } location = /GeneralConventions { return 301 /Basics/GeneralConventions; } location = /GeneralConventions/Licenses { return 301 /Basics/GeneralConventions/Licenses; } ``` This is just a real quick showcase, we could implement this for example in the github action, to automatically create a pr or issue to publish the content. --------- Co-authored-by: lina.wolf <lwolf@w-commerce.de> Co-authored-by: Lina Wolf <48202465+linawolf@users.noreply.github.com>
1 parent e573c0a commit 690e655

File tree

7 files changed

+331
-0
lines changed

7 files changed

+331
-0
lines changed

.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ tools/
55
vendor/
66
Dockerfile
77
*.rst
8+
build

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ ENV TYPO3AZUREEDGEURIVERSION=$TYPO3AZUREEDGEURIVERSION
2020
WORKDIR /project
2121
ENTRYPOINT [ "/opt/guides/entrypoint.sh" ]
2222
CMD ["-h"]
23+
24+
RUN apk add --no-cache \
25+
git

entrypoint.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ elif [ "$1" = "configure" ]; then
7777
elif [ "$1" = "render" ]; then
7878
ENTRYPOINT="${ENTRYPOINT_DEFAULT}"
7979
shift
80+
elif [ "$1" = "create-redirects-from-git" ]; then
81+
ENTRYPOINT="${ENTRYPOINT_SYMFONY_COMMANDS} create-redirects-from-git"
82+
shift
8083
else
8184
# Default: "render"; no shifting.
8285
ENTRYPOINT="${ENTRYPOINT_DEFAULT}"

packages/typo3-guides-cli/bin/typo3-guides

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use T3Docs\GuidesCli\Command\InitCommand;
99
use T3Docs\GuidesCli\Command\MigrateSettingsCommand;
1010
use T3Docs\GuidesCli\Command\ConfigureCommand;
1111
use T3Docs\GuidesCli\Command\LintGuidesXmlCommand;
12+
use T3Docs\GuidesCli\Command\CreateRedirectsFromGitCommand;
1213

1314
use Symfony\Component\Console\Application;
1415

@@ -39,5 +40,6 @@ $application->add(new MigrateSettingsCommand());
3940
$application->add(new InitCommand());
4041
$application->add(new ConfigureCommand());
4142
$application->add(new LintGuidesXmlCommand());
43+
$application->add(new CreateRedirectsFromGitCommand());
4244

4345
$application->run();
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace T3Docs\GuidesCli\Command;
6+
7+
use Symfony\Component\Console\Command\Command;
8+
use Symfony\Component\Console\Input\InputInterface;
9+
use Symfony\Component\Console\Output\OutputInterface;
10+
use Symfony\Component\Console\Style\SymfonyStyle;
11+
use Symfony\Component\Console\Input\InputOption;
12+
use T3Docs\GuidesCli\Git\GitChangeDetector;
13+
use T3Docs\GuidesCli\Redirect\RedirectCreator;
14+
15+
final class CreateRedirectsFromGitCommand extends Command
16+
{
17+
protected static $defaultName = 'create-redirects-from-git';
18+
19+
private GitChangeDetector $gitChangeDetector;
20+
private RedirectCreator $redirectCreator;
21+
22+
public function __construct(
23+
?GitChangeDetector $gitChangeDetector = null,
24+
?RedirectCreator $redirectCreator = null
25+
) {
26+
parent::__construct();
27+
$this->gitChangeDetector = $gitChangeDetector ?? new GitChangeDetector();
28+
$this->redirectCreator = $redirectCreator ?? new RedirectCreator();
29+
}
30+
31+
protected function configure(): void
32+
{
33+
$this->setDescription('Creates nginx redirects for files moved in a GitHub pull request.');
34+
$this->setHelp(
35+
<<<'EOT'
36+
The <info>%command.name%</info> command analyzes git history to detect moved files
37+
in the current branch/PR and creates appropriate nginx redirects for them.
38+
39+
<info>$ php %command.name% [options]</info>
40+
41+
EOT
42+
);
43+
44+
$this->addOption(
45+
'base-branch',
46+
'b',
47+
InputOption::VALUE_REQUIRED,
48+
'The base branch to compare changes against (default: main)',
49+
'main'
50+
);
51+
52+
$this->addOption(
53+
'docs-path',
54+
'd',
55+
InputOption::VALUE_REQUIRED,
56+
'Path to the Documentation directory',
57+
'Documentation'
58+
);
59+
60+
$this->addOption(
61+
'output-file',
62+
'o',
63+
InputOption::VALUE_REQUIRED,
64+
'Path to the nginx redirect configuration output file',
65+
'redirects.nginx.conf'
66+
);
67+
$this->addOption(
68+
'versions',
69+
'r',
70+
InputOption::VALUE_REQUIRED,
71+
'Regex of versions to include',
72+
'(main|13.4|12.4)'
73+
);
74+
$this->addOption(
75+
'path',
76+
'p',
77+
InputOption::VALUE_REQUIRED,
78+
'Path, for example /m/typo3/reference-coreapi/',
79+
'/'
80+
);
81+
}
82+
83+
protected function execute(InputInterface $input, OutputInterface $output): int
84+
{
85+
$io = new SymfonyStyle($input, $output);
86+
87+
$baseBranch = $input->getOption('base-branch');
88+
$docsPath = $input->getOption('docs-path');
89+
$outputFile = $input->getOption('output-file');
90+
$versions = $input->getOption('versions');
91+
$path = $input->getOption('path');
92+
93+
if (!is_string($baseBranch)) {
94+
$io->error('Base branch must be a string.');
95+
return Command::FAILURE;
96+
}
97+
98+
if (!is_string($docsPath)) {
99+
$io->error('Documentation path must be a string.');
100+
return Command::FAILURE;
101+
}
102+
103+
if (!is_string($outputFile)) {
104+
$io->error('Output file must be a string.');
105+
return Command::FAILURE;
106+
}
107+
108+
if (!is_string($versions) || preg_match($versions, '') === false) {
109+
$io->error('Versions must be valid regex.');
110+
return Command::FAILURE;
111+
}
112+
113+
if (!is_string($path)) {
114+
$io->error('Path must be a string.');
115+
return Command::FAILURE;
116+
}
117+
118+
$io->title('Creating nginx redirects from git history');
119+
$io->text("Base branch: {$baseBranch}");
120+
$io->text("Documentation path: {$docsPath}");
121+
$io->text("Output file: {$outputFile}");
122+
$io->text("Versions regex: {$versions}");
123+
$io->text("Path: {$path}");
124+
125+
try {
126+
$movedFiles = $this->gitChangeDetector->detectMovedFiles($baseBranch, $docsPath);
127+
128+
if (empty($movedFiles)) {
129+
$io->success('No moved files detected in this PR.');
130+
return Command::SUCCESS;
131+
}
132+
133+
$io->section('Detected moved files:');
134+
foreach ($movedFiles as $oldPath => $newPath) {
135+
$io->text("- <info>{$oldPath}</info> → <info>{$newPath}</info>");
136+
}
137+
138+
$this->redirectCreator->setNginxRedirectFile($outputFile);
139+
140+
141+
$createdRedirects = $this->redirectCreator->createRedirects($movedFiles, $docsPath, $versions, $path);
142+
143+
$io->section('Created nginx redirects:');
144+
foreach ($createdRedirects as $source => $target) {
145+
$sourceUrl = str_replace($docsPath . '/', '', $source);
146+
$sourceUrl = preg_replace('/\.(rst|md)$/', '', $sourceUrl);
147+
148+
$targetUrl = str_replace($docsPath . '/', '', $target);
149+
$targetUrl = preg_replace('/\.(rst|md)$/', '', $targetUrl);
150+
if (is_string($targetUrl) === false) {
151+
$io->error('Target construct failed');
152+
return Command::FAILURE;
153+
}
154+
155+
$io->text("- <info>/{$sourceUrl}</info> → <info>/{$targetUrl}</info>");
156+
}
157+
158+
$io->success(sprintf('Nginx redirects created successfully in %s!', $outputFile));
159+
return Command::SUCCESS;
160+
} catch (\Exception $e) {
161+
$io->error($e->getMessage());
162+
return Command::FAILURE;
163+
}
164+
}
165+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace T3Docs\GuidesCli\Git;
6+
7+
/**
8+
* Detects file changes in git, specifically focusing on moved files
9+
*/
10+
class GitChangeDetector
11+
{
12+
/** @return array<string, string> */
13+
public function detectMovedFiles(string $baseBranch, string $docsPath): array
14+
{
15+
$movedFiles = [];
16+
17+
// Get the common ancestor commit between current branch and base branch
18+
$mergeBase = trim($this->executeGitCommand("merge-base {$baseBranch} HEAD"));
19+
20+
if (empty($mergeBase)) {
21+
throw new \RuntimeException('Could not determine merge base with the specified branch.');
22+
}
23+
24+
// Use git diff to find renamed files
25+
// --diff-filter=R shows only renamed files
26+
// -M detects renames
27+
// --name-status shows the status and filenames
28+
$command = "diff {$mergeBase} HEAD --diff-filter=R -M --name-status";
29+
$output = $this->executeGitCommand($command);
30+
31+
// Parse the output to extract renamed files
32+
$lines = explode("\n", $output);
33+
foreach ($lines as $line) {
34+
if (empty($line)) {
35+
continue;
36+
}
37+
38+
// Format is: R<score>\t<old-file>\t<new-file>
39+
$parts = preg_split('/\s+/', $line, 3);
40+
if ($parts === false) {
41+
continue;
42+
}
43+
44+
if (count($parts) !== 3 || !str_starts_with($parts[0], 'R')) {
45+
continue;
46+
}
47+
48+
$oldPath = trim($parts[1]);
49+
$newPath = trim($parts[2]);
50+
51+
if ($this->isDocumentationFile($oldPath, $docsPath) && $this->isDocumentationFile($newPath, $docsPath)) {
52+
$movedFiles[$oldPath] = $newPath;
53+
}
54+
}
55+
56+
return $movedFiles;
57+
}
58+
59+
private function isDocumentationFile(string $filePath, string $docsPath): bool
60+
{
61+
return str_starts_with($filePath, $docsPath)
62+
&& (str_ends_with($filePath, '.rst') || str_ends_with($filePath, '.md'));
63+
}
64+
65+
private function executeGitCommand(string $command): string
66+
{
67+
$fullCommand = "git {$command} 2>&1";
68+
$output = [];
69+
$returnCode = 0;
70+
71+
exec($fullCommand, $output, $returnCode);
72+
73+
if ($returnCode !== 0) {
74+
throw new \RuntimeException('Git command failed: ' . implode("\n", $output));
75+
}
76+
77+
return implode("\n", $output);
78+
}
79+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace T3Docs\GuidesCli\Redirect;
6+
7+
/**
8+
* Creates nginx redirect configurations for moved documentation files
9+
*/
10+
class RedirectCreator
11+
{
12+
private string $nginxRedirectFile = 'redirects.nginx.conf';
13+
14+
/**
15+
* @param array<string, string> $movedFiles
16+
* @return array<string, string>
17+
*/
18+
public function createRedirects(array $movedFiles, string $docsPath, string $versions, string $path): array
19+
{
20+
$createdRedirects = [];
21+
$nginxRedirects = [];
22+
23+
foreach ($movedFiles as $oldPath => $newPath) {
24+
$oldRelativePath = $this->stripDocsPathPrefix($oldPath, $docsPath);
25+
$newRelativePath = $this->stripDocsPathPrefix($newPath, $docsPath);
26+
27+
$oldUrlPath = $this->convertToUrlPath($oldRelativePath);
28+
$newUrlPath = $this->convertToUrlPath($newRelativePath);
29+
30+
$nginxRedirects[] = sprintf("location = ^%s%s/en-us/%s { return 301 %s$1/en-us/%s; }", $path, $versions, $oldUrlPath, $path, $newUrlPath);
31+
32+
$createdRedirects[$oldPath] = $newPath;
33+
}
34+
35+
if (!empty($nginxRedirects)) {
36+
$nginxConfig = "# Nginx redirects for moved files in Documentation\n";
37+
$nginxConfig .= "# Generated on: " . date('Y-m-d H:i:s') . "\n\n";
38+
$nginxConfig .= implode("\n", $nginxRedirects) . "\n";
39+
40+
file_put_contents($this->nginxRedirectFile, $nginxConfig);
41+
}
42+
43+
return $createdRedirects;
44+
}
45+
46+
/**
47+
* Set a custom path for the nginx redirect configuration file
48+
*/
49+
public function setNginxRedirectFile(string $filePath): void
50+
{
51+
$this->nginxRedirectFile = $filePath;
52+
}
53+
54+
private function stripDocsPathPrefix(string $path, string $docsPath): string
55+
{
56+
if (str_starts_with($path, $docsPath . '/')) {
57+
return substr($path, strlen($docsPath) + 1);
58+
}
59+
return $path;
60+
}
61+
62+
private function convertToUrlPath(string $path): string
63+
{
64+
$path = preg_replace('/\.(rst|md)$/', '.html', $path);
65+
if (is_string($path) === false) {
66+
throw new \RuntimeException('Failed to convert path to URL format');
67+
}
68+
69+
if (basename($path) === 'Index') {
70+
$path = dirname($path);
71+
if ($path === '.') {
72+
$path = '';
73+
}
74+
}
75+
76+
return ltrim($path, '/');
77+
}
78+
}

0 commit comments

Comments
 (0)