Skip to content

Commit 8a16714

Browse files
Fix predictable wiki gitlink auto-resolution (#323)
* Fix predictable wiki gitlink auto-resolution * Stabilize predictable gitlink conflict fixture * Update wiki submodule pointer for PR #323 --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent acbe6d2 commit 8a16714

6 files changed

Lines changed: 263 additions & 3 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}"

.github/wiki

Submodule wiki updated from c2589a2 to 363f1e5

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: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
$unmergedBefore = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);
94+
95+
self::assertStringContainsString(".github/wiki\n", $unmergedBefore->getOutput());
96+
97+
$this->runProcess([self::GITLINK_RESOLVER_PATH, $repository, '.github/wiki'], $this->workspace);
98+
99+
$unmergedAfter = $this->runProcess(['git', 'ls-files', '-u', '--', '.github/wiki'], $repository);
100+
$indexEntry = $this->runProcess(['git', 'ls-files', '-s', '--', '.github/wiki'], $repository);
101+
102+
self::assertSame('', trim($unmergedAfter->getOutput()));
103+
self::assertSame(\sprintf("160000 %s 0\t.github/wiki\n", $oursSha), $indexEntry->getOutput());
104+
}
105+
106+
/**
107+
* @return array{repository: string, ours-sha: string}
108+
*/
109+
private function createRepositoryWithUnmergedWikiGitlinkConflict(): array
110+
{
111+
$wikiRemote = $this->workspace . '/wiki-remote.git';
112+
$wikiSeed = $this->workspace . '/wiki-seed';
113+
$parentRemote = $this->workspace . '/parent-remote.git';
114+
$parentSeed = $this->workspace . '/parent-seed';
115+
$repository = $this->workspace . '/repository';
116+
117+
mkdir($wikiSeed, 0o777, true);
118+
mkdir($parentSeed, 0o777, true);
119+
120+
$this->runProcess(['git', 'init', '--bare', $wikiRemote], $this->workspace);
121+
$this->runProcess(['git', 'init', '--initial-branch=main'], $wikiSeed);
122+
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $wikiSeed);
123+
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $wikiSeed);
124+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n");
125+
$this->runProcess(['git', 'add', 'README.md'], $wikiSeed);
126+
$this->runProcess(['git', 'commit', '-m', 'Seed wiki'], $wikiSeed);
127+
$this->runProcess(['git', 'remote', 'add', 'origin', $wikiRemote], $wikiSeed);
128+
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $wikiSeed);
129+
130+
$baseSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
131+
132+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch B\n");
133+
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch B'], $wikiSeed);
134+
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-b'], $wikiSeed);
135+
$branchBSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
136+
137+
$this->runProcess(['git', 'switch', '--detach', $baseSha], $wikiSeed);
138+
file_put_contents($wikiSeed . '/README.md', "# Wiki\n\nBranch C\n");
139+
$this->runProcess(['git', 'commit', '-am', 'Advance wiki branch C'], $wikiSeed);
140+
$this->runProcess(['git', 'push', 'origin', 'HEAD:refs/heads/branch-c'], $wikiSeed);
141+
$branchCSha = trim($this->runProcess(['git', 'rev-parse', 'HEAD'], $wikiSeed)->getOutput());
142+
143+
$this->runProcess(['git', 'init', '--bare', $parentRemote], $this->workspace);
144+
$this->runProcess(['git', 'init', '--initial-branch=main'], $parentSeed);
145+
$this->runProcess(['git', 'config', 'user.name', 'Test User'], $parentSeed);
146+
$this->runProcess(['git', 'config', 'user.email', 'test@example.com'], $parentSeed);
147+
file_put_contents($parentSeed . '/composer.json', "{\n \"name\": \"fast-forward/dev-tools\"\n}\n");
148+
$this->runProcess(['git', 'add', 'composer.json'], $parentSeed);
149+
$this->runProcess(['git', 'commit', '-m', 'Seed parent repository'], $parentSeed);
150+
$this->runProcess(
151+
[
152+
'git',
153+
'-c',
154+
'protocol.file.allow=always',
155+
'submodule',
156+
'add',
157+
'-b',
158+
'main',
159+
$wikiRemote,
160+
'.github/wiki',
161+
],
162+
$parentSeed,
163+
);
164+
$this->runProcess(['git', 'commit', '-am', 'Add wiki submodule'], $parentSeed);
165+
$this->runProcess(['git', 'remote', 'add', 'origin', $parentRemote], $parentSeed);
166+
$this->runProcess(['git', 'push', '-u', 'origin', 'main'], $parentSeed);
167+
168+
$this->runProcess(['git', 'clone', '--no-tags', $parentRemote, $repository], $this->workspace);
169+
$this->runProcess(['git', 'update-index', '--force-remove', '.github/wiki'], $repository);
170+
$this->runProcessWithInput(
171+
['git', 'update-index', '--index-info'],
172+
$repository,
173+
\sprintf(
174+
"160000 %s 1\t.github/wiki\n160000 %s 2\t.github/wiki\n160000 %s 3\t.github/wiki\n",
175+
$baseSha,
176+
$branchCSha,
177+
$branchBSha,
178+
),
179+
);
180+
181+
return [
182+
'repository' => $repository,
183+
'ours-sha' => $branchCSha,
184+
];
185+
}
186+
187+
/**
188+
* @param array<int, string> $command
189+
* @param string $workingDirectory
190+
*
191+
* @return Process
192+
*/
193+
private function runProcess(array $command, string $workingDirectory): Process
194+
{
195+
$process = new Process($command, $workingDirectory);
196+
$process->mustRun();
197+
198+
return $process;
199+
}
200+
201+
/**
202+
* @param array<int, string> $command
203+
* @param string $workingDirectory
204+
*
205+
* @return Process
206+
*/
207+
private function runProcessAllowingFailure(array $command, string $workingDirectory): Process
208+
{
209+
$process = new Process($command, $workingDirectory);
210+
$process->run();
211+
212+
return $process;
213+
}
214+
215+
/**
216+
* @param array<int, string> $command
217+
* @param string $workingDirectory
218+
* @param string $input
219+
*
220+
* @return Process
221+
*/
222+
private function runProcessWithInput(array $command, string $workingDirectory, string $input): Process
223+
{
224+
$process = new Process($command, $workingDirectory);
225+
$process->setInput($input);
226+
$process->mustRun();
227+
228+
return $process;
229+
}
230+
}

0 commit comments

Comments
 (0)