11/**
22 * Syncthing 3-Way Merge Deconflicter (Node.js)
33 *
4- * Automatically resolves Syncthing conflicts by performing a git three-way merge.
5- * Prevents nested markers and handles locked temporary files .
4+ * Automatically resolves Syncthing conflicts via git three-way merge.
5+ * Features: Anti-nesting, Device ID detection, and cross-platform temp cleanup .
66 */
77
88require ( 'dotenv' ) . config ( ) ;
@@ -18,7 +18,7 @@ const CONFIG = {
1818 syncRootPath : process . env . SYNC_ROOT || './' ,
1919 versionsDirName : process . env . VERSIONS_DIR || '.stversions' ,
2020 gitBinary : process . env . GIT_BIN || 'git' ,
21- settleDelayMs : parseInt ( process . env . SETTLE_DELAY ) || 2500 , // User set to 2500
21+ settleDelayMs : parseInt ( process . env . SETTLE_DELAY ) || 2500 ,
2222 dryRun : process . env . DRY_RUN === 'true' ,
2323
2424 useUnionMerge : process . env . USE_UNION_MERGE === 'true' ,
@@ -47,7 +47,7 @@ function escapeRegex(str) {
4747
4848/**
4949 * UNIFIED FILE WALKER
50- * Fast-tracks Syncthing temporary files while ignoring standard hidden folders .
50+ * Identifies Syncthing temp files using a broad pattern to ensure no ghosts are missed .
5151 */
5252function getFilesRecursive ( dirPath , arrayOfFiles = [ ] ) {
5353 if ( ! fs . existsSync ( dirPath ) ) return [ ] ;
@@ -57,18 +57,19 @@ function getFilesRecursive(dirPath, arrayOfFiles = []) {
5757 files . forEach ( file => {
5858 const fullPath = path . join ( dirPath , file ) ;
5959 let stat ;
60-
6160 try {
6261 stat = fs . statSync ( fullPath ) ;
6362 } catch ( err ) { return ; }
6463
6564 if ( stat . isDirectory ( ) ) {
65+ // Standard directory ignore, but allow .stversions
6666 if ( ( file . startsWith ( '.' ) && file !== CONFIG . versionsDirName ) || file === 'node_modules' ) {
6767 return ;
6868 }
6969 getFilesRecursive ( fullPath , arrayOfFiles ) ;
7070 } else {
71- const isSyncthingTemp = / ^ [ . ~ ] s y n c t h i n g ~ .* \. t m p $ / i. test ( file ) ;
71+ // Robust pattern: Matches .syncthing. and ~syncthing~ and variations
72+ const isSyncthingTemp = / [ . ~ ] s y n c t h i n g [ . ~ ] .* \. t m p $ / i. test ( file ) ;
7273 const isHidden = file . startsWith ( '.' ) || file . startsWith ( '~' ) ;
7374
7475 if ( ! isHidden || isSyncthingTemp ) {
@@ -91,15 +92,16 @@ function appendMergeLog(entry) {
9192 }
9293 fs . appendFileSync ( logPath , entry ) ;
9394 } catch ( err ) {
94- log ( 'WARN' , `Failed to write merge log: ${ err . message } ` ) ;
95+ log ( 'WARN' , `Merge log failed : ${ err . message } ` ) ;
9596 }
9697}
9798
98- function formatMergeLogEntry ( originalFile , conflictFile , baseFile , status , error = null ) {
99+ function formatMergeLogEntry ( originalFile , conflictFile , baseFile , status , deviceId , error = null ) {
100+ const deviceStr = deviceId ? ` (Device: ${ deviceId } )` : '' ;
99101 return `## ${ new Date ( ) . toISOString ( ) } \n` +
100102 `- **Status:** ${ status } \n` +
101103 `- **File:** \`${ originalFile } \`\n` +
102- `- **Conflict:** \`${ conflictFile } \`\n` +
104+ `- **Conflict:** \`${ conflictFile } \`${ deviceStr } \n` +
103105 `- **Base:** \`${ baseFile } \`\n` +
104106 ( error ? `- **Error:** ${ error } \n` : '' ) +
105107 `\n---\n\n` ;
@@ -113,48 +115,74 @@ function verifyGitAvailable() {
113115}
114116
115117/**
116- * Cleans up temp files with an additional delay to ensure Syncthing releases locks.
118+ * Cleans up temp files with logic to handle Windows (~syncthing~) and Linux (.syncthing.)
117119 */
118120async function cleanupSyncthingTemp ( filePath ) {
119121 const dir = path . dirname ( filePath ) ;
120122 const fileName = path . basename ( filePath ) ;
121- const variations = [ `~syncthing~${ fileName } .tmp` , `.syncthing.${ fileName } .tmp` ] ;
122123
123- // Tiny extra grace period for the OS to release the file handle
124+ // We check for the specific temp patterns of the conflict file just processed
125+ const variations = [
126+ `~syncthing~${ fileName } .tmp` ,
127+ `.syncthing.${ fileName } .tmp`
128+ ] ;
129+
124130 await new Promise ( r => setTimeout ( r , 500 ) ) ;
125131
126132 variations . forEach ( tempName => {
127133 const tempPath = path . join ( dir , tempName ) ;
128134 if ( fs . existsSync ( tempPath ) ) {
129135 try {
130136 if ( ! CONFIG . dryRun ) fs . unlinkSync ( tempPath ) ;
131- log ( 'CLEAN' , `Removed temp file : ${ tempName } ` ) ;
137+ log ( 'CLEAN' , `Removed: ${ tempName } ` ) ;
132138 } catch ( err ) {
133- log ( 'WARN' , `Could not remove " ${ tempName } " (likely still locked by Syncthing). ` ) ;
139+ log ( 'WARN' , `Locked: ${ tempName } (Syncthing still busy) ` ) ;
134140 }
135141 }
136142 } ) ;
137143}
138144
145+ /**
146+ * Robust Startup Ghost Removal
147+ * Matches any syncthing temp files left behind.
148+ */
139149function cleanupPreviousGhosts ( ) {
140- log ( 'GHOST' , `Scanning for leftover conflict temp files...` ) ;
150+ log ( 'GHOST' , `Searching for ghost temp files...` ) ;
141151 const allFiles = getFilesRecursive ( path . resolve ( CONFIG . watchPath ) ) ;
142- 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;
152+
153+ // Broad regex for all syncthing temps
154+ const ghostRegex = / [ . ~ ] s y n c t h i n g [ . ~ ] .* \. t m p $ / i;
143155 const ghosts = allFiles . filter ( f => ghostRegex . test ( path . basename ( f ) ) ) ;
144156
157+ if ( ghosts . length === 0 ) {
158+ log ( 'GHOST' , 'No ghost files found.' ) ;
159+ return ;
160+ }
161+
145162 ghosts . forEach ( ghost => {
146163 try {
147164 if ( ! CONFIG . dryRun ) fs . unlinkSync ( ghost ) ;
148- log ( 'GHOST' , `Removed : ${ path . basename ( ghost ) } ` ) ;
165+ log ( 'GHOST' , `Purged : ${ path . basename ( ghost ) } ` ) ;
149166 } catch ( err ) {
150- log ( 'WARN' , `Failed to remove ghost ${ path . basename ( ghost ) } ` ) ;
167+ log ( 'WARN' , `Could not purge: ${ path . basename ( ghost ) } ` ) ;
151168 }
152169 } ) ;
153170}
154171
155- function mergeFiles ( original , base , conflict ) {
172+ /**
173+ * Executes git merge with custom labels to identify the conflicting device.
174+ */
175+ function mergeFiles ( original , base , conflict , deviceId ) {
156176 const flags = CONFIG . useUnionMerge ? [ '--union' ] : [ ] ;
157- const args = [ 'merge-file' , ...flags , original , base , conflict ] ;
177+
178+ // Add custom labels for the merge markers
179+ const labels = [
180+ '-L' , 'Our Local Version' ,
181+ '-L' , 'Base (Historical)' ,
182+ '-L' , `Remote Change (Device: ${ deviceId || 'Unknown' } )`
183+ ] ;
184+
185+ const args = [ 'merge-file' , ...flags , ...labels , original , base , conflict ] ;
158186
159187 log ( 'MERGE' , `${ CONFIG . gitBinary } ${ args . join ( ' ' ) } ` ) ;
160188 if ( CONFIG . dryRun ) return - 1 ;
@@ -173,19 +201,19 @@ async function handleFileEvent(filePath, isStartupScan = false) {
173201 } catch ( err ) { return ; }
174202
175203 const fileName = path . basename ( absConflictPath ) ;
204+ // Group 4 extracts the 7-character Device ID
176205 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;
177206 const match = fileName . match ( conflictRegex ) ;
178207
179208 if ( ! match || fileName . endsWith ( '.tmp' ) ) return ;
180209
181- const [ _ , baseName , date , time , id , extension ] = match ;
210+ const [ _ , baseName , date , time , deviceId , extension ] = match ;
182211 if ( ! CONFIG . allowedExtensions . includes ( ( extension || '' ) . toLowerCase ( ) ) ) return ;
183212
184213 processingFiles . add ( absConflictPath ) ;
185214 const absRoot = path . resolve ( CONFIG . syncRootPath ) ;
186215
187216 try {
188- // Wait for Syncthing to finish writing the conflict file
189217 await new Promise ( r => setTimeout ( r , CONFIG . settleDelayMs ) ) ;
190218 if ( ! fs . existsSync ( absConflictPath ) ) return ;
191219
@@ -198,18 +226,17 @@ async function handleFileEvent(filePath, isStartupScan = false) {
198226 }
199227
200228 // --- ANTI-NESTING GUARD ---
201- // If "ours" already has conflict markers, merging into it creates nested junk.
202229 const originalContent = fs . readFileSync ( originalFilePath , 'utf8' ) ;
203230 if ( originalContent . includes ( '<<<<<<<' ) && ! CONFIG . useUnionMerge ) {
204- log ( 'WARN' , `Skipping: "${ originalFileName } " already contains markers. Resolve them manually first .` ) ;
231+ log ( 'WARN' , `Skipping: "${ originalFileName } " already has markers. Resolve manually.` ) ;
205232 return ;
206233 }
207234
208235 const relativeOriginal = path . relative ( absRoot , originalFilePath ) ;
209236 const versionsFolder = path . join ( absRoot , CONFIG . versionsDirName , path . dirname ( relativeOriginal ) ) ;
210237
211238 if ( ! fs . existsSync ( versionsFolder ) ) {
212- log ( 'SKIP' , `No .stversions folder for ${ relativeOriginal } ` ) ;
239+ log ( 'SKIP' , `No .stversions for ${ relativeOriginal } ` ) ;
213240 return ;
214241 }
215242
@@ -224,7 +251,7 @@ async function handleFileEvent(filePath, isStartupScan = false) {
224251 . sort ( ) . reverse ( ) ;
225252
226253 if ( backupCandidates . length === 0 ) {
227- log ( 'SKIP' , `No historical versions for ${ relativeOriginal } ` ) ;
254+ log ( 'SKIP' , `No versions for ${ relativeOriginal } ` ) ;
228255 return ;
229256 }
230257
@@ -234,19 +261,23 @@ async function handleFileEvent(filePath, isStartupScan = false) {
234261 fs . copyFileSync ( originalFilePath , `${ originalFilePath } .${ new Date ( ) . getTime ( ) } .bak` ) ;
235262 }
236263
237- const mergeExitCode = mergeFiles ( originalFilePath , latestBackup , absConflictPath ) ;
264+ const mergeExitCode = mergeFiles ( originalFilePath , latestBackup , absConflictPath , deviceId ) ;
238265
239266 if ( ! CONFIG . dryRun ) {
240- // Clean up the conflict file
241267 fs . unlinkSync ( absConflictPath ) ;
242- // Specifically clean up associated .tmp files
243268 await cleanupSyncthingTemp ( absConflictPath ) ;
244269 }
245270
246271 const status = mergeExitCode === 0 ? 'Clean Merge' : `Conflicts Marked (${ mergeExitCode } )` ;
247272 log ( 'SUCCESS' , `Resolved: ${ relativeOriginal } (${ status } )` ) ;
248273
249- appendMergeLog ( formatMergeLogEntry ( relativeOriginal , fileName , path . basename ( latestBackup ) , status ) ) ;
274+ appendMergeLog ( formatMergeLogEntry (
275+ relativeOriginal ,
276+ fileName ,
277+ path . basename ( latestBackup ) ,
278+ status ,
279+ deviceId
280+ ) ) ;
250281
251282 } catch ( err ) {
252283 log ( 'ERROR' , `Failed ${ fileName } : ${ err . message } ` ) ;
@@ -256,7 +287,7 @@ async function handleFileEvent(filePath, isStartupScan = false) {
256287}
257288
258289async function startupScan ( ) {
259- log ( 'SCAN' , `Scanning ${ CONFIG . watchPath } ...` ) ;
290+ log ( 'SCAN' , `Scanning for existing conflicts ...` ) ;
260291 const allFiles = getFilesRecursive ( path . resolve ( CONFIG . watchPath ) ) ;
261292 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;
262293
@@ -266,13 +297,12 @@ async function startupScan() {
266297 } ) ;
267298
268299 for ( const c of conflicts ) await handleFileEvent ( c , true ) ;
269- log ( 'SCAN' , 'Startup scan complete.' ) ;
270300}
271301
272302// --- STARTUP ---
273303
274304verifyGitAvailable ( ) ;
275- cleanupPreviousGhosts ( ) ;
305+ cleanupPreviousGhosts ( ) ; // Step 1: Broad cleanup of all syncthing temps
276306
277307startupScan ( ) . then ( ( ) => {
278308 const watcher = chokidar . watch ( CONFIG . watchPath , {
@@ -287,7 +317,7 @@ startupScan().then(() => {
287317 . on ( 'change' , f => handleFileEvent ( f ) )
288318 . on ( 'error' , e => log ( 'ERROR' , `Watcher: ${ e } ` ) ) ;
289319
290- log ( 'INFO' , `Watcher active. (Settle delay : ${ CONFIG . settleDelayMs } ms)` ) ;
320+ log ( 'INFO' , `Watcher active. (Delay : ${ CONFIG . settleDelayMs } ms)` ) ;
291321
292322 process . on ( 'SIGINT' , ( ) => watcher . close ( ) . then ( ( ) => process . exit ( 0 ) ) ) ;
293323} ) ;
0 commit comments