Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ runs:
INPUT_BASE_REF: ${{ inputs.base-ref }}
INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
DEV_TOOLS_CONFLICT_RESOLVER: ${{ github.action_path }}/resolve-changelog.php
DEV_TOOLS_GITLINK_RESOLVER: ${{ github.action_path }}/stage-unmerged-gitlink.sh
run: ${{ github.action_path }}/run.sh
4 changes: 2 additions & 2 deletions .github/actions/github/resolve-predictable-conflicts/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ resolve_pull_request() {
fi

if grep -Fx --quiet -- ".github/wiki" <<< "${conflicts}"; then
git -C "${workdir}/repo" checkout --ours -- .github/wiki
git -C "${workdir}/repo" add .github/wiki
# Resolve the gitlink directly from the index so uninitialized submodules do not break staging.
"${DEV_TOOLS_GITLINK_RESOLVER}" "${workdir}/repo" ".github/wiki"
fi

if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail

repository="${1:?Repository path is required.}"
conflict_path="${2:?Conflict path is required.}"
stage="${3:-2}"

entry="$(
git -C "${repository}" ls-files -u -- "${conflict_path}" |
awk -v stage="${stage}" '$3 == stage { print $1 " " $2; exit }'
)"

if [ -z "${entry}" ]; then
printf 'No unmerged stage %s entry was found for %s.\n' "${stage}" "${conflict_path}" >&2

exit 1
fi

mode="${entry%% *}"
object_id="${entry#* }"

if [ "${mode}" != '160000' ]; then
printf 'Path %s is not a gitlink conflict (mode %s).\n' "${conflict_path}" "${mode}" >&2

exit 1
fi

git -C "${repository}" update-index --cacheinfo "${mode},${object_id},${conflict_path}"
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from c2589a to 363f1e
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Keep release-preparation wiki preview refreshes installing Composer plugins so `phpdocumentor/shim` still exposes `phpdoc` when release pull request creation rebuilds `.github/wiki` (#318)
- Keep release-preparation pull requests refreshing their wiki preview and parent `.github/wiki` pointer before merge, then publish merged release wikis from that preview branch so branch protection no longer requires direct post-merge pointer commits to `main` (#315)
- Resolve predictable `.github/wiki` gitlink conflicts by staging the current-branch submodule pointer directly from the merge index, so auto-resolve automation no longer depends on `git add` for uninitialized submodule checkouts (#321)

## [1.24.6] - 2026-04-30

Expand Down
230 changes: 230 additions & 0 deletions tests/GitHubActions/ResolvePredictableConflictsActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php

declare(strict_types=1);

/**
* Fast Forward Development Tools for PHP projects.
*
* This file is part of fast-forward/dev-tools project.
*
* @author Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
* @license https://opensource.org/licenses/MIT MIT License
*
* @see https://github.com/php-fast-forward/
* @see https://github.com/php-fast-forward/dev-tools
* @see https://github.com/php-fast-forward/dev-tools/issues
* @see https://php-fast-forward.github.io/dev-tools/
* @see https://datatracker.ietf.org/doc/html/rfc2119
*/

namespace FastForward\DevTools\Tests\GitHubActions;

use FilesystemIterator;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Symfony\Component\Process\Process;

use function Safe\file_put_contents;
use function Safe\mkdir;
use function Safe\rmdir;
use function Safe\unlink;

#[CoversNothing]
final class ResolvePredictableConflictsActionTest extends TestCase
{
private const string GITLINK_RESOLVER_PATH = __DIR__ . '/../../.github/actions/github/resolve-predictable-conflicts/stage-unmerged-gitlink.sh';

private string $workspace;

/**
* @return void
*/
protected function setUp(): void
{
$this->workspace = sys_get_temp_dir() . '/resolve-predictable-conflicts-action-test-' . bin2hex(
random_bytes(4)
);
mkdir($this->workspace, 0o777, true);
}

/**
* @return void
*/
protected function tearDown(): void
{
if (! is_dir($this->workspace)) {
return;
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->workspace, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);

/** @var SplFileInfo $item */
foreach ($iterator as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());

continue;
}

unlink($item->getPathname());
}

rmdir($this->workspace);
}

/**
* @return void
*/
#[Test]
public function gitlinkResolverWillStageTheCurrentBranchPointerWithoutMaterializingTheSubmoduleCheckout(): void
{
[
'repository' => $repository,
'ours-sha' => $oursSha,
] = $this->createRepositoryWithUnmergedWikiGitlinkConflict();

$unmergedBefore = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);

self::assertStringContainsString(".github/wiki\n", $unmergedBefore->getOutput());

$this->runProcess([self::GITLINK_RESOLVER_PATH, $repository, '.github/wiki'], $this->workspace);

$unmergedAfter = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);
$indexEntry = $this->runProcess(['git', 'ls-files', '-s', '--', '.github/wiki'], $repository);

self::assertSame('', trim($unmergedAfter->getOutput()));
self::assertSame(\sprintf("160000 %s 0\t.github/wiki\n", $oursSha), $indexEntry->getOutput());
}

