|
| 1 | +module mcl.commands.dev_commit; |
| 2 | + |
| 3 | +import std.algorithm : any, cache, canFind, filter, find, map, sort, startsWith, uniq; |
| 4 | +import std.array : appender, array, assocArray, front, join, replace, split; |
| 5 | +import std.conv : to; |
| 6 | +import std.file : dirEntries, exists, readText, SpanMode; |
| 7 | +import std.json : JSONOptions, parseJSON; |
| 8 | +import std.parallelism : parallel, taskPool; |
| 9 | +import std.path : globMatch, stripExtension; |
| 10 | +import std.process : ProcessPipes, wait; |
| 11 | +import std.regex : ctRegex, match, Regex, regex, replaceAll, replaceFirst; |
| 12 | +import std.stdio : writeln; |
| 13 | +import std.string : indexOf, startsWith, strip; |
| 14 | +import std.typecons : tuple; |
| 15 | +import mcl.utils.env : parseEnv, optional; |
| 16 | +import mcl.utils.process : execute; |
| 17 | +import mcl.utils.path : rootDir; |
| 18 | +import mcl.utils.log : prompt; |
| 19 | +import mcl.utils.json : fromJSON; |
| 20 | + |
| 21 | +string[] modifiedFiles = []; |
| 22 | +static const enum CommitType |
| 23 | +{ |
| 24 | + feat, |
| 25 | + fix, |
| 26 | + refactor, |
| 27 | + ci, |
| 28 | + docs, |
| 29 | + style, |
| 30 | + config, |
| 31 | + build, |
| 32 | + chore, |
| 33 | + perf, |
| 34 | + test |
| 35 | +} |
| 36 | + |
| 37 | +struct Config |
| 38 | +{ |
| 39 | + struct Exclude |
| 40 | + { |
| 41 | + string[] startsWith = []; |
| 42 | + string[] contains = [".gitkeep"]; |
| 43 | + string[] equals = [ |
| 44 | + "src", "packages", "pkg", "pkgs", "apps", "libs", "modules", |
| 45 | + "services", ".git" |
| 46 | + ]; |
| 47 | + } |
| 48 | + |
| 49 | + struct Scope |
| 50 | + { |
| 51 | + string[string] replaceAll = [ |
| 52 | + "(src|packages|pkg|pkgs|apps|libs|modules|services)/": "", |
| 53 | + "mcl/mcl/": "mcl/", |
| 54 | + "mcl/commands/": "mcl/", |
| 55 | + "(docs.*/)?(pages/)?docs/": "docs/" |
| 56 | + ]; |
| 57 | + string[string] replaceFirst = [ |
| 58 | + "^docs/": "", |
| 59 | + "^nix/": "", |
| 60 | + "/(default|main|index|start|app|init|__init__|entry|package)$": "" |
| 61 | + ]; |
| 62 | + } |
| 63 | + |
| 64 | + struct Type |
| 65 | + { |
| 66 | + CommitType[string] equals = [ |
| 67 | + ".gitignore": CommitType.config, |
| 68 | + ]; |
| 69 | + CommitType[string] contains; |
| 70 | + CommitType[string] startsWith = [ |
| 71 | + "docs": CommitType.docs, |
| 72 | + ".github/": CommitType.ci, |
| 73 | + ".gitlab/": CommitType.ci, |
| 74 | + |
| 75 | + ]; |
| 76 | + } |
| 77 | + |
| 78 | + Exclude exclude; |
| 79 | + Scope _scope; |
| 80 | + Type type; |
| 81 | +} |
| 82 | + |
| 83 | +static Config config; |
| 84 | + |
| 85 | +void initGitDiff() |
| 86 | +{ |
| 87 | + auto status = execute("git diff --name-only --cached", false).split("\n") |
| 88 | + .map!(a => a.strip) |
| 89 | + .cache |
| 90 | + .filter!((a) { |
| 91 | + if (config.exclude.equals.canFind(a)) |
| 92 | + return false; |
| 93 | + else if (config.exclude.contains.any!(c => a.indexOf(c) != -1)) |
| 94 | + return false; |
| 95 | + else if (config.exclude.startsWith.any!(c => a.startsWith(c))) |
| 96 | + return false; |
| 97 | + else |
| 98 | + return true; |
| 99 | + }) |
| 100 | + .array; |
| 101 | + if (status.length) |
| 102 | + { |
| 103 | + modifiedFiles = status |
| 104 | + .map!(a => stripExtension(a.strip)).array; |
| 105 | + writeln("Modified files (staged): "); |
| 106 | + writeln(status.map!(f => "> " ~ f).array.join("\n")); |
| 107 | + writeln("\n"); |
| 108 | + } |
| 109 | +} |
| 110 | + |
| 111 | +CommitType guessType() |
| 112 | +{ |
| 113 | + if (modifiedFiles.length) |
| 114 | + { |
| 115 | + foreach (string file; modifiedFiles) |
| 116 | + { |
| 117 | + auto contains = config.type.contains.keys.find!(k => file.indexOf(k) != -1); |
| 118 | + auto startsWith = config.type.startsWith.keys.find!(k => file.startsWith(k)); |
| 119 | + |
| 120 | + if (config.type.equals.keys.canFind(file)) |
| 121 | + { |
| 122 | + return config.type.equals[file]; |
| 123 | + } |
| 124 | + else if (contains.length) |
| 125 | + { |
| 126 | + return config.type.contains[contains.front]; |
| 127 | + } |
| 128 | + else if (startsWith.length) |
| 129 | + { |
| 130 | + return config.type.startsWith[startsWith.front]; |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | + return CommitType.feat; |
| 135 | +} |
| 136 | + |
| 137 | +string[] guessScope() |
| 138 | +{ |
| 139 | + |
| 140 | + Regex!char[string] replaceAllRegexes = config._scope.replaceAll.keys.map!( |
| 141 | + key => tuple(key, regex(key, "g"))).assocArray; |
| 142 | + Regex!char[string] replaceFirstRegexes = config._scope.replaceFirst.keys.map!( |
| 143 | + key => tuple(key, regex(key, "g"))).assocArray; |
| 144 | + |
| 145 | + auto files = modifiedFiles |
| 146 | + .map!((a) { |
| 147 | + foreach (i, value; config._scope.replaceAll) |
| 148 | + { |
| 149 | + a = a.replaceAll(replaceAllRegexes[i], value); |
| 150 | + } |
| 151 | + foreach (i, value; config._scope.replaceFirst) |
| 152 | + { |
| 153 | + a = a.replaceFirst(replaceFirstRegexes[i], value); |
| 154 | + } |
| 155 | + return a; |
| 156 | + } |
| 157 | + ) |
| 158 | + .array |
| 159 | + .sort |
| 160 | + .uniq |
| 161 | + .array; |
| 162 | + return files; |
| 163 | +} |
| 164 | + |
| 165 | +static immutable auto botRegex = ctRegex!(`(\[bot\]|dependabot|actions-bot)`); |
| 166 | + |
| 167 | +string[] getAuthors() |
| 168 | +{ |
| 169 | + auto authors = execute("git log --format='%aN' | sort -u", false).split("\n"); |
| 170 | + return authors |
| 171 | + .filter!(a => !match(a, botRegex)) |
| 172 | + .map!(a => a.strip) |
| 173 | + .array ~ [""]; |
| 174 | +} |
| 175 | + |
| 176 | +struct CommitParams |
| 177 | +{ |
| 178 | + CommitType type; |
| 179 | + string _scope; |
| 180 | + string shortDescription; |
| 181 | + string description; |
| 182 | + bool isBreaking; |
| 183 | + string breaking; |
| 184 | + bool isIssue; |
| 185 | + int issue; |
| 186 | + string[] coAuthors; |
| 187 | +} |
| 188 | + |
| 189 | +string createCommitMessage(CommitParams params) |
| 190 | +{ |
| 191 | + auto strBuilder = appender!string; |
| 192 | + strBuilder.put(params.type.to!string); |
| 193 | + strBuilder.put("("); |
| 194 | + strBuilder.put(params._scope); |
| 195 | + strBuilder.put("): "); |
| 196 | + strBuilder.put(params.shortDescription); |
| 197 | + if (params.description.length) |
| 198 | + { |
| 199 | + strBuilder.put("\n\n"); |
| 200 | + strBuilder.put(params.description); |
| 201 | + } |
| 202 | + if (params.isBreaking) |
| 203 | + { |
| 204 | + strBuilder.put("\n\nBREAKING CHANGE:"); |
| 205 | + strBuilder.put(params.breaking); |
| 206 | + |
| 207 | + } |
| 208 | + if (params.isIssue) |
| 209 | + { |
| 210 | + strBuilder.put("\n\nCloses #"); |
| 211 | + strBuilder.put(params.issue.to!string); |
| 212 | + } |
| 213 | + if (params.coAuthors.length) |
| 214 | + { |
| 215 | + strBuilder.put("\n\nCo-authored-by: "); |
| 216 | + strBuilder.put(params.coAuthors.join(", ")); |
| 217 | + } |
| 218 | + return strBuilder.toString(); |
| 219 | +} |
| 220 | + |
| 221 | +CommitParams promptCommitParams(bool automatic) |
| 222 | +{ |
| 223 | + CommitParams commitParams; |
| 224 | + commitParams.type = automatic ? guessType |
| 225 | + : prompt!CommitType("Commit type (suggestion: " ~ guessType.to!string ~ ")"); |
| 226 | + auto scopeSuggestion = guessScope; |
| 227 | + commitParams._scope = automatic ? scopeSuggestion.front |
| 228 | + : prompt!string( |
| 229 | + "Scope (suggestion: " ~ scopeSuggestion.join(", ") ~ ")"); |
| 230 | + commitParams.shortDescription = automatic ? "" : prompt!string("Short Description"); |
| 231 | + commitParams.description = automatic ? "" : prompt!string("Description"); |
| 232 | + commitParams.isBreaking = automatic ? false : prompt!bool("Breaking change"); |
| 233 | + commitParams.breaking = commitParams.isBreaking ? prompt!string("Breaking change description") |
| 234 | + : ""; |
| 235 | + commitParams.isIssue = automatic ? false : prompt!bool( |
| 236 | + "Does this commit relate to an existing issue"); |
| 237 | + if (commitParams.isIssue) |
| 238 | + { |
| 239 | + commitParams.issue = prompt!int("Issue number"); |
| 240 | + } |
| 241 | + commitParams.coAuthors = automatic ? [] : prompt!string( |
| 242 | + "Co-authors (comma separated)", getAuthors).split(",").map!(a => a.strip) |
| 243 | + .cache |
| 244 | + .filter!(a => a != "") |
| 245 | + .array; |
| 246 | + return commitParams; |
| 247 | +} |
| 248 | + |
| 249 | +export void dev_commit() |
| 250 | +{ |
| 251 | + Params params = parseEnv!Params; |
| 252 | + |
| 253 | + string mclFile = rootDir ~ "/.mcl.json"; |
| 254 | + if (mclFile.exists) |
| 255 | + config = parseJSON(readText(mclFile), JSONOptions.none).fromJSON!Config; |
| 256 | + |
| 257 | + initGitDiff(); |
| 258 | + |
| 259 | + CommitParams commitParams = promptCommitParams(params.automatic); |
| 260 | + |
| 261 | + writeln(); |
| 262 | + string commitMessage = createCommitMessage(commitParams); |
| 263 | + writeln(commitMessage); |
| 264 | + writeln(); |
| 265 | + |
| 266 | + bool commit = prompt!bool("Commit?"); |
| 267 | + if (commit) |
| 268 | + { |
| 269 | + auto pipes = execute!ProcessPipes("git commit -F -", false); |
| 270 | + pipes.stdin.writeln(commitMessage); |
| 271 | + pipes.stdin.flush(); |
| 272 | + pipes.stdin.close(); |
| 273 | + writeln(pipes.stdout.byLineCopy.array.join("\n")); |
| 274 | + writeln(pipes.stderr.byLineCopy.array.join("\n")); |
| 275 | + wait(pipes.pid); |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +struct Params |
| 280 | +{ |
| 281 | + @optional() bool automatic = false; |
| 282 | + void setup() |
| 283 | + { |
| 284 | + } |
| 285 | +} |
0 commit comments