Skip to content

Commit be7bb2c

Browse files
committed
Fix predictable wiki gitlink auto-resolution
1 parent acbe6d2 commit be7bb2c

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

.github/actions/github/resolve-predictable-conflicts/action.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ runs:
2020
INPUT_BASE_REF: ${{ inputs.base-ref }}
2121
INPUT_PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
2222
DEV_TOOLS_CONFLICT_RESOLVER: ${{ github.action_path }}/resolve-changelog.php
23+
DEV_TOOLS_GITLINK_RESOLVER: ${{ github.action_path }}/stage-unmerged-gitlink.sh
2324
run: ${{ github.action_path }}/run.sh

.github/actions/github/resolve-predictable-conflicts/run.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ resolve_pull_request() {
158158
fi
159159

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

165165
if grep -Fx --quiet -- "CHANGELOG.md" <<< "${conflicts}"; then
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
repository="${1:?Repository path is required.}"
5+
conflict_path="${2:?Conflict path is required.}"
6+
stage="${3:-2}"
7+
8+
entry="$(
9+
git -C "${repository}" ls-files -u -- "${conflict_path}" |
10+
awk -v stage="${stage}" '$3 == stage { print $1 " " $2; exit }'
11+
)"
12+
13+
if [ -z "${entry}" ]; then
14+
printf 'No unmerged stage %s entry was found for %s.\n' "${stage}" "${conflict_path}" >&2
15+
16+
exit 1
17+
fi
18+
19+
mode="${entry%% *}"
20+
object_id="${entry#* }"
21+
22+
if [ "${mode}" != '160000' ]; then
23+
printf 'Path %s is not a gitlink conflict (mode %s).\n' "${conflict_path}" "${mode}" >&2
24+
25+
exit 1
26+
fi
27+
28+
git -C "${repository}" update-index --cacheinfo "${mode},${object_id},${conflict_path}"

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
- Keep release-preparation wiki preview refreshes installing Composer plugins so `phpdocumentor/shim` still exposes `phpdoc` when release pull request creation rebuilds `.github/wiki` (#318)
1717
- 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)
18+
- 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)
1819

