Skip to content

Commit 359a3eb

Browse files
authored
Update syncthing-deconflict.js
1 parent 5dd48f7 commit 359a3eb

1 file changed

Lines changed: 19 additions & 68 deletions

File tree

syncthing-deconflict.js

Lines changed: 19 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const CONFIG = {
2121
settleDelayMs: parseInt(process.env.SETTLE_DELAY) || 250,
2222
dryRun: process.env.DRY_RUN === 'true',
2323

24+
// TOGGLE MERGE STRATEGY:
25+
// true: No markers, but might duplicate lines (Safer for data)
26+
// false: Intelligent merge, but inserts <<<<<< markers on direct conflicts (Cleaner for Markdown)
27+
useUnionMerge: process.env.USE_UNION_MERGE === 'true',
28+
2429
// ONLY process these extensions (comma-separated, no dots)
2530
allowedExtensions: (process.env.ALLOWED_EXTENSIONS || 'md,txt,json,yaml,yml,org,canvas,taskpaper')
2631
.split(',')
@@ -120,7 +125,10 @@ function verifyGitAvailable() {
120125
* Returns true on success, false on failure.
121126
*/
122127
function mergeFiles(original, base, conflict) {
123-
const args = ['merge-file', '--union', original, base, conflict];
128+
// Dynamically include --union flag based on configuration
129+
const flags = CONFIG.useUnionMerge ? ['--union'] : [];
130+
const args = ['merge-file', ...flags, original, base, conflict];
131+
124132
log('MERGE', `${CONFIG.gitBinary} ${args.join(' ')}`);
125133

126134
if (CONFIG.dryRun) return true;
@@ -130,19 +138,15 @@ function mergeFiles(original, base, conflict) {
130138
encoding: 'utf8'
131139
});
132140

133-
// Check if spawn itself failed (e.g., binary not found)
134141
if (result.error) {
135142
throw new Error(`Git failed to execute: ${result.error.message}`);
136143
}
137144

138145
// git merge-file returns:
139146
// 0 = clean merge
140-
// >0 = number of conflicts (but --union auto-resolves these)
141-
// -1 = error (but this comes as result.error, not status)
142-
// With --union, any positive exit code still means the merge completed
143-
144-
if (result.status < 0) {
145-
// Negative status indicates a signal termination
147+
// >0 = number of conflicts
148+
// If not using --union, result.status > 0 is common and means markers were inserted.
149+
if (result.status !== null && result.status < 0) {
146150
throw new Error(`Git terminated by signal: ${result.signal}`);
147151
}
148152

@@ -155,15 +159,13 @@ function mergeFiles(original, base, conflict) {
155159

156160
/**
157161
* Helper to find all files in a directory recursively.
158-
* Ignores dotfiles, dotfolders, and node_modules (matching chokidar behavior).
159162
*/
160163
function getAllFiles(dirPath, arrayOfFiles = []) {
161164
if (!fs.existsSync(dirPath)) return [];
162165

163166
const files = fs.readdirSync(dirPath);
164167

165168
files.forEach(file => {
166-
// Skip dotfiles/dotfolders and node_modules
167169
if (file.startsWith('.') || file === 'node_modules') {
168170
return;
169171
}
@@ -176,7 +178,6 @@ function getAllFiles(dirPath, arrayOfFiles = []) {
176178
arrayOfFiles.push(fullPath);
177179
}
178180
} catch (err) {
179-
// File may have been deleted between readdir and stat
180181
if (CONFIG.verbose) {
181182
log('WARN', `Could not stat ${fullPath}: ${err.message}`);
182183
}
@@ -206,28 +207,16 @@ function createBackup(filePath) {
206207
async function handleFileEvent(filePath, isStartupScan = false) {
207208
const absConflictPath = path.resolve(filePath);
208209

209-
// Debounce: skip if already processing this file
210-
if (processingFiles.has(absConflictPath)) {
211-
if (CONFIG.verbose) {
212-
log('DEBOUNCE', `Already processing: ${absConflictPath}`);
213-
}
214-
return;
215-
}
210+
if (processingFiles.has(absConflictPath)) return;
216211

217-
// Check file exists and is a regular file
218212
try {
219213
const stats = fs.lstatSync(absConflictPath);
220214
if (!stats.isFile()) return;
221215
} catch (err) {
222-
// File doesn't exist or can't be accessed
223216
return;
224217
}
225218

226219
const fileName = path.basename(absConflictPath);
227-
228-
// Syncthing conflict pattern:
229-
// name.sync-conflict-YYYYMMDD-HHMMSS-XXXXXXX.ext
230-
// or URL-encoded variant with %2F
231220
const conflictRegex = /^(.*?)(?:\.|%2F)sync-conflict-([0-9]{8})-([0-9]{6})-([A-Z0-9]{7})\.?(.*)$/i;
232221
const match = fileName.match(conflictRegex);
233222

@@ -236,40 +225,25 @@ async function handleFileEvent(filePath, isStartupScan = false) {
236225
const [_full, baseName, _date, _time, _id, extension] = match;
237226
const extLower = (extension || '').toLowerCase();
238227

239-
// --- EXTENSION SECURITY CHECK ---
240-
// For extensionless files, check if empty string is in allowed list
241-
// (by default it's not, so extensionless files are skipped)
242228
if (!CONFIG.allowedExtensions.includes(extLower)) {
243-
if (CONFIG.verbose) {
244-
const extDisplay = extLower ? `.${extLower}` : '(no extension)';
245-
log('IGNORE', `Conflict found for "${fileName}", but extension "${extDisplay}" is not in allowed list.`);
246-
}
247229
return;
248230
}
249231

250-
// Mark as processing to prevent duplicate handling
251232
processingFiles.add(absConflictPath);
252233

253-
// Variables for merge log
254234
let originalFileName = '';
255235
let latestBackupName = '';
256236

257237
try {
258238
const source = isStartupScan ? 'SCAN' : 'FOUND';
259239
log(source, `Valid conflict file: ${fileName}`);
260240

261-
// Wait for Syncthing to finish disk I/O (skip on startup scan)
262241
if (!isStartupScan) {
263242
await new Promise(resolve => setTimeout(resolve, CONFIG.settleDelayMs));
264243
}
265244

266-
// Verify file still exists after delay
267-
if (!fs.existsSync(absConflictPath)) {
268-
log('SKIP', `Conflict file disappeared: ${fileName}`);
269-
return;
270-
}
245+
if (!fs.existsSync(absConflictPath)) return;
271246

272-
// 1. Determine the path of the Original file
273247
originalFileName = extension ? `${baseName}.${extension}` : baseName;
274248
const originalFilePath = path.join(path.dirname(absConflictPath), originalFileName);
275249

@@ -278,7 +252,6 @@ async function handleFileEvent(filePath, isStartupScan = false) {
278252
return;
279253
}
280254

281-
// 2. Locate the backup in .stversions
282255
const absRoot = path.resolve(CONFIG.syncRootPath);
283256
const relativeToRoot = path.relative(absRoot, originalFilePath);
284257
const relativeDir = path.dirname(relativeToRoot);
@@ -289,38 +262,33 @@ async function handleFileEvent(filePath, isStartupScan = false) {
289262
return;
290263
}
291264

292-
// Match the Syncthing versioning format: filename~YYYYMMDD-HHMMSS.ext
293265
const escapedBase = escapeRegex(baseName);
294266
const escapedExt = escapeRegex(extension);
295267

296-
// Build regex that handles both files with and without extensions
297268
const backupRegex = extension
298269
? new RegExp(`^${escapedBase}~([0-9]{8})-([0-9]{6})\\.${escapedExt}$`)
299270
: new RegExp(`^${escapedBase}~([0-9]{8})-([0-9]{6})$`);
300271

301272
const backupCandidates = getAllFiles(specificBackupFolder)
302273
.filter(f => backupRegex.test(path.basename(f)))
303274
.sort()
304-
.reverse(); // Most recent first (lexicographic sort works for YYYYMMDD-HHMMSS)
275+
.reverse();
305276

306277
if (backupCandidates.length === 0) {
307-
log('SKIP', `No historical versions found for "${originalFileName}" in ${specificBackupFolder}`);
278+
log('SKIP', `No historical versions found for "${originalFileName}"`);
308279
return;
309280
}
310281

311282
const latestBackup = backupCandidates[0];
312283
latestBackupName = path.basename(latestBackup);
313284

314-
log('INFO', `Using base version: ${latestBackupName}`);
315285
log('INFO', `Merging: "${originalFileName}" (ours) + "${fileName}" (theirs)`);
316286

317-
// Optional: Create safety backup before merging
318287
if (CONFIG.backupBeforeMerge && !CONFIG.dryRun) {
319288
const backup = createBackup(originalFilePath);
320289
log('BACKUP', `Created pre-merge backup: ${path.basename(backup)}`);
321290
}
322291

323-
// Perform the merge
324292
mergeFiles(originalFilePath, latestBackup, absConflictPath);
325293

326294
log('CLEAN', `Deleting conflict file: ${fileName}`);
@@ -330,7 +298,6 @@ async function handleFileEvent(filePath, isStartupScan = false) {
330298

331299
log('SUCCESS', `Resolved: ${originalFileName}`);
332300

333-
// Write to merge log
334301
appendMergeLog(formatMergeLogEntry(
335302
originalFileName,
336303
fileName,
@@ -339,22 +306,15 @@ async function handleFileEvent(filePath, isStartupScan = false) {
339306
));
340307

341308
} catch (err) {
342-
log('ERROR', `Resolution failed for ${fileName}: ${err.message}`);
343-
if (CONFIG.verbose) {
344-
console.error(err.stack);
345-
}
346-
347-
// Write error to merge log
309+
log('ERROR', `Resolution failed: ${err.message}`);
348310
appendMergeLog(formatMergeLogEntry(
349311
originalFileName || fileName,
350312
fileName,
351313
latestBackupName || 'N/A',
352314
'❌ Failed',
353315
err.message
354316
));
355-
356317
} finally {
357-
// Always remove from processing set
358318
processingFiles.delete(absConflictPath);
359319
}
360320
}
@@ -364,10 +324,8 @@ async function handleFileEvent(filePath, isStartupScan = false) {
364324
*/
365325
async function startupScan() {
366326
log('SCAN', `Scanning for existing conflicts in ${CONFIG.watchPath}...`);
367-
368327
const allFiles = getAllFiles(path.resolve(CONFIG.watchPath));
369328
const conflictRegex = /sync-conflict-[0-9]{8}-[0-9]{6}-[A-Z0-9]{7}/i;
370-
371329
const conflicts = allFiles.filter(f => conflictRegex.test(path.basename(f)));
372330

373331
if (conflicts.length === 0) {
@@ -376,12 +334,9 @@ async function startupScan() {
376334
}
377335

378336
log('SCAN', `Found ${conflicts.length} existing conflict(s). Processing...`);
379-
380337
for (const conflict of conflicts) {
381338
await handleFileEvent(conflict, true);
382339
}
383-
384-
log('SCAN', 'Startup scan complete.');
385340
}
386341

387342
// --- STARTUP ---
@@ -401,9 +356,8 @@ try {
401356

402357
console.log(`Watching: ${path.resolve(CONFIG.watchPath)}`);
403358
console.log(`Sync Root: ${path.resolve(CONFIG.syncRootPath)}`);
404-
console.log(`Versions Dir: ${CONFIG.versionsDirName}`);
405359
console.log(`Allowed Exts: ${CONFIG.allowedExtensions.join(', ')}`);
406-
console.log(`Settle Delay: ${CONFIG.settleDelayMs}ms`);
360+
console.log(`Union Merge Mode: ${CONFIG.useUnionMerge ? 'ENABLED (Safe/Duplicate lines)' : 'DISABLED (Smart/Markers)'}`);
407361
console.log(`Pre-merge Backup: ${CONFIG.backupBeforeMerge ? 'enabled' : 'disabled'}`);
408362
console.log(`Merge Log: ${CONFIG.mergeLogPath || 'disabled'}`);
409363

@@ -414,16 +368,14 @@ if (CONFIG.dryRun) {
414368

415369
console.log('');
416370

417-
// Run startup scan, then start watcher
418371
startupScan().then(() => {
419372
console.log('');
420373
log('INFO', 'Starting file watcher...');
421374

422375
const watcher = chokidar.watch(CONFIG.watchPath, {
423-
ignored: /(^|[\/\\])\.|node_modules/, // Ignore dotfiles and node_modules
376+
ignored: /(^|[\/\\])\.|node_modules/,
424377
persistent: true,
425378
ignoreInitial: true,
426-
// Add some stability options
427379
awaitWriteFinish: {
428380
stabilityThreshold: 100,
429381
pollInterval: 50
@@ -438,7 +390,6 @@ startupScan().then(() => {
438390
log('INFO', 'Watcher started. Waiting for conflict files...');
439391
console.log('');
440392

441-
// Graceful shutdown
442393
function shutdown() {
443394
console.log('');
444395
log('INFO', 'Shutting down...');

0 commit comments

Comments
 (0)