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);