@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
@@ -0,0 +1,121 @@
1
+ import { c, displayWidth, keyValues, pad, print, table, title, truncate, ui } from '../tools/theme.js'
2
+
3
+ // 各命令共用的“视图”渲染函数,保证输出风格一致。
4
+
5
+ export { print, title, keyValues, table, ui, c }
6
+
7
+ /**
8
+ * 渲染检查项列表(ready / doctor)。
9
+ * checks: [{ status, title, detail, nextStep?, suggestion? }]
10
+ */
11
+ export function renderChecks(checks = []) {
12
+ const icon = { pass: c.green('✔'), warn: c.yellow('▲'), fail: c.red('✖') }
13
+ const titleWidth = Math.min(28, Math.max(0, ...checks.map((check) => displayWidth(check.title))))
14
+ for (const check of checks) {
15
+ const mark = icon[check.status] || c.gray('·')
16
+ const name = check.status === 'pass' ? check.title : check.status === 'fail' ? c.red(check.title) : c.yellow(check.title)
17
+ print(` ${mark} ${pad(name, titleWidth)} ${c.gray(check.detail || '')}`)
18
+ const next = check.nextStep || check.suggestion
19
+ if (next && check.status !== 'pass') {
20
+ print(` ${c.cyan('→')} ${next}`)
21
+ }
22
+ }
23
+ }
24
+
25
+ /**
26
+ * 渲染整体状态行。status: pass | warn | fail
27
+ */
28
+ export function renderOverallStatus(status, counts = {}) {
29
+ const label = {
30
+ pass: c.green(c.bold('✔ 一切就绪')),
31
+ warn: c.yellow(c.bold('▲ 可以继续,但有需要注意的地方')),
32
+ fail: c.red(c.bold('✖ 需要先处理以下问题'))
33
+ }[status] || status
34
+ const detail = Object.entries(counts)
35
+ .filter(([, value]) => value)
36
+ .map(([key, value]) => `${key} ${value}`)
37
+ .join(c.gray(' · '))
38
+ print(`\n${label}${detail ? ` ${c.gray(detail)}` : ''}`)
39
+ }
40
+
41
+ /**
42
+ * 工作区改动统计,例如:已暂存 2 · 未暂存 1 · 未跟踪 3。
43
+ */
44
+ export function formatChanges(changes = {}) {
45
+ const parts = [
46
+ changes.staged ? c.green(`已暂存 ${changes.staged}`) : '',
47
+ changes.unstaged ? c.yellow(`未暂存 ${changes.unstaged}`) : '',
48
+ changes.untracked ? c.cyan(`未跟踪 ${changes.untracked}`) : '',
49
+ changes.conflicted ? c.red(`冲突 ${changes.conflicted}`) : ''
50
+ ].filter(Boolean)
51
+ return parts.length ? parts.join(c.gray(' · ')) : c.gray('工作区干净')
52
+ }
53
+
54
+ /**
55
+ * 提交列表:hash 时间 作者 标题 (refs)。
56
+ */
57
+ export function renderCommits(commits = [], options = {}) {
58
+ const width = process.stdout.columns || 100
59
+ const authorWidth = Math.min(14, Math.max(4, ...commits.map((item) => String(item.author || '').length)))
60
+ for (const item of commits) {
61
+ const refs = item.refs?.length ? ` ${c.yellow(`(${item.refs.join(', ')})`)}` : ''
62
+ const subject = truncate(item.subject, Math.max(20, width - authorWidth - 40))
63
+ const parts = [
64
+ ui.hash(item.shortHash || item.hash).padEnd(options.hashWidth || 0),
65
+ ui.time(pad(item.relativeDate || '', 12)),
66
+ options.hideAuthor ? '' : ui.author(pad(truncate(item.author || '', authorWidth), authorWidth)),
67
+ `${ui.subject(subject)}${refs}`
68
+ ].filter(Boolean)
69
+ print(` ${parts.join(' ')}`)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * 分支结果汇总(push / sync / clean 等)。
75
+ * items: [{ branch, status, reason? }],labels: status -> 中文
76
+ */
77
+ export function renderBranchResults(items = [], labels = {}) {
78
+ const goodStatuses = new Set(['pushed', 'merged-and-pushed', 'remote-merged-and-pushed', 'force-pushed', 'synced', 'synced-and-pushed', 'deleted'])
79
+ const skipStatuses = new Set(['skipped-protected', 'skipped-missing-remote', 'missing'])
80
+ const width = Math.max(0, ...items.map((item) => String(item.branch).length))
81
+ for (const item of items) {
82
+ const mark = goodStatuses.has(item.status) ? c.green('✔') : skipStatuses.has(item.status) ? c.gray('○') : c.red('✖')
83
+ const label = labels[item.status] || item.status
84
+ const reason = item.reason ? c.gray(` ${item.reason}`) : ''
85
+ const url = item.mergeRequestUrl ? ` ${ui.url(item.mergeRequestUrl)}` : ''
86
+ print(` ${mark} ${ui.branch(pad(item.branch, width))} ${goodStatuses.has(item.status) ? label : c.yellow(label)}${reason}${url}`)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 下一步建议列表。
92
+ */
93
+ export function renderNextSteps(steps = []) {
94
+ const list = steps.filter(Boolean)
95
+ if (!list.length) {
96
+ return
97
+ }
98
+ print(c.gray('\n 下一步:'))
99
+ for (const step of list) {
100
+ print(` ${c.cyan('›')} ${step}`)
101
+ }
102
+ }
103
+
104
+ // 简单渲染 changelog markdown:标题加粗、列表符号着色。
105
+ export function renderMarkdown(text) {
106
+ return String(text || '')
107
+ .split('\n')
108
+ .map((line) => {
109
+ if (line.startsWith('## ')) {
110
+ return ` ${c.bold(c.cyan(line.slice(3)))}`
111
+ }
112
+ if (line.startsWith('### ')) {
113
+ return ` ${c.bold(line.slice(4))}`
114
+ }
115
+ if (line.startsWith('- ')) {
116
+ return ` ${c.cyan('•')} ${line.slice(2).replace(/\*\*([^*]+)\*\*/g, (_, scope) => c.gray(scope))}`
117
+ }
118
+ return line ? ` ${line}` : ''
119
+ })
120
+ .join('\n')
121
+ }
@@ -30,14 +30,22 @@ function formatFailureMessage(command, args, result) {
30
30
  */
31
31
  export async function runCommand(command, args = [], options = {}) {
32
32
  const { cwd = process.cwd(), env = process.env, allowFailure = false } = options
33
+ const hasInput = typeof options.input === 'string'
33
34
 
34
35
  return new Promise((resolve, reject) => {
35
36
  const child = spawn(command, args, {
36
37
  cwd,
37
38
  env,
38
- stdio: ['ignore', 'pipe', 'pipe']
39
+ stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe']
39
40
  })
40
41
 
42
+ if (hasInput) {
43
+ child.stdin.on('error', () => {
44
+ // 子进程提前退出时忽略写入错误,结果以退出码为准。
45
+ })
46
+ child.stdin.end(options.input)
47
+ }
48
+
41
49
  let stdout = ''
42
50
  let stderr = ''
43
51
 
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { IcodeError } from '../errors.js'
4
+
5
+ // 仓库级配置文件名,按顺序查找第一个存在的文件。可以提交进仓库供团队共享。
6
+ export const REPO_CONFIG_FILE_NAMES = ['.icoderc.json', '.icoderc']
7
+
8
+ const DEFAULT_REPO_CONFIG = {
9
+ remote: '',
10
+ defaultBranch: '',
11
+ protectedBranches: [],
12
+ save: {
13
+ maxFileSizeMB: 5,
14
+ sensitivePatterns: [],
15
+ allowPatterns: []
16
+ }
17
+ }
18
+
19
+ function cloneDefault() {
20
+ return JSON.parse(JSON.stringify(DEFAULT_REPO_CONFIG))
21
+ }
22
+
23
+ /**
24
+ * 查找仓库级配置文件。
25
+ * 输入为仓库根目录,输出文件绝对路径;不存在时返回空串。
26
+ */
27
+ export function findRepoConfigFile(repoRoot) {
28
+ if (!repoRoot) {
29
+ return ''
30
+ }
31
+
32
+ for (const fileName of REPO_CONFIG_FILE_NAMES) {
33
+ const filePath = path.resolve(repoRoot, fileName)
34
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
35
+ return filePath
36
+ }
37
+ }
38
+
39
+ return ''
40
+ }
41
+
42
+ function normalizeStringList(value) {
43
+ if (!Array.isArray(value)) {
44
+ return []
45
+ }
46
+ return value.map((item) => String(item || '').trim()).filter(Boolean)
47
+ }
48
+
49
+ /**
50
+ * 读取仓库级配置(.icoderc.json / .icoderc,JSON 格式)。
51
+ * 输入为仓库根目录,输出合并默认值后的配置;文件不存在时返回默认值。
52
+ */
53
+ export function readRepoConfig(repoRoot) {
54
+ const filePath = findRepoConfigFile(repoRoot)
55
+ const config = cloneDefault()
56
+ if (!filePath) {
57
+ return { ...config, filePath: '' }
58
+ }
59
+
60
+ let parsed
61
+ try {
62
+ const content = fs.readFileSync(filePath, 'utf8')
63
+ parsed = content.trim() ? JSON.parse(content) : {}
64
+ } catch (error) {
65
+ throw new IcodeError(`仓库配置文件解析失败: ${filePath}(需要 JSON 格式)`, {
66
+ code: 'REPO_CONFIG_PARSE_ERROR',
67
+ exitCode: 2,
68
+ cause: error
69
+ })
70
+ }
71
+
72
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
73
+ throw new IcodeError(`仓库配置文件必须是 JSON 对象: ${filePath}`, {
74
+ code: 'REPO_CONFIG_INVALID',
75
+ exitCode: 2
76
+ })
77
+ }
78
+
79
+ const save = parsed.save && typeof parsed.save === 'object' ? parsed.save : {}
80
+ const maxFileSizeMB = Number(save.maxFileSizeMB)
81
+
82
+ return {
83
+ remote: typeof parsed.remote === 'string' ? parsed.remote.trim() : '',
84
+ defaultBranch: typeof parsed.defaultBranch === 'string' ? parsed.defaultBranch.trim() : '',
85
+ protectedBranches: normalizeStringList(parsed.protectedBranches),
86
+ save: {
87
+ maxFileSizeMB: Number.isFinite(maxFileSizeMB) && maxFileSizeMB > 0 ? maxFileSizeMB : config.save.maxFileSizeMB,
88
+ sensitivePatterns: normalizeStringList(save.sensitivePatterns),
89
+ allowPatterns: normalizeStringList(save.allowPatterns)
90
+ },
91
+ filePath
92
+ }
93
+ }
@@ -49,6 +49,45 @@ function ensureDirectory(filePath) {
49
49
  }
50
50
  }
51
51
 
52
+ function secureConfigFile(filePath) {
53
+ try {
54
+ fs.chmodSync(filePath, 0o600)
55
+ } catch (error) {
56
+ throw new IcodeError(`无法设置配置文件权限: ${filePath}`, {
57
+ code: 'CONFIG_PERMISSION_ERROR',
58
+ cause: error
59
+ })
60
+ }
61
+ }
62
+
63
+ function writeConfigFile(filePath, config) {
64
+ ensureDirectory(filePath)
65
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
66
+
67
+ try {
68
+ fs.writeFileSync(tempPath, JSON.stringify(config, null, 2), {
69
+ encoding: 'utf8',
70
+ mode: 0o600
71
+ })
72
+ fs.renameSync(tempPath, filePath)
73
+ secureConfigFile(filePath)
74
+ } catch (error) {
75
+ try {
76
+ fs.rmSync(tempPath, { force: true })
77
+ } catch {
78
+ // 保留原始写入异常。
79
+ }
80
+
81
+ if (error instanceof IcodeError) {
82
+ throw error
83
+ }
84
+ throw new IcodeError(`配置文件写入失败: ${filePath}`, {
85
+ code: 'CONFIG_WRITE_ERROR',
86
+ cause: error
87
+ })
88
+ }
89
+ }
90
+
52
91
  function cloneDefault() {
53
92
  return JSON.parse(JSON.stringify(DEFAULT_CONFIG))
54
93
  }
@@ -59,15 +98,16 @@ export function readConfig() {
59
98
 
60
99
  if (!fs.existsSync(configPath)) {
61
100
  const initial = cloneDefault()
62
- fs.writeFileSync(configPath, JSON.stringify(initial, null, 2), 'utf8')
101
+ writeConfigFile(configPath, initial)
63
102
  return initial
64
103
  }
65
104
 
66
105
  try {
106
+ secureConfigFile(configPath)
67
107
  const content = fs.readFileSync(configPath, 'utf8')
68
108
  if (!content.trim()) {
69
109
  const initial = cloneDefault()
70
- fs.writeFileSync(configPath, JSON.stringify(initial, null, 2), 'utf8')
110
+ writeConfigFile(configPath, initial)
71
111
  return initial
72
112
  }
73
113
 
@@ -113,16 +153,26 @@ export function readConfig() {
113
153
  }
114
154
 
115
155
  export function writeConfig(nextConfig) {
116
- const configPath = getConfigFilePath()
117
- ensureDirectory(configPath)
118
- fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2), 'utf8')
156
+ writeConfigFile(getConfigFilePath(), nextConfig)
119
157
  }
120
158
 
121
- function splitPathSegments(pathExpression) {
122
- return pathExpression
123
- .split('.')
124
- .map((segment) => segment.trim())
125
- .filter(Boolean)
159
+ /**
160
+ * 解析配置路径。支持点号和方括号两种写法,方括号里可以包含点:
161
+ * platforms.tokens.github
162
+ * platforms.hosts[git.company.com]
163
+ * platforms.hosts["git.company.com"]
164
+ */
165
+ export function splitPathSegments(pathExpression) {
166
+ const segments = []
167
+ const pattern = /\[\s*(?:"([^"]*)"|'([^']*)'|([^\]]*))\s*\]|([^.[\]]+)/g
168
+ let match
169
+ while ((match = pattern.exec(String(pathExpression || ''))) !== null) {
170
+ const segment = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? '').trim()
171
+ if (segment) {
172
+ segments.push(segment)
173
+ }
174
+ }
175
+ return segments
126
176
  }
127
177
 
128
178
  export function getValue(pathExpression) {
@@ -0,0 +1,46 @@
1
+ import { logger } from '../tools/logger.js'
2
+
3
+ /**
4
+ * 在执行 fn 前自动 stash 未提交改动(含未跟踪文件),结束后自动恢复。
5
+ * 输入为 GitService、是否启用、操作名和回调,输出回调的返回值。
6
+ * 关键假设:恢复冲突时不丢数据,改动会留在 stash 里并提示用户处理。
7
+ */
8
+ export async function withAutoStash(git, enabled, label, fn) {
9
+ if (!enabled) {
10
+ return fn({ stashed: false })
11
+ }
12
+
13
+ const changes = await git.getStatusSummary()
14
+ if (changes.clean) {
15
+ return fn({ stashed: false })
16
+ }
17
+
18
+ const message = `icode autostash: ${label}`
19
+ await git.stashPush({ message, includeUntracked: true })
20
+ const stashSha = await git.revParse('stash@{0}')
21
+ logger.info(`已自动暂存未提交改动(${message})`)
22
+
23
+ let restored = false
24
+ try {
25
+ return await fn({ stashed: true, stashSha })
26
+ } finally {
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
+ }
33
+ if (entry) {
34
+ try {
35
+ await git.stashApply(entry.ref, { pop: true })
36
+ restored = true
37
+ } catch (error) {
38
+ logger.warn(`自动恢复改动时发生冲突,改动仍保存在 ${entry.ref}。解决冲突后可执行 \`icode stash drop ${entry.ref}\`。`)
39
+ logger.debug(error.message)
40
+ }
41
+ }
42
+ if (restored) {
43
+ logger.info('已恢复自动暂存的改动。')
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,170 @@
1
+ import { logger } from '../tools/logger.js'
2
+
3
+ // 所有备份都放在这个命名空间下,不会出现在 git branch / git tag 里,也不会被 push。
4
+ export const BACKUP_REF_PREFIX = 'refs/icode/backup'
5
+ const DEFAULT_KEEP_GROUPS = 50
6
+ const WORKTREE_ENTRY = '_worktree'
7
+
8
+ function pad(value, length = 2) {
9
+ return String(value).padStart(length, '0')
10
+ }
11
+
12
+ /**
13
+ * 生成备份组 id,格式 YYYYMMDD-HHMMSS-mmm,可按字典序排序。
14
+ * 输入为 Date,输出字符串。
15
+ */
16
+ export function buildBackupId(date = new Date()) {
17
+ return [
18
+ `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`,
19
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`,
20
+ pad(date.getMilliseconds(), 3)
21
+ ].join('-')
22
+ }
23
+
24
+ // 分支名里的 / 在 ref 里合法,但为了一层目录好解析,统一编码成 __。
25
+ function encodeBranch(branchName) {
26
+ return String(branchName).replace(/\//g, '__')
27
+ }
28
+
29
+ function decodeBranch(entryName) {
30
+ return String(entryName).replace(/__/g, '/')
31
+ }
32
+
33
+ /**
34
+ * 创建一个备份组:记录指定分支(默认当前 HEAD)的提交,可选记录未提交改动。
35
+ * 输入为 GitService 与 { reason, branches, includeWorktree, remoteBranches },
36
+ * 输出 { id, reason, entries };没有任何可备份内容时 entries 为空。
37
+ * 关键假设:备份失败不应阻断主流程,只输出警告。
38
+ */
39
+ export async function createBackup(git, options = {}) {
40
+ const id = buildBackupId(options.date)
41
+ const reason = String(options.reason || 'manual').replace(/\s+/g, ' ').trim()
42
+ const entries = []
43
+ const branches = Array.isArray(options.branches) && options.branches.length ? options.branches : [null]
44
+
45
+ try {
46
+ for (const branchName of branches) {
47
+ const ref = branchName || 'HEAD'
48
+ const sha = await git.revParse(ref)
49
+ if (!sha) {
50
+ continue
51
+ }
52
+ const label = branchName || (await git.getCurrentBranch()) || 'HEAD'
53
+ const refName = `${BACKUP_REF_PREFIX}/${id}/${encodeBranch(label)}`
54
+ await git.updateRef(refName, sha, `icode backup: ${reason}`)
55
+ entries.push({ ref: refName, branch: label, sha })
56
+ }
57
+
58
+ for (const branchName of options.remoteBranches || []) {
59
+ const sha = await git.getRemoteBranchSha(branchName)
60
+ if (!sha) {
61
+ continue
62
+ }
63
+ const label = `remote-${git.remote}-${branchName}`
64
+ const refName = `${BACKUP_REF_PREFIX}/${id}/${encodeBranch(label)}`
65
+ await git.updateRef(refName, sha, `icode backup: ${reason}`)
66
+ entries.push({ ref: refName, branch: `${git.remote}/${branchName}`, sha })
67
+ }
68
+
69
+ if (options.includeWorktree) {
70
+ // stash create 只生成提交对象,不改动工作区;未跟踪文件不包含在内。
71
+ const stashSha = await git.stashCreate(`icode backup: ${reason}`)
72
+ if (stashSha) {
73
+ const refName = `${BACKUP_REF_PREFIX}/${id}/${WORKTREE_ENTRY}`
74
+ await git.updateRef(refName, stashSha, `icode backup: ${reason}`)
75
+ entries.push({ ref: refName, branch: '(未提交改动)', sha: stashSha, worktree: true })
76
+ }
77
+ }
78
+
79
+ if (entries.length) {
80
+ // 用一个 note ref 记录原因,方便 rescue list 展示。
81
+ await writeBackupReason(git, id, reason)
82
+ await pruneBackups(git, { keep: options.keep || DEFAULT_KEEP_GROUPS })
83
+ if (!options.silent) {
84
+ logger.info(`已创建恢复点 ${logger.color('green', id)}(${reason}),可用 \`icode rescue\` 找回。`)
85
+ }
86
+ }
87
+ } catch (error) {
88
+ logger.warn(`创建恢复点失败(不影响本次操作): ${error.message}`)
89
+ }
90
+
91
+ return { id, reason, entries }
92
+ }
93
+
94
+ // 原因写成一个 blob 对象挂在 _meta ref 上,不依赖额外文件。
95
+ async function writeBackupReason(git, id, reason) {
96
+ const result = await git.exec(['hash-object', '-w', '--stdin'], {
97
+ allowFailure: true,
98
+ input: reason
99
+ })
100
+ const blobSha = String(result.stdout || '').trim()
101
+ if (result.exitCode === 0 && blobSha) {
102
+ await git.exec(['update-ref', `${BACKUP_REF_PREFIX}/${id}/_meta`, blobSha], { allowFailure: true })
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 列出备份组,按时间倒序。
108
+ * 输出 [{ id, reason, entries: [{ ref, branch, sha, worktree }] }]。
109
+ */
110
+ export async function listBackups(git) {
111
+ const lines = await git.listRefs(BACKUP_REF_PREFIX, '%(refname)%09%(objectname)%09%(objecttype)')
112
+ const groups = new Map()
113
+
114
+ for (const line of lines) {
115
+ const [refName = '', sha = '', type = ''] = line.split('\t')
116
+ const rest = refName.slice(BACKUP_REF_PREFIX.length + 1)
117
+ const slashIndex = rest.indexOf('/')
118
+ if (slashIndex === -1) {
119
+ continue
120
+ }
121
+ const id = rest.slice(0, slashIndex)
122
+ const entryName = rest.slice(slashIndex + 1)
123
+ if (!groups.has(id)) {
124
+ groups.set(id, { id, reason: '', entries: [] })
125
+ }
126
+ const group = groups.get(id)
127
+
128
+ if (entryName === '_meta') {
129
+ if (type === 'blob') {
130
+ const content = await git.exec(['cat-file', '-p', sha], { allowFailure: true })
131
+ group.reason = String(content.stdout || '').trim()
132
+ }
133
+ continue
134
+ }
135
+
136
+ group.entries.push({
137
+ ref: refName,
138
+ branch: entryName === WORKTREE_ENTRY ? '(未提交改动)' : decodeBranch(entryName),
139
+ sha,
140
+ shortSha: sha.slice(0, 8),
141
+ worktree: entryName === WORKTREE_ENTRY
142
+ })
143
+ }
144
+
145
+ return [...groups.values()]
146
+ .filter((group) => group.entries.length)
147
+ .sort((a, b) => (a.id < b.id ? 1 : -1))
148
+ }
149
+
150
+ /**
151
+ * 删除旧备份,只保留最近 keep 组。
152
+ * 输入为 GitService 与 { keep },输出删除的组 id 列表。
153
+ */
154
+ export async function pruneBackups(git, options = {}) {
155
+ const keep = Number.isInteger(options.keep) && options.keep >= 0 ? options.keep : DEFAULT_KEEP_GROUPS
156
+ const lines = await git.listRefs(BACKUP_REF_PREFIX, '%(refname)')
157
+ const ids = [...new Set(lines.map((refName) => refName.slice(BACKUP_REF_PREFIX.length + 1).split('/')[0]).filter(Boolean))]
158
+ .sort()
159
+ .reverse()
160
+ const removed = ids.slice(keep)
161
+
162
+ for (const refName of lines) {
163
+ const id = refName.slice(BACKUP_REF_PREFIX.length + 1).split('/')[0]
164
+ if (removed.includes(id)) {
165
+ await git.deleteRef(refName)
166
+ }
167
+ }
168
+
169
+ return removed
170
+ }
@@ -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
+ }