@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 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: string;
15
- constructor(message: string, code: string);
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 {};
@@ -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
- // ── Direct git operations ──
360
- export async function runQuickCommit(opts) {
361
- const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
362
- if (!cwd || !existsSync(cwd)) {
363
- throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
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
- let isInside;
451
+ catch {
452
+ hasUpstream = false;
453
+ }
454
+ const pushRemote = resolvePushRemote(cwd);
366
455
  try {
367
- isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
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
- throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
479
+ return { pushedCommits, pushedTags, error: getGitErrorMessage(error) };
371
480
  }
372
- if (isInside !== "true") {
373
- throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
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
- // Step 1: stage all
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
- if (!stagedFiles) {
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
- // Step 4: commit
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
- // Step 5: tag
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
- try {
439
- let hasUpstream = false;
440
- let pushRemote = "origin";
441
- try {
442
- runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
443
- hasUpstream = true;
444
- try {
445
- const currentBranch = runGit(["branch", "--show-current"], cwd);
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));