@icode-js/icode 3.2.0 → 3.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.
Files changed (75) hide show
  1. package/README.md +287 -24
  2. package/bin/icode.js +10 -0
  3. package/package.json +3 -3
  4. package/src/cli.js +175 -8
  5. package/src/commands/checkout.js +73 -6
  6. package/src/commands/clean.js +47 -7
  7. package/src/commands/config-ux.js +292 -0
  8. package/src/commands/config.js +199 -48
  9. package/src/commands/continue.js +104 -0
  10. package/src/commands/doctor.js +15 -29
  11. package/src/commands/help.js +54 -34
  12. package/src/commands/history.js +147 -0
  13. package/src/commands/info.js +39 -4
  14. package/src/commands/log.js +142 -0
  15. package/src/commands/migrate.js +19 -12
  16. package/src/commands/open.js +138 -0
  17. package/src/commands/pr.js +219 -0
  18. package/src/commands/push.js +62 -22
  19. package/src/commands/ready.js +18 -48
  20. package/src/commands/release.js +112 -0
  21. package/src/commands/rescue.js +164 -0
  22. package/src/commands/save.js +52 -11
  23. package/src/commands/stash.js +210 -0
  24. package/src/commands/sw.js +89 -0
  25. package/src/commands/sync.js +19 -2
  26. package/src/commands/tag.js +40 -1
  27. package/src/commands/undo.js +9 -3
  28. package/src/core/ai/diff-range.js +1 -1
  29. package/src/core/cli/completion.js +237 -1
  30. package/src/core/cli/output.js +46 -0
  31. package/src/core/cli/render.js +121 -0
  32. package/src/core/commands/shell.js +9 -1
  33. package/src/core/config/repo-config.js +93 -0
  34. package/src/core/config/store.js +60 -10
  35. package/src/core/git/autostash.js +46 -0
  36. package/src/core/git/backup.js +170 -0
  37. package/src/core/git/conflicts.js +58 -0
  38. package/src/core/git/context.js +72 -5
  39. package/src/core/git/file-guard.js +193 -0
  40. package/src/core/git/operation-state.js +63 -0
  41. package/src/core/git/operations.js +32 -0
  42. package/src/core/git/semver.js +194 -0
  43. package/src/core/git/service.js +454 -24
  44. package/src/core/platform/http.js +53 -0
  45. package/src/core/platform/index.js +165 -0
  46. package/src/core/platform/providers.js +454 -0
  47. package/src/core/platform/remote-url.js +92 -0
  48. package/src/core/remote-merge/client.js +33 -3
  49. package/src/core/tools/browser.js +28 -0
  50. package/src/core/tools/commit-prompt.js +137 -0
  51. package/src/core/tools/interactive.js +272 -40
  52. package/src/core/tools/logger.js +33 -23
  53. package/src/core/tools/theme.js +228 -0
  54. package/src/workflows/ai-commit-workflow.js +52 -20
  55. package/src/workflows/checkout-workflow.js +24 -10
  56. package/src/workflows/clean-workflow.js +135 -83
  57. package/src/workflows/continue-workflow.js +233 -0
  58. package/src/workflows/doctor-workflow.js +10 -7
  59. package/src/workflows/history-workflow.js +211 -0
  60. package/src/workflows/migrate-workflow.js +39 -14
  61. package/src/workflows/pr-workflow.js +252 -0
  62. package/src/workflows/push/commit-step.js +123 -0
  63. package/src/workflows/push/local-merge-step.js +213 -0
  64. package/src/workflows/push/plan.js +63 -0
  65. package/src/workflows/push/remote-merge-step.js +224 -0
  66. package/src/workflows/push-workflow.js +185 -492
  67. package/src/workflows/ready-workflow.js +14 -10
  68. package/src/workflows/release-workflow.js +189 -0
  69. package/src/workflows/rescue-workflow.js +137 -0
  70. package/src/workflows/save-workflow.js +106 -41
  71. package/src/workflows/switch-workflow.js +171 -0
  72. package/src/workflows/sync-workflow.js +143 -44
  73. package/src/workflows/tag-workflow.js +103 -17
  74. package/src/workflows/undo-workflow.js +113 -34
  75. package/src/workflows/rollback-workflow.js +0 -99
