Skip to content

Commit b40a8b1

Browse files
authored
Update syncthing-deconflict.js
1 parent 9952e31 commit b40a8b1

1 file changed

Lines changed: 64 additions & 34 deletions

File tree

syncthing-deconflict.js

Lines changed: 64 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
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

88
require('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
*/
5252
function 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 = /^[.~]syncthing~.*\.tmp$/i.test(file);
71+
// Robust pattern: Matches .syncthing. and ~syncthing~ and variations
72+
const isSyncthingTemp = /[.~]syncthing[.~].*\.tmp$/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
*/
118120
async 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+
*/
139149
function 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 = /^[.~]syncthing~.*sync-conflict.*\.tmp$/i;
152+
153+
// Broad regex for all syncthing temps
154+
const ghostRegex = /[.~]syncthing[.~].*\.tmp$/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 = /^(.*?)(?:\.|%2F)sync-conflict-([0-9]{8})-([0-9]{6})-([A-Z0-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

258289
async 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 = /sync-conflict-[0-9]{8}-[0-9]{6}-[A-Z0-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

274304
verifyGitAvailable();
275-
cleanupPreviousGhosts();
305+
cleanupPreviousGhosts(); // Step 1: Broad cleanup of all syncthing temps
276306

277307
startupScan().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

Comments
 (0)