@goodandready/dsh-github-ops 0.2.6 → 0.2.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Notable changes to `@goodandready/dsh-github-ops`.
4
4
 
5
+ ## 0.2.7
6
+
7
+ ### Added
8
+ - **Git Worktrees Support** (#47): porcelain worktrees inventory parsing (`parseWorktrees`), `worktreeAdd` and `worktreeRemove` actions with confirm protection, and UI worktree switching bar in Source Control tab.
9
+ - **Automated Release Preflight** (#65): `gh_release_preflight` tool validating package version, cordis.patch.yml alignment, changelog release sections, 3-language READMEs (EN, RU, ZH), package bundle size limits (<=256 KiB), and git index hygiene.
10
+
5
11
  ## 0.2.6
6
12
 
7
13
  ### Fixed
package/lib/client.js CHANGED
@@ -104,6 +104,7 @@ window.__ModuleLoader__.load({
104
104
  scmNoChanges: 'Nothing staged or changed',
105
105
  scmBehindHint: 'Behind the upstream: sync pulls with rebase, then pushes',
106
106
  scmPushBranch: 'Push branch',
107
+ scmWorktrees: 'Worktrees',
107
108
  scmRepoPath: 'Repository path (optional)',
108
109
  scmRepoPathHint: 'Absolute path; empty means the session workspace',
109
110
  prBarBranch: 'to',
@@ -205,6 +206,7 @@ window.__ModuleLoader__.load({
205
206
  scmNoChanges: '没有已暂存或已修改的内容',
206
207
  scmBehindHint: '落后于上游:同步会先 rebase 拉取,再推送',
207
208
  scmPushBranch: '推送分支',
209
+ scmWorktrees: '工作树',
208
210
  scmRepoPath: '仓库路径(可选)',
209
211
  scmRepoPathHint: '绝对路径;留空则使用会话工作区',
210
212
  prBarBranch: '→',
@@ -428,6 +430,21 @@ window.__ModuleLoader__.load({
428
430
  )),
429
431
  )
430
432
 
433
+ const worktreeBar = (payload.worktrees && payload.worktrees.length > 0)
434
+ ? React.createElement('div', { className: 'gho-scm-bar' },
435
+ React.createElement('span', { className: 'gho-scm-counts' }, t('scmWorktrees')),
436
+ payload.worktrees.map((wt) => {
437
+ const label = wt.branch ? `${wt.branch} (${wt.path.split('/').pop()})` : (wt.path.split('/').pop() || wt.path)
438
+ const isCurrent = wt.path === payload.cwd
439
+ return button(
440
+ isCurrent ? `● ${label}` : label,
441
+ () => { if (!isCurrent) rememberPath(wt.path) },
442
+ { disabled: isCurrent || Boolean(busy) },
443
+ )
444
+ }),
445
+ )
446
+ : null
447
+
431
448
  const stashBar = React.createElement('div', { className: 'gho-scm-bar' },
432
449
  React.createElement('input', {
433
450
  type: 'text',
@@ -479,6 +496,7 @@ window.__ModuleLoader__.load({
479
496
  : null,
480
497
  branchBar,
481
498
  stashBar,
499
+ worktreeBar,
482
500
  React.createElement('div', { className: 'gho-scm-meta' },
483
501
  React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
484
502
  ),
package/lib/routes.js CHANGED
@@ -10,7 +10,7 @@ import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, sendJson } from './status.js'
12
12
  import { buildAction, validateCwd } from './scm-write.js'
13
- import { parseStatus, parseBranches, parseStashes, parseCount, scmPayload, mergeStateFrom } from './scm.js'
13
+ import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
14
14
  import { listIssues } from './tools/issues.js'
15
15
  import { listRuns } from './tools/actions.js'
16
16
  import { listNotifications, repoTreeView } from './tools/panel-data.js'
@@ -179,11 +179,12 @@ export function registerRoutes(ctx, deps = {}) {
179
179
  ahead = aheadAnswer.code === 0 ? parseCount(aheadAnswer.stdout) : null
180
180
  behind = behindAnswer.code === 0 ? parseCount(behindAnswer.stdout) : null
181
181
  }
182
- const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer] = await Promise.all([
182
+ const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer, worktreesAnswer] = await Promise.all([
183
183
  git(['status', '--porcelain=v1'], { cwd }),
184
184
  git(['branch', '--format=%(refname:short)\t%(HEAD)'], { cwd }),
185
185
  git(['tag', '--list'], { cwd }),
186
186
  git(['stash', 'list', '--format=%gd\t%gs'], { cwd }),
187
+ git(['worktree', 'list', '--porcelain'], { cwd }),
187
188
  ])
188
189
  const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
189
190
  const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
@@ -203,6 +204,7 @@ export function registerRoutes(ctx, deps = {}) {
203
204
  branches: parseBranches(branchesAnswer.code === 0 ? branchesAnswer.stdout : ''),
204
205
  tags: tagsAnswer.code === 0 ? tagsAnswer.stdout.split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 50) : [],
205
206
  stashes: parseStashes(stashesAnswer.code === 0 ? stashesAnswer.stdout : ''),
207
+ worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
206
208
  merge,
207
209
  }))
208
210
  },
package/lib/scm-write.js CHANGED
Binary file
package/lib/scm.js CHANGED
@@ -76,6 +76,42 @@ export function parseStashes(text) {
76
76
  })
77
77
  }
78
78
 
79
+ /** Parse output of `git worktree list --porcelain`. */
80
+ export function parseWorktrees(text) {
81
+ const chunks = String(text || '').trim().split(/\n\s*\n/)
82
+ const worktrees = []
83
+ for (const chunk of chunks) {
84
+ if (!chunk.trim()) continue
85
+ const lines = chunk.split('\n')
86
+ let path = ''
87
+ let head = ''
88
+ let branch = ''
89
+ let bare = false
90
+ let detached = false
91
+ let locked = false
92
+ let lockReason = ''
93
+ let prunable = false
94
+
95
+ for (const line of lines) {
96
+ if (line.startsWith('worktree ')) path = line.slice(9).trim()
97
+ else if (line.startsWith('HEAD ')) head = line.slice(5).trim()
98
+ else if (line.startsWith('branch ')) branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
99
+ else if (line === 'bare') bare = true
100
+ else if (line === 'detached') detached = true
101
+ else if (line.startsWith('locked')) {
102
+ locked = true
103
+ lockReason = line.slice(6).trim()
104
+ } else if (line.startsWith('prunable')) {
105
+ prunable = true
106
+ }
107
+ }
108
+ if (path) {
109
+ worktrees.push({ path, head, branch, bare, detached, locked, lockReason: lockReason || undefined, prunable })
110
+ }
111
+ }
112
+ return worktrees
113
+ }
114
+
79
115
  /** Counts from `git rev-list --count <range>`. */
80
116
  export function parseCount(text) {
81
117
  const value = Number.parseInt(String(text || '').trim(), 10)
@@ -87,7 +123,7 @@ export function parseCount(text) {
87
123
  * ahead/behind, and that is reported as such rather than as an error.
88
124
  */
89
125
  export function scmPayload({
90
- cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, merge, error,
126
+ cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, worktrees, merge, error,
91
127
  } = {}) {
92
128
  return {
93
129
  cwd: cwd || '',
@@ -100,6 +136,7 @@ export function scmPayload({
100
136
  branches: Array.isArray(branches) ? branches : [],
101
137
  tags: Array.isArray(tags) ? tags : [],
102
138
  stashes: Array.isArray(stashes) ? stashes : [],
139
+ worktrees: Array.isArray(worktrees) ? worktrees : [],
103
140
  mergeState: merge || { inProgress: false, kind: '' },
104
141
  error: error ? String(error).slice(0, 300) : '',
105
142
  }
@@ -1,3 +1,4 @@
1
+ import { runReleasePreflight } from './release-preflight.js'
1
2
  // Core tool registrations: repository and file reads, releases, tags, the guarded
2
3
  // generic API pass-through, and repository creation/edits.
3
4
  //
@@ -332,4 +333,21 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
332
333
  }
333
334
  },
334
335
  )
336
+
337
+ tool(
338
+ 'gh_release_preflight',
339
+ 'Run automated pre-release audit on the plugin package: checks package.json version, cordis.patch.yml, CHANGELOG.md release notes, three READMEs (EN/RU/ZH), npm pack file sizes (<256 KiB), and git index hygiene.',
340
+ {
341
+ cwd: { type: 'string', description: 'Directory to audit (defaults to workspace or process.cwd()).' },
342
+ runTests: { type: 'boolean', description: 'Whether to run npm test as part of the audit (default false).' },
343
+ maxFileSize: { type: 'number', description: 'Max allowed size in bytes per packaged file (default 262144).' },
344
+ },
345
+ async (args) => {
346
+ return runReleasePreflight({
347
+ cwd: args.cwd,
348
+ runTests: args.runTests,
349
+ maxFileSize: args.maxFileSize,
350
+ })
351
+ },
352
+ )
335
353
  }
@@ -0,0 +1,202 @@
1
+ // lib/tools/release-preflight.js
2
+ // Automated release preflight audit for DSH plugins according to
3
+ // dhs-plugin-release-workflow and dsh-documentation-standard.
4
+
5
+ import { readFileSync, existsSync } from 'node:fs'
6
+ import { join as joinPath } from 'node:path'
7
+ import { execFile as defaultExecFile } from 'node:child_process'
8
+ import { promisify } from 'node:util'
9
+
10
+ const pExecFile = promisify(defaultExecFile)
11
+
12
+ const MAX_FILE_SIZE_DEFAULT = 262144 // 256 KiB per file
13
+ const FORBIDDEN_PACK_PREFIXES = ['docs/', 'test/', 'tests/', '.planning/', '.worktrees/']
14
+ const FORBIDDEN_PACK_EXACT = ['AGENTS.md', '.env', '.env.local', 'deploy.sh']
15
+
16
+ /**
17
+ * Runs preflight release audit checks on a plugin directory.
18
+ *
19
+ * @param {object} options
20
+ * @param {string} [options.cwd] Directory to check (default: process.cwd())
21
+ * @param {boolean} [options.runTests] Whether to execute npm test (default: false)
22
+ * @param {number} [options.maxFileSize] Max allowed size for any single packaged file (default: 256 KiB)
23
+ * @param {object} [deps] Injectable dependencies for unit testing
24
+ */
25
+ export async function runReleasePreflight(options = {}, deps = {}) {
26
+ const cwd = options.cwd || process.cwd()
27
+ const runTests = options.runTests === true
28
+ const maxFileSize = typeof options.maxFileSize === 'number' ? options.maxFileSize : MAX_FILE_SIZE_DEFAULT
29
+ const exec = deps.execFile || pExecFile
30
+ const fsRead = deps.readFileSync || readFileSync
31
+ const fsExists = deps.existsSync || existsSync
32
+
33
+ const checks = []
34
+ const findings = []
35
+
36
+ // Helper to record result
37
+ const record = (name, ok, details, isWarn = false) => {
38
+ checks.push({ name, ok, details, severity: ok ? 'info' : (isWarn ? 'warn' : 'fail') })
39
+ if (!ok && !isWarn) {
40
+ findings.push(`[FAIL] ${name}: ${details}`)
41
+ } else if (!ok && isWarn) {
42
+ findings.push(`[WARN] ${name}: ${details}`)
43
+ }
44
+ }
45
+
46
+ // 1. Check package.json
47
+ let pkg = null
48
+ const pkgPath = joinPath(cwd, 'package.json')
49
+ if (!fsExists(pkgPath)) {
50
+ record('package.json', false, 'package.json not found in repository root')
51
+ return { ok: false, verdict: 'FAIL', checks, findings, version: '' }
52
+ }
53
+
54
+ try {
55
+ pkg = JSON.parse(fsRead(pkgPath, 'utf8'))
56
+ const hasName = typeof pkg.name === 'string' && pkg.name.trim().length > 0
57
+ const hasVersion = typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version)
58
+ record('package.json', hasName && hasVersion, `name="${pkg.name}", version="${pkg.version}"`)
59
+ } catch (err) {
60
+ record('package.json', false, `invalid JSON: ${err.message}`)
61
+ return { ok: false, verdict: 'FAIL', checks, findings, version: '' }
62
+ }
63
+
64
+ const version = pkg.version || ''
65
+ const pkgName = pkg.name || ''
66
+
67
+ // 2. Check cordis.patch.yml matching package.json
68
+ const patchPath = joinPath(cwd, 'cordis.patch.yml')
69
+ if (fsExists(patchPath)) {
70
+ try {
71
+ const patchText = fsRead(patchPath, 'utf8')
72
+ const matchesName = patchText.includes(pkgName)
73
+ record('cordis.patch.yml', matchesName, matchesName ? `matches package name "${pkgName}"` : `does not contain package name "${pkgName}"`)
74
+ } catch (err) {
75
+ record('cordis.patch.yml', false, `read error: ${err.message}`)
76
+ }
77
+ } else {
78
+ record('cordis.patch.yml', false, 'cordis.patch.yml not found', true)
79
+ }
80
+
81
+ // 3. Check CHANGELOG.md contains release notes for version
82
+ const changelogPath = joinPath(cwd, 'CHANGELOG.md')
83
+ if (fsExists(changelogPath)) {
84
+ try {
85
+ const clText = fsRead(changelogPath, 'utf8')
86
+ const versionRe = new RegExp(`(^|\\n)##+\\s+(\\[?v?${version.replace(/\./g, '\\.')}\\]?)`, 'i')
87
+ const hasNotes = versionRe.test(clText) || clText.includes(version)
88
+ record('CHANGELOG.md', hasNotes, hasNotes ? `found release section for ${version}` : `missing release entry for ${version}`)
89
+ } catch (err) {
90
+ record('CHANGELOG.md', false, `read error: ${err.message}`)
91
+ }
92
+ } else {
93
+ record('CHANGELOG.md', false, 'CHANGELOG.md not found')
94
+ }
95
+
96
+ // 4. Check tri-lingual READMEs
97
+ const readmes = [
98
+ { file: 'README.md', lang: 'EN' },
99
+ { file: 'README.ru.md', lang: 'RU' },
100
+ { file: 'README.zh.md', lang: 'ZH' },
101
+ ]
102
+ for (const item of readmes) {
103
+ const fPath = joinPath(cwd, item.file)
104
+ if (fsExists(fPath)) {
105
+ try {
106
+ const content = fsRead(fPath, 'utf8')
107
+ const okLen = content.trim().length >= 20 || Buffer.byteLength(content) >= 50
108
+ record(`README (${item.lang})`, okLen, okLen ? `${item.file} present (${content.length} bytes)` : `${item.file} is suspiciously short`)
109
+ } catch (err) {
110
+ record(`README (${item.lang})`, false, `read error: ${err.message}`)
111
+ }
112
+ } else {
113
+ record(`README (${item.lang})`, false, `${item.file} missing`)
114
+ }
115
+ }
116
+
117
+ // 5. Check npm pack --dry-run --json composition and sizes
118
+ let packFiles = []
119
+ let packedBytes = 0
120
+ let unpackedBytes = 0
121
+ try {
122
+ const { stdout } = await exec('npm', ['pack', '--dry-run', '--json'], { cwd, maxBuffer: 10 * 1024 * 1024 })
123
+ const jsonStart = stdout.search(/[{\[]/)
124
+ if (jsonStart !== -1) {
125
+ const raw = JSON.parse(stdout.slice(jsonStart))
126
+ const packData = Array.isArray(raw) ? raw[0] : (Object.values(raw)[0] || raw)
127
+ packFiles = Array.isArray(packData.files) ? packData.files : []
128
+ packedBytes = packData.size || 0
129
+ unpackedBytes = packData.unpackedSize || 0
130
+ }
131
+ } catch (err) {
132
+ record('npm pack', false, `npm pack --dry-run failed: ${err.message}`)
133
+ }
134
+
135
+ if (packFiles.length > 0) {
136
+ const oversized = packFiles.filter((f) => (f.size || 0) > maxFileSize)
137
+ record(
138
+ 'npm pack file sizes',
139
+ oversized.length === 0,
140
+ oversized.length === 0
141
+ ? `all ${packFiles.length} files <= ${maxFileSize} bytes`
142
+ : `oversized files: ${oversized.map((f) => `${f.path} (${f.size}B)`).join(', ')}`
143
+ )
144
+
145
+ const leakedFiles = packFiles.filter((f) => {
146
+ const p = f.path || ''
147
+ if (FORBIDDEN_PACK_EXACT.includes(p)) return true
148
+ return FORBIDDEN_PACK_PREFIXES.some((prefix) => p.startsWith(prefix))
149
+ })
150
+ record(
151
+ 'npm pack hygiene',
152
+ leakedFiles.length === 0,
153
+ leakedFiles.length === 0
154
+ ? 'no internal/forbidden docs or test files in package'
155
+ : `forbidden files in npm package: ${leakedFiles.map((f) => f.path).join(', ')}`
156
+ )
157
+ }
158
+
159
+ // 6. Check git tracking hygiene if in git repo
160
+ try {
161
+ const { stdout } = await exec('git', ['ls-files'], { cwd })
162
+ const tracked = stdout.split('\n').map((l) => l.trim()).filter(Boolean)
163
+ const trackedForbidden = tracked.filter((p) => {
164
+ return p === 'AGENTS.md' || p.startsWith('.planning') || p.startsWith('.worktrees') || p.startsWith('openwiki')
165
+ })
166
+ record(
167
+ 'git tracked hygiene',
168
+ trackedForbidden.length === 0,
169
+ trackedForbidden.length === 0
170
+ ? 'clean git index (no AGENTS.md, .planning, .worktrees)'
171
+ : `forbidden files tracked in git: ${trackedForbidden.join(', ')}`,
172
+ true // warn
173
+ )
174
+ } catch {
175
+ // not a git repo or git not found
176
+ }
177
+
178
+ // 7. Optional tests execution
179
+ if (runTests) {
180
+ try {
181
+ await exec('npm', ['test'], { cwd, timeout: 60000 })
182
+ record('npm test', true, 'test suite passed successfully')
183
+ } catch (err) {
184
+ record('npm test', false, `test suite failed: ${err.message}`)
185
+ }
186
+ }
187
+
188
+ const failures = checks.filter((c) => c.severity === 'fail')
189
+ const ok = failures.length === 0
190
+
191
+ return {
192
+ ok,
193
+ verdict: ok ? 'PASS' : 'FAIL',
194
+ version,
195
+ packageName: pkgName,
196
+ packedBytes,
197
+ unpackedBytes,
198
+ unpackedSizeKiB: Math.round(unpackedBytes / 1024 * 10) / 10,
199
+ checks,
200
+ findings,
201
+ }
202
+ }
@@ -198,3 +198,4 @@ export async function generateReleaseNotes(client, { owner, repo, tag, target, p
198
198
  previousTag: previousTag || '',
199
199
  }
200
200
  }
201
+ export { runReleasePreflight } from './release-preflight.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
5
5
  "license": "MIT",
6
6
  "type": "module",