@denisvieiradev/gitwise-core 0.1.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/README.md +3 -0
- package/dist/index.d.ts +840 -0
- package/dist/index.js +3077 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/index.d.ts +58 -0
- package/dist/testing/index.js +83 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-DnMpR1qf.d.ts +29 -0
- package/package.json +58 -0
- package/templates/.gitkeep +0 -0
- package/templates/commit.md +1 -0
- package/templates/pr.md +11 -0
- package/templates/release-changelog.md +19 -0
- package/templates/release-notes.md +13 -0
- package/templates/release-version.md +11 -0
- package/templates/review.md +19 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3077 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// package.json
|
|
8
|
+
var package_default = {
|
|
9
|
+
name: "@denisvieiradev/gitwise-core",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
description: "Shared logic for gitwise: non-interactive commit/review/pr/release commands, LLM providers, git/github primitives, prompt templates.",
|
|
12
|
+
type: "module",
|
|
13
|
+
main: "./dist/index.js",
|
|
14
|
+
types: "./dist/index.d.ts",
|
|
15
|
+
exports: {
|
|
16
|
+
".": {
|
|
17
|
+
types: "./dist/index.d.ts",
|
|
18
|
+
import: "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./testing": {
|
|
21
|
+
types: "./dist/testing/index.d.ts",
|
|
22
|
+
import: "./dist/testing/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
files: [
|
|
27
|
+
"dist",
|
|
28
|
+
"templates",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
scripts: {
|
|
33
|
+
build: "tsup",
|
|
34
|
+
test: "node --experimental-vm-modules ../../node_modules/.bin/jest --passWithNoTests",
|
|
35
|
+
lint: "tsc --noEmit",
|
|
36
|
+
typecheck: "tsc --noEmit"
|
|
37
|
+
},
|
|
38
|
+
keywords: [
|
|
39
|
+
"gitwise",
|
|
40
|
+
"git",
|
|
41
|
+
"ai",
|
|
42
|
+
"claude",
|
|
43
|
+
"commit",
|
|
44
|
+
"pull-request",
|
|
45
|
+
"release",
|
|
46
|
+
"code-review"
|
|
47
|
+
],
|
|
48
|
+
author: "Denis Vieira <denisvieira05@gmail.com> (https://github.com/denisvieiradev)",
|
|
49
|
+
license: "MIT",
|
|
50
|
+
repository: {
|
|
51
|
+
type: "git",
|
|
52
|
+
url: "git+https://github.com/denisvieiradev/gitwise.git",
|
|
53
|
+
directory: "packages/core"
|
|
54
|
+
},
|
|
55
|
+
bugs: {
|
|
56
|
+
url: "https://github.com/denisvieiradev/gitwise/issues"
|
|
57
|
+
},
|
|
58
|
+
homepage: "https://github.com/denisvieiradev/gitwise#readme",
|
|
59
|
+
engines: {
|
|
60
|
+
node: ">=22.12.0"
|
|
61
|
+
},
|
|
62
|
+
dependencies: {
|
|
63
|
+
"@anthropic-ai/sdk": "^0.109.0"
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/errors.ts
|
|
68
|
+
var EXIT_CODES = Object.freeze({
|
|
69
|
+
OK: 0,
|
|
70
|
+
UNKNOWN: 1,
|
|
71
|
+
NOTHING_STAGED: 10,
|
|
72
|
+
INVALID_INTENT: 11,
|
|
73
|
+
GIT_FAILED: 20,
|
|
74
|
+
GH_FAILED: 21,
|
|
75
|
+
REPO_STATE_INVALID: 22,
|
|
76
|
+
API_FAILED: 30,
|
|
77
|
+
API_KEY_MISSING: 31,
|
|
78
|
+
API_RATE_LIMITED: 32,
|
|
79
|
+
USER_ABORT: 40,
|
|
80
|
+
CONFIG_INVALID: 50,
|
|
81
|
+
RELEASE_PLAN_STALE: 60,
|
|
82
|
+
RELEASE_BRANCH_CONFLICT: 61,
|
|
83
|
+
SENSITIVE_FILE_BLOCKED: 70,
|
|
84
|
+
REPO_LOCKED: 80,
|
|
85
|
+
ROLLBACK_PARTIAL: 81
|
|
86
|
+
});
|
|
87
|
+
var GitwiseError = class extends Error {
|
|
88
|
+
code;
|
|
89
|
+
exitCode;
|
|
90
|
+
cause;
|
|
91
|
+
details;
|
|
92
|
+
constructor(args) {
|
|
93
|
+
super(args.message);
|
|
94
|
+
this.name = "GitwiseError";
|
|
95
|
+
this.code = args.code;
|
|
96
|
+
this.exitCode = args.exitCode ?? EXIT_CODES[args.code] ?? 1;
|
|
97
|
+
this.cause = args.cause;
|
|
98
|
+
this.details = args.details;
|
|
99
|
+
}
|
|
100
|
+
toJSON() {
|
|
101
|
+
return {
|
|
102
|
+
name: this.name,
|
|
103
|
+
code: this.code,
|
|
104
|
+
exitCode: this.exitCode,
|
|
105
|
+
message: this.message,
|
|
106
|
+
...this.details !== void 0 ? { details: this.details } : {}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
function wrapError(err) {
|
|
111
|
+
if (err instanceof GitwiseError) return err;
|
|
112
|
+
if (err instanceof Error) {
|
|
113
|
+
return new GitwiseError({
|
|
114
|
+
code: "UNKNOWN",
|
|
115
|
+
message: err.message,
|
|
116
|
+
cause: err
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return new GitwiseError({
|
|
120
|
+
code: "UNKNOWN",
|
|
121
|
+
message: typeof err === "string" ? err : "Unknown error",
|
|
122
|
+
cause: err
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/infra/logger.ts
|
|
127
|
+
var verboseEnabled = false;
|
|
128
|
+
if (process.env["GITWISE_DEBUG"] === "1") {
|
|
129
|
+
verboseEnabled = true;
|
|
130
|
+
}
|
|
131
|
+
function setVerbose(enabled) {
|
|
132
|
+
verboseEnabled = enabled;
|
|
133
|
+
}
|
|
134
|
+
function isVerbose() {
|
|
135
|
+
return verboseEnabled;
|
|
136
|
+
}
|
|
137
|
+
function info(message, context) {
|
|
138
|
+
if (context) {
|
|
139
|
+
console.log(message, context);
|
|
140
|
+
} else {
|
|
141
|
+
console.log(message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function error(message, context) {
|
|
145
|
+
if (context) {
|
|
146
|
+
console.error(message, context);
|
|
147
|
+
} else {
|
|
148
|
+
console.error(message);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function warn(message, context) {
|
|
152
|
+
if (context) {
|
|
153
|
+
console.warn(message, context);
|
|
154
|
+
} else {
|
|
155
|
+
console.warn(message);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function debug(message, context) {
|
|
159
|
+
if (!verboseEnabled) return;
|
|
160
|
+
if (context) {
|
|
161
|
+
process.stderr.write(`[debug] ${message} ${JSON.stringify(context)}
|
|
162
|
+
`);
|
|
163
|
+
} else {
|
|
164
|
+
process.stderr.write(`[debug] ${message}
|
|
165
|
+
`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// src/infra/filesystem.ts
|
|
170
|
+
import { access, mkdir, readFile, writeFile } from "fs/promises";
|
|
171
|
+
import { dirname } from "path";
|
|
172
|
+
async function fileExists(filePath) {
|
|
173
|
+
try {
|
|
174
|
+
await access(filePath);
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function readJSON(filePath) {
|
|
181
|
+
const content = await readFile(filePath, "utf-8");
|
|
182
|
+
return JSON.parse(content);
|
|
183
|
+
}
|
|
184
|
+
async function writeJSON(filePath, data) {
|
|
185
|
+
await ensureDir(dirname(filePath));
|
|
186
|
+
const content = JSON.stringify(data, null, 2) + "\n";
|
|
187
|
+
await writeFile(filePath, content, "utf-8");
|
|
188
|
+
}
|
|
189
|
+
async function ensureDir(dirPath) {
|
|
190
|
+
await mkdir(dirPath, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/infra/git.ts
|
|
194
|
+
var git_exports = {};
|
|
195
|
+
__export(git_exports, {
|
|
196
|
+
add: () => add,
|
|
197
|
+
applyCommit: () => applyCommit,
|
|
198
|
+
branchExists: () => branchExists,
|
|
199
|
+
checkout: () => checkout,
|
|
200
|
+
checkoutForce: () => checkoutForce,
|
|
201
|
+
cleanForced: () => cleanForced,
|
|
202
|
+
commit: () => commit,
|
|
203
|
+
createBranch: () => createBranch,
|
|
204
|
+
createTag: () => createTag,
|
|
205
|
+
deleteBranch: () => deleteBranch,
|
|
206
|
+
detectBaseBranch: () => detectBaseBranch,
|
|
207
|
+
fetch: () => fetch,
|
|
208
|
+
getBranch: () => getBranch,
|
|
209
|
+
getChangedFiles: () => getChangedFiles,
|
|
210
|
+
getDiff: () => getDiff,
|
|
211
|
+
getLatestTag: () => getLatestTag,
|
|
212
|
+
getLog: () => getLog,
|
|
213
|
+
getStagedDiff: () => getStagedDiff,
|
|
214
|
+
getStagedFiles: () => getStagedFiles,
|
|
215
|
+
getStagedFilesList: () => getStagedFilesList,
|
|
216
|
+
getUnstagedFiles: () => getUnstagedFiles,
|
|
217
|
+
headSha: () => headSha,
|
|
218
|
+
isBranchMerged: () => isBranchMerged,
|
|
219
|
+
mergeNoFf: () => mergeNoFf,
|
|
220
|
+
parseStatus: () => parseStatus,
|
|
221
|
+
push: () => push,
|
|
222
|
+
pushWithTags: () => pushWithTags,
|
|
223
|
+
resetHard: () => resetHard,
|
|
224
|
+
resetSoft: () => resetSoft,
|
|
225
|
+
resetStaged: () => resetStaged,
|
|
226
|
+
showFileAtHead: () => showFileAtHead,
|
|
227
|
+
stagePathsFromTree: () => stagePathsFromTree,
|
|
228
|
+
stashApplyNamed: () => stashApplyNamed,
|
|
229
|
+
stashDropNamed: () => stashDropNamed,
|
|
230
|
+
stashList: () => stashList,
|
|
231
|
+
stashPopNamed: () => stashPopNamed,
|
|
232
|
+
stashPushNamed: () => stashPushNamed,
|
|
233
|
+
status: () => status,
|
|
234
|
+
tagExists: () => tagExists,
|
|
235
|
+
writeTree: () => writeTree
|
|
236
|
+
});
|
|
237
|
+
import { execFile } from "child_process";
|
|
238
|
+
import { promisify } from "util";
|
|
239
|
+
var exec = promisify(execFile);
|
|
240
|
+
var GIT_TIMEOUT_MS = 3e4;
|
|
241
|
+
var GIT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
242
|
+
function execStderr(err) {
|
|
243
|
+
const stderr = err?.stderr;
|
|
244
|
+
if (typeof stderr === "string" && stderr.length > 0) return stderr;
|
|
245
|
+
return void 0;
|
|
246
|
+
}
|
|
247
|
+
async function run(args, cwd) {
|
|
248
|
+
debug("git command", { args, cwd });
|
|
249
|
+
try {
|
|
250
|
+
const result = await exec("git", args, { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER });
|
|
251
|
+
return result.stdout.trim();
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof Error && "killed" in err && err.killed) {
|
|
254
|
+
throw new GitwiseError({
|
|
255
|
+
code: "GIT_FAILED",
|
|
256
|
+
message: `Git command timed out after ${GIT_TIMEOUT_MS / 1e3}s: git ${args.join(" ")}`,
|
|
257
|
+
cause: err,
|
|
258
|
+
details: { command: `git ${args.join(" ")}`, timedOut: true }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const stderr = execStderr(err);
|
|
262
|
+
throw new GitwiseError({
|
|
263
|
+
code: "GIT_FAILED",
|
|
264
|
+
message: err instanceof Error ? err.message : String(err),
|
|
265
|
+
cause: err,
|
|
266
|
+
details: {
|
|
267
|
+
command: `git ${args.join(" ")}`,
|
|
268
|
+
...stderr !== void 0 ? { stderr } : {}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function getBranch(cwd) {
|
|
274
|
+
return run(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
275
|
+
}
|
|
276
|
+
async function createBranch(cwd, branchName, startPoint) {
|
|
277
|
+
const args = ["checkout", "-b", branchName];
|
|
278
|
+
if (startPoint) args.push(startPoint);
|
|
279
|
+
await run(args, cwd);
|
|
280
|
+
}
|
|
281
|
+
async function checkout(cwd, branchName) {
|
|
282
|
+
await run(["checkout", branchName], cwd);
|
|
283
|
+
}
|
|
284
|
+
async function checkoutForce(cwd, branchName) {
|
|
285
|
+
await run(["checkout", "-f", branchName], cwd);
|
|
286
|
+
}
|
|
287
|
+
async function resetHard(cwd, ref) {
|
|
288
|
+
await run(["reset", "--hard", ref], cwd);
|
|
289
|
+
}
|
|
290
|
+
async function getDiff(cwd, base) {
|
|
291
|
+
const args = base ? ["diff", `${base}...HEAD`] : ["diff"];
|
|
292
|
+
return run(args, cwd);
|
|
293
|
+
}
|
|
294
|
+
async function getStagedDiff(cwd) {
|
|
295
|
+
return run(["diff", "--cached"], cwd);
|
|
296
|
+
}
|
|
297
|
+
async function getLog(cwd, range, maxCount) {
|
|
298
|
+
const args = ["log", "--oneline"];
|
|
299
|
+
if (maxCount) args.push(`-${maxCount}`);
|
|
300
|
+
if (range) args.push(range);
|
|
301
|
+
return run(args, cwd);
|
|
302
|
+
}
|
|
303
|
+
async function add(cwd, files) {
|
|
304
|
+
await run(["add", ...files], cwd);
|
|
305
|
+
}
|
|
306
|
+
async function writeTree(cwd) {
|
|
307
|
+
return run(["write-tree"], cwd);
|
|
308
|
+
}
|
|
309
|
+
async function stagePathsFromTree(cwd, tree, files) {
|
|
310
|
+
if (files.length === 0) return;
|
|
311
|
+
await run(["reset", "-q", tree, "--", ...files], cwd);
|
|
312
|
+
}
|
|
313
|
+
async function commit(cwd, message) {
|
|
314
|
+
return run(["commit", "-m", message], cwd);
|
|
315
|
+
}
|
|
316
|
+
async function status(cwd) {
|
|
317
|
+
debug("git command", { args: ["status", "--porcelain"], cwd });
|
|
318
|
+
try {
|
|
319
|
+
const result = await exec(
|
|
320
|
+
"git",
|
|
321
|
+
["status", "--porcelain"],
|
|
322
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }
|
|
323
|
+
);
|
|
324
|
+
return result.stdout.replace(/\n+$/, "");
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err instanceof Error && "killed" in err && err.killed) {
|
|
327
|
+
throw new GitwiseError({
|
|
328
|
+
code: "GIT_FAILED",
|
|
329
|
+
message: `Git command timed out after ${GIT_TIMEOUT_MS / 1e3}s: git status --porcelain`,
|
|
330
|
+
cause: err,
|
|
331
|
+
details: { command: "git status --porcelain", timedOut: true }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const stderr = execStderr(err);
|
|
335
|
+
throw new GitwiseError({
|
|
336
|
+
code: "GIT_FAILED",
|
|
337
|
+
message: err instanceof Error ? err.message : String(err),
|
|
338
|
+
cause: err,
|
|
339
|
+
details: {
|
|
340
|
+
command: "git status --porcelain",
|
|
341
|
+
...stderr !== void 0 ? { stderr } : {}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function push(cwd, remote, branch) {
|
|
347
|
+
await run(["push", remote, branch], cwd);
|
|
348
|
+
}
|
|
349
|
+
async function fetch(cwd, remote) {
|
|
350
|
+
await run(["fetch", remote], cwd);
|
|
351
|
+
}
|
|
352
|
+
async function getChangedFiles(cwd) {
|
|
353
|
+
const files = await parseStatus(cwd);
|
|
354
|
+
return files.map((f) => f.file);
|
|
355
|
+
}
|
|
356
|
+
async function parseStatus(cwd) {
|
|
357
|
+
let result;
|
|
358
|
+
try {
|
|
359
|
+
result = await exec("git", ["status", "--porcelain"], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER });
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const stderr = execStderr(err);
|
|
362
|
+
throw new GitwiseError({
|
|
363
|
+
code: "GIT_FAILED",
|
|
364
|
+
message: `Failed to read git status: ${err instanceof Error ? err.message : String(err)}`,
|
|
365
|
+
cause: err,
|
|
366
|
+
details: {
|
|
367
|
+
command: "git status --porcelain",
|
|
368
|
+
...stderr !== void 0 ? { stderr } : {}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const output = result.stdout;
|
|
373
|
+
if (!output || !output.trim()) return [];
|
|
374
|
+
return output.split("\n").filter((line) => line.length >= 3).map((line) => {
|
|
375
|
+
const indexStatus = line[0];
|
|
376
|
+
const workTreeStatus = line[1];
|
|
377
|
+
let file = line.slice(3).trim();
|
|
378
|
+
if ((indexStatus === "R" || indexStatus === "C") && file.includes(" -> ")) {
|
|
379
|
+
file = file.split(" -> ").pop();
|
|
380
|
+
}
|
|
381
|
+
return { file, indexStatus, workTreeStatus };
|
|
382
|
+
}).filter((entry) => entry.file.length > 0);
|
|
383
|
+
}
|
|
384
|
+
async function getStagedFiles(cwd) {
|
|
385
|
+
const files = await parseStatus(cwd);
|
|
386
|
+
return files.filter((f) => f.indexStatus !== " " && f.indexStatus !== "?");
|
|
387
|
+
}
|
|
388
|
+
async function resetStaged(cwd) {
|
|
389
|
+
await run(["reset", "HEAD"], cwd);
|
|
390
|
+
}
|
|
391
|
+
async function getStagedFilesList(cwd) {
|
|
392
|
+
const output = await run(["diff", "--cached", "--name-only"], cwd);
|
|
393
|
+
if (!output) return [];
|
|
394
|
+
return output.split("\n").filter((f) => f.length > 0);
|
|
395
|
+
}
|
|
396
|
+
async function getUnstagedFiles(cwd) {
|
|
397
|
+
const files = await parseStatus(cwd);
|
|
398
|
+
return files.filter(
|
|
399
|
+
(f) => f.indexStatus === "?" && f.workTreeStatus === "?" || f.workTreeStatus !== " "
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
async function getLatestTag(cwd) {
|
|
403
|
+
try {
|
|
404
|
+
return await run(["describe", "--tags", "--abbrev=0"], cwd);
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function createTag(cwd, tag, message, options) {
|
|
410
|
+
const flag = options?.signed === true ? "-s" : "-a";
|
|
411
|
+
await run(["tag", flag, tag, "-m", message], cwd);
|
|
412
|
+
}
|
|
413
|
+
async function tagExists(cwd, tag) {
|
|
414
|
+
try {
|
|
415
|
+
await exec("git", ["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`], {
|
|
416
|
+
cwd,
|
|
417
|
+
timeout: GIT_TIMEOUT_MS
|
|
418
|
+
});
|
|
419
|
+
return true;
|
|
420
|
+
} catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function pushWithTags(cwd, remote, branch) {
|
|
425
|
+
await run(["push", remote, branch, "--follow-tags"], cwd);
|
|
426
|
+
}
|
|
427
|
+
async function mergeNoFf(cwd, source) {
|
|
428
|
+
await run(["merge", "--no-ff", source], cwd);
|
|
429
|
+
}
|
|
430
|
+
async function branchExists(cwd, branch) {
|
|
431
|
+
try {
|
|
432
|
+
await exec(
|
|
433
|
+
"git",
|
|
434
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
435
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }
|
|
436
|
+
);
|
|
437
|
+
return true;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async function headSha(cwd) {
|
|
443
|
+
return run(["rev-parse", "HEAD"], cwd);
|
|
444
|
+
}
|
|
445
|
+
async function resetSoft(cwd, ref) {
|
|
446
|
+
await run(["reset", "--soft", ref], cwd);
|
|
447
|
+
}
|
|
448
|
+
async function stashPushNamed(cwd, message) {
|
|
449
|
+
await run(["stash", "push", "--include-untracked", "-m", message], cwd);
|
|
450
|
+
}
|
|
451
|
+
async function stashList(cwd) {
|
|
452
|
+
return run(["stash", "list"], cwd);
|
|
453
|
+
}
|
|
454
|
+
async function findStashRef(cwd, stashName) {
|
|
455
|
+
const list = await stashList(cwd);
|
|
456
|
+
const line = list.split("\n").find((l) => l.includes(stashName));
|
|
457
|
+
if (!line) {
|
|
458
|
+
throw new GitwiseError({
|
|
459
|
+
code: "GIT_FAILED",
|
|
460
|
+
message: `Named stash not found in stash list: ${stashName}`,
|
|
461
|
+
details: { stashName }
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const match = /^(stash@\{\d+\})/.exec(line);
|
|
465
|
+
if (!match?.[1]) {
|
|
466
|
+
throw new GitwiseError({
|
|
467
|
+
code: "GIT_FAILED",
|
|
468
|
+
message: `Cannot parse stash ref from stash list line: ${line}`,
|
|
469
|
+
details: { stashName, line }
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
return match[1];
|
|
473
|
+
}
|
|
474
|
+
async function stashApplyNamed(cwd, stashName) {
|
|
475
|
+
const ref = await findStashRef(cwd, stashName);
|
|
476
|
+
await run(["stash", "apply", ref], cwd);
|
|
477
|
+
}
|
|
478
|
+
async function stashPopNamed(cwd, stashName) {
|
|
479
|
+
const ref = await findStashRef(cwd, stashName);
|
|
480
|
+
await run(["stash", "pop", ref], cwd);
|
|
481
|
+
}
|
|
482
|
+
async function stashDropNamed(cwd, stashName) {
|
|
483
|
+
const ref = await findStashRef(cwd, stashName);
|
|
484
|
+
await run(["stash", "drop", ref], cwd);
|
|
485
|
+
}
|
|
486
|
+
async function cleanForced(cwd) {
|
|
487
|
+
await run(["clean", "-fd"], cwd);
|
|
488
|
+
}
|
|
489
|
+
async function showFileAtHead(cwd, path3) {
|
|
490
|
+
debug("git command", { args: ["show", `HEAD:${path3}`], cwd });
|
|
491
|
+
try {
|
|
492
|
+
const result = await exec("git", ["show", `HEAD:${path3}`], {
|
|
493
|
+
cwd,
|
|
494
|
+
timeout: GIT_TIMEOUT_MS,
|
|
495
|
+
maxBuffer: GIT_MAX_BUFFER
|
|
496
|
+
});
|
|
497
|
+
return result.stdout;
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function deleteBranch(cwd, branch, force = false) {
|
|
503
|
+
await run(["branch", force ? "-D" : "-d", branch], cwd);
|
|
504
|
+
}
|
|
505
|
+
async function isBranchMerged(cwd, branch, target) {
|
|
506
|
+
try {
|
|
507
|
+
await exec(
|
|
508
|
+
"git",
|
|
509
|
+
["merge-base", "--is-ancestor", branch, target],
|
|
510
|
+
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER }
|
|
511
|
+
);
|
|
512
|
+
return true;
|
|
513
|
+
} catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function detectBaseBranch(cwd) {
|
|
518
|
+
try {
|
|
519
|
+
await exec("git", ["rev-parse", "--verify", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
|
|
520
|
+
return "main";
|
|
521
|
+
} catch {
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
await exec("git", ["rev-parse", "--verify", "master"], { cwd, timeout: GIT_TIMEOUT_MS });
|
|
525
|
+
return "master";
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
throw new GitwiseError({
|
|
529
|
+
code: "NO_BASE_BRANCH",
|
|
530
|
+
message: "No base branch found: neither main nor master exists",
|
|
531
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
async function applyCommit(params) {
|
|
535
|
+
const { message, files, cwd } = params;
|
|
536
|
+
try {
|
|
537
|
+
if (files.length > 0) {
|
|
538
|
+
await add(cwd, files);
|
|
539
|
+
}
|
|
540
|
+
await commit(cwd, message);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
543
|
+
const stderr = execStderr(err);
|
|
544
|
+
throw new GitwiseError({
|
|
545
|
+
code: "COMMIT_HOOK_FAILURE",
|
|
546
|
+
message: `Git commit failed: ${msg}`,
|
|
547
|
+
exitCode: EXIT_CODES.GIT_FAILED,
|
|
548
|
+
cause: err,
|
|
549
|
+
details: stderr !== void 0 ? { stderr } : void 0
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// src/infra/github.ts
|
|
555
|
+
var github_exports = {};
|
|
556
|
+
__export(github_exports, {
|
|
557
|
+
createGitHubRelease: () => createGitHubRelease,
|
|
558
|
+
createPR: () => createPR,
|
|
559
|
+
getGhVersion: () => getGhVersion,
|
|
560
|
+
getPrUrl: () => getPrUrl,
|
|
561
|
+
isGhAvailable: () => isGhAvailable,
|
|
562
|
+
openPr: () => openPr,
|
|
563
|
+
updatePR: () => updatePR
|
|
564
|
+
});
|
|
565
|
+
import { execFile as execFile2 } from "child_process";
|
|
566
|
+
import { promisify as promisify2 } from "util";
|
|
567
|
+
var exec2 = promisify2(execFile2);
|
|
568
|
+
async function isGhAvailable() {
|
|
569
|
+
try {
|
|
570
|
+
await exec2("gh", ["--version"]);
|
|
571
|
+
return true;
|
|
572
|
+
} catch {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function getGhVersion() {
|
|
577
|
+
try {
|
|
578
|
+
const result = await exec2("gh", ["--version"]);
|
|
579
|
+
const firstLine = result.stdout.split("\n")[0] ?? "";
|
|
580
|
+
return firstLine.trim() || null;
|
|
581
|
+
} catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function createPR(params) {
|
|
586
|
+
debug("Creating PR via gh", { title: params.title });
|
|
587
|
+
const args = ["pr", "create", "--title", params.title, "--body", params.body];
|
|
588
|
+
if (params.base) {
|
|
589
|
+
args.push("--base", params.base);
|
|
590
|
+
}
|
|
591
|
+
if (params.draft) {
|
|
592
|
+
args.push("--draft");
|
|
593
|
+
}
|
|
594
|
+
const result = await exec2("gh", args, { cwd: params.cwd });
|
|
595
|
+
const url = result.stdout?.trim();
|
|
596
|
+
if (!url) {
|
|
597
|
+
throw new GitwiseError({
|
|
598
|
+
code: "GH_FAILED",
|
|
599
|
+
message: "gh pr create returned empty output \u2014 check gh auth status",
|
|
600
|
+
details: { command: "gh pr create" }
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return { url };
|
|
604
|
+
}
|
|
605
|
+
async function updatePR(params) {
|
|
606
|
+
debug("Updating PR via gh", { prNumber: params.prNumber });
|
|
607
|
+
const args = ["pr", "edit", String(params.prNumber)];
|
|
608
|
+
if (params.title) args.push("--title", params.title);
|
|
609
|
+
if (params.body) args.push("--body", params.body);
|
|
610
|
+
await exec2("gh", args, { cwd: params.cwd });
|
|
611
|
+
const url = await getPrUrl(params.prNumber, params.cwd);
|
|
612
|
+
return { url };
|
|
613
|
+
}
|
|
614
|
+
async function getPrUrl(prNumber, cwd) {
|
|
615
|
+
const result = await exec2(
|
|
616
|
+
"gh",
|
|
617
|
+
["pr", "view", String(prNumber), "--json", "url", "-q", ".url"],
|
|
618
|
+
{ cwd }
|
|
619
|
+
);
|
|
620
|
+
const url = result.stdout?.trim();
|
|
621
|
+
if (!url) {
|
|
622
|
+
throw new GitwiseError({
|
|
623
|
+
code: "GH_FAILED",
|
|
624
|
+
message: `gh pr view ${prNumber} returned empty output \u2014 check gh auth status`,
|
|
625
|
+
details: { command: `gh pr view ${prNumber}` }
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return url;
|
|
629
|
+
}
|
|
630
|
+
async function createGitHubRelease(params) {
|
|
631
|
+
debug("Creating GitHub release via gh", { tag: params.tag });
|
|
632
|
+
const args = [
|
|
633
|
+
"release",
|
|
634
|
+
"create",
|
|
635
|
+
params.tag,
|
|
636
|
+
"--title",
|
|
637
|
+
params.title,
|
|
638
|
+
"--notes",
|
|
639
|
+
params.body
|
|
640
|
+
];
|
|
641
|
+
const result = await exec2("gh", args, { cwd: params.cwd });
|
|
642
|
+
const url = result.stdout?.trim();
|
|
643
|
+
if (!url) {
|
|
644
|
+
throw new GitwiseError({
|
|
645
|
+
code: "GH_FAILED",
|
|
646
|
+
message: "gh release create returned empty output \u2014 check gh auth status",
|
|
647
|
+
details: { command: "gh release create" }
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return { url };
|
|
651
|
+
}
|
|
652
|
+
var openPr = createPR;
|
|
653
|
+
|
|
654
|
+
// src/infra/env.ts
|
|
655
|
+
var env_exports = {};
|
|
656
|
+
__export(env_exports, {
|
|
657
|
+
loadEnv: () => loadEnv,
|
|
658
|
+
read: () => read,
|
|
659
|
+
readEnvVar: () => readEnvVar,
|
|
660
|
+
writeEnvVar: () => writeEnvVar
|
|
661
|
+
});
|
|
662
|
+
import { readFile as readFile2, open, rename, unlink } from "fs/promises";
|
|
663
|
+
import { join } from "path";
|
|
664
|
+
var ENV_DIR = ".gitwise";
|
|
665
|
+
var ENV_FILE = ".env";
|
|
666
|
+
function getEnvPath(projectRoot) {
|
|
667
|
+
return join(projectRoot, ENV_DIR, ENV_FILE);
|
|
668
|
+
}
|
|
669
|
+
function parseLine(line) {
|
|
670
|
+
const trimmed = line.trim();
|
|
671
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
672
|
+
const eq = trimmed.indexOf("=");
|
|
673
|
+
if (eq < 1) return null;
|
|
674
|
+
return [trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim()];
|
|
675
|
+
}
|
|
676
|
+
async function loadEnv(projectRoot) {
|
|
677
|
+
const envPath = getEnvPath(projectRoot);
|
|
678
|
+
if (!await fileExists(envPath)) return;
|
|
679
|
+
const content = await readFile2(envPath, "utf-8");
|
|
680
|
+
for (const line of content.split("\n")) {
|
|
681
|
+
const parsed = parseLine(line);
|
|
682
|
+
if (!parsed) continue;
|
|
683
|
+
const [key, value] = parsed;
|
|
684
|
+
if (process.env[key] === void 0) {
|
|
685
|
+
process.env[key] = value;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
async function writeEnvVar(projectRoot, key, value) {
|
|
690
|
+
const envPath = getEnvPath(projectRoot);
|
|
691
|
+
await ensureDir(join(projectRoot, ENV_DIR));
|
|
692
|
+
let lines = [];
|
|
693
|
+
if (await fileExists(envPath)) {
|
|
694
|
+
const content = await readFile2(envPath, "utf-8");
|
|
695
|
+
lines = content.split("\n");
|
|
696
|
+
}
|
|
697
|
+
const prefix = `${key}=`;
|
|
698
|
+
const idx = lines.findIndex((l) => l.trim().startsWith(prefix));
|
|
699
|
+
const entry = `${key}=${value}`;
|
|
700
|
+
if (idx >= 0) {
|
|
701
|
+
lines[idx] = entry;
|
|
702
|
+
} else {
|
|
703
|
+
if (lines.length === 1 && lines[0] === "") {
|
|
704
|
+
lines[0] = entry;
|
|
705
|
+
} else {
|
|
706
|
+
lines.push(entry);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const final = lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
710
|
+
const payload = final.endsWith("\n") ? final : final + "\n";
|
|
711
|
+
const tmpPath = `${envPath}.${process.pid}.${Date.now()}.tmp`;
|
|
712
|
+
const fd = await open(tmpPath, "w", 384);
|
|
713
|
+
try {
|
|
714
|
+
await fd.writeFile(payload, "utf-8");
|
|
715
|
+
} finally {
|
|
716
|
+
await fd.close();
|
|
717
|
+
}
|
|
718
|
+
try {
|
|
719
|
+
await rename(tmpPath, envPath);
|
|
720
|
+
} catch (err) {
|
|
721
|
+
await unlink(tmpPath).catch(() => void 0);
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function readEnvVar(projectRoot, key) {
|
|
726
|
+
const envPath = getEnvPath(projectRoot);
|
|
727
|
+
if (!await fileExists(envPath)) return void 0;
|
|
728
|
+
const content = await readFile2(envPath, "utf-8");
|
|
729
|
+
for (const line of content.split("\n")) {
|
|
730
|
+
const parsed = parseLine(line);
|
|
731
|
+
if (parsed && parsed[0] === key) return parsed[1];
|
|
732
|
+
}
|
|
733
|
+
return void 0;
|
|
734
|
+
}
|
|
735
|
+
async function read(key, projectRoot) {
|
|
736
|
+
if (process.env[key] !== void 0) {
|
|
737
|
+
return process.env[key];
|
|
738
|
+
}
|
|
739
|
+
if (projectRoot) {
|
|
740
|
+
return readEnvVar(projectRoot, key);
|
|
741
|
+
}
|
|
742
|
+
return void 0;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/infra/transaction.ts
|
|
746
|
+
var Transaction = class {
|
|
747
|
+
applied = [];
|
|
748
|
+
async run(step) {
|
|
749
|
+
const result = await step.apply();
|
|
750
|
+
this.applied.push({ step, result });
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
get size() {
|
|
754
|
+
return this.applied.length;
|
|
755
|
+
}
|
|
756
|
+
async rollback(reason, logger) {
|
|
757
|
+
const failures = [];
|
|
758
|
+
for (const { step, result } of [...this.applied].reverse()) {
|
|
759
|
+
try {
|
|
760
|
+
await step.compensate(result);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
failures.push({ step: step.name, error: err });
|
|
763
|
+
logger.warn("compensate-failed", {
|
|
764
|
+
step: step.name,
|
|
765
|
+
reason: serializeError(err)
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (failures.length > 0) {
|
|
770
|
+
logger.warn("rollback partial: one or more compensate actions failed", {
|
|
771
|
+
code: "ROLLBACK_PARTIAL",
|
|
772
|
+
originalCode: reason.code,
|
|
773
|
+
failures: failures.map((f) => ({
|
|
774
|
+
step: f.step,
|
|
775
|
+
error: serializeError(f.error)
|
|
776
|
+
}))
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return { partial: failures.length > 0, failures };
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
function serializeError(err) {
|
|
783
|
+
if (err instanceof Error) {
|
|
784
|
+
return { name: err.name, message: err.message };
|
|
785
|
+
}
|
|
786
|
+
return err;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/infra/lockfile.ts
|
|
790
|
+
import { mkdir as mkdir2, open as open2, readFile as readFile3, unlink as unlink2 } from "fs/promises";
|
|
791
|
+
import { hostname } from "os";
|
|
792
|
+
import path from "path";
|
|
793
|
+
var STALE_LOCK_MS = 10 * 60 * 1e3;
|
|
794
|
+
async function acquireRepoLock(repoPath, options = {}) {
|
|
795
|
+
const command = options.command ?? "unknown";
|
|
796
|
+
const staleMs = options.staleMs ?? STALE_LOCK_MS;
|
|
797
|
+
const isAlive = options.isProcessAlive ?? defaultIsProcessAlive;
|
|
798
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
799
|
+
const dir = path.join(repoPath, ".gitwise");
|
|
800
|
+
const lockPath = path.join(dir, ".lock");
|
|
801
|
+
await mkdir2(dir, { recursive: true });
|
|
802
|
+
const payload = {
|
|
803
|
+
pid: process.pid,
|
|
804
|
+
host: hostname(),
|
|
805
|
+
command,
|
|
806
|
+
acquiredAt: now().toISOString()
|
|
807
|
+
};
|
|
808
|
+
await tryAcquire(lockPath, payload, staleMs, isAlive, now, 0, options.onReclaim);
|
|
809
|
+
let released = false;
|
|
810
|
+
return async () => {
|
|
811
|
+
if (released) return;
|
|
812
|
+
released = true;
|
|
813
|
+
try {
|
|
814
|
+
await unlink2(lockPath);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
if (err.code !== "ENOENT") throw err;
|
|
817
|
+
}
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
async function tryAcquire(lockPath, payload, staleMs, isAlive, now, attempt, onReclaim) {
|
|
821
|
+
try {
|
|
822
|
+
const handle = await open2(lockPath, "wx");
|
|
823
|
+
try {
|
|
824
|
+
await handle.writeFile(JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
825
|
+
} finally {
|
|
826
|
+
await handle.close();
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
} catch (err) {
|
|
830
|
+
if (err.code !== "EEXIST") throw err;
|
|
831
|
+
}
|
|
832
|
+
if (attempt >= 1) {
|
|
833
|
+
throw new GitwiseError({
|
|
834
|
+
code: "REPO_LOCKED",
|
|
835
|
+
message: "Another gitwise process holds the lock on this repository",
|
|
836
|
+
details: { lockPath }
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
const existing = await readExisting(lockPath);
|
|
840
|
+
if (existing && !isStale(existing, staleMs, isAlive, now)) {
|
|
841
|
+
throw new GitwiseError({
|
|
842
|
+
code: "REPO_LOCKED",
|
|
843
|
+
message: `gitwise lock held by pid ${existing.pid} (command: ${existing.command}) since ${existing.acquiredAt}`,
|
|
844
|
+
details: { existing, lockPath }
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
await unlink2(lockPath);
|
|
849
|
+
} catch (unlinkErr) {
|
|
850
|
+
if (unlinkErr.code !== "ENOENT") throw unlinkErr;
|
|
851
|
+
}
|
|
852
|
+
if (onReclaim) await onReclaim();
|
|
853
|
+
return tryAcquire(lockPath, payload, staleMs, isAlive, now, attempt + 1, onReclaim);
|
|
854
|
+
}
|
|
855
|
+
async function readExisting(lockPath) {
|
|
856
|
+
try {
|
|
857
|
+
const content = await readFile3(lockPath, "utf-8");
|
|
858
|
+
const parsed = JSON.parse(content);
|
|
859
|
+
if (typeof parsed.pid !== "number" || typeof parsed.host !== "string" || typeof parsed.command !== "string" || typeof parsed.acquiredAt !== "string") {
|
|
860
|
+
return null;
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
pid: parsed.pid,
|
|
864
|
+
host: parsed.host,
|
|
865
|
+
command: parsed.command,
|
|
866
|
+
acquiredAt: parsed.acquiredAt
|
|
867
|
+
};
|
|
868
|
+
} catch {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
function isStale(existing, staleMs, isAlive, now) {
|
|
873
|
+
if (!isAlive(existing.pid)) return true;
|
|
874
|
+
const acquiredAt = Date.parse(existing.acquiredAt);
|
|
875
|
+
if (Number.isNaN(acquiredAt)) return true;
|
|
876
|
+
const age = now().getTime() - acquiredAt;
|
|
877
|
+
return age > staleMs;
|
|
878
|
+
}
|
|
879
|
+
function defaultIsProcessAlive(pid) {
|
|
880
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
881
|
+
try {
|
|
882
|
+
process.kill(pid, 0);
|
|
883
|
+
return true;
|
|
884
|
+
} catch (err) {
|
|
885
|
+
const code = err.code;
|
|
886
|
+
if (code === "ESRCH") return false;
|
|
887
|
+
if (code === "EPERM") return true;
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/providers/claude-code.ts
|
|
893
|
+
import { execFile as execFile3, execSync, spawn } from "child_process";
|
|
894
|
+
import fs from "fs";
|
|
895
|
+
import os from "os";
|
|
896
|
+
import path2 from "path";
|
|
897
|
+
import { promisify as promisify3 } from "util";
|
|
898
|
+
var execFileAsync = promisify3(execFile3);
|
|
899
|
+
var LARGE_PROMPT_THRESHOLD = 1e5;
|
|
900
|
+
var DEFAULT_TIMEOUT_MS = 12e4;
|
|
901
|
+
var COMMON_CLAUDE_PATHS = [
|
|
902
|
+
// Native installs (Homebrew, manual) — preferred over npm
|
|
903
|
+
"/opt/homebrew/bin/claude",
|
|
904
|
+
"/usr/local/bin/claude",
|
|
905
|
+
path2.join(os.homedir(), ".claude", "local", "claude"),
|
|
906
|
+
// npm global installs — fallback
|
|
907
|
+
path2.join(os.homedir(), ".npm-global", "bin", "claude")
|
|
908
|
+
];
|
|
909
|
+
function isExecutable(filePath) {
|
|
910
|
+
try {
|
|
911
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
912
|
+
return true;
|
|
913
|
+
} catch {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function resolveClaudeBinary(customPath) {
|
|
918
|
+
if (customPath) {
|
|
919
|
+
if (isExecutable(customPath)) return customPath;
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
for (const candidate of COMMON_CLAUDE_PATHS) {
|
|
923
|
+
if (isExecutable(candidate)) return candidate;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
const found = execSync("which claude", { stdio: "pipe" }).toString().trim();
|
|
927
|
+
if (found && isExecutable(found)) return found;
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
const nvmDir = path2.join(os.homedir(), ".nvm", "versions", "node");
|
|
931
|
+
try {
|
|
932
|
+
const versions = fs.readdirSync(nvmDir);
|
|
933
|
+
for (const version2 of versions) {
|
|
934
|
+
const candidate = path2.join(nvmDir, version2, "bin", "claude");
|
|
935
|
+
if (isExecutable(candidate)) return candidate;
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
}
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
var ClaudeCodeProvider = class {
|
|
942
|
+
models;
|
|
943
|
+
claudeBinaryPath;
|
|
944
|
+
constructor(models, claudeCliPath) {
|
|
945
|
+
this.models = models;
|
|
946
|
+
this.claudeBinaryPath = claudeCliPath ?? resolveClaudeBinary() ?? "claude";
|
|
947
|
+
}
|
|
948
|
+
async chat(req) {
|
|
949
|
+
const modelId = this.resolveModel(req.tier);
|
|
950
|
+
debug("Calling Claude Code CLI", { model: modelId, tier: req.tier, binary: this.claudeBinaryPath });
|
|
951
|
+
const userContent = req.userMessage;
|
|
952
|
+
const args = this.buildArgs(req.systemPrompt, modelId, userContent);
|
|
953
|
+
const result = userContent.length > LARGE_PROMPT_THRESHOLD ? await this.callViaStdin(args, userContent) : await this.callViaCli(args);
|
|
954
|
+
return {
|
|
955
|
+
content: result.result,
|
|
956
|
+
tokens: {
|
|
957
|
+
input: result.usage.input_tokens,
|
|
958
|
+
output: result.usage.output_tokens
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
buildArgs(systemPrompt, modelId, userContent) {
|
|
963
|
+
const args = [
|
|
964
|
+
"-p",
|
|
965
|
+
...userContent.length <= LARGE_PROMPT_THRESHOLD ? [userContent] : [],
|
|
966
|
+
"--system-prompt",
|
|
967
|
+
systemPrompt,
|
|
968
|
+
"--model",
|
|
969
|
+
modelId,
|
|
970
|
+
"--output-format",
|
|
971
|
+
"json"
|
|
972
|
+
];
|
|
973
|
+
return args;
|
|
974
|
+
}
|
|
975
|
+
async callViaCli(args) {
|
|
976
|
+
try {
|
|
977
|
+
const { stdout } = await execFileAsync(this.claudeBinaryPath, args, {
|
|
978
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
979
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
980
|
+
...{ input: "" }
|
|
981
|
+
});
|
|
982
|
+
return this.parseResponse(stdout);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
const execErr = err;
|
|
985
|
+
if (execErr.stdout) {
|
|
986
|
+
try {
|
|
987
|
+
const parsed = JSON.parse(execErr.stdout);
|
|
988
|
+
if (parsed.is_error) {
|
|
989
|
+
throw new Error(`Claude CLI error: ${parsed.result}`);
|
|
990
|
+
}
|
|
991
|
+
} catch (parseErr) {
|
|
992
|
+
if (parseErr instanceof Error && parseErr.message.startsWith("Claude CLI error:")) {
|
|
993
|
+
throw parseErr;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const stderr = execErr.stderr?.replace(/Warning: no stdin data.*\n?/g, "").trim();
|
|
998
|
+
if (stderr) {
|
|
999
|
+
throw new Error(`Claude CLI failed: ${stderr}`);
|
|
1000
|
+
}
|
|
1001
|
+
throw this.wrapError(err);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async callViaStdin(args, input) {
|
|
1005
|
+
return new Promise((resolve, reject) => {
|
|
1006
|
+
const child = spawn(this.claudeBinaryPath, args, {
|
|
1007
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1008
|
+
timeout: DEFAULT_TIMEOUT_MS
|
|
1009
|
+
});
|
|
1010
|
+
let stdout = "";
|
|
1011
|
+
let stderr = "";
|
|
1012
|
+
child.stdout.on("data", (data) => {
|
|
1013
|
+
stdout += data.toString();
|
|
1014
|
+
});
|
|
1015
|
+
child.stderr.on("data", (data) => {
|
|
1016
|
+
stderr += data.toString();
|
|
1017
|
+
});
|
|
1018
|
+
child.on("close", (code) => {
|
|
1019
|
+
if (code !== 0) {
|
|
1020
|
+
if (stdout) {
|
|
1021
|
+
try {
|
|
1022
|
+
const parsed = JSON.parse(stdout);
|
|
1023
|
+
if (parsed.is_error) {
|
|
1024
|
+
reject(new Error(`Claude CLI error: ${parsed.result}`));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
} catch {
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const filteredStderr = stderr.replace(/Warning: no stdin data.*\n?/g, "").trim();
|
|
1031
|
+
reject(
|
|
1032
|
+
new Error(
|
|
1033
|
+
`Claude CLI exited with code ${code}${filteredStderr ? `: ${filteredStderr}` : ""}`
|
|
1034
|
+
)
|
|
1035
|
+
);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
try {
|
|
1039
|
+
resolve(this.parseResponse(stdout));
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
reject(err);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
child.on("error", (err) => {
|
|
1045
|
+
reject(this.wrapError(err));
|
|
1046
|
+
});
|
|
1047
|
+
child.stdin.write(input);
|
|
1048
|
+
child.stdin.end();
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
parseResponse(stdout) {
|
|
1052
|
+
const parsed = JSON.parse(stdout);
|
|
1053
|
+
if (parsed.is_error) {
|
|
1054
|
+
throw new Error(`Claude CLI returned error: ${parsed.result}`);
|
|
1055
|
+
}
|
|
1056
|
+
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
1057
|
+
if (parsed.usage) {
|
|
1058
|
+
usage.input_tokens = parsed.usage.input_tokens ?? 0;
|
|
1059
|
+
usage.output_tokens = parsed.usage.output_tokens ?? 0;
|
|
1060
|
+
}
|
|
1061
|
+
return {
|
|
1062
|
+
result: parsed.result ?? "",
|
|
1063
|
+
is_error: false,
|
|
1064
|
+
usage
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
resolveModel(tier) {
|
|
1068
|
+
return this.models[tier];
|
|
1069
|
+
}
|
|
1070
|
+
wrapError(err) {
|
|
1071
|
+
if (err instanceof Error) {
|
|
1072
|
+
if (err.message.includes("ENOENT")) {
|
|
1073
|
+
return new GitwiseError({
|
|
1074
|
+
code: "PROVIDER_UNAVAILABLE",
|
|
1075
|
+
message: `Claude Code CLI not found at "${this.claudeBinaryPath}". Re-run \`gw config\` to reconfigure.`,
|
|
1076
|
+
exitCode: EXIT_CODES.API_FAILED,
|
|
1077
|
+
cause: err
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
return err;
|
|
1081
|
+
}
|
|
1082
|
+
return new Error(String(err));
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
// src/config/types.ts
|
|
1087
|
+
var DEFAULT_USER_CONFIG = {
|
|
1088
|
+
provider: "api",
|
|
1089
|
+
models: {
|
|
1090
|
+
fast: "claude-haiku-4-5-20251001",
|
|
1091
|
+
balanced: "claude-sonnet-4-6",
|
|
1092
|
+
powerful: "claude-opus-4-7"
|
|
1093
|
+
},
|
|
1094
|
+
language: "en",
|
|
1095
|
+
commitConvention: "conventional"
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// src/config/merge.ts
|
|
1099
|
+
import os3 from "os";
|
|
1100
|
+
|
|
1101
|
+
// src/config/user.ts
|
|
1102
|
+
import { join as join2 } from "path";
|
|
1103
|
+
import os2 from "os";
|
|
1104
|
+
var GITWISE_DIR = ".gitwise";
|
|
1105
|
+
var USER_CONFIG_FILE = "config.json";
|
|
1106
|
+
function getUserConfigPath(homeDir) {
|
|
1107
|
+
return join2(homeDir ?? os2.homedir(), GITWISE_DIR, USER_CONFIG_FILE);
|
|
1108
|
+
}
|
|
1109
|
+
function mergeWithDefaults(partial) {
|
|
1110
|
+
return {
|
|
1111
|
+
...DEFAULT_USER_CONFIG,
|
|
1112
|
+
...partial,
|
|
1113
|
+
models: {
|
|
1114
|
+
...DEFAULT_USER_CONFIG.models,
|
|
1115
|
+
...partial.models ?? {}
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
async function readUserConfig(homeDir) {
|
|
1120
|
+
const configPath = getUserConfigPath(homeDir);
|
|
1121
|
+
if (!await fileExists(configPath)) {
|
|
1122
|
+
debug("User config not found, using defaults", { path: configPath });
|
|
1123
|
+
return { ...DEFAULT_USER_CONFIG };
|
|
1124
|
+
}
|
|
1125
|
+
const raw = await readJSON(configPath);
|
|
1126
|
+
return mergeWithDefaults(raw);
|
|
1127
|
+
}
|
|
1128
|
+
async function writeUserConfig(partial, homeDir) {
|
|
1129
|
+
const configPath = getUserConfigPath(homeDir);
|
|
1130
|
+
const existing = await readUserConfig(homeDir);
|
|
1131
|
+
const updated = mergeWithDefaults({ ...existing, ...partial });
|
|
1132
|
+
debug("Writing user config", { path: configPath });
|
|
1133
|
+
await writeJSON(configPath, updated);
|
|
1134
|
+
}
|
|
1135
|
+
async function writeApiKey(value, homeDir) {
|
|
1136
|
+
const home = homeDir ?? os2.homedir();
|
|
1137
|
+
await writeEnvVar(home, "ANTHROPIC_API_KEY", value);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/config/repo.ts
|
|
1141
|
+
import { join as join3 } from "path";
|
|
1142
|
+
var REPO_CONFIG_FILE = ".gitwise.json";
|
|
1143
|
+
async function readRepoConfig(cwd) {
|
|
1144
|
+
const configPath = join3(cwd, REPO_CONFIG_FILE);
|
|
1145
|
+
if (!await fileExists(configPath)) {
|
|
1146
|
+
debug("Repo config not found", { path: configPath });
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
const raw = await readJSON(configPath);
|
|
1151
|
+
return raw;
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
throw new GitwiseError({
|
|
1154
|
+
code: "INVALID_REPO_CONFIG",
|
|
1155
|
+
message: `Invalid repo config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1156
|
+
exitCode: EXIT_CODES.CONFIG_INVALID,
|
|
1157
|
+
cause: err
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// src/config/merge.ts
|
|
1163
|
+
function deepMerge(base, override) {
|
|
1164
|
+
return {
|
|
1165
|
+
...base,
|
|
1166
|
+
...override.language !== void 0 && { language: override.language },
|
|
1167
|
+
...override.defaultBaseBranch !== void 0 && { defaultBaseBranch: override.defaultBaseBranch },
|
|
1168
|
+
...override.commitConvention !== void 0 && { commitConvention: override.commitConvention },
|
|
1169
|
+
...override.templatesPath !== void 0 && { templatesPath: override.templatesPath },
|
|
1170
|
+
...override.releaseStrategy !== void 0 && { releaseStrategy: override.releaseStrategy },
|
|
1171
|
+
...override.developBranch !== void 0 && { developBranch: override.developBranch },
|
|
1172
|
+
models: {
|
|
1173
|
+
...base.models,
|
|
1174
|
+
...override.models ?? {}
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
async function getMergedConfig(options) {
|
|
1179
|
+
const { cwd, homeDir } = options;
|
|
1180
|
+
const userConfig = await readUserConfig(homeDir);
|
|
1181
|
+
const repoConfig = await readRepoConfig(cwd);
|
|
1182
|
+
if (!repoConfig) {
|
|
1183
|
+
return userConfig;
|
|
1184
|
+
}
|
|
1185
|
+
return deepMerge(userConfig, repoConfig);
|
|
1186
|
+
}
|
|
1187
|
+
async function getApiKey(homeDir) {
|
|
1188
|
+
const home = homeDir ?? os3.homedir();
|
|
1189
|
+
return read("ANTHROPIC_API_KEY", home);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// src/template/loader.ts
|
|
1193
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1194
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
1195
|
+
import { fileURLToPath } from "url";
|
|
1196
|
+
import os4 from "os";
|
|
1197
|
+
|
|
1198
|
+
// src/template/interpolate.ts
|
|
1199
|
+
function interpolate(template, ctx) {
|
|
1200
|
+
return template.replace(
|
|
1201
|
+
/\{\{(\w+)\}\}/g,
|
|
1202
|
+
(_match, key) => ctx[key] ?? _match
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// src/template/loader.ts
|
|
1207
|
+
var __dirname2 = dirname2(fileURLToPath(import.meta.url));
|
|
1208
|
+
var BUNDLED_TEMPLATES_CANDIDATES = [
|
|
1209
|
+
join4(__dirname2, "..", "templates"),
|
|
1210
|
+
join4(__dirname2, "..", "..", "templates")
|
|
1211
|
+
];
|
|
1212
|
+
function validateTemplateName(name) {
|
|
1213
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
1214
|
+
throw new GitwiseError({
|
|
1215
|
+
code: "TEMPLATE_INVALID_NAME",
|
|
1216
|
+
message: `Invalid template name: '${name}'. Only alphanumeric characters, hyphens, and underscores are allowed.`,
|
|
1217
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function loadTemplate(name, options = {}) {
|
|
1222
|
+
validateTemplateName(name);
|
|
1223
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
1224
|
+
const userTemplatesPath = options.templatesPath ?? join4(os4.homedir(), ".gitwise", "templates");
|
|
1225
|
+
const repoOverride = join4(repoRoot, ".gitwise", "templates", `${name}.md`);
|
|
1226
|
+
if (await fileExists(repoOverride)) {
|
|
1227
|
+
debug("Loading repo-level template override", { path: repoOverride });
|
|
1228
|
+
return readFile4(repoOverride, "utf-8");
|
|
1229
|
+
}
|
|
1230
|
+
const userOverride = join4(userTemplatesPath, `${name}.md`);
|
|
1231
|
+
if (await fileExists(userOverride)) {
|
|
1232
|
+
debug("Loading user-global template override", { path: userOverride });
|
|
1233
|
+
return readFile4(userOverride, "utf-8");
|
|
1234
|
+
}
|
|
1235
|
+
for (const candidate of BUNDLED_TEMPLATES_CANDIDATES) {
|
|
1236
|
+
const bundled = join4(candidate, `${name}.md`);
|
|
1237
|
+
if (await fileExists(bundled)) {
|
|
1238
|
+
debug("Loading bundled template", { path: bundled });
|
|
1239
|
+
return readFile4(bundled, "utf-8");
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
throw new GitwiseError({
|
|
1243
|
+
code: "TEMPLATE_NOT_FOUND",
|
|
1244
|
+
message: `Template '${name}' not found`,
|
|
1245
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
async function loadAndInterpolate(name, ctx, options = {}) {
|
|
1249
|
+
const template = await loadTemplate(name, options);
|
|
1250
|
+
return interpolate(template, ctx);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/providers/model-router.ts
|
|
1254
|
+
var COMMAND_TIER_MAP = {
|
|
1255
|
+
commit: "fast",
|
|
1256
|
+
review: "powerful",
|
|
1257
|
+
pr: "fast",
|
|
1258
|
+
release: "fast"
|
|
1259
|
+
};
|
|
1260
|
+
function resolveModelTier(command) {
|
|
1261
|
+
return COMMAND_TIER_MAP[command] ?? "balanced";
|
|
1262
|
+
}
|
|
1263
|
+
var SUPPORTED_COMMANDS = Object.keys(COMMAND_TIER_MAP);
|
|
1264
|
+
|
|
1265
|
+
// src/commands/commit.ts
|
|
1266
|
+
var SENSITIVE_PATTERNS = [
|
|
1267
|
+
/^\.env$/,
|
|
1268
|
+
/^\.env\./,
|
|
1269
|
+
/\.pem$/,
|
|
1270
|
+
/\.key$/,
|
|
1271
|
+
/^id_rsa/,
|
|
1272
|
+
/^id_dsa/,
|
|
1273
|
+
/^id_ecdsa/,
|
|
1274
|
+
/^id_ed25519/,
|
|
1275
|
+
/credentials\.json$/,
|
|
1276
|
+
/secrets\.json$/,
|
|
1277
|
+
/auth\.json$/,
|
|
1278
|
+
/service-account\.json$/,
|
|
1279
|
+
/\.p12$/,
|
|
1280
|
+
/\.pfx$/,
|
|
1281
|
+
/\.pkcs12$/
|
|
1282
|
+
];
|
|
1283
|
+
var SAFE_ENV_TEMPLATE_SUFFIXES = [
|
|
1284
|
+
".example",
|
|
1285
|
+
".sample",
|
|
1286
|
+
".template",
|
|
1287
|
+
".dist",
|
|
1288
|
+
".defaults"
|
|
1289
|
+
];
|
|
1290
|
+
function isSafeEnvTemplate(basename) {
|
|
1291
|
+
if (!basename.startsWith(".env")) return false;
|
|
1292
|
+
return SAFE_ENV_TEMPLATE_SUFFIXES.some((suffix) => basename.endsWith(suffix));
|
|
1293
|
+
}
|
|
1294
|
+
function isSensitiveFile(filePath) {
|
|
1295
|
+
const basename = filePath.split("/").pop() ?? filePath;
|
|
1296
|
+
if (isSafeEnvTemplate(basename)) return false;
|
|
1297
|
+
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(basename));
|
|
1298
|
+
}
|
|
1299
|
+
function tryParseJson(text) {
|
|
1300
|
+
try {
|
|
1301
|
+
const parsed = JSON.parse(text.trim());
|
|
1302
|
+
if (parsed.type === "plan" && Array.isArray(parsed.commits)) {
|
|
1303
|
+
return parsed;
|
|
1304
|
+
}
|
|
1305
|
+
if (parsed.type === "single" && typeof parsed.message === "string") {
|
|
1306
|
+
return parsed;
|
|
1307
|
+
}
|
|
1308
|
+
} catch {
|
|
1309
|
+
}
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
function extractBalancedJsonCandidates(raw) {
|
|
1313
|
+
const candidates = [];
|
|
1314
|
+
const stack = [];
|
|
1315
|
+
let inString = false;
|
|
1316
|
+
let escape = false;
|
|
1317
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1318
|
+
const c = raw[i];
|
|
1319
|
+
if (inString) {
|
|
1320
|
+
if (escape) {
|
|
1321
|
+
escape = false;
|
|
1322
|
+
} else if (c === "\\") {
|
|
1323
|
+
escape = true;
|
|
1324
|
+
} else if (c === '"') {
|
|
1325
|
+
inString = false;
|
|
1326
|
+
}
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
if (c === '"') {
|
|
1330
|
+
inString = true;
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
if (c === "{") {
|
|
1334
|
+
stack.push(i);
|
|
1335
|
+
} else if (c === "}") {
|
|
1336
|
+
const start = stack.pop();
|
|
1337
|
+
if (start !== void 0) {
|
|
1338
|
+
candidates.push(raw.slice(start, i + 1));
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return candidates;
|
|
1343
|
+
}
|
|
1344
|
+
function parseAlternativesResponse(raw) {
|
|
1345
|
+
const isValid = (p) => typeof p === "object" && p !== null && p["type"] === "alternatives" && Array.isArray(p["options"]) && p["options"].length > 0 && p["options"].every((o) => typeof o === "string");
|
|
1346
|
+
try {
|
|
1347
|
+
const parsed = JSON.parse(raw.trim());
|
|
1348
|
+
if (isValid(parsed)) return parsed.options;
|
|
1349
|
+
} catch {
|
|
1350
|
+
}
|
|
1351
|
+
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1352
|
+
if (fence?.[1]) {
|
|
1353
|
+
try {
|
|
1354
|
+
const parsed = JSON.parse(fence[1].trim());
|
|
1355
|
+
if (isValid(parsed)) return parsed.options;
|
|
1356
|
+
} catch {
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
for (const line of raw.split("\n")) {
|
|
1360
|
+
const t = line.trim();
|
|
1361
|
+
if (!t.startsWith("{")) continue;
|
|
1362
|
+
try {
|
|
1363
|
+
const parsed = JSON.parse(t);
|
|
1364
|
+
if (isValid(parsed)) return parsed.options;
|
|
1365
|
+
} catch {
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return null;
|
|
1369
|
+
}
|
|
1370
|
+
function parseCommitResponse(raw) {
|
|
1371
|
+
const direct = tryParseJson(raw);
|
|
1372
|
+
if (direct) return direct;
|
|
1373
|
+
const fenceMatch = raw.match(/```json?\s*\n?([\s\S]*?)```/);
|
|
1374
|
+
if (fenceMatch?.[1]) {
|
|
1375
|
+
const fromFence = tryParseJson(fenceMatch[1]);
|
|
1376
|
+
if (fromFence) return fromFence;
|
|
1377
|
+
}
|
|
1378
|
+
const candidates = extractBalancedJsonCandidates(raw);
|
|
1379
|
+
const parsedCandidates = candidates.map(tryParseJson).filter((p) => p !== null);
|
|
1380
|
+
const plan = parsedCandidates.find((p) => p.type === "plan");
|
|
1381
|
+
if (plan) return plan;
|
|
1382
|
+
if (parsedCandidates[0]) return parsedCandidates[0];
|
|
1383
|
+
return { type: "single", message: raw.trim() };
|
|
1384
|
+
}
|
|
1385
|
+
var MAX_DIFF_CHARS = 8e4;
|
|
1386
|
+
function truncateDiff(diff) {
|
|
1387
|
+
if (diff.length <= MAX_DIFF_CHARS) return diff;
|
|
1388
|
+
return diff.slice(0, MAX_DIFF_CHARS) + "\n\n[diff truncated \u2014 too large for context window]";
|
|
1389
|
+
}
|
|
1390
|
+
var SYSTEM_PROMPT = `You are a developer writing commit messages. Analyze the git diff and the list of staged files to determine if the changes span one or multiple contexts.
|
|
1391
|
+
|
|
1392
|
+
Rules for commit messages:
|
|
1393
|
+
- Format: type(scope): description
|
|
1394
|
+
- Types: feat, fix, refactor, test, chore, style, docs
|
|
1395
|
+
- Description must be imperative, lowercase, max 72 chars
|
|
1396
|
+
- Scope is optional but recommended
|
|
1397
|
+
- Do NOT mention AI, Claude, generated, LLM, or copilot
|
|
1398
|
+
|
|
1399
|
+
Response format (JSON only, no extra text):
|
|
1400
|
+
|
|
1401
|
+
If all changes belong to a SINGLE context, return:
|
|
1402
|
+
{"type": "single", "message": "type(scope): description"}
|
|
1403
|
+
|
|
1404
|
+
If changes span MULTIPLE distinct contexts (e.g., a bug fix AND a new feature, or docs AND refactoring), return:
|
|
1405
|
+
{"type": "plan", "commits": [{"message": "type(scope): short title", "description": "brief explanation of what and why", "files": ["file1.ts"]}, {"message": "type(scope): short title", "description": "brief explanation of what and why", "files": ["file3.ts"]}]}
|
|
1406
|
+
|
|
1407
|
+
Rules for plan:
|
|
1408
|
+
- "message" is the commit title (max 72 chars, imperative, lowercase)
|
|
1409
|
+
- "description" is a brief one-line explanation of the change purpose
|
|
1410
|
+
- "files" lists only the files belonging to that commit
|
|
1411
|
+
- Every staged file must appear in exactly one commit \u2014 do not leave any file unassigned
|
|
1412
|
+
- Only return a plan when there are clearly separate concerns. Do not split for minor differences.`;
|
|
1413
|
+
async function commit2(opts) {
|
|
1414
|
+
const { cwd, provider, prompt, split = "auto" } = opts;
|
|
1415
|
+
const stagedFiles = await getStagedFilesList(cwd);
|
|
1416
|
+
const diff = await getStagedDiff(cwd);
|
|
1417
|
+
if (!diff) {
|
|
1418
|
+
throw new GitwiseError({
|
|
1419
|
+
code: "NOTHING_STAGED",
|
|
1420
|
+
message: "No staged changes to commit"
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
const sensitiveFiles = stagedFiles.filter(isSensitiveFile);
|
|
1424
|
+
if (sensitiveFiles.length > 0) {
|
|
1425
|
+
debug("Sensitive files blocked from commit", { files: sensitiveFiles });
|
|
1426
|
+
throw new GitwiseError({
|
|
1427
|
+
code: "SENSITIVE_FILE_BLOCKED",
|
|
1428
|
+
message: `SENSITIVE_FILE_BLOCKED: ${sensitiveFiles.length} file(s) matched sensitive patterns (env/pem/credentials). Re-run with --debug (or set GITWISE_DEBUG=1) to see which files were flagged.`,
|
|
1429
|
+
details: { files: sensitiveFiles }
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
let systemPrompt = SYSTEM_PROMPT;
|
|
1433
|
+
try {
|
|
1434
|
+
const templateContent = await loadTemplate("commit", {
|
|
1435
|
+
repoRoot: opts.repoRoot ?? cwd,
|
|
1436
|
+
templatesPath: opts.templatesPath
|
|
1437
|
+
});
|
|
1438
|
+
if (templateContent && !templateContent.includes("{{type}}")) {
|
|
1439
|
+
systemPrompt = templateContent;
|
|
1440
|
+
}
|
|
1441
|
+
} catch {
|
|
1442
|
+
}
|
|
1443
|
+
const effectiveSystemPrompt = opts.generateAlternatives ? `${systemPrompt}
|
|
1444
|
+
|
|
1445
|
+
When asked to generate alternatives, return ONLY this JSON (no other text):
|
|
1446
|
+
{"type": "alternatives", "options": ["message1", "message2", "message3"]}` : systemPrompt;
|
|
1447
|
+
const userMessage = [
|
|
1448
|
+
`Staged files:
|
|
1449
|
+
${stagedFiles.join("\n")}`,
|
|
1450
|
+
`
|
|
1451
|
+
Diff:
|
|
1452
|
+
${truncateDiff(diff)}`,
|
|
1453
|
+
prompt ? `
|
|
1454
|
+
User intent: ${prompt}` : "",
|
|
1455
|
+
opts.feedbackHint ? `
|
|
1456
|
+
User feedback on previous suggestion: ${opts.feedbackHint}` : "",
|
|
1457
|
+
opts.generateAlternatives ? `
|
|
1458
|
+
IMPORTANT: Generate exactly 3 different alternative commit messages. Return JSON only: {"type": "alternatives", "options": ["message1", "message2", "message3"]}` : ""
|
|
1459
|
+
].join("");
|
|
1460
|
+
debug("Calling LLM for commit analysis", { tier: "fast", fileCount: stagedFiles.length });
|
|
1461
|
+
const tier = resolveModelTier("commit");
|
|
1462
|
+
const response = await provider.chat({ systemPrompt: effectiveSystemPrompt, userMessage, tier });
|
|
1463
|
+
const parsed = parseCommitResponse(response.content);
|
|
1464
|
+
const tokens = { input: response.tokens.input, output: response.tokens.output };
|
|
1465
|
+
if (opts.generateAlternatives) {
|
|
1466
|
+
const options = parseAlternativesResponse(response.content);
|
|
1467
|
+
if (options && options.length > 0) {
|
|
1468
|
+
return { kind: "alternatives", options, tokens };
|
|
1469
|
+
}
|
|
1470
|
+
const fallbackMsg = parsed.type === "single" ? parsed.message : parsed.commits[0]?.message ?? response.content.trim().slice(0, 100);
|
|
1471
|
+
return { kind: "alternatives", options: [fallbackMsg], tokens };
|
|
1472
|
+
}
|
|
1473
|
+
if (split === "never") {
|
|
1474
|
+
const message2 = parsed.type === "single" ? parsed.message : parsed.commits.map((c) => c.message).join("\n\n");
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "single",
|
|
1477
|
+
commits: [{ message: message2, files: stagedFiles }],
|
|
1478
|
+
tokens
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
if (parsed.type === "plan" && parsed.commits.length > 1) {
|
|
1482
|
+
if (split === "always" || split === "auto") {
|
|
1483
|
+
const assignedFiles = new Set(parsed.commits.flatMap((c) => c.files));
|
|
1484
|
+
const missing = stagedFiles.filter((f) => !assignedFiles.has(f));
|
|
1485
|
+
if (missing.length > 0) {
|
|
1486
|
+
parsed.commits[parsed.commits.length - 1].files.push(...missing);
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
kind: "split",
|
|
1490
|
+
commits: parsed.commits,
|
|
1491
|
+
tokens
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
if (split === "always" && parsed.type !== "plan") {
|
|
1496
|
+
throw new GitwiseError({
|
|
1497
|
+
code: "NO_SPLIT_POSSIBLE",
|
|
1498
|
+
message: "split: 'always' requested but LLM returned a single-context plan",
|
|
1499
|
+
exitCode: EXIT_CODES.INVALID_INTENT
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const message = parsed.type === "single" ? parsed.message : parsed.commits[0]?.message ?? "chore: update";
|
|
1503
|
+
return {
|
|
1504
|
+
kind: "single",
|
|
1505
|
+
commits: [{ message, files: stagedFiles }],
|
|
1506
|
+
tokens
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
function takeNamedStashStep(cwd, stashName) {
|
|
1510
|
+
return {
|
|
1511
|
+
name: `takeNamedStash(${stashName})`,
|
|
1512
|
+
async apply() {
|
|
1513
|
+
await stashPushNamed(cwd, stashName);
|
|
1514
|
+
await stashApplyNamed(cwd, stashName);
|
|
1515
|
+
},
|
|
1516
|
+
async compensate() {
|
|
1517
|
+
await resetHard(cwd, "HEAD");
|
|
1518
|
+
await cleanForced(cwd);
|
|
1519
|
+
await stashPopNamed(cwd, stashName);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
function applyOneCommitStep(entry, cwd, stagedTree) {
|
|
1524
|
+
const msg = entry.description ? `${entry.message}
|
|
1525
|
+
|
|
1526
|
+
${entry.description}` : entry.message;
|
|
1527
|
+
return {
|
|
1528
|
+
name: `applyCommit(${entry.message})`,
|
|
1529
|
+
async apply() {
|
|
1530
|
+
const priorSha = await headSha(cwd);
|
|
1531
|
+
await stagePathsFromTree(cwd, stagedTree, entry.files);
|
|
1532
|
+
const staged = await getStagedFilesList(cwd);
|
|
1533
|
+
if (staged.length === 0) {
|
|
1534
|
+
debug("Skipping commit group with no staged changes", {
|
|
1535
|
+
message: entry.message,
|
|
1536
|
+
files: entry.files
|
|
1537
|
+
});
|
|
1538
|
+
return { priorSha, newSha: priorSha };
|
|
1539
|
+
}
|
|
1540
|
+
await applyCommit({ message: msg, files: [], cwd });
|
|
1541
|
+
const newSha = await headSha(cwd);
|
|
1542
|
+
return { priorSha, newSha };
|
|
1543
|
+
},
|
|
1544
|
+
async compensate({ priorSha }) {
|
|
1545
|
+
await resetSoft(cwd, priorSha);
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
async function applyCommitPlan(plan, opts) {
|
|
1550
|
+
const { cwd, push: shouldPush = false, remote = "origin" } = opts;
|
|
1551
|
+
if (plan.kind === "split") {
|
|
1552
|
+
if (plan.commits.length === 0) {
|
|
1553
|
+
throw new GitwiseError({
|
|
1554
|
+
code: "INVALID_INTENT",
|
|
1555
|
+
message: "Commit split plan has zero commits; cannot apply"
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
const stashName = `gitwise/split-${(/* @__PURE__ */ new Date()).toISOString()}`;
|
|
1559
|
+
const releaseLock = await acquireRepoLock(cwd, { command: "commit-split" });
|
|
1560
|
+
try {
|
|
1561
|
+
const tx = new Transaction();
|
|
1562
|
+
const logger = { warn };
|
|
1563
|
+
try {
|
|
1564
|
+
const stagedTree = await writeTree(cwd);
|
|
1565
|
+
await tx.run(takeNamedStashStep(cwd, stashName));
|
|
1566
|
+
await resetStaged(cwd);
|
|
1567
|
+
for (const entry of plan.commits) {
|
|
1568
|
+
await tx.run(applyOneCommitStep(entry, cwd, stagedTree));
|
|
1569
|
+
}
|
|
1570
|
+
await stashDropNamed(cwd, stashName);
|
|
1571
|
+
} catch (err) {
|
|
1572
|
+
const wrapped = err instanceof GitwiseError ? err : new GitwiseError({
|
|
1573
|
+
code: "GIT_FAILED",
|
|
1574
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1575
|
+
cause: err,
|
|
1576
|
+
details: { stderr: err instanceof Error ? err.message : String(err) }
|
|
1577
|
+
});
|
|
1578
|
+
await tx.rollback(wrapped, logger);
|
|
1579
|
+
throw wrapped;
|
|
1580
|
+
}
|
|
1581
|
+
} finally {
|
|
1582
|
+
await releaseLock();
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
const entry = plan.commits[0];
|
|
1586
|
+
if (!entry) return;
|
|
1587
|
+
const msg = entry.description ? `${entry.message}
|
|
1588
|
+
|
|
1589
|
+
${entry.description}` : entry.message;
|
|
1590
|
+
await applyCommit({ message: msg, files: [], cwd });
|
|
1591
|
+
}
|
|
1592
|
+
if (shouldPush) {
|
|
1593
|
+
const branch = await getBranch(cwd);
|
|
1594
|
+
await push(cwd, remote, branch);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/commands/review.ts
|
|
1599
|
+
var MAX_DIFF_CHARS2 = 8e4;
|
|
1600
|
+
var DEFAULT_REVIEW_TEMPLATE = `You are a senior code reviewer. Analyze the diff and produce a code review with findings in these categories:
|
|
1601
|
+
|
|
1602
|
+
## Critical
|
|
1603
|
+
Issues that must be fixed before merging (bugs, security, data loss).
|
|
1604
|
+
|
|
1605
|
+
## Suggestions
|
|
1606
|
+
Improvements worth considering (performance, readability, patterns).
|
|
1607
|
+
|
|
1608
|
+
## Nitpicks
|
|
1609
|
+
Minor style or convention issues.
|
|
1610
|
+
|
|
1611
|
+
For each finding, include:
|
|
1612
|
+
- File and line reference
|
|
1613
|
+
- Description of the issue
|
|
1614
|
+
- Suggested fix
|
|
1615
|
+
|
|
1616
|
+
End with a summary: total findings count per category and overall recommendation (approve, request changes).
|
|
1617
|
+
|
|
1618
|
+
{{diff}}
|
|
1619
|
+
`;
|
|
1620
|
+
function truncateDiff2(diff) {
|
|
1621
|
+
if (diff.length <= MAX_DIFF_CHARS2) return diff;
|
|
1622
|
+
return diff.slice(0, MAX_DIFF_CHARS2) + "\n\n[diff truncated \u2014 too large for context window]";
|
|
1623
|
+
}
|
|
1624
|
+
function extractSection(markdown, heading) {
|
|
1625
|
+
const headingRegex = new RegExp(`##\\s*${heading}\\b([\\s\\S]*?)(?=##|$)`, "i");
|
|
1626
|
+
const match = markdown.match(headingRegex);
|
|
1627
|
+
if (!match || !match[1]) return [];
|
|
1628
|
+
return match[1].split("\n").map((l) => l.replace(/^[-*•]\s*/, "").trim()).filter((l) => l.length > 0);
|
|
1629
|
+
}
|
|
1630
|
+
function linesToFindings(lines) {
|
|
1631
|
+
return lines.map((line) => ({
|
|
1632
|
+
description: line
|
|
1633
|
+
}));
|
|
1634
|
+
}
|
|
1635
|
+
function parseReviewMarkdown(text) {
|
|
1636
|
+
return {
|
|
1637
|
+
critical: linesToFindings(extractSection(text, "Critical")),
|
|
1638
|
+
suggestions: linesToFindings(extractSection(text, "Suggestions")),
|
|
1639
|
+
nitpicks: linesToFindings(extractSection(text, "Nitpicks"))
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
function buildMarkdown(parsed) {
|
|
1643
|
+
const sections = [];
|
|
1644
|
+
sections.push("## Critical");
|
|
1645
|
+
if (parsed.critical.length > 0) {
|
|
1646
|
+
sections.push(...parsed.critical.map((f) => `- ${f.description}`));
|
|
1647
|
+
} else {
|
|
1648
|
+
sections.push("_No critical issues found._");
|
|
1649
|
+
}
|
|
1650
|
+
sections.push("\n## Suggestions");
|
|
1651
|
+
if (parsed.suggestions.length > 0) {
|
|
1652
|
+
sections.push(...parsed.suggestions.map((f) => `- ${f.description}`));
|
|
1653
|
+
} else {
|
|
1654
|
+
sections.push("_No suggestions._");
|
|
1655
|
+
}
|
|
1656
|
+
sections.push("\n## Nitpicks");
|
|
1657
|
+
if (parsed.nitpicks.length > 0) {
|
|
1658
|
+
sections.push(...parsed.nitpicks.map((f) => `- ${f.description}`));
|
|
1659
|
+
} else {
|
|
1660
|
+
sections.push("_No nitpicks._");
|
|
1661
|
+
}
|
|
1662
|
+
return sections.join("\n");
|
|
1663
|
+
}
|
|
1664
|
+
async function review(opts) {
|
|
1665
|
+
const { cwd, provider, prompt, tier: requestedTier } = opts;
|
|
1666
|
+
const baseBranch = opts.baseBranch ?? await resolveBaseBranch(cwd);
|
|
1667
|
+
let diff;
|
|
1668
|
+
try {
|
|
1669
|
+
diff = await getDiff(cwd, baseBranch);
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
if (isUnknownRevisionError(err)) {
|
|
1672
|
+
diff = await getDiff(cwd);
|
|
1673
|
+
} else {
|
|
1674
|
+
const reason = errorMessage(err);
|
|
1675
|
+
throw new GitwiseError({
|
|
1676
|
+
code: "DIFF_FAILED",
|
|
1677
|
+
message: `Failed to compute diff against ${baseBranch}: ${reason}`,
|
|
1678
|
+
exitCode: EXIT_CODES.GIT_FAILED,
|
|
1679
|
+
cause: err
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (!diff) {
|
|
1684
|
+
throw new GitwiseError({
|
|
1685
|
+
code: "EMPTY_DIFF",
|
|
1686
|
+
message: `No changes found between current branch and ${baseBranch}`,
|
|
1687
|
+
exitCode: EXIT_CODES.NOTHING_STAGED
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
let templateContent;
|
|
1691
|
+
try {
|
|
1692
|
+
templateContent = await loadTemplate("review", {
|
|
1693
|
+
repoRoot: opts.repoRoot ?? cwd,
|
|
1694
|
+
templatesPath: opts.templatesPath
|
|
1695
|
+
});
|
|
1696
|
+
} catch {
|
|
1697
|
+
templateContent = DEFAULT_REVIEW_TEMPLATE;
|
|
1698
|
+
}
|
|
1699
|
+
const truncated = truncateDiff2(diff);
|
|
1700
|
+
const userMessage = interpolate(templateContent, { diff: truncated }) + (prompt ? `
|
|
1701
|
+
|
|
1702
|
+
Additional context: ${prompt}` : "");
|
|
1703
|
+
const systemPrompt = "You are a senior code reviewer. Analyze the provided diff carefully and return findings.";
|
|
1704
|
+
const defaultTier = resolveModelTier("review");
|
|
1705
|
+
const activeTier = requestedTier ?? defaultTier;
|
|
1706
|
+
debug("Calling LLM for code review", { tier: activeTier, diffLength: truncated.length });
|
|
1707
|
+
const response = await provider.chat({ systemPrompt, userMessage, tier: activeTier });
|
|
1708
|
+
const tokens = { input: response.tokens.input, output: response.tokens.output };
|
|
1709
|
+
const parsed = parseReviewMarkdown(response.content);
|
|
1710
|
+
const markdown = buildMarkdown(parsed);
|
|
1711
|
+
return {
|
|
1712
|
+
critical: parsed.critical,
|
|
1713
|
+
suggestions: parsed.suggestions,
|
|
1714
|
+
nitpicks: parsed.nitpicks,
|
|
1715
|
+
markdown,
|
|
1716
|
+
tokens
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
async function resolveBaseBranch(cwd) {
|
|
1720
|
+
try {
|
|
1721
|
+
return await detectBaseBranch(cwd);
|
|
1722
|
+
} catch {
|
|
1723
|
+
return "main";
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
function errorMessage(err) {
|
|
1727
|
+
if (err && typeof err === "object" && typeof err.message === "string") {
|
|
1728
|
+
return err.message;
|
|
1729
|
+
}
|
|
1730
|
+
return String(err);
|
|
1731
|
+
}
|
|
1732
|
+
function isUnknownRevisionError(err) {
|
|
1733
|
+
if (err === null || typeof err !== "object") return false;
|
|
1734
|
+
const errObj = err;
|
|
1735
|
+
const message = typeof errObj.message === "string" ? errObj.message : "";
|
|
1736
|
+
const stderr = typeof errObj.stderr === "string" ? errObj.stderr : "";
|
|
1737
|
+
const text = `${message}
|
|
1738
|
+
${stderr}`;
|
|
1739
|
+
return /unknown revision|bad revision|not a valid object name|ambiguous argument/i.test(text);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/commands/pr.ts
|
|
1743
|
+
import { execFile as execFile4 } from "child_process";
|
|
1744
|
+
import { promisify as promisify4 } from "util";
|
|
1745
|
+
var exec3 = promisify4(execFile4);
|
|
1746
|
+
function parsePrResponse(content) {
|
|
1747
|
+
const titleMatch = content.match(/^TITLE:\s*(.+)$/m);
|
|
1748
|
+
const title = titleMatch ? titleMatch[1].trim() : "Update";
|
|
1749
|
+
const separatorIdx = content.indexOf("---");
|
|
1750
|
+
const body = separatorIdx >= 0 ? content.slice(separatorIdx + 3).trim() : content;
|
|
1751
|
+
return { title, body };
|
|
1752
|
+
}
|
|
1753
|
+
async function detectExistingPr(cwd) {
|
|
1754
|
+
try {
|
|
1755
|
+
const result = await exec3("gh", ["pr", "view", "--json", "number", "--jq", ".number"], { cwd });
|
|
1756
|
+
const numberStr = result.stdout.trim();
|
|
1757
|
+
if (numberStr) {
|
|
1758
|
+
const n = parseInt(numberStr, 10);
|
|
1759
|
+
if (!isNaN(n)) return n;
|
|
1760
|
+
}
|
|
1761
|
+
} catch {
|
|
1762
|
+
}
|
|
1763
|
+
return void 0;
|
|
1764
|
+
}
|
|
1765
|
+
var PR_SYSTEM_PROMPT = `You are a developer creating a pull request. Based on the commit log, generate a PR title and description.
|
|
1766
|
+
|
|
1767
|
+
Output format (nothing else):
|
|
1768
|
+
TITLE: <concise title, max 70 chars>
|
|
1769
|
+
---
|
|
1770
|
+
## Summary
|
|
1771
|
+
<1-3 bullet points>
|
|
1772
|
+
|
|
1773
|
+
## Changes
|
|
1774
|
+
<changelog based on commits>
|
|
1775
|
+
|
|
1776
|
+
## Test Plan
|
|
1777
|
+
<testing checklist>`;
|
|
1778
|
+
async function pr(opts) {
|
|
1779
|
+
const { cwd, provider, prompt } = opts;
|
|
1780
|
+
const baseBranch = opts.baseBranch ?? await resolveBaseBranch2(cwd);
|
|
1781
|
+
const currentBranch = await getBranch(cwd);
|
|
1782
|
+
const commits = await getLog(cwd, `${baseBranch}..HEAD`);
|
|
1783
|
+
if (!commits) {
|
|
1784
|
+
throw new GitwiseError({
|
|
1785
|
+
code: "NO_COMMITS",
|
|
1786
|
+
message: `No commits found on this branch relative to ${baseBranch}`,
|
|
1787
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
let systemPrompt = PR_SYSTEM_PROMPT;
|
|
1791
|
+
let userMessageFromTemplate = `Branch: ${currentBranch}
|
|
1792
|
+
|
|
1793
|
+
Commits:
|
|
1794
|
+
${commits}`;
|
|
1795
|
+
try {
|
|
1796
|
+
const templateContent = await loadTemplate("pr", {
|
|
1797
|
+
repoRoot: opts.repoRoot ?? cwd,
|
|
1798
|
+
templatesPath: opts.templatesPath
|
|
1799
|
+
});
|
|
1800
|
+
if (templateContent && templateContent.includes("{{")) {
|
|
1801
|
+
userMessageFromTemplate = interpolate(templateContent, {
|
|
1802
|
+
branch: currentBranch,
|
|
1803
|
+
commits,
|
|
1804
|
+
summary: "",
|
|
1805
|
+
changelog: "",
|
|
1806
|
+
test_plan: ""
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
} catch {
|
|
1810
|
+
}
|
|
1811
|
+
const userMessage = userMessageFromTemplate + (prompt ? `
|
|
1812
|
+
|
|
1813
|
+
Additional context: ${prompt}` : "");
|
|
1814
|
+
const existingPrNumber = await detectExistingPr(cwd);
|
|
1815
|
+
debug("Calling LLM for PR draft", { tier: "fast", branch: currentBranch, existingPrNumber });
|
|
1816
|
+
const tier = resolveModelTier("pr");
|
|
1817
|
+
const response = await provider.chat({ systemPrompt, userMessage, tier });
|
|
1818
|
+
const tokens = { input: response.tokens.input, output: response.tokens.output };
|
|
1819
|
+
const { title, body } = parsePrResponse(response.content);
|
|
1820
|
+
return {
|
|
1821
|
+
title,
|
|
1822
|
+
body,
|
|
1823
|
+
existingPrNumber,
|
|
1824
|
+
tokens
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
async function applyPr(draft, opts) {
|
|
1828
|
+
const { cwd, draft: isDraft = false, baseBranch } = opts;
|
|
1829
|
+
const ghAvailable = await isGhAvailable();
|
|
1830
|
+
if (!ghAvailable) {
|
|
1831
|
+
throw new GitwiseError({
|
|
1832
|
+
code: "GH_UNAVAILABLE",
|
|
1833
|
+
message: "gh CLI is not installed \u2014 cannot create or update a PR",
|
|
1834
|
+
exitCode: EXIT_CODES.GH_FAILED,
|
|
1835
|
+
details: { draft }
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
if (draft.existingPrNumber !== void 0) {
|
|
1839
|
+
const updated = await updatePR({
|
|
1840
|
+
prNumber: draft.existingPrNumber,
|
|
1841
|
+
title: draft.title,
|
|
1842
|
+
body: draft.body,
|
|
1843
|
+
cwd
|
|
1844
|
+
});
|
|
1845
|
+
return { url: updated.url };
|
|
1846
|
+
}
|
|
1847
|
+
const created = await createPR({
|
|
1848
|
+
title: draft.title,
|
|
1849
|
+
body: draft.body,
|
|
1850
|
+
base: baseBranch,
|
|
1851
|
+
cwd,
|
|
1852
|
+
draft: isDraft
|
|
1853
|
+
});
|
|
1854
|
+
return { url: created.url };
|
|
1855
|
+
}
|
|
1856
|
+
async function resolveBaseBranch2(cwd) {
|
|
1857
|
+
try {
|
|
1858
|
+
return await detectBaseBranch(cwd);
|
|
1859
|
+
} catch {
|
|
1860
|
+
return "main";
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// src/commands/release.ts
|
|
1865
|
+
import { readFile as readFile6, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
|
|
1866
|
+
import { join as join6, relative } from "path";
|
|
1867
|
+
|
|
1868
|
+
// src/strategies/release.ts
|
|
1869
|
+
var githubFlow = Object.freeze({
|
|
1870
|
+
name: "github-flow",
|
|
1871
|
+
releaseBranchFor(_version) {
|
|
1872
|
+
return null;
|
|
1873
|
+
},
|
|
1874
|
+
mergeTargets(mainBranch, _developBranch) {
|
|
1875
|
+
return [mainBranch];
|
|
1876
|
+
},
|
|
1877
|
+
requiresDevelop() {
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
var gitflow = Object.freeze({
|
|
1882
|
+
name: "gitflow",
|
|
1883
|
+
releaseBranchFor(version2) {
|
|
1884
|
+
return `release/${version2}`;
|
|
1885
|
+
},
|
|
1886
|
+
mergeTargets(mainBranch, developBranch) {
|
|
1887
|
+
return developBranch ? [mainBranch, developBranch] : [mainBranch];
|
|
1888
|
+
},
|
|
1889
|
+
requiresDevelop() {
|
|
1890
|
+
return true;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
var STRATEGIES = Object.freeze({
|
|
1894
|
+
"github-flow": githubFlow,
|
|
1895
|
+
gitflow
|
|
1896
|
+
});
|
|
1897
|
+
function createReleaseStrategy(name) {
|
|
1898
|
+
return STRATEGIES[name];
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// src/commands/release-plan.ts
|
|
1902
|
+
import { readFile as readFile5, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
|
|
1903
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1904
|
+
var PLAN_REL_PATH = ".gitwise/release-plan.json";
|
|
1905
|
+
function planPath(cwd) {
|
|
1906
|
+
return join5(cwd, PLAN_REL_PATH);
|
|
1907
|
+
}
|
|
1908
|
+
async function saveReleasePlan(cwd, plan) {
|
|
1909
|
+
await writeJSON(planPath(cwd), plan);
|
|
1910
|
+
}
|
|
1911
|
+
async function loadReleasePlan(cwd) {
|
|
1912
|
+
const filePath = planPath(cwd);
|
|
1913
|
+
if (!await fileExists(filePath)) return null;
|
|
1914
|
+
const raw = await readFile5(filePath, "utf-8");
|
|
1915
|
+
let parsed;
|
|
1916
|
+
try {
|
|
1917
|
+
parsed = JSON.parse(raw);
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
throw new GitwiseError({
|
|
1920
|
+
code: "INVALID_PLAN_JSON",
|
|
1921
|
+
message: `Release plan at ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
1922
|
+
exitCode: EXIT_CODES.CONFIG_INVALID,
|
|
1923
|
+
cause: err
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
const schema = parsed?.schema;
|
|
1927
|
+
if (schema !== 1) {
|
|
1928
|
+
throw new GitwiseError({
|
|
1929
|
+
code: "INVALID_PLAN_SCHEMA",
|
|
1930
|
+
message: `Release plan schema ${String(schema)} is not supported by this gitwise binary (expected 1).`,
|
|
1931
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
if (!isPersistedReleasePlan(parsed)) {
|
|
1935
|
+
throw new GitwiseError({
|
|
1936
|
+
code: "INVALID_PLAN_SCHEMA",
|
|
1937
|
+
message: `Release plan at ${filePath} is missing or has wrong-typed required fields for schema 1.`,
|
|
1938
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
return parsed;
|
|
1942
|
+
}
|
|
1943
|
+
function isPersistedReleasePlan(value) {
|
|
1944
|
+
if (!value || typeof value !== "object") return false;
|
|
1945
|
+
const p = value;
|
|
1946
|
+
if (p.schema !== 1) return false;
|
|
1947
|
+
if (p.strategy !== "gitflow" && p.strategy !== "github-flow") return false;
|
|
1948
|
+
if (p.suggestedBump !== "major" && p.suggestedBump !== "minor" && p.suggestedBump !== "patch") {
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
if (typeof p.currentVersion !== "string") return false;
|
|
1952
|
+
if (typeof p.newVersion !== "string") return false;
|
|
1953
|
+
if (typeof p.changelog !== "string") return false;
|
|
1954
|
+
if (typeof p.notes !== "string") return false;
|
|
1955
|
+
if (typeof p.commits !== "string") return false;
|
|
1956
|
+
if (typeof p.preparedAt !== "string") return false;
|
|
1957
|
+
if (typeof p.baseCommit !== "string") return false;
|
|
1958
|
+
if (typeof p.targetBranch !== "string") return false;
|
|
1959
|
+
if (typeof p.releaseBranchCreated !== "boolean") return false;
|
|
1960
|
+
if (!p.tokens || typeof p.tokens !== "object") return false;
|
|
1961
|
+
const tokens = p.tokens;
|
|
1962
|
+
if (typeof tokens.input !== "number" || !Number.isFinite(tokens.input)) return false;
|
|
1963
|
+
if (typeof tokens.output !== "number" || !Number.isFinite(tokens.output)) return false;
|
|
1964
|
+
return true;
|
|
1965
|
+
}
|
|
1966
|
+
async function deleteReleasePlan(cwd) {
|
|
1967
|
+
try {
|
|
1968
|
+
await unlink3(planPath(cwd));
|
|
1969
|
+
} catch (err) {
|
|
1970
|
+
if (err.code === "ENOENT") return;
|
|
1971
|
+
throw err;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function applyGitignoreEntry(content, entry) {
|
|
1975
|
+
if (isCovered(content, entry)) return content;
|
|
1976
|
+
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
1977
|
+
return `${content}${needsLeadingNewline ? "\n" : ""}${entry}
|
|
1978
|
+
`;
|
|
1979
|
+
}
|
|
1980
|
+
async function ensureGitignored(cwd, entry) {
|
|
1981
|
+
const gitignorePath = join5(cwd, ".gitignore");
|
|
1982
|
+
const exists = await fileExists(gitignorePath);
|
|
1983
|
+
const original = exists ? await readFile5(gitignorePath, "utf-8") : "";
|
|
1984
|
+
const next = applyGitignoreEntry(original, entry);
|
|
1985
|
+
if (next === original) return;
|
|
1986
|
+
await writeFile2(gitignorePath, next, "utf-8");
|
|
1987
|
+
info(`Added ${entry} to .gitignore`);
|
|
1988
|
+
}
|
|
1989
|
+
function isCovered(content, entry) {
|
|
1990
|
+
const candidates = /* @__PURE__ */ new Set([entry]);
|
|
1991
|
+
const dir = dirname3(entry);
|
|
1992
|
+
if (dir && dir !== "." && dir !== "/") {
|
|
1993
|
+
candidates.add(`${dir}/`);
|
|
1994
|
+
candidates.add(`${dir}/*`);
|
|
1995
|
+
}
|
|
1996
|
+
for (const rawLine of content.split("\n")) {
|
|
1997
|
+
const line = rawLine.trim();
|
|
1998
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
1999
|
+
if (candidates.has(line)) return true;
|
|
2000
|
+
}
|
|
2001
|
+
return false;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// src/commands/release.ts
|
|
2005
|
+
var RELEASE_PLAN_REL_PATH = ".gitwise/release-plan.json";
|
|
2006
|
+
var RELEASE_NOTES_GLOB_REL_PATH = ".gitwise/release-*.md";
|
|
2007
|
+
var STRICT_SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
2008
|
+
function bumpVersion(current, type) {
|
|
2009
|
+
const match = STRICT_SEMVER_RE.exec(current);
|
|
2010
|
+
if (!match) {
|
|
2011
|
+
throw new GitwiseError({
|
|
2012
|
+
code: "INVALID_VERSION",
|
|
2013
|
+
message: `Invalid current version: ${current}`,
|
|
2014
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
const major = Number(match[1]);
|
|
2018
|
+
const minor = Number(match[2]);
|
|
2019
|
+
const patch = Number(match[3]);
|
|
2020
|
+
switch (type) {
|
|
2021
|
+
case "major":
|
|
2022
|
+
return `${major + 1}.0.0`;
|
|
2023
|
+
case "minor":
|
|
2024
|
+
return `${major}.${minor + 1}.0`;
|
|
2025
|
+
case "patch":
|
|
2026
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
2027
|
+
// Belt-and-suspenders: TS makes this unreachable for typed callers, but
|
|
2028
|
+
// any JS caller (or a cast like `parseVersionSuggestion`'s former one)
|
|
2029
|
+
// could smuggle in a bogus value. Surface it as INVALID_VERSION instead
|
|
2030
|
+
// of silently returning undefined and minting `release/undefined` /
|
|
2031
|
+
// `vundefined` artifacts downstream.
|
|
2032
|
+
default:
|
|
2033
|
+
throw new GitwiseError({
|
|
2034
|
+
code: "INVALID_VERSION",
|
|
2035
|
+
message: `Invalid bump type: ${String(type)}`,
|
|
2036
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
function parseVersionSuggestion(raw) {
|
|
2041
|
+
try {
|
|
2042
|
+
const cleaned = raw.replace(/```(?:json)?\n?/g, "").trim();
|
|
2043
|
+
const parsed = JSON.parse(cleaned);
|
|
2044
|
+
const { suggestion, reasoning } = parsed;
|
|
2045
|
+
if ((suggestion === "major" || suggestion === "minor" || suggestion === "patch") && typeof reasoning === "string") {
|
|
2046
|
+
return { suggestion, reasoning };
|
|
2047
|
+
}
|
|
2048
|
+
} catch {
|
|
2049
|
+
}
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
function heuristicBump(commits) {
|
|
2053
|
+
if (/BREAKING CHANGE|!:/.test(commits)) return "major";
|
|
2054
|
+
if (/^feat[:(]/m.test(commits)) return "minor";
|
|
2055
|
+
return "patch";
|
|
2056
|
+
}
|
|
2057
|
+
var CHANGELOG_HEADER = `# Changelog
|
|
2058
|
+
|
|
2059
|
+
All notable changes to this project will be documented in this file.
|
|
2060
|
+
|
|
2061
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com),
|
|
2062
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
2063
|
+
|
|
2064
|
+
`;
|
|
2065
|
+
async function release(opts) {
|
|
2066
|
+
const { cwd, provider, language = "en" } = opts;
|
|
2067
|
+
const pkgPath = join6(cwd, "package.json");
|
|
2068
|
+
if (!await fileExists(pkgPath)) {
|
|
2069
|
+
throw new GitwiseError({
|
|
2070
|
+
code: "NO_PACKAGE_JSON",
|
|
2071
|
+
message: "No package.json found",
|
|
2072
|
+
exitCode: EXIT_CODES.CONFIG_INVALID
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
const pkg = await readJSON(pkgPath);
|
|
2076
|
+
const currentVersion = pkg.version;
|
|
2077
|
+
const projectName = pkg.name ?? "project";
|
|
2078
|
+
const lastTag = await getLatestTag(cwd);
|
|
2079
|
+
const logRange = lastTag ? `${lastTag}..HEAD` : void 0;
|
|
2080
|
+
const commits = await getLog(cwd, logRange);
|
|
2081
|
+
if (!commits) {
|
|
2082
|
+
throw new GitwiseError({
|
|
2083
|
+
code: "NO_COMMITS",
|
|
2084
|
+
message: "No new commits since last release",
|
|
2085
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
const templateOpts = {
|
|
2089
|
+
repoRoot: opts.repoRoot ?? cwd,
|
|
2090
|
+
templatesPath: opts.templatesPath
|
|
2091
|
+
};
|
|
2092
|
+
const tier = resolveModelTier("release");
|
|
2093
|
+
let totalInput = 0;
|
|
2094
|
+
let totalOutput = 0;
|
|
2095
|
+
let suggestedBump;
|
|
2096
|
+
if (opts.bump) {
|
|
2097
|
+
suggestedBump = opts.bump;
|
|
2098
|
+
} else {
|
|
2099
|
+
const versionTemplate = await loadTemplate("release-version", templateOpts);
|
|
2100
|
+
const versionPrompt = interpolate(versionTemplate, { currentVersion });
|
|
2101
|
+
debug("Calling LLM for version suggestion");
|
|
2102
|
+
const versionResponse = await provider.chat({
|
|
2103
|
+
systemPrompt: "You are a release engineer. Respond with JSON only.",
|
|
2104
|
+
userMessage: `${versionPrompt}
|
|
2105
|
+
|
|
2106
|
+
Commits:
|
|
2107
|
+
${commits}`,
|
|
2108
|
+
tier
|
|
2109
|
+
});
|
|
2110
|
+
totalInput += versionResponse.tokens.input;
|
|
2111
|
+
totalOutput += versionResponse.tokens.output;
|
|
2112
|
+
const suggestion = parseVersionSuggestion(versionResponse.content);
|
|
2113
|
+
suggestedBump = suggestion?.suggestion ?? heuristicBump(commits);
|
|
2114
|
+
}
|
|
2115
|
+
const newVersion = bumpVersion(currentVersion, suggestedBump);
|
|
2116
|
+
const changelogTemplate = await loadTemplate("release-changelog", templateOpts);
|
|
2117
|
+
const changelogPrompt = interpolate(changelogTemplate, { projectName });
|
|
2118
|
+
debug("Calling LLM for changelog generation");
|
|
2119
|
+
const changelogResponse = await provider.chat({
|
|
2120
|
+
systemPrompt: "You are a technical writer generating a changelog. Follow Keep a Changelog format.",
|
|
2121
|
+
userMessage: `${changelogPrompt}
|
|
2122
|
+
|
|
2123
|
+
Commits:
|
|
2124
|
+
${commits}`,
|
|
2125
|
+
tier
|
|
2126
|
+
});
|
|
2127
|
+
totalInput += changelogResponse.tokens.input;
|
|
2128
|
+
totalOutput += changelogResponse.tokens.output;
|
|
2129
|
+
const changelog = changelogResponse.content;
|
|
2130
|
+
const notesTemplate = await loadTemplate("release-notes", templateOpts);
|
|
2131
|
+
const notesPrompt = interpolate(notesTemplate, {
|
|
2132
|
+
version: newVersion,
|
|
2133
|
+
projectName,
|
|
2134
|
+
language
|
|
2135
|
+
});
|
|
2136
|
+
debug("Calling LLM for release notes generation");
|
|
2137
|
+
const notesResponse = await provider.chat({
|
|
2138
|
+
systemPrompt: "You are a product communications specialist writing release notes.",
|
|
2139
|
+
userMessage: `${notesPrompt}
|
|
2140
|
+
|
|
2141
|
+
Commits:
|
|
2142
|
+
${commits}`,
|
|
2143
|
+
tier
|
|
2144
|
+
});
|
|
2145
|
+
totalInput += notesResponse.tokens.input;
|
|
2146
|
+
totalOutput += notesResponse.tokens.output;
|
|
2147
|
+
const notes = notesResponse.content;
|
|
2148
|
+
return {
|
|
2149
|
+
suggestedBump,
|
|
2150
|
+
newVersion,
|
|
2151
|
+
currentVersion,
|
|
2152
|
+
changelog,
|
|
2153
|
+
notes,
|
|
2154
|
+
commits,
|
|
2155
|
+
tokens: { input: totalInput, output: totalOutput }
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
function createReleaseBranchStep(cwd, branchName, startPoint) {
|
|
2159
|
+
return {
|
|
2160
|
+
name: `create-branch:${branchName}`,
|
|
2161
|
+
apply: async () => {
|
|
2162
|
+
const previousBranch = await getBranch(cwd);
|
|
2163
|
+
await createBranch(cwd, branchName, startPoint);
|
|
2164
|
+
return { branchName, previousBranch };
|
|
2165
|
+
},
|
|
2166
|
+
compensate: async ({ branchName: branch, previousBranch }) => {
|
|
2167
|
+
await checkoutForce(cwd, previousBranch);
|
|
2168
|
+
await deleteBranch(cwd, branch, true);
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
function writeFileStep(filePath, contents) {
|
|
2173
|
+
return {
|
|
2174
|
+
name: `write-file:${filePath}`,
|
|
2175
|
+
apply: async () => {
|
|
2176
|
+
const priorBytes = await fileExists(filePath) ? await readFile6(filePath) : null;
|
|
2177
|
+
await writeFile3(filePath, contents);
|
|
2178
|
+
return priorBytes;
|
|
2179
|
+
},
|
|
2180
|
+
compensate: async (priorBytes) => {
|
|
2181
|
+
if (priorBytes === null) {
|
|
2182
|
+
try {
|
|
2183
|
+
await unlink4(filePath);
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
if (err.code !== "ENOENT") throw err;
|
|
2186
|
+
}
|
|
2187
|
+
} else {
|
|
2188
|
+
await writeFile3(filePath, priorBytes);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
function mutateGitignoreStep(cwd) {
|
|
2194
|
+
const gitignorePath = join6(cwd, ".gitignore");
|
|
2195
|
+
return {
|
|
2196
|
+
name: "mutate-gitignore",
|
|
2197
|
+
apply: async () => {
|
|
2198
|
+
const priorBytes = await fileExists(gitignorePath) ? await readFile6(gitignorePath) : null;
|
|
2199
|
+
await ensureGitignored(cwd, RELEASE_PLAN_REL_PATH);
|
|
2200
|
+
await ensureGitignored(cwd, RELEASE_NOTES_GLOB_REL_PATH);
|
|
2201
|
+
return priorBytes;
|
|
2202
|
+
},
|
|
2203
|
+
compensate: async (priorBytes) => {
|
|
2204
|
+
if (priorBytes === null) {
|
|
2205
|
+
try {
|
|
2206
|
+
await unlink4(gitignorePath);
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
if (err.code !== "ENOENT") throw err;
|
|
2209
|
+
}
|
|
2210
|
+
} else {
|
|
2211
|
+
await writeFile3(gitignorePath, priorBytes);
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
function writeChangelogStep(cwd, newVersion, entryBody) {
|
|
2217
|
+
const changelogPath = join6(cwd, "CHANGELOG.md");
|
|
2218
|
+
return {
|
|
2219
|
+
name: "write-changelog",
|
|
2220
|
+
apply: async () => {
|
|
2221
|
+
const priorBytes = await fileExists(changelogPath) ? await readFile6(changelogPath) : null;
|
|
2222
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
2223
|
+
const versionHeader = `## [${newVersion}] - ${date}
|
|
2224
|
+
|
|
2225
|
+
${entryBody}
|
|
2226
|
+
|
|
2227
|
+
`;
|
|
2228
|
+
if (priorBytes !== null) {
|
|
2229
|
+
const existing = priorBytes.toString("utf-8");
|
|
2230
|
+
const headerEnd = existing.indexOf("## [");
|
|
2231
|
+
if (headerEnd > 0) {
|
|
2232
|
+
await writeFile3(
|
|
2233
|
+
changelogPath,
|
|
2234
|
+
existing.slice(0, headerEnd) + versionHeader + existing.slice(headerEnd),
|
|
2235
|
+
"utf-8"
|
|
2236
|
+
);
|
|
2237
|
+
} else {
|
|
2238
|
+
const body = existing.startsWith(CHANGELOG_HEADER) ? existing.slice(CHANGELOG_HEADER.length) : existing;
|
|
2239
|
+
await writeFile3(
|
|
2240
|
+
changelogPath,
|
|
2241
|
+
CHANGELOG_HEADER + versionHeader + body,
|
|
2242
|
+
"utf-8"
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
} else {
|
|
2246
|
+
await writeFile3(
|
|
2247
|
+
changelogPath,
|
|
2248
|
+
CHANGELOG_HEADER + versionHeader,
|
|
2249
|
+
"utf-8"
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
return priorBytes;
|
|
2253
|
+
},
|
|
2254
|
+
compensate: async (priorBytes) => {
|
|
2255
|
+
if (priorBytes === null) {
|
|
2256
|
+
try {
|
|
2257
|
+
await unlink4(changelogPath);
|
|
2258
|
+
} catch (err) {
|
|
2259
|
+
if (err.code !== "ENOENT") throw err;
|
|
2260
|
+
}
|
|
2261
|
+
} else {
|
|
2262
|
+
await writeFile3(changelogPath, priorBytes);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
function commitReleaseStep(cwd, message, files) {
|
|
2268
|
+
return {
|
|
2269
|
+
name: "commit-release-bump",
|
|
2270
|
+
apply: async () => {
|
|
2271
|
+
const preSha = await headSha(cwd);
|
|
2272
|
+
await applyCommit({ message, files, cwd });
|
|
2273
|
+
return preSha;
|
|
2274
|
+
},
|
|
2275
|
+
compensate: async (preSha) => {
|
|
2276
|
+
await resetHard(cwd, preSha);
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
function savePlanStep(cwd, plan) {
|
|
2281
|
+
return {
|
|
2282
|
+
name: "save-plan",
|
|
2283
|
+
apply: async () => {
|
|
2284
|
+
await saveReleasePlan(cwd, plan);
|
|
2285
|
+
},
|
|
2286
|
+
compensate: async () => {
|
|
2287
|
+
await deleteReleasePlan(cwd);
|
|
2288
|
+
}
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
async function prepareRelease(opts) {
|
|
2292
|
+
const { cwd } = opts;
|
|
2293
|
+
const repoConfig = await readRepoConfig(cwd);
|
|
2294
|
+
const strategyName = opts.strategy ?? repoConfig?.releaseStrategy ?? "github-flow";
|
|
2295
|
+
const developBranch = opts.developBranch ?? repoConfig?.developBranch ?? "develop";
|
|
2296
|
+
const strategy = createReleaseStrategy(strategyName);
|
|
2297
|
+
debug("release.prepare.start", { strategy: strategyName, cwd });
|
|
2298
|
+
const releaseLock = await acquireRepoLock(cwd, {
|
|
2299
|
+
command: "release prepare"
|
|
2300
|
+
});
|
|
2301
|
+
try {
|
|
2302
|
+
const dirtyEntries = (await status(cwd)).split("\n").map((line) => line.replace(/\s+$/, "")).filter((line) => line.length >= 3).filter((line) => {
|
|
2303
|
+
const path3 = line.slice(3).trim();
|
|
2304
|
+
if (path3 === ".gitignore") return false;
|
|
2305
|
+
if (path3 === ".gitwise/" || path3 === ".gitwise") return false;
|
|
2306
|
+
if (path3.startsWith(".gitwise/")) return false;
|
|
2307
|
+
return true;
|
|
2308
|
+
});
|
|
2309
|
+
if (dirtyEntries.length > 0) {
|
|
2310
|
+
throw new GitwiseError({
|
|
2311
|
+
code: "WORKING_TREE_DIRTY",
|
|
2312
|
+
message: `Working tree must be clean before preparing a release \u2014 commit or stash first.
|
|
2313
|
+
${dirtyEntries.join("\n")}`,
|
|
2314
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
2315
|
+
});
|
|
2316
|
+
}
|
|
2317
|
+
const existingPlan = await loadReleasePlan(cwd);
|
|
2318
|
+
if (existingPlan) {
|
|
2319
|
+
throw new GitwiseError({
|
|
2320
|
+
code: "RELEASE_PLAN_EXISTS",
|
|
2321
|
+
message: `An in-flight release plan already exists at .gitwise/release-plan.json for v${existingPlan.newVersion} (${existingPlan.strategy}). Finish it with "gw release finish" or discard it with "gw release abort" before preparing a new release.`,
|
|
2322
|
+
exitCode: EXIT_CODES.RELEASE_BRANCH_CONFLICT
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
if (strategy.requiresDevelop()) {
|
|
2326
|
+
if (!await branchExists(cwd, developBranch)) {
|
|
2327
|
+
throw new GitwiseError({
|
|
2328
|
+
code: "STRATEGY_DEVELOP_MISSING",
|
|
2329
|
+
message: `GitFlow requires a "${developBranch}" branch but it does not exist. Create it first (e.g. git checkout -b ${developBranch}).`,
|
|
2330
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
const baseCommit = await headSha(cwd);
|
|
2335
|
+
const plan = await release(opts);
|
|
2336
|
+
const releaseBranch = strategy.releaseBranchFor(plan.newVersion);
|
|
2337
|
+
if (releaseBranch && await branchExists(cwd, releaseBranch)) {
|
|
2338
|
+
throw new GitwiseError({
|
|
2339
|
+
code: "RELEASE_BRANCH_CONFLICT",
|
|
2340
|
+
message: `Release branch "${releaseBranch}" already exists. Delete it or pick a different version \u2014 see docs/recovery.md if a prior prepare crashed before its rollback finished.`,
|
|
2341
|
+
exitCode: EXIT_CODES.RELEASE_BRANCH_CONFLICT,
|
|
2342
|
+
details: { releaseBranch, newVersion: plan.newVersion }
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
const tx = new Transaction();
|
|
2346
|
+
await ensureDir(join6(cwd, ".gitwise"));
|
|
2347
|
+
let targetBranch;
|
|
2348
|
+
try {
|
|
2349
|
+
if (releaseBranch) {
|
|
2350
|
+
await tx.run(
|
|
2351
|
+
createReleaseBranchStep(cwd, releaseBranch, developBranch)
|
|
2352
|
+
);
|
|
2353
|
+
debug("release.prepare.branch.created", {
|
|
2354
|
+
branch: releaseBranch,
|
|
2355
|
+
from: developBranch
|
|
2356
|
+
});
|
|
2357
|
+
targetBranch = releaseBranch;
|
|
2358
|
+
} else {
|
|
2359
|
+
targetBranch = await getBranch(cwd);
|
|
2360
|
+
}
|
|
2361
|
+
const notesPath = join6(cwd, ".gitwise", `release-${plan.newVersion}.md`);
|
|
2362
|
+
await tx.run(writeFileStep(notesPath, plan.notes));
|
|
2363
|
+
let propagatedManifests = [];
|
|
2364
|
+
if (releaseBranch) {
|
|
2365
|
+
const pkgPath = join6(cwd, "package.json");
|
|
2366
|
+
await tx.run(writeWorkspaceVersionStep(pkgPath, plan.newVersion));
|
|
2367
|
+
if (opts.workspacePropagation) {
|
|
2368
|
+
propagatedManifests = await runWorkspaceVersionStepsInto(
|
|
2369
|
+
tx,
|
|
2370
|
+
cwd,
|
|
2371
|
+
plan.newVersion
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
await tx.run(writeChangelogStep(cwd, plan.newVersion, plan.changelog));
|
|
2375
|
+
}
|
|
2376
|
+
await tx.run(mutateGitignoreStep(cwd));
|
|
2377
|
+
if (releaseBranch) {
|
|
2378
|
+
const stagePaths = ["package.json", "CHANGELOG.md"];
|
|
2379
|
+
if (await fileExists(join6(cwd, ".gitignore"))) {
|
|
2380
|
+
stagePaths.push(".gitignore");
|
|
2381
|
+
}
|
|
2382
|
+
stagePaths.push(...propagatedManifests);
|
|
2383
|
+
await tx.run(
|
|
2384
|
+
commitReleaseStep(
|
|
2385
|
+
cwd,
|
|
2386
|
+
`chore(release): v${plan.newVersion}`,
|
|
2387
|
+
stagePaths
|
|
2388
|
+
)
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
const persistedPlan = {
|
|
2392
|
+
schema: 1,
|
|
2393
|
+
strategy: strategyName,
|
|
2394
|
+
currentVersion: plan.currentVersion,
|
|
2395
|
+
newVersion: plan.newVersion,
|
|
2396
|
+
suggestedBump: plan.suggestedBump,
|
|
2397
|
+
changelog: plan.changelog,
|
|
2398
|
+
notes: plan.notes,
|
|
2399
|
+
commits: plan.commits,
|
|
2400
|
+
preparedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2401
|
+
baseCommit,
|
|
2402
|
+
targetBranch,
|
|
2403
|
+
releaseBranchCreated: releaseBranch !== null,
|
|
2404
|
+
tokens: plan.tokens
|
|
2405
|
+
};
|
|
2406
|
+
await tx.run(savePlanStep(cwd, persistedPlan));
|
|
2407
|
+
debug("release.prepare.plan.saved", {
|
|
2408
|
+
newVersion: plan.newVersion,
|
|
2409
|
+
targetBranch,
|
|
2410
|
+
releaseBranchCreated: persistedPlan.releaseBranchCreated
|
|
2411
|
+
});
|
|
2412
|
+
return persistedPlan;
|
|
2413
|
+
} catch (err) {
|
|
2414
|
+
const reason = err instanceof GitwiseError ? err : new GitwiseError({
|
|
2415
|
+
code: "RELEASE_PREPARE_FAILED",
|
|
2416
|
+
message: `Failed to prepare release: ${err instanceof Error ? err.message : String(err)}`,
|
|
2417
|
+
exitCode: EXIT_CODES.GIT_FAILED,
|
|
2418
|
+
cause: err
|
|
2419
|
+
});
|
|
2420
|
+
debug("release.prepare.rollback.start", {
|
|
2421
|
+
appliedSteps: tx.size,
|
|
2422
|
+
code: reason.code
|
|
2423
|
+
});
|
|
2424
|
+
await tx.rollback(reason, txLogger);
|
|
2425
|
+
throw reason;
|
|
2426
|
+
}
|
|
2427
|
+
} finally {
|
|
2428
|
+
await releaseLock();
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
async function applyRelease(plan, opts) {
|
|
2432
|
+
const { cwd, tagAndPush = true, createGhRelease = true, workspacePropagation = false, signTags } = opts;
|
|
2433
|
+
const dirty = (await status(cwd)).trim();
|
|
2434
|
+
if (dirty) {
|
|
2435
|
+
throw new GitwiseError({
|
|
2436
|
+
code: "WORKING_TREE_DIRTY",
|
|
2437
|
+
message: `Working tree must be clean before releasing \u2014 commit or stash first.
|
|
2438
|
+
${dirty}`,
|
|
2439
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
const tag = `v${plan.newVersion}`;
|
|
2443
|
+
if (await tagExists(cwd, tag)) {
|
|
2444
|
+
throw new GitwiseError({
|
|
2445
|
+
code: "TAG_EXISTS",
|
|
2446
|
+
message: `Tag ${tag} already exists. Bump to a new version or delete the tag.`,
|
|
2447
|
+
exitCode: EXIT_CODES.RELEASE_BRANCH_CONFLICT
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
await ensureDir(join6(cwd, ".gitwise"));
|
|
2451
|
+
await writeFile3(
|
|
2452
|
+
join6(cwd, ".gitwise", `release-${plan.newVersion}.md`),
|
|
2453
|
+
plan.notes,
|
|
2454
|
+
"utf-8"
|
|
2455
|
+
);
|
|
2456
|
+
const persistedPlan = {
|
|
2457
|
+
schema: 1,
|
|
2458
|
+
strategy: "github-flow",
|
|
2459
|
+
currentVersion: plan.currentVersion,
|
|
2460
|
+
newVersion: plan.newVersion,
|
|
2461
|
+
suggestedBump: plan.suggestedBump,
|
|
2462
|
+
changelog: plan.changelog,
|
|
2463
|
+
notes: plan.notes,
|
|
2464
|
+
commits: plan.commits,
|
|
2465
|
+
preparedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2466
|
+
baseCommit: await headSha(cwd),
|
|
2467
|
+
targetBranch: await getBranch(cwd),
|
|
2468
|
+
releaseBranchCreated: false,
|
|
2469
|
+
tokens: plan.tokens
|
|
2470
|
+
};
|
|
2471
|
+
await ensureGitignored(cwd, RELEASE_PLAN_REL_PATH);
|
|
2472
|
+
await ensureGitignored(cwd, RELEASE_NOTES_GLOB_REL_PATH);
|
|
2473
|
+
await saveReleasePlan(cwd, persistedPlan);
|
|
2474
|
+
await finishRelease({ cwd, tagAndPush, createGhRelease, workspacePropagation, signTags });
|
|
2475
|
+
}
|
|
2476
|
+
async function finishRelease(opts) {
|
|
2477
|
+
const {
|
|
2478
|
+
cwd,
|
|
2479
|
+
tagAndPush = true,
|
|
2480
|
+
createGhRelease = true,
|
|
2481
|
+
deleteReleaseBranch = true,
|
|
2482
|
+
workspacePropagation = false,
|
|
2483
|
+
signTags = true
|
|
2484
|
+
} = opts;
|
|
2485
|
+
if (signTags === false) {
|
|
2486
|
+
process.stderr.write(
|
|
2487
|
+
"[gitwise] WARNING: --no-sign / signTags:false is a testing-only escape hatch. Release tags will NOT be GPG-signed. Do not use in production releases.\n"
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
const plan = await loadReleasePlan(cwd);
|
|
2491
|
+
if (!plan) {
|
|
2492
|
+
throw new GitwiseError({
|
|
2493
|
+
code: "NO_RELEASE_PLAN",
|
|
2494
|
+
message: `No release plan found at .gitwise/release-plan.json. Run "gw release prepare" first.`,
|
|
2495
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
debug("release.finish.start", {
|
|
2499
|
+
strategy: plan.strategy,
|
|
2500
|
+
newVersion: plan.newVersion,
|
|
2501
|
+
targetBranch: plan.targetBranch
|
|
2502
|
+
});
|
|
2503
|
+
const strategy = createReleaseStrategy(plan.strategy);
|
|
2504
|
+
const tag = `v${plan.newVersion}`;
|
|
2505
|
+
if (await tagExists(cwd, tag)) {
|
|
2506
|
+
debug("release.finish.validate.failed", {
|
|
2507
|
+
code: "STALE_PLAN_TAG_EXISTS",
|
|
2508
|
+
tag
|
|
2509
|
+
});
|
|
2510
|
+
throw new GitwiseError({
|
|
2511
|
+
code: "STALE_PLAN_TAG_EXISTS",
|
|
2512
|
+
message: `Tag ${tag} already exists \u2014 the saved plan is stale. Run "gw release abort" or delete the tag before retrying.`,
|
|
2513
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
const currentBranch = await getBranch(cwd);
|
|
2517
|
+
if (currentBranch !== plan.targetBranch) {
|
|
2518
|
+
debug("release.finish.validate.failed", {
|
|
2519
|
+
code: "STALE_PLAN_BRANCH_MISMATCH",
|
|
2520
|
+
expected: plan.targetBranch,
|
|
2521
|
+
actual: currentBranch
|
|
2522
|
+
});
|
|
2523
|
+
throw new GitwiseError({
|
|
2524
|
+
code: "STALE_PLAN_BRANCH_MISMATCH",
|
|
2525
|
+
message: `Release plan targets "${plan.targetBranch}" but the current branch is "${currentBranch}". Check out the target branch before running finish.`,
|
|
2526
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
const expectedDirtyPaths = /* @__PURE__ */ new Set([
|
|
2530
|
+
".gitwise/",
|
|
2531
|
+
".gitwise/release-plan.json",
|
|
2532
|
+
`.gitwise/release-${plan.newVersion}.md`
|
|
2533
|
+
]);
|
|
2534
|
+
if (await gitignoreMatchesPrepareOutput(cwd)) {
|
|
2535
|
+
expectedDirtyPaths.add(".gitignore");
|
|
2536
|
+
}
|
|
2537
|
+
const dirtyEntries = (await status(cwd)).split("\n").map((line) => line.replace(/\s+$/, "")).filter((line) => line.length >= 3).filter((line) => !expectedDirtyPaths.has(line.slice(3).trim()));
|
|
2538
|
+
if (dirtyEntries.length > 0) {
|
|
2539
|
+
debug("release.finish.validate.failed", {
|
|
2540
|
+
code: "WORKING_TREE_DIRTY"
|
|
2541
|
+
});
|
|
2542
|
+
throw new GitwiseError({
|
|
2543
|
+
code: "WORKING_TREE_DIRTY",
|
|
2544
|
+
message: `Working tree must be clean before finishing a release \u2014 commit or stash first.
|
|
2545
|
+
${dirtyEntries.join("\n")}`,
|
|
2546
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
const repoConfig = await readRepoConfig(cwd);
|
|
2550
|
+
const developBranch = repoConfig?.developBranch ?? "develop";
|
|
2551
|
+
if (strategy.requiresDevelop()) {
|
|
2552
|
+
if (!await branchExists(cwd, developBranch)) {
|
|
2553
|
+
debug("release.finish.validate.failed", {
|
|
2554
|
+
code: "STRATEGY_DEVELOP_MISSING",
|
|
2555
|
+
developBranch
|
|
2556
|
+
});
|
|
2557
|
+
throw new GitwiseError({
|
|
2558
|
+
code: "STRATEGY_DEVELOP_MISSING",
|
|
2559
|
+
message: `GitFlow requires a "${developBranch}" branch but it does not exist.`,
|
|
2560
|
+
exitCode: EXIT_CODES.REPO_STATE_INVALID
|
|
2561
|
+
});
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
const mainBranch = strategy.requiresDevelop() ? await detectBaseBranch(cwd) : plan.targetBranch;
|
|
2565
|
+
const notesPath = join6(cwd, ".gitwise", `release-${plan.newVersion}.md`);
|
|
2566
|
+
let notes;
|
|
2567
|
+
try {
|
|
2568
|
+
notes = await readFile6(notesPath, "utf-8");
|
|
2569
|
+
} catch (err) {
|
|
2570
|
+
if (err.code === "ENOENT") {
|
|
2571
|
+
debug("release.finish.notes.missing", { path: notesPath });
|
|
2572
|
+
notes = plan.notes;
|
|
2573
|
+
} else {
|
|
2574
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
2575
|
+
debug("release.finish.notes.read.failed", { path: notesPath, error: cause });
|
|
2576
|
+
throw new GitwiseError({
|
|
2577
|
+
code: "NOTES_READ_FAILED",
|
|
2578
|
+
message: `Failed to read release notes at ${notesPath}: ${cause}. Recreate the file from the plan or run "gw release abort" to discard the in-flight release.`,
|
|
2579
|
+
cause: err
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (!plan.releaseBranchCreated) {
|
|
2584
|
+
const pkgPath = join6(cwd, "package.json");
|
|
2585
|
+
const pkg = await readJSON(pkgPath);
|
|
2586
|
+
pkg["version"] = plan.newVersion;
|
|
2587
|
+
await writeJSON(pkgPath, pkg);
|
|
2588
|
+
let propagatedManifests = [];
|
|
2589
|
+
if (workspacePropagation) {
|
|
2590
|
+
propagatedManifests = await propagateVersionToWorkspaces(cwd, plan.newVersion);
|
|
2591
|
+
}
|
|
2592
|
+
const changelogPath = join6(cwd, "CHANGELOG.md");
|
|
2593
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
2594
|
+
const versionHeader = `## [${plan.newVersion}] - ${date}
|
|
2595
|
+
|
|
2596
|
+
${plan.changelog}
|
|
2597
|
+
|
|
2598
|
+
`;
|
|
2599
|
+
if (await fileExists(changelogPath)) {
|
|
2600
|
+
const existing = await readFile6(changelogPath, "utf-8");
|
|
2601
|
+
const headerEnd = existing.indexOf("## [");
|
|
2602
|
+
if (headerEnd > 0) {
|
|
2603
|
+
await writeFile3(
|
|
2604
|
+
changelogPath,
|
|
2605
|
+
existing.slice(0, headerEnd) + versionHeader + existing.slice(headerEnd),
|
|
2606
|
+
"utf-8"
|
|
2607
|
+
);
|
|
2608
|
+
} else {
|
|
2609
|
+
const body = existing.startsWith(CHANGELOG_HEADER) ? existing.slice(CHANGELOG_HEADER.length) : existing;
|
|
2610
|
+
await writeFile3(
|
|
2611
|
+
changelogPath,
|
|
2612
|
+
CHANGELOG_HEADER + versionHeader + body,
|
|
2613
|
+
"utf-8"
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
} else {
|
|
2617
|
+
await writeFile3(changelogPath, CHANGELOG_HEADER + versionHeader, "utf-8");
|
|
2618
|
+
}
|
|
2619
|
+
const stagePaths = ["package.json", "CHANGELOG.md"];
|
|
2620
|
+
if (await fileExists(join6(cwd, ".gitignore"))) {
|
|
2621
|
+
stagePaths.push(".gitignore");
|
|
2622
|
+
}
|
|
2623
|
+
stagePaths.push(...propagatedManifests);
|
|
2624
|
+
await applyCommit({
|
|
2625
|
+
message: `chore(release): v${plan.newVersion}`,
|
|
2626
|
+
files: stagePaths,
|
|
2627
|
+
cwd
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
await deleteReleasePlan(cwd);
|
|
2631
|
+
const mergeTargets = strategy.mergeTargets(mainBranch, developBranch);
|
|
2632
|
+
for (const target of mergeTargets) {
|
|
2633
|
+
if (target === plan.targetBranch) continue;
|
|
2634
|
+
await checkout(cwd, target);
|
|
2635
|
+
try {
|
|
2636
|
+
await mergeNoFf(cwd, plan.targetBranch);
|
|
2637
|
+
} catch (err) {
|
|
2638
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
2639
|
+
debug("release.finish.merge.failed", {
|
|
2640
|
+
target,
|
|
2641
|
+
source: plan.targetBranch,
|
|
2642
|
+
error: cause
|
|
2643
|
+
});
|
|
2644
|
+
throw new GitwiseError({
|
|
2645
|
+
code: "FINISH_MERGE_CONFLICT",
|
|
2646
|
+
message: `Failed to merge "${plan.targetBranch}" into "${target}" while finishing v${plan.newVersion}. The release plan file has already been deleted, so finish cannot be re-run. Resolve the conflicts, run "git merge --continue", then tag and push manually: git tag -a v${plan.newVersion} -F .gitwise/release-${plan.newVersion}.md && git push --follow-tags origin ${mainBranch}.
|
|
2647
|
+
${cause}`,
|
|
2648
|
+
exitCode: EXIT_CODES.GIT_FAILED,
|
|
2649
|
+
cause: err,
|
|
2650
|
+
details: {
|
|
2651
|
+
target,
|
|
2652
|
+
source: plan.targetBranch,
|
|
2653
|
+
newVersion: plan.newVersion
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
}
|
|
2657
|
+
debug("release.finish.merge.target", {
|
|
2658
|
+
target,
|
|
2659
|
+
source: plan.targetBranch
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
if (await getBranch(cwd) !== mainBranch) {
|
|
2663
|
+
await checkout(cwd, mainBranch);
|
|
2664
|
+
}
|
|
2665
|
+
if (tagAndPush) {
|
|
2666
|
+
await createTag(cwd, tag, notes, { signed: signTags !== false });
|
|
2667
|
+
await pushWithTags(cwd, "origin", mainBranch);
|
|
2668
|
+
debug("release.finish.tag.pushed", {
|
|
2669
|
+
tag,
|
|
2670
|
+
branch: mainBranch,
|
|
2671
|
+
remote: "origin"
|
|
2672
|
+
});
|
|
2673
|
+
if (strategy.requiresDevelop()) {
|
|
2674
|
+
await push(cwd, "origin", developBranch);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
if (createGhRelease) {
|
|
2678
|
+
if (await isGhAvailable()) {
|
|
2679
|
+
try {
|
|
2680
|
+
await createGitHubRelease({
|
|
2681
|
+
tag,
|
|
2682
|
+
title: tag,
|
|
2683
|
+
body: notes,
|
|
2684
|
+
cwd
|
|
2685
|
+
});
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
debug("release.finish.gh.failed", {
|
|
2688
|
+
tag,
|
|
2689
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
} else {
|
|
2693
|
+
debug("gh not available, skipping GitHub release creation");
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
if (plan.releaseBranchCreated && deleteReleaseBranch) {
|
|
2697
|
+
try {
|
|
2698
|
+
await deleteBranch(cwd, plan.targetBranch);
|
|
2699
|
+
} catch (err) {
|
|
2700
|
+
debug("release.finish.branch.delete.failed", {
|
|
2701
|
+
branch: plan.targetBranch,
|
|
2702
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
async function abortRelease(opts) {
|
|
2708
|
+
const { cwd, deleteBranch: deleteBranch2 = false } = opts;
|
|
2709
|
+
debug("release.abort.start", { cwd, deleteBranch: deleteBranch2 });
|
|
2710
|
+
const plan = await loadReleasePlan(cwd);
|
|
2711
|
+
if (!plan) {
|
|
2712
|
+
throw new GitwiseError({
|
|
2713
|
+
code: "NO_RELEASE_PLAN",
|
|
2714
|
+
message: `No release plan found at .gitwise/release-plan.json. Nothing to abort.`,
|
|
2715
|
+
exitCode: EXIT_CODES.RELEASE_PLAN_STALE
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
const shouldDeleteBranch = deleteBranch2 && plan.releaseBranchCreated;
|
|
2719
|
+
let mainBranch = "";
|
|
2720
|
+
if (shouldDeleteBranch) {
|
|
2721
|
+
const strategy = createReleaseStrategy(plan.strategy);
|
|
2722
|
+
const repoConfig = await readRepoConfig(cwd);
|
|
2723
|
+
const developBranch = repoConfig?.developBranch ?? "develop";
|
|
2724
|
+
mainBranch = strategy.requiresDevelop() ? await detectBaseBranch(cwd) : plan.targetBranch;
|
|
2725
|
+
for (const target of strategy.mergeTargets(mainBranch, developBranch)) {
|
|
2726
|
+
if (target === plan.targetBranch) continue;
|
|
2727
|
+
if (!await isBranchMerged(cwd, plan.targetBranch, target)) {
|
|
2728
|
+
throw new GitwiseError({
|
|
2729
|
+
code: "RELEASE_BRANCH_UNMERGED",
|
|
2730
|
+
message: `Refusing to delete release branch "${plan.targetBranch}" \u2014 it has commits not present in "${target}". Merge or cherry-pick them first, or remove the branch manually.`,
|
|
2731
|
+
exitCode: EXIT_CODES.RELEASE_BRANCH_CONFLICT
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
await deleteReleasePlan(cwd);
|
|
2737
|
+
if (shouldDeleteBranch) {
|
|
2738
|
+
if (await getBranch(cwd) === plan.targetBranch) {
|
|
2739
|
+
await checkout(cwd, mainBranch);
|
|
2740
|
+
}
|
|
2741
|
+
await deleteBranch(cwd, plan.targetBranch);
|
|
2742
|
+
debug("release.abort.branch.deleted", { branch: plan.targetBranch });
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
async function runReleaseInProcess(opts) {
|
|
2746
|
+
const plan = await prepareRelease(opts);
|
|
2747
|
+
const resolveDeleteBranch = async () => {
|
|
2748
|
+
const setting = opts.confirmAbortDeletesBranch;
|
|
2749
|
+
if (typeof setting !== "function") return setting ?? false;
|
|
2750
|
+
try {
|
|
2751
|
+
return await setting(plan) === true;
|
|
2752
|
+
} catch {
|
|
2753
|
+
return false;
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
let confirmed;
|
|
2757
|
+
try {
|
|
2758
|
+
confirmed = await opts.confirm(plan);
|
|
2759
|
+
} catch (err) {
|
|
2760
|
+
await abortRelease({
|
|
2761
|
+
cwd: opts.cwd,
|
|
2762
|
+
deleteBranch: await resolveDeleteBranch()
|
|
2763
|
+
});
|
|
2764
|
+
throw err;
|
|
2765
|
+
}
|
|
2766
|
+
if (!confirmed) {
|
|
2767
|
+
await abortRelease({
|
|
2768
|
+
cwd: opts.cwd,
|
|
2769
|
+
deleteBranch: await resolveDeleteBranch()
|
|
2770
|
+
});
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
await finishRelease({ cwd: opts.cwd, ...opts.finishOptions });
|
|
2774
|
+
return plan;
|
|
2775
|
+
}
|
|
2776
|
+
async function gitignoreMatchesPrepareOutput(cwd) {
|
|
2777
|
+
const headContent = await showFileAtHead(cwd, ".gitignore") ?? "";
|
|
2778
|
+
const gitignorePath = join6(cwd, ".gitignore");
|
|
2779
|
+
const currentContent = await fileExists(gitignorePath) ? await readFile6(gitignorePath, "utf-8") : "";
|
|
2780
|
+
let expected = applyGitignoreEntry(headContent, RELEASE_PLAN_REL_PATH);
|
|
2781
|
+
expected = applyGitignoreEntry(expected, RELEASE_NOTES_GLOB_REL_PATH);
|
|
2782
|
+
return currentContent === expected;
|
|
2783
|
+
}
|
|
2784
|
+
function writeWorkspaceVersionStep(manifestPath, newVersion) {
|
|
2785
|
+
return {
|
|
2786
|
+
name: `write-version:${manifestPath}`,
|
|
2787
|
+
apply: async () => {
|
|
2788
|
+
const priorBytes = await readFile6(manifestPath);
|
|
2789
|
+
const parsed = JSON.parse(priorBytes.toString("utf-8"));
|
|
2790
|
+
parsed["version"] = newVersion;
|
|
2791
|
+
await writeJSON(manifestPath, parsed);
|
|
2792
|
+
return priorBytes;
|
|
2793
|
+
},
|
|
2794
|
+
compensate: async (priorBytes) => {
|
|
2795
|
+
await writeFile3(manifestPath, priorBytes);
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
}
|
|
2799
|
+
var txLogger = {
|
|
2800
|
+
warn(message, context) {
|
|
2801
|
+
warn(`[gitwise] ${message}`, context);
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
async function propagateVersionToWorkspaces(cwd, version2) {
|
|
2805
|
+
const releaseLock = await acquireRepoLock(cwd, {
|
|
2806
|
+
command: "release propagate-version"
|
|
2807
|
+
});
|
|
2808
|
+
try {
|
|
2809
|
+
const tx = new Transaction();
|
|
2810
|
+
try {
|
|
2811
|
+
return await runWorkspaceVersionStepsInto(tx, cwd, version2);
|
|
2812
|
+
} catch (err) {
|
|
2813
|
+
const reason = err instanceof GitwiseError ? err : new GitwiseError({
|
|
2814
|
+
code: "WORKSPACE_VERSION_WRITE_FAILED",
|
|
2815
|
+
message: `Failed to propagate version ${version2} to workspaces: ${err instanceof Error ? err.message : String(err)}`,
|
|
2816
|
+
exitCode: EXIT_CODES.GIT_FAILED,
|
|
2817
|
+
cause: err
|
|
2818
|
+
});
|
|
2819
|
+
await tx.rollback(reason, txLogger);
|
|
2820
|
+
throw reason;
|
|
2821
|
+
}
|
|
2822
|
+
} finally {
|
|
2823
|
+
await releaseLock();
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
async function runWorkspaceVersionStepsInto(tx, cwd, version2) {
|
|
2827
|
+
const patterns = await readWorkspacePatterns(cwd);
|
|
2828
|
+
const workspaceDirs = (await expandWorkspacePatterns(cwd, patterns)).sort();
|
|
2829
|
+
const modified = [];
|
|
2830
|
+
for (const dir of workspaceDirs) {
|
|
2831
|
+
const pkgPath = join6(dir, "package.json");
|
|
2832
|
+
if (await fileExists(pkgPath)) {
|
|
2833
|
+
await tx.run(writeWorkspaceVersionStep(pkgPath, version2));
|
|
2834
|
+
modified.push(relative(cwd, pkgPath));
|
|
2835
|
+
}
|
|
2836
|
+
const pluginPath = join6(dir, "plugin.json");
|
|
2837
|
+
if (await fileExists(pluginPath)) {
|
|
2838
|
+
await tx.run(writeWorkspaceVersionStep(pluginPath, version2));
|
|
2839
|
+
modified.push(relative(cwd, pluginPath));
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return modified;
|
|
2843
|
+
}
|
|
2844
|
+
async function detectWorkspaceRoot(cwd) {
|
|
2845
|
+
const patterns = await readWorkspacePatterns(cwd);
|
|
2846
|
+
const dirs = await expandWorkspacePatterns(cwd, patterns);
|
|
2847
|
+
for (const dir of dirs) {
|
|
2848
|
+
if (await fileExists(join6(dir, "package.json"))) return true;
|
|
2849
|
+
}
|
|
2850
|
+
return false;
|
|
2851
|
+
}
|
|
2852
|
+
async function readWorkspacePatterns(cwd) {
|
|
2853
|
+
const pkgPath = join6(cwd, "package.json");
|
|
2854
|
+
if (!await fileExists(pkgPath)) return ["packages/*"];
|
|
2855
|
+
let parsed;
|
|
2856
|
+
try {
|
|
2857
|
+
parsed = await readJSON(pkgPath);
|
|
2858
|
+
} catch {
|
|
2859
|
+
return ["packages/*"];
|
|
2860
|
+
}
|
|
2861
|
+
const ws = parsed.workspaces;
|
|
2862
|
+
const fromArray = Array.isArray(ws) ? ws.filter((p) => typeof p === "string" && p.length > 0) : [];
|
|
2863
|
+
if (fromArray.length > 0) return fromArray;
|
|
2864
|
+
if (ws && typeof ws === "object" && !Array.isArray(ws)) {
|
|
2865
|
+
const inner = ws.packages;
|
|
2866
|
+
if (Array.isArray(inner)) {
|
|
2867
|
+
const fromObject = inner.filter(
|
|
2868
|
+
(p) => typeof p === "string" && p.length > 0
|
|
2869
|
+
);
|
|
2870
|
+
if (fromObject.length > 0) return fromObject;
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
return ["packages/*"];
|
|
2874
|
+
}
|
|
2875
|
+
async function expandWorkspacePatterns(cwd, patterns) {
|
|
2876
|
+
const { readdir } = await import("fs/promises");
|
|
2877
|
+
const readdirFn = readdir;
|
|
2878
|
+
const matched = /* @__PURE__ */ new Set();
|
|
2879
|
+
for (const pattern of patterns) {
|
|
2880
|
+
if (pattern.startsWith("!")) continue;
|
|
2881
|
+
const segments = pattern.split("/").filter((s) => s.length > 0);
|
|
2882
|
+
if (segments.length === 0) continue;
|
|
2883
|
+
await walkWorkspaceSegments(cwd, segments, 0, matched, readdirFn);
|
|
2884
|
+
}
|
|
2885
|
+
return Array.from(matched);
|
|
2886
|
+
}
|
|
2887
|
+
async function walkWorkspaceSegments(current, segments, index, out, readdirFn) {
|
|
2888
|
+
if (index >= segments.length) {
|
|
2889
|
+
out.add(current);
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
const segment = segments[index] ?? "";
|
|
2893
|
+
if (!segment.includes("*")) {
|
|
2894
|
+
await walkWorkspaceSegments(
|
|
2895
|
+
join6(current, segment),
|
|
2896
|
+
segments,
|
|
2897
|
+
index + 1,
|
|
2898
|
+
out,
|
|
2899
|
+
readdirFn
|
|
2900
|
+
);
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
let entries;
|
|
2904
|
+
try {
|
|
2905
|
+
entries = await readdirFn(current, { withFileTypes: true });
|
|
2906
|
+
} catch {
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
const regex = segmentToRegex(segment);
|
|
2910
|
+
for (const entry of entries) {
|
|
2911
|
+
if (!entry.isDirectory()) continue;
|
|
2912
|
+
if (!regex.test(entry.name)) continue;
|
|
2913
|
+
await walkWorkspaceSegments(
|
|
2914
|
+
join6(current, entry.name),
|
|
2915
|
+
segments,
|
|
2916
|
+
index + 1,
|
|
2917
|
+
out,
|
|
2918
|
+
readdirFn
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
function segmentToRegex(segment) {
|
|
2923
|
+
const escaped = segment.split(/(\*|\?)/).map((part) => {
|
|
2924
|
+
if (part === "*") return "[^/]*";
|
|
2925
|
+
if (part === "?") return "[^/]";
|
|
2926
|
+
return part.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2927
|
+
}).join("");
|
|
2928
|
+
return new RegExp(`^${escaped}$`);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/providers/anthropic.ts
|
|
2932
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2933
|
+
var DEFAULT_MAX_TOKENS = 4096;
|
|
2934
|
+
var DEFAULT_TIMEOUT_MS2 = 12e4;
|
|
2935
|
+
var MAX_RETRIES = 3;
|
|
2936
|
+
var BASE_DELAY_MS = 1e3;
|
|
2937
|
+
var AnthropicProvider = class {
|
|
2938
|
+
client;
|
|
2939
|
+
models;
|
|
2940
|
+
constructor(apiKey, models) {
|
|
2941
|
+
this.client = new Anthropic({
|
|
2942
|
+
apiKey: apiKey ?? process.env["ANTHROPIC_API_KEY"],
|
|
2943
|
+
timeout: DEFAULT_TIMEOUT_MS2
|
|
2944
|
+
});
|
|
2945
|
+
this.models = models;
|
|
2946
|
+
}
|
|
2947
|
+
async chat(req) {
|
|
2948
|
+
const modelId = this.resolveModel(req.tier);
|
|
2949
|
+
debug("Calling Anthropic API", { model: modelId, tier: req.tier });
|
|
2950
|
+
return this.callWithRetry(req, modelId);
|
|
2951
|
+
}
|
|
2952
|
+
async callWithRetry(req, modelId) {
|
|
2953
|
+
let lastError;
|
|
2954
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
2955
|
+
try {
|
|
2956
|
+
return await this.callApi(req, modelId);
|
|
2957
|
+
} catch (err) {
|
|
2958
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2959
|
+
if (this.isRetryable(err)) {
|
|
2960
|
+
const delay = BASE_DELAY_MS * Math.pow(2, attempt);
|
|
2961
|
+
debug("Retrying after error", { attempt, delay, error: lastError.message });
|
|
2962
|
+
await this.sleep(delay);
|
|
2963
|
+
continue;
|
|
2964
|
+
}
|
|
2965
|
+
throw lastError;
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
throw new GitwiseError({
|
|
2969
|
+
code: "API_RATE_LIMITED",
|
|
2970
|
+
message: lastError?.message ?? "Max retries exceeded",
|
|
2971
|
+
cause: lastError
|
|
2972
|
+
});
|
|
2973
|
+
}
|
|
2974
|
+
async callApi(req, modelId) {
|
|
2975
|
+
const response = await this.client.messages.create({
|
|
2976
|
+
model: modelId,
|
|
2977
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
2978
|
+
system: req.systemPrompt,
|
|
2979
|
+
messages: [{ role: "user", content: req.userMessage }]
|
|
2980
|
+
});
|
|
2981
|
+
const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
2982
|
+
return {
|
|
2983
|
+
content: text,
|
|
2984
|
+
tokens: {
|
|
2985
|
+
input: response.usage.input_tokens,
|
|
2986
|
+
output: response.usage.output_tokens
|
|
2987
|
+
}
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
resolveModel(tier) {
|
|
2991
|
+
return this.models[tier];
|
|
2992
|
+
}
|
|
2993
|
+
isRetryable(err) {
|
|
2994
|
+
if (err instanceof Anthropic.APIError) {
|
|
2995
|
+
return err.status === 429 || err.status === 529;
|
|
2996
|
+
}
|
|
2997
|
+
return false;
|
|
2998
|
+
}
|
|
2999
|
+
sleep(ms) {
|
|
3000
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3001
|
+
}
|
|
3002
|
+
};
|
|
3003
|
+
|
|
3004
|
+
// src/providers/factory.ts
|
|
3005
|
+
function createProvider(config) {
|
|
3006
|
+
if (config.kind === "claude-code") {
|
|
3007
|
+
return new ClaudeCodeProvider(config.models, config.claudeCliPath);
|
|
3008
|
+
}
|
|
3009
|
+
return new AnthropicProvider(config.apiKey, config.models);
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
// src/index.ts
|
|
3013
|
+
var version = package_default.version;
|
|
3014
|
+
var __placeholder__ = /* @__PURE__ */ Symbol.for("@denisvieiradev/gitwise-core#placeholder");
|
|
3015
|
+
export {
|
|
3016
|
+
DEFAULT_USER_CONFIG,
|
|
3017
|
+
EXIT_CODES,
|
|
3018
|
+
GitwiseError,
|
|
3019
|
+
STALE_LOCK_MS,
|
|
3020
|
+
SUPPORTED_COMMANDS,
|
|
3021
|
+
Transaction,
|
|
3022
|
+
__placeholder__,
|
|
3023
|
+
abortRelease,
|
|
3024
|
+
acquireRepoLock,
|
|
3025
|
+
applyCommitPlan,
|
|
3026
|
+
applyOneCommitStep,
|
|
3027
|
+
applyPr,
|
|
3028
|
+
applyRelease,
|
|
3029
|
+
bumpVersion,
|
|
3030
|
+
commit2 as commit,
|
|
3031
|
+
createProvider,
|
|
3032
|
+
createReleaseStrategy,
|
|
3033
|
+
debug,
|
|
3034
|
+
deleteReleasePlan,
|
|
3035
|
+
detectWorkspaceRoot,
|
|
3036
|
+
ensureDir,
|
|
3037
|
+
ensureGitignored,
|
|
3038
|
+
env_exports as env,
|
|
3039
|
+
error,
|
|
3040
|
+
fileExists,
|
|
3041
|
+
finishRelease,
|
|
3042
|
+
getApiKey,
|
|
3043
|
+
getMergedConfig,
|
|
3044
|
+
git_exports as git,
|
|
3045
|
+
github_exports as github,
|
|
3046
|
+
heuristicBump,
|
|
3047
|
+
info,
|
|
3048
|
+
interpolate,
|
|
3049
|
+
isVerbose,
|
|
3050
|
+
loadAndInterpolate,
|
|
3051
|
+
loadReleasePlan,
|
|
3052
|
+
loadTemplate,
|
|
3053
|
+
parseCommitResponse,
|
|
3054
|
+
pr,
|
|
3055
|
+
prepareRelease,
|
|
3056
|
+
propagateVersionToWorkspaces,
|
|
3057
|
+
readJSON,
|
|
3058
|
+
readRepoConfig,
|
|
3059
|
+
readUserConfig,
|
|
3060
|
+
release,
|
|
3061
|
+
resolveClaudeBinary,
|
|
3062
|
+
resolveModelTier,
|
|
3063
|
+
review,
|
|
3064
|
+
runReleaseInProcess,
|
|
3065
|
+
saveReleasePlan,
|
|
3066
|
+
setVerbose,
|
|
3067
|
+
stashList,
|
|
3068
|
+
takeNamedStashStep,
|
|
3069
|
+
version,
|
|
3070
|
+
warn,
|
|
3071
|
+
wrapError,
|
|
3072
|
+
writeApiKey,
|
|
3073
|
+
writeJSON,
|
|
3074
|
+
writeUserConfig,
|
|
3075
|
+
writeWorkspaceVersionStep
|
|
3076
|
+
};
|
|
3077
|
+
//# sourceMappingURL=index.js.map
|