@@ -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 */
122127function 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 */
160163function 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) {
206207async 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 = / ^ ( .* ?) (?: \. | % 2 F ) s y n c - c o n f l i c t - ( [ 0 - 9 ] { 8 } ) - ( [ 0 - 9 ] { 6 } ) - ( [ A - Z 0 - 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 */
365325async function startupScan ( ) {
366326 log ( 'SCAN' , `Scanning for existing conflicts in ${ CONFIG . watchPath } ...` ) ;
367-
368327 const allFiles = getAllFiles ( path . resolve ( CONFIG . watchPath ) ) ;
369328 const conflictRegex = / s y n c - c o n f l i c t - [ 0 - 9 ] { 8 } - [ 0 - 9 ] { 6 } - [ A - Z 0 - 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
402357console . log ( `Watching: ${ path . resolve ( CONFIG . watchPath ) } ` ) ;
403358console . log ( `Sync Root: ${ path . resolve ( CONFIG . syncRootPath ) } ` ) ;
404- console . log ( `Versions Dir: ${ CONFIG . versionsDirName } ` ) ;
405359console . 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)' } ` ) ;
407361console . log ( `Pre-merge Backup: ${ CONFIG . backupBeforeMerge ? 'enabled' : 'disabled' } ` ) ;
408362console . log ( `Merge Log: ${ CONFIG . mergeLogPath || 'disabled' } ` ) ;
409363
@@ -414,16 +368,14 @@ if (CONFIG.dryRun) {
414368
415369console . log ( '' ) ;
416370
417- // Run startup scan, then start watcher
418371startupScan ( ) . then ( ( ) => {
419372 console . log ( '' ) ;
420373 log ( 'INFO' , 'Starting file watcher...' ) ;
421374
422375 const watcher = chokidar . watch ( CONFIG . watchPath , {
423- ignored : / ( ^ | [ \/ \\ ] ) \. | n o d e _ m o d u l e s / , // Ignore dotfiles and node_modules
376+ ignored : / ( ^ | [ \/ \\ ] ) \. | n o d e _ m o d u l e s / ,
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