@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.
- package/README.md +287 -24
- package/bin/icode.js +10 -0
- package/package.json +3 -3
- package/src/cli.js +175 -8
- package/src/commands/checkout.js +73 -6
- package/src/commands/clean.js +47 -7
- package/src/commands/config-ux.js +292 -0
- package/src/commands/config.js +199 -48
- package/src/commands/continue.js +104 -0
- package/src/commands/doctor.js +15 -29
- package/src/commands/help.js +54 -34
- package/src/commands/history.js +147 -0
- package/src/commands/info.js +39 -4
- package/src/commands/log.js +142 -0
- package/src/commands/migrate.js +19 -12
- package/src/commands/open.js +138 -0
- package/src/commands/pr.js +219 -0
- package/src/commands/push.js +62 -22
- package/src/commands/ready.js +18 -48
- package/src/commands/release.js +112 -0
- package/src/commands/rescue.js +164 -0
- package/src/commands/save.js +52 -11
- package/src/commands/stash.js +210 -0
- package/src/commands/sw.js +89 -0
- package/src/commands/sync.js +19 -2
- package/src/commands/tag.js +40 -1
- package/src/commands/undo.js +9 -3
- package/src/core/ai/diff-range.js +1 -1
- package/src/core/cli/completion.js +237 -1
- package/src/core/cli/output.js +46 -0
- package/src/core/cli/render.js +121 -0
- package/src/core/commands/shell.js +9 -1
- package/src/core/config/repo-config.js +93 -0
- package/src/core/config/store.js +60 -10
- package/src/core/git/autostash.js +46 -0
- package/src/core/git/backup.js +170 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/context.js +72 -5
- package/src/core/git/file-guard.js +193 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/git/semver.js +194 -0
- package/src/core/git/service.js +454 -24
- package/src/core/platform/http.js +53 -0
- package/src/core/platform/index.js +165 -0
- package/src/core/platform/providers.js +454 -0
- package/src/core/platform/remote-url.js +92 -0
- package/src/core/remote-merge/client.js +33 -3
- package/src/core/tools/browser.js +28 -0
- package/src/core/tools/commit-prompt.js +137 -0
- package/src/core/tools/interactive.js +272 -40
- package/src/core/tools/logger.js +33 -23
- package/src/core/tools/theme.js +228 -0
- package/src/workflows/ai-commit-workflow.js +52 -20
- package/src/workflows/checkout-workflow.js +24 -10
- package/src/workflows/clean-workflow.js +135 -83
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/doctor-workflow.js +10 -7
- package/src/workflows/history-workflow.js +211 -0
- package/src/workflows/migrate-workflow.js +39 -14
- package/src/workflows/pr-workflow.js +252 -0
- package/src/workflows/push/commit-step.js +123 -0
- package/src/workflows/push/local-merge-step.js +213 -0
- package/src/workflows/push/plan.js +63 -0
- package/src/workflows/push/remote-merge-step.js +224 -0
- package/src/workflows/push-workflow.js +185 -492
- package/src/workflows/ready-workflow.js +14 -10
- package/src/workflows/release-workflow.js +189 -0
- package/src/workflows/rescue-workflow.js +137 -0
- package/src/workflows/save-workflow.js +106 -41
- package/src/workflows/switch-workflow.js +171 -0
- package/src/workflows/sync-workflow.js +143 -44
- package/src/workflows/tag-workflow.js +103 -17
- package/src/workflows/undo-workflow.js +113 -34
- package/src/workflows/rollback-workflow.js +0 -99
package/src/core/git/service.js
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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',
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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',
|
|
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,
|
|
254
|
+
await this.exec(['branch', '--track', branchName, this.remoteRef(branchName)])
|
|
223
255
|
}
|
|
224
256
|
|
|
225
257
|
async pull(branchName, options = {}) {
|
|
226
|
-
const args = ['pull',
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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',
|
|
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
|
+
}
|