/**
* @return array{repository: string, ours-sha: string}
*/
private function createRepositoryWithUnmergedWikiGitlinkConflict(): array
{
$wikiRemote = $this->workspace . '/wiki-remote.git';
$wikiSeed = $this->workspace . '/wiki-seed';
$parentRemote = $this->workspace . '/parent-remote.git';
$parentSeed = $this->workspace . '/parent-seed';
$repository = $this->workspace . '/repository';

mkdir($wikiSeed, 0o777, true);
mkdir($parentSeed, 0o777, true);

$this->runProcess(['git', 'init', '--bare', $wikiRemote], $this->workspace);
$this->runProcess(['git', 'init', '--initial-branch=main'], $wikiSeed);
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $wikiSeed);
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $wikiSeed);
file_put_contents($wikiSeed . '/README.md', "# Wiki\n");
$this->runProcess(['git', 'add', 'README.md'], $wikiSeed);
$this->runProcess(['git', 'commit', '-m', 'Seed wiki'], $wikiSeed);
$this->runProcess(['git', 'remote', 'add', 'origin', $wikiRemote], $wikiSeed);
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $wikiSeed);

$baseSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());

file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch B\n");
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch B'], $wikiSeed);
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-b'], $wikiSeed);
$branchBSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());

$this->runProcess(['git', 'switch', '--detach', $baseSha], $wikiSeed);
file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch C\n");
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch C'], $wikiSeed);
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-c'], $wikiSeed);
$branchCSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());

$this->runProcess(['git', 'init', '--bare', $parentRemote], $this->workspace);
$this->runProcess(['git', 'init', '--initial-branch=main'], $parentSeed);
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $parentSeed);
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $parentSeed);
file_put_contents($parentSeed . '/composer.json', "{\n \"name\": \"fast-forward/dev-tools\"\n}\n");
$this->runProcess(['git', 'add', 'composer.json'], $parentSeed);
$this->runProcess(['git', 'commit', '-m', 'Seed parent repository'], $parentSeed);
$this->runProcess(
[
'git',
'-c',
'protocol.file.allow=always',
'submodule',
'add',
'-b',
'main',
$wikiRemote,
'.github/wiki',
],
$parentSeed,
);
$this->runProcess(['git', 'commit', '-am', 'Add wiki submodule'], $parentSeed);
$this->runProcess(['git', 'remote', 'add', 'origin', $parentRemote], $parentSeed);
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $parentSeed);

$this->runProcess(['git', 'clone', '--no-tags', $parentRemote, $repository], $this->workspace);
$this->runProcess(['git', 'update-index', '--force-remove', '.github/wiki'], $repository);
$this->runProcessWithInput(
['git', 'update-index', '--index-info'],
$repository,
\sprintf(
"160000 %s 1\t.github/wiki\n160000 %s 2\t.github/wiki\n160000 %s 3\t.github/wiki\n",
$baseSha,
$branchCSha,
$branchBSha,
),
);

return [
'repository' => $repository,
'ours-sha' => $branchCSha,
];
}

/**
* @param array<int, string> $command
* @param string $workingDirectory
*
* @return Process
*/
private function runProcess(array $command, string $workingDirectory): Process
{
$process = new Process($command, $workingDirectory);
$process->mustRun();

return $process;
}

/**
* @param array<int, string> $command
* @param string $workingDirectory
*
* @return Process
*/
private function runProcessAllowingFailure(array $command, string $workingDirectory): Process
{
$process = new Process($command, $workingDirectory);
$process->run();

return $process;
}

/**
* @param array<int, string> $command
* @param string $workingDirectory
* @param string $input
*
* @return Process
*/
private function runProcessWithInput(array $command, string $workingDirectory, string $input): Process
{
$process = new Process($command, $workingDirectory);
$process->setInput($input);
$process->mustRun();

return $process;
}
}
Loading