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/schema.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cordis `Config` schema, i.e. the plugin's visual settings form.
|
|
3
|
+
*
|
|
4
|
+
* HOW DSH TURNS THIS INTO A FORM
|
|
5
|
+
* A plugin whose row accepts a `config` mapping exports a schemastery schema as
|
|
6
|
+
* `Config`. DSH's `@deepseek-ai/dsh-settings` service derives a namespace from
|
|
7
|
+
* it (`autoGenerate`), the Settings page renders the fields, and
|
|
8
|
+
* `@deepseek-ai/dsh-config-editor` writes what the person chose into the active
|
|
9
|
+
* profile's `cordis.patch.yml` — for our own entry id, so it lands next to the
|
|
10
|
+
* loader row this bundle inserts, in the same place `ui-theme`'s `preference`
|
|
11
|
+
* lives. A field declared `.volatile()` is written into the running references
|
|
12
|
+
* and announced with `loader/volatile-update`, which is why our values are read
|
|
13
|
+
* through `.get()` on every call instead of being cached at mount: a change in
|
|
14
|
+
* the form takes effect on the next tool call, with no remount and no restart.
|
|
15
|
+
*
|
|
16
|
+
* WHY LOADING THE LIBRARY IS GUARDED
|
|
17
|
+
* The plugin deliberately keeps its runtime imports to itself: with a `link:`
|
|
18
|
+
* install the schema library is reachable only because the launcher installs a
|
|
19
|
+
* runtime resolution computed from the installation and the bundle dependency
|
|
20
|
+
* graphs. If that ever fails, the right outcome is a plugin without a settings
|
|
21
|
+
* form — not a plugin that cannot mount. So the library is loaded through
|
|
22
|
+
* `createRequire` inside a `try`, and every failure (missing library, a schema
|
|
23
|
+
* the library rejects, an unexpected builder result) degrades to `Config ===
|
|
24
|
+
* undefined`, which DSH reads as "this row has no configuration fields".
|
|
25
|
+
*
|
|
26
|
+
* VOLATILE PLACEMENT RULES (enforced by schemastery, easy to get wrong)
|
|
27
|
+
* `validateVolatileSchema` throws for a volatile field under a dict value, an
|
|
28
|
+
* array item, a map key, or a union/lazy branch: volatile fields must sit at a
|
|
29
|
+
* fixed object path and must not be nested inside another volatile field. Every
|
|
30
|
+
* field below is therefore a direct leaf of the root object (or a leaf of the
|
|
31
|
+
* fixed-path `pinnedIdentity` object), never a union or an array item.
|
|
32
|
+
*/
|
|
33
|
+
import { createRequire } from 'node:module'
|
|
34
|
+
|
|
35
|
+
import { FIELDS, IDENTITY_FIELDS, loadFileSettingsSync } from './config.js'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load `@deepseek-ai/schemastery` without letting a resolution failure matter.
|
|
39
|
+
*
|
|
40
|
+
* @returns {any | undefined} the schema constructor, or undefined
|
|
41
|
+
*/
|
|
42
|
+
export function loadSchemaLibrary() {
|
|
43
|
+
try {
|
|
44
|
+
const require = createRequire(import.meta.url)
|
|
45
|
+
const loaded = require('@deepseek-ai/schemastery')
|
|
46
|
+
const Schema = loaded?.default ?? loaded
|
|
47
|
+
return typeof Schema === 'function' ? Schema : undefined
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One leaf field: typed, defaulted from the settings file, described, volatile. */
|
|
54
|
+
function leaf(Schema, field, value) {
|
|
55
|
+
const base = field.kind === 'boolean'
|
|
56
|
+
? Schema.boolean()
|
|
57
|
+
: field.kind === 'number'
|
|
58
|
+
? Schema.number()
|
|
59
|
+
: Schema.string()
|
|
60
|
+
let node = base.default(value).description(field.label)
|
|
61
|
+
if (typeof field.min === 'number') node = node.min(field.min)
|
|
62
|
+
return node.volatile()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Why the schema could not be built, for the log and for tests. */
|
|
66
|
+
let schemaProblem
|
|
67
|
+
|
|
68
|
+
/** The reason `CONFIG` is undefined, or undefined when it was built. */
|
|
69
|
+
export function schemaBuildProblem() {
|
|
70
|
+
return schemaProblem
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the schema.
|
|
75
|
+
*
|
|
76
|
+
* Defaults come from the settings file so the form opens on the values the
|
|
77
|
+
* plugin is actually using; the built-in defaults remain the last resort.
|
|
78
|
+
*
|
|
79
|
+
* @param {any} Schema the schemastery constructor (or undefined)
|
|
80
|
+
* @param {typeof import('./config.js').DEFAULTS} [seed] values to use as defaults
|
|
81
|
+
* @returns {any | undefined} the schema, or undefined when it cannot be built
|
|
82
|
+
*/
|
|
83
|
+
export function buildConfigSchema(Schema, seed = loadFileSettingsSync()) {
|
|
84
|
+
if (Schema === undefined) {
|
|
85
|
+
schemaProblem = '@deepseek-ai/schemastery could not be resolved from this package'
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const fields = {}
|
|
90
|
+
for (const field of FIELDS) fields[field.key] = leaf(Schema, field, seed[field.key])
|
|
91
|
+
fields.pinnedIdentity = Schema.object({
|
|
92
|
+
[IDENTITY_FIELDS[0].key]: leaf(Schema, IDENTITY_FIELDS[0], seed.pinnedIdentity?.name ?? ''),
|
|
93
|
+
[IDENTITY_FIELDS[1].key]: leaf(Schema, IDENTITY_FIELDS[1], seed.pinnedIdentity?.email ?? ''),
|
|
94
|
+
}).description('仅本次提交使用的 git 身份(以 -c 传入,不会改写你的 git 配置)')
|
|
95
|
+
const schema = Schema.object(fields)
|
|
96
|
+
schemaProblem = undefined
|
|
97
|
+
return schema
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A schema the library rejects would otherwise take the whole plugin down at
|
|
100
|
+
// mount; a missing settings form is the lesser failure by far.
|
|
101
|
+
schemaProblem = `${error?.name ?? 'Error'}: ${error?.message ?? String(error)}`
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The schema DSH mounts with this plugin.
|
|
108
|
+
*
|
|
109
|
+
* `undefined` when the schema library cannot be reached — see the note above.
|
|
110
|
+
*/
|
|
111
|
+
export const CONFIG = buildConfigSchema(loadSchemaLibrary())
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `git-commit-push` skill this plugin registers at mount time.
|
|
3
|
+
*
|
|
4
|
+
* WHY THE PLUGIN SHIPS ITS OWN SKILL
|
|
5
|
+
* The trigger policy — "commit only when the user asked, never on your own
|
|
6
|
+
* initiative" — has to reach the model through every surface it consults, not
|
|
7
|
+
* just the tool description. A skill file gives the model the full procedure
|
|
8
|
+
* (two-step `prepare` → `apply`, message conventions, boundaries) on demand.
|
|
9
|
+
*
|
|
10
|
+
* Why register it at runtime instead of telling the user to copy SKILL.md into
|
|
11
|
+
* their skills directory: this package is published to npm, so it is installed
|
|
12
|
+
* into `node_modules`, and asking every user to copy a file out of there by
|
|
13
|
+
* hand is exactly the kind of setup step that npm publishing is supposed to
|
|
14
|
+
* remove. `ctx.skills.register(...)` is the documented seam for an in-memory
|
|
15
|
+
* ("embedded") skill and needs no filesystem root.
|
|
16
|
+
*
|
|
17
|
+
* Source of truth is the shipped SKILL.md: the name, the description and the
|
|
18
|
+
* body are parsed from it, so the npm page, the skill catalog and the file can
|
|
19
|
+
* never drift. Precedence is the registry's business: a PROJECT-level skill of
|
|
20
|
+
* the same name outranks this runtime registration, so a user who keeps their
|
|
21
|
+
* own `git-commit-push` skill in the workspace still wins.
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
|
|
29
|
+
/** Absolute path of the shipped skill file. */
|
|
30
|
+
export const SKILL_PATH = join(here, '..', 'SKILL.md')
|
|
31
|
+
|
|
32
|
+
/** Kebab-case skill name, as the registry's grammar demands. Matches the package name. */
|
|
33
|
+
export const SKILL_NAME = 'git-commit-push'
|
|
34
|
+
|
|
35
|
+
/** `source` label shown in skill listings; the registry adds the `runtime` provider. */
|
|
36
|
+
export const SKILL_SOURCE = 'dsh-plugin-git-commit-push'
|
|
37
|
+
|
|
38
|
+
/** The registry's public skill-name grammar (see `@deepseek-ai/dsh-skill`). */
|
|
39
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split a `---`-fenced frontmatter block off a skill file.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately a reader for the flat `key: value` frontmatter this project
|
|
45
|
+
* writes, not a YAML parser: the package ships with no dependencies, and the
|
|
46
|
+
* two fields read here (name, description) are single-line scalars.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {{ fields: Record<string, string>, content: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function parseSkillFile(text) {
|
|
52
|
+
const fenced = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(text)
|
|
53
|
+
if (fenced === null) return { fields: {}, content: text.trim() }
|
|
54
|
+
const fields = {}
|
|
55
|
+
for (const line of fenced[1].split(/\r?\n/)) {
|
|
56
|
+
const pair = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(line)
|
|
57
|
+
if (pair === null) continue
|
|
58
|
+
const value = pair[2].trim()
|
|
59
|
+
fields[pair[1]] = /^(['"]).*\1$/.test(value) ? value.slice(1, -1) : value
|
|
60
|
+
}
|
|
61
|
+
return { fields, content: text.slice(fenced[0].length).trim() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The definition handed to `ctx.skills.register(...)`.
|
|
66
|
+
*
|
|
67
|
+
* Field requirements are the registry's: a kebab-case `name`, a non-empty
|
|
68
|
+
* `description`, an optional `{ modelInvocable, userInvocable }` policy, and a
|
|
69
|
+
* `content` string. `provider` is filled in by the registry (`runtime`), so it
|
|
70
|
+
* is deliberately absent here.
|
|
71
|
+
*
|
|
72
|
+
* @returns {{ name: string, description: string, invocation: { modelInvocable: boolean, userInvocable: boolean }, source: string, content: string } | undefined}
|
|
73
|
+
* `undefined` when the shipped file is missing or unusable — a skill that
|
|
74
|
+
* cannot be registered must not take the tool down with it.
|
|
75
|
+
*/
|
|
76
|
+
export function skillDefinition() {
|
|
77
|
+
let parsed
|
|
78
|
+
try {
|
|
79
|
+
parsed = parseSkillFile(readFileSync(SKILL_PATH, 'utf8'))
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
const name = parsed.fields.name ?? SKILL_NAME
|
|
84
|
+
const description = parsed.fields.description ?? ''
|
|
85
|
+
if (!SKILL_NAME_PATTERN.test(name) || description.trim() === '' || parsed.content.trim() === '') return undefined
|
|
86
|
+
return {
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
// The skill is model-facing procedure; the human-facing surface is the
|
|
90
|
+
// `/git-commit-push` command, so this skill is not offered as a user command.
|
|
91
|
+
invocation: { modelInvocable: true, userInvocable: false },
|
|
92
|
+
source: SKILL_SOURCE,
|
|
93
|
+
content: parsed.content,
|
|
94
|
+
}
|
|
95
|
+
}
|
package/lib/survey.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One repository survey: everything the plugin needs to describe a changeset,
|
|
3
|
+
* gathered with a fixed, small number of git calls.
|
|
4
|
+
*
|
|
5
|
+
* Nothing in here writes to the repository. The only content it reads are
|
|
6
|
+
* bounded diff samples, which is what keeps a survey cheap enough that a model
|
|
7
|
+
* can afford to call it before every commit.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
currentBranch, git, gitAvailable, hasUpstream, headShort, isRepo,
|
|
11
|
+
numstat, rawDiff, recentSubjects, repoRoot, resolveRepo, status,
|
|
12
|
+
} from './git.js'
|
|
13
|
+
import {
|
|
14
|
+
VERSION_FILE, buildMessage, declaredSymbols, removedDeclarationCount, scopeOf, typeOfPath,
|
|
15
|
+
} from './analyze.js'
|
|
16
|
+
|
|
17
|
+
/** Git porcelain XY codes collapsed to one display status letter. */
|
|
18
|
+
function statusLetter(xy) {
|
|
19
|
+
const index = xy[0]
|
|
20
|
+
const worktree = xy[1]
|
|
21
|
+
if (xy === '??') return '?'
|
|
22
|
+
if (xy === '!!') return '!'
|
|
23
|
+
// `--porcelain=v2` uses '.' for "unchanged"; never let it fall through to 'M'.
|
|
24
|
+
if (index === 'R') return 'R'
|
|
25
|
+
if (index === 'C') return 'C'
|
|
26
|
+
if (index === 'D' || worktree === 'D') return 'D'
|
|
27
|
+
if (index === 'A') return 'A'
|
|
28
|
+
if (index === 'U' || worktree === 'U' || xy === 'AA' || xy === 'DD') return 'U'
|
|
29
|
+
if (worktree === 'M' || index === 'M') return 'M'
|
|
30
|
+
if (index === '.' && worktree === '.') return 'M'
|
|
31
|
+
return 'M'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize porcelain entries into the plugin's change vocabulary.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ xy: string, path: string, origPath?: string }[]} raw
|
|
38
|
+
* @returns {{ status: string, path: string, staged: boolean, unstaged: boolean, origPath?: string }[]}
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeEntries(raw) {
|
|
41
|
+
return raw.map(entry => ({
|
|
42
|
+
status: statusLetter(entry.xy),
|
|
43
|
+
path: entry.path,
|
|
44
|
+
staged: entry.xy !== '??' && entry.xy !== '!!' && entry.xy[0] !== ' ' && entry.xy[0] !== '?',
|
|
45
|
+
unstaged: entry.xy === '??' || entry.xy[1] !== ' ',
|
|
46
|
+
...(entry.origPath === undefined ? {} : { origPath: entry.origPath }),
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract a version string from JSON, TOML, YAML or MSBuild text.
|
|
52
|
+
*
|
|
53
|
+
* The quote handling is load-bearing: in package.json the line is
|
|
54
|
+
* ` "version": "1.1.0",`, so a pattern anchored on a bare `version` key never
|
|
55
|
+
* matches and the tag question would silently never fire for the single most
|
|
56
|
+
* common version-carrying file.
|
|
57
|
+
*/
|
|
58
|
+
function parseVersion(text) {
|
|
59
|
+
if (typeof text !== 'string' || text === '') return undefined
|
|
60
|
+
try {
|
|
61
|
+
const data = JSON.parse(text)
|
|
62
|
+
if (typeof data?.version === 'string') return data.version
|
|
63
|
+
if (typeof data?.Project?.Version === 'string') return data.Project.Version
|
|
64
|
+
} catch {
|
|
65
|
+
// Not JSON; fall through to the text patterns.
|
|
66
|
+
}
|
|
67
|
+
const patterns = [
|
|
68
|
+
// "version": "1.1.0" / version = '1.1.0' / version: 1.1.0
|
|
69
|
+
/^\s*["']?version["']?\s*[:=]\s*["']?(v?\d[\w.\-+]*)["']?/mi,
|
|
70
|
+
// MSBuild: <Version>1.1.0</Version>
|
|
71
|
+
/<Version>\s*([\w.\-+]+)\s*<\/Version>/i,
|
|
72
|
+
// Python module dunder: __version__ = "1.1.0"
|
|
73
|
+
/^\s*__version__\s*[:=]\s*["']?(v?\d[\w.\-+]*)["']?/mi,
|
|
74
|
+
]
|
|
75
|
+
for (const pattern of patterns) {
|
|
76
|
+
const match = pattern.exec(text)
|
|
77
|
+
if (match !== null) return match[1]
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Detect a version bump by reading both sides from the repository itself.
|
|
84
|
+
*
|
|
85
|
+
* Both sides come from git (`git show HEAD:<f>` and `git show :<f>` — the index),
|
|
86
|
+
* never from the working tree, so an unstaged edit can never be reported as the
|
|
87
|
+
* version being committed, and the two sides are read in exactly the form
|
|
88
|
+
* `git add -A` + commit would use.
|
|
89
|
+
*
|
|
90
|
+
* @returns {Promise<{ file: string, from?: string, to?: string } | undefined>}
|
|
91
|
+
*/
|
|
92
|
+
async function detectVersion(root, files) {
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
let staged
|
|
95
|
+
try {
|
|
96
|
+
staged = await git(root, ['diff', '--cached', '--no-color', '--', file], { timeoutMs: 15_000 })
|
|
97
|
+
} catch {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (staged.trim() === '') continue
|
|
101
|
+
|
|
102
|
+
// Read the staged blob in full rather than rejoining the diff's added lines:
|
|
103
|
+
// a re-joined fragment is not valid JSON, which is exactly how a package.json
|
|
104
|
+
// bump used to be missed.
|
|
105
|
+
const to = parseVersion(await showBlob(root, `:${file}`))
|
|
106
|
+
if (to === undefined) continue
|
|
107
|
+
const from = parseVersion(await showBlob(root, `HEAD:${file}`))
|
|
108
|
+
if (from === to) continue
|
|
109
|
+
return { file, ...(from === undefined ? {} : { from }), to }
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Best-effort read of one blob; undefined when the revision has no such path. */
|
|
115
|
+
async function showBlob(root, rev) {
|
|
116
|
+
try {
|
|
117
|
+
return await git(root, ['show', rev], { timeoutMs: 15_000 })
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read a repository and describe its pending changes.
|
|
125
|
+
*
|
|
126
|
+
* One deliberate side effect: when a version-carrying file is among the
|
|
127
|
+
* changes, that file is staged before the version is read. A version bump is
|
|
128
|
+
* only visible in `git diff --cached`, and this is what lets `prepare` report
|
|
129
|
+
* "1.2.3 → 1.2.4" — and therefore offer the right tag — without a second tool
|
|
130
|
+
* call. The commit path stages everything anyway, so nothing is decided early.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} input
|
|
133
|
+
* @param {string} input.cwd the session working directory
|
|
134
|
+
* @param {string} [input.cwdOverride] an explicit repository path from the caller
|
|
135
|
+
* @param {number} [input.maxFilesShown]
|
|
136
|
+
* @param {'zh' | 'en'} [input.language]
|
|
137
|
+
* @returns {Promise<
|
|
138
|
+
* | { ok: false, notRepo: true, candidates: string[] }
|
|
139
|
+
* | { ok: false, notRepo?: false, reason: string, message: string }
|
|
140
|
+
* | { ok: true, root: string, branch: string, entries: any[], stats: Map<string, any>,
|
|
141
|
+
* recentSubjects: string[], version?: object, breaking: boolean,
|
|
142
|
+
* draft: { message: string, type: string, scope?: string, subject: string },
|
|
143
|
+
* hasUpstream: boolean, head?: string, unifiedDiff: string }
|
|
144
|
+
* >}
|
|
145
|
+
*/
|
|
146
|
+
export async function survey(input) {
|
|
147
|
+
const { cwd, cwdOverride, language = 'zh', maxFilesShown = 12 } = input
|
|
148
|
+
|
|
149
|
+
const resolved = await resolveRepo(cwd, cwdOverride)
|
|
150
|
+
if ('notRepo' in resolved) {
|
|
151
|
+
// "git is not installed" and "this directory is not a repository" are
|
|
152
|
+
// different problems with different fixes; never report one as the other.
|
|
153
|
+
if (!await gitAvailable()) {
|
|
154
|
+
return { ok: false, reason: 'git-unavailable', message: '找不到 git 可执行文件,无法提交。请先安装 Git 并确保它在 PATH 中。' }
|
|
155
|
+
}
|
|
156
|
+
return { ok: false, notRepo: true, candidates: resolved.candidates }
|
|
157
|
+
}
|
|
158
|
+
const root = resolved.root
|
|
159
|
+
|
|
160
|
+
const [rawStatus, branch, upstream, head, subjects, diffText] = await Promise.all([
|
|
161
|
+
status(root),
|
|
162
|
+
currentBranch(root),
|
|
163
|
+
hasUpstream(root),
|
|
164
|
+
headShort(root),
|
|
165
|
+
recentSubjects(root, 8),
|
|
166
|
+
rawDiff(root),
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
const entries = normalizeEntries(rawStatus)
|
|
170
|
+
if (entries.length === 0) {
|
|
171
|
+
return { ok: false, reason: 'clean', message: '没有需要提交的改动' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const tracked = await numstat(root, false)
|
|
175
|
+
const stats = new Map(tracked)
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!stats.has(entry.path)) stats.set(entry.path, { added: 0, deleted: 0, binary: false })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const breaking = removedDeclarationCount(diffText) > 0
|
|
181
|
+
const versionFiles = entries.map(entry => entry.path).filter(path => VERSION_FILE.test(path))
|
|
182
|
+
|
|
183
|
+
// A version bump is only detectable once the file is staged, so stage the
|
|
184
|
+
// version-carrying files and then re-read the index. The commit path stages
|
|
185
|
+
// the whole working tree afterwards, so this never narrows what gets committed.
|
|
186
|
+
let version
|
|
187
|
+
if (versionFiles.length > 0) {
|
|
188
|
+
try {
|
|
189
|
+
await git(root, ['add', '-A', '--', ...versionFiles], { timeoutMs: 20_000 })
|
|
190
|
+
} catch {
|
|
191
|
+
// Best effort: an unstaged version file only means no tag is suggested.
|
|
192
|
+
}
|
|
193
|
+
version = await detectVersion(root, versionFiles)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const draft = buildMessage({
|
|
197
|
+
entries: entries.map(entry => ({ status: entry.status, path: entry.path, ...(entry.origPath === undefined ? {} : { origPath: entry.origPath }) })),
|
|
198
|
+
stats,
|
|
199
|
+
unifiedDiff: diffText,
|
|
200
|
+
recentSubjects: subjects,
|
|
201
|
+
language,
|
|
202
|
+
maxFiles: maxFilesShown,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
root,
|
|
208
|
+
branch,
|
|
209
|
+
entries,
|
|
210
|
+
stats,
|
|
211
|
+
recentSubjects: subjects,
|
|
212
|
+
...(version === undefined ? {} : { version }),
|
|
213
|
+
breaking,
|
|
214
|
+
draft,
|
|
215
|
+
hasUpstream: upstream,
|
|
216
|
+
...(head === undefined ? {} : { head }),
|
|
217
|
+
unifiedDiff: diffText,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Re-exported so callers can classify a path without importing analyze.js. */
|
|
222
|
+
export { typeOfPath, scopeOf, declaredSymbols }
|
|
223
|
+
|
|
224
|
+
/** Whether `dir` is a work-tree root, for the container-cwd error message. */
|
|
225
|
+
export { isRepo, repoRoot }
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A throwaway git repository for tests.
|
|
3
|
+
*
|
|
4
|
+
* Everything lives under the OS temp directory and is removed on cleanup, so a
|
|
5
|
+
* test run can exercise real git without touching any repository the user
|
|
6
|
+
* cares about. Git's global and system config are disabled inside the fixture,
|
|
7
|
+
* which makes the commit identity explicit and keeps a user's `commit.gpgsign`,
|
|
8
|
+
* hooks or templates from leaking into a test.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from 'node:child_process'
|
|
11
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { dirname, join } from 'node:path'
|
|
13
|
+
import { tmpdir } from 'node:os'
|
|
14
|
+
import { isolatedGitEnv } from './git.js'
|
|
15
|
+
|
|
16
|
+
/** Deterministic identity so a fixture never depends on the machine's config. */
|
|
17
|
+
const IDENTITY = { name: 'dsh-plugin-test', email: 'test@example.invalid' }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a repository with one empty commit in a fresh temp directory.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} [prefix] temp-directory prefix, for readable diagnostics
|
|
23
|
+
* @returns {{ dir: string, git: (args: string[]) => string, write: (path: string, text: string) => void, rename: (from: string, to: string) => void, remove: (path: string) => void, commitAll: (message: string) => void, cleanup: () => void }}
|
|
24
|
+
*/
|
|
25
|
+
export function createFixtureRepo(prefix = 'git-commit-push-fixture-') {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
27
|
+
// `isolatedGitEnv()` carries the per-platform `/dev/null` spelling too, which
|
|
28
|
+
// is what keeps this fixture correct on Windows.
|
|
29
|
+
const env = isolatedGitEnv()
|
|
30
|
+
|
|
31
|
+
/** Run one git command in the fixture and return stdout. */
|
|
32
|
+
const git = args => execFileSync('git', args, { cwd: dir, encoding: 'utf8', env })
|
|
33
|
+
|
|
34
|
+
/** Run git with the fixture's pinned identity (only commits need one). */
|
|
35
|
+
const committed = args => git([
|
|
36
|
+
'-c', `user.name=${IDENTITY.name}`, '-c', `user.email=${IDENTITY.email}`, ...args,
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
git(['init', '-q', '-b', 'main'])
|
|
40
|
+
committed(['commit', '-q', '--allow-empty', '-m', 'chore: init'])
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
dir,
|
|
44
|
+
git,
|
|
45
|
+
write(path, text) {
|
|
46
|
+
const absolute = join(dir, path)
|
|
47
|
+
mkdirSync(dirname(absolute), { recursive: true })
|
|
48
|
+
writeFileSync(absolute, text)
|
|
49
|
+
},
|
|
50
|
+
rename(from, to) {
|
|
51
|
+
renameSync(join(dir, from), join(dir, to))
|
|
52
|
+
},
|
|
53
|
+
remove(path) {
|
|
54
|
+
rmSync(join(dir, path), { force: true })
|
|
55
|
+
},
|
|
56
|
+
commitAll(message) {
|
|
57
|
+
git(['add', '-A'])
|
|
58
|
+
committed(['commit', '-q', '-m', message])
|
|
59
|
+
},
|
|
60
|
+
cleanup() {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true })
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Git commit & push",
|
|
4
|
+
"description": "One call commits and pushes: surveys the working tree, writes a Conventional Commits message, asks about a tag when one is warranted, then pushes. Also ships a /git-commit-push command that runs without the model."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-git-commit-push",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "One-call Conventional-Commits git workflow for DeepSeek Harness: survey + commit + optional tag + push, plus a /git-commit-push slash command that runs with no model involvement. Windows and macOS/Linux.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./package.json": "./package.json",
|
|
10
|
+
"./locale/*": "./locale/*"
|
|
11
|
+
},
|
|
12
|
+
"icon": "./icon.svg",
|
|
13
|
+
"dsh": {
|
|
14
|
+
"manifestVersion": 1,
|
|
15
|
+
"bundle": {
|
|
16
|
+
"patch": "./cordis.patch.yml"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.23.0",
|
|
21
|
+
"dsh": ">=0.2.0-rc.1 <0.3.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.1"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@deepseek-ai/dsh-tools": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node self-test.mjs && node self-test-git.mjs",
|
|
36
|
+
"probe": "node capture-git-format.mjs",
|
|
37
|
+
"prepublishOnly": "node self-test.mjs && node self-test-git.mjs"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"dsh",
|
|
41
|
+
"dsh-plugin",
|
|
42
|
+
"deepseek",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"cordis-plugin",
|
|
45
|
+
"git",
|
|
46
|
+
"commit",
|
|
47
|
+
"push",
|
|
48
|
+
"conventional-commits",
|
|
49
|
+
"tag",
|
|
50
|
+
"agent"
|
|
51
|
+
],
|
|
52
|
+
"author": "ygzhang <ygzhang.cn@msn.com>",
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/ygzhang-lab/dsh-plugin-git-commit-push.git"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/ygzhang-lab/dsh-plugin-git-commit-push#readme",
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/ygzhang-lab/dsh-plugin-git-commit-push/issues"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public",
|
|
64
|
+
"registry": "https://registry.npmjs.org/"
|
|
65
|
+
},
|
|
66
|
+
"files": [
|
|
67
|
+
"index.js",
|
|
68
|
+
"lib",
|
|
69
|
+
"cordis.patch.yml",
|
|
70
|
+
"locale",
|
|
71
|
+
"icon.svg",
|
|
72
|
+
"git-commit-push.config.json",
|
|
73
|
+
"setup.ps1",
|
|
74
|
+
"setup.sh",
|
|
75
|
+
"self-test.mjs",
|
|
76
|
+
"self-test-git.mjs",
|
|
77
|
+
"capture-git-format.mjs",
|
|
78
|
+
"e2e-check.mjs",
|
|
79
|
+
"SKILL.md",
|
|
80
|
+
"README.md",
|
|
81
|
+
"README.en.md"
|
|
82
|
+
]
|
|
83
|
+
}
|