@hunan36/dsh-git-flow 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +253 -2
- package/README.zh.md +262 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2138 -0
- package/lib/index.js +1347 -0
- package/lib/types/client/BranchChip.d.ts +8 -0
- package/lib/types/client/BranchMenu.d.ts +27 -0
- package/lib/types/client/CommitDialog.d.ts +45 -0
- package/lib/types/client/GitFlowDialog.d.ts +37 -0
- package/lib/types/client/api.d.ts +42 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/locales.d.ts +236 -0
- package/lib/types/client/styles.d.ts +12 -0
- package/lib/types/contract.d.ts +207 -0
- package/lib/types/git.d.ts +86 -0
- package/lib/types/index.d.ts +12 -0
- package/lib/types/message.d.ts +27 -0
- package/lib/types/routes.d.ts +14 -0
- package/lib/types/service.d.ts +178 -0
- package/package.json +92 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import * as nodePath from "node:path";
|
|
5
|
+
//#region src/git.ts
|
|
6
|
+
/** A git operation that cannot be represented as a normal exit code. */
|
|
7
|
+
var GitError = class extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "GitError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** Cap on collected stdout, in bytes. */
|
|
16
|
+
const MAX_STDOUT_BYTES = 4194304;
|
|
17
|
+
/** Cap on collected stderr, in bytes. */
|
|
18
|
+
const MAX_STDERR_BYTES = 65536;
|
|
19
|
+
/**
|
|
20
|
+
* Run git without a shell and without ambient credentials leaking in.
|
|
21
|
+
* One instance per service; the executable is resolved lazily and cached.
|
|
22
|
+
*/
|
|
23
|
+
var GitRunner = class {
|
|
24
|
+
subprocess;
|
|
25
|
+
timeoutMs;
|
|
26
|
+
executable;
|
|
27
|
+
constructor(subprocess, timeoutMs) {
|
|
28
|
+
this.subprocess = subprocess;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
}
|
|
31
|
+
program() {
|
|
32
|
+
this.executable ??= this.subprocess.resolveExecutable("git").catch((error) => {
|
|
33
|
+
throw new GitError("git/not-installed", `git executable is unavailable: ${String(error)}`);
|
|
34
|
+
});
|
|
35
|
+
return this.executable;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* @param cwd - directory the command runs in.
|
|
39
|
+
* @param args - git subcommand and flags; never interpolated into a shell.
|
|
40
|
+
* @returns exit code and collected streams; a non-zero exit is a result.
|
|
41
|
+
* @throws {GitError} when git is missing, the run times out, or spawn fails.
|
|
42
|
+
*/
|
|
43
|
+
async run(cwd, args) {
|
|
44
|
+
const program = await this.program();
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("git command timed out")), this.timeoutMs);
|
|
47
|
+
try {
|
|
48
|
+
const handle = this.subprocess.spawn({
|
|
49
|
+
argv: [program, ...args],
|
|
50
|
+
cwd,
|
|
51
|
+
stdio: {
|
|
52
|
+
stdin: "ignore",
|
|
53
|
+
stdout: { maxBytes: MAX_STDOUT_BYTES },
|
|
54
|
+
stderr: { maxBytes: MAX_STDERR_BYTES }
|
|
55
|
+
},
|
|
56
|
+
graceMs: 2e3,
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
env: {
|
|
59
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
60
|
+
LC_ALL: "C",
|
|
61
|
+
LANG: "C"
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
let exitCode;
|
|
65
|
+
try {
|
|
66
|
+
exitCode = (await handle.done).exitCode ?? -1;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (controller.signal.aborted) throw timeoutError(args);
|
|
69
|
+
throw new GitError("git/failed", `failed to run git ${args.join(" ")}: ${String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
if (controller.signal.aborted) throw timeoutError(args);
|
|
72
|
+
return {
|
|
73
|
+
exitCode,
|
|
74
|
+
stdout: handle.collected.stdout?.readFrom(0).text ?? "",
|
|
75
|
+
stderr: handle.collected.stderr?.readFrom(0).text ?? ""
|
|
76
|
+
};
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Run git and treat a non-zero exit as a `git/failed` error. */
|
|
82
|
+
async runOk(cwd, args) {
|
|
83
|
+
const result = await this.run(cwd, args);
|
|
84
|
+
if (result.exitCode !== 0) throw new GitError("git/failed", describeFailure(args, result));
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
function timeoutError(args) {
|
|
89
|
+
return new GitError("git/timeout", `git ${args.join(" ")} timed out`);
|
|
90
|
+
}
|
|
91
|
+
/** Trim git's stderr into one line suitable for a UI detail message. */
|
|
92
|
+
function describeFailure(args, result) {
|
|
93
|
+
const detail = (result.stderr || result.stdout).replace(/\s+/g, " ").trim();
|
|
94
|
+
return `git ${args.join(" ")} failed (${result.exitCode}): ${detail || "no output"}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Parse `git status --porcelain=v2 --branch -z`.
|
|
98
|
+
*
|
|
99
|
+
* The `-z` form is the only one whose paths can be trusted: in the line-based
|
|
100
|
+
* form git C-quotes any path holding a non-ASCII byte, a double quote, or a
|
|
101
|
+
* backslash, so a Chinese file name arrives as `"docs/00-\350\265\204..."`
|
|
102
|
+
* with the quotes and escapes intact. Handing that text back to git as a
|
|
103
|
+
* pathspec fails with `pathspec ... did not match any files`, which is why
|
|
104
|
+
* committing a Chinese-named file used to break. `-z` terminates every record
|
|
105
|
+
* (headers included) with NUL and never quotes a path.
|
|
106
|
+
* @param stdout - raw porcelain output.
|
|
107
|
+
* @returns branch facts and one entry per changed path.
|
|
108
|
+
*/
|
|
109
|
+
function parseStatus(stdout) {
|
|
110
|
+
const status = {
|
|
111
|
+
head: "(unknown)",
|
|
112
|
+
oid: "",
|
|
113
|
+
ahead: 0,
|
|
114
|
+
behind: 0,
|
|
115
|
+
files: []
|
|
116
|
+
};
|
|
117
|
+
const records = stdout.split("\0");
|
|
118
|
+
for (let i = 0; i < records.length; i++) {
|
|
119
|
+
const line = records[i];
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
if (line.startsWith("# branch.oid ")) status.oid = line.slice(13).trim();
|
|
122
|
+
else if (line.startsWith("# branch.head ")) status.head = line.slice(14).trim();
|
|
123
|
+
else if (line.startsWith("# branch.upstream ")) status.upstream = line.slice(18).trim();
|
|
124
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
125
|
+
for (const field of line.slice(12).trim().split(/\s+/)) if (field.startsWith("+")) status.ahead = Number(field.slice(1)) || 0;
|
|
126
|
+
else if (field.startsWith("-")) status.behind = Number(field.slice(1)) || 0;
|
|
127
|
+
} else if (line.startsWith("? ")) status.files.push({
|
|
128
|
+
path: line.slice(2),
|
|
129
|
+
indexStatus: "?",
|
|
130
|
+
worktreeStatus: "?",
|
|
131
|
+
staged: false,
|
|
132
|
+
untracked: true,
|
|
133
|
+
conflicted: false
|
|
134
|
+
});
|
|
135
|
+
else if (line.startsWith("u ")) {
|
|
136
|
+
const fields = line.split(" ");
|
|
137
|
+
const path = fields.slice(10).join(" ");
|
|
138
|
+
status.files.push({
|
|
139
|
+
path,
|
|
140
|
+
indexStatus: fields[1][0],
|
|
141
|
+
worktreeStatus: fields[1][1],
|
|
142
|
+
staged: false,
|
|
143
|
+
untracked: false,
|
|
144
|
+
conflicted: true
|
|
145
|
+
});
|
|
146
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
147
|
+
const fields = line.split(" ");
|
|
148
|
+
const xy = fields[1] ?? "..";
|
|
149
|
+
const path = fields.slice(line[0] === "2" ? 9 : 8).join(" ");
|
|
150
|
+
const oldPath = line[0] === "2" ? records[++i] : void 0;
|
|
151
|
+
status.files.push({
|
|
152
|
+
path,
|
|
153
|
+
oldPath,
|
|
154
|
+
indexStatus: xy[0],
|
|
155
|
+
worktreeStatus: xy[1],
|
|
156
|
+
staged: xy[0] !== ".",
|
|
157
|
+
untracked: false,
|
|
158
|
+
conflicted: false
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return status;
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/message.ts
|
|
166
|
+
/** Bound on the auxiliary call itself, independent of git timeouts. */
|
|
167
|
+
const MESSAGE_TIMEOUT_MS = 6e4;
|
|
168
|
+
/**
|
|
169
|
+
* Output budget for the auxiliary call. A reasoning model counts its thinking
|
|
170
|
+
* against this, and with a wide selection (many files, a truncated diff) the
|
|
171
|
+
* thinking alone can exhaust a small budget: the stream then ends with no text
|
|
172
|
+
* at all and the answer is unusable. 4096 leaves room for both.
|
|
173
|
+
*/
|
|
174
|
+
const MESSAGE_MAX_TOKENS = 4096;
|
|
175
|
+
/** Commit messages longer than this are treated as the model rambling. */
|
|
176
|
+
const MAX_MESSAGE_CHARS = 2e3;
|
|
177
|
+
const SYSTEM = {
|
|
178
|
+
zh: [
|
|
179
|
+
"你在为一次 git 提交写提交信息。",
|
|
180
|
+
"只输出提交信息本身:不要 markdown 代码块、不要引号、不要解释、不要提问。",
|
|
181
|
+
"第一行必须是 conventional commit 标题:`<type>: <一句话描述>`,可在 type 后加 `(scope)`。",
|
|
182
|
+
"type 只能取 feat fix refactor perf docs style test build chore revert。",
|
|
183
|
+
"标题不超过 72 个字符,用祈使句,结尾不加句号。",
|
|
184
|
+
"空一行后用一到三条 `- ` 要点说明改动动机与影响。",
|
|
185
|
+
"标题与正文都用简体中文,即使 diff、文件名或分支名是英文;type 前缀、文件路径、标识符、API 名保持原文。"
|
|
186
|
+
].join("\n"),
|
|
187
|
+
en: [
|
|
188
|
+
"You are writing a git commit message.",
|
|
189
|
+
"Output only the message: no markdown fences, no quotes, no explanation, no questions.",
|
|
190
|
+
"The first line must be a conventional commit subject: `<type>: <summary>`, optionally `<type>(scope): <summary>`.",
|
|
191
|
+
"type must be one of feat fix refactor perf docs style test build chore revert.",
|
|
192
|
+
"Keep the subject at or below 72 characters, imperative mood, no trailing period.",
|
|
193
|
+
"After a blank line add one to three `- ` bullets covering motivation and impact.",
|
|
194
|
+
"Write both the subject and the body in English, even when the diff, file names, or branch name are not English; keep file paths, identifiers, and API names verbatim."
|
|
195
|
+
].join("\n")
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Produce one commit message for the selection.
|
|
199
|
+
* @throws {GitError} `git/no-route` when the session has no logged model route.
|
|
200
|
+
* @returns the message plus whether the template fallback had to be used, and
|
|
201
|
+
* why it was, so the panel can report a broken route instead of a vague notice.
|
|
202
|
+
*/
|
|
203
|
+
async function generateCommitMessage(input) {
|
|
204
|
+
const config = input.session?.requestHeader()?.config;
|
|
205
|
+
if (config === void 0) throw new GitError("git/no-route", "this session has no recorded model route yet; send one message in the conversation first");
|
|
206
|
+
const route = `${config.provider}/${config.model}`;
|
|
207
|
+
const fallback = fallbackMessage(input);
|
|
208
|
+
let raw = "";
|
|
209
|
+
try {
|
|
210
|
+
raw = await streamMessage(input, config.provider, config.model);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
input.ctx.logger.warn(`dsh-git-flow: message model failed, using template: ${String(error)}`);
|
|
213
|
+
return {
|
|
214
|
+
message: fallback,
|
|
215
|
+
fallback: true,
|
|
216
|
+
reason: describeModelFailure(error),
|
|
217
|
+
language: input.language,
|
|
218
|
+
route
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
const message = normalize(raw);
|
|
222
|
+
if (message === void 0) {
|
|
223
|
+
input.ctx.logger.warn(`dsh-git-flow: message model answer rejected, using template: ${JSON.stringify(raw.slice(0, 200))}`);
|
|
224
|
+
return {
|
|
225
|
+
message: fallback,
|
|
226
|
+
fallback: true,
|
|
227
|
+
reason: raw.trim().length === 0 ? "the model returned no text (its output budget may have gone to reasoning)" : "the model answer was not a conventional commit message",
|
|
228
|
+
language: input.language,
|
|
229
|
+
route
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
message,
|
|
234
|
+
fallback: false,
|
|
235
|
+
language: input.language,
|
|
236
|
+
route
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** One line of failure text for the panel, without a stack or multi-line dump. */
|
|
240
|
+
function describeModelFailure(error) {
|
|
241
|
+
return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim().slice(0, 300) || "unknown model failure";
|
|
242
|
+
}
|
|
243
|
+
/** One model call, returning its concatenated text blocks. */
|
|
244
|
+
async function streamMessage(input, provider, model) {
|
|
245
|
+
const signal = AbortSignal.timeout(MESSAGE_TIMEOUT_MS);
|
|
246
|
+
const assembler = new TextAssembly();
|
|
247
|
+
for await (const chunk of input.ctx.llm.stream({
|
|
248
|
+
provider,
|
|
249
|
+
model,
|
|
250
|
+
system: SYSTEM[input.language],
|
|
251
|
+
messages: [{
|
|
252
|
+
role: "user",
|
|
253
|
+
content: [{
|
|
254
|
+
type: "text",
|
|
255
|
+
text: buildUserPrompt(input)
|
|
256
|
+
}]
|
|
257
|
+
}],
|
|
258
|
+
maxTokens: MESSAGE_MAX_TOKENS,
|
|
259
|
+
temperature: .2,
|
|
260
|
+
sessionId: input.session?.id,
|
|
261
|
+
signal
|
|
262
|
+
})) {
|
|
263
|
+
if (signal.aborted) break;
|
|
264
|
+
assembler.push(chunk);
|
|
265
|
+
}
|
|
266
|
+
if (assembler.toolCall) throw new Error("message model requested a tool");
|
|
267
|
+
const reason = assembler.finishReason?.kind;
|
|
268
|
+
if (reason !== void 0 && reason !== "stop") input.ctx.logger.warn(`dsh-git-flow: message stream finished with ${reason}`);
|
|
269
|
+
return assembler.text;
|
|
270
|
+
}
|
|
271
|
+
/** Render the diff packet handed to the model. */
|
|
272
|
+
function buildUserPrompt(input) {
|
|
273
|
+
const rows = input.files.filter((file) => input.selected.includes(file.path) || file.oldPath !== void 0 && input.selected.includes(file.oldPath)).map((file) => {
|
|
274
|
+
return `- [${file.conflicted ? "U" : file.untracked ? "?" : `${file.indexStatus}${file.worktreeStatus}`}] ${file.oldPath === void 0 ? file.path : `${file.oldPath} -> ${file.path}`}`;
|
|
275
|
+
});
|
|
276
|
+
return [
|
|
277
|
+
`branch: ${input.head}`,
|
|
278
|
+
`files (${input.selected.length}):`,
|
|
279
|
+
...rows,
|
|
280
|
+
"",
|
|
281
|
+
input.truncated ? "diff (truncated):" : "diff:",
|
|
282
|
+
input.diff || "(no textual diff; the change is a rename, mode, or deletion only)"
|
|
283
|
+
].join("\n");
|
|
284
|
+
}
|
|
285
|
+
/** Template used when no model answer is usable. */
|
|
286
|
+
function fallbackMessage(input) {
|
|
287
|
+
const count = input.selected.length;
|
|
288
|
+
return `${input.language === "zh" ? `chore: 更新 ${count} 个文件` : `chore: update ${count} ${count === 1 ? "file" : "files"}`}\n\n${input.selected.slice(0, 6).map((path) => `- ${path}`).join("\n")}${input.selected.length > 6 ? `\n- …${input.selected.length - 6} more` : ""}`;
|
|
289
|
+
}
|
|
290
|
+
/** Strip fences and prose wrappers, then require a conventional subject. */
|
|
291
|
+
function normalize(raw) {
|
|
292
|
+
const text = raw.trim().replace(/^```[a-z]*\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
293
|
+
if (text.length === 0 || text.length > MAX_MESSAGE_CHARS) return void 0;
|
|
294
|
+
const lines = text.split("\n").map((line) => line.trimEnd());
|
|
295
|
+
const subject = (lines[0] ?? "").replace(/^[-*]\s+/, "");
|
|
296
|
+
if (!/^(feat|fix|refactor|perf|docs|style|test|build|chore|revert)(\([^)]{1,32}\))?!?:\s*\S/.test(subject)) return void 0;
|
|
297
|
+
const body = lines.slice(1).join("\n").trim();
|
|
298
|
+
const message = body.length > 0 ? `${subject}\n\n${body}` : subject;
|
|
299
|
+
return message.length > MAX_MESSAGE_CHARS ? message.slice(0, MAX_MESSAGE_CHARS) : message;
|
|
300
|
+
}
|
|
301
|
+
/** Minimal fold over a chunk stream: text deltas, tool requests, finish reason. */
|
|
302
|
+
var TextAssembly = class {
|
|
303
|
+
text = "";
|
|
304
|
+
toolCall = false;
|
|
305
|
+
finishReason;
|
|
306
|
+
push(chunk) {
|
|
307
|
+
if (chunk.type === "text-delta") this.text += chunk.text;
|
|
308
|
+
else if (chunk.type === "block-start" && chunk.blockType === "tool-call") this.toolCall = true;
|
|
309
|
+
else if (chunk.type === "finish") this.finishReason = chunk.reason;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/routes.ts
|
|
314
|
+
const BASE = "/api/dsh-git-flow";
|
|
315
|
+
/** Commit panels send path lists, not content; anything larger is a client bug. */
|
|
316
|
+
const MAX_BODY_BYTES = 262144;
|
|
317
|
+
/**
|
|
318
|
+
* Register every route on the mounted context.
|
|
319
|
+
* @param ctx - context that already sees `webServer`.
|
|
320
|
+
* @param service - the git surface to expose.
|
|
321
|
+
* @returns a disposer removing all routes.
|
|
322
|
+
*/
|
|
323
|
+
function registerGitFlowRoutes(ctx, service) {
|
|
324
|
+
const routes = [
|
|
325
|
+
ctx.webServer.register({
|
|
326
|
+
kind: "exact",
|
|
327
|
+
path: `${BASE}/status`,
|
|
328
|
+
handler: (req, res) => handle(req, res, () => {
|
|
329
|
+
requireMethod(req, "GET");
|
|
330
|
+
return service.status(sessionIdFromQuery(req));
|
|
331
|
+
})
|
|
332
|
+
}),
|
|
333
|
+
ctx.webServer.register({
|
|
334
|
+
kind: "exact",
|
|
335
|
+
path: `${BASE}/branches`,
|
|
336
|
+
handler: (req, res) => handle(req, res, () => {
|
|
337
|
+
requireMethod(req, "GET");
|
|
338
|
+
return service.branches(sessionIdFromQuery(req));
|
|
339
|
+
})
|
|
340
|
+
}),
|
|
341
|
+
ctx.webServer.register({
|
|
342
|
+
kind: "exact",
|
|
343
|
+
path: `${BASE}/checkout`,
|
|
344
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
345
|
+
requireMethod(req, "POST");
|
|
346
|
+
const { sessionId, name, force } = readBody(body);
|
|
347
|
+
if (typeof name !== "string") throw new GitError("git/invalid-input", "name is required");
|
|
348
|
+
return service.checkout(sessionId, name, force === true);
|
|
349
|
+
})
|
|
350
|
+
}),
|
|
351
|
+
ctx.webServer.register({
|
|
352
|
+
kind: "exact",
|
|
353
|
+
path: `${BASE}/branch`,
|
|
354
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
355
|
+
requireMethod(req, "POST");
|
|
356
|
+
const { sessionId, name, base } = readBody(body);
|
|
357
|
+
if (typeof name !== "string") throw new GitError("git/invalid-input", "name is required");
|
|
358
|
+
if (base !== void 0 && typeof base !== "string") throw new GitError("git/invalid-input", "base must be a string");
|
|
359
|
+
return service.createBranch(sessionId, name, base);
|
|
360
|
+
})
|
|
361
|
+
}),
|
|
362
|
+
ctx.webServer.register({
|
|
363
|
+
kind: "exact",
|
|
364
|
+
path: `${BASE}/commit`,
|
|
365
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
366
|
+
requireMethod(req, "POST");
|
|
367
|
+
const { sessionId, files, message, push } = readBody(body);
|
|
368
|
+
return service.commit(sessionId, stringArray(files, "files"), requireString(message, "message"), push === true);
|
|
369
|
+
})
|
|
370
|
+
}),
|
|
371
|
+
ctx.webServer.register({
|
|
372
|
+
kind: "exact",
|
|
373
|
+
path: `${BASE}/discard`,
|
|
374
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
375
|
+
requireMethod(req, "POST");
|
|
376
|
+
const { sessionId, files } = readBody(body);
|
|
377
|
+
return service.discard(sessionId, stringArray(files, "files"));
|
|
378
|
+
})
|
|
379
|
+
}),
|
|
380
|
+
ctx.webServer.register({
|
|
381
|
+
kind: "exact",
|
|
382
|
+
path: `${BASE}/push`,
|
|
383
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
384
|
+
requireMethod(req, "POST");
|
|
385
|
+
const { sessionId } = readBody(body);
|
|
386
|
+
return {
|
|
387
|
+
pushed: true,
|
|
388
|
+
pushNote: await service.push(sessionId)
|
|
389
|
+
};
|
|
390
|
+
})
|
|
391
|
+
}),
|
|
392
|
+
ctx.webServer.register({
|
|
393
|
+
kind: "exact",
|
|
394
|
+
path: `${BASE}/pull`,
|
|
395
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
396
|
+
requireMethod(req, "POST");
|
|
397
|
+
const { sessionId } = readBody(body);
|
|
398
|
+
return {
|
|
399
|
+
pulled: true,
|
|
400
|
+
pullNote: await service.pull(sessionId)
|
|
401
|
+
};
|
|
402
|
+
})
|
|
403
|
+
}),
|
|
404
|
+
ctx.webServer.register({
|
|
405
|
+
kind: "exact",
|
|
406
|
+
path: `${BASE}/generate-message`,
|
|
407
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
408
|
+
requireMethod(req, "POST");
|
|
409
|
+
const { sessionId, files, language } = readBody(body);
|
|
410
|
+
if (language !== void 0 && language !== "zh" && language !== "en") throw new GitError("git/invalid-input", "language must be \"zh\" or \"en\"");
|
|
411
|
+
return service.generateMessage(sessionId, stringArray(files, "files"), language);
|
|
412
|
+
})
|
|
413
|
+
}),
|
|
414
|
+
ctx.webServer.register({
|
|
415
|
+
kind: "exact",
|
|
416
|
+
path: `${BASE}/gitflow`,
|
|
417
|
+
handler: (req, res) => handle(req, res, () => {
|
|
418
|
+
requireMethod(req, "GET");
|
|
419
|
+
return service.gitflowState(sessionIdFromQuery(req));
|
|
420
|
+
})
|
|
421
|
+
}),
|
|
422
|
+
ctx.webServer.register({
|
|
423
|
+
kind: "exact",
|
|
424
|
+
path: `${BASE}/gitflow/start`,
|
|
425
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
426
|
+
requireMethod(req, "POST");
|
|
427
|
+
const { sessionId, kind, name } = readBody(body);
|
|
428
|
+
if (kind !== "feature" && kind !== "release" && kind !== "hotfix") throw new GitError("git/invalid-input", "kind must be \"feature\", \"release\", or \"hotfix\"");
|
|
429
|
+
return service.startFlow(sessionId, kind, requireString(name, "name"));
|
|
430
|
+
})
|
|
431
|
+
}),
|
|
432
|
+
ctx.webServer.register({
|
|
433
|
+
kind: "exact",
|
|
434
|
+
path: `${BASE}/gitflow/init-develop`,
|
|
435
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
436
|
+
requireMethod(req, "POST");
|
|
437
|
+
const { sessionId } = readBody(body);
|
|
438
|
+
return service.initDevelop(sessionId);
|
|
439
|
+
})
|
|
440
|
+
}),
|
|
441
|
+
ctx.webServer.register({
|
|
442
|
+
kind: "exact",
|
|
443
|
+
path: `${BASE}/gitflow/finish`,
|
|
444
|
+
handler: (req, res) => handle(req, res, async (body) => {
|
|
445
|
+
requireMethod(req, "POST");
|
|
446
|
+
const { sessionId, version } = readBody(body);
|
|
447
|
+
if (version !== void 0 && typeof version !== "string") throw new GitError("git/invalid-input", "version must be a string");
|
|
448
|
+
return service.finishFlow(sessionId, version);
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
];
|
|
452
|
+
return () => {
|
|
453
|
+
for (const remove of routes.reverse()) remove();
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/** Run one handler, translating thrown values into the shared envelope. */
|
|
457
|
+
async function handle(req, res, work) {
|
|
458
|
+
try {
|
|
459
|
+
json(res, 200, {
|
|
460
|
+
ok: true,
|
|
461
|
+
data: await work(req.method === "GET" || req.method === "HEAD" ? {} : await readJson(req))
|
|
462
|
+
});
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const code = error instanceof GitError ? error.code : error instanceof MethodError ? "git/invalid-input" : "git/failed";
|
|
465
|
+
json(res, statusFor(code), {
|
|
466
|
+
ok: false,
|
|
467
|
+
code,
|
|
468
|
+
message: error instanceof Error ? error.message : String(error)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** A wrong HTTP verb is a client bug, reported as an invalid request. */
|
|
473
|
+
var MethodError = class extends Error {};
|
|
474
|
+
function requireMethod(req, method) {
|
|
475
|
+
if (req.method === method) return;
|
|
476
|
+
throw new MethodError(`expected a ${method} request, got ${req.method ?? "unknown"}`);
|
|
477
|
+
}
|
|
478
|
+
function statusFor(code) {
|
|
479
|
+
if (code === "git/invalid-input" || code === "git/no-files-selected" || code === "git/detached-head" || code === "git/dirty-worktree") return 400;
|
|
480
|
+
if (code === "git/develop-missing" || code === "git/merge-conflict" || code === "git/tag-exists" || code === "git/gitflow-branch-required") return 409;
|
|
481
|
+
if (code === "git/not-a-repository" || code === "git/not-installed" || code === "git/no-route") return 404;
|
|
482
|
+
if (code === "git/timeout") return 504;
|
|
483
|
+
return 500;
|
|
484
|
+
}
|
|
485
|
+
function json(res, status, payload) {
|
|
486
|
+
const text = JSON.stringify(payload);
|
|
487
|
+
res.writeHead(status, {
|
|
488
|
+
"content-type": "application/json; charset=utf-8",
|
|
489
|
+
"content-length": Buffer.byteLength(text),
|
|
490
|
+
"cache-control": "no-store"
|
|
491
|
+
});
|
|
492
|
+
res.end(text);
|
|
493
|
+
}
|
|
494
|
+
/** Read and parse a bounded JSON body. */
|
|
495
|
+
async function readJson(req) {
|
|
496
|
+
const chunks = [];
|
|
497
|
+
let size = 0;
|
|
498
|
+
for await (const chunk of req) {
|
|
499
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
500
|
+
size += buffer.length;
|
|
501
|
+
if (size > MAX_BODY_BYTES) throw new GitError("git/invalid-input", "request body is too large");
|
|
502
|
+
chunks.push(buffer);
|
|
503
|
+
}
|
|
504
|
+
if (size === 0) return {};
|
|
505
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
506
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new GitError("git/invalid-input", "expected a JSON object body");
|
|
507
|
+
return parsed;
|
|
508
|
+
}
|
|
509
|
+
function readBody(body) {
|
|
510
|
+
return {
|
|
511
|
+
...body,
|
|
512
|
+
sessionId: requireString(body.sessionId, "sessionId")
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function requireString(value, field) {
|
|
516
|
+
if (typeof value !== "string" || value.length === 0) throw new GitError("git/invalid-input", `${field} must be a non-empty string`);
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
function stringArray(value, field) {
|
|
520
|
+
if (!Array.isArray(value)) throw new GitError("git/invalid-input", `${field} must be an array of paths`);
|
|
521
|
+
return value.map((item) => requireString(item, field));
|
|
522
|
+
}
|
|
523
|
+
function sessionIdFromQuery(req) {
|
|
524
|
+
return requireString(new URL(req.url ?? "/", "http://dsh.invalid").searchParams.get("sessionId"), "sessionId");
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/service.ts
|
|
528
|
+
/**
|
|
529
|
+
* `ctx.gitFlow` — the workspace git surface behind `/api/dsh-git-flow/*`.
|
|
530
|
+
* Every operation is keyed by session id: the repository directory is resolved
|
|
531
|
+
* by the host from the session's workspace, so the browser can never name a
|
|
532
|
+
* path for git to run in.
|
|
533
|
+
*/
|
|
534
|
+
/**
|
|
535
|
+
* Branch switching, panel-scoped commits, pushes, and AI commit messages for
|
|
536
|
+
* the session's workspace.
|
|
537
|
+
*/
|
|
538
|
+
var GitFlow = class extends Service {
|
|
539
|
+
/**
|
|
540
|
+
* `workspaceRegistry` is read through `ctx.get` rather than declared here: a
|
|
541
|
+
* profile without one (the shipped `headless` profile) must still activate
|
|
542
|
+
* this entry instead of reporting a permanently pending plugin. Every git
|
|
543
|
+
* call in such a profile answers `git/not-a-repository`, since no session can
|
|
544
|
+
* resolve a repository.
|
|
545
|
+
*/
|
|
546
|
+
static inject = [
|
|
547
|
+
"subprocess",
|
|
548
|
+
"sessions",
|
|
549
|
+
"llm"
|
|
550
|
+
];
|
|
551
|
+
static Config = z.object({
|
|
552
|
+
timeoutMs: z.number().default(15e3),
|
|
553
|
+
pushTimeoutMs: z.number().default(12e4),
|
|
554
|
+
messageMaxDiffBytes: z.number().default(65536),
|
|
555
|
+
messageLanguage: z.union([z.const("zh"), z.const("en")]).default("en"),
|
|
556
|
+
productionBranch: z.string().default("main"),
|
|
557
|
+
developBranch: z.string().default("develop"),
|
|
558
|
+
featurePrefix: z.string().default("feature/"),
|
|
559
|
+
releasePrefix: z.string().default("release/"),
|
|
560
|
+
hotfixPrefix: z.string().default("hotfix/"),
|
|
561
|
+
tagPrefix: z.string().default("v")
|
|
562
|
+
});
|
|
563
|
+
config;
|
|
564
|
+
git;
|
|
565
|
+
/** Remote-waiting calls (push, pull) outlive the default runner deadline, so the runner is per-call-site. */
|
|
566
|
+
remoteGit;
|
|
567
|
+
constructor(ctx, config = {}) {
|
|
568
|
+
super(ctx, "gitFlow");
|
|
569
|
+
this.config = {
|
|
570
|
+
timeoutMs: config.timeoutMs ?? 15e3,
|
|
571
|
+
pushTimeoutMs: config.pushTimeoutMs ?? 12e4,
|
|
572
|
+
messageMaxDiffBytes: config.messageMaxDiffBytes ?? 65536,
|
|
573
|
+
messageLanguage: config.messageLanguage ?? "en",
|
|
574
|
+
productionBranch: config.productionBranch ?? "main",
|
|
575
|
+
developBranch: config.developBranch ?? "develop",
|
|
576
|
+
featurePrefix: config.featurePrefix ?? "feature/",
|
|
577
|
+
releasePrefix: config.releasePrefix ?? "release/",
|
|
578
|
+
hotfixPrefix: config.hotfixPrefix ?? "hotfix/",
|
|
579
|
+
tagPrefix: config.tagPrefix ?? "v"
|
|
580
|
+
};
|
|
581
|
+
this.git = new GitRunner(ctx.subprocess, this.config.timeoutMs);
|
|
582
|
+
this.remoteGit = new GitRunner(ctx.subprocess, this.config.pushTimeoutMs);
|
|
583
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
584
|
+
webCtx.effect(() => registerGitFlowRoutes(webCtx, this), "dsh-git-flow: api routes");
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
589
|
+
* @returns branch, ahead/behind, and one row per changed path.
|
|
590
|
+
*/
|
|
591
|
+
async status(sessionId) {
|
|
592
|
+
const repo = await this.resolveRepo(sessionId);
|
|
593
|
+
const parsed = parseStatus((await this.git.runOk(repo, [
|
|
594
|
+
"status",
|
|
595
|
+
"--porcelain=v2",
|
|
596
|
+
"--branch",
|
|
597
|
+
"-z"
|
|
598
|
+
])).stdout);
|
|
599
|
+
const files = dedupeFiles(parsed.files);
|
|
600
|
+
return {
|
|
601
|
+
repo,
|
|
602
|
+
head: parsed.head,
|
|
603
|
+
detached: parsed.head === "(detached)",
|
|
604
|
+
upstream: parsed.upstream,
|
|
605
|
+
ahead: parsed.ahead,
|
|
606
|
+
behind: parsed.behind,
|
|
607
|
+
dirty: files.length > 0,
|
|
608
|
+
files
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
613
|
+
* @returns local and remote-tracking branch rows, current one flagged.
|
|
614
|
+
*/
|
|
615
|
+
async branches(sessionId) {
|
|
616
|
+
const status = await this.status(sessionId);
|
|
617
|
+
const repo = status.repo;
|
|
618
|
+
const rows = (await this.git.runOk(repo, [
|
|
619
|
+
"for-each-ref",
|
|
620
|
+
`--format=%(refname)%09%(refname:short)%09%(upstream:short)`,
|
|
621
|
+
"refs/heads",
|
|
622
|
+
"refs/remotes"
|
|
623
|
+
])).stdout.split("\n").filter((line) => line.includes(" ")).map((line) => {
|
|
624
|
+
const [full, name, upstream] = line.split(" ");
|
|
625
|
+
return {
|
|
626
|
+
full,
|
|
627
|
+
name,
|
|
628
|
+
upstream
|
|
629
|
+
};
|
|
630
|
+
});
|
|
631
|
+
const localNames = new Set(rows.filter((row) => row.full.startsWith("refs/heads/")).map((row) => row.name));
|
|
632
|
+
const local = rows.filter((row) => row.full.startsWith("refs/heads/")).map((row) => ({
|
|
633
|
+
name: row.name,
|
|
634
|
+
current: row.name === status.head,
|
|
635
|
+
upstream: row.upstream || void 0,
|
|
636
|
+
remoteOnly: false
|
|
637
|
+
}));
|
|
638
|
+
for (const row of rows) {
|
|
639
|
+
if (!row.full.startsWith("refs/remotes/")) continue;
|
|
640
|
+
const withoutHead = stripRemoteHead(row.name);
|
|
641
|
+
if (withoutHead === void 0 || localNames.has(shortRemoteName(withoutHead))) continue;
|
|
642
|
+
local.push({
|
|
643
|
+
name: withoutHead,
|
|
644
|
+
current: false,
|
|
645
|
+
upstream: withoutHead,
|
|
646
|
+
remoteOnly: true
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
local.sort((a, b) => a.name.localeCompare(b.name));
|
|
650
|
+
const remote = rows.filter((row) => row.full.startsWith("refs/remotes/")).map((row) => stripRemoteHead(row.name)).filter((name) => name !== void 0);
|
|
651
|
+
return {
|
|
652
|
+
head: status.head,
|
|
653
|
+
local,
|
|
654
|
+
remote
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Switch branches, optionally creating a local branch for a unique remote one.
|
|
659
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
660
|
+
* @param name - branch to check out.
|
|
661
|
+
* @param force - discard conflicting worktree changes; never used silently.
|
|
662
|
+
*/
|
|
663
|
+
async checkout(sessionId, name, force = false) {
|
|
664
|
+
const repo = await this.resolveRepo(sessionId);
|
|
665
|
+
assertBranchName(name);
|
|
666
|
+
const args = ["switch"];
|
|
667
|
+
if (force) args.push("--force");
|
|
668
|
+
if (await this.hasLocalBranch(repo, name)) args.push(name);
|
|
669
|
+
else {
|
|
670
|
+
const remotes = await this.remoteBranches(repo, name);
|
|
671
|
+
if (remotes.length > 1) throw new GitError("git/invalid-input", `branch ${name} exists on multiple remotes: ${remotes.join(", ")}`);
|
|
672
|
+
if (remotes.length === 1) args.push("-c", name, "--track", remotes[0]);
|
|
673
|
+
else args.push(name);
|
|
674
|
+
}
|
|
675
|
+
await this.runSwitch(repo, args);
|
|
676
|
+
return this.status(sessionId);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Create one branch from `base` (default: current HEAD) and switch to it.
|
|
680
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
681
|
+
* @param name - new branch name.
|
|
682
|
+
* @param base - existing ref to start from.
|
|
683
|
+
*/
|
|
684
|
+
async createBranch(sessionId, name, base) {
|
|
685
|
+
const repo = await this.resolveRepo(sessionId);
|
|
686
|
+
assertBranchName(name);
|
|
687
|
+
if (await this.hasLocalBranch(repo, name)) throw new GitError("git/invalid-input", `branch ${name} already exists`);
|
|
688
|
+
if (base !== void 0 && base.length > 0) {
|
|
689
|
+
assertBranchName(base);
|
|
690
|
+
if ((await this.git.run(repo, [
|
|
691
|
+
"rev-parse",
|
|
692
|
+
"--verify",
|
|
693
|
+
"--quiet",
|
|
694
|
+
`${base}^{commit}`
|
|
695
|
+
])).exitCode !== 0) throw new GitError("git/invalid-input", `base ${base} does not exist`);
|
|
696
|
+
}
|
|
697
|
+
const args = [
|
|
698
|
+
"switch",
|
|
699
|
+
"-c",
|
|
700
|
+
name
|
|
701
|
+
];
|
|
702
|
+
if (base !== void 0 && base.length > 0) args.push(base);
|
|
703
|
+
await this.runSwitch(repo, args);
|
|
704
|
+
return this.status(sessionId);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Throw away the worktree and index changes for the selected paths: tracked
|
|
708
|
+
* paths go back to HEAD (a staged addition is removed from the index and from
|
|
709
|
+
* disk), and untracked paths are deleted. Destructive by definition, so the
|
|
710
|
+
* caller confirms first; conflicted paths are refused rather than guessed at.
|
|
711
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
712
|
+
* @param files - paths as reported by {@link status}; anything else is rejected.
|
|
713
|
+
*/
|
|
714
|
+
async discard(sessionId, files) {
|
|
715
|
+
const status = await this.status(sessionId);
|
|
716
|
+
const selected = selectPaths(status.files, files);
|
|
717
|
+
const byPath = new Map(status.files.map((file) => [file.path, file]));
|
|
718
|
+
const conflicted = selected.filter((path) => byPath.get(path)?.conflicted === true);
|
|
719
|
+
if (conflicted.length > 0) throw new GitError("git/invalid-input", `${conflicted[0]} has an unresolved conflict; resolve it first`);
|
|
720
|
+
const untracked = selected.filter((path) => byPath.get(path)?.untracked === true);
|
|
721
|
+
const tracked = selected.filter((path) => byPath.get(path)?.untracked !== true);
|
|
722
|
+
if (tracked.length > 0) await this.git.runOk(status.repo, [
|
|
723
|
+
"restore",
|
|
724
|
+
"--source=HEAD",
|
|
725
|
+
"--staged",
|
|
726
|
+
"--worktree",
|
|
727
|
+
"--",
|
|
728
|
+
...tracked
|
|
729
|
+
]);
|
|
730
|
+
if (untracked.length > 0) await this.git.runOk(status.repo, [
|
|
731
|
+
"clean",
|
|
732
|
+
"-fd",
|
|
733
|
+
"--",
|
|
734
|
+
...untracked
|
|
735
|
+
]);
|
|
736
|
+
return this.status(sessionId);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Stage exactly the selected paths, commit them, and optionally push.
|
|
740
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
741
|
+
* @param files - paths as reported by {@link status}; anything else is rejected.
|
|
742
|
+
* @param message - commit message text.
|
|
743
|
+
* @param push - push the resulting commit to the branch upstream.
|
|
744
|
+
*/
|
|
745
|
+
async commit(sessionId, files, message, push = false) {
|
|
746
|
+
const status = await this.status(sessionId);
|
|
747
|
+
if (status.detached) throw new GitError("git/detached-head", "committing on a detached HEAD needs an explicit branch");
|
|
748
|
+
if (message.trim().length === 0) throw new GitError("git/invalid-input", "a commit message is required");
|
|
749
|
+
const selected = selectPaths(status.files, files);
|
|
750
|
+
const args = [
|
|
751
|
+
"add",
|
|
752
|
+
"-A",
|
|
753
|
+
"--",
|
|
754
|
+
...selected
|
|
755
|
+
];
|
|
756
|
+
await this.git.runOk(status.repo, args);
|
|
757
|
+
const staged = await this.changedInIndex(status.repo, selected);
|
|
758
|
+
if (staged.length === 0) throw new GitError("git/invalid-input", "the selected files have no changes left to commit; refresh the list and try again");
|
|
759
|
+
const result = await this.git.runOk(status.repo, [
|
|
760
|
+
"commit",
|
|
761
|
+
"--only",
|
|
762
|
+
"-m",
|
|
763
|
+
message,
|
|
764
|
+
"--",
|
|
765
|
+
...staged
|
|
766
|
+
]);
|
|
767
|
+
const hash = (await this.git.runOk(status.repo, ["rev-parse", "HEAD"])).stdout.trim();
|
|
768
|
+
const view = {
|
|
769
|
+
hash,
|
|
770
|
+
shortHash: hash.slice(0, 7),
|
|
771
|
+
branch: status.head,
|
|
772
|
+
files: staged.length,
|
|
773
|
+
pushed: false,
|
|
774
|
+
pushNote: trimOutput(result.stdout)
|
|
775
|
+
};
|
|
776
|
+
if (push) {
|
|
777
|
+
const pushed = await this.push(sessionId);
|
|
778
|
+
view.pushed = true;
|
|
779
|
+
view.pushNote = pushed;
|
|
780
|
+
}
|
|
781
|
+
return view;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Push the current branch to its upstream. Never force, and never to a
|
|
785
|
+
* caller-supplied remote or refspec.
|
|
786
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
787
|
+
* @returns git's push summary.
|
|
788
|
+
*/
|
|
789
|
+
async push(sessionId) {
|
|
790
|
+
const status = await this.status(sessionId);
|
|
791
|
+
if (status.detached) throw new GitError("git/detached-head", "there is no branch to push");
|
|
792
|
+
if (status.upstream) {
|
|
793
|
+
const result = await this.remoteGit.runOk(status.repo, ["push"]);
|
|
794
|
+
return trimOutput(`${result.stdout}\n${result.stderr}`);
|
|
795
|
+
}
|
|
796
|
+
const remote = (await this.git.run(status.repo, [
|
|
797
|
+
"config",
|
|
798
|
+
"--get",
|
|
799
|
+
`branch.${status.head}.remote`
|
|
800
|
+
])).stdout.trim() || "origin";
|
|
801
|
+
const result = await this.remoteGit.runOk(status.repo, [
|
|
802
|
+
"push",
|
|
803
|
+
"--set-upstream",
|
|
804
|
+
remote,
|
|
805
|
+
`HEAD:refs/heads/${status.head}`
|
|
806
|
+
]);
|
|
807
|
+
return trimOutput(`${result.stdout}\n${result.stderr}`);
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Pull the current branch's upstream with `--ff-only`: a fast-forward is the
|
|
811
|
+
* only allowed outcome, so a diverged branch fails instead of producing a
|
|
812
|
+
* merge commit or rewriting local commits. This is the plugin's one
|
|
813
|
+
* user-initiated network call outside push; nothing fetches in the background.
|
|
814
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
815
|
+
* @returns git's pull summary.
|
|
816
|
+
*/
|
|
817
|
+
async pull(sessionId) {
|
|
818
|
+
const status = await this.status(sessionId);
|
|
819
|
+
if (status.detached) throw new GitError("git/detached-head", "there is no branch to pull");
|
|
820
|
+
if (!status.upstream) throw new GitError("git/invalid-input", "this branch has no upstream to pull from");
|
|
821
|
+
const result = await this.remoteGit.runOk(status.repo, ["pull", "--ff-only"]);
|
|
822
|
+
return trimOutput(`${result.stdout}\n${result.stderr}`);
|
|
823
|
+
}
|
|
824
|
+
/** The resolved branch layout, as the panel renders it. */
|
|
825
|
+
layout() {
|
|
826
|
+
return {
|
|
827
|
+
production: this.config.productionBranch,
|
|
828
|
+
develop: this.config.developBranch,
|
|
829
|
+
featurePrefix: this.config.featurePrefix,
|
|
830
|
+
releasePrefix: this.config.releasePrefix,
|
|
831
|
+
hotfixPrefix: this.config.hotfixPrefix,
|
|
832
|
+
tagPrefix: this.config.tagPrefix
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Describe the flow state of the current branch: which family it belongs to,
|
|
837
|
+
* the version its name carries, whether the branches a run needs exist, and
|
|
838
|
+
* whether the tag a finish would create is already taken.
|
|
839
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
840
|
+
*/
|
|
841
|
+
async gitflowState(sessionId) {
|
|
842
|
+
const status = await this.status(sessionId);
|
|
843
|
+
const layout = this.layout();
|
|
844
|
+
const repo = status.repo;
|
|
845
|
+
const parsed = splitFlowBranch(status.head, layout);
|
|
846
|
+
const hasDevelop = await this.hasLocalBranch(repo, layout.develop) || (await this.remoteBranches(repo, layout.develop)).length > 0;
|
|
847
|
+
const hasProduction = await this.hasLocalBranch(repo, layout.production) || (await this.remoteBranches(repo, layout.production)).length > 0;
|
|
848
|
+
const target = parsed?.kind === "feature" ? layout.develop : layout.production;
|
|
849
|
+
const ahead = parsed === void 0 ? 0 : await this.countAhead(repo, status.head, target);
|
|
850
|
+
const tagName = parsed?.version === void 0 || parsed.kind === "feature" ? void 0 : `${layout.tagPrefix}${parsed.version}`;
|
|
851
|
+
const tagExists = tagName !== void 0 && await this.tagExists(repo, tagName);
|
|
852
|
+
return {
|
|
853
|
+
head: status.head,
|
|
854
|
+
detached: status.detached,
|
|
855
|
+
kind: parsed?.kind,
|
|
856
|
+
slug: parsed?.slug,
|
|
857
|
+
version: parsed?.version,
|
|
858
|
+
hasDevelop,
|
|
859
|
+
hasProduction,
|
|
860
|
+
ahead,
|
|
861
|
+
tagName,
|
|
862
|
+
tagExists,
|
|
863
|
+
branches: layoutView(layout)
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Create a flow branch from the branch its family starts at and switch to it.
|
|
868
|
+
* A missing develop is reported as `git/develop-missing` so the panel can
|
|
869
|
+
* offer to create it rather than dead-ending on a raw git failure.
|
|
870
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
871
|
+
* @param kind - branch family to start.
|
|
872
|
+
* @param name - name inside the family, without the prefix.
|
|
873
|
+
*/
|
|
874
|
+
async startFlow(sessionId, kind, name) {
|
|
875
|
+
const status = await this.status(sessionId);
|
|
876
|
+
if (status.detached) throw new GitError("git/detached-head", "start a flow branch from a real branch, not a detached HEAD");
|
|
877
|
+
const layout = this.layout();
|
|
878
|
+
const slug = name.trim();
|
|
879
|
+
assertFlowSlug(slug);
|
|
880
|
+
const branch = `${prefixOf(kind, layout)}${slug}`;
|
|
881
|
+
if (await this.hasLocalBranch(status.repo, branch)) throw new GitError("git/invalid-input", `branch ${branch} already exists`);
|
|
882
|
+
const base = kind === "hotfix" ? layout.production : layout.develop;
|
|
883
|
+
if (!await this.hasLocalBranch(status.repo, base)) {
|
|
884
|
+
if (kind !== "hotfix") throw new GitError("git/develop-missing", `branch ${base} does not exist yet; create it first`);
|
|
885
|
+
throw new GitError("git/invalid-input", `branch ${base} does not exist; a hotfix starts from it`);
|
|
886
|
+
}
|
|
887
|
+
await this.runSwitch(status.repo, [
|
|
888
|
+
"switch",
|
|
889
|
+
"-c",
|
|
890
|
+
branch,
|
|
891
|
+
base
|
|
892
|
+
]);
|
|
893
|
+
return this.status(sessionId);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Create the develop branch from the production branch and switch to it.
|
|
897
|
+
* Only offered when develop is missing; an existing one is left untouched.
|
|
898
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
899
|
+
*/
|
|
900
|
+
async initDevelop(sessionId) {
|
|
901
|
+
const status = await this.status(sessionId);
|
|
902
|
+
const layout = this.layout();
|
|
903
|
+
if (await this.hasLocalBranch(status.repo, layout.develop)) return status;
|
|
904
|
+
const from = await this.hasLocalBranch(status.repo, layout.production) ? layout.production : status.head;
|
|
905
|
+
if (status.detached) throw new GitError("git/detached-head", "cannot create a develop branch from a detached HEAD");
|
|
906
|
+
await this.runSwitch(status.repo, [
|
|
907
|
+
"switch",
|
|
908
|
+
"-c",
|
|
909
|
+
layout.develop,
|
|
910
|
+
from
|
|
911
|
+
]);
|
|
912
|
+
return this.status(sessionId);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Finish the current flow branch: merge it back (`--no-ff`), tag a release or
|
|
916
|
+
* hotfix, write its version, push, and delete the branch.
|
|
917
|
+
*
|
|
918
|
+
* Every check that can fail without side effects runs first: the worktree is
|
|
919
|
+
* clean, the merge would not conflict, and the tag is free. Past that point
|
|
920
|
+
* steps are not rolled back, because each one is a committed fact; a failure
|
|
921
|
+
* reports which step stopped the run and leaves the repository as it stands.
|
|
922
|
+
* @param sessionId - session whose workspace backs the repository.
|
|
923
|
+
* @param version - `package.json` version to write, release and hotfix only.
|
|
924
|
+
*/
|
|
925
|
+
async finishFlow(sessionId, version) {
|
|
926
|
+
const status = await this.status(sessionId);
|
|
927
|
+
if (status.detached) throw new GitError("git/detached-head", "finish a flow branch, not a detached HEAD");
|
|
928
|
+
const layout = this.layout();
|
|
929
|
+
const parsed = splitFlowBranch(status.head, layout);
|
|
930
|
+
if (parsed === void 0) throw new GitError("git/gitflow-branch-required", `${status.head} is not a feature, release, or hotfix branch`);
|
|
931
|
+
const repo = status.repo;
|
|
932
|
+
const branch = status.head;
|
|
933
|
+
const { kind } = parsed;
|
|
934
|
+
if (status.files.length > 0) throw new GitError("git/dirty-worktree", "commit or discard the working tree changes before finishing");
|
|
935
|
+
const targets = kind === "feature" ? [layout.develop] : [layout.production, layout.develop];
|
|
936
|
+
for (const target of targets) if (!await this.hasLocalBranch(repo, target)) throw new GitError("git/invalid-input", `branch ${target} does not exist, so ${branch} cannot be merged back into it`);
|
|
937
|
+
const tagName = kind === "feature" ? void 0 : `${layout.tagPrefix}${parsed.version ?? await this.currentVersion(repo) ?? ""}`;
|
|
938
|
+
if (tagName === `${layout.tagPrefix}`) throw new GitError("git/invalid-input", "a version is required to tag this branch");
|
|
939
|
+
if (tagName !== void 0 && await this.tagExists(repo, tagName)) throw new GitError("git/tag-exists", `tag ${tagName} already exists`);
|
|
940
|
+
const nextVersion = kind === "feature" ? void 0 : version ?? parsed.version;
|
|
941
|
+
if (nextVersion !== void 0) assertVersion(nextVersion);
|
|
942
|
+
const mergedInto = [];
|
|
943
|
+
for (const target of targets) {
|
|
944
|
+
await this.mergeInto(repo, target, branch);
|
|
945
|
+
mergedInto.push(target);
|
|
946
|
+
}
|
|
947
|
+
if (tagName !== void 0) await this.git.runOk(repo, [
|
|
948
|
+
"tag",
|
|
949
|
+
"-a",
|
|
950
|
+
tagName,
|
|
951
|
+
"-m",
|
|
952
|
+
`${tagName}`
|
|
953
|
+
]);
|
|
954
|
+
let wroteVersion;
|
|
955
|
+
if (nextVersion !== void 0) wroteVersion = await this.writeVersion(repo, nextVersion);
|
|
956
|
+
const pushed = [];
|
|
957
|
+
const failure = await this.pushFinished(repo, targets, tagName, pushed);
|
|
958
|
+
let deleted = false;
|
|
959
|
+
if (failure === void 0) deleted = (await this.git.run(repo, [
|
|
960
|
+
"branch",
|
|
961
|
+
"-d",
|
|
962
|
+
branch
|
|
963
|
+
])).exitCode === 0;
|
|
964
|
+
return {
|
|
965
|
+
kind,
|
|
966
|
+
branch,
|
|
967
|
+
mergedInto,
|
|
968
|
+
tag: tagName,
|
|
969
|
+
version: wroteVersion,
|
|
970
|
+
pushed,
|
|
971
|
+
deleted,
|
|
972
|
+
failure
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/** Merge one branch into a target with a merge commit, refusing on conflict. */
|
|
976
|
+
async mergeInto(repo, target, branch) {
|
|
977
|
+
await this.runSwitch(repo, ["switch", target]);
|
|
978
|
+
const result = await this.git.run(repo, [
|
|
979
|
+
"merge",
|
|
980
|
+
"--no-ff",
|
|
981
|
+
branch,
|
|
982
|
+
"-m",
|
|
983
|
+
`Merge branch '${branch}' into ${target}`
|
|
984
|
+
]);
|
|
985
|
+
if (result.exitCode === 0) return;
|
|
986
|
+
if (parseStatus((await this.git.runOk(repo, [
|
|
987
|
+
"status",
|
|
988
|
+
"--porcelain=v2",
|
|
989
|
+
"--branch",
|
|
990
|
+
"-z"
|
|
991
|
+
])).stdout).files.some((file) => file.conflicted)) throw new GitError("git/merge-conflict", `merging ${branch} into ${target} conflicted; resolve the conflicts, then finish again`);
|
|
992
|
+
throw new GitError("git/failed", describeFailure([
|
|
993
|
+
"merge",
|
|
994
|
+
"--no-ff",
|
|
995
|
+
branch
|
|
996
|
+
], result));
|
|
997
|
+
}
|
|
998
|
+
/** Push each merged target and the new tag; the first failure is reported, not thrown. */
|
|
999
|
+
async pushFinished(repo, targets, tag, pushed) {
|
|
1000
|
+
for (const target of targets) {
|
|
1001
|
+
const result = await this.remoteGit.run(repo, [
|
|
1002
|
+
"push",
|
|
1003
|
+
"-u",
|
|
1004
|
+
"origin",
|
|
1005
|
+
target
|
|
1006
|
+
]);
|
|
1007
|
+
if (result.exitCode !== 0) return {
|
|
1008
|
+
id: "push",
|
|
1009
|
+
detail: describeFailure([
|
|
1010
|
+
"push",
|
|
1011
|
+
"-u",
|
|
1012
|
+
"origin",
|
|
1013
|
+
target
|
|
1014
|
+
], result),
|
|
1015
|
+
ok: false
|
|
1016
|
+
};
|
|
1017
|
+
pushed.push(target);
|
|
1018
|
+
}
|
|
1019
|
+
if (tag !== void 0) {
|
|
1020
|
+
const result = await this.remoteGit.run(repo, [
|
|
1021
|
+
"push",
|
|
1022
|
+
"origin",
|
|
1023
|
+
tag
|
|
1024
|
+
]);
|
|
1025
|
+
if (result.exitCode !== 0) return {
|
|
1026
|
+
id: "push",
|
|
1027
|
+
detail: describeFailure([
|
|
1028
|
+
"push",
|
|
1029
|
+
"origin",
|
|
1030
|
+
tag
|
|
1031
|
+
], result),
|
|
1032
|
+
ok: false
|
|
1033
|
+
};
|
|
1034
|
+
pushed.push(tag);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/** Write `version` into the root `package.json`; a non-Node repo skips it. */
|
|
1038
|
+
async writeVersion(repo, version) {
|
|
1039
|
+
const path = nodePath.join(repo, "package.json");
|
|
1040
|
+
let text;
|
|
1041
|
+
try {
|
|
1042
|
+
text = await readFile(path, "utf8");
|
|
1043
|
+
} catch {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
let manifest;
|
|
1047
|
+
try {
|
|
1048
|
+
manifest = JSON.parse(text);
|
|
1049
|
+
} catch {
|
|
1050
|
+
throw new GitError("git/invalid-input", "package.json is not valid JSON, so its version cannot be updated");
|
|
1051
|
+
}
|
|
1052
|
+
if ((typeof manifest.version === "string" ? manifest.version : void 0) === version) return version;
|
|
1053
|
+
manifest.version = version;
|
|
1054
|
+
await writeFile(path, `${JSON.stringify(manifest, void 0, 2)}\n`, "utf8");
|
|
1055
|
+
await this.git.runOk(repo, [
|
|
1056
|
+
"add",
|
|
1057
|
+
"--",
|
|
1058
|
+
"package.json"
|
|
1059
|
+
]);
|
|
1060
|
+
await this.git.runOk(repo, [
|
|
1061
|
+
"commit",
|
|
1062
|
+
"-m",
|
|
1063
|
+
`chore(release): ${version}`,
|
|
1064
|
+
"--",
|
|
1065
|
+
"package.json"
|
|
1066
|
+
]);
|
|
1067
|
+
return version;
|
|
1068
|
+
}
|
|
1069
|
+
/** Version currently declared by the root package.json, when there is one. */
|
|
1070
|
+
async currentVersion(repo) {
|
|
1071
|
+
try {
|
|
1072
|
+
const manifest = JSON.parse(await readFile(nodePath.join(repo, "package.json"), "utf8"));
|
|
1073
|
+
return typeof manifest.version === "string" ? manifest.version : void 0;
|
|
1074
|
+
} catch {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async countAhead(repo, branch, target) {
|
|
1079
|
+
const result = await this.git.run(repo, [
|
|
1080
|
+
"rev-list",
|
|
1081
|
+
"--count",
|
|
1082
|
+
`${target}..${branch}`
|
|
1083
|
+
]);
|
|
1084
|
+
return result.exitCode === 0 ? Number(result.stdout.trim()) || 0 : 0;
|
|
1085
|
+
}
|
|
1086
|
+
async tagExists(repo, tag) {
|
|
1087
|
+
return (await this.git.run(repo, [
|
|
1088
|
+
"rev-parse",
|
|
1089
|
+
"--verify",
|
|
1090
|
+
"--quiet",
|
|
1091
|
+
`refs/tags/${tag}`
|
|
1092
|
+
])).exitCode === 0;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Ask the session's own model route for a commit message over the selected diff.
|
|
1096
|
+
* @param sessionId - session whose workspace and route back the call.
|
|
1097
|
+
* @param files - paths as reported by {@link status}.
|
|
1098
|
+
* @param language - per-call override for the configured message language.
|
|
1099
|
+
*/
|
|
1100
|
+
async generateMessage(sessionId, files, language) {
|
|
1101
|
+
const status = await this.status(sessionId);
|
|
1102
|
+
const selected = selectPaths(status.files, files);
|
|
1103
|
+
const diff = await this.collectDiff(status.repo, selected);
|
|
1104
|
+
const session = liveSession(this.ctx, sessionId);
|
|
1105
|
+
return generateCommitMessage({
|
|
1106
|
+
ctx: this.ctx,
|
|
1107
|
+
session,
|
|
1108
|
+
language: language ?? this.config.messageLanguage,
|
|
1109
|
+
head: status.head,
|
|
1110
|
+
selected,
|
|
1111
|
+
files: status.files.filter((file) => selected.includes(file.path) || selected.includes(file.oldPath ?? "")),
|
|
1112
|
+
diff: diff.text,
|
|
1113
|
+
truncated: diff.truncated
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
/** Collect the selected diff without staging anything. */
|
|
1117
|
+
async collectDiff(repo, selected) {
|
|
1118
|
+
const parts = [];
|
|
1119
|
+
const display = ["-c", "core.quotePath=false"];
|
|
1120
|
+
const numstat = await this.git.runOk(repo, [
|
|
1121
|
+
...display,
|
|
1122
|
+
"diff",
|
|
1123
|
+
"HEAD",
|
|
1124
|
+
"--numstat",
|
|
1125
|
+
"--",
|
|
1126
|
+
...selected
|
|
1127
|
+
]);
|
|
1128
|
+
if (numstat.stdout.trim()) parts.push(`# numstat\n${numstat.stdout.trim()}`);
|
|
1129
|
+
const tracked = await this.git.runOk(repo, [
|
|
1130
|
+
...display,
|
|
1131
|
+
"diff",
|
|
1132
|
+
"HEAD",
|
|
1133
|
+
"--",
|
|
1134
|
+
...selected
|
|
1135
|
+
]);
|
|
1136
|
+
if (tracked.stdout.trim()) parts.push(tracked.stdout);
|
|
1137
|
+
for (const path of selected) {
|
|
1138
|
+
if ((await this.git.run(repo, [
|
|
1139
|
+
"ls-files",
|
|
1140
|
+
"--error-unmatch",
|
|
1141
|
+
"--",
|
|
1142
|
+
path
|
|
1143
|
+
])).exitCode === 0) continue;
|
|
1144
|
+
const added = await this.git.run(repo, [
|
|
1145
|
+
...display,
|
|
1146
|
+
"diff",
|
|
1147
|
+
"--no-index",
|
|
1148
|
+
"--",
|
|
1149
|
+
"/dev/null",
|
|
1150
|
+
path
|
|
1151
|
+
]);
|
|
1152
|
+
if (added.stdout.trim()) parts.push(added.stdout);
|
|
1153
|
+
}
|
|
1154
|
+
const text = parts.join("\n");
|
|
1155
|
+
const limit = this.config.messageMaxDiffBytes;
|
|
1156
|
+
if (Buffer.byteLength(text, "utf8") <= limit) return {
|
|
1157
|
+
text,
|
|
1158
|
+
truncated: false
|
|
1159
|
+
};
|
|
1160
|
+
return {
|
|
1161
|
+
text: `${Buffer.from(text, "utf8").subarray(0, limit).toString("utf8")}\n… diff truncated …`,
|
|
1162
|
+
truncated: true
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** Repository top level for one session's workspace. */
|
|
1166
|
+
async resolveRepo(sessionId) {
|
|
1167
|
+
const workspace = this.resolveWorkspace(sessionId);
|
|
1168
|
+
const result = await this.git.run(workspace.path, ["rev-parse", "--show-toplevel"]);
|
|
1169
|
+
if (result.exitCode !== 0) throw new GitError("git/not-a-repository", `${workspace.path} is not inside a git repository`);
|
|
1170
|
+
return result.stdout.trim();
|
|
1171
|
+
}
|
|
1172
|
+
resolveWorkspace(sessionId) {
|
|
1173
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
1174
|
+
if (registry === void 0) throw new GitError("git/not-a-repository", "this profile registers no workspace, so no repository can be resolved");
|
|
1175
|
+
const match = registry.list().find((workspace) => workspaceHasSession(workspace, sessionId));
|
|
1176
|
+
if (match === void 0) throw new GitError("git/not-a-repository", "this session has no registered workspace");
|
|
1177
|
+
return match;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Keep only the selected paths whose staged content differs from HEAD.
|
|
1181
|
+
* `add -A` has just run, so the index is the truth; a path that resolves to
|
|
1182
|
+
* the HEAD blob (reverted, or touched between the status snapshot and now)
|
|
1183
|
+
* would make `commit --only` reject the entire commit.
|
|
1184
|
+
* @returns pathspecs in the same order git reported them, `-z` so a non-ASCII
|
|
1185
|
+
* name is never C-quoted.
|
|
1186
|
+
*/
|
|
1187
|
+
async changedInIndex(repo, selected) {
|
|
1188
|
+
return (await this.git.runOk(repo, [
|
|
1189
|
+
"diff",
|
|
1190
|
+
"--cached",
|
|
1191
|
+
"--name-only",
|
|
1192
|
+
"-z",
|
|
1193
|
+
"--",
|
|
1194
|
+
...selected
|
|
1195
|
+
])).stdout.split("\0").filter(Boolean);
|
|
1196
|
+
}
|
|
1197
|
+
async hasLocalBranch(repo, name) {
|
|
1198
|
+
return (await this.git.run(repo, [
|
|
1199
|
+
"show-ref",
|
|
1200
|
+
"--verify",
|
|
1201
|
+
"--quiet",
|
|
1202
|
+
`refs/heads/${name}`
|
|
1203
|
+
])).exitCode === 0;
|
|
1204
|
+
}
|
|
1205
|
+
async remoteBranches(repo, name) {
|
|
1206
|
+
return (await this.git.runOk(repo, [
|
|
1207
|
+
"for-each-ref",
|
|
1208
|
+
"--format=%(refname:short)",
|
|
1209
|
+
`refs/remotes/*/${name}`
|
|
1210
|
+
])).stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1211
|
+
}
|
|
1212
|
+
async runSwitch(repo, args) {
|
|
1213
|
+
const result = await this.git.run(repo, args);
|
|
1214
|
+
if (result.exitCode === 0) return;
|
|
1215
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
1216
|
+
if (/local changes|would be overwritten|not clean|Your index contains staged changes/i.test(detail)) throw new GitError("git/dirty-worktree", detail);
|
|
1217
|
+
throw new GitError("git/failed", describeFailure(args, result));
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
/** Reject inputs that could read as an option or break out of the repository. */
|
|
1221
|
+
function assertBranchName(name) {
|
|
1222
|
+
if (name.length === 0) throw new GitError("git/invalid-input", "a branch name is required");
|
|
1223
|
+
if (name.startsWith("-")) throw new GitError("git/invalid-input", "a branch name cannot start with -");
|
|
1224
|
+
if (/[\s\u0000-\u001f\u007f~^:?*[\\]/.test(name)) throw new GitError("git/invalid-input", `invalid branch name: ${name}`);
|
|
1225
|
+
if (name.includes("..") || name.includes("//") || /[.@{}]$/.test(name) || /^-|-$/.test(name) || name.includes("@{")) throw new GitError("git/invalid-input", `invalid branch name: ${name}`);
|
|
1226
|
+
}
|
|
1227
|
+
/** Merge porcelain rows that report one path twice (a staged delete plus an untracked add). */
|
|
1228
|
+
function dedupeFiles(entries) {
|
|
1229
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1230
|
+
for (const entry of entries) {
|
|
1231
|
+
const existing = byPath.get(entry.path);
|
|
1232
|
+
if (existing === void 0) {
|
|
1233
|
+
byPath.set(entry.path, { ...entry });
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
existing.staged ||= entry.staged;
|
|
1237
|
+
existing.untracked ||= entry.untracked;
|
|
1238
|
+
if (existing.indexStatus === "." || existing.indexStatus === "?") existing.indexStatus = entry.indexStatus;
|
|
1239
|
+
if (entry.worktreeStatus !== "." && entry.worktreeStatus !== "?") existing.worktreeStatus = entry.worktreeStatus;
|
|
1240
|
+
}
|
|
1241
|
+
return [...byPath.values()];
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Validate the browser's selection against what `status` just reported.
|
|
1245
|
+
* @returns pathspecs to hand to git, renames expanded to both sides.
|
|
1246
|
+
*/
|
|
1247
|
+
function selectPaths(files, requested) {
|
|
1248
|
+
if (requested.length === 0) throw new GitError("git/no-files-selected", "select at least one file to commit");
|
|
1249
|
+
const reported = /* @__PURE__ */ new Map();
|
|
1250
|
+
for (const file of files) {
|
|
1251
|
+
reported.set(file.path, file);
|
|
1252
|
+
if (file.oldPath !== void 0) reported.set(file.oldPath, file);
|
|
1253
|
+
}
|
|
1254
|
+
const paths = [];
|
|
1255
|
+
for (const raw of requested) {
|
|
1256
|
+
const file = reported.get(raw);
|
|
1257
|
+
if (file === void 0) throw new GitError("git/invalid-input", `${raw} is not a changed path`);
|
|
1258
|
+
if (raw.startsWith("-")) throw new GitError("git/invalid-input", `${raw} is not a valid path`);
|
|
1259
|
+
if (file.oldPath !== void 0 && !paths.includes(file.oldPath)) paths.push(file.oldPath);
|
|
1260
|
+
if (!paths.includes(file.path)) paths.push(file.path);
|
|
1261
|
+
}
|
|
1262
|
+
return paths;
|
|
1263
|
+
}
|
|
1264
|
+
/** `origin/main` from `origin/HEAD -> main` style rows; undefined for symbolic pointers. */
|
|
1265
|
+
function stripRemoteHead(name) {
|
|
1266
|
+
if (name.endsWith("/HEAD")) return void 0;
|
|
1267
|
+
return name;
|
|
1268
|
+
}
|
|
1269
|
+
function shortRemoteName(fullRemote) {
|
|
1270
|
+
const slash = fullRemote.indexOf("/");
|
|
1271
|
+
return slash < 0 ? fullRemote : fullRemote.slice(slash + 1);
|
|
1272
|
+
}
|
|
1273
|
+
function workspaceHasSession(workspace, sessionId) {
|
|
1274
|
+
return workspace.sessionIds.some((id) => String(id) === sessionId);
|
|
1275
|
+
}
|
|
1276
|
+
function liveSession(ctx, sessionId) {
|
|
1277
|
+
return ctx.sessions.list().find((session) => String(session.id) === sessionId);
|
|
1278
|
+
}
|
|
1279
|
+
/** Keep push output to the last meaningful lines. */
|
|
1280
|
+
function trimOutput(text) {
|
|
1281
|
+
return text.split("\n").map((line) => line.trim()).filter(Boolean).slice(-4).join("\n");
|
|
1282
|
+
}
|
|
1283
|
+
/** Publishable view of the branch layout. */
|
|
1284
|
+
function layoutView(layout) {
|
|
1285
|
+
return {
|
|
1286
|
+
production: layout.production,
|
|
1287
|
+
develop: layout.develop,
|
|
1288
|
+
featurePrefix: layout.featurePrefix,
|
|
1289
|
+
releasePrefix: layout.releasePrefix,
|
|
1290
|
+
hotfixPrefix: layout.hotfixPrefix,
|
|
1291
|
+
tagPrefix: layout.tagPrefix
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function prefixOf(kind, layout) {
|
|
1295
|
+
if (kind === "feature") return layout.featurePrefix;
|
|
1296
|
+
if (kind === "release") return layout.releasePrefix;
|
|
1297
|
+
if (kind === "hotfix") return layout.hotfixPrefix;
|
|
1298
|
+
throw new GitError("git/invalid-input", `unknown flow kind: ${String(kind)}`);
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Read a branch name as a flow branch. A hotfix prefix is tested before the
|
|
1302
|
+
* release one only because the two are distinct names, never nesting prefixes.
|
|
1303
|
+
* @returns the family, the name inside it, and the version when it carries one.
|
|
1304
|
+
*/
|
|
1305
|
+
function splitFlowBranch(branch, layout) {
|
|
1306
|
+
const families = [
|
|
1307
|
+
["feature", layout.featurePrefix],
|
|
1308
|
+
["release", layout.releasePrefix],
|
|
1309
|
+
["hotfix", layout.hotfixPrefix]
|
|
1310
|
+
];
|
|
1311
|
+
for (const [kind, prefix] of families) {
|
|
1312
|
+
if (!branch.startsWith(prefix) || branch.length === prefix.length) continue;
|
|
1313
|
+
const slug = branch.slice(prefix.length);
|
|
1314
|
+
return {
|
|
1315
|
+
kind,
|
|
1316
|
+
slug,
|
|
1317
|
+
version: kind === "feature" ? void 0 : parseVersion(slug)
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/** Leading semver in a branch slug (`1.2.0`, `v1.2.0-rc.1`), if it has one. */
|
|
1322
|
+
function parseVersion(slug) {
|
|
1323
|
+
return /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(slug)?.[1];
|
|
1324
|
+
}
|
|
1325
|
+
/** A flow slug must be safe as both a branch segment and a tag fragment. */
|
|
1326
|
+
function assertFlowSlug(slug) {
|
|
1327
|
+
if (slug.length === 0) throw new GitError("git/invalid-input", "a branch name is required");
|
|
1328
|
+
if (slug.startsWith("-")) throw new GitError("git/invalid-input", "a branch name cannot start with -");
|
|
1329
|
+
if (/[\s\u0000-\u001f\u007f~^:?*[\\]/.test(slug) || slug.includes("..") || slug.includes("//")) throw new GitError("git/invalid-input", `invalid branch name: ${slug}`);
|
|
1330
|
+
if (/[.@{}]$/.test(slug) || slug.endsWith(".lock")) throw new GitError("git/invalid-input", `invalid branch name: ${slug}`);
|
|
1331
|
+
}
|
|
1332
|
+
/** A version destined for `package.json` and a tag: digits and dots, optional prerelease. */
|
|
1333
|
+
function assertVersion(version) {
|
|
1334
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new GitError("git/invalid-input", `invalid version: ${version}`);
|
|
1335
|
+
}
|
|
1336
|
+
//#endregion
|
|
1337
|
+
//#region src/index.ts
|
|
1338
|
+
/**
|
|
1339
|
+
* dsh-git-flow, node half. The bundle patch mounts {@link GitFlow} as
|
|
1340
|
+
* `ctx.gitFlow`; it provides the workspace git operations and registers
|
|
1341
|
+
* `/api/dsh-git-flow/*` on the web profile. The browser half ships through
|
|
1342
|
+
* `exports['./client']` and the package.json `dsh.client` declaration.
|
|
1343
|
+
* @module dsh-git-flow
|
|
1344
|
+
*/
|
|
1345
|
+
var src_default = GitFlow;
|
|
1346
|
+
//#endregion
|
|
1347
|
+
export { GitFlow, src_default as default };
|