|
|
@@ -275,6 +275,11 @@ class DataCollector:
|
|
275
|
275
|
|
|
276
|
276
|
# Repository size tracking
|
|
277
|
277
|
self.repository_size_mb = 0.0
|
|
|
278
|
+
|
|
|
279
|
+ # Branch analysis
|
|
|
280
|
+ self.branches = {} # branch_name -> {'commits': 0, 'lines_added': 0, 'lines_removed': 0, 'authors': {}, 'is_merged': True, 'merge_base': '', 'unique_commits': []}
|
|
|
281
|
+ self.unmerged_branches = [] # list of branch names that are not merged into main branch
|
|
|
282
|
+ self.main_branch = 'master' # will be detected automatically
|
|
278
|
283
|
|
|
279
|
284
|
##
|
|
280
|
285
|
# This should be the main function to extract data from the repository.
|
|
|
@@ -814,6 +819,11 @@ class GitDataCollector(DataCollector):
|
|
814
|
819
|
print('Warning: failed to handle line "%s"' % line)
|
|
815
|
820
|
(files, inserted, deleted) = (0, 0, 0)
|
|
816
|
821
|
|
|
|
822
|
+ # Branch analysis - collect unmerged branches and per-branch statistics
|
|
|
823
|
+ if conf['verbose']:
|
|
|
824
|
+ print('Analyzing branches and detecting unmerged branches...')
|
|
|
825
|
+ self._analyzeBranches()
|
|
|
826
|
+
|
|
817
|
827
|
# Calculate repository size (this is slow as noted in TODO)
|
|
818
|
828
|
if conf['verbose']:
|
|
819
|
829
|
print('Calculating repository size...')
|
|
|
@@ -827,6 +837,183 @@ class GitDataCollector(DataCollector):
|
|
827
|
837
|
print('Warning: Could not calculate repository size')
|
|
828
|
838
|
self.repository_size_mb = 0.0
|
|
829
|
839
|
|
|
|
840
|
+ def _detectMainBranch(self):
|
|
|
841
|
+ """Detect the main branch (master, main, develop, etc.)"""
|
|
|
842
|
+ # Try common main branch names in order of preference
|
|
|
843
|
+ main_branch_candidates = ['master', 'main', 'develop', 'development']
|
|
|
844
|
+
|
|
|
845
|
+ # Get all local branches
|
|
|
846
|
+ branches_output = getpipeoutput(['git branch'])
|
|
|
847
|
+ local_branches = [line.strip().lstrip('* ') for line in branches_output.split('\n') if line.strip()]
|
|
|
848
|
+
|
|
|
849
|
+ # Check if any of the common main branches exist
|
|
|
850
|
+ for candidate in main_branch_candidates:
|
|
|
851
|
+ if candidate in local_branches:
|
|
|
852
|
+ self.main_branch = candidate
|
|
|
853
|
+ return candidate
|
|
|
854
|
+
|
|
|
855
|
+ # If none found, use the first branch or fall back to 'master'
|
|
|
856
|
+ if local_branches:
|
|
|
857
|
+ self.main_branch = local_branches[0]
|
|
|
858
|
+ return local_branches[0]
|
|
|
859
|
+
|
|
|
860
|
+ # Fall back to master
|
|
|
861
|
+ self.main_branch = 'master'
|
|
|
862
|
+ return 'master'
|
|
|
863
|
+
|
|
|
864
|
+ def _analyzeBranches(self):
|
|
|
865
|
+ """Analyze all branches and detect unmerged ones"""
|
|
|
866
|
+ try:
|
|
|
867
|
+ # Detect main branch
|
|
|
868
|
+ main_branch = self._detectMainBranch()
|
|
|
869
|
+ if conf['verbose']:
|
|
|
870
|
+ print(f'Detected main branch: {main_branch}')
|
|
|
871
|
+
|
|
|
872
|
+ # Get all local branches
|
|
|
873
|
+ branches_output = getpipeoutput(['git branch'])
|
|
|
874
|
+ all_branches = [line.strip().lstrip('* ') for line in branches_output.split('\n') if line.strip()]
|
|
|
875
|
+
|
|
|
876
|
+ # Get unmerged branches (branches not merged into main)
|
|
|
877
|
+ try:
|
|
|
878
|
+ unmerged_output = getpipeoutput([f'git branch --no-merged {main_branch}'])
|
|
|
879
|
+ self.unmerged_branches = [line.strip().lstrip('* ') for line in unmerged_output.split('\n')
|
|
|
880
|
+ if line.strip() and not line.strip().startswith('*')]
|
|
|
881
|
+ except:
|
|
|
882
|
+ # If main branch doesn't exist or command fails, assume all branches are unmerged
|
|
|
883
|
+ self.unmerged_branches = [b for b in all_branches if b != main_branch]
|
|
|
884
|
+
|
|
|
885
|
+ if conf['verbose']:
|
|
|
886
|
+ print(f'Found {len(self.unmerged_branches)} unmerged branches: {", ".join(self.unmerged_branches)}')
|
|
|
887
|
+
|
|
|
888
|
+ # Analyze each branch
|
|
|
889
|
+ for branch in all_branches:
|
|
|
890
|
+ if conf['verbose']:
|
|
|
891
|
+ print(f'Analyzing branch: {branch}')
|
|
|
892
|
+ self._analyzeBranch(branch, main_branch)
|
|
|
893
|
+
|
|
|
894
|
+ except Exception as e:
|
|
|
895
|
+ if conf['verbose'] or conf['debug']:
|
|
|
896
|
+ print(f'Warning: Branch analysis failed: {e}')
|
|
|
897
|
+ # Initialize empty structures if analysis fails
|
|
|
898
|
+ self.unmerged_branches = []
|
|
|
899
|
+ self.branches = {}
|
|
|
900
|
+
|
|
|
901
|
+ def _analyzeBranch(self, branch_name, main_branch):
|
|
|
902
|
+ """Analyze a single branch for commits, authors, and line changes"""
|
|
|
903
|
+ try:
|
|
|
904
|
+ # Initialize branch data
|
|
|
905
|
+ self.branches[branch_name] = {
|
|
|
906
|
+ 'commits': 0,
|
|
|
907
|
+ 'lines_added': 0,
|
|
|
908
|
+ 'lines_removed': 0,
|
|
|
909
|
+ 'authors': {},
|
|
|
910
|
+ 'is_merged': branch_name not in self.unmerged_branches,
|
|
|
911
|
+ 'merge_base': '',
|
|
|
912
|
+ 'unique_commits': []
|
|
|
913
|
+ }
|
|
|
914
|
+
|
|
|
915
|
+ # Get merge base with main branch
|
|
|
916
|
+ try:
|
|
|
917
|
+ merge_base = getpipeoutput([f'git merge-base {branch_name} {main_branch}']).strip()
|
|
|
918
|
+ self.branches[branch_name]['merge_base'] = merge_base
|
|
|
919
|
+ except:
|
|
|
920
|
+ self.branches[branch_name]['merge_base'] = ''
|
|
|
921
|
+
|
|
|
922
|
+ # Get commits unique to this branch (not in main branch)
|
|
|
923
|
+ if branch_name != main_branch:
|
|
|
924
|
+ try:
|
|
|
925
|
+ # Get commits that are in branch but not in main
|
|
|
926
|
+ unique_commits_output = getpipeoutput([f'git rev-list {branch_name} ^{main_branch}'])
|
|
|
927
|
+ unique_commits = [line.strip() for line in unique_commits_output.split('\n') if line.strip()]
|
|
|
928
|
+ self.branches[branch_name]['unique_commits'] = unique_commits
|
|
|
929
|
+
|
|
|
930
|
+ # Analyze each unique commit
|
|
|
931
|
+ for commit in unique_commits:
|
|
|
932
|
+ self._analyzeBranchCommit(branch_name, commit)
|
|
|
933
|
+
|
|
|
934
|
+ except:
|
|
|
935
|
+ # If command fails, analyze all commits in the branch
|
|
|
936
|
+ try:
|
|
|
937
|
+ all_commits_output = getpipeoutput([f'git rev-list {branch_name}'])
|
|
|
938
|
+ all_commits = [line.strip() for line in all_commits_output.split('\n') if line.strip()]
|
|
|
939
|
+ self.branches[branch_name]['unique_commits'] = all_commits[:50] # Limit to avoid too much data
|
|
|
940
|
+
|
|
|
941
|
+ for commit in all_commits[:50]:
|
|
|
942
|
+ self._analyzeBranchCommit(branch_name, commit)
|
|
|
943
|
+ except:
|
|
|
944
|
+ pass
|
|
|
945
|
+ else:
|
|
|
946
|
+ # For main branch, count all commits
|
|
|
947
|
+ try:
|
|
|
948
|
+ all_commits_output = getpipeoutput([f'git rev-list {branch_name}'])
|
|
|
949
|
+ all_commits = [line.strip() for line in all_commits_output.split('\n') if line.strip()]
|
|
|
950
|
+ self.branches[branch_name]['commits'] = len(all_commits)
|
|
|
951
|
+ self.branches[branch_name]['unique_commits'] = all_commits[:100] # Limit for performance
|
|
|
952
|
+ except:
|
|
|
953
|
+ pass
|
|
|
954
|
+
|
|
|
955
|
+ except Exception as e:
|
|
|
956
|
+ if conf['debug']:
|
|
|
957
|
+ print(f'Warning: Failed to analyze branch {branch_name}: {e}')
|
|
|
958
|
+
|
|
|
959
|
+ def _analyzeBranchCommit(self, branch_name, commit_hash):
|
|
|
960
|
+ """Analyze a single commit for branch statistics"""
|
|
|
961
|
+ try:
|
|
|
962
|
+ # Get commit author and timestamp
|
|
|
963
|
+ commit_info = getpipeoutput([f'git log -1 --pretty=format:"%aN %at" {commit_hash}'])
|
|
|
964
|
+ if not commit_info:
|
|
|
965
|
+ return
|
|
|
966
|
+
|
|
|
967
|
+ parts = commit_info.rsplit(' ', 1)
|
|
|
968
|
+ if len(parts) != 2:
|
|
|
969
|
+ return
|
|
|
970
|
+
|
|
|
971
|
+ author = parts[0]
|
|
|
972
|
+ try:
|
|
|
973
|
+ timestamp = int(parts[1])
|
|
|
974
|
+ except ValueError:
|
|
|
975
|
+ return
|
|
|
976
|
+
|
|
|
977
|
+ # Update branch commit count
|
|
|
978
|
+ self.branches[branch_name]['commits'] += 1
|
|
|
979
|
+
|
|
|
980
|
+ # Update author statistics for this branch
|
|
|
981
|
+ if author not in self.branches[branch_name]['authors']:
|
|
|
982
|
+ self.branches[branch_name]['authors'][author] = {
|
|
|
983
|
+ 'commits': 0,
|
|
|
984
|
+ 'lines_added': 0,
|
|
|
985
|
+ 'lines_removed': 0
|
|
|
986
|
+ }
|
|
|
987
|
+ self.branches[branch_name]['authors'][author]['commits'] += 1
|
|
|
988
|
+
|
|
|
989
|
+ # Get line changes for this commit
|
|
|
990
|
+ try:
|
|
|
991
|
+ numstat_output = getpipeoutput([f'git show --numstat --format="" {commit_hash}'])
|
|
|
992
|
+ for line in numstat_output.split('\n'):
|
|
|
993
|
+ if line.strip() and '\t' in line:
|
|
|
994
|
+ parts = line.split('\t')
|
|
|
995
|
+ if len(parts) >= 2:
|
|
|
996
|
+ try:
|
|
|
997
|
+ additions = int(parts[0]) if parts[0] != '-' else 0
|
|
|
998
|
+ deletions = int(parts[1]) if parts[1] != '-' else 0
|
|
|
999
|
+
|
|
|
1000
|
+ # Update branch statistics
|
|
|
1001
|
+ self.branches[branch_name]['lines_added'] += additions
|
|
|
1002
|
+ self.branches[branch_name]['lines_removed'] += deletions
|
|
|
1003
|
+
|
|
|
1004
|
+ # Update author statistics for this branch
|
|
|
1005
|
+ self.branches[branch_name]['authors'][author]['lines_added'] += additions
|
|
|
1006
|
+ self.branches[branch_name]['authors'][author]['lines_removed'] += deletions
|
|
|
1007
|
+
|
|
|
1008
|
+ except ValueError:
|
|
|
1009
|
+ pass
|
|
|
1010
|
+ except:
|
|
|
1011
|
+ pass
|
|
|
1012
|
+
|
|
|
1013
|
+ except Exception as e:
|
|
|
1014
|
+ if conf['debug']:
|
|
|
1015
|
+ print(f'Warning: Failed to analyze commit {commit_hash}: {e}')
|
|
|
1016
|
+
|
|
830
|
1017
|
def refine(self):
|
|
831
|
1018
|
# authors
|
|
832
|
1019
|
# name -> {place_by_commits, commits_frac, date_first, date_last, timedelta}
|
|
|
@@ -977,6 +1164,53 @@ class GitDataCollector(DataCollector):
|
|
977
|
1164
|
"""Get repository size in MB."""
|
|
978
|
1165
|
return getattr(self, 'repository_size_mb', 0.0)
|
|
979
|
1166
|
|
|
|
1167
|
+ def getBranches(self):
|
|
|
1168
|
+ """Get all branches with their statistics."""
|
|
|
1169
|
+ return self.branches
|
|
|
1170
|
+
|
|
|
1171
|
+ def getUnmergedBranches(self):
|
|
|
1172
|
+ """Get list of unmerged branch names."""
|
|
|
1173
|
+ return self.unmerged_branches
|
|
|
1174
|
+
|
|
|
1175
|
+ def getMainBranch(self):
|
|
|
1176
|
+ """Get the detected main branch name."""
|
|
|
1177
|
+ return getattr(self, 'main_branch', 'master')
|
|
|
1178
|
+
|
|
|
1179
|
+ def getBranchInfo(self, branch_name):
|
|
|
1180
|
+ """Get detailed information about a specific branch."""
|
|
|
1181
|
+ return self.branches.get(branch_name, {})
|
|
|
1182
|
+
|
|
|
1183
|
+ def getBranchAuthors(self, branch_name):
|
|
|
1184
|
+ """Get authors who contributed to a specific branch."""
|
|
|
1185
|
+ branch_info = self.branches.get(branch_name, {})
|
|
|
1186
|
+ return branch_info.get('authors', {})
|
|
|
1187
|
+
|
|
|
1188
|
+ def getBranchesByCommits(self, limit=None):
|
|
|
1189
|
+ """Get branches sorted by number of commits."""
|
|
|
1190
|
+ sorted_branches = sorted(self.branches.items(),
|
|
|
1191
|
+ key=lambda x: x[1].get('commits', 0),
|
|
|
1192
|
+ reverse=True)
|
|
|
1193
|
+ if limit:
|
|
|
1194
|
+ return sorted_branches[:limit]
|
|
|
1195
|
+ return sorted_branches
|
|
|
1196
|
+
|
|
|
1197
|
+ def getBranchesByLinesChanged(self, limit=None):
|
|
|
1198
|
+ """Get branches sorted by total lines changed."""
|
|
|
1199
|
+ sorted_branches = sorted(self.branches.items(),
|
|
|
1200
|
+ key=lambda x: x[1].get('lines_added', 0) + x[1].get('lines_removed', 0),
|
|
|
1201
|
+ reverse=True)
|
|
|
1202
|
+ if limit:
|
|
|
1203
|
+ return sorted_branches[:limit]
|
|
|
1204
|
+ return sorted_branches
|
|
|
1205
|
+
|
|
|
1206
|
+ def getUnmergedBranchStats(self):
|
|
|
1207
|
+ """Get statistics for unmerged branches only."""
|
|
|
1208
|
+ unmerged_stats = {}
|
|
|
1209
|
+ for branch_name in self.unmerged_branches:
|
|
|
1210
|
+ if branch_name in self.branches:
|
|
|
1211
|
+ unmerged_stats[branch_name] = self.branches[branch_name]
|
|
|
1212
|
+ return unmerged_stats
|
|
|
1213
|
+
|
|
980
|
1214
|
def revToDate(self, rev):
|
|
981
|
1215
|
stamp = int(getpipeoutput(['git log --pretty=format:%%at "%s" -n 1' % rev]))
|
|
982
|
1216
|
return datetime.datetime.fromtimestamp(stamp).strftime('%Y-%m-%d')
|
|
|
@@ -1067,6 +1301,18 @@ class HTMLReportCreator(ReportCreator):
|
|
1067
|
1301
|
f.write('<dt>Total Commits</dt><dd>%s (average %.1f commits per active day, %.1f per all days)</dd>' % (total_commits, avg_active, avg_all))
|
|
1068
|
1302
|
avg_per_author = float(total_commits) / total_authors if total_authors else 0.0
|
|
1069
|
1303
|
f.write('<dt>Authors</dt><dd>%s (average %.1f commits per author)</dd>' % (total_authors, avg_per_author))
|
|
|
1304
|
+
|
|
|
1305
|
+ # Branch statistics
|
|
|
1306
|
+ branches = data.getBranches() if hasattr(data, 'getBranches') else {}
|
|
|
1307
|
+ unmerged_branches = data.getUnmergedBranches() if hasattr(data, 'getUnmergedBranches') else []
|
|
|
1308
|
+ main_branch = data.getMainBranch() if hasattr(data, 'getMainBranch') else 'master'
|
|
|
1309
|
+
|
|
|
1310
|
+ if branches:
|
|
|
1311
|
+ f.write('<dt>Total Branches</dt><dd>%d</dd>' % len(branches))
|
|
|
1312
|
+ if unmerged_branches:
|
|
|
1313
|
+ f.write('<dt>Unmerged Branches</dt><dd>%d (%s)</dd>' % (len(unmerged_branches), ', '.join(unmerged_branches[:5]) + ('...' if len(unmerged_branches) > 5 else '')))
|
|
|
1314
|
+ f.write('<dt>Main Branch</dt><dd>%s</dd>' % main_branch)
|
|
|
1315
|
+
|
|
1070
|
1316
|
f.write('</dl>')
|
|
1071
|
1317
|
|
|
1072
|
1318
|
f.write('</body>\n</html>')
|
|
|
@@ -1416,6 +1662,111 @@ class HTMLReportCreator(ReportCreator):
|
|
1416
|
1662
|
f.write('</body></html>')
|
|
1417
|
1663
|
f.close()
|
|
1418
|
1664
|
|
|
|
1665
|
+ ###
|
|
|
1666
|
+ # Branches
|
|
|
1667
|
+ f = open(path + '/branches.html', 'w')
|
|
|
1668
|
+ self.printHeader(f)
|
|
|
1669
|
+ f.write('<h1>Branches</h1>')
|
|
|
1670
|
+ self.printNav(f)
|
|
|
1671
|
+
|
|
|
1672
|
+ # Branch summary
|
|
|
1673
|
+ branches = data.getBranches() if hasattr(data, 'getBranches') else {}
|
|
|
1674
|
+ unmerged_branches = data.getUnmergedBranches() if hasattr(data, 'getUnmergedBranches') else []
|
|
|
1675
|
+ main_branch = data.getMainBranch() if hasattr(data, 'getMainBranch') else 'master'
|
|
|
1676
|
+
|
|
|
1677
|
+ f.write('<dl>')
|
|
|
1678
|
+ f.write('<dt>Total Branches</dt><dd>%d</dd>' % len(branches))
|
|
|
1679
|
+ if unmerged_branches:
|
|
|
1680
|
+ f.write('<dt>Unmerged Branches</dt><dd>%d</dd>' % len(unmerged_branches))
|
|
|
1681
|
+ f.write('<dt>Main Branch</dt><dd>%s</dd>' % main_branch)
|
|
|
1682
|
+ f.write('</dl>')
|
|
|
1683
|
+
|
|
|
1684
|
+ if branches:
|
|
|
1685
|
+ # Branches :: All Branches
|
|
|
1686
|
+ f.write(html_header(2, 'All Branches'))
|
|
|
1687
|
+ f.write('<table class="branches sortable" id="branches">')
|
|
|
1688
|
+ f.write('<tr><th>Branch</th><th>Status</th><th>Commits</th><th>Lines Added</th><th>Lines Removed</th><th>Total Changes</th><th>Authors</th></tr>')
|
|
|
1689
|
+
|
|
|
1690
|
+ # Sort branches by total changes (lines added + removed)
|
|
|
1691
|
+ sorted_branches = sorted(branches.items(),
|
|
|
1692
|
+ key=lambda x: x[1].get('lines_added', 0) + x[1].get('lines_removed', 0),
|
|
|
1693
|
+ reverse=True)
|
|
|
1694
|
+
|
|
|
1695
|
+ for branch_name, branch_info in sorted_branches:
|
|
|
1696
|
+ status = 'Merged' if branch_info.get('is_merged', True) else 'Unmerged'
|
|
|
1697
|
+ commits = branch_info.get('commits', 0)
|
|
|
1698
|
+ lines_added = branch_info.get('lines_added', 0)
|
|
|
1699
|
+ lines_removed = branch_info.get('lines_removed', 0)
|
|
|
1700
|
+ total_changes = lines_added + lines_removed
|
|
|
1701
|
+ authors_count = len(branch_info.get('authors', {}))
|
|
|
1702
|
+
|
|
|
1703
|
+ # Highlight unmerged branches
|
|
|
1704
|
+ row_class = 'class="unmerged"' if not branch_info.get('is_merged', True) else ''
|
|
|
1705
|
+ f.write('<tr %s><td>%s</td><td>%s</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td><td>%d</td></tr>' %
|
|
|
1706
|
+ (row_class, branch_name, status, commits, lines_added, lines_removed, total_changes, authors_count))
|
|
|
1707
|
+ f.write('</table>')
|
|
|
1708
|
+
|
|
|
1709
|
+ # Unmerged Branches Detail
|
|
|
1710
|
+ if unmerged_branches:
|
|
|
1711
|
+ f.write(html_header(2, 'Unmerged Branches Detail'))
|
|
|
1712
|
+ f.write('<p>These branches have not been merged into the main branch (%s) and may represent ongoing work or abandoned features.</p>' % main_branch)
|
|
|
1713
|
+
|
|
|
1714
|
+ f.write('<table class="unmerged-branches sortable" id="unmerged">')
|
|
|
1715
|
+ f.write('<tr><th>Branch</th><th>Commits</th><th>Authors</th><th>Top Contributors</th><th>Lines Added</th><th>Lines Removed</th></tr>')
|
|
|
1716
|
+
|
|
|
1717
|
+ unmerged_stats = data.getUnmergedBranchStats() if hasattr(data, 'getUnmergedBranchStats') else {}
|
|
|
1718
|
+
|
|
|
1719
|
+ for branch_name in unmerged_branches:
|
|
|
1720
|
+ if branch_name in unmerged_stats:
|
|
|
1721
|
+ branch_info = unmerged_stats[branch_name]
|
|
|
1722
|
+ commits = branch_info.get('commits', 0)
|
|
|
1723
|
+ authors = branch_info.get('authors', {})
|
|
|
1724
|
+ lines_added = branch_info.get('lines_added', 0)
|
|
|
1725
|
+ lines_removed = branch_info.get('lines_removed', 0)
|
|
|
1726
|
+
|
|
|
1727
|
+ # Get top contributors
|
|
|
1728
|
+ top_contributors = sorted(authors.items(), key=lambda x: x[1].get('commits', 0), reverse=True)[:3]
|
|
|
1729
|
+ contributors_str = ', '.join([f"{author} ({info.get('commits', 0)})" for author, info in top_contributors])
|
|
|
1730
|
+
|
|
|
1731
|
+ f.write('<tr><td>%s</td><td>%d</td><td>%d</td><td>%s</td><td>%d</td><td>%d</td></tr>' %
|
|
|
1732
|
+ (branch_name, commits, len(authors), contributors_str, lines_added, lines_removed))
|
|
|
1733
|
+ f.write('</table>')
|
|
|
1734
|
+
|
|
|
1735
|
+ # Branch Activity by Author
|
|
|
1736
|
+ f.write(html_header(2, 'Branch Activity by Author'))
|
|
|
1737
|
+ f.write('<p>This table shows which authors have contributed to which branches.</p>')
|
|
|
1738
|
+
|
|
|
1739
|
+ # Collect all unique authors across all branches
|
|
|
1740
|
+ all_authors = set()
|
|
|
1741
|
+ for branch_info in branches.values():
|
|
|
1742
|
+ all_authors.update(branch_info.get('authors', {}).keys())
|
|
|
1743
|
+
|
|
|
1744
|
+ if all_authors and len(branches) > 1:
|
|
|
1745
|
+ f.write('<table class="branch-authors sortable" id="branch-authors">')
|
|
|
1746
|
+ header = '<tr><th>Author</th>'
|
|
|
1747
|
+ for branch_name in sorted(branches.keys()):
|
|
|
1748
|
+ header += '<th>%s</th>' % branch_name
|
|
|
1749
|
+ header += '<th>Total Branches</th></tr>'
|
|
|
1750
|
+ f.write(header)
|
|
|
1751
|
+
|
|
|
1752
|
+ for author in sorted(all_authors):
|
|
|
1753
|
+ row = '<tr><td>%s</td>' % author
|
|
|
1754
|
+ branch_count = 0
|
|
|
1755
|
+ for branch_name in sorted(branches.keys()):
|
|
|
1756
|
+ branch_authors = branches[branch_name].get('authors', {})
|
|
|
1757
|
+ if author in branch_authors:
|
|
|
1758
|
+ commits = branch_authors[author].get('commits', 0)
|
|
|
1759
|
+ row += '<td>%d</td>' % commits
|
|
|
1760
|
+ branch_count += 1
|
|
|
1761
|
+ else:
|
|
|
1762
|
+ row += '<td>-</td>'
|
|
|
1763
|
+ row += '<td>%d</td></tr>' % branch_count
|
|
|
1764
|
+ f.write(row)
|
|
|
1765
|
+ f.write('</table>')
|
|
|
1766
|
+
|
|
|
1767
|
+ f.write('</body></html>')
|
|
|
1768
|
+ f.close()
|
|
|
1769
|
+
|
|
1419
|
1770
|
###
|
|
1420
|
1771
|
# Files
|
|
1421
|
1772
|
f = open(path + '/files.html', 'w')
|
|
|
@@ -1873,6 +2224,7 @@ plot 'pace_of_changes.dat' using 1:2 w lines lw 2
|
|
1873
|
2224
|
<li><a href="index.html">General</a></li>
|
|
1874
|
2225
|
<li><a href="activity.html">Activity</a></li>
|
|
1875
|
2226
|
<li><a href="authors.html">Authors</a></li>
|
|
|
2227
|
+<li><a href="branches.html">Branches</a></li>
|
|
1876
|
2228
|
<li><a href="files.html">Files</a></li>
|
|
1877
|
2229
|
<li><a href="lines.html">Lines</a></li>
|
|
1878
|
2230
|
<li><a href="tags.html">Tags</a></li>
|
|
|
@@ -1905,6 +2257,7 @@ class PDFReportCreator(ReportCreator):
|
|
1905
|
2257
|
self._create_files_page(data)
|
|
1906
|
2258
|
self._create_lines_page(data)
|
|
1907
|
2259
|
self._create_tags_page(data)
|
|
|
2260
|
+ self._create_branches_page(data)
|
|
1908
|
2261
|
|
|
1909
|
2262
|
# Save PDF
|
|
1910
|
2263
|
pdf_path = os.path.join(path, f"gitstats_{data.projectname.replace(' ', '_')}.pdf")
|
|
|
@@ -1975,7 +2328,8 @@ class PDFReportCreator(ReportCreator):
|
|
1975
|
2328
|
'3. Authors Statistics',
|
|
1976
|
2329
|
'4. Files Statistics',
|
|
1977
|
2330
|
'5. Lines of Code Statistics',
|
|
1978
|
|
- '6. Tags Statistics'
|
|
|
2331
|
+ '6. Tags Statistics',
|
|
|
2332
|
+ '7. Branches Statistics'
|
|
1979
|
2333
|
]
|
|
1980
|
2334
|
|
|
1981
|
2335
|
for section in sections:
|
|
|
@@ -2007,7 +2361,10 @@ class PDFReportCreator(ReportCreator):
|
|
2007
|
2361
|
('Comment Lines', f"{data.getTotalCommentLines()} ({(100.0 * data.getTotalCommentLines() / data.getTotalLOC()) if data.getTotalLOC() else 0.0:.1f}%)"),
|
|
2008
|
2362
|
('Blank Lines', f"{data.getTotalBlankLines()} ({(100.0 * data.getTotalBlankLines() / data.getTotalLOC()) if data.getTotalLOC() else 0.0:.1f}%)"),
|
|
2009
|
2363
|
('Total Commits', f"{total_commits} (average {(float(total_commits) / total_active_days) if total_active_days else 0.0:.1f} commits per active day, {(float(total_commits) / delta_days) if delta_days else 0.0:.1f} per all days)"),
|
|
2010
|
|
- ('Authors', f"{total_authors} (average {(float(total_commits) / total_authors) if total_authors else 0.0:.1f} commits per author)")
|
|
|
2364
|
+ ('Authors', f"{total_authors} (average {(float(total_commits) / total_authors) if total_authors else 0.0:.1f} commits per author)"),
|
|
|
2365
|
+ ('Total Branches', str(len(data.getBranches()))),
|
|
|
2366
|
+ ('Unmerged Branches', str(len(data.getUnmergedBranches()))),
|
|
|
2367
|
+ ('Main Branch', data.main_branch if hasattr(data, 'main_branch') else 'N/A')
|
|
2011
|
2368
|
]
|
|
2012
|
2369
|
|
|
2013
|
2370
|
# Display stats
|
|
|
@@ -2496,6 +2853,114 @@ class PDFReportCreator(ReportCreator):
|
|
2496
|
2853
|
self.pdf.cell(30, 6, tag_info['date'][:10], 1, 0, 'C')
|
|
2497
|
2854
|
self.pdf.cell(25, 6, str(tag_info['commits']), 1, 0, 'C')
|
|
2498
|
2855
|
self.pdf.cell(80, 6, display_authors, 1, 1, 'L')
|
|
|
2856
|
+
|
|
|
2857
|
+ def _create_branches_page(self, data):
|
|
|
2858
|
+ """Create the branches statistics page (mirrors branches.html)."""
|
|
|
2859
|
+ self.pdf.add_page()
|
|
|
2860
|
+ self.pdf.set_font('Arial', 'B', 20)
|
|
|
2861
|
+ self.pdf.cell(0, 15, '7. Branches Statistics', 0, 1, 'L')
|
|
|
2862
|
+
|
|
|
2863
|
+ self.pdf.set_font('Arial', '', 12)
|
|
|
2864
|
+
|
|
|
2865
|
+ if not hasattr(data, 'branches') or not data.branches:
|
|
|
2866
|
+ self.pdf.cell(0, 10, 'No branches found in repository.', 0, 1, 'L')
|
|
|
2867
|
+ return
|
|
|
2868
|
+
|
|
|
2869
|
+ # Basic branch stats
|
|
|
2870
|
+ total_branches = len(data.getBranches())
|
|
|
2871
|
+ unmerged_branches = data.getUnmergedBranches()
|
|
|
2872
|
+ total_unmerged = len(unmerged_branches)
|
|
|
2873
|
+ main_branch = data.main_branch if hasattr(data, 'main_branch') else 'N/A'
|
|
|
2874
|
+
|
|
|
2875
|
+ stats = [
|
|
|
2876
|
+ ('Total branches', str(total_branches)),
|
|
|
2877
|
+ ('Unmerged branches', str(total_unmerged)),
|
|
|
2878
|
+ ('Main branch', main_branch),
|
|
|
2879
|
+ ]
|
|
|
2880
|
+
|
|
|
2881
|
+ for label, value in stats:
|
|
|
2882
|
+ self.pdf.cell(50, 8, f"{label}:", 0, 0, 'L')
|
|
|
2883
|
+ self.pdf.cell(0, 8, str(value), 0, 1, 'L')
|
|
|
2884
|
+
|
|
|
2885
|
+ self.pdf.ln(10)
|
|
|
2886
|
+
|
|
|
2887
|
+ # Branches summary table
|
|
|
2888
|
+ self.pdf.set_font('Arial', 'B', 12)
|
|
|
2889
|
+ self.pdf.cell(0, 10, 'All Branches', 0, 1, 'L')
|
|
|
2890
|
+
|
|
|
2891
|
+ # Table header
|
|
|
2892
|
+ self.pdf.set_font('Arial', 'B', 9)
|
|
|
2893
|
+ self.pdf.cell(35, 8, 'Branch Name', 1, 0, 'C')
|
|
|
2894
|
+ self.pdf.cell(20, 8, 'Status', 1, 0, 'C')
|
|
|
2895
|
+ self.pdf.cell(20, 8, 'Commits', 1, 0, 'C')
|
|
|
2896
|
+ self.pdf.cell(25, 8, 'Lines Added', 1, 0, 'C')
|
|
|
2897
|
+ self.pdf.cell(25, 8, 'Lines Removed', 1, 0, 'C')
|
|
|
2898
|
+ self.pdf.cell(20, 8, 'Authors', 1, 0, 'C')
|
|
|
2899
|
+ self.pdf.cell(45, 8, 'First Author', 1, 1, 'C')
|
|
|
2900
|
+
|
|
|
2901
|
+ # Table data - sort by commits descending
|
|
|
2902
|
+ self.pdf.set_font('Arial', '', 8)
|
|
|
2903
|
+ branches_sorted = sorted(data.branches.items(),
|
|
|
2904
|
+ key=lambda x: x[1].get('commits', 0), reverse=True)
|
|
|
2905
|
+
|
|
|
2906
|
+ for branch_name, branch_data in branches_sorted:
|
|
|
2907
|
+ # Determine status
|
|
|
2908
|
+ status = 'Unmerged' if branch_name in [b for b in unmerged_branches] else 'Merged'
|
|
|
2909
|
+
|
|
|
2910
|
+ # Get branch statistics
|
|
|
2911
|
+ commits = branch_data.get('commits', 0)
|
|
|
2912
|
+ lines_added = branch_data.get('lines_added', 0)
|
|
|
2913
|
+ lines_removed = branch_data.get('lines_removed', 0)
|
|
|
2914
|
+ authors_count = len(branch_data.get('authors', {}))
|
|
|
2915
|
+
|
|
|
2916
|
+ # Get first/main author
|
|
|
2917
|
+ authors = branch_data.get('authors', {})
|
|
|
2918
|
+ if authors:
|
|
|
2919
|
+ first_author = max(authors.items(), key=lambda x: x[1])[0]
|
|
|
2920
|
+ first_author = first_author[:20] + "..." if len(first_author) > 23 else first_author
|
|
|
2921
|
+ else:
|
|
|
2922
|
+ first_author = 'N/A'
|
|
|
2923
|
+
|
|
|
2924
|
+ # Truncate branch name if too long
|
|
|
2925
|
+ display_branch = branch_name[:18] + "..." if len(branch_name) > 21 else branch_name
|
|
|
2926
|
+
|
|
|
2927
|
+ self.pdf.cell(35, 6, display_branch, 1, 0, 'L')
|
|
|
2928
|
+ self.pdf.cell(20, 6, status, 1, 0, 'C')
|
|
|
2929
|
+ self.pdf.cell(20, 6, str(commits), 1, 0, 'C')
|
|
|
2930
|
+ self.pdf.cell(25, 6, str(lines_added), 1, 0, 'C')
|
|
|
2931
|
+ self.pdf.cell(25, 6, str(lines_removed), 1, 0, 'C')
|
|
|
2932
|
+ self.pdf.cell(20, 6, str(authors_count), 1, 0, 'C')
|
|
|
2933
|
+ self.pdf.cell(45, 6, first_author, 1, 1, 'L')
|
|
|
2934
|
+
|
|
|
2935
|
+ # Unmerged branches detail section
|
|
|
2936
|
+ if total_unmerged > 0:
|
|
|
2937
|
+ self.pdf.ln(10)
|
|
|
2938
|
+ self.pdf.set_font('Arial', 'B', 14)
|
|
|
2939
|
+ self.pdf.cell(0, 10, f'Unmerged Branches Details ({total_unmerged})', 0, 1, 'L')
|
|
|
2940
|
+
|
|
|
2941
|
+ self.pdf.set_font('Arial', '', 10)
|
|
|
2942
|
+ for branch_name in unmerged_branches:
|
|
|
2943
|
+ if branch_name in data.branches:
|
|
|
2944
|
+ branch_data = data.branches[branch_name]
|
|
|
2945
|
+
|
|
|
2946
|
+ self.pdf.set_font('Arial', 'B', 10)
|
|
|
2947
|
+ self.pdf.cell(0, 8, f"Branch: {branch_name}", 0, 1, 'L')
|
|
|
2948
|
+
|
|
|
2949
|
+ self.pdf.set_font('Arial', '', 9)
|
|
|
2950
|
+ self.pdf.cell(20, 6, f" Commits: {branch_data.get('commits', 0)}", 0, 1, 'L')
|
|
|
2951
|
+ self.pdf.cell(20, 6, f" Lines: +{branch_data.get('lines_added', 0)} -{branch_data.get('lines_removed', 0)}", 0, 1, 'L')
|
|
|
2952
|
+
|
|
|
2953
|
+ # Show authors
|
|
|
2954
|
+ authors = branch_data.get('authors', {})
|
|
|
2955
|
+ if authors:
|
|
|
2956
|
+ author_list = sorted(authors.items(), key=lambda x: x[1], reverse=True)
|
|
|
2957
|
+ author_str = ', '.join([f"{author}({commits})" for author, commits in author_list[:3]])
|
|
|
2958
|
+ if len(author_list) > 3:
|
|
|
2959
|
+ author_str += f" and {len(author_list) - 3} more"
|
|
|
2960
|
+ self.pdf.cell(20, 6, f" Authors: {author_str}", 0, 1, 'L')
|
|
|
2961
|
+
|
|
|
2962
|
+ self.pdf.ln(2)
|
|
|
2963
|
+
|
|
2499
|
2964
|
|
|
2500
|
2965
|
|
|
2501
|
2966
|
def usage():
|