Merge lp:~jelmer/brz/bundle-stats into lp:brz
- bundle-stats
- Merge into trunk
Proposed by
Jelmer Vernooij
Status: | Merged |
---|---|
Approved by: | Jelmer Vernooij |
Approved revision: | no longer in the source branch. |
Merge reported by: | The Breezy Bot |
Merged at revision: | not available |
Proposed branch: | lp:~jelmer/brz/bundle-stats |
Merge into: | lp:brz |
Diff against target: |
723 lines (+688/-0) 6 files modified
breezy/plugins/stats/__init__.py (+39/-0) breezy/plugins/stats/classify.py (+69/-0) breezy/plugins/stats/cmds.py (+426/-0) breezy/plugins/stats/test_classify.py (+45/-0) breezy/plugins/stats/test_stats.py (+106/-0) doc/en/release-notes/brz-3.0.txt (+3/-0) |
To merge this branch: | bzr merge lp:~jelmer/brz/bundle-stats |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Martin Packman | Approve | ||
Review via email: mp+324984@code.launchpad.net |
Commit message
Bundle the stats plugin.
Description of the change
Bundle the stats plugin.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === added directory 'breezy/plugins/stats' |
2 | === added file 'breezy/plugins/stats/__init__.py' |
3 | --- breezy/plugins/stats/__init__.py 1970-01-01 00:00:00 +0000 |
4 | +++ breezy/plugins/stats/__init__.py 2017-06-02 00:37:26 +0000 |
5 | @@ -0,0 +1,39 @@ |
6 | +# Copyright (C) 2006-2010 Canonical Ltd |
7 | + |
8 | +# This program is free software; you can redistribute it and/or modify |
9 | +# it under the terms of the GNU General Public License as published by |
10 | +# the Free Software Foundation; either version 2 of the License, or |
11 | +# (at your option) any later version. |
12 | + |
13 | +# This program is distributed in the hope that it will be useful, |
14 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
15 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
16 | +# GNU General Public License for more details. |
17 | + |
18 | +# You should have received a copy of the GNU General Public License |
19 | +# along with this program; if not, write to the Free Software |
20 | +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
21 | +"""A Simple bzr plugin to generate statistics about the history.""" |
22 | + |
23 | +from __future__ import absolute_import |
24 | + |
25 | +from ... import _format_version_tuple, version_info |
26 | + |
27 | +__version__ = _format_version_tuple(version_info) |
28 | + |
29 | +from ...commands import plugin_cmds |
30 | + |
31 | +plugin_cmds.register_lazy("cmd_credits", [], |
32 | + "breezy.plugins.stats.cmds") |
33 | +plugin_cmds.register_lazy("cmd_committer_statistics", |
34 | + ['stats', 'committer-stats'], "breezy.plugins.stats.cmds") |
35 | +plugin_cmds.register_lazy("cmd_ancestor_growth", [], |
36 | + "breezy.plugins.stats.cmds") |
37 | + |
38 | +def load_tests(loader, basic_tests, pattern): |
39 | + testmod_names = [__name__ + '.' + x for x in [ |
40 | + 'test_classify', |
41 | + 'test_stats', |
42 | + ]] |
43 | + basic_tests.addTest(loader.loadTestsFromModuleNames(testmod_names)) |
44 | + return basic_tests |
45 | |
46 | === added file 'breezy/plugins/stats/classify.py' |
47 | --- breezy/plugins/stats/classify.py 1970-01-01 00:00:00 +0000 |
48 | +++ breezy/plugins/stats/classify.py 2017-06-02 00:37:26 +0000 |
49 | @@ -0,0 +1,69 @@ |
50 | +# Copyright (C) 2008, 2010 Jelmer Vernooij <jelmer@samba.org> |
51 | + |
52 | +# This program is free software; you can redistribute it and/or modify |
53 | +# it under the terms of the GNU General Public License as published by |
54 | +# the Free Software Foundation; either version 2 of the License, or |
55 | +# (at your option) any later version. |
56 | + |
57 | +# This program is distributed in the hope that it will be useful, |
58 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
59 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
60 | +# GNU General Public License for more details. |
61 | + |
62 | +# You should have received a copy of the GNU General Public License |
63 | +# along with this program; if not, write to the Free Software |
64 | +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
65 | +"""Classify a commit based on the types of files it changed.""" |
66 | + |
67 | +from __future__ import absolute_import |
68 | + |
69 | +import os.path |
70 | + |
71 | +from ... import urlutils |
72 | +from ...trace import mutter |
73 | + |
74 | + |
75 | +def classify_filename(name): |
76 | + """Classify a file based on its name. |
77 | + |
78 | + :param name: File path. |
79 | + :return: One of code, documentation, translation or art. |
80 | + None if determining the file type failed. |
81 | + """ |
82 | + # FIXME: Use mime types? Ohcount? |
83 | + # TODO: It will be better move those filters to properties file |
84 | + # and have possibility to determining own types !? |
85 | + extension = os.path.splitext(name)[1] |
86 | + if extension in (".c", ".h", ".py", ".cpp", ".rb", ".pm", ".pl", ".ac", |
87 | + ".java", ".cc", ".proto", ".yy", ".l"): |
88 | + return "code" |
89 | + if extension in (".html", ".xml", ".txt", ".rst", ".TODO"): |
90 | + return "documentation" |
91 | + if extension in (".po",): |
92 | + return "translation" |
93 | + if extension in (".svg", ".png", ".jpg"): |
94 | + return "art" |
95 | + if not extension: |
96 | + basename = urlutils.basename(name) |
97 | + if basename in ("README", "NEWS", "TODO", |
98 | + "AUTHORS", "COPYING"): |
99 | + return "documentation" |
100 | + if basename in ("Makefile",): |
101 | + return "code" |
102 | + |
103 | + mutter("don't know how to classify %s", name) |
104 | + return None |
105 | + |
106 | + |
107 | +def classify_delta(delta): |
108 | + """Determine what sort of changes a delta contains. |
109 | + |
110 | + :param delta: A TreeDelta to inspect |
111 | + :return: List with classes found (see classify_filename) |
112 | + """ |
113 | + # TODO: This is inaccurate, since it doesn't look at the |
114 | + # number of lines changed in a file. |
115 | + types = [] |
116 | + for d in delta.added + delta.modified: |
117 | + types.append(classify_filename(d[0])) |
118 | + return types |
119 | |
120 | === added file 'breezy/plugins/stats/cmds.py' |
121 | --- breezy/plugins/stats/cmds.py 1970-01-01 00:00:00 +0000 |
122 | +++ breezy/plugins/stats/cmds.py 2017-06-02 00:37:26 +0000 |
123 | @@ -0,0 +1,426 @@ |
124 | +# Copyright (C) 2006-2010 Canonical Ltd |
125 | + |
126 | +# This program is free software; you can redistribute it and/or modify |
127 | +# it under the terms of the GNU General Public License as published by |
128 | +# the Free Software Foundation; either version 2 of the License, or |
129 | +# (at your option) any later version. |
130 | + |
131 | +# This program is distributed in the hope that it will be useful, |
132 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
133 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
134 | +# GNU General Public License for more details. |
135 | + |
136 | +# You should have received a copy of the GNU General Public License |
137 | +# along with this program; if not, write to the Free Software |
138 | +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
139 | +"""A Simple bzr plugin to generate statistics about the history.""" |
140 | + |
141 | +from __future__ import absolute_import |
142 | + |
143 | +from ... import ( |
144 | + branch, |
145 | + commands, |
146 | + config, |
147 | + errors, |
148 | + option, |
149 | + trace, |
150 | + tsort, |
151 | + ui, |
152 | + workingtree, |
153 | + ) |
154 | +from ...revision import NULL_REVISION |
155 | +from .classify import classify_delta |
156 | + |
157 | +from itertools import izip |
158 | + |
159 | + |
160 | +def collapse_by_person(revisions, canonical_committer): |
161 | + """The committers list is sorted by email, fix it up by person. |
162 | + |
163 | + Some people commit with a similar username, but different email |
164 | + address. Which makes it hard to sort out when they have multiple |
165 | + entries. Email is actually more stable, though, since people |
166 | + frequently forget to set their name properly. |
167 | + |
168 | + So take the most common username for each email address, and |
169 | + combine them into one new list. |
170 | + """ |
171 | + # Map from canonical committer to |
172 | + # {committer: ([rev_list], {email: count}, {fname:count})} |
173 | + committer_to_info = {} |
174 | + for rev in revisions: |
175 | + authors = rev.get_apparent_authors() |
176 | + for author in authors: |
177 | + username, email = config.parse_username(author) |
178 | + if len(username) == 0 and len(email) == 0: |
179 | + continue |
180 | + canon_author = canonical_committer[(username, email)] |
181 | + info = committer_to_info.setdefault(canon_author, ([], {}, {})) |
182 | + info[0].append(rev) |
183 | + info[1][email] = info[1].setdefault(email, 0) + 1 |
184 | + info[2][username] = info[2].setdefault(username, 0) + 1 |
185 | + res = [(len(revs), revs, emails, fnames) |
186 | + for revs, emails, fnames in committer_to_info.itervalues()] |
187 | + res.sort(reverse=True) |
188 | + return res |
189 | + |
190 | + |
191 | +def collapse_email_and_users(email_users, combo_count): |
192 | + """Combine the mapping of User Name to email and email to User Name. |
193 | + |
194 | + If a given User Name is used for multiple emails, try to map it all to one |
195 | + entry. |
196 | + """ |
197 | + id_to_combos = {} |
198 | + username_to_id = {} |
199 | + email_to_id = {} |
200 | + id_counter = 0 |
201 | + |
202 | + def collapse_ids(old_id, new_id, new_combos): |
203 | + old_combos = id_to_combos.pop(old_id) |
204 | + new_combos.update(old_combos) |
205 | + for old_user, old_email in old_combos: |
206 | + if (old_user and old_user != user): |
207 | + low_old_user = old_user.lower() |
208 | + old_user_id = username_to_id[low_old_user] |
209 | + assert old_user_id in (old_id, new_id) |
210 | + username_to_id[low_old_user] = new_id |
211 | + if (old_email and old_email != email): |
212 | + old_email_id = email_to_id[old_email] |
213 | + assert old_email_id in (old_id, new_id) |
214 | + email_to_id[old_email] = cur_id |
215 | + for email, usernames in email_users.iteritems(): |
216 | + assert email not in email_to_id |
217 | + if not email: |
218 | + # We use a different algorithm for usernames that have no email |
219 | + # address, we just try to match by username, and not at all by |
220 | |
221 | + for user in usernames: |
222 | + if not user: |
223 | + continue # The mysterious ('', '') user |
224 | + # When mapping, use case-insensitive names |
225 | + low_user = user.lower() |
226 | + user_id = username_to_id.get(low_user) |
227 | + if user_id is None: |
228 | + id_counter += 1 |
229 | + user_id = id_counter |
230 | + username_to_id[low_user] = user_id |
231 | + id_to_combos[user_id] = id_combos = set() |
232 | + else: |
233 | + id_combos = id_to_combos[user_id] |
234 | + id_combos.add((user, email)) |
235 | + continue |
236 | + |
237 | + id_counter += 1 |
238 | + cur_id = id_counter |
239 | + id_to_combos[cur_id] = id_combos = set() |
240 | + email_to_id[email] = cur_id |
241 | + |
242 | + for user in usernames: |
243 | + combo = (user, email) |
244 | + id_combos.add(combo) |
245 | + if not user: |
246 | + # We don't match on empty usernames |
247 | + continue |
248 | + low_user = user.lower() |
249 | + user_id = username_to_id.get(low_user) |
250 | + if user_id is not None: |
251 | + # This UserName was matched to an cur_id |
252 | + if user_id != cur_id: |
253 | + # And it is a different identity than the current email |
254 | + collapse_ids(user_id, cur_id, id_combos) |
255 | + username_to_id[low_user] = cur_id |
256 | + combo_to_best_combo = {} |
257 | + for cur_id, combos in id_to_combos.iteritems(): |
258 | + best_combo = sorted(combos, |
259 | + key=lambda x:combo_count[x], |
260 | + reverse=True)[0] |
261 | + for combo in combos: |
262 | + combo_to_best_combo[combo] = best_combo |
263 | + return combo_to_best_combo |
264 | + |
265 | + |
266 | +def get_revisions_and_committers(a_repo, revids): |
267 | + """Get the Revision information, and the best-match for committer.""" |
268 | + |
269 | + email_users = {} # user@email.com => User Name |
270 | + combo_count = {} |
271 | + pb = ui.ui_factory.nested_progress_bar() |
272 | + try: |
273 | + trace.note('getting revisions') |
274 | + revisions = a_repo.get_revisions(revids) |
275 | + for count, rev in enumerate(revisions): |
276 | + pb.update('checking', count, len(revids)) |
277 | + for author in rev.get_apparent_authors(): |
278 | + # XXX: There is a chance sometimes with svn imports that the |
279 | + # full name and email can BOTH be blank. |
280 | + username, email = config.parse_username(author) |
281 | + email_users.setdefault(email, set()).add(username) |
282 | + combo = (username, email) |
283 | + combo_count[combo] = combo_count.setdefault(combo, 0) + 1 |
284 | + finally: |
285 | + pb.finished() |
286 | + return revisions, collapse_email_and_users(email_users, combo_count) |
287 | + |
288 | + |
289 | +def get_info(a_repo, revision): |
290 | + """Get all of the information for a particular revision""" |
291 | + pb = ui.ui_factory.nested_progress_bar() |
292 | + a_repo.lock_read() |
293 | + try: |
294 | + trace.note('getting ancestry') |
295 | + graph = a_repo.get_graph() |
296 | + ancestry = [ |
297 | + r for (r, ps) in graph.iter_ancestry([revision]) |
298 | + if ps is not None and r != NULL_REVISION] |
299 | + revs, canonical_committer = get_revisions_and_committers(a_repo, ancestry) |
300 | + finally: |
301 | + a_repo.unlock() |
302 | + pb.finished() |
303 | + |
304 | + return collapse_by_person(revs, canonical_committer) |
305 | + |
306 | + |
307 | +def get_diff_info(a_repo, start_rev, end_rev): |
308 | + """Get only the info for new revisions between the two revisions |
309 | + |
310 | + This lets us figure out what has actually changed between 2 revisions. |
311 | + """ |
312 | + pb = ui.ui_factory.nested_progress_bar() |
313 | + a_repo.lock_read() |
314 | + try: |
315 | + graph = a_repo.get_graph() |
316 | + trace.note('getting ancestry diff') |
317 | + ancestry = graph.find_difference(start_rev, end_rev)[1] |
318 | + revs, canonical_committer = get_revisions_and_committers(a_repo, ancestry) |
319 | + finally: |
320 | + a_repo.unlock() |
321 | + pb.finished() |
322 | + |
323 | + return collapse_by_person(revs, canonical_committer) |
324 | + |
325 | + |
326 | +def display_info(info, to_file, gather_class_stats=None): |
327 | + """Write out the information""" |
328 | + |
329 | + for count, revs, emails, fullnames in info: |
330 | + # Get the most common email name |
331 | + sorted_emails = sorted(((count, email) |
332 | + for email,count in emails.iteritems()), |
333 | + reverse=True) |
334 | + sorted_fullnames = sorted(((count, fullname) |
335 | + for fullname,count in fullnames.iteritems()), |
336 | + reverse=True) |
337 | + if sorted_fullnames[0][1] == '' and sorted_emails[0][1] == '': |
338 | + to_file.write('%4d %s\n' |
339 | + % (count, 'Unknown')) |
340 | + else: |
341 | + to_file.write('%4d %s <%s>\n' |
342 | + % (count, sorted_fullnames[0][1], |
343 | + sorted_emails[0][1])) |
344 | + if len(sorted_fullnames) > 1: |
345 | + to_file.write(' Other names:\n') |
346 | + for count, fname in sorted_fullnames: |
347 | + to_file.write(' %4d ' % (count,)) |
348 | + if fname == '': |
349 | + to_file.write("''\n") |
350 | + else: |
351 | + to_file.write("%s\n" % (fname,)) |
352 | + if len(sorted_emails) > 1: |
353 | + to_file.write(' Other email addresses:\n') |
354 | + for count, email in sorted_emails: |
355 | + to_file.write(' %4d ' % (count,)) |
356 | + if email == '': |
357 | + to_file.write("''\n") |
358 | + else: |
359 | + to_file.write("%s\n" % (email,)) |
360 | + if gather_class_stats is not None: |
361 | + to_file.write(' Contributions:\n') |
362 | + classes, total = gather_class_stats(revs) |
363 | + for name,count in sorted(classes.items(), lambda x,y: cmp((x[1], x[0]), (y[1], y[0]))): |
364 | + if name is None: |
365 | + name = "Unknown" |
366 | + to_file.write(" %4.0f%% %s\n" % ((float(count) / total) * 100.0, name)) |
367 | + |
368 | + |
369 | +class cmd_committer_statistics(commands.Command): |
370 | + """Generate statistics for LOCATION.""" |
371 | + |
372 | + aliases = ['stats', 'committer-stats'] |
373 | + takes_args = ['location?'] |
374 | + takes_options = ['revision', |
375 | + option.Option('show-class', help="Show the class of contributions.")] |
376 | + |
377 | + encoding_type = 'replace' |
378 | + |
379 | + def run(self, location='.', revision=None, show_class=False): |
380 | + alternate_rev = None |
381 | + try: |
382 | + wt = workingtree.WorkingTree.open_containing(location)[0] |
383 | + except errors.NoWorkingTree: |
384 | + a_branch = branch.Branch.open(location) |
385 | + last_rev = a_branch.last_revision() |
386 | + else: |
387 | + a_branch = wt.branch |
388 | + last_rev = wt.last_revision() |
389 | + |
390 | + if revision is not None: |
391 | + last_rev = revision[0].in_history(a_branch).rev_id |
392 | + if len(revision) > 1: |
393 | + alternate_rev = revision[1].in_history(a_branch).rev_id |
394 | + |
395 | + a_branch.lock_read() |
396 | + try: |
397 | + if alternate_rev: |
398 | + info = get_diff_info(a_branch.repository, last_rev, |
399 | + alternate_rev) |
400 | + else: |
401 | + info = get_info(a_branch.repository, last_rev) |
402 | + finally: |
403 | + a_branch.unlock() |
404 | + if show_class: |
405 | + def fetch_class_stats(revs): |
406 | + return gather_class_stats(a_branch.repository, revs) |
407 | + else: |
408 | + fetch_class_stats = None |
409 | + display_info(info, self.outf, fetch_class_stats) |
410 | + |
411 | + |
412 | +class cmd_ancestor_growth(commands.Command): |
413 | + """Figure out the ancestor graph for LOCATION""" |
414 | + |
415 | + takes_args = ['location?'] |
416 | + |
417 | + encoding_type = 'replace' |
418 | + |
419 | + def run(self, location='.'): |
420 | + try: |
421 | + wt = workingtree.WorkingTree.open_containing(location)[0] |
422 | + except errors.NoWorkingTree: |
423 | + a_branch = branch.Branch.open(location) |
424 | + last_rev = a_branch.last_revision() |
425 | + else: |
426 | + a_branch = wt.branch |
427 | + last_rev = wt.last_revision() |
428 | + |
429 | + a_branch.lock_read() |
430 | + try: |
431 | + graph = a_branch.repository.get_graph() |
432 | + revno = 0 |
433 | + cur_parents = 0 |
434 | + sorted_graph = tsort.merge_sort(graph.iter_ancestry([last_rev]), |
435 | + last_rev) |
436 | + for num, node_name, depth, isend in reversed(sorted_graph): |
437 | + cur_parents += 1 |
438 | + if depth == 0: |
439 | + revno += 1 |
440 | + self.outf.write('%4d, %4d\n' % (revno, cur_parents)) |
441 | + finally: |
442 | + a_branch.unlock() |
443 | + |
444 | + |
445 | +def gather_class_stats(repository, revs): |
446 | + ret = {} |
447 | + total = 0 |
448 | + pb = ui.ui_factory.nested_progress_bar() |
449 | + try: |
450 | + repository.lock_read() |
451 | + try: |
452 | + i = 0 |
453 | + for delta in repository.get_deltas_for_revisions(revs): |
454 | + pb.update("classifying commits", i, len(revs)) |
455 | + for c in classify_delta(delta): |
456 | + if not c in ret: |
457 | + ret[c] = 0 |
458 | + ret[c] += 1 |
459 | + total += 1 |
460 | + i += 1 |
461 | + finally: |
462 | + repository.unlock() |
463 | + finally: |
464 | + pb.finished() |
465 | + return ret, total |
466 | + |
467 | + |
468 | +def display_credits(credits, to_file): |
469 | + (coders, documenters, artists, translators) = credits |
470 | + def print_section(name, lst): |
471 | + if len(lst) == 0: |
472 | + return |
473 | + to_file.write("%s:\n" % name) |
474 | + for name in lst: |
475 | + to_file.write("%s\n" % name) |
476 | + to_file.write('\n') |
477 | + print_section("Code", coders) |
478 | + print_section("Documentation", documenters) |
479 | + print_section("Art", artists) |
480 | + print_section("Translations", translators) |
481 | + |
482 | + |
483 | +def find_credits(repository, revid): |
484 | + """Find the credits of the contributors to a revision. |
485 | + |
486 | + :return: tuple with (authors, documenters, artists, translators) |
487 | + """ |
488 | + ret = {"documentation": {}, |
489 | + "code": {}, |
490 | + "art": {}, |
491 | + "translation": {}, |
492 | + None: {} |
493 | + } |
494 | + repository.lock_read() |
495 | + try: |
496 | + graph = repository.get_graph() |
497 | + ancestry = [r for (r, ps) in graph.iter_ancestry([revid]) |
498 | + if ps is not None and r != NULL_REVISION] |
499 | + revs = repository.get_revisions(ancestry) |
500 | + pb = ui.ui_factory.nested_progress_bar() |
501 | + try: |
502 | + iterator = izip(revs, repository.get_deltas_for_revisions(revs)) |
503 | + for i, (rev,delta) in enumerate(iterator): |
504 | + pb.update("analysing revisions", i, len(revs)) |
505 | + # Don't count merges |
506 | + if len(rev.parent_ids) > 1: |
507 | + continue |
508 | + for c in set(classify_delta(delta)): |
509 | + for author in rev.get_apparent_authors(): |
510 | + if not author in ret[c]: |
511 | + ret[c][author] = 0 |
512 | + ret[c][author] += 1 |
513 | + finally: |
514 | + pb.finished() |
515 | + finally: |
516 | + repository.unlock() |
517 | + def sort_class(name): |
518 | + return map(lambda (x,y): x, |
519 | + sorted(ret[name].items(), lambda x,y: cmp((x[1], x[0]), (y[1], y[0])), reverse=True)) |
520 | + return (sort_class("code"), sort_class("documentation"), sort_class("art"), sort_class("translation")) |
521 | + |
522 | + |
523 | +class cmd_credits(commands.Command): |
524 | + """Determine credits for LOCATION.""" |
525 | + |
526 | + takes_args = ['location?'] |
527 | + takes_options = ['revision'] |
528 | + |
529 | + encoding_type = 'replace' |
530 | + |
531 | + def run(self, location='.', revision=None): |
532 | + try: |
533 | + wt = workingtree.WorkingTree.open_containing(location)[0] |
534 | + except errors.NoWorkingTree: |
535 | + a_branch = branch.Branch.open(location) |
536 | + last_rev = a_branch.last_revision() |
537 | + else: |
538 | + a_branch = wt.branch |
539 | + last_rev = wt.last_revision() |
540 | + |
541 | + if revision is not None: |
542 | + last_rev = revision[0].in_history(a_branch).rev_id |
543 | + |
544 | + a_branch.lock_read() |
545 | + try: |
546 | + credits = find_credits(a_branch.repository, last_rev) |
547 | + display_credits(credits, self.outf) |
548 | + finally: |
549 | + a_branch.unlock() |
550 | |
551 | === added file 'breezy/plugins/stats/test_classify.py' |
552 | --- breezy/plugins/stats/test_classify.py 1970-01-01 00:00:00 +0000 |
553 | +++ breezy/plugins/stats/test_classify.py 2017-06-02 00:37:26 +0000 |
554 | @@ -0,0 +1,45 @@ |
555 | +# Copyright (C) 2008, 2010 Jelmer Vernooij <jelmer@samba.org> |
556 | + |
557 | +# This program is free software; you can redistribute it and/or modify |
558 | +# it under the terms of the GNU General Public License as published by |
559 | +# the Free Software Foundation; either version 2 of the License, or |
560 | +# (at your option) any later version. |
561 | + |
562 | +# This program is distributed in the hope that it will be useful, |
563 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
564 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
565 | +# GNU General Public License for more details. |
566 | + |
567 | +# You should have received a copy of the GNU General Public License |
568 | +# along with this program; if not, write to the Free Software |
569 | +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
570 | + |
571 | +from __future__ import absolute_import |
572 | + |
573 | +from ...tests import TestCase |
574 | +from .classify import classify_filename |
575 | + |
576 | + |
577 | +class TestClassify(TestCase): |
578 | + def test_classify_code(self): |
579 | + self.assertEquals("code", classify_filename("foo/bar.c")) |
580 | + self.assertEquals("code", classify_filename("foo/bar.pl")) |
581 | + self.assertEquals("code", classify_filename("foo/bar.pm")) |
582 | + |
583 | + def test_classify_documentation(self): |
584 | + self.assertEquals("documentation", classify_filename("bla.html")) |
585 | + |
586 | + def test_classify_translation(self): |
587 | + self.assertEquals("translation", classify_filename("nl.po")) |
588 | + |
589 | + def test_classify_art(self): |
590 | + self.assertEquals("art", classify_filename("icon.png")) |
591 | + |
592 | + def test_classify_unknown(self): |
593 | + self.assertEquals(None, classify_filename("something.bar")) |
594 | + |
595 | + def test_classify_doc_hardcoded(self): |
596 | + self.assertEquals("documentation", classify_filename("README")) |
597 | + |
598 | + def test_classify_multiple_periods(self): |
599 | + self.assertEquals("documentation", classify_filename("foo.bla.html")) |
600 | |
601 | === added file 'breezy/plugins/stats/test_stats.py' |
602 | --- breezy/plugins/stats/test_stats.py 1970-01-01 00:00:00 +0000 |
603 | +++ breezy/plugins/stats/test_stats.py 2017-06-02 00:37:26 +0000 |
604 | @@ -0,0 +1,106 @@ |
605 | +from __future__ import absolute_import |
606 | + |
607 | +from ...tests import TestCase, TestCaseWithTransport |
608 | +from ...revision import Revision |
609 | +from .cmds import get_revisions_and_committers, collapse_by_person |
610 | + |
611 | + |
612 | +class TestGetRevisionsAndCommitters(TestCaseWithTransport): |
613 | + |
614 | + def test_simple(self): |
615 | + wt = self.make_branch_and_tree('.') |
616 | + wt.commit(message='1', committer='Fero <fero@example.com>', rev_id='1') |
617 | + wt.commit(message='2', committer='Fero <fero@example.com>', rev_id='2') |
618 | + wt.commit(message='3', committer='Jano <jano@example.com>', rev_id='3') |
619 | + wt.commit(message='4', committer='Jano <jano@example.com>', |
620 | + authors=['Vinco <vinco@example.com>'], rev_id='4') |
621 | + wt.commit(message='5', committer='Ferko <fero@example.com>', rev_id='5') |
622 | + revs, committers = get_revisions_and_committers(wt.branch.repository, |
623 | + ['1', '2', '3', '4', '5']) |
624 | + fero = ('Fero', 'fero@example.com') |
625 | + jano = ('Jano', 'jano@example.com') |
626 | + vinco = ('Vinco', 'vinco@example.com') |
627 | + ferok = ('Ferko', 'fero@example.com') |
628 | + self.assertEqual({fero: fero, jano: jano, vinco:vinco, ferok: fero}, |
629 | + committers) |
630 | + |
631 | + def test_empty_email(self): |
632 | + wt = self.make_branch_and_tree('.') |
633 | + wt.commit(message='1', committer='Fero', rev_id='1') |
634 | + wt.commit(message='2', committer='Fero', rev_id='2') |
635 | + wt.commit(message='3', committer='Jano', rev_id='3') |
636 | + revs, committers = get_revisions_and_committers(wt.branch.repository, |
637 | + ['1', '2', '3']) |
638 | + self.assertEqual({('Fero', ''): ('Fero', ''), |
639 | + ('Jano', ''): ('Jano', ''), |
640 | + }, committers) |
641 | + |
642 | + def test_different_case(self): |
643 | + wt = self.make_branch_and_tree('.') |
644 | + wt.commit(message='1', committer='Fero', rev_id='1') |
645 | + wt.commit(message='2', committer='Fero', rev_id='2') |
646 | + wt.commit(message='3', committer='FERO', rev_id='3') |
647 | + revs, committers = get_revisions_and_committers(wt.branch.repository, |
648 | + ['1', '2', '3']) |
649 | + self.assertEqual({('Fero', ''): ('Fero', ''), |
650 | + ('FERO', ''): ('Fero', ''), |
651 | + }, committers) |
652 | + |
653 | + |
654 | +class TestCollapseByPerson(TestCase): |
655 | + |
656 | + def test_no_conflicts(self): |
657 | + revisions = [ |
658 | + Revision('1', {}, committer='Foo <foo@example.com>'), |
659 | + Revision('2', {}, committer='Bar <bar@example.com>'), |
660 | + Revision('3', {}, committer='Bar <bar@example.com>'), |
661 | + ] |
662 | + foo = ('Foo', 'foo@example.com') |
663 | + bar = ('Bar', 'bar@example.com') |
664 | + committers = {foo: foo, bar: bar} |
665 | + info = collapse_by_person(revisions, committers) |
666 | + self.assertEquals(2, info[0][0]) |
667 | + self.assertEquals({'bar@example.com': 2}, info[0][2]) |
668 | + self.assertEquals({'Bar': 2}, info[0][3]) |
669 | + |
670 | + def test_different_email(self): |
671 | + revisions = [ |
672 | + Revision('1', {}, committer='Foo <foo@example.com>'), |
673 | + Revision('2', {}, committer='Foo <bar@example.com>'), |
674 | + Revision('3', {}, committer='Foo <bar@example.com>'), |
675 | + ] |
676 | + foo = ('Foo', 'foo@example.com') |
677 | + bar = ('Foo', 'bar@example.com') |
678 | + committers = {foo: foo, bar: foo} |
679 | + info = collapse_by_person(revisions, committers) |
680 | + self.assertEquals(3, info[0][0]) |
681 | + self.assertEquals({'foo@example.com': 1, 'bar@example.com': 2}, info[0][2]) |
682 | + self.assertEquals({'Foo': 3}, info[0][3]) |
683 | + |
684 | + def test_different_name(self): |
685 | + revisions = [ |
686 | + Revision('1', {}, committer='Foo <foo@example.com>'), |
687 | + Revision('2', {}, committer='Bar <foo@example.com>'), |
688 | + Revision('3', {}, committer='Bar <foo@example.com>'), |
689 | + ] |
690 | + foo = ('Foo', 'foo@example.com') |
691 | + bar = ('Bar', 'foo@example.com') |
692 | + committers = {foo: foo, bar: foo} |
693 | + info = collapse_by_person(revisions, committers) |
694 | + self.assertEquals(3, info[0][0]) |
695 | + self.assertEquals({'foo@example.com': 3}, info[0][2]) |
696 | + self.assertEquals({'Foo': 1, 'Bar': 2}, info[0][3]) |
697 | + |
698 | + def test_different_name_case(self): |
699 | + revisions = [ |
700 | + Revision('1', {}, committer='Foo <foo@example.com>'), |
701 | + Revision('2', {}, committer='Foo <foo@example.com>'), |
702 | + Revision('3', {}, committer='FOO <bar@example.com>'), |
703 | + ] |
704 | + foo = ('Foo', 'foo@example.com') |
705 | + FOO = ('FOO', 'bar@example.com') |
706 | + committers = {foo: foo, FOO: foo} |
707 | + info = collapse_by_person(revisions, committers) |
708 | + self.assertEquals(3, info[0][0]) |
709 | + self.assertEquals({'foo@example.com': 2, 'bar@example.com': 1}, info[0][2]) |
710 | + self.assertEquals({'Foo': 2, 'FOO': 1}, info[0][3]) |
711 | |
712 | === modified file 'doc/en/release-notes/brz-3.0.txt' |
713 | --- doc/en/release-notes/brz-3.0.txt 2017-05-30 22:59:36 +0000 |
714 | +++ doc/en/release-notes/brz-3.0.txt 2017-06-02 00:37:26 +0000 |
715 | @@ -30,6 +30,9 @@ |
716 | * The 'fastimport' plugin is now bundled with Bazaar. |
717 | (Jelmer Vernooij) |
718 | |
719 | + * The 'stats' plugin is now bundled with Bazaar. |
720 | + (Jelmer Vernooij) |
721 | + |
722 | * The 'import' command is now bundled with brz. |
723 | Imported from bzrtools by Aaron Bentley. (Jelmer Vernooij, #773241) |
724 |
There's an izip, some iteritems, and string bits to sort out, but is pretty straight forward.