@@ -96,10 +96,31 @@ function parseGitLogRecords(output) {
96
96
  .filter((item) => item.hash && item.subject)
97
97
  }
98
98
 
99
+ // 把秒数格式化成“3 分钟前”这类相对时间。
100
+ function formatRelativeSeconds(seconds) {
101
+ const value = Math.max(0, seconds)
102
+ if (value < 60) {
103
+ return `${value} 秒前`
104
+ }
105
+ if (value < 3600) {
106
+ return `${Math.floor(value / 60)} 分钟前`
107
+ }
108
+ if (value < 86400) {
109
+ return `${Math.floor(value / 3600)} 小时前`
110
+ }
111
+ return `${Math.floor(value / 86400)} 天前`
112
+ }
113
+
99
114
  export class GitService {
100
115
  constructor(context) {
101
116
  this.context = context
102
117
  this.cwd = context.topLevelPath
118
+ this.remote = context.remote || 'origin'
119
+ }
120
+
121
+ // 远程跟踪分支引用,例如 origin/main。
122
+ remoteRef(branchName) {
123
+ return `${this.remote}/${branchName}`
103
124
  }
104
125
 
105
126
  async exec(args, options = {}) {
@@ -117,8 +138,18 @@ export class GitService {
117
138
  return cleanOutput(result.stdout)
118
139
  }
119
140
 
120
- async fetch() {
121
- await this.exec(['fetch', '--all', '--prune'])
141
+ // 默认只 fetch 当前使用的 remote,remote 很多的仓库也不会变慢。
142
+ async fetch(options = {}) {
143
+ if (options.all) {
144
+ await this.exec(['fetch', '--all', '--prune'])
145
+ return
146
+ }
147
+ // 仓库没有配置这个 remote(例如纯本地仓库)时跳过,和 fetch --all 的行为保持一致。
148
+ if (Array.isArray(this.context.remotes) && !this.context.remotes.includes(this.remote)) {
149
+ logger.debug(`remote ${this.remote} 不存在,跳过 fetch`)
150
+ return
151
+ }
152
+ await this.exec(['fetch', this.remote, '--prune'])
122
153
  }
123
154
 
124
155
  operationFileExists(fileName) {
@@ -162,13 +193,13 @@ export class GitService {
162
193
  }
163
194
 
164
195
  async branchExistsRemote(branchName) {
165
- const result = await this.exec(['ls-remote', '--exit-code', '--heads', 'origin', branchName], {
196
+ const result = await this.exec(['ls-remote', '--exit-code', '--heads', this.remote, branchName], {
166
197
  allowFailure: true
167
198
  })
168
199
  return result.exitCode === 0
169
200
  }
170
201
 
171
- async getRemoteUrl(remoteName = 'origin') {
202
+ async getRemoteUrl(remoteName = this.remote) {
172
203
  const result = await this.exec(['remote', 'get-url', remoteName], {
173
204
  allowFailure: true
174
205
  })
@@ -183,7 +214,7 @@ export class GitService {
183
214
  .filter(Boolean)
184
215
  }
185
216
 
186
- async listRemoteBranches(remoteName = 'origin') {
217
+ async listRemoteBranches(remoteName = this.remote) {
187
218
  const result = await this.exec(['for-each-ref', '--format=%(refname:strip=3)', `refs/remotes/${remoteName}`], {
188
219
  allowFailure: true
189
220
  })
@@ -199,10 +230,11 @@ export class GitService {
199
230
  }
200
231
 
201
232
  async listMergedLocalBranches(targetRef) {
202
- const result = await this.exec(['branch', '--merged', targetRef])
233
+ // for-each-ref 输出稳定,不会带上 `*` / `+`(worktree)等标记。
234
+ const result = await this.exec(['for-each-ref', '--format=%(refname:short)', `--merged=${targetRef}`, 'refs/heads'])
203
235
  return result.stdout
204
236
  .split('\n')
205
- .map((item) => item.replace('*', '').trim())
237
+ .map((item) => item.trim())
206
238
  .filter(Boolean)
207
239
  }
208
240
 
@@ -211,7 +243,7 @@ export class GitService {
211
243
  }
212
244
 
213
245
  async checkoutTracking(branchName) {
214
- await this.exec(['checkout', '-b', branchName, '--track', `origin/${branchName}`])
246
+ await this.exec(['checkout', '-b', branchName, '--track', this.remoteRef(branchName)])
215
247
  }
216
248
 
217
249
  async checkoutNewBranch(branchName, fromBranch) {
@@ -219,11 +251,11 @@ export class GitService {
219
251
  }
220
252
 
221
253
  async createTrackingFromRemote(branchName) {
222
- await this.exec(['branch', '--track', branchName, `origin/${branchName}`])
254
+ await this.exec(['branch', '--track', branchName, this.remoteRef(branchName)])
223
255
  }
224
256
 
225
257
  async pull(branchName, options = {}) {
226
- const args = ['pull', 'origin', branchName]
258
+ const args = ['pull', this.remote, branchName]
227
259
 
228
260
  if (options.rebase) {
229
261
  args.push('--rebase')
@@ -255,7 +287,7 @@ export class GitService {
255
287
 
256
288
  // 继续已解决冲突的 merge 操作,供 undo recover 统一恢复合并现场。
257
289
  async mergeContinue() {
258
- await this.exec(['merge', '--continue'])
290
+ await this.exec(['merge', '--continue'], { env: { ...process.env, GIT_EDITOR: 'true' } })
259
291
  }
260
292
 
261
293
  // 中止未完成的 merge 操作,保留给显式 recover abort 使用,不自动清理用户现场。
@@ -347,8 +379,12 @@ export class GitService {
347
379
  }
348
380
 
349
381
  // 列出未被忽略的未跟踪文件,供 AI 工作流补全新文件 diff。
350
- async listUntrackedFiles() {
351
- const result = await this.exec(['ls-files', '--others', '--exclude-standard'])
382
+ async listUntrackedFiles(paths = []) {
383
+ const args = ['ls-files', '--others', '--exclude-standard']
384
+ if (paths.length) {
385
+ args.push('--', ...paths)
386
+ }
387
+ const result = await this.exec(args)
352
388
  return result.stdout
353
389
  .split('\n')
354
390
  .map((item) => item.trim())
@@ -363,10 +399,10 @@ export class GitService {
363
399
  return result.stdout
364
400
  }
365
401
 
366
- async diffWorkingTree() {
402
+ async diffWorkingTree(paths = []) {
367
403
  // git diff 默认不包含 untracked 文件,这里显式补齐,避免新仓库被误判成“无改动”。
368
- const trackedResult = await this.exec(['diff'])
369
- const untrackedFiles = await this.listUntrackedFiles()
404
+ const trackedResult = await this.exec(paths.length ? ['diff', '--', ...paths] : ['diff'])
405
+ const untrackedFiles = await this.listUntrackedFiles(paths)
370
406
  const untrackedDiffs = []
371
407
 
372
408
  for (const filePath of untrackedFiles) {
@@ -381,8 +417,8 @@ export class GitService {
381
417
  .join('\n\n')
382
418
  }
383
419
 
384
- async diffStaged() {
385
- const result = await this.exec(['diff', '--staged'])
420
+ async diffStaged(paths = []) {
421
+ const result = await this.exec(paths.length ? ['diff', '--staged', '--', ...paths] : ['diff', '--staged'])
386
422
  return result.stdout
387
423
  }
388
424
 
@@ -465,9 +501,16 @@ export class GitService {
465
501
  }
466
502
 
467
503
  const args = ['commit', '-m', message]
504
+ if (options.amend) {
505
+ args.push('--amend')
506
+ }
468
507
  if (options.noVerify) {
469
508
  args.push('--no-verify')
470
509
  }
510
+ if (Array.isArray(options.paths) && options.paths.length) {
511
+ // 只提交指定路径,其它已暂存改动保持原样。
512
+ args.push('--', ...options.paths)
513
+ }
471
514
 
472
515
  try {
473
516
  await this.exec(args)
@@ -484,6 +527,26 @@ export class GitService {
484
527
  }
485
528
  }
486
529
 
530
+ // 生成 fixup! 提交,供 rebase --autosquash 自动合并到目标提交。
531
+ async commitFixup(targetSha, options = {}) {
532
+ const args = ['commit', `--fixup=${targetSha}`]
533
+ if (options.noVerify) {
534
+ args.push('--no-verify')
535
+ }
536
+ try {
537
+ await this.exec(args)
538
+ } catch (error) {
539
+ if (detectHookFailure(error)) {
540
+ throw new IcodeError(buildHookHint('git commit --fixup'), {
541
+ code: 'HOOK_COMMIT_BLOCKED',
542
+ cause: error,
543
+ meta: error.meta
544
+ })
545
+ }
546
+ throw error
547
+ }
548
+ }
549
+
487
550
  async push(branchName, options = {}) {
488
551
  const args = ['push']
489
552
 
@@ -491,7 +554,14 @@ export class GitService {
491
554
  args.push('--no-verify')
492
555
  }
493
556
 
494
- args.push('origin', branchName)
557
+ if (options.forceWithLease) {
558
+ // 只允许 --force-with-lease:远程被别人更新过时会拒绝覆盖。
559
+ args.push(options.expectedRemoteSha
560
+ ? `--force-with-lease=${branchName}:${options.expectedRemoteSha}`
561
+ : '--force-with-lease')
562
+ }
563
+
564
+ args.push(this.remote, branchName)
495
565
 
496
566
  if (options.setUpstream) {
497
567
  args.push('--set-upstream')
@@ -519,7 +589,7 @@ export class GitService {
519
589
  args.push('--no-verify')
520
590
  }
521
591
 
522
- args.push('origin', `${sourceBranch}:${targetBranch}`)
592
+ args.push(this.remote, `${sourceBranch}:${targetBranch}`)
523
593
 
524
594
  try {
525
595
  await this.exec(args)
@@ -556,7 +626,7 @@ export class GitService {
556
626
  if (options.noVerify) {
557
627
  args.push('--no-verify')
558
628
  }
559
- args.push('origin', tagName)
629
+ args.push(this.remote, tagName)
560
630
 
561
631
  try {
562
632
  await this.exec(args)
@@ -589,7 +659,7 @@ export class GitService {
589
659
  }
590
660
 
591
661
  async revertContinue() {
592
- await this.exec(['revert', '--continue'])
662
+ await this.exec(['revert', '--continue'], { env: { ...process.env, GIT_EDITOR: 'true' } })
593
663
  }
594
664
 
595
665
  async revertAbort() {
@@ -632,7 +702,7 @@ export class GitService {
632
702
  }
633
703
 
634
704
  async cherryPickContinue() {
635
- await this.exec(['cherry-pick', '--continue'])
705
+ await this.exec(['cherry-pick', '--continue'], { env: { ...process.env, GIT_EDITOR: 'true' } })
636
706
  }
637
707
 
638
708
  async abortCherryPick() {
@@ -645,6 +715,366 @@ export class GitService {
645
715
  }
646
716
 
647
717
  async deleteRemoteBranch(branchName) {
648
- await this.exec(['push', 'origin', '--delete', branchName])
718
+ await this.exec(['push', this.remote, '--delete', branchName])
719
+ }
720
+
721
+ // ---------- 通用读取 ----------
722
+
723
+ async revParse(ref = 'HEAD') {
724
+ const result = await this.exec(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { allowFailure: true })
725
+ return cleanOutput(result.stdout)
726
+ }
727
+
728
+ async refExists(ref) {
729
+ return Boolean(await this.revParse(ref))
730
+ }
731
+
732
+ async isAncestor(ancestorRef, descendantRef) {
733
+ const result = await this.exec(['merge-base', '--is-ancestor', ancestorRef, descendantRef], { allowFailure: true })
734
+ return result.exitCode === 0
735
+ }
736
+
737
+ async mergeBase(refA, refB) {
738
+ const result = await this.exec(['merge-base', refA, refB], { allowFailure: true })
739
+ return cleanOutput(result.stdout)
740
+ }
741
+
742
+ async countCommits(rangeSpec) {
743
+ const result = await this.exec(['rev-list', '--count', rangeSpec], { allowFailure: true })
744
+ const value = Number(cleanOutput(result.stdout))
745
+ return Number.isFinite(value) ? value : 0
746
+ }
747
+
748
+ /**
749
+ * 列出将被提交的文件(不含删除),用于提交前的敏感文件/大文件扫描。
750
+ * mode=all:全部改动(含未跟踪文件,逐个展开目录);mode=staged:仅暂存区;
751
+ * 传 paths 时只看这些路径。输出为相对仓库根目录的文件路径数组。
752
+ */
753
+ async listPendingFiles(options = {}) {
754
+ const mode = options.mode || 'all'
755
+ const paths = Array.isArray(options.paths) ? options.paths.filter(Boolean) : []
756
+
757
+ if (mode === 'staged') {
758
+ const result = await this.exec(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'])
759
+ return result.stdout.split('\0').map((item) => item.trim()).filter(Boolean)
760
+ }
761
+
762
+ const args = ['status', '--porcelain=v1', '-z', '-uall']
763
+ if (paths.length) {
764
+ args.push('--', ...paths)
765
+ }
766
+ const result = await this.exec(args)
767
+ const tokens = result.stdout.split('\0')
768
+ const files = []
769
+ for (let index = 0; index < tokens.length; index += 1) {
770
+ const token = tokens[index]
771
+ if (!token || token.length < 4) {
772
+ continue
773
+ }
774
+ const indexStatus = token[0]
775
+ const worktreeStatus = token[1]
776
+ const filePath = token.slice(3)
777
+ if (indexStatus === 'R' || indexStatus === 'C') {
778
+ // -z 模式下 rename 的源路径紧跟在下一个 token。
779
+ index += 1
780
+ }
781
+ if (indexStatus === 'D' || worktreeStatus === 'D') {
782
+ continue
783
+ }
784
+ files.push(filePath)
785
+ }
786
+ return [...new Set(files)]
787
+ }
788
+
789
+ async stagePaths(paths = []) {
790
+ if (!paths.length) {
791
+ return
792
+ }
793
+ await this.exec(['add', '-A', '--', ...paths])
794
+ }
795
+
796
+ async hasStagedChanges() {
797
+ const result = await this.exec(['diff', '--cached', '--quiet'], { allowFailure: true })
798
+ return result.exitCode === 1
799
+ }
800
+
801
+ // 读取当前分支 upstream 的远程 sha,没有 upstream 时返回空串。
802
+ async getRemoteBranchSha(branchName) {
803
+ return this.revParse(this.remoteRef(branchName))
804
+ }
805
+
806
+ // ---------- 引用 / 备份 ----------
807
+
808
+ async updateRef(refName, sha, message = '') {
809
+ const args = ['update-ref']
810
+ if (message) {
811
+ args.push('-m', message)
812
+ }
813
+ args.push(refName, sha)
814
+ await this.exec(args)
815
+ }
816
+
817
+ async deleteRef(refName) {
818
+ await this.exec(['update-ref', '-d', refName], { allowFailure: true })
819
+ }
820
+
821
+ async listRefs(prefix, format = '%(refname)%09%(objectname)') {
822
+ const result = await this.exec(['for-each-ref', `--format=${format}`, prefix], { allowFailure: true })
823
+ return result.stdout.split('\n').map((item) => item.trimEnd()).filter(Boolean)
824
+ }
825
+
826
+ // 生成不修改工作区的 stash 提交(git stash create),没有改动时返回空串。
827
+ async stashCreate(message = '') {
828
+ const args = ['stash', 'create']
829
+ if (message) {
830
+ args.push(message)
831
+ }
832
+ const result = await this.exec(args, { allowFailure: true })
833
+ return cleanOutput(result.stdout)
834
+ }
835
+
836
+ // ---------- stash ----------
837
+
838
+ async stashList() {
839
+ const result = await this.exec(['stash', 'list', '--format=%gd%x1f%H%x1f%cr%x1f%gs'], { allowFailure: true })
840
+ return result.stdout
841
+ .split('\n')
842
+ .map((line) => line.trim())
843
+ .filter(Boolean)
844
+ .map((line) => {
845
+ const [ref = '', hash = '', relativeDate = '', subject = ''] = line.split('\x1f')
846
+ return { ref, hash, relativeDate, subject }
847
+ })
848
+ }
849
+
850
+ async stashPush(options = {}) {
851
+ const args = ['stash', 'push']
852
+ if (options.includeUntracked !== false) {
853
+ args.push('--include-untracked')
854
+ }
855
+ if (options.staged) {
856
+ args.push('--staged')
857
+ }
858
+ if (options.message) {
859
+ args.push('-m', options.message)
860
+ }
861
+ if (Array.isArray(options.paths) && options.paths.length) {
862
+ args.push('--', ...options.paths)
863
+ }
864
+ await this.exec(args)
865
+ }
866
+
867
+ async stashApply(ref = 'stash@{0}', options = {}) {
868
+ const args = ['stash', options.pop ? 'pop' : 'apply']
869
+ if (options.index) {
870
+ args.push('--index')
871
+ }
872
+ args.push(ref)
873
+ await this.exec(args)
874
+ }
875
+
876
+ async stashDrop(ref = 'stash@{0}') {
877
+ await this.exec(['stash', 'drop', ref])
878
+ }
879
+
880
+ async stashShow(ref = 'stash@{0}') {
881
+ const result = await this.exec(['stash', 'show', '--stat', '--include-untracked', ref], { allowFailure: true })
882
+ if (result.exitCode === 0) {
883
+ return result.stdout
884
+ }
885
+ const fallback = await this.exec(['stash', 'show', '--stat', ref], { allowFailure: true })
886
+ return fallback.stdout
887
+ }
888
+
889
+ // ---------- 分支 ----------
890
+
891
+ /**
892
+ * 列出本地分支详情:最近提交时间、upstream、ahead/behind、是否 gone。
893
+ * 输出按最近提交时间倒序排列。
894
+ */
895
+ async listBranchDetails() {
896
+ const format = [
897
+ '%(refname:short)',
898
+ '%(committerdate:unix)',
899
+ '%(committerdate:relative)',
900
+ '%(upstream:short)',
901
+ '%(upstream:track)',
902
+ '%(objectname:short)',
903
+ '%(contents:subject)'
904
+ ].join('%1f')
905
+ const result = await this.exec(['for-each-ref', '--sort=-committerdate', `--format=${format}`, 'refs/heads'], {
906
+ allowFailure: true
907
+ })
908
+ return result.stdout
909
+ .split('\n')
910
+ .map((line) => line.trim())
911
+ .filter(Boolean)
912
+ .map((line) => {
913
+ const [name = '', unix = '0', relativeDate = '', upstream = '', track = '', sha = '', subject = ''] = line.split('\x1f')
914
+ const aheadMatch = track.match(/ahead (\d+)/)
915
+ const behindMatch = track.match(/behind (\d+)/)
916
+ return {
917
+ name,
918
+ timestamp: Number(unix) || 0,
919
+ relativeDate,
920
+ upstream,
921
+ gone: track.includes('gone'),
922
+ ahead: aheadMatch ? Number(aheadMatch[1]) : 0,
923
+ behind: behindMatch ? Number(behindMatch[1]) : 0,
924
+ sha,
925
+ subject
926
+ }
927
+ })
928
+ }
929
+
930
+ async switchBranch(branchName, options = {}) {
931
+ const args = ['switch']
932
+ if (options.create) {
933
+ args.push('-c', branchName)
934
+ if (options.startPoint) {
935
+ args.push(options.startPoint)
936
+ }
937
+ if (options.track) {
938
+ args.push('--track')
939
+ }
940
+ } else {
941
+ args.push(branchName)
942
+ }
943
+ await this.exec(args)
649
944
  }
945
+
946
+ async createBranch(branchName, startPoint) {
947
+ await this.exec(['branch', branchName, startPoint])
948
+ }
949
+
950
+ // ---------- 历史 ----------
951
+
952
+ /**
953
+ * 结构化读取提交历史。
954
+ * 输入为 range/limit/author/file 等过滤条件,输出 hash/作者/时间/标题/引用列表。
955
+ */
956
+ async logEntries(options = {}) {
957
+ const args = ['log', `--format=%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%ar%x1f%D%x1f%s%x1e`]
958
+ if (options.limit) {
959
+ args.push(`-${options.limit}`)
960
+ }
961
+ if (options.author) {
962
+ args.push(`--author=${options.author}`)
963
+ }
964
+ if (options.since) {
965
+ args.push(`--since=${options.since}`)
966
+ }
967
+ if (options.grep) {
968
+ args.push(`--grep=${options.grep}`, '-i')
969
+ }
970
+ if (options.noMerges) {
971
+ args.push('--no-merges')
972
+ }
973
+ if (options.firstParent) {
974
+ args.push('--first-parent')
975
+ }
976
+ if (options.range) {
977
+ args.push(options.range)
978
+ }
979
+ if (options.file) {
980
+ args.push('--follow', '--', options.file)
981
+ }
982
+ const result = await this.exec(args, { allowFailure: true })
983
+ return result.stdout
984
+ .split('\x1e')
985
+ .map((record) => record.trim())
986
+ .filter(Boolean)
987
+ .map((record) => {
988
+ const [hash = '', shortHash = '', author = '', email = '', date = '', relativeDate = '', refs = '', subject = ''] = record.split('\x1f')
989
+ return {
990
+ hash,
991
+ shortHash,
992
+ author,
993
+ email,
994
+ date,
995
+ relativeDate,
996
+ refs: refs ? refs.split(',').map((item) => item.trim()).filter(Boolean) : [],
997
+ subject
998
+ }
999
+ })
1000
+ }
1001
+
1002
+ async getConfigValue(key) {
1003
+ const result = await this.exec(['config', '--get', key], { allowFailure: true })
1004
+ return cleanOutput(result.stdout)
1005
+ }
1006
+
1007
+ async reflogEntries(ref = 'HEAD', limit = 30) {
1008
+ // --date=unix 让 %gd 输出操作时间戳(HEAD@{1730000000}),选择器按顺序还原成 HEAD@{n}。
1009
+ const result = await this.exec(['reflog', 'show', `-${limit}`, '--date=unix', '--format=%H%x1f%h%x1f%gd%x1f%gs', ref], {
1010
+ allowFailure: true
1011
+ })
1012
+ const now = Math.floor(Date.now() / 1000)
1013
+ return result.stdout
1014
+ .split('\n')
1015
+ .map((line) => line.trim())
1016
+ .filter(Boolean)
1017
+ .map((line, index) => {
1018
+ const [hash = '', shortHash = '', rawSelector = '', subject = ''] = line.split('\x1f')
1019
+ const timestamp = Number((rawSelector.match(/\{(\d+)\}/) || [])[1]) || 0
1020
+ return {
1021
+ hash,
1022
+ shortHash,
1023
+ selector: `${ref}@{${index}}`,
1024
+ timestamp,
1025
+ relativeDate: timestamp ? formatRelativeSeconds(now - timestamp) : '',
1026
+ subject
1027
+ }
1028
+ })
1029
+ }
1030
+
1031
+ // ---------- rebase ----------
1032
+
1033
+ /**
1034
+ * 执行非交互 rebase。
1035
+ * autosquash=true 时通过 GIT_SEQUENCE_EDITOR=: 自动接受 fixup 排序,不会打开编辑器。
1036
+ */
1037
+ async rebaseOnto(baseRef, options = {}) {
1038
+ const args = ['rebase']
1039
+ if (options.autosquash) {
1040
+ args.push('-i', '--autosquash')
1041
+ }
1042
+ if (options.autostash) {
1043
+ args.push('--autostash')
1044
+ }
1045
+ if (options.root) {
1046
+ args.push('--root')
1047
+ } else {
1048
+ args.push(baseRef)
1049
+ }
1050
+ const env = options.autosquash ? { ...process.env, GIT_SEQUENCE_EDITOR: ':' } : process.env
1051
+ await this.exec(args, { env })
1052
+ }
1053
+
1054
+ async rebaseContinue() {
1055
+ // 继续 rebase 时不弹编辑器,沿用原提交信息。
1056
+ await this.exec(['rebase', '--continue'], { env: { ...process.env, GIT_EDITOR: 'true' } })
1057
+ }
1058
+
1059
+ // ---------- tag ----------
1060
+
1061
+ async listTagsSorted(pattern = '*') {
1062
+ const result = await this.exec(['tag', '--list', pattern, '--sort=-v:refname'], { allowFailure: true })
1063
+ return result.stdout.split('\n').map((item) => item.trim()).filter(Boolean)
1064
+ }
1065
+
1066
+ async describeLatestTag(ref = 'HEAD') {
1067
+ const result = await this.exec(['describe', '--tags', '--abbrev=0', ref], { allowFailure: true })
1068
+ return result.exitCode === 0 ? cleanOutput(result.stdout) : ''
1069
+ }
1070
+
1071
+ async tagExists(tagName) {
1072
+ const result = await this.exec(['show-ref', '--verify', '--quiet', `refs/tags/${tagName}`], { allowFailure: true })
1073
+ return result.exitCode === 0
1074
+ }
1075
+
1076
+ async deleteTag(tagName) {
1077
+ await this.exec(['tag', '-d', tagName], { allowFailure: true })
1078
+ }
1079
+
650
1080
  }
@@ -0,0 +1,53 @@
1
+ import { IcodeError } from '../errors.js'
2
+ import { normalizeRemoteMergeReason, parseRemoteMergePayload } from '../remote-merge/shared.js'
3
+
4
+ /**
5
+ * 发起一次 JSON 请求并归一化结果。
6
+ * 输入为 URL、fetch 选项和失败时的文案前缀,输出 { ok, status, payload, rawText, reason }。
7
+ * 网络错误直接抛 IcodeError;HTTP 错误不抛错,交给调用方按状态码判断。
8
+ */
9
+ export async function requestJson(url, options = {}, errorLabel = '平台 API 请求失败') {
10
+ let response
11
+ try {
12
+ response = await fetch(url, options)
13
+ } catch (error) {
14
+ throw new IcodeError(`${errorLabel}: ${error?.message || '未知网络错误'}`, {
15
+ code: 'PLATFORM_FETCH_ERROR',
16
+ exitCode: 2,
17
+ cause: error
18
+ })
19
+ }
20
+
21
+ const rawText = await response.text()
22
+ const payload = parseRemoteMergePayload(rawText)
23
+
24
+ return {
25
+ ok: response.ok,
26
+ status: response.status,
27
+ payload,
28
+ rawText,
29
+ reason: response.ok
30
+ ? ''
31
+ : normalizeRemoteMergeReason(payload, `HTTP ${response.status}${rawText ? `: ${rawText.trim().slice(0, 500)}` : ''}`)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * 请求失败时抛出统一错误。
37
+ * 输入为 requestJson 结果和文案前缀,输出为空。
38
+ */
39
+ export function assertResponseOk(result, label, code = 'PLATFORM_API_ERROR') {
40
+ if (result.ok) {
41
+ return
42
+ }
43
+
44
+ const hint = result.status === 401 || result.status === 403
45
+ ? '\n请检查平台 token 是否有效、是否有仓库权限:icode config platform token set <provider> <token>'
46
+ : ''
47
+
48
+ throw new IcodeError(`${label}(HTTP ${result.status}): ${result.reason}${hint}`, {
49
+ code,
50
+ exitCode: 2,
51
+ meta: { status: result.status, reason: result.reason }
52
+ })
53
+ }