Merge lp:~jelmer/brz/bundle-stats into lp:brz

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
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.
Revision history for this message
Martin Packman (gz) wrote :

There's an izip, some iteritems, and string bits to sort out, but is pretty straight forward.

review: Approve

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+ # email
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

Subscribers

People subscribed via source and target branches