├── CHANGELOG.md ├── README.md └── pcommit.py /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 18.7.24 2 | * **Fix(es):** 3 | * changelog file name 4 | * **Refactor(s):** 5 | * make logs more readable 6 | 7 | Generated at 2018-07-24 17:33 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pouriya's commit handler 2 | I use this script in coding time to have structured Git commit messages and a nice changelog (history of project changes). This script does two things; commit changes, generate changelog file. 3 | 4 | 5 | # Commit changes 6 | ```sh 7 | $ git init 8 | ... 9 | $ touch foo.bar 10 | $ git add foo.bar # don't forget this 11 | $ pcommit -m 12 | Insert commit type ('init', 'fix', 'feat', 'ref', 'test', 'doc', 'build', 'style', 'ci', 'ver'): init 13 | Insert commit main description (between 10-65 characters): add project source code 14 | Insert changed files separated by comma (Enter to skip): foo.bar 15 | Insert commit long description (Enter to skip): I have just added one file because of X and Y 16 | $ # ... 17 | ``` 18 | As you can see, each commit MUST have a type part which can be one of: 19 | * **init:** for initial commits. 20 | * **fix:** for fixing bugs. 21 | * **feat:** for adding features. 22 | * **ref:** for refactoring code. 23 | * **test:** for adding, fixing, improving test codes. 24 | * **doc:** for adding or updating documentation. 25 | * **build:** for updating Makefiles, etc for build process. 26 | * **style:** for fixing indentation or line breakings, etc. 27 | * **ci:** for updating CI files, for example `.travis.yml` file. 28 | * **ver:** for versioning. 29 | 30 | After type part, each commit has a short description and after that it CAN has a changed file list and also CAN has a long description. This part of scripts makes commit message in form of: 31 | ```txt 32 | : \n[][] 33 | ``` 34 | 35 | # Generate changelog 36 | I have 10 commit types, but actually I dont want a changelog for all types. Personally I prefer seeing just `fix`, `feat`, `ref` and `test` commits with its details. If you want more or less, there are some flags for changing output and finally you can simply edit the code for your own changelog. 37 | Suppose that I have a project with following commits: 38 | ```sh 39 | $ git log --oneline 40 | b98906f init: add files\nFiles: hello_world.c, Makefile, test/test.py\nThis is initial commit of project 41 | 69ffd51 fix: api function foo(bar, baz)\nFiles: hello_world.c\nBug X was generated because Y and fixed 42 | 6698b7a ver: 0.10.2 43 | 7789086 doc: improve\n New functions usage has been documented well using markdown in code 44 | 17fa611 build: change gcc flags\nFiles: Makefile\nAdded -O2 and -pipe 45 | 12434ab ref: use new X library API 46 | 28006e4 style: fix indentation 47 | 67b57af ver: 1.0.0 48 | 3be3d51 feat: CI using travis service 49 | fed846b ci: add redis server 50 | 0d122a4 ci: add code coverage 51 | ca38cea style: fix line breaking 52 | f851753 ref: improve speed\nfiles: hello_world.c\nSupport threading using pthread library 53 | 484b053 ver: 2.0.0 54 | ``` 55 | and I want to generate a changelog. Just run: 56 | ```sh 57 | $ pcommit -c 58 | ``` 59 | and I have new file named `CHANGELOG.md`. 60 | ```sh 61 | $ cat CHANGELOG.md 62 | ### 0.10.2 63 | * **Fix(es):** 64 | * api function foo(bar, baz) 65 | >Bug X was generated because Y and fixed 66 | ... 67 | * improve speed 68 | >Support threading using pthread library 69 | 70 | Files changed: hello_world.c 71 | 72 | Generated at 2018-07-18 00:00 73 | ``` 74 | It's nice Markdown file. I copied the output bellow. 75 | 76 | # Changelog output example 77 | ### 0.10.2 78 | * **Fix(es):** 79 | * api function foo(bar, baz) 80 | >Bug X was generated because Y and fixed 81 | 82 | Files changed: hello_world.c 83 | ### 1.0.0 84 | * **Refactor(s):** 85 | * use new X library API 86 | ### 2.0.0 87 | * **Feature(s):** 88 | * CI using travis service 89 | * **Refactor(s):** 90 | * improve speed 91 | >Support threading using pthread library 92 | 93 | Files changed: hello_world.c 94 | 95 | Generated at 2018-07-18 00:00 96 | 97 | # Notes 98 | You can use `-s` or `--since` to generating changelog only after specified version. 99 | If you have a repository with lots of different commit messages and want to use this script for next commits, don't worry. By default it skip unknown commit messages. If you want to stop on unknown commit message, use `--no-unknown-commits`. 100 | If you want to generate changelog in other formats for example HTML page, This script is your friend, Just edit `MarkdownChangeLogGenerator` class. 101 | 102 | # Install on a *nix OS 103 | Assuming that python 2 or 3 is installed. 104 | ```sh 105 | $ chmod a+x pcommit.py 106 | $ sudo ln -s $PWD/pcommit.py /usr/local/bin/pcommit 107 | ``` 108 | -------------------------------------------------------------------------------- /pcommit.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from subprocess import check_output 5 | from time import strftime, localtime 6 | import sys 7 | 8 | 9 | if sys.version_info < (3, 0): 10 | reload(sys) 11 | sys.setdefaultencoding('utf8') 12 | else: 13 | raw_input = input 14 | 15 | 16 | __author__ = "Pouriya Jahanbakhsh" 17 | __version__ = "18.7.24" 18 | 19 | 20 | global INIT_TYPE 21 | INIT_TYPE = "init" # Initial commits 22 | 23 | global FIX_TYPE 24 | FIX_TYPE = "fix" # Fix bugs 25 | 26 | global FEAT_TYPE 27 | FEAT_TYPE = "feat" # New features 28 | 29 | global REF_TYPE 30 | REF_TYPE = "ref" # Refactors 31 | 32 | global TEST_TYPE 33 | TEST_TYPE = "test" # Writing, fixing, refactoring test codes 34 | 35 | global DOC_TYPE 36 | DOC_TYPE = "doc" # Change documentations 37 | 38 | global BUILD_TYPE 39 | BUILD_TYPE = "build" # Changing Makefiles, etc 40 | 41 | global STYLE_TYPE 42 | STYLE_TYPE = "style" # Fix indentation, line breaks, etc 43 | 44 | global CI_TYPE 45 | CI_TYPE = "ci" # Editing file for Continuous Integration 46 | 47 | global VERSION_TYPE 48 | VERSION_TYPE = "ver" # Versioning 49 | 50 | global COMMIT_TYPES 51 | COMMIT_TYPES = [INIT_TYPE 52 | ,FIX_TYPE 53 | ,FEAT_TYPE 54 | ,REF_TYPE 55 | ,TEST_TYPE 56 | ,DOC_TYPE 57 | ,BUILD_TYPE 58 | ,STYLE_TYPE 59 | ,CI_TYPE 60 | ,VERSION_TYPE] 61 | 62 | 63 | class CommitsParser: 64 | 65 | 66 | def __init__(self, skip_unknown_commits=True): 67 | self.skip_unknown_commits = skip_unknown_commits 68 | 69 | 70 | def get_commit_lines(self): 71 | output = run_command("git log --oneline") 72 | # output = run_command("cat commits.log") # For test 73 | lines = [] 74 | for line in output.splitlines(): 75 | lines.append(line.split(" ", 1)[1]) # skip commit hash 76 | return lines 77 | 78 | 79 | def parse_commits(self, commit_lines): 80 | parsed_commits = [] 81 | for commit_line in commit_lines: 82 | try: 83 | commit = Commit(commit_line) 84 | parsed_commits.append(commit) 85 | except Exception as error: 86 | if not self.skip_unknown_commits: 87 | print("Error: {}".format(error)) 88 | exit(1) 89 | print("Warning: {}".format(error)) 90 | return parsed_commits 91 | 92 | 93 | def save_commits(self, commits): 94 | number = 1 95 | data = {number: [None, []]} 96 | commits.reverse() 97 | for commit in commits: 98 | if commit.type != VERSION_TYPE: 99 | data[number][1].append(commit) 100 | else: # i.e ver: 1.2.10 101 | data[number][0] = commit.short_description 102 | data[number][1].append(commit) 103 | 104 | number += 1 105 | data[number] = [None, []] 106 | self.commits = [] 107 | index = 1 108 | while index <= number: 109 | if data[index][0]: 110 | self.commits.append(tuple(data[index])) 111 | index += 1 112 | return self.commits 113 | 114 | 115 | def main(self): 116 | commit_lines = self.get_commit_lines() 117 | commits = self.parse_commits(commit_lines) 118 | self.save_commits(commits) 119 | 120 | 121 | class ChangeLog(CommitsParser): 122 | 123 | 124 | def __init__(self, since, skip_unknown_commits=True): 125 | CommitsParser.__init__(self, skip_unknown_commits) 126 | self.since = since 127 | 128 | 129 | def handle_commits(self, version, commits): 130 | return 131 | 132 | 133 | def handle_end_of_commits(self): 134 | pass 135 | 136 | 137 | def main(self): 138 | commit_lines = self.get_commit_lines() 139 | commits = self.parse_commits(commit_lines) 140 | self.save_commits(commits) 141 | if self.since: 142 | run_callback = False 143 | else: 144 | run_callback = True 145 | 146 | for commit in self.commits: 147 | if run_callback: 148 | self.handle_commits(commit[0], commit[1]) 149 | continue 150 | if commit[0] == self.since: 151 | run_callback = True 152 | self.handle_commits(commit[0], commit[1]) 153 | self.handle_end_of_commits() 154 | 155 | class MarkDownChangeLogGenerator(ChangeLog): 156 | 157 | FILE_NAME = "CHANGELOG.md" 158 | 159 | def __init__(self 160 | ,since=None 161 | ,skip_unknown_commits=True 162 | ,include_fix=True 163 | ,include_feat=True 164 | ,include_ref=True 165 | ,include_test=True 166 | ,fix_include_long_description=True 167 | ,feat_include_long_description=True 168 | ,ref_include_long_description=True 169 | ,test_include_long_description=True 170 | ,fix_include_files=True 171 | ,feat_include_files=True 172 | ,ref_include_files=True 173 | ,test_include_files=True): 174 | ChangeLog.__init__(self, since, skip_unknown_commits) 175 | 176 | self.fd = open(self.FILE_NAME, 'w') 177 | 178 | self.include_fix = include_fix 179 | self.include_feat = include_feat 180 | self.include_ref = include_ref 181 | self.include_test = include_test 182 | self.fix_include_long_description = fix_include_long_description 183 | self.feat_include_long_description = feat_include_long_description 184 | self.ref_include_long_description = ref_include_long_description 185 | self.test_include_long_description = test_include_long_description 186 | self.fix_include_files = fix_include_files 187 | self.feat_include_files = feat_include_files 188 | self.ref_include_files = ref_include_files 189 | self.test_include_files = test_include_files 190 | 191 | 192 | 193 | def handle_commits(self, version, commits): 194 | self.fd.write("### {}\r\n".format(version)) 195 | fix = [] 196 | feat = [] 197 | ref = [] 198 | test = [] 199 | for commit in commits: 200 | if commit.type == FIX_TYPE: 201 | fix.append(commit) 202 | elif commit.type == FEAT_TYPE: 203 | feat.append(commit) 204 | elif commit.type == REF_TYPE: 205 | ref.append(commit) 206 | elif commit.type == TEST_TYPE: 207 | test.append(commit) 208 | 209 | if self.include_fix and fix: 210 | self.fd.write("* **Fix(es):**\r\n\r\n") 211 | for commit in fix: 212 | self.fd.write(" * {} \r\n\r\n".format(commit.short_description)) 213 | if self.fix_include_long_description and commit.long_description: 214 | self.fd.write(" >{} \r\n\r\n".format(commit.long_description)) 215 | if self.fix_include_files and commit.files: 216 | files = "" 217 | for file in commit.files: 218 | files += ", {}".format(file) 219 | self.fd.write(" Files changed: {} \r\n\r\n".format(files[2:])) 220 | if self.include_feat and feat: 221 | self.fd.write("* **Feature(s):**\r\n\r\n") 222 | for commit in feat: 223 | self.fd.write(" * {} \r\n\r\n".format(commit.short_description)) 224 | if self.feat_include_long_description and commit.long_description: 225 | self.fd.write(" >{} \r\n\r\n".format(commit.long_description)) 226 | if self.feat_include_files and commit.files: 227 | files = "" 228 | for file in commit.files: 229 | files += ", {}".format(file) 230 | self.fd.write(" Files changed: {} \r\n\r\n".format(files[2:])) 231 | if self.include_ref and ref: 232 | self.fd.write("* **Refactor(s):**\r\n\r\n") 233 | for commit in ref: 234 | self.fd.write(" * {} \r\n\r\n".format(commit.short_description)) 235 | if self.ref_include_long_description and commit.long_description: 236 | self.fd.write(" >{} \r\n\r\n".format(commit.long_description)) 237 | if self.ref_include_files and commit.files: 238 | files = "" 239 | for file in commit.files: 240 | files += ", {}".format(file) 241 | self.fd.write(" Files changed: {} \r\n\r\n".format(files[2:])) 242 | if self.include_test and test: 243 | self.fd.write("* **Test improvment(s):**\r\n\r\n") 244 | for commit in test: 245 | self.fd.write(" * {} \r\n".format(commit.short_description)) 246 | if self.test_include_long_description and commit.long_description: 247 | self.fd.write(" >{} \r\n\r\n".format(commit.long_description)) 248 | if self.test_include_files and commit.files: 249 | files = "" 250 | for file in commit.files: 251 | files += ", {}".format(file) 252 | self.fd.write(" Files changed: {} \r\n\r\n".format(files[2:])) 253 | 254 | 255 | def handle_end_of_commits(self): 256 | self.fd.write("Generated at {}\r\n".format(strftime('%Y-%m-%d %H:%M', localtime()))) 257 | self.fd.close() 258 | 259 | 260 | class CommitChanges: 261 | 262 | def __init__(self): 263 | self.type = self.get_type() 264 | self.short_description = self.get_short_description() 265 | self.files = self.get_files() 266 | self.long_description = self.get_long_description() 267 | 268 | commit = "{}: {}".format(self.type, self.short_description) 269 | if self.files: 270 | commit += "\nFiles: {}".format(self.files) 271 | if self.long_description: 272 | commit += "\n{}".format(self.long_description) 273 | run_command("git commit -m {!r}".format(commit)) 274 | 275 | 276 | def get_type(self): 277 | while True: 278 | text = raw_input("Insert commit type {}: ".format(tuple(COMMIT_TYPES))) 279 | if text in COMMIT_TYPES: 280 | return text 281 | 282 | 283 | def get_short_description(self): 284 | while True: 285 | text = raw_input("Insert commit main description (between 10-65 characters): ") 286 | if 1 <= len(text) <= 65: 287 | return text 288 | 289 | 290 | def get_files(self): 291 | text = raw_input("Insert changed files separated by comma (Enter to skip): ") 292 | if text.strip(): 293 | return text 294 | return 295 | 296 | def get_long_description(self): 297 | text = raw_input("Insert commit long description (Enter to skip): ") 298 | if text.strip(): 299 | return text 300 | return 301 | 302 | 303 | class Commit: 304 | 305 | 306 | def __init__(self, text): 307 | self.text = text 308 | 309 | (self.type, text) = self.parse_type(text) 310 | (self.short_description, text) = self.parse_short_description(text) 311 | (self.files, text) = self.parse_files(text) 312 | self.long_description = self.parse_long_description(text) 313 | 314 | 315 | def parse_type(self, text): 316 | parsed_text = text.split(":", 1) 317 | commit_type = parsed_text[0] 318 | if commit_type not in COMMIT_TYPES: 319 | raise TypeError("Warning: unknown commit type {!r} in {!r}".format(commit_type, self.text)) 320 | return (commit_type.strip(), parsed_text[1]) 321 | 322 | 323 | def parse_short_description(self, text): 324 | parsed_text = text.split("\\n", 1) 325 | if len(parsed_text) == 1: 326 | return (parsed_text[0].strip(), "") 327 | return (parsed_text[0].strip(), parsed_text[1]) 328 | 329 | 330 | def parse_files(self, text): 331 | parsed_text = text.split(":", 1) 332 | if parsed_text[0].lower() != "files": 333 | return ([], text) 334 | 335 | parsed_text = parsed_text[1].split("\\n", 1) 336 | files = [] 337 | for file in parsed_text[0].split(","): 338 | files.append(file.strip()) 339 | 340 | if len(parsed_text) == 1: 341 | return (files, "") 342 | return (files, parsed_text[1]) 343 | 344 | 345 | def parse_long_description(self, text): 346 | return text 347 | 348 | 349 | def run_command(command): 350 | try: 351 | return check_output(command, shell=True) 352 | except Exception as error: 353 | print("\nError: could not run command:\n\t {!r} because of\n\t {}\n".format(command, error)) 354 | exit(1) 355 | 356 | 357 | if __name__ == "__main__": 358 | from optparse import OptionParser 359 | 360 | optp = OptionParser() 361 | optp.add_option("-m" 362 | ,"--message" 363 | ,action="store_const" 364 | ,const=True 365 | ,help="Used to wrap a commit and run 'git commit -m'" 366 | ,dest='m') 367 | 368 | optp.add_option("-c" 369 | ,"--changelog" 370 | ,action="store_const" 371 | ,const=True 372 | ,help="Used to generating changelog" 373 | ,dest='changelog') 374 | optp.add_option("-s" 375 | ,"--since" 376 | ,help="Generate changelog since specified version name" 377 | ,dest='since') 378 | optp.add_option("-n" 379 | ,"--no-unknown-commits" 380 | ,action="store_const" 381 | ,const=False 382 | ,help="Prints warnings and skips unknown commands" 383 | ,dest='no_unknown_commits' 384 | ,default=True) 385 | 386 | optp.add_option("--include-fix" 387 | ,action="store_const" 388 | ,const=True 389 | ,help="When generating changelog, used to include commits with 'fix' type" 390 | ,dest='include_fix' 391 | ,default=True) 392 | optp.add_option("--fix-include-long-desc" 393 | ,action="store_const" 394 | ,const=True 395 | ,help="When generating changelog and --include-fix is used, It also includes long description of commit" 396 | ,dest='fix_include_long_description' 397 | ,default=True) 398 | optp.add_option("--fix-include-files" 399 | ,action="store_const" 400 | ,const=True 401 | ,help="When generating changelog and --include-fix is used, It also includes changed files for commit" 402 | ,dest='fix_include_files' 403 | ,default=True) 404 | 405 | optp.add_option("--include-feat" 406 | ,action="store_const" 407 | ,const=True 408 | ,help="When generating changelog, used to include commits with 'feat' type" 409 | ,dest='include_feat' 410 | ,default=True) 411 | optp.add_option("--feat-include-long-desc" 412 | ,action="store_const" 413 | ,const=True 414 | ,help="When generating changelog and --include-feat is used, It also includes long description of commit" 415 | ,dest='feat_include_long_description' 416 | ,default=True) 417 | optp.add_option("--feat-include-files" 418 | ,action="store_const" 419 | ,const=True 420 | ,help="When generating changelog and --include-feat is used, It also includes changed files for commit" 421 | ,dest='feat_include_files' 422 | ,default=True) 423 | 424 | optp.add_option("--include-ref" 425 | ,action="store_const" 426 | ,const=True 427 | ,help="When generating changelog, used to include commits with 'ref' type" 428 | ,dest='include_ref' 429 | ,default=True) 430 | optp.add_option("--ref-include-long-desc" 431 | ,action="store_const" 432 | ,const=True 433 | ,help="When generating changelog and --include-ref is used, It also includes long description of commit" 434 | ,dest='ref_include_long_description' 435 | ,default=True) 436 | optp.add_option("--ref-include-files" 437 | ,action="store_const" 438 | ,const=True 439 | ,help="When generating changelog and --include-ref is used, It also includes changed files for commit" 440 | ,dest='ref_include_files' 441 | ,default=True) 442 | 443 | optp.add_option("--include-test" 444 | ,action="store_const" 445 | ,const=True 446 | ,help="When generating changelog, used to include commits with 'test' type" 447 | ,dest='include_test' 448 | ,default=True) 449 | optp.add_option("--test-include-long-desc" 450 | ,action="store_const" 451 | ,const=True 452 | ,help="When generating changelog and --include-test is used, It also includes long description of commit" 453 | ,dest='test_include_long_description' 454 | ,default=True) 455 | optp.add_option("--test-include-files" 456 | ,action="store_const" 457 | ,const=True 458 | ,help="When generating changelog and --include-test is used, It also includes changed files for commit" 459 | ,dest='test_include_files' 460 | ,default=True) 461 | 462 | opts, args = optp.parse_args() 463 | 464 | if opts.m: 465 | try: 466 | CommitChanges() 467 | except KeyboardInterrupt: 468 | print() 469 | finally: 470 | exit(0) 471 | 472 | if opts.changelog: 473 | try: 474 | MarkDownChangeLogGenerator(since=opts.since 475 | ,skip_unknown_commits=opts.no_unknown_commits 476 | ,include_fix=opts.include_fix 477 | ,include_feat=opts.include_feat 478 | ,include_ref=opts.include_ref 479 | ,include_test=opts.include_test 480 | ,fix_include_long_description=opts.fix_include_long_description 481 | ,feat_include_long_description=opts.feat_include_long_description 482 | ,ref_include_long_description=opts.ref_include_long_description 483 | ,test_include_long_description=opts.test_include_long_description 484 | ,fix_include_files=opts.fix_include_files 485 | ,feat_include_files=opts.feat_include_files 486 | ,ref_include_files=opts.ref_include_files 487 | ,test_include_files=opts.test_include_files).main() 488 | except KeyboardInterrupt: 489 | print() 490 | finally: 491 | exit(0) 492 | optp.print_help() 493 | --------------------------------------------------------------------------------