@baseplate-dev/sync
Version:
Library for syncing Baseplate descriptions
94 lines • 4.56 kB
JavaScript
// src/git-merge-driver.ts
import { enhanceErrorWithContext } from '@baseplate-dev/utils';
import { execa, ExecaError, parseCommandString } from 'execa';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* Generates a StringMergeAlgorithm that uses an external Git merge driver.
*
* @param config - Configuration for the Git merge driver.
* @returns A StringMergeAlgorithm function.
*/
export const gitMergeDriverAlgorithmGenerator = (config) =>
// Return the actual merge algorithm function
async (input) => {
let tempDir;
const tempFilePrefix = `merge-${config.name.replaceAll(/[^a-zA-Z0-9]/g, '-')}`;
const fileExtension = path.extname(input.filePath);
const fileBase = `base${fileExtension}`;
const fileCurrent = `current${fileExtension}`; // %A - Driver is expected to write result here
const fileOther = `other${fileExtension}`;
try {
// 1. Create a unique temporary directory
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), `${tempFilePrefix}-`));
// 2. Define file paths
const basePath = path.join(tempDir, fileBase); // %O
const currentPath = path.join(tempDir, fileCurrent); // %A
const otherPath = path.join(tempDir, fileOther); // %B
const outputPath = path.join(tempDir, fileCurrent); // %P
// 3. Write input strings to temporary files
await Promise.all([
fs.writeFile(basePath, input.previousGeneratedText, 'utf8'), // Base
fs.writeFile(currentPath, input.previousWorkingText, 'utf8'), // Ours/Current (%A)
fs.writeFile(otherPath, input.currentGeneratedText, 'utf8'), // Theirs/Other (%B)
]);
// 4. Construct the command
const markerSize = (config.conflictMarkerSize ?? 7).toString();
const labelBase = config.labelBase ?? 'BASE';
const labelCurrent = config.labelCurrent ?? 'CURRENT'; // %X
const labelOther = config.labelOther ?? 'BASEPLATE'; // %Y
const command = config.driver
.replaceAll('%O', basePath)
.replaceAll('%A', currentPath)
.replaceAll('%B', otherPath)
.replaceAll('%P', outputPath)
.replaceAll('%L', markerSize)
.replaceAll('%S', labelBase) // Placeholder for base label (undocumented but sometimes used)
.replaceAll('%X', labelCurrent) // Placeholder for current label
.replaceAll('%Y', labelOther); // Placeholder for other label
let exitCode = 0;
try {
const [file, ...commandArguments] = parseCommandString(command);
await execa(file, commandArguments);
// Exit code 0 implies success (no conflicts reported by driver)
}
catch (error) {
// Check if it's an execution error with an exit code
if (error instanceof ExecaError) {
exitCode = error.exitCode ?? 'unknown';
// Non-zero exit code implies conflict or failure
// Git specifies non-zero for conflict, >128 for crash/signal
if (typeof exitCode !== 'number' || exitCode > 128) {
throw new Error(`Git merge driver '${config.name}' crashed or failed with exit code ${exitCode}: ${error.message}`);
}
// Otherwise, assume non-zero means conflict (exit codes 1-128)
}
else {
// Other errors (e.g., command not found, permission denied)
throw enhanceErrorWithContext(error, `Failed to execute git merge driver '${config.name}'`);
}
}
// 6. Read the result from the file designated by %A
const mergedText = await fs.readFile(currentPath, 'utf8');
// 7. Determine conflict status
// A non-zero exit code reliably indicates conflict *reported by the driver*.
// However, some drivers might exit 0 but still insert markers (like default git merge).
// So, we check both exit code and content.
const hasConflict = exitCode !== 0 || mergedText.includes('<<<<<<<');
return {
mergedText,
hasConflict,
};
}
catch (error) {
throw enhanceErrorWithContext(error, `Error during git merge driver process for '${config.name}'`);
}
finally {
// 8. Clean up temporary directory
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
};
//# sourceMappingURL=git-merge-driver.js.map