@icode-js/icode 3.0.4 → 3.2.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -5
  3. package/bin/icode.js +4 -4
  4. package/package.json +1 -1
  5. package/src/cli.js +60 -23
  6. package/src/commands/ai.js +4 -4
  7. package/src/commands/checkout.js +2 -2
  8. package/src/commands/clean.js +5 -2
  9. package/src/commands/codereview.js +5 -2
  10. package/src/commands/completion.js +93 -0
  11. package/src/commands/config.js +123 -10
  12. package/src/commands/doctor.js +80 -0
  13. package/src/commands/explain.js +6 -3
  14. package/src/commands/help.js +17 -0
  15. package/src/commands/info.js +5 -2
  16. package/src/commands/migrate.js +40 -3
  17. package/src/commands/push.js +72 -24
  18. package/src/commands/ready.js +110 -0
  19. package/src/commands/save.js +98 -0
  20. package/src/commands/sync.js +2 -2
  21. package/src/commands/tag.js +2 -2
  22. package/src/commands/undo.js +73 -10
  23. package/src/core/{ai-client.js → ai/client.js} +3 -3
  24. package/src/core/{ai-config.js → ai/config.js} +2 -2
  25. package/src/core/ai/diff-prompt.js +204 -0
  26. package/src/core/{ai-diff-range.js → ai/diff-range.js} +2 -2
  27. package/src/core/{args.js → cli/args.js} +23 -2
  28. package/src/core/cli/completion.js +720 -0
  29. package/src/core/cli/update-notifier.js +138 -0
  30. package/src/core/{shell.js → commands/shell.js} +13 -1
  31. package/src/core/{config-store.js → config/store.js} +53 -3
  32. package/src/core/{git-context.js → git/context.js} +24 -10
  33. package/src/core/git/preconditions.js +88 -0
  34. package/src/core/{git-service.js → git/service.js} +195 -5
  35. package/src/core/remote-merge/client.js +42 -0
  36. package/src/core/remote-merge/providers/gitlab.js +596 -0
  37. package/src/core/remote-merge/shared.js +49 -0
  38. package/src/core/tools/interactive.js +368 -0
  39. package/src/core/{loading.js → tools/loading.js} +5 -1
  40. package/src/core/{logger.js → tools/logger.js} +5 -0
  41. package/src/workflows/ai-codereview-workflow.js +18 -6
  42. package/src/workflows/ai-commit-workflow.js +20 -12
  43. package/src/workflows/ai-conflict-workflow.js +4 -4
  44. package/src/workflows/ai-explain-workflow.js +4 -4
  45. package/src/workflows/ai-risk-review-workflow.js +1 -1
  46. package/src/workflows/checkout-workflow.js +17 -5
  47. package/src/workflows/clean-workflow.js +92 -75
  48. package/src/workflows/doctor-workflow.js +131 -0
  49. package/src/workflows/info-workflow.js +4 -4
  50. package/src/workflows/migrate-workflow.js +269 -62
  51. package/src/workflows/push-workflow.js +475 -58
  52. package/src/workflows/ready-workflow.js +278 -0
  53. package/src/workflows/rollback-workflow.js +24 -9
  54. package/src/workflows/save-workflow.js +152 -0
  55. package/src/workflows/sync-workflow.js +22 -6
  56. package/src/workflows/tag-workflow.js +4 -4
  57. package/src/workflows/undo-workflow.js +37 -12
  58. package/src/core/prompts.js +0 -152
  59. /package/src/core/{ai-commit-summary.js → ai/commit-summary.js} +0 -0
  60. /package/src/core/{commit-conventions.js → git/commit-conventions.js} +0 -0
  61. /package/src/core/{hook-diagnostics.js → git/hook-diagnostics.js} +0 -0
