-
Notifications
You must be signed in to change notification settings - Fork 1
/
git2gantt
executable file
·337 lines (274 loc) · 11 KB
/
git2gantt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
#!/usr/bin/env python3
"""Simple tool to turn git commit histories into a mermaid gantt chart."""
##############################################################################
# Module information.
__author__ = "Dave Pearson"
__copyright__ = "Copyright 2019, Dave Pearson"
__licence__ = "GPL"
__credits__ = [ "Dave Pearson" ]
__maintainer__ = "Dave Pearson"
__version__ = "0.0.1"
##############################################################################
# Imports.
import os
import sys
import argparse
import subprocess
from pathlib import Path
from datetime import datetime, timedelta
##############################################################################
# Exception for signifying that there's no such repo.
class NoSuchRepo( FileNotFoundError ):
"""No such repository error."""
##############################################################################
# Exception for signifying that a directory isn't a git repo.
class NotAGitRepo( Exception ):
"""Directory exists but isn't a git repository."""
##############################################################################
# Does a given location look like a git repository?
def is_gitish( location ):
"""Does the given location look gitish?
:param Path location: A path to look at.
:returns: True if it looks like a git repository, False if not.
:rtype: bool
"""
return ( location / ".git" ).is_dir()
##############################################################################
# Find the root directory of a repository.
def find_repo_root( repo ):
"""Find the root directory of a given repository.
:param Path repo: A path to look at.
:returns: The path to the root, if one could be found. Or None.
"""
for location in [ repo, *repo.parents ]:
if is_gitish( location ):
return location
return None
##############################################################################
# Tidy up a repository.
def tidy_repo( repo ):
"""Tidy the details of the given repository.
:param str repo: A path to a possible repository.
:returns: A fully-qualified path to the repository.
:rtype: Path
:raises: NoSuchRepo
:raises: NotAGitRepo
"""
# Wrap it up as a path object.
repo = Path( repo ).resolve()
# Does it exist?
if not Path( repo ).exists():
# If the above use of Path, again, looks odd... It's to keep pylint
# quiet. For some reason it thinks repo is an instance of PurePath
# here, rather than Path.
raise NoSuchRepo( "{} does not exist".format( str( repo ) ) )
# Given the path, try and find the repo root in it.
repo = find_repo_root( repo )
# Throw an error if one wasn't found.
if not repo:
raise NotAGitRepo(
"{} does not look like a git repository".format( str( repo ) )
)
# Return clean repository path.
return repo
##############################################################################
# Tidy up the repository list.
def tidy_repos( repos ):
"""Tidy the repository list, and also check they're all good.
:param list repos: A list of repository paths to work from.
:returns: A tidy list of repositories.
:rtype: list
Note that this function takes each of the repository paths and turns
them into a Path object, ensuring that they exist and look like a git
repository along the way.
"""
return [ tidy_repo( repo ) for repo in repos ]
##############################################################################
# Turn a string date into a date/time value.
def str_to_date( string_date ):
"""Convert a string date into a date value.
:param str string_date: A string date in YYYY-MM-DD format.
:returns: A date value.
:rtype: datetime
"""
return datetime.strptime( string_date, "%Y-%m-%d" )
##############################################################################
# Get the history of the given repository.
def get_history( args, repo ):
"""Get the history of the given repository.
:param Namespace args: The command line arguments.
:param Path repo: A path to a repository.
:returns: A list of commit dates for the repository.
:rtype: list
"""
# Go to the repo.
os.chdir( str( repo ) )
# Get the author argument.
author = [ "--author={}".format( args.author ) ] if args.author else []
# Get the branch argument.
branches = [ "--all" ] if args.every_branch else []
# Get the repo history from git.
log, _ = subprocess.Popen( [
"git", "log", "--date=iso8601-strict", "--pretty=format:%ad",
*author, *branches
], stdout=subprocess.PIPE, universal_newlines=True ).communicate()
# Return a sorted list of unique dates that there's been commits to the repo.
return sorted( { str_to_date( date[ 0:10 ] ) for date in log.splitlines() } )
##############################################################################
# Get the command line params.
def get_args():
"""Parse the command line parameters.
:returns: The parsed command line arguments.
:rtype: Namespace
"""
# Create the argument parser object.
parser = argparse.ArgumentParser(
description = "git history to mermaid gantt chart tool",
epilog = "v{}".format( __version__ )
)
# Add --author
parser.add_argument(
"-a", "--author",
help = "Limit to commits by the given author."
)
# Add --description
parser.add_argument(
"-d", "--description",
default = "Development",
help = "Description to give each session."
)
# Add --every-branch
parser.add_argument(
"-e", "--every-branch",
action = "store_true",
default = False,
help = "Use commits for every available branch."
)
# Add --fuzz
parser.add_argument(
"-f", "--fuzz",
default = "0",
type = int,
help = "Size of a 'fuzzy' period when deciding contiguous days."
)
# Add --title
parser.add_argument(
"-t", "--title",
default = "git2gantt output",
help = "The title for the chart."
)
# Add --version
parser.add_argument(
"-v", "--version",
help = "Show version information.",
action = "version",
version = "%(prog)s v{}".format( __version__ )
)
# The remainder is the path to the repositories to check.
parser.add_argument( "repos", nargs="+" )
# Parse the command line.
return parser.parse_args()
##############################################################################
# Get a list of the next days that are considered to be working days.
def next_working_days( args, day ):
"""Return a list of dates that are considered to be contiguous.
:param Namespace args: The command line arguments.
:param datetime day: The day to get the next working day from.
:returns: A list of dates that are considered to be contiguous.
:rtype: list
Note that this function makes a lot of assumptions about what a working
day is and how to get to it. This works for me. If anyone else needs to
make use of this and their idea of a working day differs it would make
sense to make this function far more flexible.
"""
return [
day + timedelta( days=days+1 ) for days in range( {
0: 1, # Monday
1: 1, # Tuesday
2: 1, # Wednesday
3: 1, # Thursday
4: 3, # Friday
5: 2, # Saturday
6: 1, # Sunday
}[ day.weekday() ] + args.fuzz )
]
##############################################################################
# Convert a repo's history into a list of start days and sessions lengths.
def contiguous_days( args, history ):
"""Convert a repository's history into a list of sessions and length.
:param Namespace args: The command line arguments.
:param list history: The history of commit days.
:returns: A list of start days and session length.
:rtype: list
"""
# Get the initial session start date.
session_start_day = history[ 0 ]
# Start with the first day in the history.
days = { session_start_day : session_start_day }
# Working through the history, looking at "yesterday" and "today"...
for yesterday, today in zip( history, history[ 1: ] ):
# If it's not a contiguous working day...
if not today in next_working_days( args, yesterday ):
# ...it's the start of a new coding session.
session_start_day = today
# Record today as the last day of the session (for now).
days[ session_start_day ] = today
# Return the result.
return days
##############################################################################
# Emit the mermaid code for the repository.
def emit_repo( args, name, history ):
"""Emit the mermaid code for the given repository.
:param Namespace args: The command line arguments.
:param str name: The name of the repository.
:param list history: The history of commit days for the given repository.
"""
# If there's any history to report...
if history:
# Start the section for the repo.
print()
print( " section {}".format( name ) )
# Print out the coding sessions.
for start_day, end_day in sorted( contiguous_days( args, history ).items() ):
print( " {}: {}, {}, {}".format(
args.description,
"dev{}{}".format( name, start_day.strftime( "%Y%m%d" ) ),
start_day.strftime( "%Y-%m-%d" ),
( end_day + timedelta( days=1 ) ).strftime( "%Y-%m-%d" )
) )
##############################################################################
# Emit the mermaid code for the repositories.
def emit_mermaid( args, repos ):
"""Emit the mermaid code for the given repositories.
:param Namespace args: The command line arguments.
:param dict repos: Dictionary of repositories.
"""
# Print the header of the diagram.
print( "gantt" )
print( " title {}".format( args.title ) )
print( " dateFormat YYYY-MM-DD" )
# Emit each of the repos.
for repo, history in repos:
emit_repo( args, repo, history )
##############################################################################
# Main code.
def main():
"""Main entry point."""
# Get the arguments.
args = get_args()
try:
# Tidy up all the repo pointers, and ensure they're all good.
args.repos = tidy_repos( args.repos )
except ( NoSuchRepo, NotAGitRepo ) as error:
print( error, file=sys.stderr )
sys.exit( 1 )
# Emit the gantt diagram from the commit histories.
emit_mermaid( args, [
( repo.parts[ -1 ], get_history( args, repo ) ) for repo in args.repos
] )
##############################################################################
# Main entry point.
if __name__ == "__main__":
main()
### git2gantt ends here