Skip to content

Commit 2f83efa

Browse files
committed
build(broccoli): add tree-differ for diffing broccoli trees
1 parent 32c5ab9 commit 2f83efa

File tree

2 files changed

+300
-0
lines changed

2 files changed

+300
-0
lines changed

tools/broccoli/tree-differ.spec.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/// <reference path="../typings/node/node.d.ts" />
2+
/// <reference path="../typings/jasmine/jasmine.d.ts" />
3+
4+
let mockfs = require('mock-fs');
5+
import fs = require('fs');
6+
import TreeDiffer = require('./tree-differ');
7+
8+
9+
describe('TreeDiffer', () => {
10+
11+
afterEach(() => mockfs.restore());
12+
13+
14+
describe('diff of changed files', () => {
15+
16+
it('should list all files but no directories during the first diff', () => {
17+
let testDir = {
18+
'dir1': {
19+
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
20+
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
21+
'subdir-1': {
22+
'file-1.1.txt': mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
23+
},
24+
'empty-dir': {}
25+
}
26+
};
27+
mockfs(testDir);
28+
29+
let differ = new TreeDiffer('dir1');
30+
31+
let diffResult = differ.diffTree();
32+
33+
expect(diffResult.changedPaths)
34+
.toEqual(['file-1.txt', 'file-2.txt', 'subdir-1/file-1.1.txt']);
35+
36+
expect(diffResult.removedPaths).toEqual([]);
37+
});
38+
39+
40+
it('should return empty diff if nothing has changed', () => {
41+
let testDir = {
42+
'dir1': {
43+
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
44+
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
45+
'subdir-1': {
46+
'file-1.1.txt': mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
47+
},
48+
}
49+
};
50+
mockfs(testDir);
51+
52+
let differ = new TreeDiffer('dir1');
53+
54+
let diffResult = differ.diffTree();
55+
56+
expect(diffResult.changedPaths).not.toEqual([]);
57+
expect(diffResult.removedPaths).toEqual([]);
58+
59+
diffResult = differ.diffTree();
60+
61+
expect(diffResult.changedPaths).toEqual([]);
62+
expect(diffResult.removedPaths).toEqual([]);
63+
});
64+
65+
66+
it('should list only changed files during the subsequent diffs', () => {
67+
let testDir = {
68+
'dir1': {
69+
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
70+
'file-2.txt': mockfs.file({content: 'file-2.txt content', mtime: new Date(1000)}),
71+
'subdir-1': {
72+
'file-1.1.txt':
73+
mockfs.file({content: 'file-1.1.txt content', mtime: new Date(1000)})
74+
}
75+
}
76+
};
77+
mockfs(testDir);
78+
79+
let differ = new TreeDiffer('dir1');
80+
81+
let diffResult = differ.diffTree();
82+
83+
expect(diffResult.changedPaths)
84+
.toEqual(['file-1.txt', 'file-2.txt', 'subdir-1/file-1.1.txt']);
85+
86+
// change two files
87+
testDir['dir1']['file-1.txt'] = mockfs.file({content: 'new content', mtime: new Date(1000)});
88+
testDir['dir1']['subdir-1']['file-1.1.txt'] =
89+
mockfs.file({content: 'file-1.1.txt content', mtime: new Date(9999)});
90+
mockfs(testDir);
91+
92+
diffResult = differ.diffTree();
93+
94+
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'subdir-1/file-1.1.txt']);
95+
96+
expect(diffResult.removedPaths).toEqual([]);
97+
98+
// change one file
99+
testDir['dir1']['file-1.txt'] = mockfs.file({content: 'super new', mtime: new Date(1000)});
100+
mockfs(testDir);
101+
102+
diffResult = differ.diffTree();
103+
expect(diffResult.changedPaths).toEqual(['file-1.txt']);
104+
});
105+
});
106+
107+
describe('diff of new files', () => {
108+
109+
it('should detect file additions and report them as changed files', () => {
110+
let testDir = {
111+
'dir1':
112+
{'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
113+
};
114+
mockfs(testDir);
115+
116+
let differ = new TreeDiffer('dir1');
117+
differ.diffTree();
118+
119+
testDir['dir1']['file-2.txt'] = 'new file';
120+
mockfs(testDir);
121+
122+
let diffResult = differ.diffTree();
123+
expect(diffResult.changedPaths).toEqual(['file-2.txt']);
124+
});
125+
});
126+
127+
128+
it('should detect file additions mixed with file changes', () => {
129+
let testDir = {
130+
'dir1': {'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
131+
};
132+
mockfs(testDir);
133+
134+
let differ = new TreeDiffer('dir1');
135+
differ.diffTree();
136+
137+
testDir['dir1']['file-1.txt'] = 'new content';
138+
testDir['dir1']['file-2.txt'] = 'new file';
139+
mockfs(testDir);
140+
141+
let diffResult = differ.diffTree();
142+
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'file-2.txt']);
143+
});
144+
145+
146+
describe('diff of removed files', () => {
147+
148+
it('should detect file removals and report them as removed files', () => {
149+
let testDir = {
150+
'dir1':
151+
{'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})}
152+
};
153+
mockfs(testDir);
154+
155+
let differ = new TreeDiffer('dir1');
156+
differ.diffTree();
157+
158+
delete testDir['dir1']['file-1.txt'];
159+
mockfs(testDir);
160+
161+
let diffResult = differ.diffTree();
162+
expect(diffResult.changedPaths).toEqual([]);
163+
expect(diffResult.removedPaths).toEqual(['file-1.txt']);
164+
});
165+
});
166+
167+
168+
it('should detect file removals mixed with file changes and additions', () => {
169+
let testDir = {
170+
'dir1': {
171+
'file-1.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)}),
172+
'file-2.txt': mockfs.file({content: 'file-1.txt content', mtime: new Date(1000)})
173+
}
174+
};
175+
176+
mockfs(testDir);
177+
178+
let differ = new TreeDiffer('dir1');
179+
differ.diffTree();
180+
181+
testDir['dir1']['file-1.txt'] = 'changed content';
182+
delete testDir['dir1']['file-2.txt'];
183+
testDir['dir1']['file-3.txt'] = 'new content';
184+
mockfs(testDir);
185+
186+
let diffResult = differ.diffTree();
187+
expect(diffResult.changedPaths).toEqual(['file-1.txt', 'file-3.txt']);
188+
expect(diffResult.removedPaths).toEqual(['file-2.txt']);
189+
});
190+
});

