diff --git a/src/Installer.php b/src/Installer.php index d57eb61..8181372 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -269,12 +269,88 @@ protected function mergeStash(string $stash, string $base, string $install): voi // exit code >0 = conflict markers inserted, but result is still usable if ($process->getExitCode() > 0) { $this->io->writeError(" Merge conflict in $rel — conflict markers inserted"); + // $newFile is still the clean upstream copy here — rename happens at line below. + $this->stageConflictInIndex($stashFile, $baseFile, $newFile, $install, $rel); } (new SymfonyFilesystem)->rename($merged, $newFile, true); } } + /** + * Register the three pre-merge file versions in git's index at stages 1/2/3 so the + * working-tree file (with conflict markers) appears as a real conflict in `git status`, + * VSCode's Source Control panel, and the merge editor. + * + * Fails silently — if git is unavailable or $installPath is outside a repo, the conflict + * markers remain in the file but the index is left unchanged. + */ + protected function stageConflictInIndex( + string $ours, + string $base, + string $theirs, + string $installPath, + string $relativePathname + ): void { + try { + $gitRootProc = new Process(['git', '-C', $installPath, 'rev-parse', '--show-toplevel']); + $gitRootProc->run(); + if (! $gitRootProc->isSuccessful()) { + return; + } + $root = rtrim($gitRootProc->getOutput(), "\r\n"); + + $absFile = $installPath.DIRECTORY_SEPARATOR.$relativePathname; + $absFile = str_replace('\\', '/', realpath($absFile) ?: $absFile); + $rootNorm = rtrim(str_replace('\\', '/', $root), '/'); + if (strpos($absFile, $rootNorm.'/') !== 0) { + return; + } + $relFromRoot = substr($absFile, strlen($rootNorm) + 1); + + $hashBase = new Process(['git', '-C', $root, 'hash-object', '-w', $base]); + $hashOurs = new Process(['git', '-C', $root, 'hash-object', '-w', $ours]); + $hashTheirs = new Process(['git', '-C', $root, 'hash-object', '-w', $theirs]); + $hashBase->run(); + $hashOurs->run(); + $hashTheirs->run(); + + if (! $hashBase->isSuccessful() || ! $hashOurs->isSuccessful() || ! $hashTheirs->isSuccessful()) { + return; + } + + $shaBase = trim($hashBase->getOutput()); + $shaOurs = trim($hashOurs->getOutput()); + $shaTheirs = trim($hashTheirs->getOutput()); + + foreach ([$shaBase, $shaOurs, $shaTheirs] as $sha) { + if (! preg_match('/^[0-9a-f]{40}$/', $sha)) { + return; + } + } + + // --force-remove is required because the working-tree file already exists + // (written by downloadFresh); plain --remove is a no-op when the file is present. + $remove = new Process(['git', '-C', $root, 'update-index', '--force-remove', $relFromRoot]); + $remove->run(); + if (! $remove->isSuccessful()) { + return; + } + + $indexInfo = implode("\n", [ + "100644 {$shaBase} 1\t{$relFromRoot}", + "100644 {$shaOurs} 2\t{$relFromRoot}", + "100644 {$shaTheirs} 3\t{$relFromRoot}", + ])."\n"; + + $updateIndex = new Process(['git', '-C', $root, 'update-index', '--index-info']); + $updateIndex->setInput($indexInfo); + $updateIndex->run(); + } catch (\Throwable $e) { + // Degrade gracefully — conflict markers remain in the file. + } + } + /** * Returns true when the package is served by a local path repository. * Path repos have source == install path, so the normal download/delete cycle would diff --git a/tests/ModuleInstallerTest.php b/tests/ModuleInstallerTest.php index c650218..3f349c9 100644 --- a/tests/ModuleInstallerTest.php +++ b/tests/ModuleInstallerTest.php @@ -17,6 +17,7 @@ use Saucebase\ModuleInstaller\Exceptions\ModuleInstallerException; use Saucebase\ModuleInstaller\Installer; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Process\Process; /** * Shim that avoids LibraryInstaller's heavy constructor. @@ -66,6 +67,16 @@ public function callMergeStash(string $stash, string $base, string $install): vo parent::mergeStash($stash, $base, $install); } + public function callStageConflictInIndex( + string $ours, + string $base, + string $theirs, + string $installPath, + string $relativePathname + ): void { + parent::stageConflictInIndex($ours, $base, $theirs, $installPath, $relativePathname); + } + public function callRestoreStash(string $stashPath, PackageInterface $package): void { parent::restoreStash($stashPath, $package); @@ -457,6 +468,51 @@ public function test_merge_stash_inserts_conflict_markers_on_overlapping_edits() $fs->remove($install); } + public function test_stage_conflict_in_index_registers_git_conflict_stages(): void + { + $repo = sys_get_temp_dir().'/conflict-index-test-'.uniqid('', true); + mkdir($repo.'/modules/auth', 0755, true); + + (new Process(['git', 'init'], $repo))->mustRun(); + (new Process(['git', 'config', 'user.email', 'test@test.com'], $repo))->mustRun(); + (new Process(['git', 'config', 'user.name', 'Test'], $repo))->mustRun(); + + // Commit an initial file so the index has a known baseline + file_put_contents($repo.'/modules/auth/api.php', "original\n"); + (new Process(['git', 'add', '.'], $repo))->mustRun(); + (new Process(['git', 'commit', '-m', 'init'], $repo))->mustRun(); + + // Simulate the three versions available at conflict time + $oursFile = sys_get_temp_dir().'/ours-'.uniqid('', true).'.php'; + $baseFile = sys_get_temp_dir().'/base-'.uniqid('', true).'.php'; + file_put_contents($oursFile, "user-change\n"); + file_put_contents($baseFile, "original\n"); + // The install-path file holds upstream content (not yet overwritten by merge result) + file_put_contents($repo.'/modules/auth/api.php', "upstream-change\n"); + + $installer = new TestableInstaller($this->createStub(IOInterface::class), null); + $installer->callStageConflictInIndex( + $oursFile, + $baseFile, + $repo.'/modules/auth/api.php', + $repo.'/modules/auth', + 'api.php' + ); + + $lsFiles = new Process(['git', 'ls-files', '--stage', 'modules/auth/api.php'], $repo); + $lsFiles->run(); + $output = $lsFiles->getOutput(); + + // ls-files --stage format: " \t" + $this->assertStringContainsString(" 1\t", $output, 'Stage 1 (base) should be registered'); + $this->assertStringContainsString(" 2\t", $output, 'Stage 2 (ours) should be registered'); + $this->assertStringContainsString(" 3\t", $output, 'Stage 3 (theirs) should be registered'); + + unlink($oursFile); + unlink($baseFile); + (new Filesystem)->remove($repo); + } + public function test_merge_stash_keeps_user_added_files(): void { $io = $this->createStub(IOInterface::class);