一键提交推送 ygzhang-lab/dsh-plugin-git-commit-push
DeepSeek Harnessv1.0.4一条命令搞定 DSH 的 Conventional-Commits git 工作流:检查 + 提交 + 可选打标签 + 推送,另带 /commit-push 斜杠命令,全程不调用模型。支持 Windows 和 macOS/Linux。
数据截至 2026/10/15 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
dsh plugin --profile web add ygzhang-lab/dsh-plugin-git-commit-push状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
安装兼容性检查需源码安装engines.node 要求 >=24,不满足 Node 22.19.0
展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
源码安装时若 pnpm 拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑。
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/13 04:29:09
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
查看扫描证据(共 129 处,展示前 56 条)
- 读取本地文件lib/config.js:161
text = await readFile(path, 'utf8') - 读取本地文件lib/config.js:237
const template = JSON.parse(readFileSync(PACKAGE_CONFIG_PATH, 'utf8')) - 写入本地文件lib/config.js:244
mkdirSync(dirname(path), { recursive: true }) - 写入本地文件lib/config.js:251
writeFileSync(path, body, { encoding: 'utf8', flag: 'wx' }) - 读取本地文件lib/skill.js:79
parsed = parseSkillFile(readFileSync(SKILL_PATH, 'utf8')) - 读取本地文件lib/survey.js:199
const stats = lstatSync(join(root, file)) - 读取本地文件lib/survey.js:201
return parseVersion(readFileSync(join(root, file), 'utf8')) !== undefined - 执行外部命令固定命令lib/git.js:113
child = spawn('git', argv, { - 读取本地文件lib/git.js:212
entries = await readdir(dir, { withFileTypes: true }) - 读取本地文件lib/git.js:577
if (existsSync(gitDir)) return true - 读取本地文件lib/git.js:579
return existsSync(applyDir) - 执行外部命令固定命令capture-git-format.mjs:34
return execFileSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }) - 写入本地文件capture-git-format.mjs:45
mkdirSync(join(cwd, 'src'), { recursive: true }) - 写入本地文件capture-git-format.mjs:46
writeFileSync(join(cwd, 'src', 'keep.js'), 'export const keep = 1\n') - 写入本地文件capture-git-format.mjs:47
writeFileSync(join(cwd, 'src', 'old-name.js'), 'export const moved = 1\n') - 写入本地文件capture-git-format.mjs:48
writeFileSync(join(cwd, 'src', 'gone.js'), 'export const gone = 1\n') - 写入本地文件capture-git-format.mjs:49
writeFileSync(join(cwd, 'src', '中文.js'), 'export const zh = 1\n') - 写入本地文件capture-git-format.mjs:54
writeFileSync(join(cwd, 'src', 'keep.js'), 'export const keep = 2\n') - 删除本地文件capture-git-format.mjs:58
rmSync(join(cwd, 'src', 'gone.js')) - 删除本地文件递归capture-git-format.mjs:91
rmSync(cwd, { recursive: true, force: true }) - 读取本地文件lib/profile-edit.mjs:141
raw = readFileSync(manifestPath, 'utf8') - 写入本地文件lib/profile-edit.mjs:168
if (status !== 'unchanged') writeFileSync(manifestPath, text, { encoding: 'utf8' }) - 读取本地文件lib/profile-edit.mjs:171
const verify = JSON.parse(readFileSync(manifestPath, 'utf8')) - 读取本地文件lib/profile-link.mjs:144
stats = lstatSync(target) - 删除本地文件lib/profile-link.mjs:157
unlinkSync(target) - 删除本地文件递归lib/profile-link.mjs:161
rmSync(target, { recursive: true, force: true, maxRetries: 3 }) - 删除本地文件lib/profile-link.mjs:164
rmSync(target, { force: true }) - 读取本地文件lib/profile-link.mjs:190
const stats = lstatSync(dest) - 写入本地文件lib/profile-link.mjs:195
mkdirSync(nodeModules, { recursive: true }) - 执行外部命令固定命令lib/test-fixture.mjs:32
const git = args => execFileSync('git', args, { cwd: dir, encoding: 'utf8', env }) - 写入本地文件lib/test-fixture.mjs:47
mkdirSync(dirname(absolute), { recursive: true }) - 写入本地文件lib/test-fixture.mjs:48
writeFileSync(absolute, text) - 写入本地文件lib/test-fixture.mjs:51
renameSync(join(dir, from), join(dir, to)) - 删除本地文件lib/test-fixture.mjs:54
rmSync(join(dir, path), { force: true }) - 删除本地文件递归lib/test-fixture.mjs:61
rmSync(dir, { recursive: true, force: true }) - 删除本地文件递归self-test-git.mjs:654
rmSync(bareRoot, { recursive: true, force: true }) - 删除本地文件递归self-test-git.mjs:779
rmSync(bareRoot, { recursive: true, force: true }) - 读取本地文件self-test.mjs:58
const realUserConfigBefore = existsSync(REAL_USER_CONFIG) ? statSync(REAL_USER_CONFIG).mtimeMs : undefined - 读取本地文件self-test.mjs:58
const realUserConfigBefore = existsSync(REAL_USER_CONFIG) ? statSync(REAL_USER_CONFIG).mtimeMs : undefined - 删除本地文件递归self-test.mjs:65
rmSync(sandboxHome, { recursive: true, force: true }) - 读取本地文件self-test.mjs:1011
const text = readFileSync(file, 'utf8') - 读取本地文件self-test.mjs:1094
assert.equal(existsSync(join(PACKAGE_DIR, 'lib', 'schema.js')), false, 'lib/schema.js must stay deleted') - 读取本地文件self-test.mjs:1095
const entryText = readFileSync(join(PACKAGE_DIR, 'index.js'), 'utf8') - 读取本地文件self-test.mjs:1099
const configText = readFileSync(join(PACKAGE_DIR, 'lib', 'config.js'), 'utf8') - 写入本地文件self-test.mjs:1162
mkdirSync(join(root, 'node_modules')) - 删除本地文件递归self-test.mjs:1186
rmSync(root, { recursive: true, force: true }) - 删除本地文件递归self-test.mjs:1216
rmSync(home, { recursive: true, force: true }) - 写入本地文件self-test.mjs:1223
writeFileSync(file, JSON.stringify({ tagPrefix: 'release-', tagOnFileCount: 3 })) - 写入本地文件self-test.mjs:1244
writeFileSync(join(home, 'git-commit-push.config.json'), '{ "autoPush": tru }') - 写入本地文件self-test.mjs:1282
writeFileSync(path, '{ "tagPrefix": "mine-" }') - 写入本地文件self-test.mjs:1313
writeFileSync(blocked, 'x') - 删除本地文件递归self-test.mjs:1322
rmSync(home, { recursive: true, force: true }) - 写入本地文件self-test.mjs:1446
mkdirSync(destination) - 删除本地文件递归self-test.mjs:1464
rmSync(destination, { recursive: true, force: true }) - 删除本地文件递归self-test.mjs:1517
rmSync(root, { recursive: true, force: true }) - 执行外部命令来自配置/变量self-test.mjs:1712
const runCli = extra => execFileSync(process.execPath, [
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
@deepseek-ai/dsh-tools给这个插件打分还没有人投票↓
功能亮点
- 自动生成提交信息
- 提交并推送远程
- 可选打标签
README
由 DeepSeek 最新模型翻译生成展开完整文档(README 剩余 1 节)
dsh-plugin-git-commit-push
DSH(DeepSeek Harness)0 Token Git 提交推送插件,一次调用完成:汇总改动,按 Conventional Commits 为每个变更文件自动生成提交信息,按 type(scope) 拆成多次提交后一次推送到 Git,必要时询问打标签,然后推送到当前项目配置的 Git 远程仓库。
- 通过 /git-commit-push 斜杠命令使用,0 Token 即可完成提交与推送(不经过模型,脚本自动按规则生成简单明了的 Commits,并按 type(scope) 拆成多次提交);
- 通过 git_commit_push 工具调用,极少 Token 即可完成高质量的注释和按 type(scope) 拆分的多次提交与一次推送。
本插件是早期 .agents/skills/git-commit-push 这个 Skill 的工具化替代,把纯 Skill 工作需要模型跑十来条 Shell、读一堆 Git 原始输出的流程,压成 1–2 次工具调用 + 一张紧凑卡片。
装好之后你得到三样东西:
| 表面 | 名字 | 谁用它 |
| 工具 | git_commit_push(prepare / apply / auto) | 模型 |
| 斜杠命令 | /git-commit-push | 人,完全不经过模型 |
| Skill | git-commit-push | 模型,按需加载的完整流程说明 |
- 平台:Windows 与 macOS / Linux 都可用(见「跨平台」)
- 要求:DSH >=0.2.0-rc.1 <0.3.0、Node >=24、git >=2.36
安装
方式 A:插件页(推荐)
DSH → 设置 → 插件 → 安装框里填包名: dsh-plugin-git-commit-push
装完按提示重启 DSH。同一个页面还能启用 / 停用 / 卸载它——这正是本包声明 dsh.bundle.patch、把自己做成「组合包」(bundle)的原因。没有这个声明的包,插件页会回一句 「这个包没有声明组合包,不能作为插件管理」(host 侧错误码 not-bundle)。
方式 B:命令行
安装
dsh plugin --profile <profile> add dsh-plugin-git-commit-push
dsh plugin --profile web add dsh-plugin-git-commit-push
卸载
dsh plugin --profile <profile> remove dsh-plugin-git-commit-push
dsh plugin --profile web remove dsh-plugin-git-commit-push
<profile> 是你的 profile 名(如 web、headless、自定义名)。装/卸都会改 profile 的 package.json(依赖 + dsh.profile.bundles),需要重启 DSH 才生效。
方式 C:源码 / 离线安装(开发用)
克隆本仓库后,用仓库里的脚本把 checkout 以 link: 形式挂进 profile:
Windows(默认 profile: desktop) powershell -ExecutionPolicy Bypass -File .\setup.ps1 powershell -ExecutionPolicy Bypass -File .\setup.ps1 -Profile web powershell -ExecutionPolicy Bypass -File .\setup.ps1 -Uninstall
macOS / Linux
sh setup.sh # 默认 profile: desktop sh setup.sh web # 指定 profile sh setup.sh web --uninstall
两个脚本做两件事(幂等、带备份):把 checkout 写成 profile 的 link: 依赖、把包名加进 dsh.profile.bundles,然后在 profile 目录跑 pnpm install。挂载行不在脚本里——它由本包自己的组合包 patch 提供(见「附:DSH 组合包契约」)。脚本还会清理老版本写进 profile cordis.patch.yml 的那段挂载行,避免同一个 id 被挂两次。
三种方式不要混用:同一个包在 profile 里只应有一处挂载。
安装报错 [ERR_PNPM_EPERM] [importPackage …\node_modules\dsh-plugin-git-commit-push]
[ERR_PNPM_EPERM] [importPackage ...\node_modules\dsh-plugin-git-commit-push]
EPERM: operation not permitted, rename '...dsh-plugin-git-commit-push_tmp_<pid>_<n>' -> '...dsh-plugin-git-commit-push'
原因:pnpm 导入一个包的方式,是先建一个临时目录、再把它 rename 到 node_modules/<名字> 上;而 rename 不能覆盖一个已存在且非空的目录,于是以 EPERM 失败(在 Windows / Node 24 上实测复现)。
最常见的残留来自更早的一次 link: 安装——一个 junction / 符号链接——它指向的 checkout 后来被移动或删掉了(本包自己就曾被以 link: 形式挂到 ~/.dsh/local-plugins/...)。
pnpm 删不掉一个悬空链接,于是重试、然后报错;而那个包其实已经存在到足以让 DSH 重启后把它显示成
「已安装」。
修复:setup.ps1 与 setup.sh 现在会在跑 pnpm install 之前清掉这个条目,走的是两个脚本共用的、经过审查的 lib/profile-link.mjs——链接只会被 unlink,绝不被跟随,所以它指向的 checkout 一个文件都不会少(同一个模块在 --uninstall 时也会跑)。
别删掉一个还好的链接:pnpm 不会重建一个它认为已经装好的 link: 条目——manifest 与 lockfile 都没变时,pnpm install 只会说 Already up to date 就结束,--force 也一样(本机实测)。所以 lib/profile-link.mjs 现在会先判断「这个链接是不是已经指向本次要装的那份 checkout」,是就返回 kept 原样留着;只有在真正清掉了条目(悬空链接 / 指向别的 checkout / 陈旧目录)之后,安装脚本才会在 pnpm install 之后用 --ensure-link 把那个洞补上(补出来的就是 pnpm 自己会建的那种 junction/symlink,且绝不覆盖已存在的东西)。这条路径是实测踩出来的:早期版本重跑一次 setup.ps1 会把好好的 junction 删掉,pnpm 拒绝重建,profile 里于是静悄悄少了这个插件。
用 Plugins 页卸载过 link: 版、再从 npm 安装:这就是最常见的触发场景。Plugins 页的卸载只改 profile manifest、再跑一次 pnpm install,而 pnpm 清一个 link: 依赖时会打印 Already up to date 并把那个条目留在原地(pnpm 11.7.0 实测)——于是紧接着的 npm 安装必然撞上上面这个 EPERM。要从 link: 版换成 npm 版,先跑 setup.ps1 -Uninstall(macOS / Linux:sh setup.sh --uninstall),或者按下面的手工修复先删掉那个条目,再去 Plugins 页安装。
这个失败不会破坏你的 checkout:实测 rename 失败后链接指向的文件一个不少(self-test.mjs 里守着这条平台前提);反过来说,报错也不代表装上了——以 Plugins 页显示的版本与 node_modules/<名字> 是否为真目录为准。
手动修复(没跑脚本、安装仍然这样失败时):先删掉这个陈旧条目,再重试安装。
Windows
Remove-Item -LiteralPath "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-plugin-git-commit-push" -Recurse -Force
macOS / Linux
rm -rf ~/.dsh/profiles/desktop/node_modules/dsh-plugin-git-commit-push
删掉之后 pnpm install 若仍报 Already up to date 而不重建条目(上面那条 pnpm 行为),用同一模块显式把链接补回来——它补出来的就是 pnpm 自己会建的那种 junction/symlink,且绝不覆盖已存在的东西:
Windows:<checkout> 替换成你的源码目录 & "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" "<checkout>\lib\profile-link.mjs" --profile-dir "$env:USERPROFILE\.dsh\profiles\desktop" --package dsh-plugin-git-commit-push --ensure-link "<checkout>"
macOS / Linux
node "<checkout>/lib/profile-link.mjs" --profile-dir ~/.dsh/profiles/desktop \
--package dsh-plugin-git-commit-push --ensure-link "<checkout>"
已经发布了新版本,插件页却升不上去(pnpm 的 release-age 策略)
pnpm 11 的供给链策略会拒绝「发布时长不足 N 分钟」的版本,而豁免项写成带版本的形式时,只放行你写的那一个版本:
%USERPROFILE%\.dsh\profiles\desktop\pnpm-workspace.yaml
minimumReleaseAgeExclude:
- dsh-plugin-git-commit-push@1.0.1 # 只放行 1.0.1 —— 发了 1.0.2 照样被挡
把版本号去掉、按包名豁免,本插件以后所有版本都不再受这条策略限制(实测:同一份空 lock 重新解析,改之前只肯给 1.0.1,改之后直接给最新版本):
minimumReleaseAgeExclude:
- dsh-plugin-git-commit-push
另外注意:pnpm install 遵守 lockfile,所以在插件页「卸载再装」不一定换到新版本;要指定版本就用 pnpm add dsh-plugin-git-commit-push@1.0.3(或插件页的更新动作),它会重写 lock 里那一条。
装完确认
重启 DSH 后:
- 模型能看到 git_commit_push 工具,输入框里能敲 /git-commit-push;
- 设置 → 插件里能看到本包(标题「Git 提交与推送」,带图标),可启用 / 停用 / 卸载;
- 模型技能目录里有 git-commit-push;
- 设置页里没有本插件的配置表单(本插件不导出 Config schema,见「配置」):配置只有 JSON 文件一条通道,改完即时生效、无需重启。
触发条件(务必先读)
只在两种情况下使用,绝不自动触发:
1. 用户敲斜杠命令 /git-commit-push —— 由命令直接执行,完全不经过模型;
2. 用户明确要求 git 提交/推送 —— 「提交」「commit」「推送」「push」这类直白指令。
改完代码、任务完成、会话快结束、用户说「存档」「好了」——都不是触发条件。 编辑文件不等于要求提交。
拿不准时先问一句,而不是直接提交。
这条规则写在三个模型可见的位置:git_commit_push 的工具描述(模型选工具时读到的第一手信息)、随包出货的 SKILL.md(挂载时注册为嵌入式 skill),以及命令自身的描述。
插件无法在 execute 内强制执行该规则——调用发生时决定已经做出。
「0 token 完成 commit push」这个表述准确吗?
部分准确,需要限定。 准确的划分是「哪条路径、消耗什么」:
| 路径 | 模型 token | 说明 |
| 1. 斜杠命令 /git-commit-push | 0(真的 0) | 完全不产生模型请求。命令的发现、执行与 UI 输出不产生模型 token,结果只渲染在 UI 里、不进对话历史。这是唯一真正 0 token 的路径。 |
| 2. 工具 git_commit_push(auto) | 少量 | 普通「提交并推送」走这条:一次调用,模型只生成这次工具调用(参数 + 思考)。提交信息由插件自己写,所以没有第二轮;返回卡片约 200 token。 |
| 3. 工具 prepare + apply | 约 2 次往返 | 只在用户想自己挑提交信息时用:只有 prepare 卡片(约 200 token)和 apply 的 message 参数进上下文,不读 diff。 |
所以:
- 说「斜杠命令 0 token」—— 准确。
- 说「git_commit_push 工具 0 token」—— 不准确:模型仍要为每次调用付 token(参数 + 卡片)。
- 说「省 token」—— 准确,但省的是比较级:省掉读 git 原始输出(数千 token → 约 200)与多轮往返。
还有一个固定成本必须说清楚:只要插件装着且工具对模型可见,它的 schema(描述约 1.5 KB + 参数)会进入每次请求,约 600 token。
若你只想用 /git-commit-push、从不让模型调用它,把工具移出模型可见面(或改用 deferLoading 按需加载)可以省掉这笔固定成本。
一句话:/git-commit-push 是 0 token;工具路径是「更省 token」,不是 0。
用
git_commit_push({ action: "auto" }) # 普通「提交并推送」就这一条:一步提交 + 推送
git_commit_push({ action: "prepare" }) # 只汇总,不改仓库(用户想挑提交信息时)
git_commit_push({ action: "apply", message: "feat(x): …" }) # 提交 + 推送,用你写的标题
git_commit_push({ action: "auto", tag: "v1.2.3" })
git_commit_push({ action: "prepare", cwd: "/path/to/repo" }) # 会话目录不是仓库时
普通的一次「提交并推送」只调一次:action="auto"(斜杠命令走的就是这条路径,插件自己按规则写提交信息,并按 type(scope) 拆成多次提交)。
prepare + apply 只在用户想自己挑提交信息、或想先看一遍改动时才值得用——prepare 给出文件清单、逐文件拟定注释和这次会拆成哪几次提交,你读完再决定标题。
/git-commit-push 变体(0 token,完全不经过模型): /git-commit-push、/git-commit-push --prepare、/git-commit-push --no-push、/git-commit-push --en、/git-commit-push --tag=v1.2.3、/git-commit-push 修复登录超时。
卡片长什么样
预览(prepare / /git-commit-push --prepare)——第一行就是结论,每个文件后面跟着它自己的拟定注释 (这一张就是给人看的复核面,列几个文件由 maxFilesShown 决定):
🔎 检查到 3 个文件改动(未提交) · main · +48 / -12 状态:新增 1 / 修改 1 / 删除 1 新增 src/foo/bar.ts +40/-0 → feat(foo): 新增 bar 组件修改 src/foo/baz.ts +8/-10 → fix(foo): 修正 parseThing 判断删除 src/old.ts → refactor: 移除 old 标签依据:版本号 1.2.3 → 1.2.4(package.json)
最近提交风格:"feat(ui): 新增主题切换" "fix(api): 修正重试判断"
拟定提交(共 3 次,按 type(scope) 拆分):
feat(foo): 更新 bar 组件
fix(foo): 修正 parseThing 判断
refactor: 移除 old (仅预览,未提交未推送)
提交后(结果卡片只有结论 + 计数摘要 + 提交标题列表 / 标签 / 推送,至多一行 说明:):
✅ Git 提交并推送成功 · main · a1b2c3d 检查到 3 个文件改动,本次提交 3 个文件(+48 / -12) · 3 次提交提交:
feat(foo): 更新 bar 组件
fix(foo): 修正 parseThing 判断
refactor: 移除 old 标签:v1.2.4 推送:已推送(含标签)
结果卡片不再逐条列出每个文件的注释:那些注释留在各自提交的正文里,git log 一看就有(见下一节)。
卡片是给人确认「活儿落地没有」的,不是过程日志。多次提交时最多列 3 条标题,其余折叠成 …另有 N 次提交;只有一次提交时保持原来的单行 信息:<标题> 形态。
四种结果一眼可辨,不会再出现「卡片只有一行、以为没干活」:
| 首行 | 含义 |
| ✅ Git 提交并推送成功 | 提交也推送了 |
| ✅ Git 提交成功(未推送) | 提交成功,autoPush 关闭或本次 --no-push |
| ⚠️ 已提交,但推送失败 | 提交在本地,推送失败(说明: 里有原因) |
| ❌ 提交失败 | 提交没成功,改动原样留在工作区 |
其他状态:ℹ️ 没有需要提交的改动、⚠️ 当前目录不是 Git 仓库(并列出候选仓库)、❌ 未初始化 Git、❌ 找不到 git。
多文件:按 type(scope) 拆成多次提交
每个文件先得到它自己的 Conventional 注释(类型、scope、符号都取自它自己:docs/ → docs:、.test.ts → test:,scope 与类型重复时省略,不会出现 docs(docs)),然后按注释里的 type(scope) 分组,每个不同的 type(scope) 是一次独立提交:
feat(foo): 更新 bar 组件
fix(foo): 修正 parseThing 判断
refactor: 移除 old
- 提交顺序固定:feat → fix → refactor → perf → test → docs → style → build → ci → chore,同类型内按 scope 字典序。同一个改动集永远产出同一段历史。
- 同一 type(scope) 的文件仍然是同一次提交:src/api/a.ts 与 src/api/b.ts 都是 feat(api),它们合成一次提交,这次提交的正文里才是逐文件注释:
feat(api): 更新 a、b
- feat(api): 更新 a · src/api/a.ts
- feat(api): 更新 b · src/api/b.ts
- 只装一个文件的提交不写正文——标题已经说完了;正文最多列 maxFilesShown 个文件(默认 12),其余折叠成一行 - …另有 N 个文件。
- 推送只做一次(所有分次提交都完成之后),标签指向最后一次提交(HEAD)。
- 冲突处理与 hook 失败策略不变;注意 hook 每组都要跑一遍——某一组被 hook 拒绝时,已完成的那几次提交保留在本地且不推送,卡片写明「已完成 N/M 次提交,未推送」。
- 你只给标题(单行 message)时,它成为文件最多的那一组(并列取计划里靠前的一组)的标题,其余各组用插件生成的标题;你自己写了正文(多行 message)则原样使用,插件不再添加任何注释。
会话里显示什么
- 插件从不打印 git 原始输出,也从不打印过程日志:它的全部输出就是摘要和卡片。
- 跑斜杠命令时,会话里出现的是 DSH 自己的命令行(运行中 → 结果卡片,可展开),0 模型 token;跑工具时,卡片就是这次工具调用的结果。
- DSH 没有给命令或工具一个「运行中途往会话里写字」的通道,所以没有实时进度流——中途能看到的就是 DSH 自己渲染的运行状态。
- 工具描述里也明确要求模型不要复述自己的步骤、不要把文件列表再念一遍。
配置
配置只有一条通道:JSON 文件。 本插件不导出 Cordis Config schema,没有挂载行 config 这一层, DSH 的设置页里也不会有它的表单。原因很直白:那个表单在受支持的 profile 上从未真正出现过,而一个悄悄什么都不做的配置界面,比没有配置界面更糟——于是 lib/schema.js 被删除, @deepseek-ai/schemastery 不再是依赖,uiOverrides / resolveSettings 那套行配置层也一并去掉。
装完就有得改:插件在挂载时把配置模板写到 <DSH_HOME>/git-commit-push.config.json。 DSH 启动加载插件时,如果这个文件还不存在,插件就用包内模板生成一份(里面每个键、每段说明都在),所以装完重启 DSH 之后,你直接打开 ~/.dsh/git-commit-push.config.json 编辑即可——不必去 node_modules 里翻模板,也不必担心重装插件把它冲掉(它不在包目录里)。规则很简单:
- 只在文件不存在时创建(用排他写 wx,不是先判断再写),所以你手改过的文件永远优先,绝不会被覆盖;
- 不会因为这件事失败:家目录不可写、$DSH_HOME 指向一个文件等情况下只记一条日志,插件照常挂载(用内置默认值);
- 删掉它就是「回到默认值」——下次 DSH 启动会再生成一份全新的。
两层来源,自上而下覆盖:
| 层 | 位置 | 谁写它 |
| 1 | <DSH_HOME>/git-commit-push.config.json(默认 ~/.dsh/git-commit-push.config.json) | 插件首次挂载时自动生成,之后你自己编辑 |
| 2 | 包内模板 git-commit-push.config.json | 随包出货(重装会被替换) |
| — | 内置默认值 | 兜住以上都没设的字段 |
文件缺失不是错误(用默认值);文件存在但不是合法 JSON 时,结果卡片里会追加一行「配置未生效:…」,而不是静默忽略。没写的键保持默认值。改完不用重启 DSH:下一次工具调用或 /git-commit-push 运行就会读到新值。
| 键 | 默认 | 说明 |
| autoPush | true | 提交后推送 |
| autoAdd | true | 提交前 git add -A |
| tagOnVersionChange | true | 版本文件变动 → 询问打 tag |
| tagOnBreaking | true | 检测到公共声明被删除 → 询问 |
| tagOnFileCount | 10 | 改动文件数 ≥ N → 询问(0 关闭) |
| tagPrefix | "v" | 建议标签前缀 |
| askBeforeTag | true | false 则不问,直接打建议标签(唯一会「不问就打」的开关) |
| askTimeoutMs | 120000 | 标签询问等待上限 |
| defaultLanguage | "zh" | 规则生成信息的语言(zh/en) |
| maxFilesShown | 12 | prepare 卡片最多列几个文件 |
| pinnedIdentity.name/email | 空 | 非空时以 -c user.name/-c user.email 仅对本次提交生效;不改你的 git 配置 |
用户文件不一定要写全:只想改一两个键,就只写那两个键,其余保持内置默认值。
包内模板是随包出货的那一份,改它会在升级包时被覆盖——你自己的设置放用户文件。
用户文件里没有的键,就是插件后来新增的:插件不会改写你已有的文件(怕覆盖你的手改),新键直接走内置默认值。
字段的说明文字是中文(提交信息的默认语言)。
环境变量:
- DSH_HOME:DSH 主目录,默认 ~/.dsh(Windows 下默认 %USERPROFILE%\.dsh)。它同时决定用户配置文件的位置。
Skill git-commit-push
包里的 SKILL.md 在插件挂载时会注册成嵌入式 skill(ctx.skills.register(...)),所以装完即用,不需要你手工往技能目录里拷文件。skill 名与包名一致(dsh-plugin- 前缀之外的部分):工具、命令、skill、npm 包只用一个名字,不必记两套。
- 想覆盖它:在工作区放一个同名项目级 skill(.agents/skills/git-commit-push/SKILL.md)。注册表按优先级排序, 项目级 > 运行时注册,你自己的版本会生效。
- 环境里没有技能注册表也能正常工作:skill 与 /git-commit-push 命令都是可选能力(用 scoped ctx.inject 等它出现),缺任何一个都不影响 git_commit_push 工具本身。
- 从早期版本迁过来:如果你曾把老 SKILL.md 手工装到 ~/.agents/skills/git-commit/,那是一份另一个名字的用户级技能,会与随包的 git-commit-push 同时出现在技能目录里。内容已被本包接管,建议删掉那个目录(或保留它并在里面写你自己的规则——两者名字不同,互不覆盖)。
安全边界
绝不:修改 .gitignore、改 git config(含 user.name/user.email 的写入)、push --force、reset --hard、git clean、checkout -- <path>、commit --no-verify。
lib/git.js 是唯一与 git 对话的地方,动词表是固定的——想加破坏性命令,得先改那里。
自动处理:无 upstream 时 push -u origin <当前分支>;推送被拒(远程有新提交)时 pull --rebase 后重推一次;rebase 冲突则只 abort 本次自己启动的 rebase(先探测 rebase-merge/rebase-apply,绝不丢弃你原有的 rebase 进度)并如实报告;tag 名字非法则跳过并说明。
打标签从严:只有你明确同意(或 askBeforeTag: false)才会打标签。但显式传入 tag 参数视为指令,直接执行——不再经过提问(此前这里有个 bug:显式 tag 也会走提问,没有可用提问者时被静默丢弃,实测抓到并修复)。提问服务不可用、你不在现场(子代理调用)、等待超时——一律不打,并在卡片里告诉你怎么用 tag 参数补打。
标签必须真的上到远端:分支推送成功后,插件会再单独执行一次标签推送(单独一条命令,这样标签被拒不会和分支被拒混在一起)。两种此前会「标签只在本地」的情况现在都覆盖了:
- 本地已存在的标签照样会被推送——跳过的是「创建」,不是「发布」(曾经两者一起跳过,于是上一次推送失败留下的标签永远留在本地,卡片还显示「已跳过」,看起来像成功了);
- 只推标签时必须显式给 remote:git push refs/tags/v1 会让 git 把 refspec 当成 remote 名而直接致命失败(does not appear to be a git repository),所以这条路径用 %(push:remotename) 解析出该分支真正的 remote。
卡片里的 标签: 显示本次真正在处理的标签(不区分是刚创建还是本地已有,说明里会讲清),推送:已推送(含标签) 只在这条标签确实到了远端时才出现;autoPush 关闭或推送失败时会明确写「标签 X 仅本地(未推送)」。工具返回值里的 tagPushed 就是「标签到底上没上远端」这个布尔量。
明确不猜:会话目录不是仓库时,返回其下的候选仓库让你用 cwd 指定,不会随便挑一个提交。git 未安装与「不是仓库」是两种不同失败,不会互相误报。
跨平台
| 能力 | Windows | macOS / Linux |
| 运行时(插件本体) | ✅ | ✅ 已审计:无遗漏的平台分支 |
| 安装脚本(方式 C) | setup.ps1(PowerShell) | setup.sh(POSIX sh) |
| 卸载 | setup.ps1 -Uninstall | setup.sh <profile> --uninstall |
| profile 清单编辑 | 两者调用同一个 lib/profile-edit.mjs | 同 |
运行时跨平台审计结果(index.js + lib/):
- 唯一的平台分支是 DEV_NULL(Windows NUL / 其他 /dev/null),只用于测试夹具隔离 git 全局配置。插件正式的 git 调用刻意保留用户全局配置——凭据助手、pull.rebase、core.autocrlf 都在那里,清空会改变用户仓库的行为。
- 无硬编码盘符、无 C:\、不依赖 powershell;模块用相对 ./ 说明符(大小写敏感文件系统上安全);临时目录统一用 os.tmpdir()。
- setup.sh 刻意避开两类跨平台陷阱:不用 sed -i(BSD/macOS 与 GNU 参数不同)、不用 readlink -f(macOS 无),改用 POSIX awk 与 cd+pwd。
自检
不需要 DSH,也不碰你的仓库(测试在系统临时目录里建自己的仓库,用完删掉):
npm test # = node self-test.mjs && node self-test-git.mjs
node self-test.mjs # 纯逻辑 + 打包 / 配置 / skill 契约(111 项)
node self-test-git.mjs # 真实 git:porcelain/-z 分帧、rename 归属、版本号识别、端到端提交、按 type(scope) 分次提交、标签发布、卡片结论(32 项)
node capture-git-format.mjs # 只打印真实 git 的 -z 原始字节,用于诊断分帧问题
Windows 上用 DSH 自带的 node:
& "$env:USERPROFILE\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\node\bin\node.exe" self-test.mjs
self-test-git.mjs 里有一条测试故意独立于实现:它同时用 git status --porcelain -z 和非 NUL 的普通格式问 git 同一个问题,要求两者描述同一组路径。这样解析器写错时测试会失败,而不是跟着实现一起错。
这条测试的由来值得记一笔:第一版 lib/survey.js 从一个不导出该名字的模块 import 了一个函数。ESM 链接期错误让整个插件图无法求值——git_commit_push 和 /git-commit-push 都不会注册。而当时的纯逻辑测试因为不 import survey.js,根本碰不到它。现在两个测试文件都显式 import 完整模块图,并且 apply() 在注册前会用 toolDefinitionProblems() 自检 schema。
self-test.mjs 末尾几节查的是打包与运行时契约,不是算法:
- dsh.bundle.patch 指向的文件存在且非空、该 patch 只插入一行挂载条目(两行就是挂两次)、id 是稳定的 git-commit-push、name 等于包名、两个安装脚本都不再自己写挂载行;
- files 白名单是否覆盖入口点 import 的每一个相对模块——这是 npm 发布最常见的翻车点:包能装上,一加载就找不到模块;
- 插件页要读的 exports 子路径、locale/.json、icon(相对路径、类型、≤256 KiB);
- 每个 DSH peer 都是 optional(否则 pnpm 会试图把宿主包装进用户 profile)、dsh.manifestVersion、engines.dsh;
- 可视化设置表单必须保持「已移除」:@deepseek-ai/schemastery 不许作为依赖或 peer 回来、lib/schema.js 不许复活、index.js 不许再导出 Config 或出现 uiOverrides、包内模板不许再提表单;
- 配置只有一条通道与字段表的一致性:字段表 ↔ 内置默认值 ↔ 包内模板三者不许漂移、用户文件胜过包内模板、损坏的配置必须被报告;
- ERR_PNPM_EPERM 那条修复的前提:先证明「rename 覆盖已存在且非空的目录会失败」这个平台前提,再验证 lib/profile-link.mjs 对链接只做 unlink(目标 checkout 完好无损)、对目录与不存在的项各有着落、拒绝 node_modules 之外的目标,且两个安装脚本都调它而不是各写一份;另外两条守着「重跑安装脚本不能把插件删掉」:已经指向本次 checkout 的链接必须返回 kept 而原样保留,ensureLink 只填洞、绝不覆盖已存在的条目,且两个脚本都带 --expected-target / --ensure-link;
- SKILL.md 解析出的 skill 定义满足注册表 validateRuntimeSkill 的规则,且 apply() 在模拟宿主上确实注册了工具 + 命令 + skill。
它自带两个只认本项目所用语法的小型读取器(YAML patch、skill frontmatter)——本包刻意零依赖,测试不能为了读三五行 YAML 引进一个 parser;读不懂的行会让测试失败而不是被忽略。
结构
index.js 插件入口:工具定义、/git-commit-push 命令、skill 注册、编排(prepare/apply/auto) cordis.patch.yml 组合包 patch:唯一一处挂载行(dsh.bundle.patch 指向它) icon.svg 插件页图标(package.json 的 icon) locale/en.json 插件页显示文本(meta.title / meta.description,英文) locale/zh.json 同上,中文 lib/git.js 唯一的 git 调用层:固定 argv、超时、输出上限、porcelain 解析、平台探测 lib/analyze.js 改动分类 + 规则化 Conventional Commits 生成 + 卡片渲染 lib/survey.js 一次仓库摸底:status / numstat / log / 有界 diff lib/config.js 配置读取 + 字段表:用户文件 > 模板 > 默认值,并报告损坏的文件 lib/skill.js 从 SKILL.md 解析出运行时 skill 定义(含 frontmatter 解析) lib/profile-edit.mjs 两个安装脚本共用的 profile 清单编辑器(幂等、保留未知字段与行位置、无 BOM、内容没变就不写文件、自校验) lib/profile-link.mjs 两个安装脚本共用的 node_modules 条目管理器:陈旧条目清理(链接只 unlink、不跟随,防 ERR_PNPM_EPERM)+ ensureLink 补上 pnpm 拒绝重建的 link: 条目 setup.ps1 Windows 安装 / 卸载(方式 C) setup.sh macOS / Linux 安装 / 卸载(方式 C) self-test.mjs 纯逻辑 + 打包 / 配置契约自检(111 项) self-test-git.mjs 真实 git 集成自检(32 项,自建临时仓库) capture-git-format.mjs 打印真实 git 的 -z 原始字节(诊断分帧问题) e2e-check.mjs 直连调用 run(),用于不重启验证提交路径
设计取舍:工具定义是手写对象而不是 defineTool(...);运行时只 import Node 内置模块与相对路径,没有任何宿主 import,也不依赖 @deepseek-ai/ 的模块解析是否覆盖到本包——本包既可能以 link: 挂在 profile 外,也可能以 npm 包形式躺在 node_modules 里,装载不该因为宿主的模块解析而失败。
代价是必须手写真正的 JSON Schema:parameters 需要 type: "object" + properties + required: [],output.schema 的 required 必须是字符串数组 (defineTool 的 per-property required: true 语法只由 defineTool 自己编译;手写定义直接送进注册表会被拒,而且是在注册时抛错,整个插件都装不上)。toolDefinitionProblems() 就是这条规则的回归测试。
exports 里除 . 之外还导出 ./package.json、./locale/、./git-commit-push.config.json 与 ./cordis.patch.yml:插件页读显示文本时走的是 Node 的模块解析(readPluginMeta 解析 <specifier>/package.json 与 <specifier>/locale/en.json),只有 . 的 exports 映射会让这些查找得到 ERR_PACKAGE_PATH_NOT_EXPORTED。同一堵墙也会挡住配置文件:git-commit-push.config.json 确实在 tarball 里、也确实躺在安装目录里,但只要 exports 不映射它,任何按包名解析它的代码(例如 import '<包名>/git-commit-push.config.json')都会失败——表现为「装了这个包,却拿不到它的配置模板」。cordis.patch.yml 属于同一类,launcher 目前按包目录读它,但按包名解析时同样需要映射。自检里有一条真的建一个 node_modules 链接去解析这四个子路径的用例守着它们。
peerDependencies 只声明 @deepseek-ai/dsh-tools 且标为 optional:它的作用是让 DSH 的兼容性检查(evaluatePluginCompatibility,只读 peerDependencies)能拿宿主版本比对;标 optional 则保证 pnpm 永远不会为了满足它去下载宿主包。本包实际不 import 它。
附:DSH 组合包契约(本包踩过的几个点)
以下各点都照着 dsh 打包产物里的实现核对过(packages/boot/plugin-manager、packages/boot/app-boot、 packages/boot/package-manifest、packages/skill/skill),不是推测。
1. 组合包 = package.json 里的 dsh.bundle.patch:一个文件路径,或有序的文件路径数组,相对包目录。dsh.profile.bundles 里选中的名字,launcher 用 bundlePatchFiles / bundlePatchPaths 解析后把该 patch 当成一层应用;解析不出 dsh.bundle 就抛 profile bundle "…" declares no dsh.bundle in its package.json 并跳过该层(记进 skippedBundles),不会拖垮启动。
2. 插件页只管理组合包。listBundles() 会把「已选中却没有 dsh.bundle」的名字列成 error.code = "not-bundle"(页面文案就是那句「这个包没有声明组合包,不能作为插件管理」);启用/停用只改 dsh.profile.bundles 的成员关系、保留依赖,卸载才动依赖。未选中的普通依赖干脆不列出来。
3. 兼容性只查 @deepseek-ai/dsh 与 @deepseek-ai/dsh- 类型的 peer,用 includePrerelease 参与比较,运行版本取 dsh-app-boot 的版本。peerDependenciesMeta.optional 不影响这项检查。
4. dsh.manifestVersion 与 engines.dsh 目前只作声明(安装器与 loader 都不强制),但它们是 @deepseek-ai/dsh-package-manifest 记载的公开作者字段,所以本包照写。
5. 显示元数据由 readPluginMeta 通过 Node 解析 <包名>/package.json、<包名>/locale/.json 与 package.json 的 icon(相对路径、SVG/PNG/JPEG/WebP、≤256 KiB、必须留在包目录内)得到,locale/en.json 是基准文件。
6. 嵌入式 skill 用 ctx.skills.register({ name, description, content, … }):name 必须匹配 /^[a-z0-9]+(?:-[a-z0-9]+)$/,description 与 content 必须是非空字符串(加载时按 validateDefinition 再校验一次),provider 由注册表填成 runtime,优先级为 项目级 > 运行时 > 用户级。
7. 设置表单 = 插件自己导出 Config schema(schemastery,zod 风格)。 DSH 的设置服务(@deepseek-ai/dsh-settings + @deepseek-ai/dsh-config-editor)只为声明了 Config 的条目生成命名空间与表单(SettingsNamespaceView.autoGenerate),写入落到该条目在 profile patch 里的 config。本包刻意不再导出 Config:它一度导出过,但那个表单在受支持的 profile 上从未出现过,而一个悄悄什么都不做的配置界面比没有更糟——于是 schema、uiOverrides / resolveSettings 那套行配置层、以及 @deepseek-ai/schemastery 依赖全被删掉。
8. 所以 JSON 文件是唯一的配置通道。 对本插件的用户来说,「配置」就是编辑 <DSH_HOME>/git-commit-push.config.json(默认 ~/.dsh/…,或包内模板),下一次工具调用或 /git-commit-push 运行就生效、不用重启;DSH 的设置页里不会有它的表单,插件列表标签照旧只负责装 / 启用 / 停用 / 卸载,pluginManager/* 那套 RPC 也只做这四件事。
另外两条容易踩的:
- Loader 的 insert 是追加语义、且不去重——同一个 id 插两次就是挂载两份,所以挂载点必须唯一。
profile 的 pnpm-workspace.yaml 通常带 autoInstallPeers: false 与 nodeLinker: hoisted:peer 不会被自动安装,未满足时只有一行警告。这也是本包把 peer 标成 optional 的原因之一。
维护者:发布到 npm
npm login # 或 CI 里的 NPM_TOKEN
npm test # prepublishOnly 也会跑一遍
npm publish # publishConfig 已把 registry 固定为 registry.npmjs.org
- publishConfig.registry 显式写成 npm 官方源:本机 ~/.npmrc 若指向 npmmirror(只读镜像),不加这一行容易把 npm publish 发到镜像上而失败。
- 发布后 npmmirror 等镜像有同步延迟,用户立刻装可能拿不到最新版。
- 版本按 SemVer 递增;dsh.manifestVersion 是清单格式标识,与包版本无关,不要跟着改。
- 改了运行时行为就同步 engines.dsh 与 peerDependencies 里 @deepseek-ai/dsh-tools 的范围,两者决定插件页会不会给出「与 DSH 不兼容」的提示。
- 本包没有 dependencies:运行时只 import Node 内置与相对路径,发布时不需要任何一起装的库。
许可
MIT © ygzhang-lab。见 LICENSE。预览分享卡片长什么样 ↓
一条命令搞定 DSH 的 Conventional-Commits git 工作流:检查 + 提交 + 可选打标签 + 推送,另带 /commit-push 斜杠命令,全程不调用模型。支持 Windows 和 macOS/Linux。
相关插件 · 市场 / 管理
这是你的仓库?认领条目