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
76 changes: 76 additions & 0 deletions src/Installer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(" <warning>Merge conflict in $rel — conflict markers inserted</warning>");
// $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
Expand Down
56 changes: 56 additions & 0 deletions tests/ModuleInstallerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: "<mode> <sha> <stage>\t<path>"
$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);
Expand Down