Skip to content

Commit 0aeff9a

Browse files
authored
Update syncthing-deconflict.js
1 parent bf0892f commit 0aeff9a

1 file changed

Lines changed: 156 additions & 22 deletions

File tree

syncthing-deconflict.js

Lines changed: 156 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ function appendMergeLog(entry) {
8787

8888
/**
8989
* Format a merge log entry.
90+
* All paths are relative to the Syncthing root folder.
9091
*/
9192
function formatMergeLogEntry(originalFile, conflictFile, baseFile, status, error = null) {
9293
const now = new Date();
@@ -122,7 +123,10 @@ function verifyGitAvailable() {
122123

123124
/**
124125
* Executes the git 3-way merge command.
125-
* Returns true on success, false on failure.
126+
* Returns the git exit code:
127+
* 0 = clean merge (no conflicts)
128+
* >0 = number of conflict markers inserted
129+
* -1 = dry run mode
126130
*/
127131
function mergeFiles(original, base, conflict) {
128132
// Dynamically include --union flag based on configuration
@@ -131,7 +135,7 @@ function mergeFiles(original, base, conflict) {
131135

132136
log('MERGE', `${CONFIG.gitBinary} ${args.join(' ')}`);
133137

134-
if (CONFIG.dryRun) return true;
138+
if (CONFIG.dryRun) return -1;
135139

136140
const result = spawnSync(CONFIG.gitBinary, args, {
137141
stdio: 'pipe',
@@ -154,7 +158,105 @@ function mergeFiles(original, base, conflict) {
154158
log('WARN', `Git stderr: ${result.stderr.trim()}`);
155159
}
156160

157-
return true;
161+
return result.status || 0;
162+
}
163+
164+
/**
165+
* Attempt to clean up Syncthing's temporary ~syncthing~*.tmp files.
166+
* These can be left behind after conflict resolution.
167+
* Handles the case where the file might still be locked by Syncthing.
168+
*/
169+
function cleanupSyncthingTemp(originalFilePath) {
170+
const dir = path.dirname(originalFilePath);
171+
const fileName = path.basename(originalFilePath);
172+
173+
// Syncthing temp files follow the pattern: ~syncthing~<filename>.tmp
174+
const tempFileName = `~syncthing~${fileName}.tmp`;
175+
const tempFilePath = path.join(dir, tempFileName);
176+
177+
if (!fs.existsSync(tempFilePath)) {
178+
return; // No temp file to clean up
179+
}
180+
181+
try {
182+
fs.unlinkSync(tempFilePath);
183+
log('CLEAN', `Removed Syncthing temp file: ${tempFileName}`);
184+
} catch (err) {
185+
if (err.code === 'EBUSY' || err.code === 'EACCES' || err.code === 'EPERM') {
186+
log('WARN', `Could not remove temp file "${tempFileName}" - likely still in use by Syncthing`);
187+
} else {
188+
log('WARN', `Failed to remove temp file "${tempFileName}": ${err.message}`);
189+
}
190+
}
191+
}
192+
193+
/**
194+
* Scan for and remove leftover Syncthing temp files for conflicts at startup.
195+
* These are ghost files left behind from interrupted syncs.
196+
* Pattern: ~syncthing~*sync-conflict*.tmp
197+
*/
198+
function cleanupPreviousGhosts() {
199+
const absRoot = path.resolve(CONFIG.syncRootPath);
200+
log('GHOST', `Scanning for leftover conflict temp files...`);
201+
202+
const allFiles = getAllFilesIncludingHidden(path.resolve(CONFIG.watchPath));
203+
const ghostRegex = /^~syncthing~.*sync-conflict.*\.tmp$/i;
204+
const ghosts = allFiles.filter(f => ghostRegex.test(path.basename(f)));
205+
206+
if (ghosts.length === 0) {
207+
log('GHOST', 'No ghost temp files found.');
208+
return;
209+
}
210+
211+
log('GHOST', `Found ${ghosts.length} ghost temp file(s). Cleaning up...`);
212+
213+
for (const ghost of ghosts) {
214+
const relativePath = path.relative(absRoot, ghost);
215+
try {
216+
if (!CONFIG.dryRun) {
217+
fs.unlinkSync(ghost);
218+
}
219+
log('GHOST', `Removed: ${relativePath}${CONFIG.dryRun ? ' (dry run)' : ''}`);
220+
} catch (err) {
221+
if (err.code === 'EBUSY' || err.code === 'EACCES' || err.code === 'EPERM') {
222+
log('WARN', `Could not remove ghost "${relativePath}" - likely still in use`);
223+
} else {
224+
log('WARN', `Failed to remove ghost "${relativePath}": ${err.message}`);
225+
}
226+
}
227+
}
228+
}
229+
230+
/**
231+
* Helper to find all files in a directory recursively (including hidden files starting with ~).
232+
* Used specifically for finding Syncthing temp files.
233+
*/
234+
function getAllFilesIncludingHidden(dirPath, arrayOfFiles = []) {
235+
if (!fs.existsSync(dirPath)) return [];
236+
237+
const files = fs.readdirSync(dirPath);
238+
239+
files.forEach(file => {
240+
// Skip .dotfiles and node_modules, but allow ~syncthing~ files
241+
if ((file.startsWith('.') && !file.startsWith('~')) || file === 'node_modules') {
242+
return;
243+
}
244+
245+
const fullPath = path.join(dirPath, file);
246+
try {
247+
if (fs.statSync(fullPath).isDirectory()) {
248+
getAllFilesIncludingHidden(fullPath, arrayOfFiles);
249+
} else {
250+
arrayOfFiles.push(fullPath);
251+
}
252+
} catch (err) {
253+
if (CONFIG.verbose) {
254+
log('WARN', `Could not stat ${fullPath}: ${err.message}`);
255+
}
256+
}
257+
});
258+
259+
return arrayOfFiles;
158260
}
159261

160262
/**
@@ -231,8 +333,13 @@ async function handleFileEvent(filePath, isStartupScan = false) {
231333

232334
processingFiles.add(absConflictPath);
233335

234-
let originalFileName = '';
235-
let latestBackupName = '';
336+
// Get absolute root path for relative path calculations
337+
const absRoot = path.resolve(CONFIG.syncRootPath);
338+
339+
// Store relative paths for logging
340+
let relativeOriginalPath = '';
341+
let relativeConflictPath = '';
342+
let relativeBasePath = '';
236343

237344
try {
238345
const source = isStartupScan ? 'SCAN' : 'FOUND';
@@ -244,15 +351,18 @@ async function handleFileEvent(filePath, isStartupScan = false) {
244351

245352
if (!fs.existsSync(absConflictPath)) return;
246353

247-
originalFileName = extension ? `${baseName}.${extension}` : baseName;
354+
const originalFileName = extension ? `${baseName}.${extension}` : baseName;
248355
const originalFilePath = path.join(path.dirname(absConflictPath), originalFileName);
249356

357+
// Calculate relative paths for logging
358+
relativeOriginalPath = path.relative(absRoot, originalFilePath);
359+
relativeConflictPath = path.relative(absRoot, absConflictPath);
360+
250361
if (!fs.existsSync(originalFilePath)) {
251-
log('SKIP', `Original file "${originalFileName}" not found.`);
362+
log('SKIP', `Original file "${relativeOriginalPath}" not found.`);
252363
return;
253364
}
254365

255-
const absRoot = path.resolve(CONFIG.syncRootPath);
256366
const relativeToRoot = path.relative(absRoot, originalFilePath);
257367
const relativeDir = path.dirname(relativeToRoot);
258368
const specificBackupFolder = path.join(absRoot, CONFIG.versionsDirName, relativeDir);
@@ -275,43 +385,56 @@ async function handleFileEvent(filePath, isStartupScan = false) {
275385
.reverse();
276386

277387
if (backupCandidates.length === 0) {
278-
log('SKIP', `No historical versions found for "${originalFileName}"`);
388+
log('SKIP', `No historical versions found for "${relativeOriginalPath}"`);
279389
return;
280390
}
281391

282392
const latestBackup = backupCandidates[0];
283-
latestBackupName = path.basename(latestBackup);
393+
relativeBasePath = path.relative(absRoot, latestBackup);
284394

285-
log('INFO', `Merging: "${originalFileName}" (ours) + "${fileName}" (theirs)`);
395+
log('INFO', `Merging: "${relativeOriginalPath}" (ours) + "${path.basename(absConflictPath)}" (theirs)`);
286396

287397
if (CONFIG.backupBeforeMerge && !CONFIG.dryRun) {
288398
const backup = createBackup(originalFilePath);
289399
log('BACKUP', `Created pre-merge backup: ${path.basename(backup)}`);
290400
}
291401

292-
mergeFiles(originalFilePath, latestBackup, absConflictPath);
402+
const mergeExitCode = mergeFiles(originalFilePath, latestBackup, absConflictPath);
293403

294404
log('CLEAN', `Deleting conflict file: ${fileName}`);
295405
if (!CONFIG.dryRun) {
296406
fs.unlinkSync(absConflictPath);
407+
408+
// Attempt to clean up any Syncthing temp files for the conflict file
409+
cleanupSyncthingTemp(absConflictPath);
297410
}
298411

299-
log('SUCCESS', `Resolved: ${originalFileName}`);
412+
// Determine merge status based on exit code
413+
let status;
414+
if (CONFIG.dryRun) {
415+
status = 'Resolved (dry run)';
416+
} else if (mergeExitCode === 0) {
417+
status = 'Resolved (Clean Merge)';
418+
log('SUCCESS', `Resolved: ${relativeOriginalPath} - Clean Merge`);
419+
} else {
420+
status = `Resolved (Conflict Markers Inserted: ${mergeExitCode})`;
421+
log('SUCCESS', `Resolved: ${relativeOriginalPath} - ${mergeExitCode} conflict marker(s) inserted`);
422+
}
300423

301424
appendMergeLog(formatMergeLogEntry(
302-
originalFileName,
303-
fileName,
304-
latestBackupName,
305-
CONFIG.dryRun ? '✅ Resolved (dry run)' : '✅ Resolved'
425+
relativeOriginalPath,
426+
relativeConflictPath,
427+
relativeBasePath,
428+
status
306429
));
307430

308431
} catch (err) {
309432
log('ERROR', `Resolution failed: ${err.message}`);
310433
appendMergeLog(formatMergeLogEntry(
311-
originalFileName || fileName,
312-
fileName,
313-
latestBackupName || 'N/A',
314-
'Failed',
434+
relativeOriginalPath || fileName,
435+
relativeConflictPath || fileName,
436+
relativeBasePath || 'N/A',
437+
'Failed',
315438
err.message
316439
));
317440
} finally {
@@ -321,12 +444,17 @@ async function handleFileEvent(filePath, isStartupScan = false) {
321444

322445
/**
323446
* Scan for existing conflict files at startup.
447+
* Ignores .tmp files to avoid processing Syncthing temp files.
324448
*/
325449
async function startupScan() {
326450
log('SCAN', `Scanning for existing conflicts in ${CONFIG.watchPath}...`);
327451
const allFiles = getAllFiles(path.resolve(CONFIG.watchPath));
328452
const conflictRegex = /sync-conflict-[0-9]{8}-[0-9]{6}-[A-Z0-9]{7}/i;
329-
const conflicts = allFiles.filter(f => conflictRegex.test(path.basename(f)));
453+
const conflicts = allFiles.filter(f => {
454+
const basename = path.basename(f);
455+
// Must match conflict pattern AND must NOT be a .tmp file
456+
return conflictRegex.test(basename) && !basename.endsWith('.tmp');
457+
});
330458

331459
if (conflicts.length === 0) {
332460
log('SCAN', 'No existing conflicts found.');
@@ -368,6 +496,12 @@ if (CONFIG.dryRun) {
368496

369497
console.log('');
370498

499+
// Step 1: Clean up any leftover ghost temp files from previous runs
500+
cleanupPreviousGhosts();
501+
502+
console.log('');
503+
504+
// Step 2: Process any existing conflict files
371505
startupScan().then(() => {
372506
console.log('');
373507
log('INFO', 'Starting file watcher...');

0 commit comments

Comments
 (0)