1920
## [1.24.6] - 2026-04-30
2021

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Fast Forward Development Tools for PHP projects.
7+
*
8+
* This file is part of fast-forward/dev-tools project.
9+
*
10+
* @author Felipe Sayão Lobato Abreu <github@mentordosnerds.com>
11+
* @license https://opensource.org/licenses/MIT MIT License
12+
*
13+
* @see https://github.com/php-fast-forward/
14+
* @see https://github.com/php-fast-forward/dev-tools
15+
* @see https://github.com/php-fast-forward/dev-tools/issues
16+
* @see https://php-fast-forward.github.io/dev-tools/
17+
* @see https://datatracker.ietf.org/doc/html/rfc2119
18+
*/
19+
20+
namespace FastForward\DevTools\Tests\GitHubActions;
21+
22+
use FilesystemIterator;
23+
use PHPUnit\Framework\Attributes\CoversNothing;
24+
use PHPUnit\Framework\Attributes\Test;
25+
use PHPUnit\Framework\TestCase;
26+
use RecursiveDirectoryIterator;
27+
use RecursiveIteratorIterator;
28+
use SplFileInfo;
29+
use Symfony\Component\Process\Process;
30+
31+
use function Safe\file_put_contents;
32+
use function Safe\mkdir;
33+
use function Safe\rmdir;
34+
use function Safe\unlink;
35+
36+
#[CoversNothing]
37+
final class ResolvePredictableConflictsActionTest extends TestCase
38+
{
39+
private const string GITLINK_RESOLVER_PATH = __DIR__ . '/../../.github/actions/github/resolve-predictable-conflicts/stage-unmerged-gitlink.sh';
40+
41+
private string $workspace;
42+
43+
/**
44+
* @return void
45+
*/
46+
protected function setUp(): void
47+
{
48+
$this->workspace = sys_get_temp_dir() . '/resolve-predictable-conflicts-action-test-' . bin2hex(
49+
random_bytes(4)
50+
);
51+
mkdir($this->workspace, 0o777, true);
52+
}
53+
54+
/**
55+
* @return void
56+
*/
57+
protected function tearDown(): void
58+
{
59+
if (! is_dir($this->workspace)) {
60+
return;
61+
}
62+
63+
$iterator = new RecursiveIteratorIterator(
64+
new RecursiveDirectoryIterator($this->workspace, FilesystemIterator::SKIP_DOTS),
65+
RecursiveIteratorIterator::CHILD_FIRST,
66+
);
67+
68+
/** @var SplFileInfo $item */
69+
foreach ($iterator as $item) {
70+
if ($item->isDir()) {
71+
rmdir($item->getPathname());
72+
73+
continue;
74+
}
75+
76+
unlink($item->getPathname());
77+
}
78+
79+
rmdir($this->workspace);
80+
}
81+
82+
/**
83+
* @return void
84+
*/
85+
#[Test]
86+
public function gitlinkResolverWillStageTheCurrentBranchPointerWithoutMaterializingTheSubmoduleCheckout(): void
87+
{
88+
[
89+
'repository' => $repository,
90+
'ours-sha' => $oursSha,
91+
] = $this->createRepositoryWithUnmergedWikiGitlinkConflict();
92+
93+
$addProcess = $this->runProcessAllowingFailure(['git', 'add', '.github/wiki'], $repository);
94+
$unmergedBefore = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);
95+
96+
self::assertNotSame(0, $addProcess->getExitCode());
97+
self::assertStringContainsString(".github/wiki\n", $unmergedBefore->getOutput());
98+
99+
$this->runProcess([self::GITLINK_RESOLVER_PATH, $repository, '.github/wiki'], $this->workspace);
100+
101+
$unmergedAfter = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);
102+
$indexEntry = $this->runProcess(['git', 'ls-files', '-s', '--', '.github/wiki'], $repository);
103+
104+
self::assertSame('', trim($unmergedAfter->getOutput()));
105+
self::assertSame(\sprintf("160000 %s 0\t.github/wiki\n", $oursSha), $indexEntry->getOutput());
106+
}
107+
108+
/**
109+
* @return array{repository: string, ours-sha: string}
110+
*/
111+
private function createRepositoryWithUnmergedWikiGitlinkConflict(): array
112+
{
113+
$wikiRemote = $this->workspace . '/wiki-remote.git';
114+
$wikiSeed = $this->workspace . '/wiki-seed';
115+
$parentRemote = $this->workspace . '/parent-remote.git';
116+
$parentSeed = $this->workspace . '/parent-seed';
117+
$repository = $this->workspace . '/repository';
118+
119+
mkdir($wikiSeed, 0o777, true);
120+
mkdir($parentSeed, 0o777, true);
121+
122+
$this->runProcess(['git', 'init', '--bare', $wikiRemote], $this->workspace);
123+
$this->runProcess(['git', 'init', '--initial-branch=main'], $wikiSeed);
124+
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $wikiSeed);
125+
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $wikiSeed);
126+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n");
127+
$this->runProcess(['git', 'add', 'README.md'], $wikiSeed);
128+
$this->runProcess(['git', 'commit', '-m', 'Seed wiki'], $wikiSeed);
129+
$this->runProcess(['git', 'remote', 'add', 'origin', $wikiRemote], $wikiSeed);
130+
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $wikiSeed);
131+
132+
$baseSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
133+
134+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch B\n");
135+
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch B'], $wikiSeed);
136+
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-b'], $wikiSeed);
137+
$branchBSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
138+
139+
$this->runProcess(['git', 'switch', '--detach', $baseSha], $wikiSeed);
140+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch C\n");
141+
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch C'], $wikiSeed);
142+
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-c'], $wikiSeed);
143+
$branchCSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
144+
145+
$this->runProcess(['git', 'init', '--bare', $parentRemote], $this->workspace);
146+
$this->runProcess(['git', 'init', '--initial-branch=main'], $parentSeed);
147+
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $parentSeed);
148+
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $parentSeed);
149+
file_put_contents($parentSeed . '/composer.json', "{\n \"name\": \"fast-forward/dev-tools\"\n}\n");
150+
$this->runProcess(['git', 'add', 'composer.json'], $parentSeed);
151+
$this->runProcess(['git', 'commit', '-m', 'Seed parent repository'], $parentSeed);
152+
$this->runProcess(
153+
[
154+
'git',
155+
'-c',
156+
'protocol.file.allow=always',
157+
'submodule',
158+
'add',
159+
'-b',
160+
'main',
161+
$wikiRemote,
162+
'.github/wiki',
163+
],
164+
$parentSeed,
165+
);
166+
$this->runProcess(['git', 'commit', '-am', 'Add wiki submodule'], $parentSeed);
167+
$this->runProcess(['git', 'remote', 'add', 'origin', $parentRemote], $parentSeed);
168+
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $parentSeed);
169+
170+
$this->runProcess(['git', 'switch', '-c', 'feature'], $parentSeed);
171+
$this->runProcess(['git', 'fetch', 'origin', 'branch-b'], $parentSeed . '/.github/wiki');
172+
$this->runProcess(['git', 'checkout', $branchBSha], $parentSeed . '/.github/wiki');
173+
$this->runProcess(['git', 'add', '.github/wiki'], $parentSeed);
174+
$this->runProcess(['git', 'commit', '-m', 'Point wiki to branch B'], $parentSeed);
175+
$this->runProcess(['git', 'push', '-u', 'origin', 'feature'], $parentSeed);
176+
177+
$this->runProcess(['git', 'switch', 'main'], $parentSeed);
178+
$this->runProcess(['git', 'fetch', 'origin', 'branch-c'], $parentSeed . '/.github/wiki');
179+
$this->runProcess(['git', 'checkout', $branchCSha], $parentSeed . '/.github/wiki');
180+
$this->runProcess(['git', 'add', '.github/wiki'], $parentSeed);
181+
$this->runProcess(['git', 'commit', '-m', 'Point wiki to branch C'], $parentSeed);
182+
$this->runProcess(['git', 'push', 'origin', 'main'], $parentSeed);
183+
184+
$this->runProcess(['git', 'clone', '--no-tags', $parentRemote, $repository], $this->workspace);
185+
$this->runProcess(['git', 'fetch', 'origin', 'feature:refs/remotes/origin/feature'], $repository);
186+
187+
$mergeProcess = $this->runProcessAllowingFailure(
188+
['git', 'merge', '--no-commit', '--no-ff', 'refs/remotes/origin/feature'],
189+
$repository,
190+
);
191+
192+
self::assertNotSame(0, $mergeProcess->getExitCode());
193+
self::assertStringContainsString(".github/wiki\n", $this->runProcess(
194+
['git', 'ls-files', '-u', '--', '.github/wiki'],
195+
$repository,
196+
)->getOutput());
197+
198+
return [
199+
'repository' => $repository,
200+
'ours-sha' => $branchCSha,
201+
];
202+
}
203+
204+
/**
205+
* @param array<int, string> $command
206+
* @param string $workingDirectory
207+
*
208+
* @return Process
209+
*/
210+
private function runProcess(array $command, string $workingDirectory): Process
211+
{
212+
$process = new Process($command, $workingDirectory);
213+
$process->mustRun();
214+
215+
return $process;
216+
}
217+
218+
/**
219+
* @param array<int, string> $command
220+
* @param string $workingDirectory
221+
*
222+
* @return Process
223+
*/
224+
private function runProcessAllowingFailure(array $command, string $workingDirectory): Process
225+
{
226+
$process = new Process($command, $workingDirectory);
227+
$process->run();
228+
229+
return $process;
230+
}
231+
}

0 commit comments

Comments
 (0)