@co0ontty/wand 1.26.0 → 1.29.1

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.
@@ -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,20 +173,18 @@ export function getGitStatus(cwd) {
141
173
  }
142
174
  const allEntries = parsePorcelainV2(porcelain);
143
175
  const files = allEntries.slice(0, MAX_FILE_ENTRIES);
144
- // Upstream / ahead / behind ──────────────────────────────────────────
145
- let hasUpstream = false;
146
176
  let upstream;
147
177
  let ahead;
148
178
  let behind;
179
+ let lastCommit;
149
180
  if (!initialCommit) {
150
181
  try {
151
182
  upstream = runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd) || undefined;
152
- hasUpstream = !!upstream;
153
183
  }
154
184
  catch {
155
- hasUpstream = false;
185
+ upstream = undefined;
156
186
  }
157
- if (hasUpstream) {
187
+ if (upstream) {
158
188
  try {
159
189
  const counts = runGit(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], cwd);
160
190
  const parts = counts.split(/\s+/).filter(Boolean);
@@ -168,13 +198,9 @@ export function getGitStatus(cwd) {
168
198
  }
169
199
  }
170
200
  catch {
171
- // ignore — keep counts undefined
201
+ // ignore — counts stay undefined
172
202
  }
173
203
  }
174
- }
175
- // HEAD commit info ───────────────────────────────────────────────────
176
- let lastCommit;
177
- if (!initialCommit) {
178
204
  try {
179
205
  const raw = runGit(["log", "-1", "--pretty=format:%H%x09%h%x09%s"], cwd);
180
206
  const parts = raw.split("\t");
@@ -186,41 +212,10 @@ export function getGitStatus(cwd) {
186
212
  // ignore
187
213
  }
188
214
  }
189
- // Unpushed tag count (best-effort; remote refs may not be fresh) ─────
190
- let unpushedTagCount;
191
- if (hasUpstream && upstream) {
192
- const slashIdx = upstream.indexOf("/");
193
- const remoteName = slashIdx > 0 ? upstream.slice(0, slashIdx) : "origin";
194
- try {
195
- const localTagsRaw = runGit(["for-each-ref", "--format=%(refname:short)", "refs/tags"], cwd);
196
- const localTags = localTagsRaw.split(/\r?\n/).filter((t) => t.trim().length > 0);
197
- if (localTags.length === 0) {
198
- unpushedTagCount = 0;
199
- }
200
- else {
201
- const remoteTagsRaw = runGit(["ls-remote", "--tags", remoteName], cwd, 3000);
202
- const remoteTags = new Set();
203
- for (const line of remoteTagsRaw.split(/\r?\n/)) {
204
- // format: <sha>\trefs/tags/<name>{^}? — strip annotated suffix
205
- const tabIdx = line.indexOf("\t");
206
- if (tabIdx === -1)
207
- continue;
208
- const ref = line.slice(tabIdx + 1).trim();
209
- if (!ref.startsWith("refs/tags/"))
210
- continue;
211
- let name = ref.slice("refs/tags/".length);
212
- if (name.endsWith("^{}"))
213
- name = name.slice(0, -3);
214
- if (name)
215
- remoteTags.add(name);
216
- }
217
- unpushedTagCount = localTags.filter((t) => !remoteTags.has(t)).length;
218
- }
219
- }
220
- catch {
221
- // remote unreachable — leave undefined so UI can hide the chip
222
- }
223
- }
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.
224
219
  return {
225
220
  isGit: true,
226
221
  branch,
@@ -229,12 +224,10 @@ export function getGitStatus(cwd) {
229
224
  head,
230
225
  repoRoot,
231
226
  initialCommit,
232
- hasUpstream,
233
227
  upstream,
234
228
  ahead,
235
229
  behind,
236
230
  lastCommit,
237
- unpushedTagCount,
238
231
  };
239
232
  }
240
233
  export class QuickCommitError extends Error {
@@ -446,27 +439,19 @@ ${diff}`;
446
439
  * Push current branch (with upstream auto-setup) and/or tags.
447
440
  * Errors are returned via `error` so callers can present partial-success states.
448
441
  */
449
- function doPush(cwd, pushCommits, pushTags) {
442
+ function doPush(opts) {
443
+ const { cwd, pushCommits, pushTags } = opts;
450
444
  let pushedCommits = false;
451
445
  let pushedTags = false;
452
446
  let hasUpstream = false;
453
- let pushRemote = "origin";
454
447
  try {
455
448
  runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
456
449
  hasUpstream = true;
457
- try {
458
- const currentBranch = runGit(["branch", "--show-current"], cwd);
459
- if (currentBranch) {
460
- pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
461
- }
462
- }
463
- catch {
464
- pushRemote = "origin";
465
- }
466
450
  }
467
451
  catch {
468
452
  hasUpstream = false;
469
453
  }
454
+ const pushRemote = resolvePushRemote(cwd);
470
455
  try {
471
456
  if (pushCommits) {
472
457
  if (hasUpstream) {
@@ -478,7 +463,14 @@ function doPush(cwd, pushCommits, pushTags) {
478
463
  pushedCommits = true;
479
464
  }
480
465
  if (pushTags) {
481
- runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
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
+ }
482
474
  pushedTags = true;
483
475
  }
484
476
  return { pushedCommits, pushedTags };
@@ -489,20 +481,7 @@ function doPush(cwd, pushCommits, pushTags) {
489
481
  }
490
482
  export async function runTagHead(opts) {
491
483
  const { cwd, language, tag, autoTag, push } = opts;
492
- if (!cwd || !existsSync(cwd)) {
493
- throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
494
- }
495
- let isInside;
496
- try {
497
- isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
498
- }
499
- catch (error) {
500
- throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
501
- }
502
- if (isInside !== "true") {
503
- throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
504
- }
505
- // Need an existing commit to tag
484
+ assertGitWorkTree(cwd);
506
485
  let headHash;
507
486
  try {
508
487
  headHash = runGit(["rev-parse", "HEAD"], cwd);
@@ -512,7 +491,6 @@ export async function runTagHead(opts) {
512
491
  }
513
492
  let tagName = (tag || "").trim();
514
493
  if (!tagName && autoTag) {
515
- // Reuse the post-commit generator — it inspects `git show HEAD` directly.
516
494
  let headSubject = "";
517
495
  try {
518
496
  headSubject = runGit(["log", "-1", "--pretty=format:%s"], cwd);
@@ -544,29 +522,9 @@ export async function runTagHead(opts) {
544
522
  let pushed = false;
545
523
  let pushError;
546
524
  if (push) {
547
- let pushRemote = "origin";
548
- try {
549
- const currentBranch = runGit(["branch", "--show-current"], cwd);
550
- if (currentBranch) {
551
- try {
552
- pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
553
- }
554
- catch {
555
- pushRemote = "origin";
556
- }
557
- }
558
- }
559
- catch {
560
- pushRemote = "origin";
561
- }
562
- try {
563
- // Push just this one tag — cheaper and more targeted than `--tags`.
564
- runGit(["push", pushRemote, `refs/tags/${tagName}`], cwd, GIT_PUSH_TIMEOUT_MS);
565
- pushed = true;
566
- }
567
- catch (error) {
568
- pushError = getGitErrorMessage(error);
569
- }
525
+ const outcome = doPush({ cwd, pushCommits: false, pushTags: [tagName] });
526
+ pushed = outcome.pushedTags;
527
+ pushError = outcome.error;
570
528
  }
571
529
  return {
572
530
  ok: true,
@@ -577,23 +535,11 @@ export async function runTagHead(opts) {
577
535
  }
578
536
  export async function runPush(opts) {
579
537
  const { cwd, pushCommits = true, pushTags = false } = opts;
580
- if (!cwd || !existsSync(cwd)) {
581
- throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
582
- }
538
+ assertGitWorkTree(cwd);
583
539
  if (!pushCommits && !pushTags) {
584
540
  throw new QuickCommitError("没有要推送的内容。", "NOTHING_TO_PUSH");
585
541
  }
586
- let isInside;
587
- try {
588
- isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
589
- }
590
- catch (error) {
591
- throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
592
- }
593
- if (isInside !== "true") {
594
- throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
595
- }
596
- const outcome = doPush(cwd, pushCommits, pushTags);
542
+ const outcome = doPush({ cwd, pushCommits, pushTags });
597
543
  return {
598
544
  ok: !outcome.error,
599
545
  pushedCommits: outcome.pushedCommits,
@@ -601,29 +547,85 @@ export async function runPush(opts) {
601
547
  error: outcome.error,
602
548
  };
603
549
  }
604
- export async function runQuickCommit(opts) {
605
- const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
606
- if (!cwd || !existsSync(cwd)) {
607
- throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
608
- }
609
- let isInside;
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;
610
563
  try {
611
- isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
564
+ porcelain = runGitAllowEmpty(["status", "--porcelain=v2", "--untracked-files=all"], parentCwd);
612
565
  }
613
- catch (error) {
614
- throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
566
+ catch {
567
+ return { commits, errors };
615
568
  }
616
- if (isInside !== "true") {
617
- throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
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 });
618
615
  }
619
- // Step 1: stage all
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。
620
623
  try {
621
624
  runGit(["add", "-A"], cwd, 5000);
622
625
  }
623
626
  catch (error) {
624
627
  throw new QuickCommitError(`git add 失败:${getGitErrorMessage(error)}`, "GIT_ADD_FAILED");
625
628
  }
626
- // Step 2: check if anything to commit
627
629
  let stagedFiles;
628
630
  try {
629
631
  stagedFiles = runGitAllowEmpty(["diff", "--cached", "--name-only"], cwd).trim();
@@ -631,10 +633,18 @@ export async function runQuickCommit(opts) {
631
633
  catch (error) {
632
634
  throw new QuickCommitError(getGitErrorMessage(error), "GIT_DIFF_FAILED");
633
635
  }
634
- 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) {
635
646
  throw new QuickCommitError("没有任何改动可以提交。", "NOTHING_TO_COMMIT");
636
647
  }
637
- // Step 3: get commit message
638
648
  let message;
639
649
  if (autoMessage) {
640
650
  message = await generateCommitMessage(cwd, language);
@@ -645,7 +655,32 @@ export async function runQuickCommit(opts) {
645
655
  throw new QuickCommitError("commit message 不能为空。", "EMPTY_MESSAGE");
646
656
  }
647
657
  }
648
- // 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
+ }
649
684
  try {
650
685
  runGit(["commit", "-m", message], cwd, 10_000);
651
686
  }
@@ -659,10 +694,7 @@ export async function runQuickCommit(opts) {
659
694
  catch {
660
695
  commitHash = "";
661
696
  }
662
- // Step 5: tag
663
- // - explicit `tag` wins
664
- // - if `tag` is empty and `autoTag` is on, ask Claude to generate one
665
- // - otherwise no tag
697
+ // Tag: explicit `tag` wins; if empty + autoTag, ask Claude; otherwise skip.
666
698
  let tagName = (tag || "").trim();
667
699
  if (!tagName && autoTag) {
668
700
  tagName = await generateTagAfterCommit(cwd, language, message);
@@ -675,11 +707,15 @@ export async function runQuickCommit(opts) {
675
707
  throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
676
708
  }
677
709
  }
678
- // Step 6: push
679
710
  let pushed = false;
680
711
  let pushError;
681
712
  if (push) {
682
- const outcome = doPush(cwd, true, !!tagName);
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
+ });
683
719
  pushed = outcome.pushedCommits && (tagName ? outcome.pushedTags : true);
684
720
  pushError = outcome.error;
685
721
  }
@@ -689,5 +725,6 @@ export async function runQuickCommit(opts) {
689
725
  tag: tagName ? { name: tagName } : undefined,
690
726
  pushed,
691
727
  pushError,
728
+ submoduleCommits: submoduleOutcome.commits.length > 0 ? submoduleOutcome.commits : undefined,
692
729
  };
693
730
  }