Skip to content

Commit f038a7d

Browse files
committed
Fix compareLastModifiedTime to not pass through unchanged files
Fixes #94
1 parent 2ee58bc commit f038a7d

File tree

2 files changed

+73
-4
lines changed

2 files changed

+73
-4
lines changed

index.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ export async function compareLastModifiedTime(sourceFile, targetPath) {
2525
2626
Additionally, we use the maximum of mtime and ctime to handle file replacement scenarios. When a file is replaced with an older version (e.g., restored from backup), its mtime might be older than the destination, but its ctime (change time) will be newer since it was just replaced. This ensures we detect all types of file changes.
2727
*/
28-
// Check if file was replaced (ctime significantly newer than mtime indicates file replacement)
29-
// We use a 1-second threshold to avoid false positives from minor timestamp variations
30-
if (sourceFile.stat.ctimeMs > sourceFile.stat.mtimeMs + 1000 && sourceFile.stat.ctimeMs > targetStat.mtimeMs) {
31-
// File was likely replaced with an older version
28+
// Check if file was replaced with an older version
29+
// This happens when: mtime is older than destination (rollback), but ctime is newer (just modified)
30+
// Use precision-aware comparison: reverse of the "is newer" check
31+
const sourceIsOlderThanDest = process.platform === 'win32'
32+
? Math.floor(sourceFile.stat.mtimeMs / 1000) < Math.floor(targetStat.mtimeMs / 1000)
33+
: Math.ceil(sourceFile.stat.mtimeMs) < Math.floor(targetStat.mtimeMs);
34+
35+
if (sourceIsOlderThanDest && sourceFile.stat.ctimeMs > sourceFile.stat.mtimeMs + 1000 && sourceFile.stat.ctimeMs > targetStat.mtimeMs) {
36+
// File was replaced with an older version (e.g., restored from backup)
3237
// Use direct comparison since ctime doesn't have the same precision issues as mtime
3338
return sourceFile;
3439
}

test.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,67 @@ test.serial(`compareLastModifiedTime ${pointer} detects file replacement with ol
203203
// Clean up
204204
deleteSync(testDir);
205205
});
206+
207+
test.serial(`compareLastModifiedTime ${pointer} should not pass through files when mtime is old but ctime is current (issue #94)`, async t => {
208+
const testDir = 'tmp-issue-94';
209+
const srcDir = path.join(testDir, 'src');
210+
const destDir = path.join(testDir, 'dest');
211+
212+
deleteSync(testDir);
213+
await fs.mkdir(srcDir, {recursive: true});
214+
await fs.mkdir(destDir, {recursive: true});
215+
216+
const srcPath = path.join(srcDir, 'app.js');
217+
const destPath = path.join(destDir, 'app.js');
218+
219+
// Create file with old mtime (simulates git checkout/archive extraction)
220+
await fs.writeFile(srcPath, 'console.log("test");');
221+
const oldTime = Date.now() - 10_000;
222+
await fs.utimes(srcPath, oldTime / 1000, oldTime / 1000);
223+
224+
// First pass - should pass through (destination doesn't exist)
225+
const stream1 = changed(destDir);
226+
let passedThrough = false;
227+
stream1.on('data', file => {
228+
passedThrough = true;
229+
fsSync.writeFileSync(destPath, file.contents);
230+
fsSync.utimesSync(destPath, file.stat.atime, file.stat.mtime);
231+
});
232+
233+
stream1.write(new Vinyl({
234+
cwd: testDir,
235+
base: srcDir,
236+
path: srcPath,
237+
contents: await fs.readFile(srcPath),
238+
stat: await fs.stat(srcPath),
239+
}));
240+
stream1.end();
241+
await new Promise(resolve => {
242+
stream1.on('end', resolve);
243+
});
244+
245+
t.true(passedThrough, 'First run should pass through');
246+
247+
// Second pass - should NOT pass through (file unchanged, despite ctime > mtime)
248+
const stream2 = changed(destDir);
249+
passedThrough = false;
250+
stream2.on('data', () => {
251+
passedThrough = true;
252+
});
253+
254+
stream2.write(new Vinyl({
255+
cwd: testDir,
256+
base: srcDir,
257+
path: srcPath,
258+
contents: await fs.readFile(srcPath),
259+
stat: await fs.stat(srcPath),
260+
}));
261+
stream2.end();
262+
await new Promise(resolve => {
263+
stream2.on('end', resolve);
264+
});
265+
266+
t.false(passedThrough, 'Second run should not pass through unchanged file');
267+
268+
deleteSync(testDir);
269+
});

0 commit comments

Comments
 (0)