tools/broccoli/tree-differ.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/// <reference path="../typings/node/node.d.ts" />
2+
3+
import fs = require('fs');
4+
import path = require('path');
5+
6+
7+
export = TreeDiffer;
8+
9+
class TreeDiffer {
10+
private fingerprints: {[key: string]: string} = Object.create(null);
11+
private nextFingerprints: {[key: string]: string} = Object.create(null);
12+
private rootDirName: string;
13+
14+
constructor(private rootPath: string) { this.rootDirName = path.basename(rootPath); }
15+
16+
17+
public diffTree(): DiffResult {
18+
let result = new DiffResult(this.rootDirName);
19+
this.dirtyCheckPath(this.rootPath, result);
20+
this.detectDeletionsAndUpdateFingerprints(result);
21+
result.endTime = Date.now();
22+
return result;
23+
}
24+
25+
26+
private dirtyCheckPath(rootDir: string, result: DiffResult) {
27+
fs.readdirSync(rootDir).forEach((segment) => {
28+
let absolutePath = path.join(rootDir, segment);
29+
let pathStat = fs.statSync(absolutePath);
30+
31+
if (pathStat.isDirectory()) {
32+
result.directoriesChecked++;
33+
this.dirtyCheckPath(absolutePath, result);
34+
} else {
35+
result.filesChecked++;
36+
if (this.isFileDirty(absolutePath, pathStat)) {
37+
result.changedPaths.push(path.relative(this.rootPath, absolutePath));
38+
}
39+
}
40+
});
41+
42+
return result;
43+
}
44+
45+
46+
private isFileDirty(path: string, stat: fs.Stats): boolean {
47+
let oldFingerprint = this.fingerprints[path];
48+
let newFingerprint = `${stat.mtime.getTime()} # ${stat.size}`;
49+
50+
this.nextFingerprints[path] = newFingerprint;
51+
52+
if (oldFingerprint) {
53+
this.fingerprints[path] = null;
54+
55+
if (oldFingerprint === newFingerprint) {
56+
// nothing changed
57+
return false;
58+
}
59+
}
60+
61+
return true;
62+
}
63+
64+
65+
private detectDeletionsAndUpdateFingerprints(result: DiffResult) {
66+
for (let absolutePath in this.fingerprints) {
67+
if (this.fingerprints[absolutePath] !== null) {
68+
let relativePath = path.relative(this.rootPath, absolutePath);
69+
result.removedPaths.push(relativePath);
70+
}
71+
}
72+
73+
this.fingerprints = this.nextFingerprints;
74+
this.nextFingerprints = Object.create(null);
75+
}
76+
}
77+
78+
79+
class DiffResult {
80+
public filesChecked: number = 0;
81+
public directoriesChecked: number = 0;
82+
public changedPaths: string[] = [];
83+
public removedPaths: string[] = [];
84+
public startTime: number = Date.now();
85+
public endTime: number = null;
86+
87+
constructor(public name: string) {}
88+
89+
toString() {
90+
return `${pad(this.name, 40)}, ` +
91+
`duration: ${pad(this.endTime - this.startTime, 5)}ms, ` +
92+
`${pad(this.changedPaths.length + this.removedPaths.length, 5)} changes detected ` +
93+
`(files: ${pad(this.filesChecked, 5)}, directories: ${pad(this.directoriesChecked, 4)})`;
94+
}
95+
96+
log(verbose) {
97+
let prefixedPaths =
98+
this.changedPaths.map((p) => `* ${p}`).concat(this.removedPaths.map((p) => `- ${p}`));
99+
console.log(`Tree diff: ${this}` +
100+
((verbose && prefixedPaths.length) ? ` [\n ${prefixedPaths.join('\n ')}\n]` : ''));
101+
}
102+
}
103+
104+
105+
function pad(value, length) {
106+
value = '' + value;
107+
let whitespaceLength = (value.length < length) ? length - value.length : 0;
108+
whitespaceLength = whitespaceLength + 1;
109+
return new Array(whitespaceLength).join(' ') + value;
110+
}

0 commit comments

Comments
 (0)