build(broccoli): add tree-differ for diffing broccoli trees

This commit is contained in:
Igor Minar 2015-04-24 10:00:38 -07:00
parent 32c5ab956c
commit 2f83efaac8
2 changed files with 300 additions and 0 deletions

View File

@ -0,0 +1,190 @@
/// <reference path="../typings/node/node.d.ts" />
/// <reference path="../typings/jasmine/jasmine.d.ts" />
let mockfs = require('mock-fs');
import fs = require('fs');
import TreeDiffer = require('./tree-differ');
describe('TreeDiffer', () => {
afterEach(() => mockfs.restore());
describe('diff of changed files', () => {
it('should list all files but no directories during the first diff', () => {
let testDir = {
'dir1': {
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
'subdir-1': {
'file-1.1.txt': mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
},
'empty-dir': {}
}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
let diffResult = differ.diffTree();
expect(diffResult.changedPaths)
.toEqual(['file-1.txt', 'file-2.txt', 'subdir-1/file-1.1.txt']);
expect(diffResult.removedPaths).toEqual([]);
});
it('should return empty diff if nothing has changed', () => {
let testDir = {
'dir1': {
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
'subdir-1': {
'file-1.1.txt': mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
},
}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
let diffResult = differ.diffTree();
expect(diffResult.changedPaths).not.toEqual([]);
expect(diffResult.removedPaths).toEqual([]);
diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual([]);
expect(diffResult.removedPaths).toEqual([]);
});
it('should list only changed files during the subsequent diffs', () => {
let testDir = {
'dir1': {
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
'subdir-1': {
'file-1.1.txt':
mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
}
}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
let diffResult = differ.diffTree();
expect(diffResult.changedPaths)
.toEqual(['file-1.txt', 'file-2.txt', 'subdir-1/file-1.1.txt']);
// change two files
testDir['dir1']['file-1.txt'] = mockfs.file({content: 'new content', mtime: new Date(1000)});
testDir['dir1']['subdir-1']['file-1.1.txt'] =
mockfs.file({content: 'file-1.1.txt content', mtime: new Date(9999)});
mockfs(testDir);
diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'subdir-1/file-1.1.txt']);
expect(diffResult.removedPaths).toEqual([]);
// change one file
testDir['dir1']['file-1.txt'] = mockfs.file({content: 'super new', mtime: new Date(1000)});
mockfs(testDir);
diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual(['file-1.txt']);
});
});
describe('diff of new files', () => {
it('should detect file additions and report them as changed files', () => {
let testDir = {
'dir1':
{'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
differ.diffTree();
testDir['dir1']['file-2.txt'] = 'new file';
mockfs(testDir);
let diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual(['file-2.txt']);
});
});
it('should detect file additions mixed with file changes', () => {
let testDir = {
'dir1': {'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
differ.diffTree();
testDir['dir1']['file-1.txt'] = 'new content';
testDir['dir1']['file-2.txt'] = 'new file';
mockfs(testDir);
let diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'file-2.txt']);
});
describe('diff of removed files', () => {
it('should detect file removals and report them as removed files', () => {
let testDir = {
'dir1':
{'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
differ.diffTree();
delete testDir['dir1']['file-1.txt'];
mockfs(testDir);
let diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual([]);
expect(diffResult.removedPaths).toEqual(['file-1.txt']);
});
});
it('should detect file removals mixed with file changes and additions', () => {
let testDir = {
'dir1': {
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
'file-2.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})
}
};
mockfs(testDir);
let differ = new TreeDiffer('dir1');
differ.diffTree();
testDir['dir1']['file-1.txt'] = 'changed content';
delete testDir['dir1']['file-2.txt'];
testDir['dir1']['file-3.txt'] = 'new content';
mockfs(testDir);
let diffResult = differ.diffTree();
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'file-3.txt']);
expect(diffResult.removedPaths).toEqual(['file-2.txt']);
});
});

View File

@ -0,0 +1,110 @@
/// <reference path="../typings/node/node.d.ts" />
import fs = require('fs');
import path = require('path');
export = TreeDiffer;
class TreeDiffer {
private fingerprints: {[key: string]: string} = Object.create(null);
private nextFingerprints: {[key: string]: string} = Object.create(null);
private rootDirName: string;
constructor(private rootPath: string) { this.rootDirName = path.basename(rootPath); }
public diffTree(): DiffResult {
let result = new DiffResult(this.rootDirName);
this.dirtyCheckPath(this.rootPath, result);
this.detectDeletionsAndUpdateFingerprints(result);
result.endTime = Date.now();
return result;
}
private dirtyCheckPath(rootDir: string, result: DiffResult) {
fs.readdirSync(rootDir).forEach((segment) => {
let absolutePath = path.join(rootDir, segment);
let pathStat = fs.statSync(absolutePath);
if (pathStat.isDirectory()) {
result.directoriesChecked++;
this.dirtyCheckPath(absolutePath, result);
} else {
result.filesChecked++;
if (this.isFileDirty(absolutePath, pathStat)) {
result.changedPaths.push(path.relative(this.rootPath, absolutePath));
}
}
});
return result;
}
private isFileDirty(path: string, stat: fs.Stats): boolean {
let oldFingerprint = this.fingerprints[path];
let newFingerprint = `${stat.mtime.getTime()} # ${stat.size}`;
this.nextFingerprints[path] = newFingerprint;
if (oldFingerprint) {
this.fingerprints[path] = null;
if (oldFingerprint === newFingerprint) {
// nothing changed
return false;
}
}
return true;
}
private detectDeletionsAndUpdateFingerprints(result: DiffResult) {
for (let absolutePath in this.fingerprints) {
if (this.fingerprints[absolutePath] !== null) {
let relativePath = path.relative(this.rootPath, absolutePath);
result.removedPaths.push(relativePath);
}
}
this.fingerprints = this.nextFingerprints;
this.nextFingerprints = Object.create(null);
}
}
class DiffResult {
public filesChecked: number = 0;
public directoriesChecked: number = 0;
public changedPaths: string[] = [];
public removedPaths: string[] = [];
public startTime: number = Date.now();
public endTime: number = null;
constructor(public name: string) {}
toString() {
return `${pad(this.name, 40)}, ` +
`duration: ${pad(this.endTime - this.startTime, 5)}ms, ` +
`${pad(this.changedPaths.length + this.removedPaths.length, 5)} changes detected ` +
`(files: ${pad(this.filesChecked, 5)}, directories: ${pad(this.directoriesChecked, 4)})`;
}
log(verbose) {
let prefixedPaths =
this.changedPaths.map((p) => `* ${p}`).concat(this.removedPaths.map((p) => `- ${p}`));
console.log(`Tree diff: ${this}` +
((verbose && prefixedPaths.length) ? ` [\n ${prefixedPaths.join('\n ')}\n]` : ''));
}
}
function pad(value, length) {
value = '' + value;
let whitespaceLength = (value.length < length) ? length - value.length : 0;
whitespaceLength = whitespaceLength + 1;
return new Array(whitespaceLength).join(' ') + value;
}