wukong-gitlog-cli 1.0.38 → 1.0.40
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/.eslintrc +1 -0
- package/.prettierrc +2 -1
- package/CHANGELOG.md +103 -0
- package/README.md +93 -173
- package/README.zh-CN.md +85 -137
- package/doc//347/233/256/345/275/225/347/273/223/346/236/204.md +2871 -0
- package/package.json +33 -29
- package/rc/.wukonggitlogrc +53 -0
- package/scripts/compareHourlyCounts.mjs +42 -0
- package/scripts/compareLatest.mjs +106 -0
- package/src/app/analyzeAction.mjs +120 -0
- package/src/app/exportAction.mjs +215 -0
- package/src/app/exportActionProgress.mjs +37 -0
- package/src/app/helpers.mjs +292 -0
- package/src/app/initAction.mjs +110 -0
- package/src/app/initActionWithTemp.mjs +192 -0
- package/src/app/journalAction.mjs +117 -0
- package/src/app/overtimeAction.mjs +100 -0
- package/src/app/runProfileEnd.mjs +0 -0
- package/src/app/serveAction.mjs +73 -0
- package/src/app/versionAction.mjs +7 -0
- package/src/cli/defineOptions.mjs +209 -0
- package/src/cli/index.mjs +0 -0
- package/src/cli/parseOptions.mjs +126 -8
- package/src/constants/index.mjs +16 -2
- package/src/domain/author/analyze.mjs +6 -0
- package/src/domain/author/map.mjs +0 -0
- package/src/domain/export/exportAuthor.mjs +28 -0
- package/src/domain/export/exportAuthorChanges.mjs +27 -0
- package/src/domain/export/exportAuthorChangesJson.mjs +31 -0
- package/src/domain/export/exportByMonth.mjs +157 -0
- package/src/domain/export/exportByWeek.mjs +121 -0
- package/src/domain/export/exportCommits.mjs +26 -0
- package/src/domain/export/exportCommitsExcel.mjs +45 -0
- package/src/domain/export/exportCommitsJson.mjs +31 -0
- package/src/domain/export/index.mjs +91 -0
- package/src/domain/git/ensureGitAvailable.mjs +66 -0
- package/src/domain/git/ensureGitRepo.mjs +41 -0
- package/src/domain/git/getGitFeatures.mjs +59 -0
- package/src/domain/git/getGitLogs.mjs +326 -0
- package/src/domain/git/getGitUser.mjs +44 -0
- package/src/domain/git/getRepoRoot.mjs +32 -0
- package/src/domain/git/gitCapability.mjs +119 -0
- package/src/domain/git/index.mjs +96 -0
- package/src/domain/git/resolveGerrit.mjs +102 -0
- package/src/domain/overtime/analyze.mjs +48 -0
- package/src/domain/overtime/index.mjs +3 -0
- package/src/domain/overtime/perPeriod.mjs +15 -0
- package/src/domain/overtime/render.mjs +15 -0
- package/src/i18n/index.mjs +38 -0
- package/src/i18n/resources.mjs +252 -0
- package/src/index.mjs +132 -649
- package/src/infra/cache.mjs +0 -0
- package/src/infra/configStore.mjs +128 -0
- package/src/infra/fs.mjs +0 -0
- package/src/infra/path.mjs +0 -0
- package/src/output/csv/overtime.mjs +12 -0
- package/src/output/csv.mjs +0 -0
- package/src/output/data/readData.mjs +54 -0
- package/src/output/data/writeData.mjs +145 -0
- package/src/output/excel/commits.mjs +9 -0
- package/src/output/excel/outputExcelDayReport.mjs +92 -0
- package/src/output/excel/perPeriod.mjs +24 -0
- package/src/{excel.mjs → output/excel.mjs} +3 -2
- package/src/output/index.mjs +79 -0
- package/src/output/json/overtime.mjs +9 -0
- package/src/output/tab/overtime.mjs +12 -0
- package/src/output/tab.mjs +0 -0
- package/src/output/text/commits.mjs +9 -0
- package/src/output/text/index.mjs +3 -0
- package/src/output/text/outputTxtDayReport.mjs +74 -0
- package/src/output/text/overtime.mjs +18 -0
- package/src/output/utils/getEsmJs.mjs +10 -0
- package/src/output/utils/index.mjs +14 -0
- package/src/output/utils/outputPath.mjs +19 -0
- package/src/output/utils/writeFile.mjs +10 -0
- package/src/serve/index.mjs +0 -0
- package/src/{server.mjs → serve/startServer.mjs} +21 -3
- package/src/serve/writeData.mjs +0 -0
- package/src/utils/authorNormalizer.mjs +28 -2
- package/src/utils/buildAuthorChangeStats.mjs +44 -0
- package/src/utils/deepMerge.mjs +13 -0
- package/src/utils/getPackage.mjs +11 -0
- package/src/utils/getProfileDirFile.mjs +12 -0
- package/src/utils/{file.mjs → groupRecords.mjs} +8 -9
- package/src/utils/index.mjs +5 -2
- package/src/utils/logger.mjs +28 -17
- package/src/utils/profiler.mjs +0 -101
- package/src/utils/resolve.mjs +11 -0
- package/src/utils/showVersionInfo.mjs +6 -2
- package/src/utils/time.mjs +0 -0
- package/src/utils/wait.mjs +2 -0
- package/web/app.js +3233 -260
- package/web/index.html +175 -22
- package/web/revoke/alpha1/app.js +4324 -0
- package/web/revoke/alpha1/index.html +266 -0
- package/web/revoke/app.before.js +3139 -0
- package/web/revoke/index-before.html +181 -0
- package/web/static/style.css +155 -9
- package/src/git.mjs +0 -256
- package/src/handlers/handleServe.mjs +0 -203
- package/src/lib/configStore.mjs +0 -11
- package/src/lib/memoize.mjs +0 -14
- package/src/utils/analyzeOvertimeCached.mjs +0 -7
- package/src/utils/checkUpdate.mjs +0 -130
- package/src/utils/exitWithTime.mjs +0 -17
- package/src/utils/handleSuccess.mjs +0 -9
- package/src/utils/logDev.mjs +0 -19
- package/src/utils/output.mjs +0 -26
- package/src/utils/profiler/diff.mjs +0 -26
- package/src/utils/profiler/format.mjs +0 -11
- package/src/utils/profiler/index.mjs +0 -144
- package/src/utils/profiler/trace.mjs +0 -26
- package/src/utils/time/scopeTimer.mjs +0 -37
- package/src/utils/time/timer.mjs +0 -33
- package/src/utils/time/withTimer.mjs +0 -11
- package/src/utils/timer.mjs +0 -35
- /package/src/{overtime → domain/overtime}/createOvertimeStats.mjs +0 -0
- /package/src/{overtime → domain/overtime}/overtime.mjs +0 -0
- /package/src/{json.mjs → output/json.mjs} +0 -0
- /package/src/{renderAuthorMapText.mjs → output/renderAuthorMapText.mjs} +0 -0
- /package/src/{stats-text.mjs → output/stats-text.mjs} +0 -0
- /package/src/{stats.mjs → output/stats.mjs} +0 -0
- /package/src/{text.mjs → output/text.mjs} +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检测 git 功能支持情况
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { getGitCapability } from './gitCapability.mjs'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @returns {Promise<{
|
|
13
|
+
* numstat: boolean
|
|
14
|
+
* dateIsoLocal: boolean
|
|
15
|
+
* }>}
|
|
16
|
+
*/
|
|
17
|
+
export async function getGitFeatures() {
|
|
18
|
+
await getGitCapability()
|
|
19
|
+
|
|
20
|
+
const features = {
|
|
21
|
+
numstat: false,
|
|
22
|
+
dateIsoLocal: false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* --numstat 是很早就支持的
|
|
27
|
+
* 实际跑一次最靠谱
|
|
28
|
+
*/
|
|
29
|
+
try {
|
|
30
|
+
await execFileAsync(
|
|
31
|
+
'git',
|
|
32
|
+
['log', '--numstat', '-1'],
|
|
33
|
+
{
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
maxBuffer: 1024 * 1024
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
features.numstat = false
|
|
40
|
+
} catch { /* empty */ }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* --date=iso-local 是相对较新的格式
|
|
44
|
+
*/
|
|
45
|
+
try {
|
|
46
|
+
await execFileAsync(
|
|
47
|
+
'git',
|
|
48
|
+
['log', '--date=iso-local', '-1'],
|
|
49
|
+
{
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
timeout: 5000,
|
|
52
|
+
maxBuffer: 1024 * 1024
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
features.dateIsoLocal = true
|
|
56
|
+
} catch { /* empty */ }
|
|
57
|
+
|
|
58
|
+
return features
|
|
59
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/* eslint-disable no-shadow */
|
|
2
|
+
/**
|
|
3
|
+
* 高性能 git log + numstat 获取 commit(无 shell / 无 WSL 版本)
|
|
4
|
+
*
|
|
5
|
+
* 特点:
|
|
6
|
+
* - 使用 execFile 直接调用 git(不经过 shell)
|
|
7
|
+
* - Windows / macOS / Linux 行为一致
|
|
8
|
+
* - 避免 zx / WSL / bash 相关问题
|
|
9
|
+
* - 支持大仓库(超大 stdout buffer)
|
|
10
|
+
*/
|
|
11
|
+
import dayjs from 'dayjs'
|
|
12
|
+
import { execFile } from 'node:child_process'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
|
|
15
|
+
import { createAuthorNormalizer } from '#utils/authorNormalizer.mjs'
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 获取 git commit 列表(高性能版)
|
|
21
|
+
*/
|
|
22
|
+
export async function getGitLogsFast(opts = {}) {
|
|
23
|
+
/*
|
|
24
|
+
git: { merges: true, limit: undefined },
|
|
25
|
+
period: { groupBy: 'month', since: '2026-12-01', until: '2026-12-06' },
|
|
26
|
+
worktime: {
|
|
27
|
+
country: 'CN',
|
|
28
|
+
start: 9,
|
|
29
|
+
end: 18,
|
|
30
|
+
lunch: { start: 12, end: 14 },
|
|
31
|
+
overnightCutoff: 6
|
|
32
|
+
},
|
|
33
|
+
output: {
|
|
34
|
+
out: 'commits',
|
|
35
|
+
dir: 'output-wukong',
|
|
36
|
+
formats: 'text',
|
|
37
|
+
perPeriod: { enabled: true, excelMode: 'sheets', formats: [] }
|
|
38
|
+
},
|
|
39
|
+
author: { include: [ '杨琼,王欢庆' ] },
|
|
40
|
+
overtime: false,
|
|
41
|
+
gerrit: { prefix: undefined, api: undefined, auth: undefined },
|
|
42
|
+
serve: { port: 3000 },
|
|
43
|
+
profile: {
|
|
44
|
+
enabled: undefined,
|
|
45
|
+
flame: true,
|
|
46
|
+
traceFile: 'trace.json',
|
|
47
|
+
hotThreshold: 0.8,
|
|
48
|
+
diffThreshold: 0.2,
|
|
49
|
+
failOnHot: false,
|
|
50
|
+
diffBaseFile: 'baseline.json'
|
|
51
|
+
}
|
|
52
|
+
*/
|
|
53
|
+
const { git = {}, period = {}, author, email } = opts
|
|
54
|
+
// 在运行时根据传入的 opts.authorAliases(或用户配置)创建 normalizer
|
|
55
|
+
const normalizer = createAuthorNormalizer(opts.authorAliases || {})
|
|
56
|
+
const { since, until } = period
|
|
57
|
+
const { limit, merges, numstat } = git
|
|
58
|
+
|
|
59
|
+
// 对于 CLI 传入的 author,只有当它是字符串时才传给 git
|
|
60
|
+
// 如果是对象(包含 include/exclude),我们在前端做更精细的过滤
|
|
61
|
+
const authorIsString = typeof author === 'string' && author.trim().length > 0
|
|
62
|
+
|
|
63
|
+
const pretty = `${[
|
|
64
|
+
'%H', // hash
|
|
65
|
+
'%an', // author name
|
|
66
|
+
'%ae', // email
|
|
67
|
+
'%ad', // date
|
|
68
|
+
'%s', // subject
|
|
69
|
+
'%B' // body
|
|
70
|
+
].join('%x1f')}%x1e`
|
|
71
|
+
|
|
72
|
+
const args = [
|
|
73
|
+
'log',
|
|
74
|
+
`--pretty=format:${pretty}`,
|
|
75
|
+
'--date=iso-local',
|
|
76
|
+
// '--numstat',
|
|
77
|
+
'--all'
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if (authorIsString) args.push(`--author=${author}`)
|
|
81
|
+
if (email && typeof email === 'string') args.push(`--author=${email}`)
|
|
82
|
+
// if (since) args.push(`--since=${until}`)
|
|
83
|
+
if (since) {
|
|
84
|
+
// 传给 git 绝对的 ISO 字符串,让 Git 自己去比对时间戳
|
|
85
|
+
args.push(`--since=${dayjs(since).startOf('day').toISOString()}`)
|
|
86
|
+
}
|
|
87
|
+
if (until) {
|
|
88
|
+
// 传给 git 绝对的 ISO 字符串,让 Git 自己去比对时间戳
|
|
89
|
+
args.push(`--until=${dayjs(until).endOf('day').toISOString()}`)
|
|
90
|
+
}
|
|
91
|
+
// numstat 是个性能杀手,默认不开启,除非用户明确要求,显示每次提交中更改的文件以及增删的行数统计
|
|
92
|
+
if (numstat) {
|
|
93
|
+
args.push('--numstat')
|
|
94
|
+
}
|
|
95
|
+
// if (until) args.push(`--until=${until}`)
|
|
96
|
+
if (!merges) args.push(`--no-merges`)
|
|
97
|
+
if (limit) args.push('-n', String(limit))
|
|
98
|
+
|
|
99
|
+
let stdout
|
|
100
|
+
try {
|
|
101
|
+
/**
|
|
102
|
+
* execFile 直接执行 git:
|
|
103
|
+
* - 不使用 shell
|
|
104
|
+
* - 不会触发 WSL / bash
|
|
105
|
+
* - maxBuffer 放大,防止大仓库 stdout 溢出
|
|
106
|
+
*/
|
|
107
|
+
const result = await execFileAsync('git', args, {
|
|
108
|
+
maxBuffer: 1024 * 1024 * 200 // 200MB(Windows 大仓库友好)
|
|
109
|
+
})
|
|
110
|
+
stdout = result.stdout
|
|
111
|
+
} catch (err) {
|
|
112
|
+
/**
|
|
113
|
+
* 统一错误出口,方便 CLI 层捕获
|
|
114
|
+
*/
|
|
115
|
+
const message = err?.stderr || err?.message || 'Failed to execute git log'
|
|
116
|
+
const error = new Error(message)
|
|
117
|
+
error.cause = err
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Windows 下 git 输出可能带 \r
|
|
123
|
+
*/
|
|
124
|
+
const raw = stdout.replace(/\r/g, '')
|
|
125
|
+
const commits = []
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 匹配每个 commit header + body + numstat
|
|
129
|
+
*/
|
|
130
|
+
const commitRegex =
|
|
131
|
+
// eslint-disable-next-line no-control-regex
|
|
132
|
+
/([0-9a-f]+)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([^\x1f]*)\x1f([\s\S]*?)(?=(?:[0-9a-f]{7,40}\x1f)|\x1e$)/g
|
|
133
|
+
|
|
134
|
+
// --- 关键改进 2: 引入内容指纹去重集 ---
|
|
135
|
+
const fingerPrintSet = new Set()
|
|
136
|
+
|
|
137
|
+
for (const match of raw.matchAll(commitRegex)) {
|
|
138
|
+
const [_, hash, authorName, emailAddr, date, subject, bodyAndNumstat] =
|
|
139
|
+
match
|
|
140
|
+
|
|
141
|
+
// 1. 统一作者名
|
|
142
|
+
const normalizedAuthor = normalizer.getAuthor(authorName, emailAddr)
|
|
143
|
+
// 2. 格式化日期(消除时刻差异,只看天)
|
|
144
|
+
const day = dayjs(date).format('YYYY-MM-DD')
|
|
145
|
+
// 3. 清理消息内容(去空格,取第一行)
|
|
146
|
+
const cleanMsg = subject.trim()
|
|
147
|
+
|
|
148
|
+
// 生成指纹:日期 + 统一后的作者 + 消息内容
|
|
149
|
+
// 这样即便 Hash 不同,分支不同,只要这三项一致,就视为同一项工作
|
|
150
|
+
const fingerPrint = `${day}_${normalizedAuthor}_${cleanMsg}`
|
|
151
|
+
|
|
152
|
+
if (fingerPrintSet.has(fingerPrint)) continue
|
|
153
|
+
fingerPrintSet.add(fingerPrint)
|
|
154
|
+
|
|
155
|
+
const [, changeId] =
|
|
156
|
+
bodyAndNumstat.match(/Change-Id:\s*(I[0-9a-fA-F]+)/) || []
|
|
157
|
+
|
|
158
|
+
const cherryPickMatch = bodyAndNumstat.match(
|
|
159
|
+
/\(cherry picked from commit\s+([0-9a-f]{7,40})\)/i
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 解析 numstat
|
|
164
|
+
*/
|
|
165
|
+
const commit = {
|
|
166
|
+
hash,
|
|
167
|
+
author: normalizer.getAuthor(authorName, emailAddr),
|
|
168
|
+
originalAuthor: authorName,
|
|
169
|
+
email: emailAddr,
|
|
170
|
+
date,
|
|
171
|
+
message: subject,
|
|
172
|
+
body: bodyAndNumstat,
|
|
173
|
+
changeId,
|
|
174
|
+
|
|
175
|
+
// ✅ 新增标记
|
|
176
|
+
isCherryPick: Boolean(cherryPickMatch),
|
|
177
|
+
cherryPickFrom: cherryPickMatch?.[1],
|
|
178
|
+
|
|
179
|
+
added: 0,
|
|
180
|
+
deleted: 0,
|
|
181
|
+
changed: 0,
|
|
182
|
+
files: []
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const numstatRegex = /^(\d+)\s+(\d+)\s+(.+)$/gm
|
|
186
|
+
for (const m of bodyAndNumstat.matchAll(numstatRegex)) {
|
|
187
|
+
const added = parseInt(m[1], 10) || 0
|
|
188
|
+
const deleted = parseInt(m[2], 10) || 0
|
|
189
|
+
const file = m[3]
|
|
190
|
+
|
|
191
|
+
commit.added += added
|
|
192
|
+
commit.deleted += deleted
|
|
193
|
+
commit.changed += added + deleted
|
|
194
|
+
commit.files.push({ file, added, deleted })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
commits.push(commit)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 最终统一覆盖 author
|
|
202
|
+
* 确保同一 email 使用同一个(中文)作者名
|
|
203
|
+
*/
|
|
204
|
+
// 应用基于邮箱的最终映射
|
|
205
|
+
const finalMap = normalizer.getMap()
|
|
206
|
+
for (const c of commits) {
|
|
207
|
+
if (c.email && finalMap[c.email]) {
|
|
208
|
+
c.author = finalMap[c.email]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 支持配置:author.include / author.exclude(可为数组或逗号分隔字符串)
|
|
213
|
+
const authorCfg = opts.author
|
|
214
|
+
function toList(v) {
|
|
215
|
+
if (!v) return null
|
|
216
|
+
if (Array.isArray(v)) return v.map((s) => String(s).trim()).filter(Boolean)
|
|
217
|
+
return String(v)
|
|
218
|
+
.split(',')
|
|
219
|
+
.map((s) => s.trim())
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const include =
|
|
224
|
+
authorCfg && typeof authorCfg === 'object'
|
|
225
|
+
? toList(authorCfg.include)
|
|
226
|
+
: typeof authorCfg === 'string'
|
|
227
|
+
? toList(authorCfg)
|
|
228
|
+
: null
|
|
229
|
+
const exclude =
|
|
230
|
+
authorCfg && typeof authorCfg === 'object'
|
|
231
|
+
? toList(authorCfg.exclude)
|
|
232
|
+
: null
|
|
233
|
+
|
|
234
|
+
let filteredCommits = commits
|
|
235
|
+
|
|
236
|
+
if (include || exclude) {
|
|
237
|
+
filteredCommits = commits.filter((c) => {
|
|
238
|
+
const name = (c.author || c.originalAuthor || '').trim().toLowerCase()
|
|
239
|
+
const mail = (c.email || '').trim().toLowerCase()
|
|
240
|
+
|
|
241
|
+
function matches(list) {
|
|
242
|
+
if (!list || !list.length) return false
|
|
243
|
+
return list.some((item) => {
|
|
244
|
+
const it = String(item).trim().toLowerCase()
|
|
245
|
+
if (it.includes('@')) return it === mail
|
|
246
|
+
return it === name
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (include && include.length) {
|
|
251
|
+
if (!matches(include)) return false
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (exclude && exclude.length) {
|
|
255
|
+
if (matches(exclude)) return false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return true
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 重新计算 authorMap / originalMap 以只包含筛选后的作者
|
|
263
|
+
const presentEmails = new Set(
|
|
264
|
+
filteredCommits.map((c) => c.email).filter(Boolean)
|
|
265
|
+
)
|
|
266
|
+
const filteredMap = {}
|
|
267
|
+
for (const [email, name] of Object.entries(finalMap)) {
|
|
268
|
+
if (presentEmails.has(email)) filteredMap[email] = name
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const original = normalizer.getOriginalMap()
|
|
272
|
+
const filteredOriginal = {}
|
|
273
|
+
for (const [email, names] of Object.entries(original)) {
|
|
274
|
+
if (presentEmails.has(email)) filteredOriginal[email] = names
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
commits: filteredCommits,
|
|
279
|
+
authorMap: filteredMap,
|
|
280
|
+
originalMap: filteredOriginal
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 去重 cherry-pick commit
|
|
286
|
+
*
|
|
287
|
+
* 默认策略:
|
|
288
|
+
* - 按 Change-Id 去重
|
|
289
|
+
* - 优先保留非 cherry-pick
|
|
290
|
+
* - 多个 cherry-pick 时保留 first / last
|
|
291
|
+
*/
|
|
292
|
+
export function dedupeCommits(
|
|
293
|
+
commits,
|
|
294
|
+
{
|
|
295
|
+
by = 'changeId', // 'changeId' | 'hash'
|
|
296
|
+
prefer = 'original' // 'original' | 'first' | 'last'
|
|
297
|
+
} = {}
|
|
298
|
+
) {
|
|
299
|
+
const map = new Map()
|
|
300
|
+
|
|
301
|
+
for (const commit of commits) {
|
|
302
|
+
const key = by === 'changeId' ? commit.changeId || commit.hash : commit.hash
|
|
303
|
+
|
|
304
|
+
if (!map.has(key)) {
|
|
305
|
+
map.set(key, commit)
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const existing = map.get(key)
|
|
310
|
+
|
|
311
|
+
// 优先保留非 cherry-pick
|
|
312
|
+
if (prefer === 'original') {
|
|
313
|
+
if (!existing.isCherryPick && commit.isCherryPick) continue
|
|
314
|
+
if (existing.isCherryPick && !commit.isCherryPick) {
|
|
315
|
+
map.set(key, commit)
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (prefer === 'last') {
|
|
321
|
+
map.set(key, commit)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return Array.from(map.values())
|
|
326
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取 git user.name / user.email
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { getGitCapability } from './gitCapability.mjs'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @returns {Promise<{ name: string, email: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function getGitUser() {
|
|
15
|
+
await getGitCapability()
|
|
16
|
+
|
|
17
|
+
const getConfig = async (key) => {
|
|
18
|
+
try {
|
|
19
|
+
const { stdout } = await execFileAsync(
|
|
20
|
+
'git',
|
|
21
|
+
['config', '--get', key],
|
|
22
|
+
{
|
|
23
|
+
windowsHide: true,
|
|
24
|
+
timeout: 3000,
|
|
25
|
+
maxBuffer: 1024 * 1024
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return stdout.trim()
|
|
30
|
+
} catch {
|
|
31
|
+
return ''
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [name, email] = await Promise.all([
|
|
36
|
+
getConfig('user.name'),
|
|
37
|
+
getConfig('user.email')
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
email
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取 git 仓库根目录
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import { getGitCapability } from './gitCapability.mjs'
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @returns {Promise<string>} 仓库根路径
|
|
13
|
+
*/
|
|
14
|
+
export async function getRepoRoot() {
|
|
15
|
+
await getGitCapability()
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await execFileAsync(
|
|
19
|
+
'git',
|
|
20
|
+
['rev-parse', '--show-toplevel'],
|
|
21
|
+
{
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
maxBuffer: 1024 * 1024
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return stdout.trim()
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`❌ 无法获取 Git 仓库根目录`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Capability Cache
|
|
3
|
+
*
|
|
4
|
+
* - CLI 生命周期内只检测一次
|
|
5
|
+
* - 不使用 shell
|
|
6
|
+
* - 不触发 WSL
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from 'node:child_process'
|
|
9
|
+
import { promisify } from 'node:util'
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @type {null | {
|
|
15
|
+
* available: boolean
|
|
16
|
+
* version: string
|
|
17
|
+
* platform: string
|
|
18
|
+
* checkedAt: number
|
|
19
|
+
* }}
|
|
20
|
+
*/
|
|
21
|
+
let cache = null
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 内部:真正执行 git 检测
|
|
25
|
+
*/
|
|
26
|
+
async function detectGit() {
|
|
27
|
+
const {platform} = process
|
|
28
|
+
|
|
29
|
+
const { stdout } = await execFileAsync('git', ['--version'], {
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
maxBuffer: 1024 * 1024
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (!stdout || !stdout.toLowerCase().includes('git version')) {
|
|
36
|
+
throw new Error(`Unexpected git output: ${stdout}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
available: true,
|
|
41
|
+
version: stdout.trim(),
|
|
42
|
+
platform,
|
|
43
|
+
checkedAt: Date.now()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 对外 API:获取 git capability(带缓存)
|
|
49
|
+
*/
|
|
50
|
+
export async function getGitCapability() {
|
|
51
|
+
if (cache) {
|
|
52
|
+
return cache
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
cache = await detectGit()
|
|
57
|
+
return cache
|
|
58
|
+
} catch (err) {
|
|
59
|
+
cache = {
|
|
60
|
+
available: false,
|
|
61
|
+
version: '',
|
|
62
|
+
platform: process.platform,
|
|
63
|
+
checkedAt: Date.now()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// eslint-disable-next-line no-use-before-define
|
|
67
|
+
const error = new Error(buildGitNotAvailableMessage())
|
|
68
|
+
error.cause = err
|
|
69
|
+
throw error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 是否已检测(通常你不需要)
|
|
75
|
+
*/
|
|
76
|
+
export function isGitCapabilityCached() {
|
|
77
|
+
return Boolean(cache)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 生成跨平台提示信息
|
|
82
|
+
*/
|
|
83
|
+
function buildGitNotAvailableMessage() {
|
|
84
|
+
const {platform} = process
|
|
85
|
+
|
|
86
|
+
if (platform === 'win32') {
|
|
87
|
+
return `
|
|
88
|
+
❌ Git 不可用,CLI 无法继续运行
|
|
89
|
+
|
|
90
|
+
请确认:
|
|
91
|
+
1️⃣ 已安装 Git for Windows
|
|
92
|
+
https://git-scm.com/download/win
|
|
93
|
+
|
|
94
|
+
2️⃣ 安装时勾选:
|
|
95
|
+
✔ "Add Git to PATH"
|
|
96
|
+
|
|
97
|
+
3️⃣ 关闭并重新打开终端后再试
|
|
98
|
+
`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (platform === 'darwin') {
|
|
102
|
+
return `
|
|
103
|
+
❌ Git 不可用,CLI 无法继续运行
|
|
104
|
+
|
|
105
|
+
可通过以下方式安装 git:
|
|
106
|
+
xcode-select --install
|
|
107
|
+
或
|
|
108
|
+
brew install git
|
|
109
|
+
`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return `
|
|
113
|
+
❌ Git 不可用,CLI 无法继续运行
|
|
114
|
+
|
|
115
|
+
请使用系统包管理器安装 git,例如:
|
|
116
|
+
apt install git
|
|
117
|
+
yum install git
|
|
118
|
+
`
|
|
119
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file: index.mjs
|
|
3
|
+
* @description:启动前环境检查
|
|
4
|
+
* @author: King Monkey
|
|
5
|
+
* @created: 2026-01-13 01:16
|
|
6
|
+
*/
|
|
7
|
+
import { ensureGitRepo } from '#src/domain/git/ensureGitRepo.mjs'
|
|
8
|
+
import { getGitFeatures } from '#src/domain/git/getGitFeatures.mjs'
|
|
9
|
+
import { getGitUser } from '#src/domain/git/getGitUser.mjs'
|
|
10
|
+
import { getRepoRoot } from '#src/domain/git/getRepoRoot.mjs'
|
|
11
|
+
import { getGitCapability } from '#src/domain/git/gitCapability.mjs'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* CLI 启动阶段 Git 运行环境预检(Preflight)
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<{
|
|
17
|
+
* git: {
|
|
18
|
+
* version: string
|
|
19
|
+
* platform: string
|
|
20
|
+
* },
|
|
21
|
+
* repo: {
|
|
22
|
+
* root: string
|
|
23
|
+
* },
|
|
24
|
+
* user: {
|
|
25
|
+
* name: string
|
|
26
|
+
* email: string
|
|
27
|
+
* },
|
|
28
|
+
* features: {
|
|
29
|
+
* numstat: boolean
|
|
30
|
+
* dateIsoLocal: boolean
|
|
31
|
+
* },
|
|
32
|
+
* meta: {
|
|
33
|
+
* checkedAt: number
|
|
34
|
+
* }
|
|
35
|
+
* }>}
|
|
36
|
+
*/
|
|
37
|
+
export const runGitPreflight = async () => {
|
|
38
|
+
// 1️⃣ git 可执行能力(带全局 cache)
|
|
39
|
+
// CLI 启动即检测(一次)
|
|
40
|
+
const gitCapability = await getGitCapability()
|
|
41
|
+
|
|
42
|
+
// 2️⃣ 必须在 git repo 内
|
|
43
|
+
await ensureGitRepo()
|
|
44
|
+
|
|
45
|
+
// 3️⃣ 并行获取上下文信息
|
|
46
|
+
const [root, user, features] = await Promise.all([
|
|
47
|
+
getRepoRoot(),
|
|
48
|
+
getGitUser(),
|
|
49
|
+
getGitFeatures()
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const result = {
|
|
53
|
+
git: {
|
|
54
|
+
version: gitCapability.version,
|
|
55
|
+
platform: gitCapability.platform
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
repo: {
|
|
59
|
+
root
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
user: {
|
|
63
|
+
name: user.name,
|
|
64
|
+
email: user.email
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
features,
|
|
68
|
+
|
|
69
|
+
meta: {
|
|
70
|
+
checkedAt: gitCapability.checkedAt
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 可选:打印
|
|
75
|
+
// console.log(`✔ Git detected: ${result.git.version}`)
|
|
76
|
+
// console.log(`✔ platform: ${result.git.platform}`)
|
|
77
|
+
|
|
78
|
+
// console.log('✔ root', result.repo.root)
|
|
79
|
+
// console.log('✔ user', result.user.name)
|
|
80
|
+
// console.log('✔ email', result.user.email)
|
|
81
|
+
// console.log('✔ features', result.features)
|
|
82
|
+
// console.log(`-`.repeat(50), '\n')
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const showGitInfo = async (gitInfo) => {
|
|
88
|
+
console.log(`✔ Git detected: ${gitInfo.git.version}`)
|
|
89
|
+
console.log(`✔ platform: ${gitInfo.git.platform}`)
|
|
90
|
+
|
|
91
|
+
console.log('✔ root', gitInfo.repo.root)
|
|
92
|
+
console.log('✔ user', gitInfo.user.name)
|
|
93
|
+
console.log('✔ email', gitInfo.user.email)
|
|
94
|
+
console.log('✔ features', gitInfo.features)
|
|
95
|
+
console.log(`-`.repeat(50), '\n')
|
|
96
|
+
}
|