@@ -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 */
9192function 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 */
127131function 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 = / ^ ~ s y n c t h i n g ~ .* s y n c - c o n f l i c t .* \. t m p $ / 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 */
325449async function startupScan ( ) {
326450 log ( 'SCAN' , `Scanning for existing conflicts in ${ CONFIG . watchPath } ...` ) ;
327451 const allFiles = getAllFiles ( path . resolve ( CONFIG . watchPath ) ) ;
328452 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;
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
369497console . 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
371505startupScan ( ) . then ( ( ) => {
372506 console . log ( '' ) ;
373507 log ( 'INFO' , 'Starting file watcher...' ) ;
0 commit comments