@co0ontty/wand 1.25.5 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.js +24 -0
- package/dist/git-quick-commit.d.ts +20 -3
- package/dist/git-quick-commit.js +304 -51
- package/dist/server-session-routes.js +58 -1
- package/dist/server.js +214 -5
- package/dist/structured-session-manager.d.ts +20 -0
- package/dist/structured-session-manager.js +192 -18
- package/dist/types.d.ts +50 -0
- package/dist/web-ui/content/scripts.js +805 -57
- package/dist/web-ui/content/styles.css +285 -0
- package/dist/ws-broadcast.d.ts +10 -0
- package/dist/ws-broadcast.js +75 -0
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -41,6 +41,7 @@ export const defaultConfig = () => ({
|
|
|
41
41
|
shortcutLogMaxBytes: 10 * 1024 * 1024,
|
|
42
42
|
language: "",
|
|
43
43
|
android: defaultAndroidApkConfig(),
|
|
44
|
+
macos: defaultMacosDmgConfig(),
|
|
44
45
|
cardDefaults: defaultCardExpandDefaults(),
|
|
45
46
|
defaultModel: "",
|
|
46
47
|
structuredRunner: "cli",
|
|
@@ -301,6 +302,28 @@ function normalizeAndroidApkConfig(input) {
|
|
|
301
302
|
: defaults.currentApkFile,
|
|
302
303
|
};
|
|
303
304
|
}
|
|
305
|
+
function defaultMacosDmgConfig() {
|
|
306
|
+
return {
|
|
307
|
+
enabled: false,
|
|
308
|
+
dmgDir: "macos",
|
|
309
|
+
currentDmgFile: "",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function normalizeMacosDmgConfig(input) {
|
|
313
|
+
if (!input || typeof input !== "object")
|
|
314
|
+
return undefined;
|
|
315
|
+
const defaults = defaultMacosDmgConfig();
|
|
316
|
+
const macosInput = input;
|
|
317
|
+
return {
|
|
318
|
+
enabled: typeof macosInput.enabled === "boolean" ? macosInput.enabled : defaults.enabled,
|
|
319
|
+
dmgDir: typeof macosInput.dmgDir === "string" && macosInput.dmgDir.trim()
|
|
320
|
+
? macosInput.dmgDir.trim()
|
|
321
|
+
: defaults.dmgDir,
|
|
322
|
+
currentDmgFile: typeof macosInput.currentDmgFile === "string"
|
|
323
|
+
? macosInput.currentDmgFile.trim()
|
|
324
|
+
: defaults.currentDmgFile,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
304
327
|
function normalizeStructuredChatPersona(input) {
|
|
305
328
|
if (!input || typeof input !== "object")
|
|
306
329
|
return undefined;
|
|
@@ -358,6 +381,7 @@ function mergeWithDefaults(input) {
|
|
|
358
381
|
? input.appSecret
|
|
359
382
|
: crypto.randomBytes(32).toString("hex"),
|
|
360
383
|
android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
|
|
384
|
+
macos: normalizeMacosDmgConfig(input.macos) ?? defaults.macos,
|
|
361
385
|
cardDefaults: normalizeCardDefaults(input.cardDefaults),
|
|
362
386
|
defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
|
|
363
387
|
structuredRunner: (input.structuredRunner === "sdk" || input.structuredRunner === "cli") ? input.structuredRunner : defaults.structuredRunner,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { GitStatusResult, QuickCommitResult } from "./types.js";
|
|
1
|
+
import { GitStatusResult, PushResult, QuickCommitResult, TagHeadResult } from "./types.js";
|
|
2
|
+
export type QuickCommitErrorCode = "CWD_MISSING" | "NO_CWD" | "NOT_A_GIT_REPO" | "NO_COMMIT" | "NOTHING_TO_COMMIT" | "NOTHING_TO_PUSH" | "EMPTY_MESSAGE" | "EMPTY_TAG" | "EMPTY_AI_MESSAGE" | "INVALID_AI_TAG" | "TAG_EXISTS" | "GIT_ADD_FAILED" | "GIT_DIFF_FAILED" | "GIT_COMMIT_FAILED" | "GIT_TAG_FAILED" | "CLAUDE_CLI_MISSING" | "CLAUDE_CLI_FAILED" | "CLAUDE_TIMEOUT";
|
|
2
3
|
export declare function getGitStatus(cwd: string): GitStatusResult;
|
|
3
4
|
interface QuickCommitOptions {
|
|
4
5
|
cwd: string;
|
|
@@ -11,8 +12,8 @@ interface QuickCommitOptions {
|
|
|
11
12
|
push?: boolean;
|
|
12
13
|
}
|
|
13
14
|
export declare class QuickCommitError extends Error {
|
|
14
|
-
readonly code:
|
|
15
|
-
constructor(message: string, code:
|
|
15
|
+
readonly code: QuickCommitErrorCode;
|
|
16
|
+
constructor(message: string, code: QuickCommitErrorCode);
|
|
16
17
|
}
|
|
17
18
|
export interface GenerateCommitMessageResult {
|
|
18
19
|
message: string;
|
|
@@ -20,5 +21,21 @@ export interface GenerateCommitMessageResult {
|
|
|
20
21
|
suggestedTag?: string;
|
|
21
22
|
}
|
|
22
23
|
export declare function generateCommitMessageOnly(cwd: string, language: string): Promise<GenerateCommitMessageResult>;
|
|
24
|
+
interface TagHeadOptions {
|
|
25
|
+
cwd: string;
|
|
26
|
+
language: string;
|
|
27
|
+
/** Explicit tag name. If empty and `autoTag` is true, ask Claude to generate one. */
|
|
28
|
+
tag?: string;
|
|
29
|
+
autoTag?: boolean;
|
|
30
|
+
/** Push only this tag to its upstream remote after creating it. */
|
|
31
|
+
push?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare function runTagHead(opts: TagHeadOptions): Promise<TagHeadResult>;
|
|
34
|
+
interface PushOptions {
|
|
35
|
+
cwd: string;
|
|
36
|
+
pushCommits?: boolean;
|
|
37
|
+
pushTags?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export declare function runPush(opts: PushOptions): Promise<PushResult>;
|
|
23
40
|
export declare function runQuickCommit(opts: QuickCommitOptions): Promise<QuickCommitResult>;
|
|
24
41
|
export {};
|
package/dist/git-quick-commit.js
CHANGED
|
@@ -31,6 +31,38 @@ function getGitErrorMessage(error) {
|
|
|
31
31
|
return e.message;
|
|
32
32
|
return String(error);
|
|
33
33
|
}
|
|
34
|
+
/** Throws `QuickCommitError` if `cwd` isn't an existing path inside a git work tree. */
|
|
35
|
+
function assertGitWorkTree(cwd) {
|
|
36
|
+
if (!cwd || !existsSync(cwd)) {
|
|
37
|
+
throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
|
|
38
|
+
}
|
|
39
|
+
let isInside;
|
|
40
|
+
try {
|
|
41
|
+
isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
|
|
45
|
+
}
|
|
46
|
+
if (isInside !== "true") {
|
|
47
|
+
throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the remote to push to. Prefers `branch.<name>.remote` config for the
|
|
52
|
+
* current branch, falls back to `origin`. Never throws.
|
|
53
|
+
*/
|
|
54
|
+
function resolvePushRemote(cwd) {
|
|
55
|
+
try {
|
|
56
|
+
const branch = runGit(["branch", "--show-current"], cwd);
|
|
57
|
+
if (branch) {
|
|
58
|
+
return runGit(["config", "--get", `branch.${branch}.remote`], cwd) || "origin";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore — fall through to default
|
|
63
|
+
}
|
|
64
|
+
return "origin";
|
|
65
|
+
}
|
|
34
66
|
function unquotePath(raw) {
|
|
35
67
|
if (raw.startsWith("\"") && raw.endsWith("\"")) {
|
|
36
68
|
return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
@@ -141,6 +173,49 @@ export function getGitStatus(cwd) {
|
|
|
141
173
|
}
|
|
142
174
|
const allEntries = parsePorcelainV2(porcelain);
|
|
143
175
|
const files = allEntries.slice(0, MAX_FILE_ENTRIES);
|
|
176
|
+
let upstream;
|
|
177
|
+
let ahead;
|
|
178
|
+
let behind;
|
|
179
|
+
let lastCommit;
|
|
180
|
+
if (!initialCommit) {
|
|
181
|
+
try {
|
|
182
|
+
upstream = runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd) || undefined;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
upstream = undefined;
|
|
186
|
+
}
|
|
187
|
+
if (upstream) {
|
|
188
|
+
try {
|
|
189
|
+
const counts = runGit(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], cwd);
|
|
190
|
+
const parts = counts.split(/\s+/).filter(Boolean);
|
|
191
|
+
if (parts.length === 2) {
|
|
192
|
+
const b = Number.parseInt(parts[0], 10);
|
|
193
|
+
const a = Number.parseInt(parts[1], 10);
|
|
194
|
+
if (!Number.isNaN(a))
|
|
195
|
+
ahead = a;
|
|
196
|
+
if (!Number.isNaN(b))
|
|
197
|
+
behind = b;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// ignore — counts stay undefined
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const raw = runGit(["log", "-1", "--pretty=format:%H%x09%h%x09%s"], cwd);
|
|
206
|
+
const parts = raw.split("\t");
|
|
207
|
+
if (parts.length >= 3) {
|
|
208
|
+
lastCommit = { hash: parts[0], shortHash: parts[1], subject: parts.slice(2).join("\t") };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// NOTE: we intentionally do NOT probe the remote for unpushed tags here.
|
|
216
|
+
// `ls-remote` is a synchronous network call that can block the event loop
|
|
217
|
+
// for seconds. The "unpushed tag" UI chip is best-effort and a separate
|
|
218
|
+
// async endpoint should compute it on demand if reintroduced.
|
|
144
219
|
return {
|
|
145
220
|
isGit: true,
|
|
146
221
|
branch,
|
|
@@ -149,6 +224,10 @@ export function getGitStatus(cwd) {
|
|
|
149
224
|
head,
|
|
150
225
|
repoRoot,
|
|
151
226
|
initialCommit,
|
|
227
|
+
upstream,
|
|
228
|
+
ahead,
|
|
229
|
+
behind,
|
|
230
|
+
lastCommit,
|
|
152
231
|
};
|
|
153
232
|
}
|
|
154
233
|
export class QuickCommitError extends Error {
|
|
@@ -356,30 +435,197 @@ ${diff}`;
|
|
|
356
435
|
}
|
|
357
436
|
return suggested;
|
|
358
437
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
438
|
+
/**
|
|
439
|
+
* Push current branch (with upstream auto-setup) and/or tags.
|
|
440
|
+
* Errors are returned via `error` so callers can present partial-success states.
|
|
441
|
+
*/
|
|
442
|
+
function doPush(opts) {
|
|
443
|
+
const { cwd, pushCommits, pushTags } = opts;
|
|
444
|
+
let pushedCommits = false;
|
|
445
|
+
let pushedTags = false;
|
|
446
|
+
let hasUpstream = false;
|
|
447
|
+
try {
|
|
448
|
+
runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
449
|
+
hasUpstream = true;
|
|
364
450
|
}
|
|
365
|
-
|
|
451
|
+
catch {
|
|
452
|
+
hasUpstream = false;
|
|
453
|
+
}
|
|
454
|
+
const pushRemote = resolvePushRemote(cwd);
|
|
366
455
|
try {
|
|
367
|
-
|
|
456
|
+
if (pushCommits) {
|
|
457
|
+
if (hasUpstream) {
|
|
458
|
+
runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
462
|
+
}
|
|
463
|
+
pushedCommits = true;
|
|
464
|
+
}
|
|
465
|
+
if (pushTags) {
|
|
466
|
+
if (Array.isArray(pushTags)) {
|
|
467
|
+
for (const name of pushTags) {
|
|
468
|
+
runGit(["push", pushRemote, `refs/tags/${name}`], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
473
|
+
}
|
|
474
|
+
pushedTags = true;
|
|
475
|
+
}
|
|
476
|
+
return { pushedCommits, pushedTags };
|
|
368
477
|
}
|
|
369
478
|
catch (error) {
|
|
370
|
-
|
|
479
|
+
return { pushedCommits, pushedTags, error: getGitErrorMessage(error) };
|
|
371
480
|
}
|
|
372
|
-
|
|
373
|
-
|
|
481
|
+
}
|
|
482
|
+
export async function runTagHead(opts) {
|
|
483
|
+
const { cwd, language, tag, autoTag, push } = opts;
|
|
484
|
+
assertGitWorkTree(cwd);
|
|
485
|
+
let headHash;
|
|
486
|
+
try {
|
|
487
|
+
headHash = runGit(["rev-parse", "HEAD"], cwd);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
throw new QuickCommitError("仓库还没有任何 commit,无法打 tag。", "NO_COMMIT");
|
|
491
|
+
}
|
|
492
|
+
let tagName = (tag || "").trim();
|
|
493
|
+
if (!tagName && autoTag) {
|
|
494
|
+
let headSubject = "";
|
|
495
|
+
try {
|
|
496
|
+
headSubject = runGit(["log", "-1", "--pretty=format:%s"], cwd);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
headSubject = "";
|
|
500
|
+
}
|
|
501
|
+
tagName = await generateTagAfterCommit(cwd, language, headSubject || "");
|
|
502
|
+
}
|
|
503
|
+
if (!tagName) {
|
|
504
|
+
throw new QuickCommitError("请填写 tag 名称,或开启 AI 生成。", "EMPTY_TAG");
|
|
505
|
+
}
|
|
506
|
+
// Refuse to overwrite an existing tag — surface a clear error code.
|
|
507
|
+
try {
|
|
508
|
+
runGit(["rev-parse", "--verify", `refs/tags/${tagName}`], cwd);
|
|
509
|
+
throw new QuickCommitError(`tag \`${tagName}\` 已存在。`, "TAG_EXISTS");
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (error instanceof QuickCommitError)
|
|
513
|
+
throw error;
|
|
514
|
+
// not found — good, proceed
|
|
374
515
|
}
|
|
375
|
-
|
|
516
|
+
try {
|
|
517
|
+
runGit(["tag", tagName], cwd);
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
|
|
521
|
+
}
|
|
522
|
+
let pushed = false;
|
|
523
|
+
let pushError;
|
|
524
|
+
if (push) {
|
|
525
|
+
const outcome = doPush({ cwd, pushCommits: false, pushTags: [tagName] });
|
|
526
|
+
pushed = outcome.pushedTags;
|
|
527
|
+
pushError = outcome.error;
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
tag: { name: tagName, commit: headHash.slice(0, 7) },
|
|
532
|
+
pushed,
|
|
533
|
+
pushError,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
export async function runPush(opts) {
|
|
537
|
+
const { cwd, pushCommits = true, pushTags = false } = opts;
|
|
538
|
+
assertGitWorkTree(cwd);
|
|
539
|
+
if (!pushCommits && !pushTags) {
|
|
540
|
+
throw new QuickCommitError("没有要推送的内容。", "NOTHING_TO_PUSH");
|
|
541
|
+
}
|
|
542
|
+
const outcome = doPush({ cwd, pushCommits, pushTags });
|
|
543
|
+
return {
|
|
544
|
+
ok: !outcome.error,
|
|
545
|
+
pushedCommits: outcome.pushedCommits,
|
|
546
|
+
pushedTags: outcome.pushedTags,
|
|
547
|
+
error: outcome.error,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* 在 commit 父仓库之前,先在每个内部 dirty / untracked 的 submodule 里
|
|
552
|
+
* 执行一次 `git add -A` + `git commit -m <msg>`,让父仓库的 add -A
|
|
553
|
+
* 能正确捕捉到新的 submodule 指针。纯指针变化(仅 commitChanged)的
|
|
554
|
+
* submodule 不会进入这条路径——那种情况父仓库 add 已经够了。
|
|
555
|
+
*
|
|
556
|
+
* 任一 submodule 提交失败都会被收集为非致命错误,不阻塞父仓库继续 commit;
|
|
557
|
+
* 调用方可以在结果里读到具体哪几个 submodule 失败。
|
|
558
|
+
*/
|
|
559
|
+
function commitDirtySubmodules(parentCwd, message) {
|
|
560
|
+
const commits = [];
|
|
561
|
+
const errors = [];
|
|
562
|
+
let porcelain;
|
|
563
|
+
try {
|
|
564
|
+
porcelain = runGitAllowEmpty(["status", "--porcelain=v2", "--untracked-files=all"], parentCwd);
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return { commits, errors };
|
|
568
|
+
}
|
|
569
|
+
const entries = parsePorcelainV2(porcelain);
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
if (!entry.isSubmodule)
|
|
572
|
+
continue;
|
|
573
|
+
const state = entry.submoduleState;
|
|
574
|
+
if (!state)
|
|
575
|
+
continue;
|
|
576
|
+
// 只有内部脏 / 未跟踪才需要进入子目录提交;纯指针变化父仓库自己就能 add。
|
|
577
|
+
if (!state.hasTrackedChanges && !state.hasUntracked)
|
|
578
|
+
continue;
|
|
579
|
+
const subCwd = `${parentCwd}/${entry.path}`;
|
|
580
|
+
if (!existsSync(subCwd)) {
|
|
581
|
+
errors.push(`submodule ${entry.path} 路径不存在`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
runGit(["add", "-A"], subCwd, 5000);
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
errors.push(`submodule ${entry.path} add 失败:${getGitErrorMessage(error)}`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// 子仓 add 之后再判断是否真的有 staged 内容:极端情况下 .gitignore 把所有 dirty
|
|
592
|
+
// 文件都过滤掉了,会得到一个空 diff,此时跳过避免空 commit。
|
|
593
|
+
let staged;
|
|
594
|
+
try {
|
|
595
|
+
staged = runGitAllowEmpty(["diff", "--cached", "--name-only"], subCwd).trim();
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
staged = "";
|
|
599
|
+
}
|
|
600
|
+
if (!staged)
|
|
601
|
+
continue;
|
|
602
|
+
try {
|
|
603
|
+
runGit(["commit", "-m", message], subCwd, 10_000);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
errors.push(`submodule ${entry.path} commit 失败:${getGitErrorMessage(error)}`);
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
let hash = "";
|
|
610
|
+
try {
|
|
611
|
+
hash = runGit(["rev-parse", "--short", "HEAD"], subCwd);
|
|
612
|
+
}
|
|
613
|
+
catch { /* ignore */ }
|
|
614
|
+
commits.push({ path: entry.path, hash });
|
|
615
|
+
}
|
|
616
|
+
return { commits, errors };
|
|
617
|
+
}
|
|
618
|
+
export async function runQuickCommit(opts) {
|
|
619
|
+
const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
|
|
620
|
+
assertGitWorkTree(cwd);
|
|
621
|
+
// 先 add 一次让我们能在 collectStagedDiff 看到完整改动(包含 submodule 指针),
|
|
622
|
+
// AI 生成 message 时也基于这个 staged diff。
|
|
376
623
|
try {
|
|
377
624
|
runGit(["add", "-A"], cwd, 5000);
|
|
378
625
|
}
|
|
379
626
|
catch (error) {
|
|
380
627
|
throw new QuickCommitError(`git add 失败:${getGitErrorMessage(error)}`, "GIT_ADD_FAILED");
|
|
381
628
|
}
|
|
382
|
-
// Step 2: check if anything to commit
|
|
383
629
|
let stagedFiles;
|
|
384
630
|
try {
|
|
385
631
|
stagedFiles = runGitAllowEmpty(["diff", "--cached", "--name-only"], cwd).trim();
|
|
@@ -387,10 +633,18 @@ export async function runQuickCommit(opts) {
|
|
|
387
633
|
catch (error) {
|
|
388
634
|
throw new QuickCommitError(getGitErrorMessage(error), "GIT_DIFF_FAILED");
|
|
389
635
|
}
|
|
390
|
-
|
|
636
|
+
// 父仓库本身可能没有 staged 文件,但 submodule 内部有 dirty / untracked——
|
|
637
|
+
// 此时也应该允许走 submodule 提交流程。
|
|
638
|
+
let parentHasStaged = stagedFiles.length > 0;
|
|
639
|
+
let submoduleHasDirty = false;
|
|
640
|
+
try {
|
|
641
|
+
const porcelain = runGitAllowEmpty(["status", "--porcelain=v2", "--untracked-files=all"], cwd);
|
|
642
|
+
submoduleHasDirty = parsePorcelainV2(porcelain).some((e) => e.isSubmodule && (e.submoduleState?.hasTrackedChanges || e.submoduleState?.hasUntracked));
|
|
643
|
+
}
|
|
644
|
+
catch { /* keep submoduleHasDirty=false */ }
|
|
645
|
+
if (!parentHasStaged && !submoduleHasDirty) {
|
|
391
646
|
throw new QuickCommitError("没有任何改动可以提交。", "NOTHING_TO_COMMIT");
|
|
392
647
|
}
|
|
393
|
-
// Step 3: get commit message
|
|
394
648
|
let message;
|
|
395
649
|
if (autoMessage) {
|
|
396
650
|
message = await generateCommitMessage(cwd, language);
|
|
@@ -401,7 +655,32 @@ export async function runQuickCommit(opts) {
|
|
|
401
655
|
throw new QuickCommitError("commit message 不能为空。", "EMPTY_MESSAGE");
|
|
402
656
|
}
|
|
403
657
|
}
|
|
404
|
-
//
|
|
658
|
+
// 先提交 submodule 内部改动;父仓库随后再 add 一次,picks up 新的 submodule 指针。
|
|
659
|
+
const submoduleOutcome = commitDirtySubmodules(cwd, message);
|
|
660
|
+
if (submoduleOutcome.commits.length > 0) {
|
|
661
|
+
try {
|
|
662
|
+
runGit(["add", "-A"], cwd, 5000);
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
throw new QuickCommitError(`父仓库 add submodule 指针失败:${getGitErrorMessage(error)}`, "GIT_ADD_FAILED");
|
|
666
|
+
}
|
|
667
|
+
// 重新评估父仓库是否有 staged 内容:如果 submodule 是新引入的或指针变了,
|
|
668
|
+
// 这里应当为真;如果完全没变就走 commit --allow-empty 路径不合适,直接报错。
|
|
669
|
+
try {
|
|
670
|
+
stagedFiles = runGitAllowEmpty(["diff", "--cached", "--name-only"], cwd).trim();
|
|
671
|
+
parentHasStaged = stagedFiles.length > 0;
|
|
672
|
+
}
|
|
673
|
+
catch { /* keep stale value */ }
|
|
674
|
+
}
|
|
675
|
+
if (!parentHasStaged) {
|
|
676
|
+
// submodule 都提交了但父仓库还是没有 staged —— 通常意味着 .gitmodules 没动
|
|
677
|
+
// 而 submodule 指针被 ignore(罕见配置)。这种情况返回成功,但用 SUBMODULE_ONLY
|
|
678
|
+
// 的语义;上层 UI 可以决定是否继续 push。这里保持向后兼容,沿用 commit 路径
|
|
679
|
+
// 但用 --allow-empty 会有副作用,干脆抛 NOTHING_TO_COMMIT。
|
|
680
|
+
throw new QuickCommitError(submoduleOutcome.commits.length > 0
|
|
681
|
+
? `已提交 ${submoduleOutcome.commits.length} 个 submodule,但父仓库没有改动可提交。`
|
|
682
|
+
: "没有任何改动可以提交。", "NOTHING_TO_COMMIT");
|
|
683
|
+
}
|
|
405
684
|
try {
|
|
406
685
|
runGit(["commit", "-m", message], cwd, 10_000);
|
|
407
686
|
}
|
|
@@ -415,10 +694,7 @@ export async function runQuickCommit(opts) {
|
|
|
415
694
|
catch {
|
|
416
695
|
commitHash = "";
|
|
417
696
|
}
|
|
418
|
-
//
|
|
419
|
-
// - explicit `tag` wins
|
|
420
|
-
// - if `tag` is empty and `autoTag` is on, ask Claude to generate one
|
|
421
|
-
// - otherwise no tag
|
|
697
|
+
// Tag: explicit `tag` wins; if empty + autoTag, ask Claude; otherwise skip.
|
|
422
698
|
let tagName = (tag || "").trim();
|
|
423
699
|
if (!tagName && autoTag) {
|
|
424
700
|
tagName = await generateTagAfterCommit(cwd, language, message);
|
|
@@ -431,41 +707,17 @@ export async function runQuickCommit(opts) {
|
|
|
431
707
|
throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
|
|
432
708
|
}
|
|
433
709
|
}
|
|
434
|
-
// Step 6: push
|
|
435
710
|
let pushed = false;
|
|
436
711
|
let pushError;
|
|
437
712
|
if (push) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if (currentBranch) {
|
|
447
|
-
pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
catch {
|
|
451
|
-
pushRemote = "origin";
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
catch {
|
|
455
|
-
hasUpstream = false;
|
|
456
|
-
}
|
|
457
|
-
if (hasUpstream) {
|
|
458
|
-
runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
459
|
-
}
|
|
460
|
-
else {
|
|
461
|
-
runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
462
|
-
}
|
|
463
|
-
runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
464
|
-
pushed = true;
|
|
465
|
-
}
|
|
466
|
-
catch (error) {
|
|
467
|
-
pushError = getGitErrorMessage(error);
|
|
468
|
-
}
|
|
713
|
+
const outcome = doPush({
|
|
714
|
+
cwd,
|
|
715
|
+
pushCommits: true,
|
|
716
|
+
// Push only the freshly-created tag — avoids surprising users by pushing stale local tags.
|
|
717
|
+
pushTags: tagName ? [tagName] : false,
|
|
718
|
+
});
|
|
719
|
+
pushed = outcome.pushedCommits && (tagName ? outcome.pushedTags : true);
|
|
720
|
+
pushError = outcome.error;
|
|
469
721
|
}
|
|
470
722
|
return {
|
|
471
723
|
ok: true,
|
|
@@ -473,5 +725,6 @@ export async function runQuickCommit(opts) {
|
|
|
473
725
|
tag: tagName ? { name: tagName } : undefined,
|
|
474
726
|
pushed,
|
|
475
727
|
pushError,
|
|
728
|
+
submoduleCommits: submoduleOutcome.commits.length > 0 ? submoduleOutcome.commits : undefined,
|
|
476
729
|
};
|
|
477
730
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { SessionInputError } from "./process-manager.js";
|
|
3
3
|
import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
|
|
4
|
-
import { getGitStatus, QuickCommitError, runQuickCommit, generateCommitMessageOnly } from "./git-quick-commit.js";
|
|
4
|
+
import { getGitStatus, QuickCommitError, runQuickCommit, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
|
|
5
5
|
export function getErrorMessage(error, fallback) {
|
|
6
6
|
return error instanceof Error ? error.message : fallback;
|
|
7
7
|
}
|
|
@@ -388,6 +388,63 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
388
388
|
res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
|
|
389
389
|
}
|
|
390
390
|
});
|
|
391
|
+
app.post("/api/sessions/:id/git/tag-head", express.json(), async (req, res) => {
|
|
392
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
393
|
+
if (!snapshot) {
|
|
394
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!snapshot.cwd) {
|
|
398
|
+
res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const body = (req.body ?? {});
|
|
402
|
+
try {
|
|
403
|
+
const result = await runTagHead({
|
|
404
|
+
cwd: snapshot.cwd,
|
|
405
|
+
language: config.language ?? "",
|
|
406
|
+
tag: typeof body.tag === "string" ? body.tag : undefined,
|
|
407
|
+
autoTag: !!body.autoTag,
|
|
408
|
+
push: !!body.push,
|
|
409
|
+
});
|
|
410
|
+
res.json(result);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
if (error instanceof QuickCommitError) {
|
|
414
|
+
const status = error.code === "TAG_EXISTS" ? 409 : 400;
|
|
415
|
+
res.status(status).json({ error: error.message, errorCode: error.code });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
res.status(400).json({ error: getErrorMessage(error, "打 tag 失败。") });
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
app.post("/api/sessions/:id/git/push", express.json(), async (req, res) => {
|
|
422
|
+
const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
|
|
423
|
+
if (!snapshot) {
|
|
424
|
+
res.status(404).json({ error: "未找到该会话。" });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!snapshot.cwd) {
|
|
428
|
+
res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const body = (req.body ?? {});
|
|
432
|
+
try {
|
|
433
|
+
const result = await runPush({
|
|
434
|
+
cwd: snapshot.cwd,
|
|
435
|
+
pushCommits: body.pushCommits !== false,
|
|
436
|
+
pushTags: !!body.pushTags,
|
|
437
|
+
});
|
|
438
|
+
res.json(result);
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (error instanceof QuickCommitError) {
|
|
442
|
+
res.status(400).json({ error: error.message, errorCode: error.code });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
res.status(400).json({ error: getErrorMessage(error, "推送失败。") });
|
|
446
|
+
}
|
|
447
|
+
});
|
|
391
448
|
app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
|
|
392
449
|
try {
|
|
393
450
|
const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
|