|
| 1 | +// src/core.ts |
| 2 | +import fs2 from "fs"; |
| 3 | +import cp from "child_process"; |
| 4 | + |
| 5 | +// src/config.ts |
| 6 | +import fs from "fs"; |
| 7 | +import path from "path"; |
| 8 | +var DEFAULTS = { |
| 9 | + includePatterns: ["*"], |
| 10 | + format: "[${ticket}] ${msg}", |
| 11 | + fallbackFormat: "[${seg0}] ${msg}", |
| 12 | + exclude: ["merge", "squash", "revert"] |
| 13 | +}; |
| 14 | +function loadConfig(cwd) { |
| 15 | + try { |
| 16 | + const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8")); |
| 17 | + const pkgCfg = pkg.commitFromBranch; |
| 18 | + const cfg = pkgCfg ?? {}; |
| 19 | + const include = cfg.includePattern ?? "*"; |
| 20 | + return { |
| 21 | + includePatterns: Array.isArray(include) ? include : [include], |
| 22 | + format: cfg.format ?? DEFAULTS.format, |
| 23 | + fallbackFormat: cfg.fallbackFormat ?? DEFAULTS.fallbackFormat, |
| 24 | + exclude: (cfg.exclude ?? DEFAULTS.exclude).map(String) |
| 25 | + }; |
| 26 | + } catch { |
| 27 | + return { ...DEFAULTS }; |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +// src/tokens.ts |
| 32 | +function renderTemplate(tpl, ctx) { |
| 33 | + let out = String(tpl); |
| 34 | + out = out.replace(/\$\{prefix:(\d+)\}/g, (_m, n) => { |
| 35 | + const k = Math.max(0, parseInt(n, 10) || 0); |
| 36 | + return ctx.segs.slice(0, k).join("/") || ""; |
| 37 | + }); |
| 38 | + out = out.replace(/\$\{seg(\d+)\}/g, (_m, i) => { |
| 39 | + const idx = parseInt(i, 10) || 0; |
| 40 | + return ctx.segs[idx] || ""; |
| 41 | + }); |
| 42 | + return out.replace(/\$\{ticket\}/g, ctx.ticket || "").replace(/\$\{branch\}/g, ctx.branch || "").replace(/\$\{segments\}/g, ctx.segs.join("/")).replace(/\$\{msg\}/g, ctx.msg || "").replace(/\$\{body\}/g, ctx.body || ""); |
| 43 | +} |
| 44 | + |
| 45 | +// src/core.ts |
| 46 | +var createRegexPattern = (pattern) => new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", "i"); |
| 47 | +var matchesAnyPattern = (value, patterns) => patterns.some((pattern) => createRegexPattern(pattern).test(value)); |
| 48 | +var parseEnvironmentFlag = (env, key) => /^(1|true|yes)$/i.test(String(env[key] || "")); |
| 49 | +var extractTicketFromBranch = (branch) => (branch.match(/([A-Z]+-\d+)/i)?.[1] || "").toUpperCase(); |
| 50 | +var getCurrentBranch = () => { |
| 51 | + try { |
| 52 | + return cp.execSync("git rev-parse --abbrev-ref HEAD", { |
| 53 | + stdio: ["ignore", "pipe", "ignore"] |
| 54 | + }).toString().trim(); |
| 55 | + } catch { |
| 56 | + return ""; |
| 57 | + } |
| 58 | +}; |
| 59 | +var escapeRegexSpecialChars = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 60 | +var createLogger = (debug) => (...args) => { |
| 61 | + if (debug) console.log("[cfb]", ...args); |
| 62 | +}; |
| 63 | +var createInitialState = (opts = {}) => { |
| 64 | + const argv = opts.argv ?? process.argv; |
| 65 | + const env = opts.env ?? process.env; |
| 66 | + const cwd = opts.cwd ?? process.cwd(); |
| 67 | + const [, , commitMsgPath, source] = argv; |
| 68 | + if (!commitMsgPath) return null; |
| 69 | + const config = loadConfig(cwd); |
| 70 | + const branch = getCurrentBranch(); |
| 71 | + const ticket = extractTicketFromBranch(branch); |
| 72 | + const debug = parseEnvironmentFlag(env, "BRANCH_PREFIX_DEBUG"); |
| 73 | + const isDryRun = parseEnvironmentFlag(env, "BRANCH_PREFIX_DRYRUN"); |
| 74 | + let originalMessage = ""; |
| 75 | + let lines = []; |
| 76 | + try { |
| 77 | + const body = fs2.readFileSync(commitMsgPath, "utf8"); |
| 78 | + lines = body.split("\n"); |
| 79 | + originalMessage = lines[0] ?? ""; |
| 80 | + } catch { |
| 81 | + } |
| 82 | + const segs = branch.split("/"); |
| 83 | + const template = ticket ? config.format : config.fallbackFormat; |
| 84 | + const context = { branch, segs, ticket, msg: originalMessage, body: lines.join("\n") }; |
| 85 | + const renderedMessage = renderTemplate(template, context); |
| 86 | + return { |
| 87 | + commitMsgPath, |
| 88 | + source, |
| 89 | + config, |
| 90 | + branch, |
| 91 | + ticket, |
| 92 | + originalMessage, |
| 93 | + lines, |
| 94 | + context, |
| 95 | + template, |
| 96 | + renderedMessage, |
| 97 | + shouldSkip: false, |
| 98 | + isDryRun, |
| 99 | + debug |
| 100 | + }; |
| 101 | +}; |
| 102 | +var validationRules = [ |
| 103 | + { |
| 104 | + name: "source-exclusion", |
| 105 | + check: (state) => !state.source || !state.config.exclude.some( |
| 106 | + (pattern) => new RegExp(pattern, "i").test(String(state.source)) |
| 107 | + ), |
| 108 | + reason: "excluded by source" |
| 109 | + }, |
| 110 | + { |
| 111 | + name: "branch-existence", |
| 112 | + check: (state) => Boolean(state.branch && state.branch !== "HEAD"), |
| 113 | + reason: "no branch or detached HEAD" |
| 114 | + }, |
| 115 | + { |
| 116 | + name: "include-pattern-match", |
| 117 | + check: (state) => matchesAnyPattern(state.branch, state.config.includePatterns), |
| 118 | + reason: "includePattern mismatch" |
| 119 | + } |
| 120 | +]; |
| 121 | +var messageProcessors = [ |
| 122 | + { |
| 123 | + name: "template-replacement", |
| 124 | + shouldApply: (state) => /\$\{msg\}|\$\{body\}/.test(state.template), |
| 125 | + process: (state) => { |
| 126 | + if (state.originalMessage === state.renderedMessage) { |
| 127 | + return { ...state, shouldSkip: true, skipReason: "message already matches template" }; |
| 128 | + } |
| 129 | + if (state.ticket) { |
| 130 | + const ticketRegex = new RegExp(`\\b${escapeRegexSpecialChars(state.ticket)}\\b`, "i"); |
| 131 | + if (ticketRegex.test(state.originalMessage)) { |
| 132 | + return { ...state, shouldSkip: true, skipReason: "ticket already in message" }; |
| 133 | + } |
| 134 | + } |
| 135 | + return { |
| 136 | + ...state, |
| 137 | + lines: [state.renderedMessage, ...state.lines.slice(1)] |
| 138 | + }; |
| 139 | + } |
| 140 | + }, |
| 141 | + { |
| 142 | + name: "prefix-addition", |
| 143 | + shouldApply: (state) => !/\$\{msg\}|\$\{body\}/.test(state.template), |
| 144 | + process: (state) => { |
| 145 | + const escaped = escapeRegexSpecialChars(state.renderedMessage); |
| 146 | + if (new RegExp("^\\s*" + escaped, "i").test(state.originalMessage)) { |
| 147 | + return { ...state, shouldSkip: true, skipReason: "prefix already exists" }; |
| 148 | + } |
| 149 | + if (state.ticket) { |
| 150 | + const ticketRegex = new RegExp(`\\b${escapeRegexSpecialChars(state.ticket)}\\b`, "i"); |
| 151 | + if (ticketRegex.test(state.originalMessage)) { |
| 152 | + return { ...state, shouldSkip: true, skipReason: "ticket already in message" }; |
| 153 | + } |
| 154 | + } |
| 155 | + const firstSeg = state.context.segs[0]; |
| 156 | + if (firstSeg && firstSeg !== "HEAD") { |
| 157 | + const segRegex = new RegExp(`\\b${escapeRegexSpecialChars(firstSeg)}\\b`, "i"); |
| 158 | + if (segRegex.test(state.originalMessage)) { |
| 159 | + return { ...state, shouldSkip: true, skipReason: "branch segment already in message" }; |
| 160 | + } |
| 161 | + } |
| 162 | + return { |
| 163 | + ...state, |
| 164 | + lines: [state.renderedMessage + state.originalMessage, ...state.lines.slice(1)] |
| 165 | + }; |
| 166 | + } |
| 167 | + } |
| 168 | +]; |
| 169 | +var applyValidationRules = (state) => { |
| 170 | + const log = createLogger(state.debug); |
| 171 | + log("config", state.config); |
| 172 | + for (const rule of validationRules) { |
| 173 | + if (!rule.check(state)) { |
| 174 | + const contextInfo = state.ticket || state.branch || "unknown"; |
| 175 | + log(`exit: ${rule.reason}`, `[${rule.name}]`, `context: ${contextInfo}`); |
| 176 | + return { ...state, shouldSkip: true, skipReason: rule.reason }; |
| 177 | + } |
| 178 | + } |
| 179 | + return state; |
| 180 | +}; |
| 181 | +var logProcessingInfo = (state) => { |
| 182 | + const log = createLogger(state.debug); |
| 183 | + const hasMsgToken = /\$\{msg\}|\$\{body\}/.test(state.template); |
| 184 | + log("branch", `${state.branch}`, "ticket", `${state.ticket || "(none)"}`, "segs", `[${state.context.segs.join(", ")}]`); |
| 185 | + log("tpl", `"${state.template}"`); |
| 186 | + log("rendered", `"${state.renderedMessage}"`); |
| 187 | + log("mode", hasMsgToken ? "replace-line" : "prefix-only", `msg: "${state.originalMessage}"`); |
| 188 | + return state; |
| 189 | +}; |
| 190 | +var processMessage = (state) => { |
| 191 | + if (state.shouldSkip) return state; |
| 192 | + const applicableProcessor = messageProcessors.find( |
| 193 | + (processor) => processor.shouldApply(state) |
| 194 | + ); |
| 195 | + if (!applicableProcessor) { |
| 196 | + return { ...state, shouldSkip: true, skipReason: "no applicable processor" }; |
| 197 | + } |
| 198 | + return applicableProcessor.process(state); |
| 199 | +}; |
| 200 | +var writeResult = (state) => { |
| 201 | + const log = createLogger(state.debug); |
| 202 | + if (state.shouldSkip) { |
| 203 | + const contextInfo = state.ticket || state.context.segs[0] || "unknown"; |
| 204 | + log(`skip: ${state.skipReason}`, `[${contextInfo}]`); |
| 205 | + return state; |
| 206 | + } |
| 207 | + if (state.isDryRun) { |
| 208 | + log("dry-run: not writing", `[${state.context.branch}]`); |
| 209 | + return state; |
| 210 | + } |
| 211 | + try { |
| 212 | + fs2.writeFileSync(state.commitMsgPath, state.lines.join("\n"), "utf8"); |
| 213 | + log("write ok", `[${state.context.branch}]`, `-> "${state.lines[0]}"`); |
| 214 | + } catch (error) { |
| 215 | + log("write error:", error, `[${state.context.branch}]`); |
| 216 | + } |
| 217 | + return state; |
| 218 | +}; |
| 219 | +var pipe = (...functions) => (value) => functions.reduce((acc, fn) => fn(acc), value); |
| 220 | +function run(opts = {}) { |
| 221 | + const initialState = createInitialState(opts); |
| 222 | + if (!initialState) return 0; |
| 223 | + const pipeline = pipe( |
| 224 | + applyValidationRules, |
| 225 | + logProcessingInfo, |
| 226 | + processMessage, |
| 227 | + writeResult |
| 228 | + ); |
| 229 | + pipeline(initialState); |
| 230 | + return 0; |
| 231 | +} |
| 232 | + |
| 233 | +export { |
| 234 | + renderTemplate, |
| 235 | + createInitialState, |
| 236 | + validationRules, |
| 237 | + messageProcessors, |
| 238 | + applyValidationRules, |
| 239 | + processMessage, |
| 240 | + run |
| 241 | +}; |
0 commit comments