瀏覽代碼

Add branch analysis and styling for unmerged branches in GitStats

lechibang-1512 2 月之前
父節點
當前提交
401800ad0c
共有 2 個檔案被更改,包括 493 行新增2 行删除
  1. 467
    2
      gitstats
  2. 26
    0
      gitstats.css

+ 467
- 2
gitstats 查看文件

@@ -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():

+ 26
- 0
gitstats.css 查看文件

@@ -143,3 +143,29 @@ h2 a {
143 143
 .moreauthors {
144 144
 	font-size: 80%;
145 145
 }
146
+
147
+/* Branch-specific styles */
148
+.branches tr.unmerged {
149
+	background-color: #ffd0d0 !important;
150
+}
151
+
152
+.branches tr.unmerged:hover {
153
+	background-color: #ffb0b0 !important;
154
+}
155
+
156
+.unmerged-branches {
157
+	border: 2px solid #ff6666;
158
+}
159
+
160
+.unmerged-branches th {
161
+	background-color: #ffcccc;
162
+}
163
+
164
+.branch-authors td {
165
+	text-align: center;
166
+}
167
+
168
+.branch-authors td:first-child {
169
+	text-align: left;
170
+	font-weight: bold;
171
+}