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; +}