@icode-js/icode 3.3.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 +27 -3
- package/package.json +3 -3
- package/src/cli.js +21 -1
- package/src/commands/continue.js +104 -0
- package/src/commands/help.js +4 -1
- package/src/commands/history.js +1 -1
- package/src/commands/undo.js +1 -1
- package/src/core/cli/completion.js +2 -0
- package/src/core/git/autostash.js +5 -0
- package/src/core/git/conflicts.js +58 -0
- package/src/core/git/operation-state.js +63 -0
- package/src/core/git/operations.js +32 -0
- package/src/core/tools/logger.js +3 -1
- package/src/workflows/continue-workflow.js +233 -0
- package/src/workflows/history-workflow.js +2 -2
- package/src/workflows/migrate-workflow.js +15 -2
- package/src/workflows/push/local-merge-step.js +1 -1
- package/src/workflows/push-workflow.js +45 -10
- package/src/workflows/ready-workflow.js +9 -6
- package/src/workflows/sync-workflow.js +85 -34
- package/src/workflows/undo-workflow.js +11 -43
package/README.md
CHANGED
|
@@ -334,7 +334,7 @@ icode push release test -r -y # 使用远程 MR 模式合并到多个目标分
|
|
|
334
334
|
|
|
335
335
|
- 自动 `add + commit + push`
|
|
336
336
|
- 存在冲突或未完成 Git 操作时会直接停止
|
|
337
|
-
- 本地 merge
|
|
337
|
+
- 本地 merge 冲突会暂停在目标分支现场;解决冲突后执行 `icode continue`,会自动暂存、推送该分支、继续处理剩下的目标分支并切回原分支;执行 `icode abort` 则放弃并切回原分支
|
|
338
338
|
- 支持 `--ai-commit` 在 push 前自动生成并应用 AI 提交信息
|
|
339
339
|
- 若仓库存在 husky/hooks/commitlint 规则,AI commit 会先扫描本地规范再生成提交信息
|
|
340
340
|
- 支持把当前分支合并到多个目标分支
|
|
@@ -567,7 +567,31 @@ icode fixup a1b2c3d -a
|
|
|
567
567
|
icode fixup a1b2c3d --no-rebase
|
|
568
568
|
```
|
|
569
569
|
|
|
570
|
-
等同 `git commit --fixup=<commit>` + `git rebase -i --autosquash`,不会打开编辑器。冲突时用 `icode
|
|
570
|
+
等同 `git commit --fixup=<commit>` + `git rebase -i --autosquash`,不会打开编辑器。冲突时用 `icode continue` / `icode abort` 处理。
|
|
571
|
+
|
|
572
|
+
### continue / abort
|
|
573
|
+
|
|
574
|
+
冲突解决后继续:
|
|
575
|
+
|
|
576
|
+
```bash
|
|
577
|
+
icode continue
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
1. 检查冲突文件里是否还有 `<<<<<<<` / `>>>>>>>`,有就列出文件和行号,不会继续
|
|
581
|
+
2. 自动 `git add` 已解决的文件
|
|
582
|
+
3. 继续 merge / rebase / cherry-pick / revert(不会打开编辑器)
|
|
583
|
+
4. 如果是 `push` / `migrate` / `sync` 中途冲突,接着把剩下的步骤做完:推送当前分支、处理剩余目标分支、切回原分支
|
|
584
|
+
|
|
585
|
+
放弃:
|
|
586
|
+
|
|
587
|
+
```bash
|
|
588
|
+
icode abort
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
中止进行中的 Git 操作,切回原分支。已经推送成功的分支不会被撤回。
|
|
592
|
+
|
|
593
|
+
有被中断的操作时,`icode ready` 会标出来,直接运行 `icode` 的菜单也会把“继续 / 放弃”放在最前面。
|
|
594
|
+
`icode undo --recover continue|abort` 仍然可用,效果相同。
|
|
571
595
|
|
|
572
596
|
### rescue
|
|
573
597
|
|
|
@@ -619,7 +643,7 @@ icode migrate <sourceBranch> <targetBranch> --dry-run --json
|
|
|
619
643
|
- 执行前要求工作区干净,且不能存在未完成的 merge/rebase/cherry-pick/revert
|
|
620
644
|
- 支持 `--dry-run` 输出 checkout/pull/cherry-pick/push 计划,不产生 Git 副作用
|
|
621
645
|
- 支持 `--json` 输出结构化计划或执行结果,适合 AI agent 消费
|
|
622
|
-
-
|
|
646
|
+
- 如果迁移冲突,解决后执行 `icode continue`(会接着迁移剩下的提交,带 `--push` 时自动推送并切回原分支),或执行 `icode abort` 放弃
|
|
623
647
|
|
|
624
648
|
参数说明:
|
|
625
649
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icode-js/icode",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Modern git workflow CLI with robust parent-repo and hook handling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "C",
|
|
7
7
|
"bin": {
|
|
8
|
-
"icode": "
|
|
8
|
+
"icode": "bin/icode.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node --test",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "https://github.com/wandou-cc/icode-js.git"
|
|
22
|
+
"url": "git+https://github.com/wandou-cc/icode-js.git"
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/wandou-cc/icode-js",
|
|
25
25
|
"license": "MIT",
|
package/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { runCodeReviewCommand } from './commands/codereview.js'
|
|
|
4
4
|
import { runCompletionCommand, runHiddenCompletionCommand } from './commands/completion.js'
|
|
5
5
|
import { runCheckoutCommand } from './commands/checkout.js'
|
|
6
6
|
import { runConfigCommand } from './commands/config.js'
|
|
7
|
+
import { runAbortCommand, runContinueCommand } from './commands/continue.js'
|
|
7
8
|
import { runDoctorCommand } from './commands/doctor.js'
|
|
8
9
|
import { runExplainCommand } from './commands/explain.js'
|
|
9
10
|
import { printMainHelp } from './commands/help.js'
|
|
@@ -27,6 +28,8 @@ import { asIcodeError, IcodeError } from './core/errors.js'
|
|
|
27
28
|
import { normalizeLegacyArgs } from './core/cli/args.js'
|
|
28
29
|
import { notifyIfCliUpdateAvailable } from './core/cli/update-notifier.js'
|
|
29
30
|
import { chooseOne, isInteractiveTerminal } from './core/tools/interactive.js'
|
|
31
|
+
import { c } from './core/tools/theme.js'
|
|
32
|
+
import { inspectPausedWork } from './workflows/continue-workflow.js'
|
|
30
33
|
import { logger } from './core/tools/logger.js'
|
|
31
34
|
|
|
32
35
|
function isTruthy(value) {
|
|
@@ -80,6 +83,8 @@ const COMMANDS = {
|
|
|
80
83
|
sync: runSyncCommand,
|
|
81
84
|
clean: runCleanCommand,
|
|
82
85
|
undo: runUndoCommand,
|
|
86
|
+
continue: runContinueCommand,
|
|
87
|
+
abort: runAbortCommand,
|
|
83
88
|
rescue: runRescueCommand,
|
|
84
89
|
stash: runStashCommand,
|
|
85
90
|
sw: runSwCommand,
|
|
@@ -117,6 +122,7 @@ const MAIN_MENU = [
|
|
|
117
122
|
{ value: 'amend', label: 'amend', hint: '修改上一个提交' },
|
|
118
123
|
{ value: 'fixup', label: 'fixup', hint: '把改动合进更早的提交' },
|
|
119
124
|
{ value: 'undo', label: 'undo', hint: '撤销 / 回滚' },
|
|
125
|
+
{ value: 'continue', label: 'continue', hint: '冲突解决后继续' },
|
|
120
126
|
{ value: 'rescue', label: 'rescue', hint: '找回丢失的提交和改动' },
|
|
121
127
|
{ value: 'clean', label: 'clean', hint: '清理已合并的分支' },
|
|
122
128
|
{ value: 'pr', label: 'pr create', hint: '创建 PR / MR' },
|
|
@@ -129,7 +135,21 @@ const MAIN_MENU = [
|
|
|
129
135
|
]
|
|
130
136
|
|
|
131
137
|
async function pickCommandFromMenu() {
|
|
132
|
-
|
|
138
|
+
// 有被冲突打断的操作时,把“继续 / 放弃”放在最前面。
|
|
139
|
+
let paused = null
|
|
140
|
+
try {
|
|
141
|
+
paused = await inspectPausedWork({})
|
|
142
|
+
} catch {
|
|
143
|
+
paused = null
|
|
144
|
+
}
|
|
145
|
+
const menu = paused
|
|
146
|
+
? [
|
|
147
|
+
{ value: 'continue', label: c.yellow('continue'), hint: `冲突解决后继续:${paused.description}` },
|
|
148
|
+
{ value: 'abort', label: 'abort', hint: '放弃被中断的操作并切回原分支' },
|
|
149
|
+
...MAIN_MENU
|
|
150
|
+
]
|
|
151
|
+
: MAIN_MENU
|
|
152
|
+
const picked = await chooseOne(paused ? `有被中断的操作(${paused.description}),要做什么?` : '要做什么?', menu, 0)
|
|
133
153
|
// pr 从菜单进入时直接走创建流程。
|
|
134
154
|
if (picked === 'pr') {
|
|
135
155
|
await COMMANDS.pr(['create'])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parseCommandArgs, printJson } from '../core/cli/output.js'
|
|
2
|
+
import { c, print, renderBranchResults } from '../core/cli/render.js'
|
|
3
|
+
import { confirm, isInteractiveTerminal } from '../core/tools/interactive.js'
|
|
4
|
+
import { logger } from '../core/tools/logger.js'
|
|
5
|
+
import { inspectPausedWork, runAbortWorkflow, runContinueWorkflow } from '../workflows/continue-workflow.js'
|
|
6
|
+
|
|
7
|
+
const RESULT_LABELS = {
|
|
8
|
+
pushed: '已推送',
|
|
9
|
+
'merged-and-pushed': '已合并并推送',
|
|
10
|
+
'force-pushed': '已强推',
|
|
11
|
+
'skipped-protected': '已跳过(受保护分支)',
|
|
12
|
+
synced: '已同步',
|
|
13
|
+
'synced-and-pushed': '已同步并推送',
|
|
14
|
+
missing: '本地和远程都不存在'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function printContinueHelp() {
|
|
18
|
+
process.stdout.write(`
|
|
19
|
+
Usage:
|
|
20
|
+
icode continue [options]
|
|
21
|
+
|
|
22
|
+
解决冲突后继续刚才被打断的操作:
|
|
23
|
+
1. 检查冲突文件里是否还有 <<<<<<< / >>>>>>> 标记,有就列出文件和行号
|
|
24
|
+
2. 自动 git add 已解决的文件
|
|
25
|
+
3. 继续 merge / rebase / cherry-pick / revert
|
|
26
|
+
4. 如果是 push / migrate / sync 中途冲突,接着把剩下的步骤做完(推送、处理剩余分支、切回原分支)
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--json 输出机器可读 JSON
|
|
30
|
+
--repo-mode <mode> 仓库模式: auto | strict
|
|
31
|
+
-h, --help 查看帮助
|
|
32
|
+
`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function printAbortHelp() {
|
|
36
|
+
process.stdout.write(`
|
|
37
|
+
Usage:
|
|
38
|
+
icode abort [options]
|
|
39
|
+
|
|
40
|
+
放弃刚才被冲突打断的操作:中止 merge / rebase / cherry-pick / revert,并切回原分支。
|
|
41
|
+
已经推送成功的分支不会被撤回。
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
-y, --yes 不再确认
|
|
45
|
+
--json 输出机器可读 JSON
|
|
46
|
+
--repo-mode <mode> 仓库模式: auto | strict
|
|
47
|
+
-h, --help 查看帮助
|
|
48
|
+
`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runContinueCommand(rawArgs) {
|
|
52
|
+
const { values } = parseCommandArgs(rawArgs)
|
|
53
|
+
if (values.help) {
|
|
54
|
+
printContinueHelp()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = await runContinueWorkflow({ repoMode: values['repo-mode'], silentLog: values.json })
|
|
59
|
+
if (values.json) {
|
|
60
|
+
printJson(result)
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (result.summary.length) {
|
|
65
|
+
print('')
|
|
66
|
+
renderBranchResults(result.summary, RESULT_LABELS)
|
|
67
|
+
print('')
|
|
68
|
+
}
|
|
69
|
+
if (result.resumed) {
|
|
70
|
+
logger.success(`${result.description} 已全部完成${result.returnedTo ? c.gray(`,已切回 ${result.returnedTo}`) : ''}`)
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runAbortCommand(rawArgs) {
|
|
76
|
+
const { values } = parseCommandArgs(rawArgs)
|
|
77
|
+
if (values.help) {
|
|
78
|
+
printAbortHelp()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!values.yes && !values.json && isInteractiveTerminal()) {
|
|
83
|
+
const paused = await inspectPausedWork({ repoMode: values['repo-mode'] })
|
|
84
|
+
if (paused) {
|
|
85
|
+
const accepted = await confirm(`放弃 ${paused.description}?冲突处理到一半的改动会丢弃`, false)
|
|
86
|
+
if (!accepted) {
|
|
87
|
+
logger.hint('已保留现场。处理完冲突后执行 icode continue。')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = await runAbortWorkflow({ repoMode: values['repo-mode'] })
|
|
94
|
+
if (values.json) {
|
|
95
|
+
printJson(result)
|
|
96
|
+
return result
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
logger.success(`已放弃 ${result.description || result.operation}${result.returnedTo ? c.gray(`,已切回 ${result.returnedTo}`) : ''}`)
|
|
100
|
+
if (result.done.length) {
|
|
101
|
+
logger.hint(`这些分支在中断前已推送,不会撤回: ${result.done.join(', ')}`)
|
|
102
|
+
}
|
|
103
|
+
return result
|
|
104
|
+
}
|
package/src/commands/help.js
CHANGED
|
@@ -26,7 +26,9 @@ Usage:
|
|
|
26
26
|
clean 清理已合并 / 远程已删除 / 长期未动的分支
|
|
27
27
|
|
|
28
28
|
兜底:
|
|
29
|
-
|
|
29
|
+
continue 冲突解决后继续(自动暂存,并接着完成被打断的 push/migrate/sync)
|
|
30
|
+
abort 放弃被冲突打断的操作并切回原分支
|
|
31
|
+
undo 向导式撤销/回滚(revert/reset)
|
|
30
32
|
rescue 找回恢复点和 reflog 里的提交
|
|
31
33
|
|
|
32
34
|
协作与发布:
|
|
@@ -71,6 +73,7 @@ Examples:
|
|
|
71
73
|
icode log --unpushed
|
|
72
74
|
icode stash save -m "wip"
|
|
73
75
|
icode clean --gone -y
|
|
76
|
+
icode continue
|
|
74
77
|
icode undo --mode hard --ref HEAD~1
|
|
75
78
|
icode rescue
|
|
76
79
|
icode pr create --ai
|
package/src/commands/history.js
CHANGED
package/src/commands/undo.js
CHANGED
|
@@ -18,7 +18,7 @@ Options:
|
|
|
18
18
|
--mode <mode> 回滚模式: revert | soft | mixed | hard
|
|
19
19
|
--ref <ref> 回滚目标,默认按 mode 自动给出
|
|
20
20
|
--hash <hash> 按 commit hash 指定回滚目标(等同 --ref)
|
|
21
|
-
--recover <action> 冲突恢复策略: continue | abort | keep
|
|
21
|
+
--recover <action> 冲突恢复策略: continue | abort | keep(推荐直接用 icode continue / icode abort)
|
|
22
22
|
--json 输出机器可读 JSON,适合 AI agent 或脚本消费
|
|
23
23
|
-y, --yes 自动确认(跳过确认提示)
|
|
24
24
|
--repo-mode <mode> 仓库模式: auto(自动继承父仓库) | strict(禁止继承)
|
|
@@ -25,6 +25,11 @@ export async function withAutoStash(git, enabled, label, fn) {
|
|
|
25
25
|
return await fn({ stashed: true, stashSha })
|
|
26
26
|
} finally {
|
|
27
27
|
const entry = (await git.stashList()).find((item) => item.hash === stashSha)
|
|
28
|
+
// 操作因冲突暂停时不要把改动倒回冲突现场,留在 stash 里等用户处理完再取回。
|
|
29
|
+
if (entry && (await git.getInProgressOperation())) {
|
|
30
|
+
logger.warn(`未提交的改动保存在 ${entry.ref},处理完冲突(icode continue)后执行 \`icode stash pop\` 取回。`)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
28
33
|
if (entry) {
|
|
29
34
|
try {
|
|
30
35
|
await git.stashApply(entry.ref, { pop: true })
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// 只认 <<<<<<< 和 >>>>>>>:单独的 ======= 在 Markdown 等文件里可能是正常内容。
|
|
5
|
+
const MARKER_PATTERN = /^(<{7}|>{7})(?: |$)/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 找出文件中残留的冲突标记所在行号(从 1 开始)。
|
|
9
|
+
* 输入为文件绝对路径,输出行号数组;文件不存在(被删除)时返回空数组。
|
|
10
|
+
*/
|
|
11
|
+
export function findConflictMarkers(filePath) {
|
|
12
|
+
let content
|
|
13
|
+
try {
|
|
14
|
+
content = fs.readFileSync(filePath, 'utf8')
|
|
15
|
+
} catch {
|
|
16
|
+
return []
|
|
17
|
+
}
|
|
18
|
+
const lines = []
|
|
19
|
+
content.split('\n').forEach((line, index) => {
|
|
20
|
+
if (MARKER_PATTERN.test(line)) {
|
|
21
|
+
lines.push(index + 1)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
return lines
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 检查当前的冲突文件:已经没有冲突标记的视为已解决,还有标记的列出行号。
|
|
29
|
+
* 输入为 GitService,输出 { resolved: [path], unresolved: [{ file, lines }] }。
|
|
30
|
+
*/
|
|
31
|
+
export async function inspectConflicts(git) {
|
|
32
|
+
const conflicted = await git.listConflictedFiles()
|
|
33
|
+
const resolved = []
|
|
34
|
+
const unresolved = []
|
|
35
|
+
for (const file of conflicted) {
|
|
36
|
+
const lines = findConflictMarkers(path.resolve(git.cwd, file))
|
|
37
|
+
if (lines.length) {
|
|
38
|
+
unresolved.push({ file, lines })
|
|
39
|
+
} else {
|
|
40
|
+
resolved.push(file)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { resolved, unresolved }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把已解决(没有冲突标记)的文件自动 git add,省去手动暂存。
|
|
48
|
+
* 被删除的冲突文件用 git rm 标记。输出已暂存的文件列表。
|
|
49
|
+
*/
|
|
50
|
+
export async function stageResolvedConflicts(git, files) {
|
|
51
|
+
const staged = []
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const exists = fs.existsSync(path.resolve(git.cwd, file))
|
|
54
|
+
await git.exec(exists ? ['add', '--', file] : ['rm', '--quiet', '--', file])
|
|
55
|
+
staged.push(file)
|
|
56
|
+
}
|
|
57
|
+
return staged
|
|
58
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// 多步骤命令(push 合并多个分支、migrate、sync)被冲突打断时,把剩下的步骤记在这里,
|
|
5
|
+
// 解决冲突后 icode continue 读取它接着做完,icode abort 读取它切回原分支。
|
|
6
|
+
// 放在 gitDir(每个 worktree 独立)下,不会被提交。
|
|
7
|
+
|
|
8
|
+
const STATE_DIR = 'icode'
|
|
9
|
+
const STATE_FILE = 'operation.json'
|
|
10
|
+
|
|
11
|
+
function statePath(context) {
|
|
12
|
+
return path.resolve(context.gitDir, STATE_DIR, STATE_FILE)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 保存中断的操作。state 至少包含 command 与 originalBranch。
|
|
17
|
+
*/
|
|
18
|
+
export function saveOperationState(context, state) {
|
|
19
|
+
const filePath = statePath(context)
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
21
|
+
fs.writeFileSync(filePath, `${JSON.stringify({ ...state, savedAt: new Date().toISOString() }, null, 2)}\n`, 'utf8')
|
|
22
|
+
return filePath
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读取中断的操作,没有或损坏时返回 null。
|
|
27
|
+
*/
|
|
28
|
+
export function loadOperationState(context) {
|
|
29
|
+
const filePath = statePath(context)
|
|
30
|
+
if (!fs.existsSync(filePath)) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const state = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
35
|
+
return state && typeof state === 'object' && state.command ? state : null
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function clearOperationState(context) {
|
|
42
|
+
fs.rmSync(statePath(context), { force: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 用一句话描述中断的操作,供 ready / 菜单 / continue 展示。
|
|
47
|
+
*/
|
|
48
|
+
export function describeOperationState(state) {
|
|
49
|
+
if (!state) {
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
if (state.command === 'push') {
|
|
53
|
+
const pending = [state.pendingPush, ...(state.remainingTargets || [])].filter(Boolean)
|
|
54
|
+
return `push ${state.sourceBranch}${pending.length ? ` → ${pending.join(', ')}` : ''}`
|
|
55
|
+
}
|
|
56
|
+
if (state.command === 'migrate') {
|
|
57
|
+
return `migrate ${state.sourceBranch} → ${state.targetBranch}`
|
|
58
|
+
}
|
|
59
|
+
if (state.command === 'sync') {
|
|
60
|
+
return `sync ${[state.currentBranch, ...(state.remainingTargets || [])].filter(Boolean).join(', ')}`
|
|
61
|
+
}
|
|
62
|
+
return state.command
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 按 Git 操作类型执行 continue / abort,undo 与 continue/abort 命令共用。
|
|
2
|
+
|
|
3
|
+
export const OPERATION_LABELS = {
|
|
4
|
+
merge: '合并 (merge)',
|
|
5
|
+
rebase: '变基 (rebase)',
|
|
6
|
+
'cherry-pick': '摘取提交 (cherry-pick)',
|
|
7
|
+
revert: '回滚 (revert)'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function continueOperation(git, operation) {
|
|
11
|
+
if (operation === 'revert') {
|
|
12
|
+
await git.revertContinue()
|
|
13
|
+
} else if (operation === 'merge') {
|
|
14
|
+
await git.mergeContinue()
|
|
15
|
+
} else if (operation === 'rebase') {
|
|
16
|
+
await git.rebaseContinue()
|
|
17
|
+
} else {
|
|
18
|
+
await git.cherryPickContinue()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function abortOperation(git, operation) {
|
|
23
|
+
if (operation === 'revert') {
|
|
24
|
+
await git.revertAbort()
|
|
25
|
+
} else if (operation === 'merge') {
|
|
26
|
+
await git.mergeAbort()
|
|
27
|
+
} else if (operation === 'rebase') {
|
|
28
|
+
await git.rebaseAbort()
|
|
29
|
+
} else {
|
|
30
|
+
await git.abortCherryPick()
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/core/tools/logger.js
CHANGED
|
@@ -2,7 +2,9 @@ import { c, setColorStream } from './theme.js'
|
|
|
2
2
|
|
|
3
3
|
let verboseEnabled = process.env.ICODE_LOG_LEVEL === 'verbose'
|
|
4
4
|
// JSON 输出模式下 stdout 只留给结构化结果,普通日志改走 stderr。
|
|
5
|
-
|
|
5
|
+
// node --test 的子进程用 stdout 和主进程传递序列化数据,日志混在里面偶尔会导致解析失败,
|
|
6
|
+
// 所以在测试子进程中默认把日志写到 stderr。
|
|
7
|
+
let logsToStderr = Boolean(process.env.NODE_TEST_CONTEXT)
|
|
6
8
|
|
|
7
9
|
function infoStream() {
|
|
8
10
|
return logsToStderr ? process.stderr : process.stdout
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { IcodeError } from '../core/errors.js'
|
|
2
|
+
import { inspectConflicts, stageResolvedConflicts } from '../core/git/conflicts.js'
|
|
3
|
+
import { resolveGitContext } from '../core/git/context.js'
|
|
4
|
+
import { clearOperationState, describeOperationState, loadOperationState, saveOperationState } from '../core/git/operation-state.js'
|
|
5
|
+
import { abortOperation, continueOperation, OPERATION_LABELS } from '../core/git/operations.js'
|
|
6
|
+
import { GitService } from '../core/git/service.js'
|
|
7
|
+
import { logger } from '../core/tools/logger.js'
|
|
8
|
+
import { ui } from '../core/tools/theme.js'
|
|
9
|
+
import { mergeIntoTargetAndPush, pushCurrentBranch } from './push/local-merge-step.js'
|
|
10
|
+
import { runSyncBranches } from './sync-workflow.js'
|
|
11
|
+
|
|
12
|
+
async function openGit(options) {
|
|
13
|
+
const context = await resolveGitContext({ cwd: options.cwd, repoMode: options.repoMode })
|
|
14
|
+
return { context, git: new GitService(context) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatUnresolved(unresolved) {
|
|
18
|
+
return unresolved
|
|
19
|
+
.map((item) => ` - ${item.file}(第 ${item.lines.slice(0, 5).join(', ')}${item.lines.length > 5 ? ' …' : ''} 行)`)
|
|
20
|
+
.join('\n')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 读取当前是否有需要继续/放弃的内容,供 ready / 主菜单提示。
|
|
25
|
+
* 输出 { operation, state, description } 或 null。
|
|
26
|
+
*/
|
|
27
|
+
export async function inspectPausedWork(options = {}) {
|
|
28
|
+
const { context, git } = await openGit(options)
|
|
29
|
+
const operation = await git.getInProgressOperation()
|
|
30
|
+
const state = loadOperationState(context)
|
|
31
|
+
if (!operation && !state) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
operation,
|
|
36
|
+
state,
|
|
37
|
+
description: [operation ? OPERATION_LABELS[operation] || operation : '', describeOperationState(state)].filter(Boolean).join(' · ')
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 冲突解决后把 push 剩下的步骤做完:推送已合并的目标分支、处理剩余目标、切回原分支。
|
|
42
|
+
async function resumePush(git, context, state) {
|
|
43
|
+
const summary = [...(state.summary || [])]
|
|
44
|
+
const options = { ...(state.options || {}), yes: true }
|
|
45
|
+
const remaining = [...(state.remainingTargets || [])]
|
|
46
|
+
|
|
47
|
+
if (state.pendingPush) {
|
|
48
|
+
const remoteExists = await git.branchExistsRemote(state.pendingPush)
|
|
49
|
+
logger.info(`推送目标分支: ${ui.branch(state.pendingPush)}`)
|
|
50
|
+
await git.push(state.pendingPush, { setUpstream: !remoteExists, noVerify: options.noVerify })
|
|
51
|
+
summary.push({ branch: state.pendingPush, status: 'merged-and-pushed' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
55
|
+
const branch = remaining[index]
|
|
56
|
+
try {
|
|
57
|
+
if (branch === state.sourceBranch) {
|
|
58
|
+
await git.checkout(state.sourceBranch)
|
|
59
|
+
summary.push(await pushCurrentBranch(git, branch, options, summary))
|
|
60
|
+
} else {
|
|
61
|
+
summary.push(await mergeIntoTargetAndPush(git, state.sourceBranch, branch, options, summary))
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// 又遇到冲突:更新进度,等下一次 icode continue。
|
|
65
|
+
if (error?.code === 'PUSH_LOCAL_MERGE_PAUSED' && await git.getInProgressOperation()) {
|
|
66
|
+
const mergedTarget = error.meta?.step === 'merge-target'
|
|
67
|
+
saveOperationState(context, {
|
|
68
|
+
...state,
|
|
69
|
+
pendingPush: mergedTarget ? branch : null,
|
|
70
|
+
remainingTargets: remaining.slice(mergedTarget ? index + 1 : index),
|
|
71
|
+
summary
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return summary
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resumeMigrate(git, state) {
|
|
82
|
+
const summary = []
|
|
83
|
+
if (state.push) {
|
|
84
|
+
logger.info(`推送目标分支: ${ui.branch(state.targetBranch)}`)
|
|
85
|
+
await git.push(state.targetBranch, { setUpstream: !state.targetExistsRemote, noVerify: state.options?.noVerify })
|
|
86
|
+
summary.push({ branch: state.targetBranch, status: 'pushed' })
|
|
87
|
+
}
|
|
88
|
+
return summary
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resumeSync(git, context, state) {
|
|
92
|
+
const targets = [state.currentBranch, ...(state.remainingTargets || [])].filter(Boolean)
|
|
93
|
+
// runSyncBranches 结束时会自己切回原分支,并在再次冲突时更新进度。
|
|
94
|
+
clearOperationState(context)
|
|
95
|
+
const summary = await runSyncBranches(git, context, targets, state.options || {}, state.originalBranch, {
|
|
96
|
+
branch: state.currentBranch,
|
|
97
|
+
startAt: state.startAt || 'push'
|
|
98
|
+
})
|
|
99
|
+
return [...(state.summary || []), ...summary]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function returnToOriginalBranch(git, state) {
|
|
103
|
+
const original = state?.originalBranch
|
|
104
|
+
if (!original) {
|
|
105
|
+
return ''
|
|
106
|
+
}
|
|
107
|
+
const current = await git.getCurrentBranch()
|
|
108
|
+
if (current !== original) {
|
|
109
|
+
await git.checkout(original)
|
|
110
|
+
}
|
|
111
|
+
return original
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* icode continue:检查冲突是否都已解决 -> 自动暂存 -> 继续 Git 操作 -> 接着完成 push/migrate/sync 剩下的步骤。
|
|
116
|
+
* 输出 { operation, staged, resumed, summary, returnedTo }。
|
|
117
|
+
*/
|
|
118
|
+
export async function runContinueWorkflow(options = {}) {
|
|
119
|
+
const { context, git } = await openGit(options)
|
|
120
|
+
const operation = await git.getInProgressOperation()
|
|
121
|
+
const state = loadOperationState(context)
|
|
122
|
+
|
|
123
|
+
if (!operation && !state) {
|
|
124
|
+
throw new IcodeError('没有需要继续的操作。', { code: 'CONTINUE_NOTHING', exitCode: 2 })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let staged = []
|
|
128
|
+
if (operation) {
|
|
129
|
+
const { resolved, unresolved } = await inspectConflicts(git)
|
|
130
|
+
if (unresolved.length) {
|
|
131
|
+
throw new IcodeError(
|
|
132
|
+
`还有 ${unresolved.length} 个文件存在冲突标记,请先处理:\n${formatUnresolved(unresolved)}\n处理完再执行 icode continue;想放弃可执行 icode abort。`,
|
|
133
|
+
{ code: 'CONTINUE_UNRESOLVED_CONFLICTS', exitCode: 2, meta: { unresolved } }
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
staged = await stageResolvedConflicts(git, resolved)
|
|
137
|
+
if (staged.length && !options.silentLog) {
|
|
138
|
+
logger.info(`已自动暂存 ${staged.length} 个已解决的文件: ${staged.map(ui.path).join(' ')}`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await continueOperation(git, operation)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// rebase / cherry-pick 的下一个提交可能又冲突。
|
|
145
|
+
const still = await git.getInProgressOperation()
|
|
146
|
+
const next = still ? await inspectConflicts(git) : { unresolved: [] }
|
|
147
|
+
if (next.unresolved.length) {
|
|
148
|
+
throw new IcodeError(
|
|
149
|
+
`下一个提交又出现冲突:\n${formatUnresolved(next.unresolved)}\n处理完再执行 icode continue。`,
|
|
150
|
+
{ code: 'CONTINUE_NEXT_CONFLICT', exitCode: 2, meta: { unresolved: next.unresolved }, cause: error }
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
throw new IcodeError(`继续 ${operation} 失败:${error.message.split('\n').slice(0, 4).join('\n')}`, {
|
|
154
|
+
code: 'CONTINUE_FAILED',
|
|
155
|
+
exitCode: 2,
|
|
156
|
+
cause: error
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (operation && !options.silentLog) {
|
|
162
|
+
logger.success(`${OPERATION_LABELS[operation] || operation} 已完成`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let summary = []
|
|
166
|
+
if (state) {
|
|
167
|
+
if (state.command === 'push') {
|
|
168
|
+
summary = await resumePush(git, context, state)
|
|
169
|
+
} else if (state.command === 'migrate') {
|
|
170
|
+
summary = await resumeMigrate(git, state)
|
|
171
|
+
} else if (state.command === 'sync') {
|
|
172
|
+
summary = await resumeSync(git, context, state)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 进度只在全部完成后清除;中途再次冲突时上面已经抛错并更新了进度。
|
|
177
|
+
let returnedTo = ''
|
|
178
|
+
if (state) {
|
|
179
|
+
if (state.command !== 'sync') {
|
|
180
|
+
returnedTo = await returnToOriginalBranch(git, state)
|
|
181
|
+
} else {
|
|
182
|
+
returnedTo = state.originalBranch || ''
|
|
183
|
+
}
|
|
184
|
+
clearOperationState(context)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
operation,
|
|
189
|
+
staged,
|
|
190
|
+
resumed: state ? state.command : '',
|
|
191
|
+
description: describeOperationState(state),
|
|
192
|
+
summary,
|
|
193
|
+
returnedTo,
|
|
194
|
+
repoRoot: context.topLevelPath
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* icode abort:中止进行中的 Git 操作,清除中断进度并切回原分支。
|
|
200
|
+
* 已经推送成功的分支不会被撤回。
|
|
201
|
+
*/
|
|
202
|
+
export async function runAbortWorkflow(options = {}) {
|
|
203
|
+
const { context, git } = await openGit(options)
|
|
204
|
+
const operation = await git.getInProgressOperation()
|
|
205
|
+
const state = loadOperationState(context)
|
|
206
|
+
|
|
207
|
+
if (!operation && !state) {
|
|
208
|
+
throw new IcodeError('没有需要放弃的操作。', { code: 'ABORT_NOTHING', exitCode: 2 })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (operation) {
|
|
212
|
+
await abortOperation(git, operation)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let returnedTo = ''
|
|
216
|
+
if (state) {
|
|
217
|
+
try {
|
|
218
|
+
returnedTo = await returnToOriginalBranch(git, state)
|
|
219
|
+
} catch (error) {
|
|
220
|
+
logger.warn(`未能切回 ${state.originalBranch}: ${error.message.split('\n')[0]}`)
|
|
221
|
+
}
|
|
222
|
+
clearOperationState(context)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
operation,
|
|
227
|
+
aborted: state ? state.command : '',
|
|
228
|
+
description: describeOperationState(state),
|
|
229
|
+
done: (state?.summary || []).filter((item) => /pushed/.test(item.status)).map((item) => item.branch),
|
|
230
|
+
returnedTo,
|
|
231
|
+
repoRoot: context.topLevelPath
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -193,8 +193,8 @@ export async function runFixupWorkflow(options = {}) {
|
|
|
193
193
|
await git.rebaseOnto(parentSha, { autosquash: true, autostash: true, root: !parentSha })
|
|
194
194
|
} catch (error) {
|
|
195
195
|
throw new IcodeError(
|
|
196
|
-
'fixup
|
|
197
|
-
`或执行 \`icode
|
|
196
|
+
'fixup 自动整理提交时发生冲突。解决冲突后执行 `icode continue`,' +
|
|
197
|
+
`或执行 \`icode abort\` 放弃(之前的状态在恢复点 ${backup.id})。`,
|
|
198
198
|
{ code: 'FIXUP_REBASE_CONFLICT', exitCode: 2, cause: error, meta: error.meta }
|
|
199
199
|
)
|
|
200
200
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IcodeError } from '../core/errors.js'
|
|
2
2
|
import { ui, shortenHome } from '../core/tools/theme.js'
|
|
3
3
|
import { createBackup } from '../core/git/backup.js'
|
|
4
|
+
import { saveOperationState } from '../core/git/operation-state.js'
|
|
4
5
|
import { resolveGitContext } from '../core/git/context.js'
|
|
5
6
|
import { GitService } from '../core/git/service.js'
|
|
6
7
|
import { chooseMany, chooseOne, isInteractiveTerminal } from '../core/tools/interactive.js'
|
|
@@ -628,8 +629,19 @@ export async function runMigrateWorkflow(options) {
|
|
|
628
629
|
try {
|
|
629
630
|
await git.cherryPick(commits)
|
|
630
631
|
} catch (error) {
|
|
632
|
+
if (await git.getInProgressOperation()) {
|
|
633
|
+
saveOperationState(context, {
|
|
634
|
+
command: 'migrate',
|
|
635
|
+
sourceBranch,
|
|
636
|
+
targetBranch,
|
|
637
|
+
originalBranch,
|
|
638
|
+
push: Boolean(options.push),
|
|
639
|
+
targetExistsRemote,
|
|
640
|
+
options: { noVerify: Boolean(options.noVerify) }
|
|
641
|
+
})
|
|
642
|
+
}
|
|
631
643
|
throw new IcodeError(
|
|
632
|
-
|
|
644
|
+
`迁移暂停: cherry-pick 发生冲突,已停在 ${targetBranch}。解决冲突后执行 \`icode continue\` 接着迁移剩下的提交${options.push ? '并推送' : ''},或执行 \`icode abort\` 放弃并切回原分支。`,
|
|
633
645
|
{
|
|
634
646
|
code: 'MIGRATE_CHERRY_PICK_FAILED',
|
|
635
647
|
cause: error,
|
|
@@ -657,7 +669,8 @@ export async function runMigrateWorkflow(options) {
|
|
|
657
669
|
repoRoot: context.topLevelPath
|
|
658
670
|
}
|
|
659
671
|
} finally {
|
|
660
|
-
|
|
672
|
+
// 冲突暂停时留在目标分支上,方便直接解决冲突。
|
|
673
|
+
if (originalBranch && originalBranch !== targetBranch && !(await git.getInProgressOperation())) {
|
|
661
674
|
try {
|
|
662
675
|
await git.checkout(originalBranch)
|
|
663
676
|
} catch (error) {
|
|
@@ -33,7 +33,7 @@ export function buildLocalMergePauseError({ sourceBranch, targetBranch, step, er
|
|
|
33
33
|
const conflict = isGitConflictFailure(error)
|
|
34
34
|
const prefix = conflict ? '本地合并冲突' : '本地合并失败'
|
|
35
35
|
const nextStep = conflict
|
|
36
|
-
? '
|
|
36
|
+
? '解决冲突后执行 `icode continue`(会自动暂存已解决的文件,并接着完成剩下的推送),或执行 `icode abort` 放弃并切回原分支。'
|
|
37
37
|
: '请根据上面的 Git 错误修复后重试。'
|
|
38
38
|
|
|
39
39
|
return new IcodeError(`${prefix}(${step}): ${sourceBranch} -> ${targetBranch}\n${reason}\n${nextStep}`, {
|
|
@@ -2,6 +2,7 @@ import { getRepoPolicy } from '../core/config/store.js'
|
|
|
2
2
|
import { ui, shortenHome } from '../core/tools/theme.js'
|
|
3
3
|
import { IcodeError } from '../core/errors.js'
|
|
4
4
|
import { createBackup } from '../core/git/backup.js'
|
|
5
|
+
import { saveOperationState } from '../core/git/operation-state.js'
|
|
5
6
|
import { resolveGitContext } from '../core/git/context.js'
|
|
6
7
|
import {
|
|
7
8
|
assertNoConflictedFiles,
|
|
@@ -68,6 +69,28 @@ function validateForcePush(inputOptions, branchTargets, currentBranch, context,
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// 去掉会被跳过的受保护分支,只保留续做时真正要处理的分支。
|
|
73
|
+
function filterRunnable(branches, protectedBranches, inputOptions) {
|
|
74
|
+
return branches.filter((branch) => !(protectedBranches.has(branch) && !inputOptions.forceProtected))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 保存 push 中断时的进度:pendingPush 是已合并但还没推送的分支,remainingTargets 是还没处理的分支。
|
|
78
|
+
function savePushState(context, inputOptions, sourceBranch, pendingPush, remainingTargets, summary) {
|
|
79
|
+
saveOperationState(context, {
|
|
80
|
+
command: 'push',
|
|
81
|
+
sourceBranch,
|
|
82
|
+
originalBranch: sourceBranch,
|
|
83
|
+
pendingPush,
|
|
84
|
+
remainingTargets,
|
|
85
|
+
options: {
|
|
86
|
+
noVerify: Boolean(inputOptions.noVerify),
|
|
87
|
+
createTarget: Boolean(inputOptions.createTarget),
|
|
88
|
+
yes: true
|
|
89
|
+
},
|
|
90
|
+
summary: summary.slice()
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
/**
|
|
72
95
|
* 交互选择推送目标:只推当前分支(默认)/ 同时本地合并到其他分支 / 通过 PR 合并。
|
|
73
96
|
* 输出 { targets, remoteMerge } 或 null(取消)。
|
|
@@ -215,13 +238,17 @@ export async function runPushWorkflow(inputOptions) {
|
|
|
215
238
|
noRebase: true
|
|
216
239
|
})
|
|
217
240
|
} catch (error) {
|
|
218
|
-
|
|
241
|
+
const pauseError = buildLocalMergePauseError({
|
|
219
242
|
sourceBranch: context.defaultBranch,
|
|
220
243
|
targetBranch: currentBranch,
|
|
221
244
|
step: 'pull-main',
|
|
222
245
|
error,
|
|
223
246
|
summary
|
|
224
247
|
})
|
|
248
|
+
if (await git.getInProgressOperation()) {
|
|
249
|
+
savePushState(context, inputOptions, currentBranch, null, filterRunnable(branchTargets, protectedBranches, inputOptions), summary)
|
|
250
|
+
}
|
|
251
|
+
throw pauseError
|
|
225
252
|
}
|
|
226
253
|
}
|
|
227
254
|
|
|
@@ -282,24 +309,32 @@ export async function runPushWorkflow(inputOptions) {
|
|
|
282
309
|
const branchOptions = { ...inputOptions, expectedRemoteSha }
|
|
283
310
|
|
|
284
311
|
try {
|
|
285
|
-
for (
|
|
312
|
+
for (let index = 0; index < branchTargets.length; index += 1) {
|
|
313
|
+
const targetBranch = branchTargets[index]
|
|
286
314
|
if (protectedBranches.has(targetBranch) && !inputOptions.forceProtected) {
|
|
287
315
|
logger.warn(`跳过受保护分支: ${ui.branch(targetBranch)}(可用 --force-protected 覆盖)`)
|
|
288
316
|
summary.push({ branch: targetBranch, status: 'skipped-protected' })
|
|
289
317
|
continue
|
|
290
318
|
}
|
|
291
319
|
|
|
292
|
-
if (targetBranch
|
|
293
|
-
summary.push(await pushCurrentBranch(git, targetBranch, branchOptions, summary))
|
|
294
|
-
continue
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
if (inputOptions.remoteMerge) {
|
|
320
|
+
if (inputOptions.remoteMerge && targetBranch !== currentBranch) {
|
|
298
321
|
remoteMergeTargets.push(targetBranch)
|
|
299
322
|
continue
|
|
300
323
|
}
|
|
301
324
|
|
|
302
|
-
|
|
325
|
+
try {
|
|
326
|
+
summary.push(targetBranch === currentBranch
|
|
327
|
+
? await pushCurrentBranch(git, targetBranch, branchOptions, summary)
|
|
328
|
+
: await mergeIntoTargetAndPush(git, currentBranch, targetBranch, branchOptions, summary))
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// 冲突暂停:记下剩余步骤,icode continue 会推送当前目标并处理剩下的分支。
|
|
331
|
+
if (error?.code === 'PUSH_LOCAL_MERGE_PAUSED' && await git.getInProgressOperation()) {
|
|
332
|
+
const mergedTarget = error.meta?.step === 'merge-target'
|
|
333
|
+
const rest = filterRunnable(branchTargets.slice(mergedTarget ? index + 1 : index), protectedBranches, inputOptions)
|
|
334
|
+
savePushState(context, inputOptions, currentBranch, mergedTarget ? targetBranch : null, rest, summary)
|
|
335
|
+
}
|
|
336
|
+
throw error
|
|
337
|
+
}
|
|
303
338
|
}
|
|
304
339
|
|
|
305
340
|
if (inputOptions.remoteMerge && remoteMergeTargets.length) {
|
|
@@ -330,7 +365,7 @@ export async function runPushWorkflow(inputOptions) {
|
|
|
330
365
|
const operationAfterWorkflow = await git.getInProgressOperation()
|
|
331
366
|
if (operationAfterWorkflow === 'merge') {
|
|
332
367
|
logger.warn(`检测到未完成的 merge,已保留在当前分支 ${branchAfterWorkflow || '(unknown)'}。`)
|
|
333
|
-
logger.warn('解决冲突后执行 `icode
|
|
368
|
+
logger.warn('解决冲突后执行 `icode continue` 接着完成剩下的步骤,或执行 `icode abort` 放弃并切回原分支。')
|
|
334
369
|
} else if (branchAfterWorkflow && branchAfterWorkflow !== originalBranch) {
|
|
335
370
|
try {
|
|
336
371
|
await git.checkout(originalBranch)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveRemoteMergePlatform } from './push/remote-merge-step.js'
|
|
2
|
+
import { describeOperationState, loadOperationState } from '../core/git/operation-state.js'
|
|
2
3
|
import { getAiConfig } from '../core/ai/config.js'
|
|
3
4
|
import { getRepoPolicy } from '../core/config/store.js'
|
|
4
5
|
import { resolveGitContext } from '../core/git/context.js'
|
|
@@ -95,17 +96,19 @@ function checkCurrentBranch(currentBranch) {
|
|
|
95
96
|
* 检查是否存在未完成的 Git 操作。
|
|
96
97
|
* 输入为 operation 字符串,输出 ready 检查项。
|
|
97
98
|
*/
|
|
98
|
-
function checkInProgressOperation(operation) {
|
|
99
|
-
|
|
99
|
+
function checkInProgressOperation(operation, pausedState) {
|
|
100
|
+
const paused = describeOperationState(pausedState)
|
|
101
|
+
if (!operation && !pausedState) {
|
|
100
102
|
return buildCheck('git.operation', 'pass', '没有未完成的 Git 操作', '工作区未处于 merge/rebase/cherry-pick/revert 流程中。')
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
const detail = [operation ? `当前操作: ${operation}` : '', paused ? `被中断的 icode 命令: ${paused}` : ''].filter(Boolean).join(';')
|
|
103
106
|
return buildCheck(
|
|
104
107
|
'git.operation',
|
|
105
108
|
'fail',
|
|
106
|
-
'
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
'有被中断的操作',
|
|
110
|
+
detail,
|
|
111
|
+
'解决冲突后执行 icode continue 接着完成;或执行 icode abort 放弃并切回原分支。'
|
|
109
112
|
)
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -249,7 +252,7 @@ export async function runReadyWorkflow(options = {}) {
|
|
|
249
252
|
|
|
250
253
|
const checks = [
|
|
251
254
|
checkCurrentBranch(currentBranch),
|
|
252
|
-
checkInProgressOperation(operation),
|
|
255
|
+
checkInProgressOperation(operation, loadOperationState(context)),
|
|
253
256
|
checkChanges(changes),
|
|
254
257
|
checkUpstream(upstream),
|
|
255
258
|
checkAiProfile(aiConfig),
|
|
@@ -2,6 +2,7 @@ import { IcodeError } from '../core/errors.js'
|
|
|
2
2
|
import { ui, shortenHome } from '../core/tools/theme.js'
|
|
3
3
|
import { withAutoStash } from '../core/git/autostash.js'
|
|
4
4
|
import { createBackup } from '../core/git/backup.js'
|
|
5
|
+
import { saveOperationState } from '../core/git/operation-state.js'
|
|
5
6
|
import { resolveGitContext } from '../core/git/context.js'
|
|
6
7
|
import {
|
|
7
8
|
assertCleanWorktree,
|
|
@@ -136,7 +137,7 @@ export async function runSyncWorkflow(options) {
|
|
|
136
137
|
})
|
|
137
138
|
|
|
138
139
|
const summary = await withAutoStash(git, options.autostash === true, `sync ${targets.join(',')}`, () =>
|
|
139
|
-
|
|
140
|
+
runSyncBranches(git, context, targets, options, originalBranch)
|
|
140
141
|
)
|
|
141
142
|
|
|
142
143
|
return {
|
|
@@ -148,51 +149,101 @@ export async function runSyncWorkflow(options) {
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
|
|
152
|
-
|
|
152
|
+
// 单个分支的同步步骤,冲突中断后可以从 startAt 指定的步骤接着做。
|
|
153
|
+
const SYNC_STEPS = ['checkout', 'pull', 'merge-main', 'push']
|
|
154
|
+
|
|
155
|
+
async function syncOneBranch(git, context, branchName, options, startAt = 'checkout') {
|
|
156
|
+
const from = SYNC_STEPS.indexOf(startAt)
|
|
157
|
+
let step = startAt
|
|
153
158
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
let remoteExists = await git.branchExistsRemote(branchName)
|
|
160
|
+
if (from <= 0) {
|
|
156
161
|
const setup = await ensureBranchReady(git, branchName)
|
|
157
|
-
|
|
158
162
|
if (!setup.available) {
|
|
159
163
|
logger.warn(`分支不存在(本地+远程): ${ui.branch(branchName)}`)
|
|
160
|
-
|
|
161
|
-
branch: branchName,
|
|
162
|
-
status: 'missing'
|
|
163
|
-
})
|
|
164
|
-
continue
|
|
164
|
+
return { branch: branchName, status: 'missing' }
|
|
165
165
|
}
|
|
166
|
+
remoteExists = setup.remoteExists
|
|
167
|
+
}
|
|
166
168
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
169
|
+
if (from <= 1 && remoteExists) {
|
|
170
|
+
step = 'pull'
|
|
171
|
+
await git.pull(branchName, {
|
|
172
|
+
noRebase: !options.rebase,
|
|
173
|
+
rebase: options.rebase
|
|
174
|
+
})
|
|
175
|
+
}
|
|
173
176
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
177
|
+
if (from <= 2 && options.mergeMain && branchName !== context.defaultBranch) {
|
|
178
|
+
step = 'merge-main'
|
|
179
|
+
// 把最新主分支合入目标分支,降低后续提测/发布时的冲突概率。
|
|
180
|
+
await git.merge(context.defaultBranch, {
|
|
181
|
+
noFf: true,
|
|
182
|
+
noEdit: true
|
|
183
|
+
})
|
|
184
|
+
}
|
|
181
185
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
if (options.push) {
|
|
187
|
+
step = 'push'
|
|
188
|
+
await git.push(branchName, {
|
|
189
|
+
setUpstream: !remoteExists,
|
|
190
|
+
noVerify: options.noVerify
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
error.syncStep = step
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
branch: branchName,
|
|
200
|
+
status: options.push ? 'synced-and-pushed' : 'synced'
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 依次同步多个分支。某个分支发生冲突时保存进度(icode continue 接着做),并留在冲突分支上。
|
|
206
|
+
* resume = { branch, startAt } 表示从某个分支的某一步继续。
|
|
207
|
+
*/
|
|
208
|
+
export async function runSyncBranches(git, context, targets, options, originalBranch, resume = null) {
|
|
209
|
+
const summary = []
|
|
210
|
+
let paused = false
|
|
211
|
+
try {
|
|
212
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
213
|
+
const branchName = targets[index]
|
|
214
|
+
const startAt = resume && resume.branch === branchName ? resume.startAt : 'checkout'
|
|
215
|
+
logger.info(`同步分支: ${ui.branch(branchName)}`)
|
|
216
|
+
try {
|
|
217
|
+
summary.push(await syncOneBranch(git, context, branchName, options, startAt))
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!(await git.getInProgressOperation())) {
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
// 冲突:记录剩余步骤,下一步从冲突步骤之后开始。
|
|
223
|
+
const nextStep = SYNC_STEPS[SYNC_STEPS.indexOf(error.syncStep || 'pull') + 1] || 'push'
|
|
224
|
+
saveOperationState(context, {
|
|
225
|
+
command: 'sync',
|
|
226
|
+
originalBranch,
|
|
227
|
+
currentBranch: branchName,
|
|
228
|
+
startAt: nextStep,
|
|
229
|
+
remainingTargets: targets.slice(index + 1),
|
|
230
|
+
options: {
|
|
231
|
+
push: Boolean(options.push),
|
|
232
|
+
mergeMain: Boolean(options.mergeMain),
|
|
233
|
+
rebase: Boolean(options.rebase),
|
|
234
|
+
noVerify: Boolean(options.noVerify)
|
|
235
|
+
},
|
|
236
|
+
summary
|
|
186
237
|
})
|
|
238
|
+
paused = true
|
|
239
|
+
throw new IcodeError(
|
|
240
|
+
`同步 ${branchName} 时发生冲突,已停在该分支。\n解决冲突后执行 \`icode continue\` 接着同步剩下的分支,或执行 \`icode abort\` 放弃并切回 ${originalBranch}。`,
|
|
241
|
+
{ code: 'SYNC_CONFLICT_PAUSED', exitCode: 2, cause: error, meta: { branch: branchName, summary } }
|
|
242
|
+
)
|
|
187
243
|
}
|
|
188
|
-
|
|
189
|
-
summary.push({
|
|
190
|
-
branch: branchName,
|
|
191
|
-
status: options.push ? 'synced-and-pushed' : 'synced'
|
|
192
|
-
})
|
|
193
244
|
}
|
|
194
245
|
} finally {
|
|
195
|
-
if (originalBranch) {
|
|
246
|
+
if (originalBranch && !paused) {
|
|
196
247
|
try {
|
|
197
248
|
await git.checkout(originalBranch)
|
|
198
249
|
} catch (error) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IcodeError } from '../core/errors.js'
|
|
2
2
|
import { shortenHome } from '../core/tools/theme.js'
|
|
3
3
|
import { createBackup } from '../core/git/backup.js'
|
|
4
|
+
import { runAbortWorkflow, runContinueWorkflow } from './continue-workflow.js'
|
|
4
5
|
import { resolveGitContext } from '../core/git/context.js'
|
|
5
6
|
import { GitService } from '../core/git/service.js'
|
|
6
7
|
import { chooseOne, confirm } from '../core/tools/interactive.js'
|
|
@@ -82,7 +83,7 @@ async function performRollback(options) {
|
|
|
82
83
|
} catch (error) {
|
|
83
84
|
if (isRevertConflict(error)) {
|
|
84
85
|
throw new IcodeError(
|
|
85
|
-
'revert
|
|
86
|
+
'revert 发生冲突。解决冲突后执行 `icode continue`,或执行 `icode abort` 直接中止。',
|
|
86
87
|
{
|
|
87
88
|
code: 'REVERT_CONFLICT',
|
|
88
89
|
cause: error,
|
|
@@ -140,30 +141,7 @@ async function performRollback(options) {
|
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
// 按操作类型调用对应的 continue / abort。
|
|
144
|
-
async function continueOperation(git, operation) {
|
|
145
|
-
if (operation === 'revert') {
|
|
146
|
-
await git.revertContinue()
|
|
147
|
-
} else if (operation === 'merge') {
|
|
148
|
-
await git.mergeContinue()
|
|
149
|
-
} else if (operation === 'rebase') {
|
|
150
|
-
await git.rebaseContinue()
|
|
151
|
-
} else {
|
|
152
|
-
await git.cherryPickContinue()
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
144
|
|
|
156
|
-
async function abortOperation(git, operation) {
|
|
157
|
-
if (operation === 'revert') {
|
|
158
|
-
await git.revertAbort()
|
|
159
|
-
} else if (operation === 'merge') {
|
|
160
|
-
await git.mergeAbort()
|
|
161
|
-
} else if (operation === 'rebase') {
|
|
162
|
-
await git.rebaseAbort()
|
|
163
|
-
} else {
|
|
164
|
-
await git.abortCherryPick()
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
145
|
|
|
168
146
|
// 解析交互菜单选择,输入菜单 value,输出 mode/ref 或 canceled。
|
|
169
147
|
function parseSelection(selection) {
|
|
@@ -295,31 +273,21 @@ async function resolvePendingOperation(options) {
|
|
|
295
273
|
}
|
|
296
274
|
}
|
|
297
275
|
|
|
276
|
+
// 与 icode continue / icode abort 走同一套流程:自动暂存已解决文件,并续做被打断的 push/migrate/sync。
|
|
298
277
|
if (action === 'continue') {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
recoverAction: 'continue'
|
|
305
|
-
}
|
|
306
|
-
} catch (error) {
|
|
307
|
-
throw new IcodeError(
|
|
308
|
-
`${pending} --continue 失败,请先解决冲突后重试,或使用 --recover abort。`,
|
|
309
|
-
{
|
|
310
|
-
code: 'UNDO_CONTINUE_FAILED',
|
|
311
|
-
cause: error,
|
|
312
|
-
meta: error.meta
|
|
313
|
-
}
|
|
314
|
-
)
|
|
278
|
+
const result = await runContinueWorkflow({ cwd: options.cwd, repoMode: options.repoMode })
|
|
279
|
+
return {
|
|
280
|
+
resolvedOperation: pending,
|
|
281
|
+
recoverAction: 'continue',
|
|
282
|
+
...(result.resumed ? { resumed: result.resumed, summary: result.summary } : {})
|
|
315
283
|
}
|
|
316
284
|
}
|
|
317
285
|
|
|
318
|
-
await
|
|
319
|
-
|
|
286
|
+
const result = await runAbortWorkflow({ cwd: options.cwd, repoMode: options.repoMode })
|
|
320
287
|
return {
|
|
321
288
|
resolvedOperation: pending,
|
|
322
|
-
recoverAction: 'abort'
|
|
289
|
+
recoverAction: 'abort',
|
|
290
|
+
...(result.aborted ? { aborted: result.aborted, returnedTo: result.returnedTo } : {})
|
|
323
291
|
}
|
|
324
292
|
}
|
|
325
293
|
|