Skip to content

Commit 49e78d6

Browse files
sungjun-lee2claude
andcommitted
fix: add duplicate ticket detection in template replacement mode and sync web-preview with main core
### Changes **Core Logic:** - Add ticket duplicate detection in template-replacement mode to prevent double tickets (e.g., `DEF-789: DEF-789: message`) - Export `renderTemplate` from core.ts for web-preview reuse - Previously only prefix mode checked for duplicates, now both modes do **Web Preview:** - Refactor to use parent package's `renderTemplate` instead of duplicating code - Import from `../../dist/core.js` to stay in sync with main package - Fix token syntax in examples: `${segs[0]}` → `${seg0}` - Update InfoBox to dynamically read version from parent package.json via Vite config - Update package name: `@253eosam/commit-from-branch` → `commit-from-branch` - Add InfoBox component with installation guide and links (GitHub, NPM, Docs) **Tests:** - Add test for full rendered format duplicate detection - Add comprehensive web-preview tests (31 new tests): - Basic scenarios (5): simple tickets, fallbacks, Jira format - Advanced scenarios (6): multi-segment branches, special chars, multiline - Edge cases (10): empty messages, unicode, long branches, etc. - Total: 70 tests, all passing **Fixes:** - Branch `feature/DEF-789/api-refactor` with message `DEF-789: refactor user API endpoints` now correctly skips (ticket already in message) - web-preview now shows correct skip behavior instead of duplicating tickets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c2d875e commit 49e78d6

File tree

14 files changed

+1281
-358
lines changed

14 files changed

+1281
-358
lines changed

dist/chunk-GLECL33F.js

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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

Comments
 (0)