├── .credo.exs ├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENCE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── config └── config.exs ├── lib ├── git_ops.ex ├── git_ops │ ├── changelog.ex │ ├── commit.ex │ ├── config.ex │ ├── git.ex │ ├── github.ex │ ├── version.ex │ └── version_replace.ex └── mix │ └── tasks │ ├── git_ops.check_message.ex │ ├── git_ops.install.ex │ ├── git_ops.message_hook.ex │ ├── git_ops.project_info.ex │ └── git_ops.release.ex ├── mix.exs ├── mix.lock ├── priv └── githooks │ └── commit-msg.template └── test ├── changelog_test.exs ├── check_message_test.exs ├── commit_test.exs ├── config_test.exs ├── git_ops_test.exs ├── install_test.exs ├── message_hook_test.exs ├── project_info_test.exs ├── release_test.exs ├── test_helper.exs ├── version_replace_test.exs └── version_test.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "test/", "web/", "apps/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames}, 55 | {Credo.Check.Consistency.LineEndings}, 56 | {Credo.Check.Consistency.ParameterPatternMatching}, 57 | {Credo.Check.Consistency.SpaceAroundOperators}, 58 | {Credo.Check.Consistency.SpaceInParentheses}, 59 | {Credo.Check.Consistency.TabsOrSpaces}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | # For some checks, you can also set other parameters 68 | # 69 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 70 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 71 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 72 | # 73 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 74 | # You can also customize the exit_status of each check. 75 | {Credo.Check.Design.TagTODO, exit_status: 0}, 76 | {Credo.Check.Design.TagFIXME}, 77 | 78 | # 79 | ## Readability Checks 80 | # 81 | {Credo.Check.Readability.FunctionNames}, 82 | {Credo.Check.Readability.LargeNumbers}, 83 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100}, 84 | {Credo.Check.Readability.ModuleAttributeNames}, 85 | {Credo.Check.Readability.ModuleDoc}, 86 | {Credo.Check.Readability.ModuleNames}, 87 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 88 | {Credo.Check.Readability.ParenthesesInCondition}, 89 | {Credo.Check.Readability.PredicateFunctionNames}, 90 | {Credo.Check.Readability.PreferImplicitTry}, 91 | {Credo.Check.Readability.RedundantBlankLines}, 92 | {Credo.Check.Readability.StringSigils}, 93 | {Credo.Check.Readability.TrailingBlankLine}, 94 | {Credo.Check.Readability.TrailingWhiteSpace}, 95 | {Credo.Check.Readability.VariableNames}, 96 | {Credo.Check.Readability.Semicolons}, 97 | {Credo.Check.Readability.SpaceAfterCommas}, 98 | 99 | # 100 | ## Refactoring Opportunities 101 | # 102 | {Credo.Check.Refactor.DoubleBooleanNegation}, 103 | {Credo.Check.Refactor.CondStatements}, 104 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 105 | {Credo.Check.Refactor.FunctionArity}, 106 | {Credo.Check.Refactor.LongQuoteBlocks}, 107 | {Credo.Check.Refactor.MatchInCondition}, 108 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 109 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 110 | {Credo.Check.Refactor.Nesting, false}, 111 | {Credo.Check.Refactor.PipeChainStart, 112 | excluded_argument_types: [:atom, :binary, :fn, :keyword], excluded_functions: []}, 113 | {Credo.Check.Refactor.UnlessWithElse}, 114 | 115 | # 116 | ## Warnings 117 | # 118 | {Credo.Check.Warning.BoolOperationOnSameValues}, 119 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, 120 | {Credo.Check.Warning.IExPry}, 121 | {Credo.Check.Warning.IoInspect}, 122 | {Credo.Check.Warning.OperationOnSameValues}, 123 | {Credo.Check.Warning.OperationWithConstantResult}, 124 | {Credo.Check.Warning.UnusedEnumOperation}, 125 | {Credo.Check.Warning.UnusedFileOperation}, 126 | {Credo.Check.Warning.UnusedKeywordOperation}, 127 | {Credo.Check.Warning.UnusedListOperation}, 128 | {Credo.Check.Warning.UnusedPathOperation}, 129 | {Credo.Check.Warning.UnusedRegexOperation}, 130 | {Credo.Check.Warning.UnusedStringOperation}, 131 | {Credo.Check.Warning.UnusedTupleOperation}, 132 | {Credo.Check.Warning.RaiseInsideRescue}, 133 | 134 | # 135 | # Controversial and experimental checks (opt-in, just remove `, false`) 136 | # 137 | {Credo.Check.Refactor.ABCSize, false}, 138 | {Credo.Check.Refactor.AppendSingleItem, false}, 139 | {Credo.Check.Refactor.VariableRebinding, false}, 140 | {Credo.Check.Warning.MapGetUnsafePass, false}, 141 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 142 | 143 | # 144 | # Deprecated checks (these will be deleted after a grace period) 145 | # 146 | {Credo.Check.Readability.Specs, false} 147 | 148 | # 149 | # Custom checks can be created using `mix credo.gen.check`. 150 | # 151 | ] 152 | } 153 | ] 154 | } 155 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | locals_without_parens: [defparsecp: 2] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | git_ops-*.tar 24 | 25 | /.elixir_ls/ 26 | /.elixir-tools/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.1.2 2 | elixir 1.18.4 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.6.0 4 | otp_release: 5 | - 19.0 6 | script: 7 | - mix compile --warnings-as-errors 8 | - mix coveralls.travis 9 | after_script: 10 | - mix deps.get 11 | - mix inch.report 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 5 | 6 | 7 | 8 | ## [v2.8.0](https://github.com/zachdaniel/git_ops/compare/v2.7.3...v2.8.0) (2025-06-05) 9 | 10 | 11 | 12 | 13 | ### Features: 14 | 15 | * Store commit authors in changelog (#74) by Abhishek Tripathi 16 | 17 | * lookup user by email on github to find correct @ names when enabled by Abhishek Tripathi 18 | 19 | ### Improvements: 20 | 21 | * set new github handle lookup option in installers by [@zachdaniel](https://github.com/zachdaniel) 22 | 23 | ## [v2.7.3](https://github.com/zachdaniel/git_ops/compare/v2.7.2...v2.7.3) (2025-05-22) 24 | 25 | 26 | 27 | 28 | ### Bug Fixes: 29 | 30 | * use config.exs and don't patch existing @version (#75) 31 | 32 | ## [v2.7.2](https://github.com/zachdaniel/git_ops/compare/v2.7.1...v2.7.2) (2025-03-06) 33 | 34 | 35 | 36 | 37 | ### Bug Fixes: 38 | 39 | * typo in igniter installer. (#72) 40 | 41 | ## [v2.7.1](https://github.com/zachdaniel/git_ops/compare/v2.7.0...v2.7.1) (2025-02-24) 42 | 43 | 44 | 45 | 46 | ### Bug Fixes: 47 | 48 | * allow disabling the compilation of igniter code 49 | 50 | ### Improvements: 51 | 52 | * add `runtime: false` while installing 53 | 54 | ## [v2.7.0](https://github.com/zachdaniel/git_ops/compare/v2.6.3...v2.7.0) (2025-02-14) 55 | 56 | 57 | 58 | 59 | ### Features: 60 | 61 | * add igniter installer (#71) 62 | 63 | ### Improvements: 64 | 65 | * add types to project.info (#69) (#70) 66 | 67 | ## [v2.6.3](https://github.com/zachdaniel/git_ops/compare/v2.6.2...v2.6.3) (2024-10-14) 68 | 69 | 70 | 71 | 72 | ### Improvements: 73 | 74 | * Enable `git_ops.check_message` to check the latest commit message. (#68) 75 | 76 | ## [v2.6.1](https://github.com/zachdaniel/git_ops/compare/v2.6.0...v2.6.1) (2024-05-10) 77 | 78 | 79 | 80 | 81 | ### Bug Fixes: 82 | 83 | * Update deprecated `set-output` format for GitHub Actions to use environment variables (#61) 84 | 85 | * Update deprecated `set-output` format for GitHub Actions to use environment variables 86 | 87 | * Write output vars to the temp file stored in `GITHUB_OUTPUT` 88 | 89 | ### Improvements: 90 | 91 | * support override option 92 | 93 | ## [v2.6.0](https://github.com/zachdaniel/git_ops/compare/v2.5.6...v2.6.0) (2023-06-09) 94 | 95 | 96 | 97 | 98 | ### Features: 99 | 100 | * AllowedTags: Adds allowed_tags option (#59) 101 | 102 | * AllowedTags: Add config allow_untagged? to tags 103 | 104 | * enable custom replace/pattern for readme versioning (#56) 105 | 106 | ## [v2.5.6](https://github.com/zachdaniel/git_ops/compare/v2.5.5...v2.5.6) (2023-03-07) 107 | 108 | 109 | 110 | 111 | ### Bug Fixes: 112 | 113 | * remove reference to unknown attribute 114 | 115 | ## [v2.5.5](https://github.com/zachdaniel/git_ops/compare/v2.5.4...v2.5.5) (2023-01-18) 116 | 117 | 118 | 119 | 120 | ### Bug Fixes: 121 | 122 | * properly support multiple readme version files 123 | 124 | * include all comits for version when not in rc 125 | 126 | ## [v2.5.4](https://github.com/zachdaniel/git_ops/compare/v2.5.3...v2.5.4) (2022-12-13) 127 | 128 | 129 | 130 | 131 | ### Bug Fixes: 132 | 133 | * detect rcs properly 134 | 135 | ## [v2.5.3](https://github.com/zachdaniel/git_ops/compare/v2.5.2...v2.5.3) (2022-12-13) 136 | 137 | 138 | 139 | 140 | ### Bug Fixes: 141 | 142 | * when rolling off an RC, just use the rc version 143 | 144 | ## [v2.5.2](https://github.com/zachdaniel/git_ops/compare/v2.5.1...v2.5.2) (2022-12-13) 145 | 146 | 147 | 148 | 149 | ### Improvements: 150 | 151 | * handle rcs ending better 152 | 153 | * handle incrementing rc versions more gracefully 154 | 155 | ## [v2.5.1](https://github.com/zachdaniel/git_ops/compare/v2.5.0...v2.5.1) (2022-10-04) 156 | 157 | 158 | 159 | 160 | ### Improvements: 161 | 162 | * handle incrementing rc versions more gracefully 163 | 164 | ## [v2.5.0](https://github.com/zachdaniel/git_ops/compare/v2.4.5...v2.5.0) (2022-09-28) 165 | 166 | 167 | 168 | 169 | ### Features: 170 | 171 | * configurable git repository root (#53) 172 | 173 | ### Improvements: 174 | 175 | * add tracking multiple "readme" file for versions 176 | 177 | ## [v2.4.5](https://github.com/zachdaniel/git_ops/compare/v2.4.4...v2.4.5) (2021-07-18) 178 | 179 | 180 | 181 | 182 | ### Bug Fixes: 183 | 184 | * more rc fixes 185 | 186 | * track rcs properly 187 | 188 | ## [v2.4.4](https://github.com/zachdaniel/git_ops/compare/v2.4.3...v2.4.4) (2021-06-24) 189 | 190 | 191 | 192 | 193 | ### Bug Fixes: 194 | 195 | * use properly sortable rc numbering 196 | 197 | ## [v2.4.3](https://github.com/zachdaniel/git_ops/compare/v2.4.2...v2.4.3) (2021-06-04) 198 | 199 | 200 | 201 | 202 | ### Bug Fixes: 203 | 204 | * use rc tags for rc releases 205 | 206 | ## [v2.4.2](https://github.com/zachdaniel/git_ops/compare/v2.4.1...v2.4.2) (2021-01-08) 207 | 208 | 209 | 210 | 211 | ### Bug Fixes: 212 | 213 | * replace headings properly 214 | 215 | ### Improvements: 216 | 217 | * clean empty lines in tag message 218 | 219 | ## [v2.4.1](https://github.com/zachdaniel/git_ops/compare/v2.4.0...v2.4.1) (2021-01-08) 220 | 221 | 222 | 223 | 224 | ### Bug Fixes: 225 | 226 | * remove heading hashes 227 | 228 | * escape headers in tag message 229 | 230 | ## [v2.4.0](https://github.com/zachdaniel/git_ops/compare/v2.3.0...v2.4.0) (2021-01-08) 231 | 232 | 233 | 234 | 235 | ### Features: 236 | 237 | * Include changelog notes to release tag message (#44) 238 | 239 | ## [v2.3.0](https://github.com/zachdaniel/git_ops/compare/v2.2.0...v2.3.0) (2020-12-28) 240 | 241 | 242 | 243 | 244 | ### Features: 245 | 246 | * project_info_dotenv_format: Add the `dotenv` format for project info output. (#42) 247 | 248 | ## [v2.2.0](https://github.com/zachdaniel/git_ops/compare/v2.1.0...v2.2.0) (2020-12-15) 249 | 250 | 251 | 252 | 253 | ### Features: 254 | 255 | * project_info_task: Add `git_ops.project_info` task. (#41) 256 | 257 | * yes: Add `--yes` flag to `mix get_ops.release` (#38) 258 | 259 | ## [v2.1.0](https://github.com/zachdaniel/git_ops/compare/v2.0.2...v2.1.0) (2020-11-21) 260 | 261 | 262 | 263 | 264 | ### Features: 265 | 266 | * yes: Add `--yes` flag to `mix get_ops.release` (#38) 267 | 268 | ## [v2.0.2](https://github.com/zachdaniel/git_ops/compare/v2.0.1...v2.0.2) (2020-11-19) 269 | 270 | 271 | 272 | 273 | ### Bug Fixes: 274 | 275 | * messaging, and changelog ranges 276 | 277 | ## [v2.0.1](https://github.com/zachdaniel/git_ops/compare/v2.0.0...v2.0.1) (2020-07-24) 278 | 279 | 280 | 281 | 282 | ### Bug Fixes: 283 | 284 | * messaging, and changelog ranges 285 | 286 | ## [v2.0.0](https://github.com/zachdaniel/git_ops/compare/1.1.3...v2.0.0) (2020-03-25) 287 | ### Breaking Changes: 288 | 289 | * parse multiple messages 290 | 291 | 292 | 293 | ## [v1.1.3](https://github.com/zachdaniel/git_ops/compare/1.1.2...v1.1.3) (2020-03-17) 294 | 295 | 296 | 297 | 298 | ### Bug Fixes: 299 | 300 | * use prefix on initial version 301 | 302 | 303 | 304 | ### Bug Fixes: 305 | 306 | # Skipped for operational reasons 307 | 308 | 309 | ### Bug Fixes: 310 | 311 | * Move version tag parsing logic 312 | 313 | * Fix tag order from git tag function. 314 | 315 | ### Performance Improvements: 316 | 317 | * Save an iteration in the map+join (#29) 318 | 319 | ## [v1.1.0](https://github.com/zachdaniel/git_ops/compare/1.0.0...v1.1.0) (2020-02-06) 320 | 321 | 322 | 323 | 324 | ### Features: 325 | 326 | * commit message validation 327 | 328 | ## [v1.0.0](https://github.com/zachdaniel/git_ops/compare/0.6.4...v1.0.0) (2019-12-4) 329 | ### Breaking Changes: 330 | 331 | * fail on prefixed `!` and support postfixed `!` (#22) 332 | 333 | 334 | 335 | ## [v0.6.4](https://github.com/zachdaniel/git_ops/compare/0.6.3...v0.6.4) (2019-12-4) 336 | 337 | 338 | 339 | 340 | ### Bug Fixes: 341 | 342 | * --initial --dry-run creates Changelog (#18) (#19) 343 | 344 | ## [v0.6.3](https://github.com/zachdaniel/git_ops/compare/0.6.2...v0.6.3) (2019-8-19) 345 | 346 | 347 | 348 | 349 | ### Bug Fixes: 350 | 351 | * explicitly add changelog 352 | 353 | ## [v0.6.2](https://github.com/zachdaniel/git_ops/compare/0.6.1...v0.6.2) (2019-8-19) 354 | 355 | 356 | 357 | 358 | ### Bug Fixes: 359 | 360 | * log less, and accept unicode 361 | 362 | ## [v0.6.1](https://github.com/zachdaniel/git_ops/compare/0.6.0...v0.6.1) (2019-7-12) 363 | 364 | 365 | 366 | 367 | ### Improvements: 368 | 369 | * support additional commit types by default 370 | 371 | ## [v0.6.0](https://github.com/zachdaniel/git_ops/compare/0.5.0...v0.6.0) (2019-1-22) 372 | 373 | 374 | 375 | 376 | ### Features: 377 | 378 | * dry_run release option 379 | 380 | ### Bug Fixes: 381 | 382 | * allow no prefix 383 | 384 | * initial mix project check (#6) 385 | 386 | ## [v0.5.0](https://github.com/zachdaniel/git_ops/compare/0.4.1...v0.5.0) (2018-11-20) 387 | 388 | 389 | 390 | 391 | ### Features: 392 | 393 | * calculate new version from project instead of tags 394 | 395 | ## [v0.4.1](https://github.com/zachdaniel/git_ops/compare/0.4.0...v0.4.1) (2018-11-16) 396 | 397 | 398 | 399 | 400 | ### Bug Fixes: 401 | 402 | * correctly handle already parsed versions 403 | 404 | ## [v0.4.0](https://github.com/zachdaniel/git_ops/compare/0.4.0...v0.4.0) (2018-11-16) 405 | 406 | 407 | 408 | 409 | ### Features: 410 | 411 | * Allow configuring a version prefix 412 | 413 | ### Bug Fixes: 414 | 415 | * messaging and tag prefix 416 | 417 | * prefix is the *first* argument to `parse!/2` 418 | 419 | * fix error when version struct not expected 420 | 421 | * resolve issue with comparing invalid version 422 | 423 | ## [0.3.4](https://github.com/zachdaniel/git_ops/compare/0.3.3...0.3.4) (2018-10-15) 424 | 425 | 426 | 427 | ### Bug Fixes: 428 | 429 | * Fail better when mix_project is not set 430 | 431 | ## [0.3.3](https://github.com/zachdaniel/git_ops/compare/0.3.2...0.3.3) (2018-10-11) 432 | 433 | 434 | 435 | 436 | ### Bug Fixes: 437 | 438 | * don't fail on unparseable commit during init 439 | 440 | ## [0.3.2](https://github.com/zachdaniel/git_ops/compare/0.3.2...0.3.2) (2018-10-11) 441 | 442 | 443 | 444 | 445 | ### Bug Fixes: 446 | 447 | * depend on correct version of nimble_parsec 448 | 449 | ## [0.3.2](https://github.com/zachdaniel/git_ops/compare/0.3.1...0.3.2) (2018-10-11) 450 | 451 | 452 | 453 | 454 | ### Bug Fixes: 455 | 456 | * depend on correct version of nimble_parsec 457 | 458 | ## [0.3.1](https://github.com/zachdaniel/git_ops/compare/0.3.0...0.3.1) (2018-10-5) 459 | 460 | 461 | 462 | 463 | ### Bug Fixes: 464 | 465 | * use annotated tags 466 | 467 | ## [0.3.0](https://github.com/zachdaniel/git_ops/compare/0.2.3...0.3.0) (2018-10-5) 468 | 469 | 470 | 471 | 472 | ### Features: 473 | 474 | * Support elixir 1.6 475 | 476 | ## [0.2.3](https://github.com/zachdaniel/git_ops/compare/0.2.2...0.2.3) (2018-10-5) 477 | 478 | 479 | 480 | 481 | ### Bug Fixes: 482 | 483 | * remove branch from changelog 484 | 485 | ## [0.2.2](https://github.com/zachdaniel/git_ops/compare/master@0.2.1...master@0.2.2) (2018-10-5) 486 | 487 | 488 | 489 | 490 | ### Bug Fixes: 491 | 492 | * inform of a safer tag push 493 | 494 | ## [0.2.1](https://github.com/zachdaniel/git_ops/compare/master@0.2.0...master@0.2.1) (2018-10-5) 495 | 496 | 497 | 498 | 499 | ### Bug Fixes: 500 | 501 | * Explain git tag pushing 502 | 503 | ## [0.2.0](https://github.com/zachdaniel/git_ops/compare/master@0.1.1...master@0.2.0) (2018-10-5) 504 | ### Breaking Changes: 505 | 506 | * Commit and tag, instead of tag and commit 507 | 508 | 509 | 510 | ## [0.1.1](https://github.com/zachdaniel/git_ops/compare/master@0.1.1-rc0...master@0.1.1) (2018-10-5) 511 | 512 | 513 | 514 | 515 | ### Bug Fixes: 516 | 517 | * Changelog: Spacing between beginning and body 518 | 519 | * Split version and changelog commits 520 | 521 | ## [0.1.1-rc0](https://github.com/zachdaniel/git_ops/compare/master@0.1.0...master@0.1.1-rc0) (2018-10-5) 522 | 523 | 524 | 525 | 526 | ### Bug Fixes: 527 | 528 | * Split version and changelog commits 529 | 530 | ## [0.1.0](https://github.com/zachdaniel/git_ops/compare/master@0.1.0...master@0.1.0) (2018-10-5) 531 | ### Breaking Changes: 532 | 533 | * Parser: finalize initial parser 534 | 535 | * Parser: Adding a basic commit parser 536 | 537 | BREAKING CHANGE: This header serves as an example. 538 | 539 | This footer serves as another example. 540 | 541 | 542 | 543 | ### Features: 544 | 545 | * Version: rc and pre_release functionality 546 | 547 | * Manage readme + mix.exs version numbers 548 | 549 | * Changelog: Add version incrementing 550 | 551 | * changelog: initial changelog writing 552 | 553 | * CLI: Add basic release command (non-functional as of yet) 554 | 555 | ### Bug Fixes: 556 | 557 | * Version: Get version from mix on init 558 | 559 | * allow --initial again 560 | 561 | * newline headings 562 | 563 | * spacing in changelog 564 | 565 | * Changelog: Correctly tag new versions 566 | 567 | * Changelog: Don't show ! in the changelog 568 | 569 | * Changelog: semicolons in scopeless commits 570 | 571 | * Changelog: Fix changelog formatting 572 | 573 | * parser: recognize exlamation points 574 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Zachary Scott Daniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Contributor checklist 2 | - [ ] My commit messages follow the [Conventional Commit Message Format](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) 3 | For example: `fix: Multiply by appropriate coefficient`, or 4 | `feat(Calculator): Correctly preserve history` 5 | Any explanation or long form information in your commit message should be 6 | in a separate paragraph, separated by a blank line from the primary message 7 | - [ ] Bug fixes include regression tests 8 | - [ ] Features include unit/acceptance tests 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitOps 2 | 3 | [![Hex pm](http://img.shields.io/hexpm/v/git_ops.svg?style=flat)](https://hex.pm/packages/git_ops) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/git_ops/) 5 | [![Total Download](https://img.shields.io/hexpm/dt/git_ops.svg)](https://hex.pm/packages/git_ops) 6 | [![License](https://img.shields.io/hexpm/l/git_ops.svg)](https://github.com/zachdaniel/git_opts/blob/master/LICENSE) 7 | [![Build Status](https://travis-ci.com/zachdaniel/git_ops.svg?branch=master)](https://travis-ci.com/zachdaniel/git_ops) 8 | [![Inline docs](http://inch-ci.org/github/zachdaniel/git_ops.svg?branch=master)](http://inch-ci.org/github/zachdaniel/git_ops) 9 | [![Coverage Status](https://coveralls.io/repos/github/zachdaniel/git_ops/badge.svg?branch=master)](https://coveralls.io/github/zachdaniel/git_ops?branch=master) 10 | 11 | A small tool to help generate changelogs from conventional commit messages. 12 | For more information, see [conventional 13 | commits](https://conventionalcommits.org). 14 | For an example, see this project's [CHANGELOG.md](https://github.com/zachdaniel/git_ops/blob/master/CHANGELOG.md). 15 | 16 | Roadmap (in no particular order): 17 | 18 | - More tests 19 | - Automatically parse issue numbers and github mentions into the correct format, linking the issue 20 | - A task to build a compliant commit 21 | - Validation of commits 22 | - Automatically link to the PR that merged a given commit in the changelog 23 | - A hundred other things I forgot to write down while writing the initial version 24 | 25 | Important addendums: 26 | 27 | A new version of the spec in beta adds a rather useful 28 | convention. Add ! after the type/scope to simply signal it as a breaking 29 | change, instead of adding `BREAKING CHANGE: description` in your commit message. 30 | For example: `fix(Spline Reticulator)!: ` 31 | 32 | The spec doesn't specify behavior around multiple scopes. This library parses 33 | scopes _as a comma separated list_. This allows for easily readable multiple 34 | word lists `feat(Something Special, Something Else Special): message`. Keep in 35 | mind that you are very limited on space in these messages, and if you find 36 | yourself using multiple scopes your commit is probably too big. 37 | 38 | ## Installation with Igniter 39 | 40 | If `Igniter` is not already in your project, add it to your deps: 41 | 42 | ```elixir 43 | def deps do 44 | [ 45 | {:igniter, "~> 0.5", only: [:dev, :test]} 46 | ] 47 | end 48 | ``` 49 | 50 | Then, run the installer: 51 | 52 | ```sh 53 | mix igniter.install git_ops 54 | ``` 55 | 56 | ## Manual Installation 57 | 58 | ```elixir 59 | def deps do 60 | [ 61 | {:git_ops, "~> 2.6.1", only: [:dev]} 62 | ] 63 | end 64 | ``` 65 | 66 | ## Configuration 67 | 68 | ```elixir 69 | config :git_ops, 70 | mix_project: Mix.Project.get!(), 71 | changelog_file: "CHANGELOG.md", 72 | 73 | # if set to true, this uses git user.email to lookup user on github and insert the handle in release notes 74 | # otherwise it uses the author name as provided in the commit 75 | github_handle_lookup?: true, 76 | 77 | repository_url: "https://github.com/my_user/my_repo", 78 | types: [ 79 | # Makes an allowed commit type called `tidbit` that is not 80 | # shown in the changelog 81 | tidbit: [ 82 | hidden?: true 83 | ], 84 | # Makes an allowed commit type called `important` that gets 85 | # a section in the changelog with the header "Important Changes" 86 | important: [ 87 | header: "Important Changes" 88 | ] 89 | ], 90 | tags: [ 91 | # Only add commits to the changelog that has the "backend" tag 92 | allowed: ["backend"], 93 | # Filter out or not commits that don't contain tags 94 | allow_untagged?: true 95 | ], 96 | # Instructs the tool to manage your mix version in your `mix.exs` file 97 | # See below for more information 98 | manage_mix_version?: true, 99 | # Instructs the tool to manage the version in your README.md 100 | # Pass in `true` to use `"README.md"` or a string to customize 101 | manage_readme_version: "README.md", 102 | version_tag_prefix: "v" 103 | ``` 104 | 105 | Getting started: 106 | 107 | ```bash 108 | mix git_ops.release --initial 109 | ``` 110 | 111 | Commit the result of that, using a message like `chore: Initial Release` 112 | 113 | Then when you want to release again, use: 114 | 115 | ```bash 116 | mix git_ops.release 117 | ``` 118 | 119 | For the full documentation of that task, see the task documentation in hex. 120 | 121 | ## Managing your mix version 122 | 123 | To have mix manage your mix version, add `manage_mix_version?: true` to your configuration. 124 | 125 | Then, use a module attribute called `@version` to manage your application's 126 | version. Look at [this project's mix.exs](mix.exs) for an example. 127 | 128 | ## Managing your readme version 129 | 130 | Most project readmes have a line like this that would ideally remain up to date: 131 | 132 | ```elixir 133 | {:git_ops, "~> 2.6.1", only: [:dev]} 134 | ``` 135 | 136 | You can keep that number up to date via `manage_readme_version`, which accepts 137 | `true` for `README.md` or a string pointing to some other path relative to your 138 | project root. 139 | 140 | ## Using this with open source projects 141 | 142 | If you'd like your contributors to use the conventional commit format, you can 143 | use a PULL_REQUEST_TEMPLATE.md like the one in our repo. However, 144 | it is also possible to manage it as the maintainers of a project by altering 145 | either the merge commit or alter the commit when merging/squashing (recommended) 146 | 147 | ## Similar projects 148 | 149 | - https://github.com/glasnoster/eliver 150 | - https://github.com/oo6/mix-bump 151 | - https://github.com/mpanarin/versioce 152 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :git_ops, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:git_ops, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | 30 | config :git_ops, 31 | mix_project: GitOps.MixProject, 32 | changelog_file: "CHANGELOG.md", 33 | repository_url: "https://github.com/zachdaniel/git_ops", 34 | types: [], 35 | github_handle_lookup?: true, 36 | manage_mix_version?: true, 37 | manage_readme_version: true, 38 | version_tag_prefix: "v" 39 | 40 | if Mix.env() == :dev do 41 | config :mix_test_interactive, 42 | clear: true, 43 | task: "interactive_tasks" 44 | end 45 | -------------------------------------------------------------------------------- /lib/git_ops.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps do 2 | @moduledoc false 3 | end 4 | -------------------------------------------------------------------------------- /lib/git_ops/changelog.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Changelog do 2 | @moduledoc """ 3 | Functions for writing commits to the changelog, and initializing it. 4 | """ 5 | 6 | alias GitOps.Commit 7 | alias GitOps.Config 8 | 9 | @spec write(String.t(), [Commit.t()], String.t(), String.t(), Keyword.t()) :: String.t() 10 | def write(path, commits, last_version, current_version, opts \\ []) do 11 | original_file_contents = File.read!(path) 12 | 13 | [head | rest] = String.split(original_file_contents, "") 14 | 15 | config_types = Config.types() 16 | 17 | breaking_changes = Enum.filter(commits, &Commit.breaking?/1) 18 | 19 | breaking_changes_contents = 20 | if Enum.empty?(breaking_changes) do 21 | [] 22 | else 23 | [ 24 | "### Breaking Changes:\n\n", 25 | Enum.map_join(breaking_changes, "\n\n", &Commit.format/1) 26 | ] 27 | end 28 | 29 | contents_to_insert = 30 | commits 31 | |> Enum.reject(&Map.get(&1, :breaking?)) 32 | |> Enum.group_by(fn commit -> 33 | String.downcase(commit.type) 34 | end) 35 | |> Stream.filter(fn {group, _commits} -> 36 | Map.has_key?(config_types, group) && !config_types[group][:hidden?] 37 | end) 38 | |> Enum.map(fn {group, commits} -> 39 | formatted_commits = Enum.map_join(commits, "\n\n", &Commit.format/1) 40 | 41 | ["\n\n### ", config_types[group][:header] || group, ":\n\n", formatted_commits] 42 | end) 43 | 44 | repository_url = Config.repository_url() 45 | 46 | today = Date.utc_today() 47 | date = ["(", Date.to_iso8601(today), ")"] 48 | 49 | version_header = 50 | if repository_url do 51 | trimmed_url = String.trim_trailing(repository_url, "/") 52 | compare_link = compare_link(trimmed_url, last_version, current_version) 53 | 54 | ["## [", current_version, "](", compare_link, ") ", date] 55 | else 56 | ["## ", current_version, " ", date] 57 | end 58 | 59 | new_message = 60 | IO.iodata_to_binary([ 61 | version_header, 62 | "\n", 63 | breaking_changes_contents, 64 | "\n\n", 65 | contents_to_insert 66 | ]) 67 | 68 | new_contents = 69 | IO.iodata_to_binary([ 70 | String.trim(head), 71 | "\n\n\n\n", 72 | new_message, 73 | rest 74 | ]) 75 | 76 | if !opts[:dry_run] do 77 | File.write!(path, new_contents) 78 | end 79 | 80 | String.trim(new_message) 81 | end 82 | 83 | @spec initialize(String.t(), Keyword.t()) :: :ok 84 | def initialize(path, opts \\ []) do 85 | contents = """ 86 | # Change Log 87 | 88 | All notable changes to this project will be documented in this file. 89 | See [Conventional Commits](Https://conventionalcommits.org) for commit guidelines. 90 | 91 | 92 | """ 93 | 94 | if File.exists?(path) do 95 | raise "\nFile already exists: #{path}. Please remove it to initialize." 96 | end 97 | 98 | if !opts[:dry_run] do 99 | File.write!(path, String.trim_leading(contents)) 100 | end 101 | 102 | :ok 103 | end 104 | 105 | defp compare_link(url, last_version, current_version) do 106 | [ 107 | url, 108 | "/compare/", 109 | Config.prefix(), 110 | last_version, 111 | "...", 112 | current_version 113 | ] 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/git_ops/commit.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Commit do 2 | @moduledoc """ 3 | Manages the structure, parsing, and formatting of commits. 4 | 5 | Using `parse/1` you can parse a commit struct out of a commit message 6 | 7 | Using `format/1` you can format a commit struct in the way that the 8 | changelog expects. 9 | """ 10 | import NimbleParsec 11 | 12 | defstruct [ 13 | :type, 14 | :scope, 15 | :message, 16 | :body, 17 | :footer, 18 | :breaking?, 19 | :author_name, 20 | :author_email, 21 | :github_user_data 22 | ] 23 | 24 | @type t :: %__MODULE__{} 25 | 26 | # credo:disable-for-lines:27 Credo.Check.Refactor.PipeChainStart 27 | whitespace = ignore(ascii_string([9, 32], min: 1)) 28 | 29 | # 40/41 are `(` and `)`, but syntax highlighters don't like ?( and ?) 30 | type = 31 | optional(whitespace) 32 | |> optional(whitespace) 33 | |> tag(ascii_string([not: ?:, not: ?!, not: 40, not: 41, not: 10, not: 32], min: 1), :type) 34 | |> optional(whitespace) 35 | 36 | scope = 37 | optional(whitespace) 38 | |> ignore(ascii_char([40])) 39 | |> tag(utf8_string([not: 40, not: 41], min: 1), :scope) 40 | |> ignore(ascii_char([41])) 41 | |> optional(whitespace) 42 | 43 | breaking_change_indicator = tag(ascii_char([?!]), :breaking?) 44 | 45 | message = tag(optional(whitespace), ascii_string([not: ?\n], min: 1), :message) 46 | 47 | commit = 48 | type 49 | |> concat(optional(scope)) 50 | |> concat(optional(breaking_change_indicator)) 51 | |> ignore(ascii_char([?:])) 52 | |> concat(message) 53 | |> concat(optional(whitespace)) 54 | |> concat(optional(ignore(ascii_string([10], min: 1)))) 55 | 56 | body = 57 | [commit, eos()] 58 | |> choice() 59 | |> lookahead_not() 60 | |> utf8_char([]) 61 | |> repeat() 62 | |> reduce({List, :to_string, []}) 63 | |> tag(:body) 64 | 65 | defparsecp( 66 | :commits, 67 | commit 68 | |> concat(body) 69 | |> tag(:commit) 70 | |> repeat(), 71 | inline: true 72 | ) 73 | 74 | def format(commit) do 75 | %{ 76 | scope: scopes, 77 | message: message, 78 | body: body, 79 | footer: footer, 80 | breaking?: breaking?, 81 | author_name: author_name, 82 | author_email: author_email, 83 | github_user_data: github_user_data 84 | } = commit 85 | 86 | scope = Enum.join(scopes || [], ",") 87 | 88 | body_text = 89 | if breaking? && String.starts_with?(body || "", "BREAKING CHANGE:") do 90 | "\n\n" <> body 91 | else 92 | "" 93 | end 94 | 95 | footer_text = 96 | if breaking? && String.starts_with?(body || "", "BREAKING CHANGE:") do 97 | "\n\n" <> footer 98 | end 99 | 100 | scope_text = 101 | if String.trim(scope) != "" do 102 | "#{scope}: " 103 | else 104 | "" 105 | end 106 | 107 | author_text = format_author(author_name, author_email, github_user_data) 108 | 109 | if author_text != "" do 110 | "* #{scope_text}#{message}#{body_text}#{footer_text} by #{author_text}" 111 | else 112 | "* #{scope_text}#{message}#{body_text}#{footer_text}" 113 | end 114 | end 115 | 116 | @doc """ 117 | Formats the author information as a GitHub username. 118 | If a GitHub username is provided, uses that with @ prefix. 119 | If the email is a GitHub noreply email, extracts the username. 120 | Otherwise, just uses the author name. 121 | """ 122 | def format_author(_name, _email, %{username: username, url: url}) 123 | when is_binary(username) and is_binary(url) do 124 | "[@#{username}](#{url})" 125 | end 126 | 127 | def format_author(_name, _email, %{username: username}) when is_binary(username) do 128 | "@#{username}" 129 | end 130 | 131 | def format_author(nil, _, _), do: "" 132 | def format_author(_, nil, _), do: "" 133 | def format_author(name, email, _), do: format_author_fallback(name, email) 134 | 135 | # Fallback to existing logic for handling author information 136 | defp format_author_fallback(name, email) do 137 | cond do 138 | # Match GitHub noreply emails like 12345678+username@users.noreply.github.com 139 | String.match?(email, ~r/\d+\+(.+)@users\.noreply\.github\.com/) -> 140 | captures = 141 | Regex.named_captures(~r/\d+\+(?.+)@users\.noreply\.github\.com/, email) 142 | 143 | "#{captures["username"]}" 144 | 145 | # Match standard GitHub emails like username@users.noreply.github.com 146 | String.match?(email, ~r/(.+)@users\.noreply\.github\.com/) -> 147 | captures = Regex.named_captures(~r/(?.+)@users\.noreply\.github\.com/, email) 148 | "#{captures["username"]}" 149 | 150 | # For other emails, just use the author name 151 | true -> 152 | "#{name}" 153 | end 154 | end 155 | 156 | def parse(text, author_info \\ nil) do 157 | case commits(text) do 158 | {:ok, [], _, _, _, _} -> 159 | :error 160 | 161 | {:ok, results, _remaining, _state, _dunno, _also_dunno} -> 162 | commits = 163 | Enum.map(results, fn {:commit, result} -> 164 | remaining_lines = 165 | result[:body] 166 | |> Enum.map_join("\n", &String.trim/1) 167 | # Remove multiple newlines 168 | |> String.split("\n") 169 | |> Enum.map(&String.trim/1) 170 | |> Enum.reject(&Kernel.==(&1, "")) 171 | 172 | body = Enum.at(remaining_lines, 0) 173 | footer = Enum.at(remaining_lines, 1) 174 | 175 | {author_name, author_email} = author_info || {nil, nil} 176 | 177 | %__MODULE__{ 178 | type: Enum.at(result[:type], 0), 179 | scope: scopes(result[:scope]), 180 | message: Enum.at(result[:message], 0), 181 | body: body, 182 | footer: footer, 183 | breaking?: breaking?(result[:breaking?], body, footer), 184 | author_name: author_name, 185 | author_email: author_email 186 | } 187 | end) 188 | 189 | {:ok, commits} 190 | end 191 | rescue 192 | _ -> 193 | :error 194 | end 195 | 196 | def breaking?(%GitOps.Commit{breaking?: breaking?}), do: breaking? 197 | 198 | def feature?(%GitOps.Commit{type: type}) do 199 | String.downcase(type) == "feat" 200 | end 201 | 202 | def fix?(%GitOps.Commit{type: type}) do 203 | String.downcase(type) == "fix" || String.downcase(type) == "improvement" 204 | end 205 | 206 | defp scopes([value]) when is_bitstring(value), do: String.split(value, ",") 207 | defp scopes(_), do: nil 208 | 209 | defp breaking?(breaking, _, _) when not is_nil(breaking), do: true 210 | defp breaking?(_, "BREAKING CHANGE:" <> _, _), do: true 211 | defp breaking?(_, _, "BREAKING CHANGE:" <> _), do: true 212 | defp breaking?(_, _, _), do: false 213 | end 214 | -------------------------------------------------------------------------------- /lib/git_ops/config.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Config do 2 | @moduledoc """ 3 | Helpers around fetching configurations, including setting defaults. 4 | """ 5 | 6 | @default_types [ 7 | build: [ 8 | hidden?: true 9 | ], 10 | chore: [ 11 | hidden?: true 12 | ], 13 | ci: [ 14 | hidden?: true 15 | ], 16 | docs: [ 17 | hidden?: true 18 | ], 19 | feat: [ 20 | header: "Features", 21 | hidden?: false 22 | ], 23 | fix: [ 24 | header: "Bug Fixes", 25 | hidden?: false 26 | ], 27 | improvement: [ 28 | header: "Improvements", 29 | hidden?: false 30 | ], 31 | perf: [ 32 | header: "Performance Improvements", 33 | hidden?: false 34 | ], 35 | refactor: [ 36 | hidden?: true 37 | ], 38 | style: [ 39 | hidden?: true 40 | ], 41 | test: [ 42 | hidden?: true 43 | ] 44 | ] 45 | 46 | def mix_project_check(opts \\ []) do 47 | if !mix_project().project()[:version] do 48 | raise "mix_project must be configured in order to use git_ops. Please see the configuration in the README.md for an example." 49 | end 50 | 51 | changelog_path = Path.expand(changelog_file()) 52 | 53 | if !(opts[:initial] || File.exists?(changelog_path)) do 54 | raise "\nFile: #{changelog_path} did not exist. Please use the `--initial` command to initialize." 55 | end 56 | end 57 | 58 | def mix_project, do: Application.get_env(:git_ops, :mix_project) 59 | def changelog_file, do: Application.get_env(:git_ops, :changelog_file) || "CHANGELOG.md" 60 | def repository_url, do: Application.get_env(:git_ops, :repository_url) 61 | def repository_path, do: Application.get_env(:git_ops, :repository_path) || File.cwd!() 62 | def manage_mix_version?, do: truthy?(Application.get_env(:git_ops, :manage_mix_version?)) 63 | 64 | @doc """ 65 | Returns whether GitHub handle lookup is enabled for contributors. 66 | When enabled, the system will attempt to find GitHub usernames for commit authors. 67 | When disabled or if lookup fails, it will use the author's name directly. 68 | """ 69 | def github_handle_lookup?, do: truthy?(Application.get_env(:git_ops, :github_handle_lookup?)) 70 | 71 | def manage_readme_version do 72 | case Application.get_env(:git_ops, :manage_readme_version) do 73 | true -> 74 | "README.md" 75 | 76 | nil -> 77 | false 78 | 79 | other -> 80 | other 81 | end 82 | end 83 | 84 | def types do 85 | configured = Application.get_env(:git_ops, :types) || [] 86 | 87 | @default_types 88 | |> Keyword.merge(configured) 89 | |> Enum.into(%{}, fn {key, value} -> 90 | sanitized_key = 91 | key 92 | |> to_string() 93 | |> String.downcase() 94 | 95 | {sanitized_key, value} 96 | end) 97 | end 98 | 99 | def type_keys do 100 | types() 101 | |> Map.keys() 102 | |> Enum.uniq() 103 | |> Enum.sort() 104 | |> Enum.join(" ") 105 | end 106 | 107 | def allowed_tags, do: :git_ops |> Application.get_env(:tags, []) |> Keyword.get(:allowed, :any) 108 | 109 | def allow_untagged?, 110 | do: :git_ops |> Application.get_env(:tags, []) |> Keyword.get(:allow_untagged?, true) 111 | 112 | def prefix, do: Application.get_env(:git_ops, :version_tag_prefix) || "" 113 | 114 | defp truthy?(nil), do: false 115 | defp truthy?(false), do: false 116 | defp truthy?(_), do: true 117 | end 118 | -------------------------------------------------------------------------------- /lib/git_ops/git.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Git do 2 | @moduledoc """ 3 | Helper functions for working with `Git` and fetching the tags/commits we care about. 4 | """ 5 | 6 | @default_githooks_path ".git/hooks" 7 | 8 | @spec init!(String.t()) :: Git.Repository.t() 9 | def init!(repo_path) do 10 | Git.init!(repo_path) 11 | end 12 | 13 | @spec add!(Git.Repository.t(), [String.t()]) :: String.t() 14 | def add!(repo, args) do 15 | Git.add!(repo, args) 16 | end 17 | 18 | @spec commit!(Git.Repository.t(), [String.t()]) :: String.t() 19 | def commit!(repo, args) do 20 | Git.commit!(repo, args) 21 | end 22 | 23 | @spec tag!(Git.Repository.t(), String.t() | [String.t()]) :: String.t() 24 | def tag!(repo, current_version) do 25 | Git.tag!(repo, current_version) 26 | end 27 | 28 | @spec get_initial_commits!(Git.Repository.t()) :: [String.t()] 29 | def get_initial_commits!(repo) do 30 | messages = 31 | repo 32 | |> Git.log!(["--format=%B--gitops--"]) 33 | |> String.split("--gitops--") 34 | |> Enum.map(&String.trim/1) 35 | |> Enum.reject(&Kernel.==(&1, "")) 36 | 37 | ["chore(GitOps): Add changelog using git_ops." | messages] 38 | end 39 | 40 | @spec tags(Git.Repository.t()) :: [String.t()] 41 | def tags(repo) do 42 | tags = 43 | repo 44 | |> Git.rev_list!(["--tags"]) 45 | |> String.split("\n", trim: true) 46 | 47 | semver_tags = 48 | repo 49 | |> Git.describe!(["--always", "--abbrev=0", "--tags"] ++ tags) 50 | |> String.split("\n", trim: true) 51 | 52 | if Enum.empty?(semver_tags) do 53 | raise """ 54 | Could not find an appropriate semver tag in git history. Ensure that you have initialized the project and commited the result. 55 | """ 56 | else 57 | semver_tags 58 | end 59 | end 60 | 61 | @spec commit_messages_since_tag(Git.Repository.t(), String.t()) :: [String.t()] 62 | def commit_messages_since_tag(repo, tag) do 63 | repo 64 | |> Git.log!(["#{tag}..HEAD", "--format=%B--gitops--"]) 65 | |> String.split("--gitops--") 66 | |> Enum.map(&String.trim/1) 67 | |> Enum.reject(&Kernel.==(&1, "")) 68 | end 69 | 70 | @spec commit_authors_since_tag(Git.Repository.t(), String.t()) :: [{String.t(), String.t()}] 71 | def commit_authors_since_tag(repo, tag) do 72 | repo 73 | |> Git.log!(["#{tag}..HEAD", "--format=%an--author--%ae--gitops--"]) 74 | |> String.split("--gitops--") 75 | |> Enum.map(&String.trim/1) 76 | |> Enum.reject(&Kernel.==(&1, "")) 77 | |> Enum.map(fn author_string -> 78 | case String.split(author_string, "--author--") do 79 | [name, email] -> {name, email} 80 | _ -> {nil, nil} 81 | end 82 | end) 83 | end 84 | 85 | @spec get_initial_commit_authors!(Git.Repository.t()) :: [{String.t(), String.t()}] 86 | def get_initial_commit_authors!(repo) do 87 | repo 88 | |> Git.log!(["--format=%an--author--%ae--gitops--"]) 89 | |> String.split("--gitops--") 90 | |> Enum.map(&String.trim/1) 91 | |> Enum.reject(&Kernel.==(&1, "")) 92 | |> Enum.map(fn author_string -> 93 | case String.split(author_string, "--author--") do 94 | [name, email] -> {name, email} 95 | _ -> {nil, nil} 96 | end 97 | end) 98 | end 99 | 100 | @spec hooks_path(Git.Repository.t()) :: String.t() | no_return 101 | def hooks_path(repo) do 102 | case Git.config(repo, ["core.hookspath"]) do 103 | {:error, error} -> 104 | handle_hooks_path_error(error) 105 | 106 | {:ok, path} -> 107 | hookspath = String.trim_trailing(path, "\n") 108 | 109 | if File.dir?(hookspath) do 110 | hookspath 111 | else 112 | raise """ 113 | Could not find the directory configured as git hooks path #{inspect(path)}. Ensure the git core.hookspath is set correctly. 114 | """ 115 | end 116 | end 117 | end 118 | 119 | defp handle_hooks_path_error(error) do 120 | with 1 <- error.code, 121 | true <- File.dir?(@default_githooks_path) do 122 | @default_githooks_path 123 | else 124 | false -> 125 | raise """ 126 | Could not find the default git hooks path #{inspect(@default_githooks_path)}. Is this a git repo? 127 | """ 128 | 129 | _ -> 130 | raise error.message 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/git_ops/github.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.GitHub do 2 | @moduledoc """ 3 | GitHub API integration for looking up user information. 4 | """ 5 | 6 | @doc """ 7 | Batch find GitHub users by their email addresses. 8 | Returns a map of %{email => {:ok, user_info} | {:error, reason}} 9 | """ 10 | def batch_find_users_by_emails(emails) when is_list(emails) do 11 | unique_emails = Enum.uniq(emails) 12 | 13 | unique_emails 14 | |> Task.async_stream(&fetch_user_from_api/1, timeout: 30_000, max_concurrency: 5) 15 | |> Enum.zip(unique_emails) 16 | |> Enum.map(fn {{:ok, result}, email} -> {email, result} end) 17 | |> Map.new() 18 | end 19 | 20 | @doc """ 21 | Find a GitHub user by their email address. 22 | Returns {:ok, user} if found, where user contains :username, :id, and :url. 23 | Returns {:error, reason} if not found or if there's an error. 24 | """ 25 | def fetch_user_from_api(email) do 26 | Application.ensure_all_started(:req) 27 | 28 | if email do 29 | headers = %{ 30 | "accept" => "application/vnd.github.v3+json", 31 | "user-agent" => "Elixir.GitOps", 32 | "X-GitHub-Api-Version" => "2022-11-28" 33 | } 34 | 35 | case Req.get("https://api.github.com/search/users", 36 | headers: headers, 37 | params: [q: "#{email} in:email", per_page: 2] 38 | ) do 39 | {:ok, %Req.Response{status: 200, body: %{"items" => [first_user | _]}}} -> 40 | {:ok, 41 | %{ 42 | username: first_user["login"], 43 | id: first_user["id"], 44 | url: first_user["html_url"] 45 | }} 46 | 47 | {:ok, %Req.Response{status: 200, body: %{"items" => []}}} -> 48 | {:error, "No user found with email #{email}"} 49 | 50 | {:ok, %Req.Response{status: status, body: body}} -> 51 | {:error, "GitHub API request failed with status #{status}: #{inspect(body)}"} 52 | 53 | {:error, reason} -> 54 | {:error, "Error making GitHub API request: #{inspect(reason)}"} 55 | end 56 | end 57 | rescue 58 | error -> 59 | {:error, "Error making GitHub API request: #{inspect(error)}"} 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/git_ops/version.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Version do 2 | @moduledoc """ 3 | Functionality around parsing and comparing versions contained in git tags 4 | """ 5 | 6 | alias GitOps.Commit 7 | 8 | @spec last_valid_non_rc_version([String.t()], String.t()) :: String.t() | nil 9 | def last_valid_non_rc_version(versions, prefix) do 10 | versions 11 | |> Enum.reject(fn tag -> parse(prefix, tag) == :error end) 12 | |> Enum.find(fn version -> 13 | match?({:ok, %{pre: []}}, parse(prefix, version)) 14 | end) 15 | end 16 | 17 | @spec last_valid_version([String.t()], String.t()) :: String.t() | nil 18 | def last_valid_version(versions, prefix) do 19 | versions 20 | |> Enum.reject(fn tag -> parse(prefix, tag) == :error end) 21 | |> Enum.find(fn version -> 22 | match?({:ok, %{}}, parse(prefix, version)) 23 | end) 24 | end 25 | 26 | def determine_new_version(current_version, prefix, commits, last_valid_non_rc_version, opts) do 27 | if opts[:override] do 28 | opts[:override] 29 | else 30 | parsed = parse!(prefix, prefix <> current_version) 31 | 32 | rc? = opts[:rc] 33 | 34 | build = opts[:build] 35 | 36 | last_valid_non_rc_version = 37 | if last_valid_non_rc_version && prefix && prefix != "" do 38 | String.trim_leading(last_valid_non_rc_version, prefix) 39 | else 40 | last_valid_non_rc_version 41 | end 42 | 43 | new_version = 44 | new_version( 45 | commits, 46 | parsed, 47 | rc?, 48 | last_valid_non_rc_version, 49 | opts 50 | ) 51 | 52 | if versions_equal?(new_version, parsed) && build == parsed.build do 53 | raise """ 54 | No changes should result in a new release version. 55 | 56 | Options: 57 | 58 | * If no fixes or features were added, then perhaps you don't need to release. 59 | * If a fix or feature commit was not correctly annotated, you could alter your git 60 | history to fix it and run this command again, or create an empty commit via 61 | `git commit --allow-empty` that contains an appropriate message. 62 | * If you don't care and want a new version, you can use `--force-patch` which 63 | will update the patch version regardless. 64 | * You can add build metadata using `--build` that will signify that something was 65 | unique about this build. 66 | """ 67 | end 68 | 69 | unprefixed = 70 | new_version 71 | |> Map.put(:build, build) 72 | |> to_string() 73 | 74 | prefix <> unprefixed 75 | end 76 | end 77 | 78 | def last_version_greater_than(versions, last_version, prefix) do 79 | Enum.find(versions, fn version -> 80 | case parse(prefix, version) do 81 | {:ok, version} -> 82 | Version.compare(version, parse!(prefix, last_version)) == :gt 83 | 84 | _ -> 85 | false 86 | end 87 | end) 88 | end 89 | 90 | defp new_version(commits, parsed, rc?, last_valid_non_rc_version, opts) do 91 | pre = default_pre_release(rc?, opts[:pre_release]) 92 | 93 | last_valid_non_rc_version = 94 | last_valid_non_rc_version && Version.parse!(last_valid_non_rc_version) 95 | 96 | new_version = 97 | cond do 98 | Enum.any?(commits, &Commit.breaking?/1) && 99 | !(rc? && last_valid_non_rc_version && 100 | last_valid_non_rc_version.major != parsed.major) -> 101 | if opts[:no_major] do 102 | %{parsed | minor: parsed.minor + 1, patch: 0, pre: pre} 103 | else 104 | %{parsed | major: parsed.major + 1, minor: 0, patch: 0, pre: pre} 105 | end 106 | 107 | Enum.any?(commits, &Commit.feature?/1) && 108 | !(rc? && last_valid_non_rc_version && 109 | (last_valid_non_rc_version.major != parsed.major || 110 | last_valid_non_rc_version.minor != parsed.minor)) -> 111 | if match?(["rc" <> _ | _], parsed.pre) && !rc? do 112 | parsed 113 | else 114 | %{parsed | minor: parsed.minor + 1, patch: 0, pre: pre} 115 | end 116 | 117 | Enum.any?(commits, &Commit.fix?/1) || opts[:force_patch] -> 118 | if match?(["rc" <> _], parsed.pre) && rc? do 119 | %{parsed | pre: increment_rc!(parsed.pre)} 120 | else 121 | new_version_patch(parsed, pre, rc?) 122 | end 123 | 124 | true -> 125 | parsed 126 | end 127 | 128 | if match?(["rc" <> _ | _], parsed.pre) && !rc? do 129 | %{new_version | pre: List.wrap(opts[:pre_release])} 130 | else 131 | new_version 132 | end 133 | end 134 | 135 | defp default_pre_release(true, _pre_release), do: ["rc.0"] 136 | defp default_pre_release(_rc?, pre_release), do: List.wrap(pre_release) 137 | 138 | defp new_version_patch(parsed, pre, rc?) do 139 | case {parsed, pre, rc?} do 140 | {parsed, [], _} -> 141 | %{parsed | patch: parsed.patch + 1, pre: []} 142 | 143 | {parsed = %{pre: []}, pre, _} -> 144 | %{parsed | patch: parsed.patch + 1, pre: pre} 145 | 146 | {parsed = %{pre: ["rc." <> _]}, pre, nil} -> 147 | %{parsed | patch: parsed.patch + 1, pre: pre} 148 | 149 | {parsed = %{pre: ["rc" <> _]}, pre, nil} -> 150 | %{parsed | patch: parsed.patch + 1, pre: pre} 151 | 152 | {parsed, _pre, true} -> 153 | %{parsed | pre: increment_rc!(parsed.pre)} 154 | 155 | {parsed, pre, _} -> 156 | %{parsed | pre: pre} 157 | end 158 | end 159 | 160 | defp increment_rc!(nil), do: ["rc", "0"] 161 | defp increment_rc!([]), do: ["rc", "0"] 162 | defp increment_rc!([rc]), do: List.wrap(increment_rc!(rc)) 163 | defp increment_rc!([rc, int]) when is_integer(int), do: [rc, int + 1] 164 | 165 | defp increment_rc!("rc" <> rc) do 166 | case Integer.parse(rc) do 167 | {int, ""} -> 168 | "rc#{int + 1}" 169 | 170 | :error -> 171 | raise "Found an rc version that could not be parsed: rc#{rc}" 172 | end 173 | end 174 | 175 | defp increment_rc!(rc) do 176 | raise "Found an rc version that could not be parsed: #{rc}" 177 | end 178 | 179 | defp versions_equal?(left, right) do 180 | Version.compare(left, right) == :eq 181 | end 182 | 183 | defp parse(_, version = %Version{}), do: {:ok, version} 184 | defp parse("", text), do: Version.parse(text) 185 | 186 | defp parse(prefix, text) do 187 | if String.starts_with?(text, prefix) do 188 | text 189 | |> String.trim_leading(prefix) 190 | |> Version.parse() 191 | else 192 | :error 193 | end 194 | end 195 | 196 | defp parse!(prefix, text) do 197 | case parse(prefix, text) do 198 | {:ok, parsed} -> 199 | parsed 200 | 201 | :error -> 202 | raise ArgumentError, "Expected: #{text} to be parseable as a version, but it was not." 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /lib/git_ops/version_replace.ex: -------------------------------------------------------------------------------- 1 | defmodule GitOps.VersionReplace do 2 | @moduledoc """ 3 | Functions that handle the logic behind replacing the version in related files. 4 | """ 5 | 6 | @spec update_mix_project(module, String.t(), String.t()) :: String.t() | {:error, :bad_replace} 7 | def update_mix_project(mix_project, current_version, new_version, opts \\ []) do 8 | file = mix_project.module_info()[:compile][:source] 9 | 10 | update_file(file, "@version \"#{current_version}\"", "@version \"#{new_version}\"", opts) 11 | end 12 | 13 | @spec update_readme( 14 | String.t() 15 | | {String.t(), fun :: (String.t() -> String.t()), fun :: (String.t() -> String.t())}, 16 | String.t(), 17 | String.t() 18 | ) :: String.t() | {:error, :bad_replace} 19 | def update_readme(readme, current_version, new_version, opts \\ []) 20 | 21 | def update_readme({readme, replace, pattern}, current_version, new_version, opts) 22 | when is_function(replace, 1) and is_function(pattern, 1) do 23 | update_file(readme, replace.(current_version), pattern.(new_version), opts) 24 | end 25 | 26 | def update_readme(readme, current_version, new_version, opts) do 27 | update_file(readme, ", \"~> #{current_version}\"", ", \"~> #{new_version}\"", opts) 28 | end 29 | 30 | defp update_file(file, replace, pattern, opts) do 31 | contents = File.read!(file) 32 | 33 | new_contents = String.replace(contents, replace, pattern) 34 | 35 | if new_contents == contents do 36 | {:error, :bad_replace} 37 | else 38 | if !opts[:dry_run] do 39 | File.write!(file, new_contents) 40 | end 41 | 42 | String.trim(new_contents, contents) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/mix/tasks/git_ops.check_message.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.GitOps.CheckMessage do 2 | use Mix.Task 3 | 4 | @shortdoc "Check if a file's content follows the Conventional Commits spec" 5 | 6 | @moduledoc """ 7 | Validates a commit message against the Conventional Commits specification. 8 | 9 | Check a file containing a commit message using: 10 | 11 | mix git_ops.check_message 12 | 13 | or to check the most recent commit on the current branch: 14 | 15 | mix git_ops.check_message --head 16 | 17 | Logs an error if the commit message is not parse-able. 18 | 19 | See https://www.conventionalcommits.org/en/v1.0.0/ for more details on Conventional Commits. 20 | """ 21 | 22 | alias GitOps.Commit 23 | alias GitOps.Config 24 | 25 | @doc false 26 | def run(["--head"]) do 27 | message = 28 | Config.repository_path() 29 | |> Git.init!() 30 | |> Git.log!(["-1", "--format=%s"]) 31 | 32 | validate(message) 33 | end 34 | 35 | def run([path]) do 36 | # Full paths do not need to be wrapped with repo root 37 | path = 38 | if path == Path.absname(path) do 39 | path 40 | else 41 | Path.join(Config.repository_path(), path) 42 | end 43 | 44 | path 45 | |> File.read!() 46 | |> validate() 47 | end 48 | 49 | def run(_), do: error_exit("Invalid usage. See `mix help git_ops.check_message`") 50 | 51 | @spec error_exit(String.t()) :: no_return 52 | defp error_exit(message), do: raise(Mix.Error, message: message) 53 | 54 | defp validate(message) do 55 | case Commit.parse(message) do 56 | {:ok, _} -> 57 | :ok 58 | 59 | :error -> 60 | types = Config.types() 61 | 62 | not_hidden_types = 63 | types 64 | |> Enum.reject(fn {_type, opts} -> opts[:hidden?] end) 65 | |> Enum.map_join("|", fn {type, _} -> type end) 66 | 67 | hidden_types = 68 | types 69 | |> Enum.filter(fn {_type, opts} -> opts[:hidden?] end) 70 | |> Enum.map_join("|", fn {type, _} -> type end) 71 | 72 | all_types = "#{not_hidden_types}|#{hidden_types}" 73 | 74 | error_exit(""" 75 | Not a valid Conventional Commit message: 76 | #{message} 77 | 78 | The Conventionl Commit message format is: 79 | 80 | [optional scope][optional !]: 81 | 82 | [optional body] 83 | 84 | [optional footer(s)] 85 | 86 | Where: 87 | • is one of #{all_types} 88 | • A bugfix is specified by type `fix` 89 | • A new feature is specified by type `feat` 90 | • A breaking change is specified by either `!` after [optional scope] or by a 91 | `BREAKING CHANGE: ` footer. 92 | 93 | See https://www.conventionalcommits.org/en/v1.0.0/ for more details. 94 | """) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/mix/tasks/git_ops.install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.GitOps.Install.Docs do 2 | @moduledoc false 3 | 4 | def short_doc do 5 | "Installs GitOps into a project." 6 | end 7 | 8 | def example do 9 | "mix igniter.install git_ops" 10 | end 11 | 12 | def long_doc do 13 | """ 14 | #{short_doc()} 15 | 16 | ## Example 17 | 18 | ```bash 19 | #{example()} 20 | ``` 21 | 22 | ## Switches 23 | 24 | - `--no-manage-readme` - Disables mangaging the package version in the README file. 25 | - `--no-manage-mix` - Disables mangaging the package version in the `mix.exs` file. 26 | """ 27 | end 28 | end 29 | 30 | if !Application.compile_env(:git_ops, :no_igniter?) && Code.ensure_loaded?(Igniter) do 31 | defmodule Mix.Tasks.GitOps.Install do 32 | @shortdoc "#{__MODULE__.Docs.short_doc()}" 33 | 34 | @moduledoc __MODULE__.Docs.long_doc() 35 | 36 | use Igniter.Mix.Task 37 | 38 | @impl Igniter.Mix.Task 39 | def info(_argv, _composing_task) do 40 | %Igniter.Mix.Task.Info{ 41 | group: :git_ops, 42 | adds_deps: [], 43 | installs: [], 44 | example: __MODULE__.Docs.example(), 45 | only: [:dev], 46 | dep_opts: [runtime: false], 47 | positional: [], 48 | composes: [], 49 | schema: [ 50 | manage_readme: :boolean, 51 | manage_mix: :boolean 52 | ], 53 | defaults: [manage_readme: true, manage_mix: true], 54 | aliases: [], 55 | required: [] 56 | } 57 | end 58 | 59 | @impl Igniter.Mix.Task 60 | def igniter(igniter) do 61 | opts = igniter.args.options 62 | 63 | manage_mix? = opts[:manage_mix] 64 | manage_readme? = opts[:manage_readme] 65 | 66 | igniter 67 | |> Igniter.Project.Config.configure_new( 68 | "config.exs", 69 | :git_ops, 70 | [:mix_project], 71 | {:code, Sourceror.parse_string!("Mix.Project.get!()")} 72 | ) 73 | |> Igniter.Project.Config.configure_new("config.exs", :git_ops, [:types], 74 | tidbit: [hidden?: true], 75 | important: [header: "Important Changes"] 76 | ) 77 | |> Igniter.Project.Config.configure_new( 78 | "config.exs", 79 | :git_ops, 80 | [:github_handle_lookup?], 81 | true 82 | ) 83 | |> Igniter.Project.Config.configure_new("config.exs", :git_ops, [:version_tag_prefix], "v") 84 | |> then(fn igniter -> 85 | if manage_mix? do 86 | Igniter.Project.MixProject.update(igniter, :project, [:version], fn zipper -> 87 | version_attribute_exists? = 88 | zipper 89 | |> Sourceror.Zipper.top() 90 | |> Igniter.Code.Module.move_to_attribute_definition(:version) == :error 91 | 92 | if version_attribute_exists? do 93 | zipper 94 | |> Igniter.Code.Common.replace_code("@version") 95 | |> Sourceror.Zipper.top() 96 | |> Sourceror.Zipper.move_to_cursor(""" 97 | defmodule __ do 98 | use __ 99 | __cursor__() 100 | end 101 | """) 102 | |> Igniter.Code.Common.add_code("@version \"#{zipper.node}\"", placement: :before) 103 | else 104 | zipper 105 | end 106 | end) 107 | else 108 | igniter 109 | end 110 | end) 111 | |> Igniter.Project.Config.configure( 112 | "config.exs", 113 | :git_ops, 114 | [:manage_mix_version?], 115 | manage_mix? 116 | ) 117 | |> Igniter.Project.Config.configure( 118 | "config.exs", 119 | :git_ops, 120 | [:manage_readme_version], 121 | manage_readme? 122 | ) 123 | |> Igniter.Project.Config.configure_new( 124 | "config.exs", 125 | :git_ops, 126 | [:mix_project], 127 | Sourceror.parse_string!("Mix.Project.get!()") 128 | ) 129 | |> Igniter.add_notice(""" 130 | GitOps has been installed. To create the first release: 131 | 132 | mix git_ops.release --initial 133 | 134 | On subsequent releases, use: 135 | 136 | mix git_ops.release 137 | 138 | """) 139 | end 140 | end 141 | else 142 | defmodule Mix.Tasks.GitOps.Install do 143 | @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" 144 | 145 | @moduledoc __MODULE__.Docs.long_doc() 146 | 147 | use Mix.Task 148 | 149 | def run(_argv) do 150 | Mix.shell().error(""" 151 | The task 'git_ops.install' requires igniter. Please install igniter and try again. 152 | 153 | For more information, see: https://hexdocs.pm/igniter/readme.html#installation 154 | """) 155 | 156 | exit({:shutdown, 1}) 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/mix/tasks/git_ops.message_hook.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.GitOps.MessageHook do 2 | use Mix.Task 3 | 4 | @shortdoc "Enables automatic check if git commit message follows Conventional Commits spec" 5 | 6 | @commit_msg_hook_name "commit-msg" 7 | 8 | @moduledoc """ 9 | Installs a Git #{@commit_msg_hook_name} hook to automatically check if the commit message follows 10 | the Conventional Commits spec: 11 | 12 | mix git_ops.message_hook 13 | 14 | The actual check is done by using the git_ops.check_message mix task: 15 | 16 | mix git_ops.check_message /path/to/commit/message/file 17 | 18 | It does nothing if a Git #{@commit_msg_hook_name} hook already exists that contains the above 19 | message validation task (unless --force was used). 20 | 21 | Logs an error if a Git #{@commit_msg_hook_name} hook exists but it does not call the message 22 | validation task. 23 | 24 | ## Switches: 25 | 26 | * `--force|-f` - Overwrites the Git #{@commit_msg_hook_name} hook if one exists but it does not 27 | call the message validation task. 28 | 29 | * `--uninstall` - Uninstalls the git #{@commit_msg_hook_name} hook if it exists. 30 | 31 | * `--verbose|-v` - Be more verbose. Pass this option twice to be even more verbose. 32 | """ 33 | 34 | alias GitOps.{Config, Git} 35 | 36 | @doc false 37 | def run(args) do 38 | {opts, _other_args, _} = 39 | OptionParser.parse(args, 40 | strict: [ 41 | force: :boolean, 42 | uninstall: :boolean, 43 | verbose: :count, 44 | commit_msg_hook_path_override: :string 45 | ], 46 | aliases: [f: :force, v: :verbose] 47 | ) 48 | 49 | opts = Keyword.merge([verbose: 0], opts) 50 | 51 | if opts[:uninstall] do 52 | uninstall(opts) 53 | else 54 | install(opts) 55 | end 56 | 57 | :ok 58 | end 59 | 60 | defp install(opts) do 61 | {commit_msg_hook_path, commit_msg_hook_exists} = commit_msg_hook_info!(opts) 62 | 63 | template_file_path = template_file_path(opts) 64 | 65 | if commit_msg_hook_exists do 66 | normalized_commit_msg_hook = 67 | normalize_script!( 68 | commit_msg_hook_path, 69 | "Git #{@commit_msg_hook_name} hook script (normalized)", 70 | opts 71 | ) 72 | 73 | normalized_validation_script = 74 | normalize_script!( 75 | template_file_path, 76 | "Conventional Commits message validation script", 77 | opts 78 | ) 79 | 80 | if normalized_commit_msg_hook =~ normalized_validation_script do 81 | if opts[:verbose] >= 1 do 82 | Mix.shell().info(""" 83 | Nothing to do: the #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` already contains the Conventional Commits message validation. 84 | """) 85 | end 86 | else 87 | if opts[:force] do 88 | Mix.shell().info(""" 89 | The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not call the Conventional Commits message validation task. 90 | """) 91 | 92 | if Mix.shell().yes?("Replacing forcefully (the current version will be lost)?") do 93 | install_commit_msg_hook!(template_file_path, commit_msg_hook_path, opts) 94 | end 95 | else 96 | error_exit(""" 97 | The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not call the Conventional Commits message validation task. 98 | Please use --help to check the available options, or manually edit the hook to call the following: 99 | #{normalized_validation_script} 100 | """) 101 | end 102 | end 103 | else 104 | install_commit_msg_hook!(template_file_path, commit_msg_hook_path, opts) 105 | end 106 | 107 | :ok 108 | end 109 | 110 | defp uninstall(opts) do 111 | {commit_msg_hook_path, commit_msg_hook_exists} = commit_msg_hook_info!(opts) 112 | 113 | template_file_path = template_file_path(opts) 114 | 115 | if commit_msg_hook_exists do 116 | normalized_commit_msg_hook = 117 | normalize_script!( 118 | commit_msg_hook_path, 119 | "Git #{@commit_msg_hook_name} hook script (normalized)", 120 | opts 121 | ) 122 | 123 | normalized_validation_script = 124 | normalize_script!( 125 | template_file_path, 126 | "Conventional Commits message validation script", 127 | opts 128 | ) 129 | 130 | if normalized_commit_msg_hook == normalized_validation_script do 131 | uninstall_commit_msg_hook!(commit_msg_hook_path, opts) 132 | else 133 | error_exit(""" 134 | The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` will not be deleted because it 135 | is not identical to the version installed by this tool. Please check it manually. 136 | """) 137 | end 138 | else 139 | error_exit(""" 140 | The #{@commit_msg_hook_name} hook `#{commit_msg_hook_path}` does not exists. Nothing to uninstall. 141 | """) 142 | end 143 | 144 | :ok 145 | end 146 | 147 | defp install_commit_msg_hook!(template_file_path, commit_msg_hook_path, _opts) do 148 | Mix.shell().info(""" 149 | Installing #{@commit_msg_hook_name} hook from #{template_file_path} to #{commit_msg_hook_path}... 150 | """) 151 | 152 | File.cp!(template_file_path, commit_msg_hook_path) 153 | 154 | Mix.shell().info(""" 155 | done. 156 | """) 157 | end 158 | 159 | defp uninstall_commit_msg_hook!(commit_msg_hook_path, _opts) do 160 | Mix.shell().info(""" 161 | Uninstalling #{@commit_msg_hook_name} hook from #{commit_msg_hook_path}... 162 | """) 163 | 164 | File.rm!(commit_msg_hook_path) 165 | 166 | Mix.shell().info(""" 167 | done. 168 | """) 169 | end 170 | 171 | defp commit_msg_hook_info!(opts) do 172 | commit_msg_hook_path_override = opts[:commit_msg_hook_path_override] 173 | 174 | commit_msg_hook_path = 175 | if commit_msg_hook_path_override && is_binary(commit_msg_hook_path_override) do 176 | commit_msg_hook_path_override 177 | else 178 | Config.repository_path() 179 | |> Git.init!() 180 | |> Git.hooks_path() 181 | |> Path.join(@commit_msg_hook_name) 182 | end 183 | 184 | commit_msg_hook_exists = File.exists?(commit_msg_hook_path) 185 | 186 | if opts[:verbose] >= 2 do 187 | Mix.shell().info(""" 188 | Git hooks path: #{commit_msg_hook_path} (#{if commit_msg_hook_exists, do: "existing", else: "not existing"}) 189 | """) 190 | end 191 | 192 | {commit_msg_hook_path, commit_msg_hook_exists} 193 | end 194 | 195 | defp template_file_path(_opts) do 196 | Path.join([:code.priv_dir(:git_ops), "githooks", "#{@commit_msg_hook_name}.template"]) 197 | end 198 | 199 | defp normalize_script!(script_path, desciption, opts) do 200 | # regex to delete 1) lines starting with # and 2) empty lines 201 | normalize_regex = ~r/(*ANYCRLF)(^#.*\R)|(^\s*\R)/m 202 | 203 | normalized_script = Regex.replace(normalize_regex, File.read!(script_path), "") 204 | 205 | if opts[:verbose] >= 2 do 206 | Mix.shell().info(""" 207 | #{desciption}: 208 | #{normalized_script} 209 | """) 210 | end 211 | 212 | normalized_script 213 | end 214 | 215 | @spec error_exit(String.t()) :: no_return 216 | defp error_exit(message), do: raise(Mix.Error, message: message) 217 | end 218 | -------------------------------------------------------------------------------- /lib/mix/tasks/git_ops.project_info.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.GitOps.ProjectInfo do 2 | use Mix.Task 3 | 4 | @shortdoc "Return information about the project." 5 | 6 | @moduledoc """ 7 | A handy helper which prints out the app name, version number and valid message types. 8 | 9 | May be useful in your CI system. 10 | 11 | mix git_ops.project_info 12 | 13 | ## Switches: 14 | 15 | * `--format|-f` selects the output format. Currently suported output formats 16 | are `json`, `toml`, `github-actions`, `shell` and `dotenv`. 17 | """ 18 | 19 | alias GitOps.Config 20 | 21 | @default_opts [format: "toml"] 22 | 23 | @doc false 24 | def run(args) do 25 | opts = 26 | @default_opts 27 | |> parse(args) 28 | 29 | types = Config.type_keys() 30 | 31 | project = 32 | Config.mix_project().project() 33 | |> Keyword.merge(types: types) 34 | 35 | opts 36 | |> Keyword.get(:format) 37 | |> String.downcase() 38 | |> case do 39 | "toml" -> 40 | format_toml(project, opts) 41 | 42 | "json" -> 43 | format_json(project, opts) 44 | 45 | "github-actions" -> 46 | format_github_actions(project, opts) 47 | 48 | "shell" -> 49 | format_shell(project, opts) 50 | 51 | "dotenv" -> 52 | format_dotenv(project, opts) 53 | 54 | format -> 55 | raise "Invalid format `#{inspect(format)}`. Valid formats are `json`, `toml`, `github-actions`, `shell` and `dotenv`." 56 | end 57 | end 58 | 59 | defp parse(defaults, args) do 60 | {opts, _} = 61 | args 62 | |> OptionParser.parse!(strict: [format: :string], aliases: [f: :format]) 63 | 64 | defaults 65 | |> Keyword.merge(opts) 66 | end 67 | 68 | defp format_toml(project, _opts) do 69 | {name, version, types} = extract_info_from_project(project) 70 | 71 | IO.write("[app]\nname = #{name}\nversion = #{version}\ntypes = \"#{types}\"\n") 72 | end 73 | 74 | defp format_json(project, _opts) do 75 | {name, version, types} = extract_info_from_project(project) 76 | 77 | IO.write(~s|{"app":{"name":"#{name}","version":"#{version}","types":"#{types}"}}\n|) 78 | end 79 | 80 | defp format_github_actions(project, _opts) do 81 | {name, version, types} = extract_info_from_project(project) 82 | 83 | System.fetch_env!("GITHUB_OUTPUT") 84 | |> File.write("app_name=#{name}\napp_version=#{version}\napp_types=\"#{types}\"\n", [:append]) 85 | end 86 | 87 | defp format_shell(project, _opts) do 88 | {name, version, types} = extract_info_from_project(project) 89 | 90 | IO.write( 91 | ~s|export APP_NAME="#{name}"\nexport APP_VERSION="#{version}"\nexport APP_TYPES="#{types}"\n| 92 | ) 93 | end 94 | 95 | defp format_dotenv(project, _opts) do 96 | {name, version, types} = extract_info_from_project(project) 97 | 98 | IO.write(~s|APP_NAME="#{name}"\nAPP_VERSION="#{version}"\nAPP_TYPES="#{types}"\n|) 99 | end 100 | 101 | defp extract_info_from_project(project) do 102 | %{app: name, version: version, types: types} = 103 | project 104 | |> Keyword.take(~w[app version types]a) 105 | |> Enum.into(%{}) 106 | 107 | {name, version, types} 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/mix/tasks/git_ops.release.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.GitOps.Release do 2 | use Mix.Task 3 | 4 | @shortdoc "Parses the commit log and writes any updates to the changelog" 5 | 6 | @moduledoc """ 7 | Updates project changelog, and any other configured release capabilities. 8 | 9 | mix git_ops.release 10 | 11 | Logs an error for any commits that were not parseable. 12 | 13 | In the case that the prior version was a pre-release and this one is not, 14 | the version is only updated via removing the pre-release identifier. 15 | 16 | For more information on semantic versioning, including pre release and build identifiers, 17 | see the specification here: https://semver.org/ 18 | 19 | ## Switches: 20 | 21 | * `--initial` - Creates the first changelog, and sets the version to whatever the 22 | configured mix project's version is. 23 | 24 | * `--pre-release` - Sets this release to be a pre release, using the configured 25 | string as the pre release identifier. This is a manual process, and results in 26 | an otherwise unchanged version. (Does not change the minor version). 27 | The version number will only change if a *higher* version number bump is required 28 | than what was originally changed in the creation of the RC. For instance, if patch 29 | was changed when creating the pre-release, and no fixes or features were added when 30 | requesting a new pre-release, then the version will not change. However, if the last 31 | pre-release had only a patch version bump, but a major change has since been added, 32 | the version will be changed accordingly. 33 | 34 | * `--rc` - Overrides the presence of `--pre-release`, and manages an incrementing 35 | identifier as the prerelease. This will look like `1.0.0-rc0` `1.0.0-rc1` and so 36 | forth. See the `--pre-release` flag for information on when the version will change 37 | for a pre-release. In the case that the version must change, the counter for 38 | the release candidate counter will be reset as well. 39 | 40 | * `--build` - Sets the release build metadata. Build information has no semantic 41 | meaning to the version itself, and so is simply attached to the end and is to 42 | be used to describe the build conditions for that release. You might build the 43 | same version many times, and this can be used to denote that in whatever way 44 | you choose. 45 | 46 | * `--force-patch` - In cases where this task is run, but the version should not 47 | change, this option will force the patch number to be incremented. 48 | 49 | * `--no-major` - Forces major version changes to instead only result in minor version 50 | changes. This would be a common option for libraries that are still in 0.x.x phases 51 | where 1.0.0 should only happen at some specified milestones. After that, it is important 52 | to *not* resist a 2.x.x change just because it doesn't seem like it deserves it. 53 | Semantic versioning uses this major version change to communicate, and it should not be 54 | reserved. 55 | 56 | * `--dry-run` - Allow users to run release process and view changes without committing and tagging 57 | 58 | * `--yes` - Don't prompt for confirmation, just perform release. Useful for your CI run. 59 | 60 | * `--override` - Provide an explicit version override 61 | """ 62 | 63 | alias GitOps.Changelog 64 | alias GitOps.Commit 65 | alias GitOps.Config 66 | alias GitOps.Git 67 | alias GitOps.VersionReplace 68 | 69 | @doc false 70 | def run(args) do 71 | opts = get_opts(args) 72 | 73 | Config.mix_project_check(opts) 74 | 75 | mix_project_module = Config.mix_project() 76 | mix_project = mix_project_module.project() 77 | 78 | changelog_file = Config.changelog_file() 79 | changelog_path = Path.expand(changelog_file) 80 | 81 | current_version = String.trim(mix_project[:version]) 82 | 83 | repo_path = Config.repository_path() 84 | repo = Git.init!(repo_path) 85 | 86 | if opts[:initial] do 87 | Changelog.initialize(changelog_path, opts) 88 | end 89 | 90 | tags = Git.tags(repo) 91 | 92 | prefix = Config.prefix() 93 | 94 | config_types = Config.types() 95 | allowed_tags = Config.allowed_tags() 96 | allow_untagged? = Config.allow_untagged?() 97 | from_rc? = Version.parse!(current_version).pre != [] 98 | 99 | {commit_messages_for_version, commit_messages_for_changelog, commit_authors} = 100 | get_commit_messages(repo, prefix, tags, from_rc?, opts) 101 | 102 | # Batch lookup GitHub handles if enabled 103 | github_lookup_map = 104 | if Config.github_handle_lookup?() do 105 | emails = 106 | commit_authors 107 | |> Enum.map(fn {_name, email} -> email end) 108 | |> Enum.reject(&is_nil/1) 109 | |> Enum.uniq() 110 | 111 | GitOps.GitHub.batch_find_users_by_emails(emails) 112 | else 113 | nil 114 | end 115 | 116 | log_for_version? = !opts[:initial] 117 | 118 | commits_for_version = 119 | parse_commits( 120 | commit_messages_for_version, 121 | commit_authors, 122 | config_types, 123 | allowed_tags, 124 | allow_untagged?, 125 | log_for_version? 126 | ) 127 | 128 | commits_for_changelog = 129 | commit_messages_for_changelog 130 | |> parse_commits( 131 | commit_authors, 132 | config_types, 133 | allowed_tags, 134 | allow_untagged?, 135 | false 136 | ) 137 | |> enrich_commits_with_github_usernames(github_lookup_map) 138 | 139 | prefixed_new_version = 140 | if opts[:initial] do 141 | prefix <> mix_project[:version] 142 | else 143 | GitOps.Version.determine_new_version( 144 | current_version, 145 | prefix, 146 | commits_for_version, 147 | GitOps.Version.last_valid_non_rc_version(tags, prefix), 148 | opts 149 | ) 150 | end 151 | 152 | new_version = 153 | if prefix != "" do 154 | String.trim_leading(prefixed_new_version, prefix) 155 | else 156 | prefixed_new_version 157 | end 158 | 159 | changelog_changes = 160 | Changelog.write( 161 | changelog_path, 162 | commits_for_changelog, 163 | current_version, 164 | prefixed_new_version, 165 | opts 166 | ) 167 | 168 | create_and_display_changes(current_version, new_version, changelog_changes, opts) 169 | 170 | cond do 171 | opts[:dry_run] -> 172 | :ok 173 | 174 | opts[:yes] -> 175 | tag(repo, changelog_path, prefixed_new_version, changelog_changes) 176 | :ok 177 | 178 | true -> 179 | confirm_and_tag(repo, changelog_path, prefixed_new_version, changelog_changes) 180 | :ok 181 | end 182 | end 183 | 184 | defp get_commit_messages(repo, prefix, tags, _from_rc?, opts) do 185 | if opts[:initial] do 186 | commits = Git.get_initial_commits!(repo) 187 | authors = Git.get_initial_commit_authors!(repo) 188 | {commits, commits, authors} 189 | else 190 | tag = 191 | if opts[:rc] do 192 | GitOps.Version.last_valid_version(tags, prefix) 193 | else 194 | GitOps.Version.last_valid_non_rc_version(tags, prefix) 195 | end 196 | 197 | commits_for_version = Git.commit_messages_since_tag(repo, tag) 198 | authors = Git.commit_authors_since_tag(repo, tag) 199 | 200 | last_version_after = GitOps.Version.last_version_greater_than(tags, tag, prefix) 201 | 202 | if last_version_after && !opts[:rc] do 203 | commit_messages_for_changelog = Git.commit_messages_since_tag(repo, last_version_after) 204 | changelog_authors = Git.commit_authors_since_tag(repo, last_version_after) 205 | 206 | {commits_for_version, commit_messages_for_changelog, changelog_authors} 207 | else 208 | {commits_for_version, commits_for_version, authors} 209 | end 210 | end 211 | end 212 | 213 | defp create_and_display_changes(current_version, new_version, changelog_changes, opts) do 214 | changelog_file = Config.changelog_file() 215 | mix_project_module = Config.mix_project() 216 | readme = Config.manage_readme_version() 217 | 218 | Mix.shell().info("Your new version is: #{new_version}\n") 219 | 220 | mix_project_changes = 221 | if Config.manage_mix_version?() do 222 | VersionReplace.update_mix_project( 223 | mix_project_module, 224 | current_version, 225 | new_version, 226 | opts 227 | ) 228 | end 229 | 230 | readme_changes = 231 | readme 232 | |> List.wrap() 233 | |> Enum.reject(&(&1 == false)) 234 | |> Enum.map(fn readme -> 235 | {readme, VersionReplace.update_readme(readme, current_version, new_version, opts)} 236 | end) 237 | 238 | if opts[:dry_run] do 239 | "Below are the contents of files that will change.\n" 240 | |> append_changes_to_message(changelog_file, changelog_changes) 241 | |> add_readme_changes(readme_changes) 242 | |> append_changes_to_message(mix_project_module, mix_project_changes) 243 | |> Mix.shell().info() 244 | end 245 | end 246 | 247 | defp add_readme_changes(message, readme_changes) do 248 | Enum.reduce(readme_changes, message, fn {file, changes}, message -> 249 | append_changes_to_message(message, file, changes) 250 | end) 251 | end 252 | 253 | defp tag(repo, changelog_path, new_version, new_message) do 254 | Git.add!(repo, [changelog_path]) 255 | Git.commit!(repo, ["-am", "chore: release version #{new_version}"]) 256 | 257 | new_message = 258 | new_message 259 | |> String.replace(~r/^#+/m, "") 260 | |> String.split("\n") 261 | |> Enum.map(&String.trim/1) 262 | |> Enum.reject(&(&1 == "")) 263 | |> Enum.join("\n") 264 | 265 | Git.tag!(repo, ["-a", new_version, "-m", "release #{new_version}\n\n" <> new_message]) 266 | 267 | Mix.shell().info("Don't forget to push with tags:\n\n git push --follow-tags") 268 | end 269 | 270 | defp confirm_and_tag(repo, changelog_path, new_version, new_message) do 271 | message = """ 272 | Shall we commit and tag? 273 | 274 | Instructions will be printed for committing and tagging if you choose no. 275 | """ 276 | 277 | if Mix.shell().yes?(message) do 278 | tag(repo, changelog_path, new_version, new_message) 279 | else 280 | Mix.shell().info(""" 281 | If you want to do it on your own, make sure you tag the release with: 282 | 283 | If you want to include your release notes in the tag message, use 284 | 285 | git commit -am "chore: release version #{new_version}" 286 | git tag -a #{new_version} 287 | 288 | And replace the contents with your release notes (make sure to escape any # with \#) 289 | 290 | Otherwise, use: 291 | 292 | git commit -am "chore: release version #{new_version}" 293 | git tag -a #{new_version} -m "release #{new_version}" 294 | git push --follow-tags 295 | """) 296 | end 297 | end 298 | 299 | defp parse_commits(messages, authors, config_types, allowed_tags, allow_untagged?, log?) do 300 | messages 301 | |> Enum.zip(authors) 302 | |> Enum.flat_map(fn {message, author} -> 303 | parse_commit(message, author, config_types, allowed_tags, allow_untagged?, log?) 304 | end) 305 | end 306 | 307 | defp parse_commit(text, author, config_types, allowed_tags, allow_untagged?, log?) do 308 | case Commit.parse(text, author) do 309 | {:ok, commits} -> 310 | commits 311 | |> commits_with_allowed_tags(allowed_tags, allow_untagged?) 312 | |> commits_with_type(config_types, text, log?) 313 | 314 | _ -> 315 | error_if_log("Unparseable commit: #{text}", log?) 316 | 317 | [] 318 | end 319 | end 320 | 321 | defp enrich_commits_with_github_usernames(commits, nil), do: commits 322 | 323 | defp enrich_commits_with_github_usernames(commits, github_lookup_map) do 324 | Enum.map(commits, fn commit -> 325 | github_user_data = 326 | case Map.get(github_lookup_map, commit.author_email) do 327 | {:ok, user_data} -> user_data 328 | _ -> nil 329 | end 330 | 331 | Map.put(commit, :github_user_data, github_user_data) 332 | end) 333 | end 334 | 335 | defp commits_with_allowed_tags(commits, :any, _), do: commits 336 | 337 | defp commits_with_allowed_tags(commits, allowed_tags, allow_untagged?) do 338 | case Enum.find(commits, fn %{type: type} -> type == "TAGS" end) do 339 | nil -> 340 | if allow_untagged?, do: commits, else: [] 341 | 342 | commit -> 343 | tags = commit.message |> String.split(",", trim: true) |> Enum.map(&String.trim/1) 344 | 345 | if Enum.any?(tags, fn tag -> tag in allowed_tags end) do 346 | commits 347 | else 348 | [] 349 | end 350 | end 351 | end 352 | 353 | defp commits_with_type(commits, config_types, text, log?) do 354 | Enum.flat_map(commits, fn commit -> 355 | if Map.has_key?(config_types, String.downcase(commit.type)) do 356 | [commit] 357 | else 358 | error_if_log("Commit with unknown type in: #{text}", log?) 359 | 360 | [] 361 | end 362 | end) 363 | end 364 | 365 | defp append_changes_to_message(message, _, {:error, :bad_replace}), do: message 366 | 367 | defp append_changes_to_message(message, file, changes) do 368 | message <> "----- BEGIN #{file} -----\n\n#{changes}\n----- END #{file} -----\n\n" 369 | end 370 | 371 | defp error_if_log(error, _log? = true), do: Mix.shell().error(error) 372 | defp error_if_log(_, _), do: :ok 373 | 374 | defp get_opts(args) do 375 | {opts, _} = 376 | OptionParser.parse!(args, 377 | strict: [ 378 | build: :string, 379 | force_patch: :boolean, 380 | initial: :boolean, 381 | no_major: :boolean, 382 | pre_release: :string, 383 | rc: :boolean, 384 | dry_run: :boolean, 385 | yes: :boolean, 386 | override: :string 387 | ], 388 | aliases: [ 389 | i: :initial, 390 | p: :pre_release, 391 | b: :build, 392 | f: :force_patch, 393 | n: :no_major, 394 | d: :dry_run, 395 | y: :yes, 396 | o: :override 397 | ] 398 | ) 399 | 400 | opts 401 | end 402 | end 403 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/zachdaniel/git_ops" 5 | @version "2.8.0" 6 | 7 | def project do 8 | [ 9 | app: :git_ops, 10 | version: @version, 11 | elixir: "~> 1.6", 12 | description: description(), 13 | package: package(), 14 | start_permanent: Mix.env() == :prod, 15 | name: "Git Ops", 16 | docs: docs(), 17 | source_url: @source_url, 18 | deps: deps(), 19 | dialyzer: [plt_add_apps: [:mix]], 20 | test_coverage: [tool: ExCoveralls], 21 | preferred_cli_env: [ 22 | "coveralls.travis": :test, 23 | coveralls: :test, 24 | "coveralls.detail": :test, 25 | "coveralls.post": :test, 26 | "coveralls.html": :test 27 | ], 28 | aliases: [interactive_tasks: ["test", "credo"]] 29 | ] 30 | end 31 | 32 | defp description do 33 | """ 34 | A tool for managing the version and changelog of a project using conventional commits. 35 | """ 36 | end 37 | 38 | defp package do 39 | [ 40 | name: :git_ops, 41 | maintainers: "Zach Daniel", 42 | licenses: ["MIT"], 43 | links: %{ 44 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", 45 | "GitHub" => @source_url 46 | } 47 | ] 48 | end 49 | 50 | defp docs do 51 | [ 52 | main: "readme", 53 | source_url: @source_url, 54 | source_ref: "v#{@version}", 55 | extras: [ 56 | "README.md" 57 | ] 58 | ] 59 | end 60 | 61 | def application do 62 | [ 63 | extra_applications: [:logger] 64 | ] 65 | end 66 | 67 | defp deps do 68 | [ 69 | {:credo, "~> 1.0", only: [:dev, :test], runtime: false}, 70 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 71 | {:mix_test_interactive, "~> 4.0", only: :dev, runtime: false}, 72 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 73 | {:excoveralls, "~> 0.6", only: :test}, 74 | {:git_cli, "~> 0.2"}, 75 | {:igniter, "~> 0.5 and >= 0.5.27", optional: true}, 76 | {:nimble_parsec, "~> 1.0"}, 77 | {:req, "~> 0.5"} 78 | ] 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 4 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.38.1", "bae0a0bd5b5925b1caef4987e3470902d072d03347114ffe03a55dbe206dd4c2", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "754636236d191b895e1e4de2ebb504c057fe1995fdfdd92e9d75c4b05633008b"}, 9 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 10 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 12 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 13 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 14 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 15 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "igniter": {:hex, :igniter, "0.6.1", "e683495de01cb3cb30943670fd93fc9f603093c380225a4a6dd1f468a393f891", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "81467df9d6e7210b262c41ccbbdb08c48491bd6fd3f2aee529a8ed730c6f84ba"}, 18 | "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, 19 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 20 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 21 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 22 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 23 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 24 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 25 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 26 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 27 | "mix_test_interactive": {:hex, :mix_test_interactive, "4.3.0", "4ac9277d622e3c3331c8a6b47751d1b97898412ff78fd9820549b536116c7449", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:process_tree, "~> 0.1.3 or ~> 0.2.0", [hex: :process_tree, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "20ebd6fed30ef8f0419396570ed992fc07538efde63350dcece1af0e93e69b68"}, 28 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 29 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 30 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 31 | "owl": {:hex, :owl, "0.12.2", "65906b525e5c3ef51bab6cba7687152be017aebe1da077bb719a5ee9f7e60762", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "6398efa9e1fea70a04d24231e10dcd66c1ac1aa2da418d20ef5357ec61de2880"}, 32 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 33 | "process_tree": {:hex, :process_tree, "0.2.1", "4ebcaa96c64a7833467909f49fee28a8e62eed04975613f4c81b4b99424f7e8a", [:mix], [], "hexpm", "68eee6bf0514351aeeda7037f1a6003c0e25de48fe6b7d15a1b0aebb4b35e713"}, 34 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, 35 | "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, 36 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 37 | "spitfire": {:hex, :spitfire, "0.2.0", "0de1f519a23f65bde40d316adad53c07a9563f25cc68915d639d8a509a0aad8a", [:mix], [], "hexpm", "743daaee2d81a0d8095431729f478ce49b47ea8943c7d770de86704975cb7775"}, 38 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 39 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 40 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 41 | "typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"}, 42 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 43 | } 44 | -------------------------------------------------------------------------------- /priv/githooks/commit-msg.template: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # A hook script to check the commit log message. 4 | # Called by "git commit" with one argument, the name of the file 5 | # that has the commit message. The hook should exit with non-zero 6 | # status after issuing an appropriate message if it wants to stop the 7 | # commit. The hook is allowed to edit the commit message file. 8 | 9 | # Call the mix task to parse the commit message file and check if it 10 | # folows the Conventional Commits spec 11 | mix git_ops.check_message "$@" 12 | -------------------------------------------------------------------------------- /test/changelog_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Test.ChangelogTest do 2 | use ExUnit.Case 3 | 4 | alias GitOps.Changelog 5 | 6 | setup context do 7 | changelog = "./TEST_CHANGELOG.md" 8 | 9 | commits = [ 10 | %GitOps.Commit{ 11 | body: nil, 12 | breaking?: false, 13 | footer: nil, 14 | message: "feat: New feature", 15 | scope: nil, 16 | type: "feat" 17 | }, 18 | %GitOps.Commit{ 19 | body: nil, 20 | breaking?: false, 21 | footer: nil, 22 | message: "fix: Fix that new feature", 23 | scope: nil, 24 | type: "fix" 25 | } 26 | ] 27 | 28 | if !context[:no_rm_on_exit] do 29 | on_exit(fn -> File.rm!(changelog) end) 30 | end 31 | 32 | %{changelog: changelog, commits: commits} 33 | end 34 | 35 | test "initialize with existing changelog raises", context do 36 | changelog = context.changelog 37 | 38 | File.write!(changelog, "") 39 | 40 | assert_raise RuntimeError, ~r/File already exists:/, fn -> 41 | Changelog.initialize(changelog) 42 | end 43 | end 44 | 45 | test "initialize creates non-empty changelog file", context do 46 | changelog = context.changelog 47 | 48 | Changelog.initialize(changelog) 49 | 50 | assert File.read!(changelog) != "" 51 | end 52 | 53 | @tag :no_rm_on_exit 54 | test "initializing with dry_run doesen't create the changelog file", context do 55 | changelog = context.changelog 56 | 57 | Changelog.initialize(changelog, dry_run: true) 58 | 59 | assert_raise File.Error, ~r/no such file or directory/, fn -> 60 | assert File.read!(changelog) 61 | end 62 | end 63 | 64 | test "writing commits to changefile works correctly", context do 65 | changelog = context.changelog 66 | 67 | Changelog.initialize(changelog) 68 | 69 | changes = Changelog.write(changelog, context.commits, "0.1.0", "0.2.0") 70 | 71 | assert String.length(changes) > 0 72 | end 73 | 74 | test "writing with dry_run produces changes that aren't written", context do 75 | changelog = context.changelog 76 | 77 | Changelog.initialize(changelog) 78 | 79 | original_contents = File.read!(changelog) 80 | 81 | changes = Changelog.write(changelog, context.commits, "0.1.0", "0.2.0", dry_run: true) 82 | 83 | assert String.length(changes) > 0 84 | 85 | assert File.read!(changelog) == original_contents 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/check_message_test.exs: -------------------------------------------------------------------------------- 1 | # Suppress output of testing mix task 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule GitOps.Mix.Tasks.Test.CheckMessageTest do 5 | use ExUnit.Case, async: false 6 | 7 | alias Mix.Tasks.GitOps.CheckMessage 8 | 9 | test "check message without parameters", _context do 10 | assert_raise Mix.Error, fn -> 11 | CheckMessage.run([]) 12 | end 13 | end 14 | 15 | test "check message with invalid path", _context do 16 | assert_raise File.Error, fn -> 17 | CheckMessage.run(["path/to/nowhere"]) 18 | end 19 | end 20 | 21 | describe "with --head" do 22 | setup do 23 | repo_path = 24 | System.tmp_dir!() 25 | |> Path.join("repo") 26 | 27 | repo = Git.init!(repo_path) 28 | 29 | Application.put_env(:git_ops, :repository_path, repo_path) 30 | 31 | on_exit(fn -> 32 | Application.delete_env(:git_ops, :repository_path) 33 | File.rm_rf!(repo_path) 34 | 35 | :ok 36 | end) 37 | 38 | {:ok, repo: repo} 39 | end 40 | 41 | test "it fails when the repo contains no commits" do 42 | assert_raise(Git.Error, ~r/does not have any commits/, fn -> 43 | CheckMessage.run(["--head"]) 44 | end) 45 | end 46 | 47 | test "it fails when the latest commit does not have a valid message", %{repo: repo} do 48 | Git.commit!(repo, ["-m 'invalid message'", "--allow-empty"]) 49 | 50 | assert_raise(Mix.Error, ~r/Not a valid Conventional Commit message/, fn -> 51 | CheckMessage.run(["--head"]) 52 | end) 53 | end 54 | 55 | test "it succeeds when the latest commit has a valid message", %{repo: repo} do 56 | Git.commit!(repo, ["-m 'chore: counting toes'", "--allow-empty"]) 57 | 58 | assert :ok = CheckMessage.run(["--head"]) 59 | end 60 | end 61 | 62 | describe "with valid path" do 63 | setup do 64 | message_file_name = "test_commit_message" 65 | 66 | on_exit(fn -> delete_temp_file!(message_file_name) end) 67 | 68 | %{message_file_name: message_file_name} 69 | end 70 | 71 | test "check incorrect message", %{message_file_name: message_file_name} do 72 | temp_file_name = 73 | create_temp_file!(message_file_name, """ 74 | fix division by zero 75 | """) 76 | 77 | assert_raise Mix.Error, ~r/Not a valid Conventional Commit message/, fn -> 78 | CheckMessage.run([temp_file_name]) 79 | end 80 | end 81 | 82 | test "mix task return code for incorrect message", %{message_file_name: message_file_name} do 83 | temp_file_name = 84 | create_temp_file!(message_file_name, """ 85 | invalid message 86 | """) 87 | 88 | {_output, exit_status} = 89 | System.cmd("mix", ["git_ops.check_message", temp_file_name], stderr_to_stdout: true) 90 | 91 | assert exit_status > 0 92 | end 93 | 94 | test "check correct message", %{message_file_name: message_file_name} do 95 | temp_file_name = 96 | create_temp_file!(message_file_name, """ 97 | fix: division by zero 98 | """) 99 | 100 | assert :ok == CheckMessage.run([temp_file_name]) 101 | end 102 | end 103 | 104 | defp temp_file_name(name), do: Path.join(System.tmp_dir!(), name) 105 | 106 | defp delete_temp_file!(name) do 107 | tmp_file = temp_file_name(name) 108 | File.rm!(tmp_file) 109 | end 110 | 111 | defp create_temp_file!(name, content) do 112 | tmp_file = temp_file_name(name) 113 | File.write!(tmp_file, content) 114 | tmp_file 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /test/commit_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Test.CommitTest do 2 | use ExUnit.Case 3 | 4 | alias GitOps.Commit 5 | 6 | defp format_one!(message) do 7 | message 8 | |> parse_one!() 9 | |> Commit.format() 10 | end 11 | 12 | defp parse_one!(message) do 13 | {:ok, [commit]} = Commit.parse(message) 14 | 15 | commit 16 | end 17 | 18 | defp parse_many!(message) do 19 | {:ok, commits} = Commit.parse(message) 20 | 21 | commits 22 | end 23 | 24 | describe "format_author/3" do 25 | test "formats GitHub noreply email with ID" do 26 | assert Commit.format_author("John Doe", "12345678+johndoe@users.noreply.github.com", nil) == 27 | "johndoe" 28 | end 29 | 30 | test "formats standard GitHub noreply email" do 31 | assert Commit.format_author("John Doe", "johndoe@users.noreply.github.com", nil) == 32 | "johndoe" 33 | end 34 | 35 | test "formats regular name by removing spaces" do 36 | assert Commit.format_author("John Doe", "john.doe@example.com", nil) == "John Doe" 37 | end 38 | 39 | test "returns empty string for nil values" do 40 | assert Commit.format_author(nil, "email@example.com", nil) == "" 41 | assert Commit.format_author("Name", nil, nil) == "" 42 | assert Commit.format_author(nil, nil, nil) == "" 43 | end 44 | 45 | test "uses GitHub username when provided" do 46 | assert Commit.format_author("John Doe", "john@example.com", %{username: "johndoe"}) == 47 | "@johndoe" 48 | 49 | assert Commit.format_author("John Doe", "john@example.com", %{ 50 | username: "johndoe", 51 | url: "a@b.com" 52 | }) == 53 | "[@johndoe](a@b.com)" 54 | end 55 | 56 | test "falls back when no GitHub username" do 57 | assert Commit.format_author("John Doe", "john@example.com", nil) == "John Doe" 58 | end 59 | end 60 | 61 | test "a simple feature is parsed with the correct type" do 62 | assert parse_one!("feat: An awesome new feature!").type == "feat" 63 | end 64 | 65 | test "a simple feature is parsed with the correct message" do 66 | assert parse_one!("feat: An awesome new feature!").message == "An awesome new feature!" 67 | end 68 | 69 | @tag :regression 70 | test "a breaking change via a prefixed exclamation mark fails to parse" do 71 | assert Commit.parse("!feat: A breaking change") == :error 72 | end 73 | 74 | test "a breaking change via a postfixed exclamation mark is parsed as a breaking change" do 75 | assert parse_one!("feat!: A breaking change").breaking? 76 | end 77 | 78 | test "a breaking change via a postfixed exclamation mark after a scope is parsed as a breaking change" do 79 | assert parse_one!("feat(stuff)!: A breaking change").breaking? 80 | end 81 | 82 | test "a simple feature is formatted correctly" do 83 | assert format_one!("feat: An awesome new feature!") == "* An awesome new feature!" 84 | end 85 | 86 | test "a breaking change does not include the exclamation mark in the formatted version" do 87 | assert format_one!("feat!: An awesome new feature!") == "* An awesome new feature!" 88 | end 89 | 90 | test "multiple messages can be parsed from a commit" do 91 | text = """ 92 | fix: fixed a bug 93 | 94 | some text about it 95 | 96 | some even more data about it 97 | 98 | improvement: improved a thing 99 | 100 | some other text about it 101 | 102 | some even more text about it 103 | """ 104 | 105 | assert [ 106 | %Commit{ 107 | message: "fixed a bug", 108 | body: "some text about it", 109 | footer: "some even more data about it" 110 | }, 111 | %Commit{ 112 | message: "improved a thing", 113 | body: "some other text about it", 114 | footer: "some even more text about it" 115 | } 116 | ] = parse_many!(text) 117 | end 118 | 119 | test "includes author information in formatted commit" do 120 | commit = %Commit{ 121 | type: "feat", 122 | message: "add new feature", 123 | author_name: "John Doe", 124 | author_email: "johndoe@users.noreply.github.com", 125 | github_user_data: nil 126 | } 127 | 128 | assert Commit.format(commit) == "* add new feature by johndoe" 129 | end 130 | 131 | test "includes GitHub username when available" do 132 | commit = %Commit{ 133 | type: "feat", 134 | message: "add new feature", 135 | author_name: "John Doe", 136 | author_email: "john.doe@example.com", 137 | github_user_data: %{ 138 | username: "johndoe" 139 | } 140 | } 141 | 142 | assert Commit.format(commit) == "* add new feature by @johndoe" 143 | end 144 | 145 | test "formats commit without author information when not available" do 146 | commit = %Commit{ 147 | type: "feat", 148 | message: "add new feature" 149 | } 150 | 151 | assert Commit.format(commit) == "* add new feature" 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /test/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Test.ConfigTest do 2 | use ExUnit.Case 3 | 4 | alias GitOps.Config 5 | 6 | setup do 7 | Application.put_env(:git_ops, :mix_project, Project) 8 | Application.put_env(:git_ops, :repository_url, "repo/url.git") 9 | Application.put_env(:git_ops, :manage_mix_version?, false) 10 | Application.put_env(:git_ops, :changelog_file, "CUSTOM_CHANGELOG.md") 11 | Application.put_env(:git_ops, :manage_readme_version, true) 12 | Application.put_env(:git_ops, :types, custom: [header: "Custom"], docs: [hidden?: false]) 13 | Application.put_env(:git_ops, :tags, allowed: ["tag_1", "tag_2"], allow_untagged?: false) 14 | Application.put_env(:git_ops, :version_tag_prefix, "v") 15 | end 16 | 17 | test "mix_project returns correctly" do 18 | assert Config.mix_project() == Project 19 | end 20 | 21 | test "changelog_file custom returns custom file" do 22 | assert Config.changelog_file() == "CUSTOM_CHANGELOG.md" 23 | end 24 | 25 | test "changelog_file nil returns default" do 26 | Application.put_env(:git_ops, :changelog_file, nil) 27 | assert Config.changelog_file() == "CHANGELOG.md" 28 | end 29 | 30 | test "mix_project_check fails on project with no version" do 31 | Application.put_env(:git_ops, :mix_project, InvalidProject) 32 | 33 | assert_raise RuntimeError, ~r/mix_project must be configured/, fn -> 34 | Config.mix_project_check() 35 | end 36 | end 37 | 38 | test "mix_project_check fails on invalid changelog" do 39 | assert_raise RuntimeError, ~r/File: .+ did not exist/, fn -> 40 | Config.mix_project_check() 41 | end 42 | end 43 | 44 | test "mix_project_check succeeds with initial flag but no changelog file" do 45 | Config.mix_project_check(initial: true) 46 | end 47 | 48 | test "mix_project_check succeeds on valid project" do 49 | changelog = "CUSTOM_CHANGELOG.md" 50 | 51 | File.write!(changelog, "") 52 | 53 | try do 54 | Config.mix_project_check(nil) 55 | after 56 | File.rm!(changelog) 57 | end 58 | end 59 | 60 | test "repository_url returns correctly" do 61 | assert Config.repository_url() == "repo/url.git" 62 | end 63 | 64 | test "repository_path returns correctly" do 65 | assert Config.repository_path() == File.cwd!() 66 | end 67 | 68 | test "manage_mix_version? returns correctly" do 69 | assert Config.manage_mix_version?() == false 70 | end 71 | 72 | test "manage_readme_version true results in default README" do 73 | assert Config.manage_readme_version() == "README.md" 74 | end 75 | 76 | test "manage_readme_version custom results in custom file" do 77 | Application.put_env(:git_ops, :manage_readme_version, "CUSTOM_README.md") 78 | 79 | assert Config.manage_readme_version() == "CUSTOM_README.md" 80 | end 81 | 82 | test "manage_readme_version nil results in false" do 83 | Application.put_env(:git_ops, :manage_readme_version, nil) 84 | 85 | assert Config.manage_readme_version() == false 86 | end 87 | 88 | test "custom types configuration merges correctly" do 89 | types = Config.types() 90 | 91 | assert types["docs"][:hidden?] == false 92 | assert types["custom"][:header] == "Custom" 93 | end 94 | 95 | test "Allowed tags configuration returns correcly" do 96 | assert Config.allowed_tags() == ["tag_1", "tag_2"] 97 | end 98 | 99 | test "Allowed tags configuration returns :any if not set" do 100 | Application.delete_env(:git_ops, :tags) 101 | 102 | assert Config.allowed_tags() == :any 103 | end 104 | 105 | test "Allow untagged? configuration returns correcly" do 106 | assert Config.allow_untagged?() == false 107 | end 108 | 109 | test "Allow untagged? configuration returns true if not set" do 110 | Application.delete_env(:git_ops, :tags) 111 | 112 | assert Config.allow_untagged?() == true 113 | end 114 | 115 | test "custom prefixes returns correctly" do 116 | assert Config.prefix() == "v" 117 | end 118 | end 119 | 120 | defmodule Project do 121 | def project, do: [version: "0.1.0"] 122 | end 123 | 124 | defmodule InvalidProject do 125 | def project, do: nil 126 | end 127 | -------------------------------------------------------------------------------- /test/git_ops_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOpsTest do 2 | use ExUnit.Case 3 | end 4 | -------------------------------------------------------------------------------- /test/install_test.exs: -------------------------------------------------------------------------------- 1 | # Suppress output of testing mix task 2 | # Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule GitOps.Mix.Tasks.Test.InstallTest do 5 | use ExUnit.Case, async: true 6 | 7 | import Igniter.Test 8 | 9 | describe "install" do 10 | test "patches configs" do 11 | config = """ 12 | import Config 13 | """ 14 | 15 | files = %{"config/config.exs" => config} 16 | 17 | [app_name: :my_app, files: files] 18 | |> test_project() 19 | |> Igniter.compose_task("git_ops.install", []) 20 | |> assert_has_patch("config/config.exs", """ 21 | + |config :git_ops, 22 | + | mix_project: Mix.Project.get!(), 23 | + | types: [tidbit: [hidden?: true], important: [header: "Important Changes"]], 24 | + | github_handle_lookup?: true, 25 | + | version_tag_prefix: "v", 26 | + | manage_mix_version?: true, 27 | + | manage_readme_version: true 28 | """) 29 | end 30 | 31 | test "opt out of managed files" do 32 | [app_name: :my_app, files: %{}] 33 | |> test_project() 34 | |> Igniter.compose_task("git_ops.install", ["--no-manage-readme", "--no-manage-mix"]) 35 | |> assert_has_patch("config/config.exs", """ 36 | |config :git_ops, 37 | | mix_project: Mix.Project.get!(), 38 | | types: [tidbit: [hidden?: true], important: [header: "Important Changes"]], 39 | | github_handle_lookup?: true, 40 | | version_tag_prefix: "v", 41 | | manage_mix_version?: false, 42 | | manage_readme_version: false 43 | """) 44 | end 45 | 46 | test "patches project version" do 47 | [app_name: :my_app, files: %{}] 48 | |> test_project() 49 | |> Igniter.compose_task("git_ops.install", []) 50 | |> assert_has_patch("mix.exs", """ 51 | 2 2 | use Mix.Project 52 | 3 3 | 53 | 4 + | @version "0.1.0" 54 | 4 5 | def project do 55 | 5 6 | [ 56 | 6 7 | app: :my_app, 57 | 7 - | version: "0.1.0", 58 | 8 + | version: @version, 59 | """) 60 | end 61 | 62 | test "skips project version patch if exists" do 63 | mix = """ 64 | defmodule Elixir.MyApp.MixProject do 65 | use Mix.Project 66 | 67 | @version "0.1.0" 68 | def project do 69 | [ 70 | app: :my_app, 71 | version: @version, 72 | elixir: "~> 1.17", 73 | start_permanent: Mix.env() == :prod, 74 | deps: deps() 75 | ] 76 | end 77 | 78 | # Run "mix help compile.app" to learn about applications. 79 | def application do 80 | [ 81 | extra_applications: [:logger] 82 | ] 83 | end 84 | 85 | # Run "mix help deps" to learn about dependencies. 86 | defp deps do 87 | [ 88 | # {:dep_from_hexpm, "~> 0.3.0"}, 89 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 90 | ] 91 | end 92 | end 93 | """ 94 | 95 | [app_name: :my_app, files: %{"mix.exs" => mix}] 96 | |> test_project() 97 | # |> dbg(structs: false) 98 | |> Igniter.compose_task("git_ops.install", []) 99 | |> assert_unchanged("mix.exs") 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/message_hook_test.exs: -------------------------------------------------------------------------------- 1 | # Read Mix input from current process to test user input 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule GitOps.Mix.Tasks.Test.MessageHookTest do 5 | use ExUnit.Case 6 | 7 | alias Mix.Tasks.GitOps.MessageHook 8 | 9 | setup do 10 | test_hooks_dir = "test_hooks_dir" 11 | 12 | on_exit(fn -> delete_temp_dir!(test_hooks_dir) end) 13 | 14 | %{test_hooks_dir: test_hooks_dir} 15 | end 16 | 17 | test "install hook when missing", %{test_hooks_dir: test_hooks_dir} do 18 | hooks_dir = create_temp_dir!(test_hooks_dir) 19 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 20 | 21 | refute File.exists?(commit_msg_hook_path) 22 | assert :ok == MessageHook.run(["--commit-msg-hook-path-override", commit_msg_hook_path]) 23 | assert File.exists?(commit_msg_hook_path) 24 | assert File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 25 | end 26 | 27 | test "install hook when already installed", %{test_hooks_dir: test_hooks_dir} do 28 | hooks_dir = create_temp_dir!(test_hooks_dir) 29 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 30 | 31 | MessageHook.run(["--commit-msg-hook-path-override", commit_msg_hook_path]) 32 | content = File.read!(commit_msg_hook_path) 33 | 34 | assert :ok == MessageHook.run(["--commit-msg-hook-path-override", commit_msg_hook_path]) 35 | assert content == File.read!(commit_msg_hook_path) 36 | end 37 | 38 | test "install hook when existing and different", %{test_hooks_dir: test_hooks_dir} do 39 | hooks_dir = create_temp_dir!(test_hooks_dir) 40 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 41 | initial_content = "initial content" 42 | 43 | File.write!(commit_msg_hook_path, initial_content) 44 | 45 | assert File.exists?(commit_msg_hook_path) 46 | refute File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 47 | 48 | regex = 49 | ~r/The commit-msg hook `.*` does not call the Conventional Commits message validation task/ 50 | 51 | assert_raise Mix.Error, regex, fn -> 52 | MessageHook.run(["--commit-msg-hook-path-override", commit_msg_hook_path]) 53 | end 54 | 55 | assert File.exists?(commit_msg_hook_path) 56 | assert initial_content == File.read!(commit_msg_hook_path) 57 | end 58 | 59 | test "install hook when existing and different - force mode", %{ 60 | test_hooks_dir: test_hooks_dir 61 | } do 62 | send(self(), {:mix_shell_input, :yes?, true}) 63 | 64 | hooks_dir = create_temp_dir!(test_hooks_dir) 65 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 66 | 67 | File.write!(commit_msg_hook_path, "initial script") 68 | 69 | assert File.exists?(commit_msg_hook_path) 70 | refute File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 71 | 72 | assert :ok == 73 | MessageHook.run([ 74 | "--commit-msg-hook-path-override", 75 | commit_msg_hook_path, 76 | "--force" 77 | ]) 78 | 79 | assert File.exists?(commit_msg_hook_path) 80 | assert File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 81 | end 82 | 83 | test "install hook when existing and different - force mode - user rejects prompt", %{ 84 | test_hooks_dir: test_hooks_dir 85 | } do 86 | send(self(), {:mix_shell_input, :yes?, false}) 87 | 88 | hooks_dir = create_temp_dir!(test_hooks_dir) 89 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 90 | initial_content = "initial content" 91 | 92 | File.write!(commit_msg_hook_path, initial_content) 93 | 94 | assert File.exists?(commit_msg_hook_path) 95 | refute File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 96 | 97 | assert :ok == 98 | MessageHook.run([ 99 | "--commit-msg-hook-path-override", 100 | commit_msg_hook_path, 101 | "--force" 102 | ]) 103 | 104 | assert File.exists?(commit_msg_hook_path) 105 | assert initial_content == File.read!(commit_msg_hook_path) 106 | end 107 | 108 | test "uninstall hook", %{test_hooks_dir: test_hooks_dir} do 109 | hooks_dir = create_temp_dir!(test_hooks_dir) 110 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 111 | 112 | MessageHook.run(["--commit-msg-hook-path-override", commit_msg_hook_path]) 113 | 114 | assert :ok == 115 | MessageHook.run([ 116 | "--commit-msg-hook-path-override", 117 | commit_msg_hook_path, 118 | "--uninstall" 119 | ]) 120 | 121 | refute File.exists?(commit_msg_hook_path) 122 | end 123 | 124 | test "uninstall hook - nonexisting", %{test_hooks_dir: test_hooks_dir} do 125 | hooks_dir = create_temp_dir!(test_hooks_dir) 126 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 127 | 128 | refute File.exists?(commit_msg_hook_path) 129 | 130 | assert_raise Mix.Error, 131 | ~r{The commit-msg hook `.*` does not exists. Nothing to uninstall.}, 132 | fn -> 133 | MessageHook.run([ 134 | "--commit-msg-hook-path-override", 135 | commit_msg_hook_path, 136 | "--uninstall" 137 | ]) 138 | end 139 | end 140 | 141 | test "uninstall hook - different", %{test_hooks_dir: test_hooks_dir} do 142 | hooks_dir = create_temp_dir!(test_hooks_dir) 143 | commit_msg_hook_path = Path.join(hooks_dir, "commit-msg") 144 | initial_content = "initial content" 145 | 146 | File.write!(commit_msg_hook_path, initial_content) 147 | 148 | assert File.exists?(commit_msg_hook_path) 149 | refute File.read!(commit_msg_hook_path) =~ ~S{mix git_ops.check_message "$@"} 150 | 151 | assert_raise Mix.Error, 152 | ~r{The commit-msg hook `.*` will not be deleted}, 153 | fn -> 154 | MessageHook.run([ 155 | "--commit-msg-hook-path-override", 156 | commit_msg_hook_path, 157 | "--uninstall" 158 | ]) 159 | end 160 | 161 | assert initial_content == File.read!(commit_msg_hook_path) 162 | end 163 | 164 | defp temp_dir_name(name), do: Path.join(System.tmp_dir!(), name) 165 | 166 | defp delete_temp_dir!(name) do 167 | dir_name = temp_dir_name(name) 168 | File.rm_rf!(dir_name) 169 | end 170 | 171 | defp create_temp_dir!(name) do 172 | dir_name = temp_dir_name(name) 173 | File.mkdir!(dir_name) 174 | dir_name 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /test/project_info_test.exs: -------------------------------------------------------------------------------- 1 | # Suppress actual of testing mix task 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule GitOps.Mix.Tasks.Test.ProjectInfoTest do 5 | use ExUnit.Case 6 | alias Mix.Tasks.GitOps.ProjectInfo 7 | import ExUnit.CaptureIO 8 | @moduledoc false 9 | 10 | setup _context do 11 | Application.put_env(:git_ops, :mix_project, GitOps.MixProject) 12 | 13 | version = GitOps.MixProject.project()[:version] 14 | 15 | {:ok, name: :git_ops, version: version, types: GitOps.Config.type_keys()} 16 | end 17 | 18 | describe "TOML format" do 19 | test "it is correctly formatted", %{name: name, version: version, types: types} do 20 | actual = run(["--format", "toml"]) 21 | 22 | expected = """ 23 | [app] 24 | name = #{name} 25 | version = #{version} 26 | types = "#{types}" 27 | """ 28 | 29 | assert actual == expected 30 | end 31 | end 32 | 33 | describe "JSON format" do 34 | test "it is correctly formatted", %{name: name, version: version, types: types} do 35 | actual = run(["--format", "json"]) 36 | 37 | expected = """ 38 | { 39 | "app": { 40 | "name": "#{name}", 41 | "version": "#{version}", 42 | "types": "#{types}" 43 | } 44 | } 45 | """ 46 | 47 | # The output is has whitespace removed for brevity 48 | assert "#{String.replace(actual, ~r/\s+/, "")}\n" == 49 | "#{String.replace(expected, ~r/\s+/, "")}\n" 50 | end 51 | end 52 | 53 | describe "Github Actions format" do 54 | test "it correctly formats data to the ENV file", %{ 55 | name: name, 56 | version: version, 57 | types: types 58 | } do 59 | file = "#{System.tmp_dir()}/test_github_actions_format" 60 | System.put_env("GITHUB_OUTPUT", file) 61 | 62 | if File.exists?(file), do: File.rm!(file) 63 | 64 | run(["--format", "github-actions"]) 65 | 66 | actual = File.read!(file) 67 | 68 | expected = """ 69 | app_name=#{name} 70 | app_version=#{version} 71 | app_types="#{types}" 72 | """ 73 | 74 | assert actual == expected 75 | 76 | # on_exit(fn -> File.rm!(file) end) 77 | end 78 | end 79 | 80 | describe "Shell format" do 81 | test "it is correctly formatted", %{name: name, version: version, types: types} do 82 | actual = run(["--format", "shell"]) 83 | 84 | expected = """ 85 | export APP_NAME="#{name}" 86 | export APP_VERSION="#{version}" 87 | export APP_TYPES="#{types}" 88 | """ 89 | 90 | assert actual == expected 91 | end 92 | end 93 | 94 | describe "Dotenv format" do 95 | test "it is correctly formatted", %{name: name, version: version, types: types} do 96 | actual = run(["--format", "dotenv"]) 97 | 98 | expected = """ 99 | APP_NAME="#{name}" 100 | APP_VERSION="#{version}" 101 | APP_TYPES="#{types}" 102 | """ 103 | 104 | assert actual == expected 105 | end 106 | end 107 | 108 | def run(args) do 109 | capture_io(fn -> 110 | ProjectInfo.run(args) 111 | end) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/release_test.exs: -------------------------------------------------------------------------------- 1 | # Suppress output of testing mix task 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule GitOps.Mix.Tasks.Test.ReleaseTest do 5 | use ExUnit.Case 6 | 7 | alias Mix.Tasks.GitOps.Release 8 | 9 | describe "with version_tag_prefix" do 10 | setup do 11 | changelog = "TEST_CHANGELOG.md" 12 | 13 | Application.put_env(:git_ops, :mix_project, GitOps.MixProject) 14 | Application.put_env(:git_ops, :repository_url, "repo/url.git") 15 | Application.put_env(:git_ops, :manage_mix_version?, true) 16 | Application.put_env(:git_ops, :changelog_file, changelog) 17 | Application.put_env(:git_ops, :manage_readme_version, true) 18 | Application.put_env(:git_ops, :types, custom: [header: "Custom"], docs: [hidden?: false]) 19 | Application.put_env(:git_ops, :version_tag_prefix, "v") 20 | 21 | on_exit(fn -> File.rm!(changelog) end) 22 | 23 | %{changelog: changelog} 24 | end 25 | 26 | test "release with dry run works properly", context do 27 | File.write!(context.changelog, "") 28 | 29 | Release.run(["--dry-run", "--force-patch"]) 30 | end 31 | end 32 | 33 | describe "without version_tag_prefix" do 34 | setup do 35 | changelog = "TEST_CHANGELOG.md" 36 | 37 | Application.put_env(:git_ops, :mix_project, GitOps.MixProject) 38 | Application.put_env(:git_ops, :repository_url, "repo/url.git") 39 | Application.put_env(:git_ops, :manage_mix_version?, true) 40 | Application.put_env(:git_ops, :changelog_file, changelog) 41 | Application.put_env(:git_ops, :manage_readme_version, true) 42 | Application.put_env(:git_ops, :types, custom: [header: "Custom"], docs: [hidden?: false]) 43 | 44 | on_exit(fn -> File.rm!(changelog) end) 45 | 46 | %{changelog: changelog} 47 | end 48 | 49 | test "release with dry run works properly", context do 50 | File.write!(context.changelog, "") 51 | 52 | Release.run(["--dry-run", "--force-patch"]) 53 | end 54 | end 55 | 56 | describe "with empty version_tag_prefix" do 57 | setup do 58 | changelog = "TEST_CHANGELOG.md" 59 | 60 | Application.put_env(:git_ops, :mix_project, GitOps.MixProject) 61 | Application.put_env(:git_ops, :repository_url, "repo/url.git") 62 | Application.put_env(:git_ops, :manage_mix_version?, true) 63 | Application.put_env(:git_ops, :changelog_file, changelog) 64 | Application.put_env(:git_ops, :manage_readme_version, true) 65 | Application.put_env(:git_ops, :types, custom: [header: "Custom"], docs: [hidden?: false]) 66 | Application.put_env(:git_ops, :version_tag_prefix, "") 67 | 68 | on_exit(fn -> File.rm!(changelog) end) 69 | 70 | %{changelog: changelog} 71 | end 72 | 73 | test "release with dry run works properly", context do 74 | File.write!(context.changelog, "") 75 | 76 | Release.run(["--dry-run", "--force-patch"]) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/version_replace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Test.VersionReplaceTest do 2 | use ExUnit.Case 3 | 4 | alias GitOps.VersionReplace 5 | 6 | def readme_contents(version) do 7 | """ 8 | ## Installation 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:git_ops, "~> #{version}", only: [:dev]} 14 | ] 15 | end 16 | ``` 17 | ... 18 | 19 | ```elixir 20 | {:git_ops, "~> #{version}", only: [:dev]} 21 | ``` 22 | """ 23 | end 24 | 25 | def package_json_contents(version) do 26 | """ 27 | { 28 | "version": "#{version}" 29 | } 30 | """ 31 | end 32 | 33 | setup do 34 | readme = "TEST_README.md" 35 | version = "0.1.1" 36 | 37 | readme_contents = readme_contents(version) 38 | 39 | File.write!(readme, readme_contents) 40 | 41 | package_json = "package.json" 42 | File.write!(package_json, package_json_contents(version)) 43 | 44 | on_exit(fn -> 45 | File.rm!(readme) 46 | File.rm!(package_json) 47 | end) 48 | 49 | %{readme: readme, package_json: package_json, version: version} 50 | end 51 | 52 | test "that README gets written to properly", context do 53 | readme = context.readme 54 | version = context.version 55 | new_version = "1.0.0" 56 | 57 | VersionReplace.update_readme(readme, version, new_version) 58 | 59 | assert File.read!(readme) == readme_contents(new_version) 60 | end 61 | 62 | test "that README changes are not written with dry_run", context do 63 | readme = context.readme 64 | version = context.version 65 | new_version = "1.0.0" 66 | 67 | VersionReplace.update_readme(readme, version, new_version, dry_run: true) 68 | 69 | assert File.read!(readme) == readme_contents(version) 70 | end 71 | 72 | test "custom replace/pattern", context do 73 | readme = context.package_json 74 | version = context.version 75 | new_version = "1.0.0" 76 | 77 | VersionReplace.update_readme( 78 | {readme, fn v -> "\"version\": \"#{v}\"" end, fn v -> "\"version\": \"#{v}\"" end}, 79 | version, 80 | new_version 81 | ) 82 | 83 | assert File.read!(readme) == package_json_contents(new_version) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/version_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitOps.Test.VersionTest do 2 | use ExUnit.Case 3 | 4 | alias GitOps.Version 5 | 6 | defp new_version(current_version, commits, last_valid_non_rc_version \\ nil, opts \\ []) do 7 | {prefix, opts} = Keyword.pop(opts, :prefix) 8 | 9 | Version.determine_new_version( 10 | current_version, 11 | prefix || "", 12 | commits, 13 | last_valid_non_rc_version, 14 | opts 15 | ) 16 | end 17 | 18 | defp minor do 19 | %GitOps.Commit{ 20 | type: "feat", 21 | message: "feat" 22 | } 23 | end 24 | 25 | defp patch do 26 | %GitOps.Commit{ 27 | type: "fix", 28 | message: "fix" 29 | } 30 | end 31 | 32 | defp chore do 33 | %GitOps.Commit{ 34 | type: "chore", 35 | message: "chore" 36 | } 37 | end 38 | 39 | defp break do 40 | %{minor() | breaking?: true} 41 | end 42 | 43 | test "a new version containing a patch commit increments only the patch" do 44 | assert new_version("0.1.0", [patch()]) == "0.1.1" 45 | end 46 | 47 | test "a new version containing a minor commit increments the minor" do 48 | assert new_version("0.1.0", [minor()]) == "0.2.0" 49 | end 50 | 51 | test "a new version containing a major commit increments the major" do 52 | assert new_version("0.1.0", [break()]) == "1.0.0" 53 | end 54 | 55 | test "a new version containing a minor commit resets the patch" do 56 | assert new_version("0.1.1", [minor()]) == "0.2.0" 57 | end 58 | 59 | test "a new version containing a major commit resets the minor and patch" do 60 | assert new_version("0.1.1", [break()]) == "1.0.0" 61 | end 62 | 63 | test "build metadata can be set with the build option" do 64 | assert new_version("0.1.1", [break()], nil, build: "150") == "1.0.0+150" 65 | end 66 | 67 | test "attempting to release when no commits would yield a new version number is an error" do 68 | assert_raise RuntimeError, ~r/No changes should result in a new release version./, fn -> 69 | new_version("0.1.1", [chore()]) 70 | end 71 | end 72 | 73 | test "if changing the build metadata, a non-version change is not an error" do 74 | new_version("0.1.1+10", [chore()], nil, build: "11") 75 | end 76 | 77 | test "if changing the pre_release, a non-version change is not an error" do 78 | new_version("0.1.1+10", [chore()], nil, pre_release: "alpha") 79 | end 80 | 81 | test "if the force_patch option is present, no error is raised and the version is patched regardless" do 82 | assert new_version("0.1.1", [chore()], nil, force_patch: true) == "0.1.2" 83 | end 84 | 85 | test "the force_patch option is present with a minor update results in minor bump" do 86 | assert new_version("0.1.1", [minor()], nil, force_patch: true) == "0.2.0" 87 | end 88 | 89 | # test "the force_patch option is present on an rc with a minor update results in minor bump" do 90 | # assert new_version("0.1.1-rc1", [minor()], nil, force_patch: true) == "0.2.0" 91 | # end 92 | 93 | test "the force_patch and rc options are present on an rc with a patch results in an rc bump" do 94 | assert new_version("0.1.1-rc1", [patch()], nil, rc: true, force_patch: true) == "0.1.1-rc2" 95 | end 96 | 97 | test "the force_patch option is present on an rc with a patch results in an patch bump" do 98 | assert new_version("0.1.1-rc1", [patch()], nil, force_patch: true) == "0.1.2" 99 | end 100 | 101 | test "if the no_major option is present, a major change only updates the patch" do 102 | assert new_version("0.1.1", [break()], nil, no_major: true) == "0.2.0" 103 | end 104 | 105 | test "if a pre_release is specified, you get the next version tagged with that pre-release with a minor change" do 106 | assert new_version("0.1.1", [minor()], nil, pre_release: "alpha") == "0.2.0-alpha" 107 | end 108 | 109 | test "if a pre_release is specified, you get the next version tagged with that pre-release with a major change" do 110 | assert new_version("0.1.1", [break()], nil, pre_release: "alpha") == "1.0.0-alpha" 111 | end 112 | 113 | test "if a pre_release is performed after a pre_release, and the version would not change then it is unchanged" do 114 | assert new_version("0.1.2-alpha", [patch()], nil, pre_release: "beta") == "0.1.2-beta" 115 | end 116 | 117 | test "if a pre_release is performed after a pre_release, and the version would change, then it is changed" do 118 | assert new_version("0.1.2-alpha", [minor()], nil, pre_release: "beta") == "0.2.0-beta" 119 | end 120 | 121 | test "if a pre_release is performed after using rc, and the version would change, then it is changed with pre-release" do 122 | assert new_version("0.1.2-rc0", [patch()], nil, pre_release: "alpha") == "0.1.3-alpha" 123 | end 124 | 125 | test "a release candidate starts at 0 if requested on patch" do 126 | assert new_version("0.1.0", [patch()], nil, rc: true) == "0.1.1-rc.0" 127 | end 128 | 129 | test "a release candidate starts at 0 if requested on minor" do 130 | assert new_version("0.1.0", [minor()], nil, rc: true) == "0.2.0-rc.0" 131 | end 132 | 133 | test "a release candidate starts at 0 if requested on break" do 134 | assert new_version("0.1.0", [break()], nil, rc: true) == "1.0.0-rc.0" 135 | end 136 | 137 | test "an old style release candidate increments by one on patch" do 138 | assert new_version("0.1.1-rc0", [patch()], nil, rc: true) == "0.1.1-rc1" 139 | end 140 | 141 | test "an old style release candidate increments by one on patch and retains the lack of a dot" do 142 | assert new_version("0.1.1-rc1", [patch()], nil, rc: true) == "0.1.1-rc2" 143 | end 144 | 145 | test "a release candidate increments by one on patch" do 146 | assert new_version("0.1.1-rc.0", [patch()], nil, rc: true) == "0.1.1-rc.1" 147 | end 148 | 149 | test "a release candidate resets on minor" do 150 | assert new_version("0.1.1-rc.0", [minor()], nil, rc: true) == "0.2.0-rc.0" 151 | end 152 | 153 | test "a release candidate resets on major" do 154 | assert new_version("0.1.1-rc.0", [break()], nil, rc: true) == "1.0.0-rc.0" 155 | end 156 | 157 | test "a release candidate raises correctly when it would not change" do 158 | assert_raise RuntimeError, ~r/No changes should result in a new release version./, fn -> 159 | new_version("0.1.1-rc0", [chore()], nil, rc: true) 160 | end 161 | end 162 | 163 | test "invalid rc number version raises" do 164 | assert_raise RuntimeError, ~r/Found an rc version that could not be parsed/, fn -> 165 | new_version("0.1.1-rca", [patch()], nil, rc: true) 166 | end 167 | end 168 | 169 | test "invalid rc label version raises" do 170 | assert_raise RuntimeError, ~r/Found an rc version that could not be parsed/, fn -> 171 | new_version("0.1.1-releasecandidate", [patch()], nil, rc: true) 172 | end 173 | end 174 | 175 | test "no prefix on version raises" do 176 | assert_raise ArgumentError, 177 | ~r/Expected: .+ to be parseable as a version, but it was not/, 178 | fn -> 179 | new_version("v0.1.1", [patch()], nil, rc: true, prefix: "vv") 180 | end 181 | end 182 | 183 | test "last valid non rc is found correctly without prefixes" do 184 | versions = ["0.2.0-alpha", "0.1.0", "0.1.0-rc0", "0.0.1"] 185 | last_rc = Version.last_valid_non_rc_version(versions, "") 186 | 187 | assert last_rc == "0.1.0" 188 | end 189 | 190 | test "last valid non rc is found correctly with prefixes" do 191 | versions = ["0.2.0-alpha", "0.1.0", "v0.1.0-rc0", "v0.0.1"] 192 | last_rc = Version.last_valid_non_rc_version(versions, "v") 193 | 194 | assert last_rc == "v0.0.1" 195 | end 196 | 197 | test "last_version_greater_than is found correctly without prefixes" do 198 | versions = ["0.0.1", "0.1.0-rc0", "0.1.0", "0.1.1", "0.2.0-alpha"] 199 | last_version_after = Version.last_version_greater_than(versions, "0.1.0", "") 200 | 201 | assert last_version_after == "0.1.1" 202 | end 203 | end 204 | --------------------------------------------------------------------------------