@goodandready/dsh-github-ops 0.6.6 → 0.6.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/lib/client.js CHANGED
@@ -147,6 +147,11 @@ window.__ModuleLoader__.load({
147
147
  scmStageHunk: 'Stage Hunk',
148
148
  scmUnstageHunk: 'Unstage Hunk',
149
149
  scmHunk: 'Hunk',
150
+ scmCherryPick: 'Cherry-pick',
151
+ scmRevert: 'Revert',
152
+ scmSubmodules: 'Submodules',
153
+ scmSubmoduleUpdate: 'Update',
154
+ scmSubmoduleUpdateAll: 'Update All Submodules',
150
155
  scmGraphTitle: 'Git Graph',
151
156
  scmGraphRefresh: 'Refresh Graph',
152
157
  scmGraphEmpty: 'No commit history available.',
@@ -269,6 +274,11 @@ window.__ModuleLoader__.load({
269
274
  scmStageHunk: '暂存代码块',
270
275
  scmUnstageHunk: '取消暂存块',
271
276
  scmHunk: '代码块',
277
+ scmCherryPick: '择优拣选',
278
+ scmRevert: '还原提交',
279
+ scmSubmodules: '子模块',
280
+ scmSubmoduleUpdate: '更新',
281
+ scmSubmoduleUpdateAll: '更新所有子模块',
272
282
  scmGraphTitle: 'Git 提交图',
273
283
  scmGraphRefresh: '刷新提交图',
274
284
  scmGraphEmpty: '暂无可用的提交历史。',
@@ -584,6 +594,62 @@ window.__ModuleLoader__.load({
584
594
  ),
585
595
  ) : null
586
596
 
597
+ const graphBar = React.createElement('div', { className: 'gho-scm-bar' },
598
+ button(
599
+ showGraph ? `${t('scmGraphTitle')} ▾` : `${t('scmGraphTitle')} ▸`,
600
+ () => {
601
+ const next = !showGraph
602
+ setShowGraph(next)
603
+ if (next && graphData.phase === 'idle') loadGraph()
604
+ },
605
+ ),
606
+ showGraph ? button(t('scmGraphRefresh'), loadGraph, { disabled: graphData.phase === 'loading' }) : null,
607
+ )
608
+
609
+ const graphView = showGraph
610
+ ? React.createElement('div', { className: 'gho-git-graph' },
611
+ graphData.phase === 'loading'
612
+ ? React.createElement('div', { className: 'gho-scm-meta' }, t('scmLoading'))
613
+ : (graphData.entries && graphData.entries.length > 0
614
+ ? graphData.entries.map((entry, idx) => {
615
+ const sha = entry.hash || entry.sha
616
+ const subject = entry.subject || entry.message
617
+ return React.createElement('div', { key: sha || idx, className: 'gho-graph-row' },
618
+ entry.graph ? React.createElement('span', { className: 'gho-graph-conn' }, entry.graph) : null,
619
+ sha ? React.createElement('span', { className: 'gho-graph-sha' }, sha.slice(0, 7)) : null,
620
+ entry.refs && entry.refs.length ? React.createElement('span', { className: 'gho-graph-refs' }, entry.refs.map((r, rIdx) => React.createElement('span', { key: rIdx, className: 'gho-graph-ref' }, r))) : null,
621
+ subject ? React.createElement('span', { className: 'gho-graph-msg' }, subject) : null,
622
+ sha ? React.createElement('span', { className: 'gho-scm-actions' },
623
+ button(
624
+ confirming === `cp:${sha}` ? t('scmConfirm') : t('scmCherryPick'),
625
+ () => armed(`cp:${sha}`, 'cherry_pick', { commit: sha, confirm: true }),
626
+ ),
627
+ button(
628
+ confirming === `rev:${sha}` ? t('scmConfirm') : t('scmRevert'),
629
+ () => armed(`rev:${sha}`, 'revert', { commit: sha, confirm: true }),
630
+ { warn: true },
631
+ ),
632
+ ) : null,
633
+ entry.date ? React.createElement('span', { className: 'gho-graph-meta' }, entry.date) : null,
634
+ )
635
+ })
636
+ : React.createElement('div', { className: 'gho-scm-meta' }, t('scmGraphEmpty'))
637
+ )
638
+ )
639
+ : null
640
+
641
+ const submoduleBar = (payload.submodules && payload.submodules.length > 0)
642
+ ? React.createElement('div', { className: 'gho-scm-bar' },
643
+ React.createElement('span', { className: 'gho-scm-counts' }, t('scmSubmodules')),
644
+ button(t('scmSubmoduleUpdateAll'), () => runAction('submodule_update', {})),
645
+ payload.submodules.map((sm, smIdx) => React.createElement('span', { key: sm.path || smIdx, style: { display: 'inline-flex', gap: '4px', alignItems: 'center' } },
646
+ React.createElement('span', { className: 'gho-scm-path' }, `${sm.path} (${sm.shortCommit || ''})`),
647
+ React.createElement('span', { className: `gho-badge gho-badge-${sm.status === 'in-sync' ? 'healthy' : (sm.status === 'conflict' ? 'failing' : 'building')}` }, sm.status),
648
+ button(t('scmSubmoduleUpdate'), () => runAction('submodule_update', { path: sm.path })),
649
+ )),
650
+ )
651
+ : null
652
+
587
653
  const worktreeBar = (payload.worktrees && payload.worktrees.length > 0)
588
654
  ? React.createElement('div', { className: 'gho-scm-bar' },
589
655
  React.createElement('span', { className: 'gho-scm-counts' }, t('scmWorktrees')),
@@ -687,6 +753,7 @@ window.__ModuleLoader__.load({
687
753
  graphView,
688
754
  branchBar,
689
755
  stashBar,
756
+ submoduleBar,
690
757
  worktreeBar,
691
758
  mirrorBar,
692
759
  React.createElement('div', { className: 'gho-scm-meta' },
package/lib/routes.js CHANGED
@@ -10,7 +10,7 @@ import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, isTrustedWriteRequest, getRejectionStatus, getWriteRejectionStatus, sendJson } from './status.js'
12
12
  import { buildAction, validateCwd, validateStashRef, resolveConflictFile } from './scm-write.js'
13
- import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
13
+ import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseSubmodules, parseCount, scmPayload, mergeStateFrom, hasConflicts, parseConflictBlocks, parseDiffHunks, parseGitGraph } from './scm.js'
14
14
  import { SseEventManager } from './events.js'
15
15
  import { encodeRepoPath } from './github.js'
16
16
  import { listIssues } from './tools/issues.js'
@@ -221,6 +221,25 @@ export function registerRoutes(ctx, deps = {}) {
221
221
  stderr: String(answer.stderr || '').slice(0, 4000),
222
222
  })
223
223
  if (answer.code !== 0) {
224
+ const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
225
+ const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
226
+ const merge = mergeStateFrom({
227
+ mergeHead: existsSync(joinPath(gitDir, 'MERGE_HEAD')),
228
+ rebaseDir: existsSync(joinPath(gitDir, 'rebase-merge')) || existsSync(joinPath(gitDir, 'rebase-apply')),
229
+ cherryPickHead: existsSync(joinPath(gitDir, 'CHERRY_PICK_HEAD')),
230
+ revertHead: existsSync(joinPath(gitDir, 'REVERT_HEAD')),
231
+ })
232
+ if (merge.inProgress) {
233
+ return sendJson(res, 200, {
234
+ ok: false,
235
+ action: built.action,
236
+ summary: built.summary,
237
+ steps,
238
+ mergeState: merge,
239
+ hasConflicts: true,
240
+ error: `${built.action} encountered merge conflicts`,
241
+ })
242
+ }
224
243
  return sendJson(res, 200, { ok: false, action: built.action, summary: built.summary, steps, error: 'git refused the step' })
225
244
  }
226
245
  }
@@ -285,12 +304,13 @@ export function registerRoutes(ctx, deps = {}) {
285
304
  ahead = aheadAnswer.code === 0 ? parseCount(aheadAnswer.stdout) : null
286
305
  behind = behindAnswer.code === 0 ? parseCount(behindAnswer.stdout) : null
287
306
  }
288
- const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer, worktreesAnswer] = await Promise.all([
307
+ const [statusAnswer, branchesAnswer, tagsAnswer, stashesAnswer, worktreesAnswer, submodulesAnswer] = await Promise.all([
289
308
  git(['status', '--porcelain=v1'], { cwd }),
290
309
  git(['branch', '--format=%(refname:short)\t%(HEAD)'], { cwd }),
291
310
  git(['tag', '--list'], { cwd }),
292
311
  git(['stash', 'list', '--format=%gd\t%gs'], { cwd }),
293
312
  git(['worktree', 'list', '--porcelain'], { cwd }),
313
+ git(['submodule', 'status'], { cwd }),
294
314
  ])
295
315
  const gitDirAnswer = await git(['rev-parse', '--absolute-git-dir'], { cwd })
296
316
  const gitDir = gitDirAnswer.code === 0 ? gitDirAnswer.stdout.trim() : joinPath(cwd, '.git')
@@ -311,6 +331,7 @@ export function registerRoutes(ctx, deps = {}) {
311
331
  tags: tagsAnswer.code === 0 ? tagsAnswer.stdout.split('\n').map((t) => t.trim()).filter(Boolean).slice(0, 50) : [],
312
332
  stashes: parseStashes(stashesAnswer.code === 0 ? stashesAnswer.stdout : ''),
313
333
  worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
334
+ submodules: parseSubmodules(submodulesAnswer.code === 0 ? submodulesAnswer.stdout : ''),
314
335
  merge,
315
336
  }))
316
337
  } catch (err) {
package/lib/scm-write.js CHANGED
@@ -17,6 +17,15 @@ const BRANCH_RE = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]{1,120}$/
17
17
  /** A repository-relative path: no absolute paths, no traversal, no control characters. */
18
18
  const PATH_RE = /^(?!-)(?!\/)(?!.*\.\.)[^\u0000-\u001f]{1,500}$/
19
19
 
20
+ /** A commit sha: 7 to 40 hexadecimal characters. */
21
+ const SHA_RE = /^[0-9a-fA-F]{7,40}$/
22
+
23
+ export function validateCommitSha(sha) {
24
+ const value = String(sha == null ? '' : sha).trim()
25
+ if (!SHA_RE.test(value)) throw new Error(`refusing an invalid commit hash: "${String(sha).slice(0, 40)}"`)
26
+ return value
27
+ }
28
+
20
29
  export function validateBranch(name) {
21
30
  const value = String(name == null ? '' : name).trim()
22
31
  if (!BRANCH_RE.test(value)) throw new Error(`refusing an unsafe branch name: "${String(name).slice(0, 60)}"`)
@@ -175,12 +184,30 @@ export function buildAction(action, args = {}) {
175
184
  step.push(path)
176
185
  return result(name, [step], destructive, `remove worktree at ${path}`)
177
186
  }
187
+ case 'cherryPick':
188
+ case 'cherry_pick': {
189
+ const sha = validateCommitSha(args.commit || args.sha)
190
+ return result('cherry_pick', [['cherry-pick', sha]], destructive, `cherry-pick ${sha}`)
191
+ }
192
+ case 'revert': {
193
+ const sha = validateCommitSha(args.commit || args.sha)
194
+ const noEdit = args.noEdit !== false
195
+ const step = noEdit ? ['revert', '--no-edit', sha] : ['revert', sha]
196
+ return result('revert', [step], destructive, `revert ${sha}`)
197
+ }
198
+ case 'submoduleUpdate':
199
+ case 'submodule_update': {
200
+ const path = args.path ? validatePath(args.path) : ''
201
+ const step = ['submodule', 'update', '--init', '--recursive']
202
+ if (path) step.push('--', path)
203
+ return result('submodule_update', [step], false, `update submodules${path ? ` in ${path}` : ''}`)
204
+ }
178
205
  default:
179
206
  throw new Error(`unknown action: "${name.slice(0, 40)}"`)
180
207
  }
181
208
  }
182
209
 
183
- const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove'])
210
+ const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove', 'cherry_pick', 'cherryPick', 'revert'])
184
211
 
185
212
  function result(action, steps, destructive, summary) {
186
213
  return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
@@ -194,6 +221,7 @@ export const ACTIONS = Object.freeze([
194
221
  'worktreeAdd', 'worktreeRemove',
195
222
  'continue', 'abort',
196
223
  'resolveConflict',
224
+ 'cherry_pick', 'revert', 'submodule_update',
197
225
  ])
198
226
 
199
227
  /** The verbs the panel offers for an unfinished operation. */
package/lib/scm.js CHANGED
@@ -1,3 +1,36 @@
1
+ export function parseSubmodules(stdout) {
2
+ if (!stdout || typeof stdout !== "string") return []
3
+ const lines = stdout.split("\n")
4
+ const submodules = []
5
+ for (const rawLine of lines) {
6
+ const line = rawLine.trimEnd()
7
+ if (!line) continue
8
+ const prefix = line[0]
9
+ let status = "in-sync"
10
+ if (prefix === "-") status = "uninitialized"
11
+ else if (prefix === "+") status = "modified"
12
+ else if (prefix === "U") status = "conflict"
13
+
14
+ const rest = (prefix === " " || prefix === "-" || prefix === "+" || prefix === "U") ? line.slice(1).trim() : line.trim()
15
+ const parts = rest.split(/\s+/)
16
+ const commit = parts[0] || ""
17
+ const path = parts[1] || ""
18
+ const describeMatch = rest.match(/\(([^)]+)\)/)
19
+ const describe = describeMatch ? describeMatch[1] : null
20
+
21
+ if (commit && path) {
22
+ submodules.push({
23
+ status,
24
+ commit,
25
+ shortCommit: commit.slice(0, 7),
26
+ path,
27
+ describe,
28
+ })
29
+ }
30
+ }
31
+ return submodules
32
+ }
33
+
1
34
  // Reading a working copy, for the Source Control tab.
2
35
  //
3
36
  // The parsers are pure: they take the text git prints and return a structure. That keeps the
@@ -123,7 +156,7 @@ export function parseCount(text) {
123
156
  * ahead/behind, and that is reported as such rather than as an error.
124
157
  */
125
158
  export function scmPayload({
126
- cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, worktrees, merge, error,
159
+ cwd, branch, upstream, ahead, behind, status, branches, tags, stashes, worktrees, submodules, merge, error,
127
160
  } = {}) {
128
161
  return {
129
162
  cwd: cwd || '',
@@ -137,6 +170,7 @@ export function scmPayload({
137
170
  tags: Array.isArray(tags) ? tags : [],
138
171
  stashes: Array.isArray(stashes) ? stashes : [],
139
172
  worktrees: Array.isArray(worktrees) ? worktrees : [],
173
+ submodules: Array.isArray(submodules) ? submodules : [],
140
174
  mergeState: merge || { inProgress: false, kind: '' },
141
175
  error: error ? String(error).slice(0, 300) : '',
142
176
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.6.6",
3
+ "version": "0.6.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",