@git.zone/cli 6.11.0 → 6.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/helpers.changelog.d.ts +4 -0
- package/dist_ts/helpers.changelog.js +73 -24
- package/dist_ts/helpers.climode.js +40 -3
- package/dist_ts/helpers.workflow.d.ts +2 -2
- package/dist_ts/helpers.workflow.js +42 -6
- package/dist_ts/mod_commit/helpers.manual.d.ts +10 -0
- package/dist_ts/mod_commit/helpers.manual.js +97 -0
- package/dist_ts/mod_commit/index.js +156 -35
- package/dist_ts/mod_commit/mod.plugins.d.ts +7 -7
- package/dist_ts/mod_commit/mod.plugins.js +9 -8
- package/dist_ts/mod_standard/index.js +2 -2
- package/package.json +1 -1
- package/readme.md +43 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/helpers.changelog.ts +108 -32
- package/ts/helpers.climode.ts +45 -2
- package/ts/helpers.workflow.ts +46 -5
- package/ts/mod_commit/helpers.manual.ts +156 -0
- package/ts/mod_commit/index.ts +272 -53
- package/ts/mod_commit/mod.plugins.ts +8 -7
- package/ts/mod_standard/index.ts +1 -1
package/readme.md
CHANGED
|
@@ -107,6 +107,12 @@ gitzone commit -ytbp
|
|
|
107
107
|
|
|
108
108
|
# Show the resolved workflow without mutating anything
|
|
109
109
|
gitzone commit --plan
|
|
110
|
+
|
|
111
|
+
# Supply a message and changelog entry without AI
|
|
112
|
+
gitzone commit -y --message 'fix(cache): expire stale entries' --changelog 'Expired entries are removed before lookup.'
|
|
113
|
+
|
|
114
|
+
# Preserve a multiline message and custom Pending Markdown
|
|
115
|
+
gitzone commit -y --message-file /tmp/commit.txt --changelog-file /tmp/pending.md
|
|
110
116
|
```
|
|
111
117
|
|
|
112
118
|
The commit flow:
|
|
@@ -128,6 +134,43 @@ Commit flags:
|
|
|
128
134
|
| `-p`, `--push` | Push after the source commit |
|
|
129
135
|
| `-f`, `--format` | Run `gitzone format --write` before commit |
|
|
130
136
|
| `--plan` | Show resolved workflow only |
|
|
137
|
+
| `-m`, `--message <text>` | Use a complete semantic commit message without AI |
|
|
138
|
+
| `--message-file <path>` | Read the complete message from a UTF-8 file |
|
|
139
|
+
| `--changelog <text>` | Supply custom Pending text with a manual message |
|
|
140
|
+
| `--changelog-file <path>` | Read custom Pending text from a UTF-8 file |
|
|
141
|
+
|
|
142
|
+
Manual messages start with `type(scope): description`; the scope is optional.
|
|
143
|
+
Conventional `!` subjects and `BREAKING CHANGE:` footers are supported. The entire
|
|
144
|
+
message, including its body, is passed literally to Git. File inputs preserve
|
|
145
|
+
multiline text; CRLF becomes LF and terminal newlines are normalized. Each input
|
|
146
|
+
is limited to 128 KiB. Use `--message` or `--message-file` once, and at most one
|
|
147
|
+
changelog option. Changelog overrides require a manual message.
|
|
148
|
+
|
|
149
|
+
Without a changelog override, GitZone derives the Pending entry from the semantic
|
|
150
|
+
subject and body. Custom text becomes an entry in the message's semantic bucket.
|
|
151
|
+
For complete Markdown, supply a Pending fragment such as:
|
|
152
|
+
|
|
153
|
+
```markdown
|
|
154
|
+
### Fixes
|
|
155
|
+
|
|
156
|
+
- Remove expired entries before lookup.
|
|
157
|
+
- Preserve entries whose validity has not expired.
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Fragments may contain `### Breaking Changes`, `### Features`, `### Fixes`,
|
|
161
|
+
`### Documentation`, and `### Maintenance`. Omit the document title, `## Pending`,
|
|
162
|
+
and version headings. GitZone merges entries into the existing Pending buckets
|
|
163
|
+
and preserves previous entries and release history. Pending headings continue to
|
|
164
|
+
determine the release bump; a breaking message requires a Breaking Changes entry,
|
|
165
|
+
and either a breaking message or new breaking changelog text requires the usual
|
|
166
|
+
interactive confirmation or `-y --allow-breaking`.
|
|
167
|
+
|
|
168
|
+
Manual mode replaces the AI analysis step with `manual`. It keeps the configured
|
|
169
|
+
format, test, build, staging and push behavior. `--plan` validates and displays the
|
|
170
|
+
message and changelog without mutation or AI access. A clean repository remains
|
|
171
|
+
unchanged. Input is validated before workflow steps run; it never falls back to AI
|
|
172
|
+
after an input error. Place input files outside the repository if they should not
|
|
173
|
+
be included by the existing stage-all workflow.
|
|
131
174
|
|
|
132
175
|
`-r` is intentionally not part of commit anymore. Use `gitzone release`.
|
|
133
176
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@git.zone/cli',
|
|
6
|
-
version: '6.
|
|
6
|
+
version: '6.12.0',
|
|
7
7
|
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
|
|
8
8
|
}
|
package/ts/helpers.changelog.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import * as plugins from "./plugins.js";
|
|
2
2
|
|
|
3
3
|
export type TChangelogBucket =
|
|
4
|
-
|
|
5
|
-
| "Features"
|
|
6
|
-
| "Fixes"
|
|
7
|
-
| "Documentation"
|
|
8
|
-
| "Maintenance";
|
|
4
|
+
"Breaking Changes" | "Features" | "Fixes" | "Documentation" | "Maintenance";
|
|
9
5
|
|
|
10
6
|
export interface IChangelogEntry {
|
|
11
7
|
type: string;
|
|
@@ -25,7 +21,7 @@ interface IChangelogSection {
|
|
|
25
21
|
end: number;
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
const bucketForCommitType = (commitType: string): TChangelogBucket => {
|
|
24
|
+
export const bucketForCommitType = (commitType: string): TChangelogBucket => {
|
|
29
25
|
switch (commitType) {
|
|
30
26
|
case "BREAKING CHANGE":
|
|
31
27
|
return "Breaking Changes";
|
|
@@ -44,18 +40,30 @@ const readChangelog = async (filePath: string): Promise<string> => {
|
|
|
44
40
|
if (!(await plugins.smartfs.file(filePath).exists())) {
|
|
45
41
|
return "# Changelog\n\n";
|
|
46
42
|
}
|
|
47
|
-
return (await plugins.smartfs
|
|
43
|
+
return (await plugins.smartfs
|
|
44
|
+
.file(filePath)
|
|
45
|
+
.encoding("utf8")
|
|
46
|
+
.read()) as string;
|
|
48
47
|
};
|
|
49
48
|
|
|
50
|
-
const writeChangelog = async (
|
|
51
|
-
|
|
49
|
+
const writeChangelog = async (
|
|
50
|
+
filePath: string,
|
|
51
|
+
content: string,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
await plugins.smartfs
|
|
54
|
+
.file(filePath)
|
|
55
|
+
.encoding("utf8")
|
|
56
|
+
.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
const findPendingSection = (
|
|
55
60
|
content: string,
|
|
56
61
|
sectionName: string,
|
|
57
62
|
): IChangelogSection | null => {
|
|
58
|
-
const headingRegex = new RegExp(
|
|
63
|
+
const headingRegex = new RegExp(
|
|
64
|
+
`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
|
|
65
|
+
"m",
|
|
66
|
+
);
|
|
59
67
|
const match = headingRegex.exec(content);
|
|
60
68
|
if (!match || match.index === undefined) {
|
|
61
69
|
return null;
|
|
@@ -64,7 +72,9 @@ const findPendingSection = (
|
|
|
64
72
|
const bodyStart = match.index + match[0].length;
|
|
65
73
|
const rest = content.slice(bodyStart);
|
|
66
74
|
const nextHeadingMatch = /^##\s+/m.exec(rest);
|
|
67
|
-
const end = nextHeadingMatch
|
|
75
|
+
const end = nextHeadingMatch
|
|
76
|
+
? bodyStart + nextHeadingMatch.index
|
|
77
|
+
: content.length;
|
|
68
78
|
return { start: match.index, bodyStart, end };
|
|
69
79
|
};
|
|
70
80
|
|
|
@@ -95,30 +105,89 @@ export const appendPendingChangelogEntry = async (
|
|
|
95
105
|
sectionName: string,
|
|
96
106
|
entry: IChangelogEntry,
|
|
97
107
|
): Promise<void> => {
|
|
98
|
-
let content = await ensurePendingSection(filePath, sectionName);
|
|
99
|
-
const pendingSection = findPendingSection(content, sectionName)!;
|
|
100
|
-
let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
|
|
101
108
|
const bucket = bucketForCommitType(entry.type);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
const entryLines = [
|
|
110
|
+
`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`,
|
|
111
|
+
];
|
|
105
112
|
for (const detail of entry.details || []) {
|
|
106
113
|
entryLines.push(` - ${detail}`);
|
|
107
114
|
}
|
|
108
115
|
const renderedEntry = entryLines.join("\n");
|
|
116
|
+
await appendPendingChangelogMarkdown(
|
|
117
|
+
filePath,
|
|
118
|
+
sectionName,
|
|
119
|
+
`### ${bucket}\n\n${renderedEntry}`,
|
|
120
|
+
);
|
|
121
|
+
};
|
|
109
122
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
/** A Pending fragment contains canonical buckets, never the document/version headings. */
|
|
124
|
+
export const validatePendingChangelogMarkdown = (markdown: string): void => {
|
|
125
|
+
const headings = [...markdown.matchAll(/^#{1,3}\s+.*$/gm)].map(
|
|
126
|
+
(match) => match[0],
|
|
127
|
+
);
|
|
128
|
+
const allowed = new Set(
|
|
129
|
+
[
|
|
130
|
+
"Breaking Changes",
|
|
131
|
+
"Features",
|
|
132
|
+
"Fixes",
|
|
133
|
+
"Documentation",
|
|
134
|
+
"Maintenance",
|
|
135
|
+
].map((bucket) => `### ${bucket}`),
|
|
136
|
+
);
|
|
137
|
+
if (
|
|
138
|
+
!markdown.startsWith("### ") ||
|
|
139
|
+
headings.length === 0 ||
|
|
140
|
+
headings.some((heading) => !allowed.has(heading))
|
|
141
|
+
) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"Changelog fragments must use canonical ### bucket headings, without title, Pending or version headings.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
for (const block of markdown.split(/^### .+\n?/m).slice(1)) {
|
|
147
|
+
if (!block.trim())
|
|
148
|
+
throw new Error("Changelog buckets must contain an entry.");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export const appendPendingChangelogMarkdown = async (
|
|
153
|
+
filePath: string,
|
|
154
|
+
sectionName: string,
|
|
155
|
+
markdown: string,
|
|
156
|
+
): Promise<void> => {
|
|
157
|
+
validatePendingChangelogMarkdown(markdown);
|
|
158
|
+
let content = await ensurePendingSection(filePath, sectionName);
|
|
159
|
+
const pendingSection = findPendingSection(content, sectionName)!;
|
|
160
|
+
let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
|
|
161
|
+
const sections = [...markdown.matchAll(/^### (.+)\n/gm)];
|
|
162
|
+
for (let index = 0; index < sections.length; index++) {
|
|
163
|
+
const section = sections[index];
|
|
164
|
+
const bucket = section[1];
|
|
165
|
+
const bucketHeading = `### ${bucket}`;
|
|
166
|
+
const renderedEntry = markdown
|
|
167
|
+
.slice(
|
|
168
|
+
section.index! + section[0].length,
|
|
169
|
+
sections[index + 1]?.index ?? markdown.length,
|
|
170
|
+
)
|
|
171
|
+
.trim();
|
|
172
|
+
|
|
173
|
+
const bucketRegex = new RegExp(
|
|
174
|
+
`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
|
|
175
|
+
"m",
|
|
176
|
+
);
|
|
177
|
+
const bucketMatch = bucketRegex.exec(pendingBody);
|
|
178
|
+
if (!bucketMatch || bucketMatch.index === undefined) {
|
|
179
|
+
pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
|
|
180
|
+
} else {
|
|
181
|
+
const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
|
|
182
|
+
const afterBucket = pendingBody.slice(bucketBodyStart);
|
|
183
|
+
const nextBucketMatch = /^###\s+/m.exec(afterBucket);
|
|
184
|
+
const insertAt = nextBucketMatch
|
|
185
|
+
? bucketBodyStart + nextBucketMatch.index
|
|
186
|
+
: pendingBody.length;
|
|
187
|
+
const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
|
|
188
|
+
const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
|
|
189
|
+
pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
|
|
190
|
+
}
|
|
122
191
|
}
|
|
123
192
|
|
|
124
193
|
content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`;
|
|
@@ -140,7 +209,9 @@ export const readPendingChangelog = async (
|
|
|
140
209
|
};
|
|
141
210
|
};
|
|
142
211
|
|
|
143
|
-
export const inferVersionTypeFromPending = (
|
|
212
|
+
export const inferVersionTypeFromPending = (
|
|
213
|
+
pendingBlock: string,
|
|
214
|
+
): "patch" | "minor" | "major" => {
|
|
144
215
|
if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) {
|
|
145
216
|
return "major";
|
|
146
217
|
}
|
|
@@ -162,7 +233,9 @@ export const movePendingToVersion = async (
|
|
|
162
233
|
if (!pendingSection) {
|
|
163
234
|
throw new Error("No pending changelog entries. Nothing to release.");
|
|
164
235
|
}
|
|
165
|
-
const pendingBlock = content
|
|
236
|
+
const pendingBlock = content
|
|
237
|
+
.slice(pendingSection.bodyStart, pendingSection.end)
|
|
238
|
+
.trim();
|
|
166
239
|
if (!pendingBlock) {
|
|
167
240
|
throw new Error("No pending changelog entries. Nothing to release.");
|
|
168
241
|
}
|
|
@@ -171,7 +244,10 @@ export const movePendingToVersion = async (
|
|
|
171
244
|
.replaceAll("{{version}}", version)
|
|
172
245
|
.replaceAll("{{date}}", dateString);
|
|
173
246
|
const beforePending = content.slice(0, pendingSection.start).trimEnd();
|
|
174
|
-
const afterPending = content
|
|
247
|
+
const afterPending = content
|
|
248
|
+
.slice(pendingSection.end)
|
|
249
|
+
.replace(/^\n+/, "")
|
|
250
|
+
.trimEnd();
|
|
175
251
|
content = [beforePending, renderedHeading, pendingBlock, afterPending]
|
|
176
252
|
.filter((block) => block.length > 0)
|
|
177
253
|
.join("\n\n");
|
package/ts/helpers.climode.ts
CHANGED
|
@@ -24,6 +24,13 @@ interface ICliConfigSettings {
|
|
|
24
24
|
|
|
25
25
|
type TArgSource = Record<string, any> & { _?: string[] };
|
|
26
26
|
|
|
27
|
+
const commitValueFlags = new Set([
|
|
28
|
+
"message",
|
|
29
|
+
"message-file",
|
|
30
|
+
"changelog",
|
|
31
|
+
"changelog-file",
|
|
32
|
+
]);
|
|
33
|
+
|
|
27
34
|
const booleanLongFlags = new Set([
|
|
28
35
|
"agent",
|
|
29
36
|
"allow-breaking",
|
|
@@ -65,10 +72,23 @@ const getArgValue = (argvArg: TArgSource, key: string): any => {
|
|
|
65
72
|
|
|
66
73
|
const parseRawArgv = (argv: string[]): TArgSource => {
|
|
67
74
|
const parsedArgv: TArgSource = { _: [] };
|
|
75
|
+
const setCommitValue = (key: string, value: string | undefined): void => {
|
|
76
|
+
if (Object.hasOwn(parsedArgv, key))
|
|
77
|
+
throw new Error(`--${key} may only be supplied once.`);
|
|
78
|
+
if (value === undefined || value.length === 0)
|
|
79
|
+
throw new Error(`--${key} requires a value.`);
|
|
80
|
+
parsedArgv[key] = value;
|
|
81
|
+
parsedArgv[camelCase(key)] = value;
|
|
82
|
+
};
|
|
68
83
|
|
|
69
84
|
for (let i = 0; i < argv.length; i++) {
|
|
70
85
|
const currentArg = argv[i];
|
|
71
86
|
|
|
87
|
+
if (currentArg === "--") {
|
|
88
|
+
parsedArgv._!.push(...argv.slice(i + 1));
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
if (currentArg.startsWith("--no-")) {
|
|
73
93
|
const key = currentArg.slice(5);
|
|
74
94
|
parsedArgv[key] = false;
|
|
@@ -80,9 +100,20 @@ const parseRawArgv = (argv: string[]): TArgSource => {
|
|
|
80
100
|
const withoutPrefix = currentArg.slice(2);
|
|
81
101
|
const equalsIndex = withoutPrefix.indexOf("=");
|
|
82
102
|
const rawKey =
|
|
83
|
-
equalsIndex === -1
|
|
103
|
+
equalsIndex === -1
|
|
104
|
+
? withoutPrefix
|
|
105
|
+
: withoutPrefix.slice(0, equalsIndex);
|
|
84
106
|
const inlineValue =
|
|
85
107
|
equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1);
|
|
108
|
+
if (commitValueFlags.has(rawKey)) {
|
|
109
|
+
const next = argv[i + 1];
|
|
110
|
+
setCommitValue(
|
|
111
|
+
rawKey,
|
|
112
|
+
inlineValue ?? (next && !next.startsWith("-") ? next : undefined),
|
|
113
|
+
);
|
|
114
|
+
if (inlineValue === undefined) i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
86
117
|
if (inlineValue !== undefined) {
|
|
87
118
|
parsedArgv[rawKey] = inlineValue;
|
|
88
119
|
parsedArgv[camelCase(rawKey)] = inlineValue;
|
|
@@ -108,7 +139,19 @@ const parseRawArgv = (argv: string[]): TArgSource => {
|
|
|
108
139
|
}
|
|
109
140
|
|
|
110
141
|
if (currentArg.startsWith("-") && currentArg.length > 1) {
|
|
111
|
-
|
|
142
|
+
const flags = currentArg.slice(1);
|
|
143
|
+
for (let flagIndex = 0; flagIndex < flags.length; flagIndex++) {
|
|
144
|
+
const shortFlag = flags[flagIndex];
|
|
145
|
+
if (shortFlag === "m") {
|
|
146
|
+
const attached = flags.slice(flagIndex + 1);
|
|
147
|
+
const next = argv[i + 1];
|
|
148
|
+
setCommitValue(
|
|
149
|
+
"message",
|
|
150
|
+
attached || (next && !next.startsWith("-") ? next : undefined),
|
|
151
|
+
);
|
|
152
|
+
if (!attached) i++;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
112
155
|
parsedArgv[shortFlag] = true;
|
|
113
156
|
}
|
|
114
157
|
continue;
|
package/ts/helpers.workflow.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getCliConfigValue } from "./helpers.smartconfig.js";
|
|
2
|
+
import { getProcessUserArgv } from "./helpers.climode.js";
|
|
2
3
|
|
|
3
4
|
export type TConfirmationMode = "prompt" | "auto" | "plan";
|
|
4
5
|
|
|
@@ -171,7 +172,7 @@ export interface IReleaseWorkflowConfig {
|
|
|
171
172
|
export interface IResolvedCommitWorkflow {
|
|
172
173
|
confirmation: TConfirmationMode;
|
|
173
174
|
allowBreaking: boolean;
|
|
174
|
-
steps: TCommitStep
|
|
175
|
+
steps: Array<TCommitStep | "manual">;
|
|
175
176
|
staging: "all";
|
|
176
177
|
testCommand: string;
|
|
177
178
|
buildCommand: string;
|
|
@@ -479,7 +480,7 @@ const readCliWorkflowConfig = async (): Promise<ICliWorkflowConfig> => {
|
|
|
479
480
|
};
|
|
480
481
|
|
|
481
482
|
const getOrderedArgsAfterCommand = (commandName: string): string[] => {
|
|
482
|
-
const rawArgs =
|
|
483
|
+
const rawArgs = getProcessUserArgv();
|
|
483
484
|
const commandIndex = rawArgs.indexOf(commandName);
|
|
484
485
|
if (commandIndex === -1) {
|
|
485
486
|
return rawArgs;
|
|
@@ -489,15 +490,29 @@ const getOrderedArgsAfterCommand = (commandName: string): string[] => {
|
|
|
489
490
|
|
|
490
491
|
const getOrderedShortFlags = (commandName: string): string[] => {
|
|
491
492
|
const orderedFlags: string[] = [];
|
|
492
|
-
|
|
493
|
+
const args = getOrderedArgsAfterCommand(commandName);
|
|
494
|
+
const valueFlags = new Set([
|
|
495
|
+
"--message",
|
|
496
|
+
"--message-file",
|
|
497
|
+
"--changelog",
|
|
498
|
+
"--changelog-file",
|
|
499
|
+
]);
|
|
500
|
+
for (let index = 0; index < args.length; index++) {
|
|
501
|
+
const arg = args[index];
|
|
493
502
|
if (arg === "--") {
|
|
494
503
|
break;
|
|
495
504
|
}
|
|
496
505
|
if (arg.startsWith("--")) {
|
|
506
|
+
if (valueFlags.has(arg)) index++;
|
|
497
507
|
continue;
|
|
498
508
|
}
|
|
499
509
|
if (arg.startsWith("-") && arg.length > 1) {
|
|
500
|
-
|
|
510
|
+
const flags = arg.slice(1);
|
|
511
|
+
const messageIndex = flags.indexOf("m");
|
|
512
|
+
orderedFlags.push(
|
|
513
|
+
...(messageIndex < 0 ? flags : flags.slice(0, messageIndex)).split(""),
|
|
514
|
+
);
|
|
515
|
+
if (messageIndex === flags.length - 1) index++;
|
|
501
516
|
}
|
|
502
517
|
}
|
|
503
518
|
return orderedFlags;
|
|
@@ -545,6 +560,9 @@ const normalizeCommitSteps = (rawSteps: TCommitStep[]): TCommitStep[] => {
|
|
|
545
560
|
}
|
|
546
561
|
|
|
547
562
|
const changelogIndex = prePushSteps.indexOf("changelog");
|
|
563
|
+
if (analyzeIndex > changelogIndex) {
|
|
564
|
+
throw new Error("Commit workflow requires analyze before changelog.");
|
|
565
|
+
}
|
|
548
566
|
if (changelogIndex === -1 || changelogIndex > commitIndex) {
|
|
549
567
|
throw new Error("Commit workflow requires changelog before commit.");
|
|
550
568
|
}
|
|
@@ -633,7 +651,28 @@ const buildReleasePlan = (options: {
|
|
|
633
651
|
|
|
634
652
|
export const resolveCommitWorkflow = async (
|
|
635
653
|
argvArg: any,
|
|
654
|
+
inputMode: "ai" | "manual" = "ai",
|
|
636
655
|
): Promise<IResolvedCommitWorkflow> => {
|
|
656
|
+
for (const flag of [
|
|
657
|
+
"y",
|
|
658
|
+
"yes",
|
|
659
|
+
"allow-breaking",
|
|
660
|
+
"allowBreaking",
|
|
661
|
+
"plan",
|
|
662
|
+
"f",
|
|
663
|
+
"format",
|
|
664
|
+
"t",
|
|
665
|
+
"test",
|
|
666
|
+
"b",
|
|
667
|
+
"build",
|
|
668
|
+
"p",
|
|
669
|
+
"push",
|
|
670
|
+
]) {
|
|
671
|
+
if (argvArg[flag] !== undefined && typeof argvArg[flag] !== "boolean")
|
|
672
|
+
throw new Error(
|
|
673
|
+
`Commit option ${flag} must be a bare Boolean flag without a value.`,
|
|
674
|
+
);
|
|
675
|
+
}
|
|
637
676
|
const cliConfig = await readCliWorkflowConfig();
|
|
638
677
|
const commitConfig = cliConfig.commit || {};
|
|
639
678
|
const releaseFlagRequested = Boolean(argvArg.r || argvArg.release);
|
|
@@ -677,7 +716,9 @@ export const resolveCommitWorkflow = async (
|
|
|
677
716
|
return {
|
|
678
717
|
confirmation,
|
|
679
718
|
allowBreaking: Boolean(argvArg["allow-breaking"] || argvArg.allowBreaking),
|
|
680
|
-
steps: normalizeCommitSteps(rawSteps)
|
|
719
|
+
steps: normalizeCommitSteps(rawSteps).map((step) =>
|
|
720
|
+
inputMode === "manual" && step === "analyze" ? "manual" : step,
|
|
721
|
+
),
|
|
681
722
|
staging: commitConfig.staging || "all",
|
|
682
723
|
testCommand: commitConfig.test?.command || "pnpm test",
|
|
683
724
|
buildCommand: commitConfig.build?.command || "pnpm build",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as plugins from "./mod.plugins.js";
|
|
2
|
+
import {
|
|
3
|
+
bucketForCommitType,
|
|
4
|
+
inferVersionTypeFromPending,
|
|
5
|
+
validatePendingChangelogMarkdown,
|
|
6
|
+
} from "../helpers.changelog.js";
|
|
7
|
+
|
|
8
|
+
export interface IManualCommitInput {
|
|
9
|
+
message: string;
|
|
10
|
+
type: string;
|
|
11
|
+
scope: string;
|
|
12
|
+
summary: string;
|
|
13
|
+
changelog: string;
|
|
14
|
+
breaking: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const maximumInputBytes = 128 * 1024;
|
|
18
|
+
const keys = [
|
|
19
|
+
"message",
|
|
20
|
+
"message-file",
|
|
21
|
+
"changelog",
|
|
22
|
+
"changelog-file",
|
|
23
|
+
] as const;
|
|
24
|
+
type TInputKey = (typeof keys)[number];
|
|
25
|
+
const readArg = (argv: Record<string, unknown>, key: TInputKey): unknown =>
|
|
26
|
+
argv[key] ??
|
|
27
|
+
argv[key.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())];
|
|
28
|
+
|
|
29
|
+
export const hasManualCommitInput = (argv: Record<string, unknown>): boolean =>
|
|
30
|
+
keys.some((key) => readArg(argv, key) !== undefined);
|
|
31
|
+
|
|
32
|
+
const readInput = async (
|
|
33
|
+
value: unknown,
|
|
34
|
+
fromFile: boolean,
|
|
35
|
+
cwd: string,
|
|
36
|
+
): Promise<string> => {
|
|
37
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0"))
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Manual commit inputs require non-empty text or file paths.",
|
|
40
|
+
);
|
|
41
|
+
let text = value;
|
|
42
|
+
if (fromFile) {
|
|
43
|
+
const file = await plugins.fs.open(
|
|
44
|
+
plugins.path.resolve(cwd, value),
|
|
45
|
+
plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_NONBLOCK,
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
const stat = await file.stat();
|
|
49
|
+
if (!stat.isFile() || stat.size > maximumInputBytes)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Manual input files must be regular files of at most 128 KiB.",
|
|
52
|
+
);
|
|
53
|
+
const bytes = Buffer.alloc(maximumInputBytes + 1);
|
|
54
|
+
let length = 0;
|
|
55
|
+
while (length < bytes.length) {
|
|
56
|
+
const part = await file.read(
|
|
57
|
+
bytes,
|
|
58
|
+
length,
|
|
59
|
+
bytes.length - length,
|
|
60
|
+
null,
|
|
61
|
+
);
|
|
62
|
+
if (!part.bytesRead) break;
|
|
63
|
+
length += part.bytesRead;
|
|
64
|
+
}
|
|
65
|
+
if (length > maximumInputBytes)
|
|
66
|
+
throw new Error("Manual inputs must be at most 128 KiB.");
|
|
67
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
68
|
+
bytes.subarray(0, length),
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await file.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
text = text.replace(/\r\n/g, "\n").replace(/\n+$/, "");
|
|
75
|
+
if (
|
|
76
|
+
!text.trim() ||
|
|
77
|
+
Buffer.byteLength(text, "utf8") > maximumInputBytes ||
|
|
78
|
+
/[\u0000-\u0008\u000b-\u001f\u007f]/.test(text)
|
|
79
|
+
) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"Manual inputs must contain non-empty text without control characters, at most 128 KiB.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return text;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const loadManualCommitInput = async (
|
|
88
|
+
argv: Record<string, unknown>,
|
|
89
|
+
cwd: string,
|
|
90
|
+
): Promise<IManualCommitInput | undefined> => {
|
|
91
|
+
if (!hasManualCommitInput(argv)) return undefined;
|
|
92
|
+
const message = readArg(argv, "message");
|
|
93
|
+
const messageFile = readArg(argv, "message-file");
|
|
94
|
+
const changelog = readArg(argv, "changelog");
|
|
95
|
+
const changelogFile = readArg(argv, "changelog-file");
|
|
96
|
+
if ((message === undefined) === (messageFile === undefined))
|
|
97
|
+
throw new Error(
|
|
98
|
+
"Supply exactly one of --message or --message-file for a manual commit.",
|
|
99
|
+
);
|
|
100
|
+
if (changelog !== undefined && changelogFile !== undefined)
|
|
101
|
+
throw new Error("Supply only one of --changelog or --changelog-file.");
|
|
102
|
+
const fullMessage = await readInput(
|
|
103
|
+
message ?? messageFile,
|
|
104
|
+
messageFile !== undefined,
|
|
105
|
+
cwd,
|
|
106
|
+
);
|
|
107
|
+
const [subject, ...body] = fullMessage.split("\n");
|
|
108
|
+
const parsed =
|
|
109
|
+
/^(BREAKING CHANGE|[a-z][a-z0-9-]*)(?:\(([^():\r\n]+)\))?(!)?: (\S.*)$/.exec(
|
|
110
|
+
subject,
|
|
111
|
+
);
|
|
112
|
+
if (!parsed)
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Manual messages must start with a semantic subject: type(scope): description, with optional scope and !.",
|
|
115
|
+
);
|
|
116
|
+
const [, originalType, scope = "", bang, summary] = parsed;
|
|
117
|
+
const messageBreaking =
|
|
118
|
+
originalType === "BREAKING CHANGE" ||
|
|
119
|
+
Boolean(bang) ||
|
|
120
|
+
/^BREAKING[ -]CHANGE:\s*\S/m.test(fullMessage);
|
|
121
|
+
const type = messageBreaking ? "BREAKING CHANGE" : originalType;
|
|
122
|
+
let markdown: string;
|
|
123
|
+
if (changelog !== undefined || changelogFile !== undefined) {
|
|
124
|
+
const text = (
|
|
125
|
+
await readInput(
|
|
126
|
+
changelog ?? changelogFile,
|
|
127
|
+
changelogFile !== undefined,
|
|
128
|
+
cwd,
|
|
129
|
+
)
|
|
130
|
+
).trim();
|
|
131
|
+
if (/^#{1,3}\s/m.test(text)) {
|
|
132
|
+
markdown = text;
|
|
133
|
+
} else {
|
|
134
|
+
markdown = `### ${bucketForCommitType(type)}\n\n- ${text.replace(/\n/g, "\n ")}`;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
const details = body
|
|
138
|
+
.filter((line) => line.trim())
|
|
139
|
+
.map((line) => ` - ${line.trim()}`);
|
|
140
|
+
markdown = `### ${bucketForCommitType(type)}\n\n- ${summary}${scope ? ` (${scope})` : ""}${details.length ? "\n" + details.join("\n") : ""}`;
|
|
141
|
+
}
|
|
142
|
+
validatePendingChangelogMarkdown(markdown);
|
|
143
|
+
const changelogBreaking = inferVersionTypeFromPending(markdown) === "major";
|
|
144
|
+
if (messageBreaking && !changelogBreaking)
|
|
145
|
+
throw new Error(
|
|
146
|
+
"A breaking commit requires a Breaking Changes changelog bucket.",
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
message: fullMessage,
|
|
150
|
+
type,
|
|
151
|
+
scope,
|
|
152
|
+
summary,
|
|
153
|
+
changelog: markdown,
|
|
154
|
+
breaking: messageBreaking || changelogBreaking,
|
|
155
|
+
};
|
|
156
|
+
};
|