@@ -0,0 +1,138 @@
1
+ import { createRequire } from 'node:module'
2
+ import { runCommand } from '../commands/shell.js'
3
+ import { logger } from '../tools/logger.js'
4
+
5
+ const require = createRequire(import.meta.url)
6
+ const packageJson = require('../../../package.json')
7
+
8
+ /**
9
+ * 规范化版本号,输出仅包含数字段的 semver 主体数组。
10
+ * 输入为字符串版本号,输出为数字数组;若格式不合法则返回 null。
11
+ */
12
+ function normalizeVersionSegments(version) {
13
+ const normalized = String(version || '').trim()
14
+ if (!normalized) {
15
+ return null
16
+ }
17
+
18
+ const coreVersion = normalized.split('-')[0]
19
+ if (!/^\d+(?:\.\d+)*$/.test(coreVersion)) {
20
+ return null
21
+ }
22
+
23
+ return coreVersion.split('.').map((segment) => Number(segment))
24
+ }
25
+
26
+ /**
27
+ * 解析 npm view 返回的版本文本,兼容 JSON 字符串与纯文本两种输出。
28
+ * 输入为命令 stdout,输出为去空白后的版本号字符串。
29
+ */
30
+ function parsePublishedVersion(stdout) {
31
+ const normalized = String(stdout || '').trim()
32
+ if (!normalized) {
33
+ return ''
34
+ }
35
+
36
+ try {
37
+ const parsed = JSON.parse(normalized)
38
+ if (typeof parsed === 'string') {
39
+ return parsed.trim()
40
+ }
41
+ } catch {
42
+ // npm 在异常场景下也可能输出纯文本版本号,这里保留原始文本继续解析。
43
+ }
44
+
45
+ return normalized.replace(/^"|"$/g, '').trim()
46
+ }
47
+
48
+ /**
49
+ * 比较两个版本号,判断远端版本是否严格高于当前版本。
50
+ * 输入为当前版本和远端版本,输出为布尔值。
51
+ */
52
+ export function hasNewerVersion(currentVersion, latestVersion) {
53
+ const currentSegments = normalizeVersionSegments(currentVersion)
54
+ const latestSegments = normalizeVersionSegments(latestVersion)
55
+
56
+ if (!currentSegments || !latestSegments) {
57
+ return false
58
+ }
59
+
60
+ const maxLength = Math.max(currentSegments.length, latestSegments.length)
61
+ for (let index = 0; index < maxLength; index += 1) {
62
+ const currentValue = currentSegments[index] || 0
63
+ const latestValue = latestSegments[index] || 0
64
+
65
+ if (latestValue > currentValue) {
66
+ return true
67
+ }
68
+
69
+ if (latestValue < currentValue) {
70
+ return false
71
+ }
72
+ }
73
+
74
+ return false
75
+ }
76
+
77
+ /**
78
+ * 查询 npm registry 上当前包的最新发布版本。
79
+ * 输入为可选 runner/packageName,输出为最新版本号;失败时返回空字符串。
80
+ */
81
+ export async function fetchLatestPublishedVersion(options = {}) {
82
+ const packageName = options.packageName || packageJson.name
83
+ const runner = options.runner || runCommand
84
+
85
+ try {
86
+ const result = await runner('npm', ['view', packageName, 'version', '--json'], {
87
+ allowFailure: true,
88
+ env: {
89
+ ...process.env,
90
+ NO_UPDATE_NOTIFIER: '1',
91
+ npm_config_update_notifier: 'false',
92
+ npm_config_loglevel: 'silent'
93
+ }
94
+ })
95
+
96
+ if (result.exitCode !== 0) {
97
+ return ''
98
+ }
99
+
100
+ return parsePublishedVersion(result.stdout)
101
+ } catch {
102
+ return ''
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 在命令成功结束后提示可用更新版本。
108
+ * 输入为可选 runner/warn/packageName/currentVersion,输出为通知结果对象。
109
+ */
110
+ export async function notifyIfCliUpdateAvailable(options = {}) {
111
+ const packageName = options.packageName || packageJson.name
112
+ const currentVersion = options.currentVersion || packageJson.version
113
+ const warn = options.warn || logger.warn
114
+
115
+ const latestVersion = await fetchLatestPublishedVersion({
116
+ runner: options.runner,
117
+ packageName
118
+ })
119
+
120
+ if (!latestVersion || !hasNewerVersion(currentVersion, latestVersion)) {
121
+ return {
122
+ notified: false,
123
+ packageName,
124
+ currentVersion,
125
+ latestVersion
126
+ }
127
+ }
128
+
129
+ warn(`发现新版本可用: ${currentVersion} -> ${latestVersion}`)
130
+ warn(`更新命令: npm i -g ${packageName}@latest`)
131
+
132
+ return {
133
+ notified: true,
134
+ packageName,
135
+ currentVersion,
136
+ latestVersion
137
+ }
138
+ }
@@ -1,10 +1,18 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { IcodeError } from './errors.js'
2
+ import { IcodeError } from '../errors.js'
3
3
 
4
+ /**
5
+ * 把命令与参数拼成可读字符串,供日志与报错复用。
6
+ * 输入为命令名与参数数组,输出为单行命令文本。
7
+ */
4
8
  function stringifyCommand(command, args) {
5
9
  return [command, ...args].join(' ')
6
10
  }
7
11
 
12
+ /**
13
+ * 组合命令失败消息,尽量附带 stderr/stdout 便于定位问题。
14
+ * 输入为命令信息与执行结果,输出为最终错误文案。
15
+ */
8
16
  function formatFailureMessage(command, args, result) {
9
17
  const baseMessage = `命令执行失败(${result.exitCode}): ${stringifyCommand(command, args)}`
10
18
  const detailLines = [result.stderr, result.stdout].map((item) => item.trim()).filter(Boolean)
@@ -16,6 +24,10 @@ function formatFailureMessage(command, args, result) {
16
24
  return `${baseMessage}\n${detailLines.join('\n')}`
17
25
  }
18
26
 
27
+ /**
28
+ * 执行外部命令并收集 stdout/stderr;允许按需把非零退出码作为普通结果返回。
29
+ * 输入为命令名、参数数组与执行选项,输出为标准化执行结果。
30
+ */
19
31
  export async function runCommand(command, args = [], options = {}) {
20
32
  const { cwd = process.cwd(), env = process.env, allowFailure = false } = options
21
33
 
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
- import { IcodeError } from './errors.js'
4
+ import { IcodeError } from '../errors.js'
5
5
 
6
6
  const DEFAULT_CONFIG = {
7
7
  version: 1,
@@ -9,6 +9,12 @@ const DEFAULT_CONFIG = {
9
9
  repoMode: 'auto',
10
10
  defaultMainBranches: ['main', 'master']
11
11
  },
12
+ platforms: {
13
+ remoteMerge: {
14
+ provider: '',
15
+ apiKey: ''
16
+ }
17
+ },
12
18
  ai: {
13
19
  activeProfile: '',
14
20
  profiles: {},
@@ -74,6 +80,14 @@ export function readConfig() {
74
80
  ...cloneDefault().defaults,
75
81
  ...(parsed.defaults || {})
76
82
  },
83
+ platforms: {
84
+ ...cloneDefault().platforms,
85
+ ...(parsed.platforms || {}),
86
+ remoteMerge: {
87
+ ...cloneDefault().platforms.remoteMerge,
88
+ ...(parsed.platforms?.remoteMerge || {})
89
+ }
90
+ },
77
91
  ai: {
78
92
  ...cloneDefault().ai,
79
93
  ...(parsed.ai || {}),
@@ -176,8 +190,12 @@ function normalizeRepoKey(repoRootPath) {
176
190
  export function getRepoPolicy(repoRootPath) {
177
191
  const key = normalizeRepoKey(repoRootPath)
178
192
  const config = readConfig()
179
- return config.repositories[key] || {
180
- protectedBranches: []
193
+ return {
194
+ protectedBranches: [],
195
+ remoteMerge: {
196
+ enabled: true
197
+ },
198
+ ...(config.repositories[key] || {})
181
199
  }
182
200
  }
183
201
 
@@ -186,9 +204,41 @@ export function setRepoPolicy(repoRootPath, policy) {
186
204
  const config = readConfig()
187
205
  config.repositories[key] = {
188
206
  protectedBranches: [],
207
+ remoteMerge: {
208
+ enabled: true
209
+ },
189
210
  ...(config.repositories[key] || {}),
190
211
  ...policy
191
212
  }
192
213
  writeConfig(config)
193
214
  return config.repositories[key]
194
215
  }
216
+
217
+ export function getPlatformConfig(platformName) {
218
+ const config = readConfig()
219
+ const name = String(platformName || '').trim()
220
+ if (!name) {
221
+ return {}
222
+ }
223
+
224
+ return config.platforms?.[name] || {}
225
+ }
226
+
227
+ export function setPlatformConfig(platformName, value) {
228
+ const config = readConfig()
229
+ const name = String(platformName || '').trim()
230
+ if (!name) {
231
+ throw new IcodeError('平台名称不能为空', { code: 'CONFIG_PLATFORM_EMPTY', exitCode: 2 })
232
+ }
233
+
234
+ config.platforms = {
235
+ ...(config.platforms || {}),
236
+ [name]: {
237
+ ...(config.platforms?.[name] || {}),
238
+ ...(value || {})
239
+ }
240
+ }
241
+
242
+ writeConfig(config)
243
+ return config.platforms[name]
244
+ }
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { readConfig } from './config-store.js'
4
- import { IcodeError } from './errors.js'
5
- import { runCommand } from './shell.js'
3
+ import { runCommand } from '../commands/shell.js'
4
+ import { readConfig } from '../config/store.js'
5
+ import { IcodeError } from '../errors.js'
6
6
 
7
7
  function cleanOutput(text) {
8
8
  return (text || '').trim()
@@ -13,6 +13,22 @@ async function commandOutput(cwd, args, allowFailure = false) {
13
13
  return result
14
14
  }
15
15
 
16
+ /**
17
+ * 确认当前目录属于 Git 仓库。
18
+ * 输入为工作目录,输出为空;不在仓库中时直接抛错,不隐式初始化仓库。
19
+ */
20
+ async function ensureGitRepository(cwd) {
21
+ const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
22
+ if (inside.exitCode === 0 && cleanOutput(inside.stdout) === 'true') {
23
+ return
24
+ }
25
+
26
+ throw new IcodeError('当前目录不在 Git 仓库中。请先进入已有仓库,或手动执行 git init。', {
27
+ code: 'NOT_IN_GIT_REPO',
28
+ exitCode: 2
29
+ })
30
+ }
31
+
16
32
  async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', 'master']) {
17
33
  const headRefResult = await commandOutput(topLevelPath, ['symbolic-ref', 'refs/remotes/origin/HEAD'], true)
18
34
  if (headRefResult.exitCode === 0) {
@@ -38,19 +54,17 @@ async function detectDefaultBranch(topLevelPath, fallbackCandidates = ['main', '
38
54
  return 'main'
39
55
  }
40
56
 
57
+ /**
58
+ * 解析当前 Git 仓库上下文。
59
+ * 输入为 cwd/repoMode 等选项,输出仓库根路径、git 目录、当前分支和默认分支等稳定上下文。
60
+ */
41
61
  export async function resolveGitContext(options = {}) {
42
62
  const cwd = path.resolve(options.cwd || process.cwd())
43
63
  const config = readConfig()
44
64
  const configRepoMode = config.defaults?.repoMode || 'auto'
45
65
  const repoMode = options.repoMode || configRepoMode
46
66
 
47
- const inside = await commandOutput(cwd, ['rev-parse', '--is-inside-work-tree'], true)
48
- if (inside.exitCode !== 0 || cleanOutput(inside.stdout) !== 'true') {
49
- throw new IcodeError('当前目录不在 Git 仓库中。', {
50
- code: 'NOT_IN_GIT_REPO',
51
- exitCode: 2
52
- })
53
- }
67
+ await ensureGitRepository(cwd)
54
68
 
55
69
  const topLevelPath = cleanOutput((await commandOutput(cwd, ['rev-parse', '--show-toplevel'])).stdout)
56
70
  const gitDirRaw = cleanOutput((await commandOutput(cwd, ['rev-parse', '--git-dir'])).stdout)
@@ -0,0 +1,88 @@
1
+ import { IcodeError } from '../errors.js'
2
+
3
+ /**
4
+ * 读取当前仓库执行状态。
5
+ * 输入为 GitService 实例,输出当前分支、未完成操作和工作区改动摘要。
6
+ */
7
+ export async function readGitSafetyState(git) {
8
+ const [currentBranch, operation, changes] = await Promise.all([
9
+ git.getCurrentBranch(),
10
+ git.getInProgressOperation(),
11
+ git.getStatusSummary()
12
+ ])
13
+
14
+ return {
15
+ currentBranch,
16
+ operation,
17
+ changes
18
+ }
19
+ }
20
+
21
+ /**
22
+ * 断言当前处于普通分支。
23
+ * 输入为分支名、命令名和错误码,输出为空;不满足时抛出 IcodeError。
24
+ */
25
+ export function assertOnBranch(currentBranch, commandName, code) {
26
+ if (currentBranch) {
27
+ return
28
+ }
29
+
30
+ throw new IcodeError(`当前不在普通分支上,${commandName} 已停止。`, {
31
+ code,
32
+ exitCode: 2
33
+ })
34
+ }
35
+
36
+ /**
37
+ * 断言不存在未完成的 Git 操作。
38
+ * 输入为操作名、命令名和错误码,输出为空;检测到 merge/rebase/cherry-pick/revert 时抛错。
39
+ */
40
+ export function assertNoPendingOperation(operation, commandName, code) {
41
+ if (!operation) {
42
+ return
43
+ }
44
+
45
+ throw new IcodeError(`检测到未完成的 Git 操作: ${operation}。请先完成或中止该操作,${commandName} 已停止。`, {
46
+ code,
47
+ exitCode: 2,
48
+ meta: {
49
+ operation
50
+ }
51
+ })
52
+ }
53
+
54
+ /**
55
+ * 断言不存在冲突文件。
56
+ * 输入为改动摘要、命令名和错误码,输出为空;存在冲突文件时抛错。
57
+ */
58
+ export function assertNoConflictedFiles(changes, commandName, code) {
59
+ if (!changes || changes.conflicted === 0) {
60
+ return
61
+ }
62
+
63
+ throw new IcodeError(`存在 ${changes.conflicted} 个冲突文件,${commandName} 已停止。`, {
64
+ code,
65
+ exitCode: 2,
66
+ meta: {
67
+ changes
68
+ }
69
+ })
70
+ }
71
+
72
+ /**
73
+ * 断言工作区干净。
74
+ * 输入为改动摘要、命令名和错误码,输出为空;存在任何未提交改动时抛错。
75
+ */
76
+ export function assertCleanWorktree(changes, commandName, code) {
77
+ if (changes && changes.clean) {
78
+ return
79
+ }
80
+
81
+ throw new IcodeError(`工作区存在未提交改动,${commandName} 已停止。请先执行 save 或手动清理。`, {
82
+ code,
83
+ exitCode: 2,
84
+ meta: {
85
+ changes
86
+ }
87
+ })
88
+ }
@@ -1,14 +1,101 @@
1
- import { IcodeError } from './errors.js'
2
- import { buildHookHint, detectHookFailure } from './hook-diagnostics.js'
3
- import { logger } from './logger.js'
4
- import { runCommand } from './shell.js'
5
1
  import fs from 'node:fs'
6
2
  import path from 'node:path'
3
+ import { buildHookHint, detectHookFailure } from './hook-diagnostics.js'
4
+ import { runCommand } from '../commands/shell.js'
5
+ import { IcodeError } from '../errors.js'
6
+ import { logger } from '../tools/logger.js'
7
7
 
8
8
  function cleanOutput(text) {
9
9
  return (text || '').trim()
10
10
  }
11
11
 
12
+ /**
13
+ * 解析 porcelain v1 单行状态。
14
+ * 输入为 `git status --porcelain` 的一行文本,输出为索引区状态、工作区状态和文件路径。
15
+ * 关键假设:本项目只需要稳定分类改动,不在这里还原 rename 的源路径细节。
16
+ */
17
+ function parsePorcelainLine(line) {
18
+ const indexStatus = line.slice(0, 1)
19
+ const worktreeStatus = line.slice(1, 2)
20
+ const rawPath = line.slice(3).trim()
21
+ const separatorIndex = rawPath.indexOf(' -> ')
22
+ const filePath = separatorIndex === -1 ? rawPath : rawPath.slice(separatorIndex + 4)
23
+
24
+ return {
25
+ raw: line,
26
+ indexStatus,
27
+ worktreeStatus,
28
+ filePath
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 汇总 porcelain 状态行。
34
+ * 输入为状态行数组,输出为 staged/unstaged/untracked/conflicted 计数和文件明细。
35
+ * 关键假设:`??` 表示未跟踪文件,`U` 或双边冲突组合表示未解决冲突。
36
+ */
37
+ function summarizeStatusEntries(entries) {
38
+ const files = []
39
+ const summary = {
40
+ staged: 0,
41
+ unstaged: 0,
42
+ untracked: 0,
43
+ conflicted: 0,
44
+ files
45
+ }
46
+
47
+ for (const entry of entries) {
48
+ const isUntracked = entry.indexStatus === '?' && entry.worktreeStatus === '?'
49
+ const isConflicted = entry.indexStatus === 'U' ||
50
+ entry.worktreeStatus === 'U' ||
51
+ (entry.indexStatus === 'A' && entry.worktreeStatus === 'A') ||
52
+ (entry.indexStatus === 'D' && entry.worktreeStatus === 'D')
53
+ const hasStaged = !isUntracked && entry.indexStatus !== ' ' && entry.indexStatus !== '?' && !isConflicted
54
+ const hasUnstaged = !isUntracked && entry.worktreeStatus !== ' ' && entry.worktreeStatus !== '?' && !isConflicted
55
+
56
+ if (isUntracked) {
57
+ summary.untracked += 1
58
+ }
59
+ if (isConflicted) {
60
+ summary.conflicted += 1
61
+ }
62
+ if (hasStaged) {
63
+ summary.staged += 1
64
+ }
65
+ if (hasUnstaged) {
66
+ summary.unstaged += 1
67
+ }
68
+
69
+ files.push({
70
+ path: entry.filePath,
71
+ indexStatus: entry.indexStatus,
72
+ worktreeStatus: entry.worktreeStatus,
73
+ staged: hasStaged,
74
+ unstaged: hasUnstaged,
75
+ untracked: isUntracked,
76
+ conflicted: isConflicted
77
+ })
78
+ }
79
+
80
+ return summary
81
+ }
82
+
83
+ function parseGitLogRecords(output) {
84
+ return output
85
+ .split('\x1e')
86
+ .map((record) => record.trim())
87
+ .filter(Boolean)
88
+ .map((record) => {
89
+ const [hash = '', subject = '', body = ''] = record.split('\x1f')
90
+ return {
91
+ hash: hash.trim(),
92
+ subject: subject.trim(),
93
+ body: body.trim()
94
+ }
95
+ })
96
+ .filter((item) => item.hash && item.subject)
97
+ }
98
+
12
99
  export class GitService {
13
100
  constructor(context) {
14
101
  this.context = context
@@ -60,6 +147,10 @@ export class GitService {
60
147
  return 'rebase'
61
148
  }
62
149
 
150
+ if (this.operationFileExists('MERGE_HEAD')) {
151
+ return 'merge'
152
+ }
153
+
63
154
  return null
64
155
  }
65
156
 
@@ -77,6 +168,13 @@ export class GitService {
77
168
  return result.exitCode === 0
78
169
  }
79
170
 
171
+ async getRemoteUrl(remoteName = 'origin') {
172
+ const result = await this.exec(['remote', 'get-url', remoteName], {
173
+ allowFailure: true
174
+ })
175
+ return cleanOutput(result.stdout)
176
+ }
177
+
80
178
  async listLocalBranches() {
81
179
  const result = await this.exec(['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
82
180
  return result.stdout
@@ -127,7 +225,9 @@ export class GitService {
127
225
  async pull(branchName, options = {}) {
128
226
  const args = ['pull', 'origin', branchName]
129
227
 
130
- if (options.noRebase !== false) {
228
+ if (options.rebase) {
229
+ args.push('--rebase')
230
+ } else if (options.noRebase !== false) {
131
231
  args.push('--no-rebase')
132
232
  }
133
233
 
@@ -153,6 +253,16 @@ export class GitService {
153
253
  await this.exec(args)
154
254
  }
155
255
 
256
+ // 继续已解决冲突的 merge 操作,供 undo recover 统一恢复合并现场。
257
+ async mergeContinue() {
258
+ await this.exec(['merge', '--continue'])
259
+ }
260
+
261
+ // 中止未完成的 merge 操作,保留给显式 recover abort 使用,不自动清理用户现场。
262
+ async mergeAbort() {
263
+ await this.exec(['merge', '--abort'], { allowFailure: true })
264
+ }
265
+
156
266
  async rebase(fromRef) {
157
267
  await this.exec(['rebase', fromRef])
158
268
  }
@@ -167,12 +277,75 @@ export class GitService {
167
277
  return result.stdout
168
278
  }
169
279
 
280
+ /**
281
+ * 读取并解析工作区状态。
282
+ * 输入为空,输出为结构化改动摘要,供 ready/save 等场景命令复用。
283
+ */
284
+ async getStatusSummary() {
285
+ const output = await this.statusPorcelain()
286
+ const entries = output
287
+ .split('\n')
288
+ .map((line) => line.trimEnd())
289
+ .filter(Boolean)
290
+ .map(parsePorcelainLine)
291
+
292
+ return {
293
+ clean: entries.length === 0,
294
+ total: entries.length,
295
+ ...summarizeStatusEntries(entries)
296
+ }
297
+ }
298
+
170
299
  async hasChanges() {
171
300
  // 只要 porcelain 有输出,就说明暂存区、工作区或未跟踪文件存在改动。
172
301
  const output = await this.statusPorcelain()
173
302
  return cleanOutput(output).length > 0
174
303
  }
175
304
 
305
+ /**
306
+ * 读取当前分支的 upstream ref。
307
+ * 输入为空,输出为 upstream 短 ref;没有 upstream 时返回空字符串。
308
+ */
309
+ async getUpstreamRef() {
310
+ const result = await this.exec(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
311
+ allowFailure: true
312
+ })
313
+
314
+ if (result.exitCode !== 0) {
315
+ return ''
316
+ }
317
+
318
+ return cleanOutput(result.stdout)
319
+ }
320
+
321
+ /**
322
+ * 读取当前 HEAD 与 upstream 的领先/落后数量。
323
+ * 输入为空,输出 upstream、ahead、behind;没有 upstream 时 explicitly 标记 configured=false。
324
+ */
325
+ async getUpstreamStatus() {
326
+ const upstream = await this.getUpstreamRef()
327
+ if (!upstream) {
328
+ return {
329
+ configured: false,
330
+ upstream: '',
331
+ ahead: 0,
332
+ behind: 0
333
+ }
334
+ }
335
+
336
+ const result = await this.exec(['rev-list', '--left-right', '--count', `${upstream}...HEAD`])
337
+ const [behindRaw = '0', aheadRaw = '0'] = cleanOutput(result.stdout).split(/\s+/)
338
+ const behind = Number.parseInt(behindRaw, 10)
339
+ const ahead = Number.parseInt(aheadRaw, 10)
340
+
341
+ return {
342
+ configured: true,
343
+ upstream,
344
+ ahead: Number.isInteger(ahead) ? ahead : 0,
345
+ behind: Number.isInteger(behind) ? behind : 0
346
+ }
347
+ }
348
+
176
349
  // 列出未被忽略的未跟踪文件,供 AI 工作流补全新文件 diff。
177
350
  async listUntrackedFiles() {
178
351
  const result = await this.exec(['ls-files', '--others', '--exclude-standard'])
@@ -431,6 +604,23 @@ export class GitService {
431
604
  .filter(Boolean)
432
605
  }
433
606
 
607
+ // 读取指定 range 内的提交信息,返回按时间顺序排列的 hash/subject/body 列表。
608
+ async listCommitMessages(rangeSpec) {
609
+ const result = await this.exec(['log', '--reverse', '--format=%H%x1f%s%x1f%b%x1e', rangeSpec], {
610
+ allowFailure: true
611
+ })
612
+ return parseGitLogRecords(result.stdout)
613
+ }
614
+
615
+ // 读取单个 ref 的最新提交信息,供无法从 range 提取提交时回退使用。
616
+ async getCommitMessage(ref = 'HEAD') {
617
+ const result = await this.exec(['log', '-1', '--format=%H%x1f%s%x1f%b%x1e', ref], {
618
+ allowFailure: true
619
+ })
620
+ const commits = parseGitLogRecords(result.stdout)
621
+ return commits[0] || null
622
+ }
623
+
434
624
  async cherryPick(commits) {
435
625
  const normalized = Array.isArray(commits) ? commits : [commits]
436
626
  const list = normalized.map((item) => item.trim()).filter(Boolean)