dsh-plugin-git-commit-push 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.en.md +473 -0
- package/README.md +381 -2
- package/SKILL.md +79 -0
- package/capture-git-format.mjs +93 -0
- package/cordis.patch.yml +35 -0
- package/e2e-check.mjs +26 -0
- package/git-commit-push.config.json +17 -0
- package/icon.svg +1 -0
- package/index.js +908 -0
- package/lib/analyze.js +517 -0
- package/lib/config.js +300 -0
- package/lib/git.js +562 -0
- package/lib/profile-edit.mjs +118 -0
- package/lib/schema.js +111 -0
- package/lib/skill.js +95 -0
- package/lib/survey.js +225 -0
- package/lib/test-fixture.mjs +64 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
- package/self-test-git.mjs +442 -0
- package/self-test.mjs +913 -0
- package/setup.ps1 +226 -0
- package/setup.sh +190 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime settings.
|
|
3
|
+
*
|
|
4
|
+
* THREE SOURCES, IN PRECEDENCE ORDER
|
|
5
|
+
*
|
|
6
|
+
* 1. **The mounted row's `config`** — what DSH's own settings surface shows as
|
|
7
|
+
* a form (see `lib/schema.js`). This is the layer a person edits in the UI;
|
|
8
|
+
* it persists into the profile's `cordis.patch.yml` for entry
|
|
9
|
+
* `git-commit-push` and, for the fields declared volatile, applies live
|
|
10
|
+
* without a remount.
|
|
11
|
+
* 2. `$DSH_HOME/git-commit-push.config.json` (default `~/.dsh/…`) — the file
|
|
12
|
+
* a person owns, and the fallback for a `link:`/offline install where the
|
|
13
|
+
* host's settings surface or its schema library may not be reachable.
|
|
14
|
+
* 3. `<package>/git-commit-push.config.json` — the template shipped in the
|
|
15
|
+
* tarball, and the file a source checkout is expected to edit.
|
|
16
|
+
* (Plus the built-in defaults for anything none of them sets.)
|
|
17
|
+
*
|
|
18
|
+
* A missing file is not an error at any level. A file that exists but cannot be
|
|
19
|
+
* read as JSON IS reported (see `loadSettingsReport`) instead of being silently
|
|
20
|
+
* ignored — a typo in a config file that quietly does nothing is worse than a
|
|
21
|
+
* visible one.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE FIELD TABLE LIVES HERE
|
|
24
|
+
* `FIELDS` is the single source of truth shared by the schema builder
|
|
25
|
+
* (`lib/schema.js`), the merge below, and the tests: a field added to one and
|
|
26
|
+
* forgotten in the other is exactly the kind of drift that makes a UI form
|
|
27
|
+
* write values no code reads.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync } from 'node:fs'
|
|
30
|
+
import { readFile } from 'node:fs/promises'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { dirname, join } from 'node:path'
|
|
34
|
+
|
|
35
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
|
|
37
|
+
/** The DSH home directory: `$DSH_HOME` when set, else `~/.dsh`. */
|
|
38
|
+
export function dshHome() {
|
|
39
|
+
const configured = process.env.DSH_HOME
|
|
40
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured
|
|
41
|
+
return join(homedir(), '.dsh')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The user-owned settings file (survives reinstalling the package). */
|
|
45
|
+
export function userConfigPath() {
|
|
46
|
+
return join(dshHome(), 'git-commit-push.config.json')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The template shipped inside the package. */
|
|
50
|
+
export const PACKAGE_CONFIG_PATH = join(here, '..', 'git-commit-push.config.json')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Back-compat alias. Historic name of the package-local path; new code should
|
|
54
|
+
* use `userConfigPath()` / `configCandidates()`.
|
|
55
|
+
*/
|
|
56
|
+
export const CONFIG_PATH = PACKAGE_CONFIG_PATH
|
|
57
|
+
|
|
58
|
+
/** Every settings file consulted, in precedence order. */
|
|
59
|
+
export function configCandidates() {
|
|
60
|
+
return [userConfigPath(), PACKAGE_CONFIG_PATH]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shipped defaults; every field is overridable in the JSON file. */
|
|
64
|
+
export const DEFAULTS = Object.freeze({
|
|
65
|
+
/** Push the branch after a successful commit. */
|
|
66
|
+
autoPush: true,
|
|
67
|
+
/** Stage the whole working tree before committing (the skill's `git add -A`). */
|
|
68
|
+
autoAdd: true,
|
|
69
|
+
/** Trigger the tag question when a version file changed. */
|
|
70
|
+
tagOnVersionChange: true,
|
|
71
|
+
/** Trigger the tag question when at least this many files changed (0 disables). */
|
|
72
|
+
tagOnFileCount: 10,
|
|
73
|
+
/** Trigger the tag question when a public declaration was removed. */
|
|
74
|
+
tagOnBreaking: true,
|
|
75
|
+
/** Prefix for the suggested tag name when the version is known. */
|
|
76
|
+
tagPrefix: 'v',
|
|
77
|
+
/** Ask the user before tagging. When false the suggested tag is created silently. */
|
|
78
|
+
askBeforeTag: true,
|
|
79
|
+
/** How long the in-plugin tag question waits for an answer. */
|
|
80
|
+
askTimeoutMs: 120_000,
|
|
81
|
+
/** `zh` or `en`: the language of a generated subject. */
|
|
82
|
+
defaultLanguage: 'zh',
|
|
83
|
+
/** How many changed paths the card lists before collapsing the rest. */
|
|
84
|
+
maxFilesShown: 12,
|
|
85
|
+
/**
|
|
86
|
+
* Commit identity applied per command with `-c user.name/-c user.email`.
|
|
87
|
+
* Empty fields are left alone, so the user's own git identity is used and
|
|
88
|
+
* NEVER written to their configuration.
|
|
89
|
+
*/
|
|
90
|
+
pinnedIdentity: Object.freeze({ name: '', email: '' }),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The settings a person can change, in the order the form shows them.
|
|
95
|
+
*
|
|
96
|
+
* `kind` matches the schema builder and the coercion in `normalize`; `label` is
|
|
97
|
+
* the description the DSH settings form renders (Chinese, matching this
|
|
98
|
+
* plugin's default message language).
|
|
99
|
+
*/
|
|
100
|
+
export const FIELDS = Object.freeze([
|
|
101
|
+
{ key: 'autoPush', kind: 'boolean', label: '提交后自动推送' },
|
|
102
|
+
{ key: 'autoAdd', kind: 'boolean', label: '提交前执行 git add -A(把整个工作区的改动加入暂存)' },
|
|
103
|
+
{ key: 'tagOnVersionChange', kind: 'boolean', label: '版本文件变化时询问是否打标签' },
|
|
104
|
+
{ key: 'tagOnBreaking', kind: 'boolean', label: '检测到公共声明被删除时询问是否打标签(疑似破坏性变更)' },
|
|
105
|
+
{ key: 'tagOnFileCount', kind: 'number', label: '改动文件数达到该值时询问打标签(0 = 关闭)', min: 0 },
|
|
106
|
+
{ key: 'tagPrefix', kind: 'string', label: '建议标签名的前缀' },
|
|
107
|
+
{ key: 'askBeforeTag', kind: 'boolean', label: '打标签前先询问;关闭则直接打建议的标签' },
|
|
108
|
+
{ key: 'askTimeoutMs', kind: 'number', label: '标签询问的等待上限(毫秒)', min: 1 },
|
|
109
|
+
{ key: 'defaultLanguage', kind: 'string', label: '规则生成提交信息的语言:zh 或 en' },
|
|
110
|
+
{ key: 'maxFilesShown', kind: 'number', label: '卡片最多列出几个改动文件', min: 1 },
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
/** The nested `pinnedIdentity` fields, kept separate because they are an object. */
|
|
114
|
+
export const IDENTITY_FIELDS = Object.freeze([
|
|
115
|
+
{ key: 'name', kind: 'string', label: '仅本次提交使用的 user.name(留空则用你的 git 配置)' },
|
|
116
|
+
{ key: 'email', kind: 'string', label: '仅本次提交使用的 user.email(留空则用你的 git 配置)' },
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
/** Coerce one loaded value to the shape the plugin expects. */
|
|
120
|
+
function normalize(raw) {
|
|
121
|
+
const settings = { ...DEFAULTS }
|
|
122
|
+
if (typeof raw !== 'object' || raw === null) return settings
|
|
123
|
+
if (typeof raw.autoPush === 'boolean') settings.autoPush = raw.autoPush
|
|
124
|
+
if (typeof raw.autoAdd === 'boolean') settings.autoAdd = raw.autoAdd
|
|
125
|
+
if (typeof raw.tagOnVersionChange === 'boolean') settings.tagOnVersionChange = raw.tagOnVersionChange
|
|
126
|
+
if (typeof raw.tagOnBreaking === 'boolean') settings.tagOnBreaking = raw.tagOnBreaking
|
|
127
|
+
if (typeof raw.askBeforeTag === 'boolean') settings.askBeforeTag = raw.askBeforeTag
|
|
128
|
+
if (Number.isFinite(raw.tagOnFileCount) && raw.tagOnFileCount >= 0) settings.tagOnFileCount = Math.floor(raw.tagOnFileCount)
|
|
129
|
+
if (typeof raw.tagPrefix === 'string') settings.tagPrefix = raw.tagPrefix
|
|
130
|
+
if (Number.isFinite(raw.askTimeoutMs) && raw.askTimeoutMs > 0) settings.askTimeoutMs = Math.floor(raw.askTimeoutMs)
|
|
131
|
+
if (raw.defaultLanguage === 'zh' || raw.defaultLanguage === 'en') settings.defaultLanguage = raw.defaultLanguage
|
|
132
|
+
if (Number.isFinite(raw.maxFilesShown) && raw.maxFilesShown > 0) settings.maxFilesShown = Math.floor(raw.maxFilesShown)
|
|
133
|
+
if (typeof raw.pinnedIdentity === 'object' && raw.pinnedIdentity !== null) {
|
|
134
|
+
settings.pinnedIdentity = {
|
|
135
|
+
name: typeof raw.pinnedIdentity.name === 'string' ? raw.pinnedIdentity.name : '',
|
|
136
|
+
email: typeof raw.pinnedIdentity.email === 'string' ? raw.pinnedIdentity.email : '',
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return settings
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the settings file that wins, and say which one it was.
|
|
144
|
+
*
|
|
145
|
+
* @returns {Promise<{ settings: typeof DEFAULTS, source?: string, problem?: string }>}
|
|
146
|
+
*/
|
|
147
|
+
export async function loadSettingsReport() {
|
|
148
|
+
for (const path of configCandidates()) {
|
|
149
|
+
let text
|
|
150
|
+
try {
|
|
151
|
+
text = await readFile(path, 'utf8')
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// Missing is the normal case for the first two candidates; anything else
|
|
154
|
+
// (a permission problem, a directory) is worth reporting rather than
|
|
155
|
+
// hiding behind the defaults.
|
|
156
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue
|
|
157
|
+
return { settings: { ...DEFAULTS }, source: path, problem: `无法读取 ${path}:${error?.message ?? String(error)}` }
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
return { settings: normalize(JSON.parse(text)), source: path }
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return {
|
|
163
|
+
settings: { ...DEFAULTS },
|
|
164
|
+
source: path,
|
|
165
|
+
problem: `${path} 不是合法的 JSON 配置(${error?.message ?? String(error)}),已按内置默认值执行`,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { settings: { ...DEFAULTS } }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The effective settings.
|
|
174
|
+
*
|
|
175
|
+
* Never throws: a missing or unreadable file falls back to the documented
|
|
176
|
+
* defaults. Use `loadSettingsReport()` when the caller should tell the user
|
|
177
|
+
* that their file did not take effect.
|
|
178
|
+
*
|
|
179
|
+
* @returns {Promise<typeof DEFAULTS>}
|
|
180
|
+
*/
|
|
181
|
+
export async function loadSettings() {
|
|
182
|
+
return (await loadSettingsReport()).settings
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The file layer, read synchronously.
|
|
187
|
+
*
|
|
188
|
+
* Used once per process, as the schema's defaults: a form should open on the
|
|
189
|
+
* values the plugin is actually using, and a schema is built at import time, so
|
|
190
|
+
* it cannot await. A later edit of the file still takes effect on the next tool
|
|
191
|
+
* call — `resolveSettings` prefers the live file value whenever the row config
|
|
192
|
+
* has not overridden that field — but the form's placeholder keeps the value
|
|
193
|
+
* from process start until the next restart.
|
|
194
|
+
*
|
|
195
|
+
* @returns {typeof DEFAULTS}
|
|
196
|
+
*/
|
|
197
|
+
export function loadFileSettingsSync() {
|
|
198
|
+
for (const path of configCandidates()) {
|
|
199
|
+
try {
|
|
200
|
+
return normalize(JSON.parse(readFileSync(path, 'utf8')))
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') continue
|
|
203
|
+
// A malformed file is reported by `loadSettingsReport`; here the only
|
|
204
|
+
// safe answer is the defaults.
|
|
205
|
+
return { ...DEFAULTS }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { ...DEFAULTS }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Unwrap a volatile field (`schema.volatile()` parses into a `.get()` reference). */
|
|
212
|
+
function readLive(value) {
|
|
213
|
+
if (typeof value === 'object' && value !== null && typeof value.get === 'function') return value.get()
|
|
214
|
+
return value
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Deep equality good enough for the scalar/object shapes a settings field holds. */
|
|
218
|
+
function sameValue(left, right) {
|
|
219
|
+
if (left === right) return true
|
|
220
|
+
if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
|
|
221
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
|
222
|
+
for (const key of keys) if (!sameValue(left[key], right[key])) return false
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The fields the DSH settings surface set, in the shape of a partial settings object.
|
|
228
|
+
*
|
|
229
|
+
* Two signals, strongest first:
|
|
230
|
+
*
|
|
231
|
+
* 1. `ctx.fiber.entry.options.config` — the raw row configuration as written
|
|
232
|
+
* in the profile patch. Only fields someone actually set are own
|
|
233
|
+
* properties, so there is no guessing. Reading it is a Cordis internal, so
|
|
234
|
+
* it is guarded; a host that does not expose it degrades to the next step.
|
|
235
|
+
* 2. A parsed config value that differs from the built-in default must have
|
|
236
|
+
* come from somewhere above the defaults (the form, or the patch), so it
|
|
237
|
+
* counts as an override. A value that merely equals our default is
|
|
238
|
+
* indistinguishable from "not set" and falls through to the live file —
|
|
239
|
+
* which is the intended precedence anyway.
|
|
240
|
+
*
|
|
241
|
+
* @param {any} ctx plugin context (may be a bare `{ get() {} }` in tests)
|
|
242
|
+
* @param {any} config the config Cordis parsed from the row (may be undefined)
|
|
243
|
+
* @returns {Record<string, unknown>}
|
|
244
|
+
*/
|
|
245
|
+
export function uiOverrides(ctx, config) {
|
|
246
|
+
const overrides = {}
|
|
247
|
+
const raw = ctx?.fiber?.entry?.options?.config
|
|
248
|
+
const rawIsObject = typeof raw === 'object' && raw !== null && !Array.isArray(raw)
|
|
249
|
+
for (const field of FIELDS) {
|
|
250
|
+
if (rawIsObject && raw[field.key] !== undefined) overrides[field.key] = raw[field.key]
|
|
251
|
+
else if (!rawIsObject && config !== undefined) {
|
|
252
|
+
const value = readLive(config[field.key])
|
|
253
|
+
if (value !== undefined && !sameValue(value, DEFAULTS[field.key])) overrides[field.key] = value
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const rawIdentity = rawIsObject ? raw.pinnedIdentity : readLive(config?.pinnedIdentity)
|
|
257
|
+
if (typeof rawIdentity === 'object' && rawIdentity !== null) {
|
|
258
|
+
const identity = {}
|
|
259
|
+
for (const field of IDENTITY_FIELDS) {
|
|
260
|
+
const value = readLive(rawIdentity[field.key])
|
|
261
|
+
if (value === undefined) continue
|
|
262
|
+
// Same rule as the scalar fields: with the raw row config in hand, an own
|
|
263
|
+
// property is an explicit choice; without it, only a value that differs
|
|
264
|
+
// from the default can have come from above the defaults.
|
|
265
|
+
if (rawIsObject || !sameValue(value, DEFAULTS.pinnedIdentity[field.key])) identity[field.key] = value
|
|
266
|
+
}
|
|
267
|
+
if (Object.keys(identity).length > 0) overrides.pinnedIdentity = identity
|
|
268
|
+
}
|
|
269
|
+
return overrides
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The settings a call runs with: row config (UI) > user file > template > defaults.
|
|
274
|
+
*
|
|
275
|
+
* @param {{ ui?: Record<string, unknown>, file?: typeof DEFAULTS }} layers
|
|
276
|
+
* @returns {typeof DEFAULTS}
|
|
277
|
+
*/
|
|
278
|
+
export function resolveSettings({ ui = {}, file } = {}) {
|
|
279
|
+
const base = file ?? { ...DEFAULTS }
|
|
280
|
+
const merged = { ...base }
|
|
281
|
+
for (const field of FIELDS) if (ui[field.key] !== undefined) merged[field.key] = ui[field.key]
|
|
282
|
+
if (ui.pinnedIdentity !== undefined) {
|
|
283
|
+
merged.pinnedIdentity = {
|
|
284
|
+
...DEFAULTS.pinnedIdentity,
|
|
285
|
+
...(base.pinnedIdentity ?? {}),
|
|
286
|
+
...normalizeIdentity(ui.pinnedIdentity),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return normalize(merged)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Keep only the identity keys the plugin understands. */
|
|
293
|
+
function normalizeIdentity(value) {
|
|
294
|
+
const identity = {}
|
|
295
|
+
if (typeof value !== 'object' || value === null) return identity
|
|
296
|
+
for (const field of IDENTITY_FIELDS) {
|
|
297
|
+
if (typeof value[field.key] === 'string') identity[field.key] = value[field.key]
|
|
298
|
+
}
|
|
299
|
+
return identity
|
|
300
|
+
}
|