diff --git a/tools/broccoli/tree-differ.spec.ts b/tools/broccoli/tree-differ.spec.ts
new file mode 100644
index 0000000000..93998714b0
--- /dev/null
+++ b/tools/broccoli/tree-differ.spec.ts
@@ -0,0 +1,190 @@
+///
+///
+
+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']);
+ });
+});
diff --git a/tools/broccoli/tree-differ.ts b/tools/broccoli/tree-differ.ts
new file mode 100644
index 0000000000..0762adbb17
--- /dev/null
+++ b/tools/broccoli/tree-differ.ts
@@ -0,0 +1,110 @@
+///
+
+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;
+}