dsh-gitbar-lite 0.2.0 → 0.3.0

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/README.en.md CHANGED
@@ -23,6 +23,7 @@ The column is narrow (300px minimum, and a narrow viewport makes it full-screen)
23
23
  | Commit | Commits exactly what is ticked — because ticking wrote the index |
24
24
  | Commit and Push | Commits, then pushes. **A failed push never claims the commit failed**: the commit happened, and the panel says "committed `abc1234`, but the push failed" |
25
25
  | Context button | **Push** when local commits are unpushed, **Publish** when the branch has no upstream, **Fetch** otherwise |
26
+ | Automatic fetch | Every five minutes while the panel is visible and the repository has a remote (`autoFetchMs`, `0` to disable). Failures are silent — no error banner |
26
27
  | Discard | One file or all of them; the diff is archived *before* anything is restored, and the confirmation offers "delete for good" |
27
28
  | Trash | Its own tab. Every discard keeps a diff, recorded **against the commit it was taken from** (`abc1234 initial commit`) |
28
29
  | Diff preview | Double-click a file, or a trash entry, to open a diff tab in the column — **one tab, ever**, re-navigated in place |
@@ -126,6 +127,35 @@ The order is fixed and cannot be swapped:
126
127
  > **A conflicted file is refused**, with one sentence. Running `git restore` to HEAD in the middle of a merge can
127
128
  > disturb the merge state, and that risk is not worth saving a sentence.
128
129
 
130
+ ### Filling the Summary from a script
131
+
132
+ Next to the Summary field is a sparkle button — rendered only when a script is configured — that fills the Summary from
133
+ the script's output. It also runs by itself when the panel regains focus: leave the sidebar open, go and let the agent
134
+ work in the conversation, come back, and the Summary is already there.
135
+
136
+ The script goes in the `summaryScript` config field. It runs with the **repository root as its working directory** and
137
+ is fed nothing on stdin, so "read the top version out of the CHANGELOG" is:
138
+
139
+ ```sh
140
+ grep -m1 '^## ' CHANGELOG.md | sed 's/^## //'
141
+ ```
142
+
143
+ Whatever it prints becomes the Summary. To fill the Description as well, put a line reading `---` in the output:
144
+ everything after it is the Description.
145
+
146
+ ```sh
147
+ echo "1.4.2"
148
+ echo "---"
149
+ echo "Bumped the patch version."
150
+ ```
151
+
152
+ About the shell: on Windows the script prefers Git's own `sh` (so `grep`/`sed`/`awk` work) and puts its `usr/bin` back on
153
+ `PATH` — without that those tools report "command not found". Only a machine with no `sh` at all falls back to
154
+ `cmd.exe`, where the script must be written in cmd syntax. Elsewhere it is plain `sh`.
155
+
156
+ A failing script does not raise an error banner; its exit code shows as a brief notice, and its stderr is kept verbatim so
157
+ you can judge for yourself.
158
+
129
159
  ## Configuration
130
160
 
131
161
  | field | default | |
@@ -137,12 +167,15 @@ The order is fixed and cannot be swapped:
137
167
  | `maxFileBytes` | `2097152` | Larger untracked files are listed but not read, archived, or diffed |
138
168
  | `trashLimit` | `200` | Trash entries kept; the oldest are dropped along with their diff files |
139
169
  | `scanDepth` | `3` | Directory depth the picker scans below the workspace root |
170
+ | `autoFetchMs` | `300000` | How often the panel fetches on its own; `0` disables it, leaving the manual button |
171
+ | `summaryScript` | `""` | A script that fills the Summary. It runs with the repository root as its working directory and whatever it prints becomes the Summary; a line reading `---` starts the Description |
140
172
 
141
173
  Every field is volatile, so a change on the settings page takes effect without a restart.
142
174
 
143
175
  **Where state lives**: `<DSH_HOME>/dsh-gitbar-lite/` (falling back to `~/.dsh`) — `bindings.json` (workspace →
144
- repository), `prefs.json` (the two remembered choices), and `trash/index.json` plus `trash/<id>.diff`. Every write
145
- goes through a temp file and a rename, so a crash mid-write cannot truncate a file.
176
+ repository), `prefs.json` (the two remembered choices), `trash/index.json` plus `trash/<id>.diff`, and
177
+ `trash/<id>.content.json` (the raw contents a restore needs). Every write goes through a temp file and a rename, so a
178
+ crash mid-write cannot truncate a file.
146
179
 
147
180
  ## How it works
148
181
 
package/README.md CHANGED
@@ -23,6 +23,7 @@ DSH 的右栏本来就是一个常驻的标签区。Git 面板放这里,意味
23
23
  | Commit | 只提交勾上的(因为勾选写进了索引) |
24
24
  | Commit and Push | 提交后立刻推送;**推送失败不会说「提交失败」**——提交已经发生了,面板会明说「已提交 xxx,但推送失败」 |
25
25
  | 上下文按钮 | 有本地未推送提交时是 **Push**,没有上游时是 **Publish**,否则是 **Fetch** |
26
+ | 自动 Fetch | 面板可见且仓库有远端时,每 5 分钟自己 fetch 一次(`autoFetchMs` 可调,`0` 关掉)。失败是静默的,不弹错误 |
26
27
  | 舍弃更改 | 单个文件或「全部舍弃」;动手前先把这次改动的 diff 归档,确认框里可以勾「彻底删除」 |
27
28
  | 回收站 | 独立 tab。每次舍弃存一份 diff,并记下**是对哪个提交(`abc1234 初始提交`)取的 diff** |
28
29
  | diff 预览 | 双击文件,或点回收站里的一条,在右栏开一个 diff 标签页——**永远只占一个**,再点就复用 |
@@ -121,6 +122,28 @@ dsh plugin --profile <你的 profile> remove dsh-gitbar-lite
121
122
 
122
123
  > **冲突中的文件会被拒绝舍弃**,只给一句提示。在合并中途 `git restore` 到 HEAD 有扰动合并状态的风险,不值得为省一句话去冒。
123
124
 
125
+ ### 用脚本填 Summary
126
+
127
+ Summary 输入框旁边有一个星星按钮(只有配了脚本才出现),点一下就把脚本的输出填进去。**侧边栏面板重新获得焦点时也会自动跑一次**——把侧边栏开着,去对话里让 agent 干活,回来,Summary 已经填好了。
128
+
129
+ 脚本写在配置的 `summaryScript` 里,以**仓库根目录为工作目录**运行,不喂任何 stdin。所以「取 CHANGELOG 最顶上的版本号」就是:
130
+
131
+ ```sh
132
+ grep -m1 '^## ' CHANGELOG.md | sed 's/^## //'
133
+ ```
134
+
135
+ 输出即 Summary。要同时填 Description,就在输出里单独放一行 `---`,**该行之后**的全部内容是 Description:
136
+
137
+ ```sh
138
+ echo "1.4.2"
139
+ echo "---"
140
+ echo "Bumped the patch version."
141
+ ```
142
+
143
+ 关于 shell:Windows 上优先用 Git 自带的 `sh`(`grep`/`sed`/`awk` 都可用),并把它的 `usr/bin` 补回 `PATH`——否则那些工具会「command not found」。机器上没有 `sh` 才退回 `cmd.exe`,那时脚本要按 cmd 语法写。非 Windows 就是 `sh`。
144
+
145
+ 脚本失败不弹错误横幅,退出码会以一条短暂提示显示;它的 stderr 原样保留,方便你自己判断。
146
+
124
147
  ## 配置
125
148
 
126
149
  | 字段 | 默认 | 说明 |
@@ -132,11 +155,14 @@ dsh plugin --profile <你的 profile> remove dsh-gitbar-lite
132
155
  | `maxFileBytes` | `2097152` | 比这大的未跟踪文件只列出来,不读内容、不归档、不出 diff |
133
156
  | `trashLimit` | `200` | 回收站保留多少条;超出丢弃最旧的连同它的 diff 文件 |
134
157
  | `scanDepth` | `3` | 「不是仓库时」扫描工作区找仓库的目录深度 |
158
+ | `autoFetchMs` | `300000` | 面板自己多久 fetch 一次;`0` = 关掉,只用手动按钮 |
159
+ | `summaryScript` | `""` | 填 Summary 的脚本。以仓库根为工作目录运行,输出即 Summary;单独一行 `---` 之后算 Description |
135
160
 
136
161
  每个字段都是 volatile 的,所以在设置页改了立刻生效、不用重启。
137
162
 
138
163
  **状态存在哪**:`<DSH_HOME>/dsh-gitbar-lite/`(没有 `DSH_HOME` 就回落 `~/.dsh`)——
139
- `bindings.json`(工作区 → 仓库)、`prefs.json`(那两个勾选偏好)、`trash/index.json` + `trash/<id>.diff`。
164
+ `bindings.json`(工作区 → 仓库)、`prefs.json`(那两个勾选偏好)、`trash/index.json` + `trash/<id>.diff` +
165
+ `trash/<id>.content.json`(恢复用的原始内容)。
140
166
  都是先写临时文件再 rename,所以写到一半崩了也不会把文件截断。
141
167
 
142
168
  ## 工作原理
package/lib/client.js CHANGED
@@ -75,6 +75,7 @@ window.__ModuleLoader__.load({
75
75
  TRASH_DELETE: 'trashDelete',
76
76
  TRASH_CLEAR: 'trashClear',
77
77
  TRASH_RESTORE: 'trashRestore',
78
+ RUN_SCRIPT: 'runScript',
78
79
  PREFS: 'prefs',
79
80
  }
80
81
 
@@ -167,6 +168,7 @@ window.__ModuleLoader__.load({
167
168
  'commit.descriptionPlaceholder': 'Description(可选)',
168
169
  'commit.expand': '展开 Description',
169
170
  'commit.collapse': '收起 Description',
171
+ 'commit.runScript': '用脚本填 Summary',
170
172
  'commit.commit': 'Commit',
171
173
  'commit.commitAndPush': 'Commit and Push',
172
174
  'commit.needsSummary': '先写 Summary',
@@ -251,6 +253,7 @@ window.__ModuleLoader__.load({
251
253
  'toast.trashCleared': '回收站已清空({n} 条)',
252
254
  'toast.trashDeleted': '已删除那条记录',
253
255
  'toast.trashRestored': '已恢复 {n} 个文件的更改',
256
+ 'toast.scriptFailed': '脚本退出码 {code}',
254
257
  'toast.pushFailed': '已提交 {commit},但推送失败',
255
258
  }
256
259
 
@@ -298,6 +301,7 @@ window.__ModuleLoader__.load({
298
301
  'commit.descriptionPlaceholder': 'Description (optional)',
299
302
  'commit.expand': 'Show the Description field',
300
303
  'commit.collapse': 'Hide the Description field',
304
+ 'commit.runScript': 'Fill the Summary from the script',
301
305
  'commit.commit': 'Commit',
302
306
  'commit.commitAndPush': 'Commit and Push',
303
307
  'commit.needsSummary': 'Write a Summary first',
@@ -382,6 +386,7 @@ window.__ModuleLoader__.load({
382
386
  'toast.trashCleared': 'Trash emptied ({n} entries)',
383
387
  'toast.trashDeleted': 'Entry deleted',
384
388
  'toast.trashRestored': 'Restored the changes to {n} files',
389
+ 'toast.scriptFailed': 'The script exited with {code}',
385
390
  'toast.pushFailed': 'Committed {commit}, but the push failed',
386
391
  }
387
392
 
@@ -576,6 +581,32 @@ window.__ModuleLoader__.load({
576
581
  return t('trash.restoreNoContent')
577
582
  }
578
583
 
584
+ /**
585
+ * Split a script's output into the Summary and the Description.
586
+ *
587
+ * Everything it printed is the Summary, so a script that knows nothing about
588
+ * this still fills the box usefully. A line reading exactly `---` is the one
589
+ * way to say "the rest is the body": it is three characters a shell script
590
+ * can print, it cannot collide with prose the way a heading might, and it is
591
+ * already the separator Markdown and YAML front-matter use.
592
+ *
593
+ * @param {string} stdout
594
+ * @returns {{ summary: string, description: string }}
595
+ */
596
+ function splitScriptOutput(stdout) {
597
+ const text = typeof stdout === 'string' ? stdout : ''
598
+ const lines = text.split(/\r?\n/)
599
+ const marker = lines.findIndex((line) => line.trim() === '---')
600
+ if (marker === -1) {
601
+ const copy = [...lines]
602
+ while (copy.length > 0 && copy[copy.length - 1].trim() === '') copy.pop()
603
+ return { summary: copy.join('\n'), description: '' }
604
+ }
605
+ const before = lines.slice(0, marker)
606
+ while (before.length > 0 && before[before.length - 1].trim() === '') before.pop()
607
+ return { summary: before.join('\n'), description: lines.slice(marker + 1).join('\n').replace(/^\n+/, '') }
608
+ }
609
+
579
610
  /** How many of a status payload's files are ticked and commit-ready. */
580
611
  function stagedCount(state) {
581
612
  if (state === null || !Array.isArray(state.files)) return 0
@@ -756,6 +787,18 @@ window.__ModuleLoader__.load({
756
787
  'M2.3 3.6V7.4H6.1',
757
788
  ], [])
758
789
 
790
+ /**
791
+ * The script mark: a sparkle, drawn to the same specification.
792
+ *
793
+ * DSH has no "generate" or "wand" glyph to copy, and a chevron would read as
794
+ * "expand" rather than "fill this in", so this is hand-drawn to match the
795
+ * house rule (16 box, `stroke` on each path).
796
+ */
797
+ const WandGlyph = artwork('0 0 16 16', [
798
+ 'M6.5 2.5L7.6 5.9L11 7L7.6 8.1L6.5 11.5L5.4 8.1L2 7L5.4 5.9Z',
799
+ 'M12.2 9.6L12.8 11.2L14.4 11.8L12.8 12.4L12.2 14L11.6 12.4L10 11.8L11.6 11.2Z',
800
+ ], [])
801
+
759
802
  /** `IconRefreshOutlineRegular`. */
760
803
  const RefreshGlyph = artwork(
761
804
  '0 0 16 16',
@@ -861,8 +904,11 @@ window.__ModuleLoader__.load({
861
904
  '.gitbar-status-conflicted{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
862
905
  '.gitbar-path{flex:1 1 auto;min-width:0;display:flex;align-items:baseline;gap:0;overflow:hidden}',
863
906
  '.gitbar-dir{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;color:var(--dsw-alias-label-tertiary,#6b7280)}',
864
- '.gitbar-sep{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280)}',
865
- '.gitbar-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
907
+ // `direction` is inherited, so the rtl above would otherwise reach the
908
+ // separator (moving the slash to the wrong end) and the file name (right-
909
+ // aligning it). Both are spelled back to ltr explicitly.
910
+ '.gitbar-sep{flex:none;direction:ltr;color:var(--dsw-alias-label-tertiary,#6b7280)}',
911
+ '.gitbar-name{flex:0 1 auto;min-width:0;direction:ltr;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
866
912
  '.gitbar-stats{flex:none;display:flex;gap:3px;font-size:10.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}',
867
913
  '.gitbar-plus{color:var(--dsw-alias-state-success-primary,#15803d)}',
868
914
  '.gitbar-minus{color:var(--dsw-alias-state-error-primary,#b91c1c)}',
@@ -1319,20 +1365,108 @@ window.__ModuleLoader__.load({
1319
1365
  [sessionId, tab.signal],
1320
1366
  )
1321
1367
 
1322
- // Load on mount, and again whenever this tab comes back to the front —
1323
- // the panel can be looking at a working tree the agent just changed.
1368
+ // Refresh when the panel comes back. This is the behaviour the panel is
1369
+ // asked for, and it is also the one that matters: leave the sidebar open,
1370
+ // go and let the agent work in the conversation, come back — the file list
1371
+ // should already show what changed, and the Summary script should already
1372
+ // have run. `tab.visible` is exactly that transition.
1373
+ //
1374
+ // Note that GitHub Desktop does *not* refresh on window focus (its focus
1375
+ // handler only tracks the resize handle); it refreshes on repository switch
1376
+ // and on an hourly background rhythm. The panel follows Desktop's shape
1377
+ // where Desktop has one, and the user's explicit request here, because a
1378
+ // DSH sidebar lives next to a chat the agent is working in.
1324
1379
  const wasVisible = useRef(false)
1325
1380
  const loadedOnce = useRef(false)
1326
1381
  useEffect(() => {
1327
1382
  const visible = tab.visible !== false
1328
1383
  if (!loadedOnce.current || visible) {
1329
1384
  loadedOnce.current = true
1385
+ const returning = wasVisible.current === false && visible
1330
1386
  wasVisible.current = visible
1331
1387
  // Quiet when there is already something to show, so returning to the
1332
1388
  // tab does not flash the loading state over a list the user recognises.
1333
- void load({ quiet: state !== null })
1389
+ void load({ quiet: state !== null }).then(() => {
1390
+ // After the list, not before: the script may read the tree, and the
1391
+ // Summary should describe what is on screen.
1392
+ if (returning && hasScript) void runScript()
1393
+ })
1394
+ }
1395
+ }, [tab.visible, sessionId, load, hasScript, runScript])
1396
+
1397
+ // Fetch unattended, in the background, while the panel is being looked at.
1398
+ //
1399
+ // GitHub Desktop's shape (`BackgroundFetcher` in app-store), which this
1400
+ // copies: the interval is long (an hour by default, with a five-minute
1401
+ // floor when the server suggests one), each tick re-asks whether it should
1402
+ // run, the next tick is scheduled from the *completion* of the last one
1403
+ // rather than on a fixed period, and a failure is logged and forgotten.
1404
+ //
1405
+ // Three gates, each with a reason:
1406
+ //
1407
+ // * the tab must be visible — a hidden panel has no business touching the
1408
+ // network, and the host may have several sessions;
1409
+ // * the repository must have a remote — otherwise `fetch` is a guaranteed
1410
+ // E_NO_REMOTE, and a timer that only ever fails is noise;
1411
+ // * nothing else may be in flight — a user mid-commit must not have an
1412
+ // extra git child appear under them.
1413
+ //
1414
+ // The "has it been long enough" question is answered by the repository
1415
+ // itself: `lastFetchAt` is the mtime of `.git/FETCH_HEAD`, so a fetch run
1416
+ // in a terminal counts, and a repository the user fetched a minute ago is
1417
+ // left alone however long the panel stays open.
1418
+ //
1419
+ // The conditions are read through a ref rather than listed as dependencies
1420
+ // on purpose: a dependency on `state` would restart the clock on every
1421
+ // refresh, and a panel the user keeps interacting with would then never
1422
+ // fetch at all.
1423
+ const latest = useRef({ state: null, busy: null })
1424
+ latest.current = { state, busy }
1425
+
1426
+ const autoFetchMs = typeof state?.autoFetchMs === 'number' && state.autoFetchMs > 0 ? state.autoFetchMs : 0
1427
+ useEffect(() => {
1428
+ if (autoFetchMs === 0) return undefined
1429
+ if (tab.visible === false) return undefined
1430
+
1431
+ const sinceLastFetch = () => {
1432
+ const at = latest.current.state?.lastFetchAt
1433
+ const when = Date.parse(typeof at === 'string' ? at : '')
1434
+ if (!Number.isFinite(when)) return Number.POSITIVE_INFINITY
1435
+ return Date.now() - when
1436
+ }
1437
+
1438
+ // Reschedule from completion, not on a fixed period, so a slow fetch
1439
+ // cannot stack up behind itself. The skew keeps two panels that happened
1440
+ // to open together from fetching in lockstep forever.
1441
+ let timer = null
1442
+ let stopped = false
1443
+ const schedule = () => {
1444
+ if (stopped) return
1445
+ const wait = Math.max(0, autoFetchMs - sinceLastFetch()) + Math.ceil(Math.random() * 30000)
1446
+ timer = setTimeout(tick, wait)
1447
+ }
1448
+ const tick = () => {
1449
+ const current = latest.current
1450
+ if (current.busy !== null) return schedule()
1451
+ if (!Array.isArray(current.state?.remotes) || current.state.remotes.length === 0) return schedule()
1452
+ if (sinceLastFetch() < autoFetchMs) return schedule()
1453
+
1454
+ void rpc(ACTIONS.FETCH, { sessionId }, tab.signal)
1455
+ .then(() => {
1456
+ if (alive.current) void load({ quiet: true })
1457
+ })
1458
+ .catch(() => {
1459
+ // Deliberately swallowed: see the note above.
1460
+ })
1461
+ .finally(schedule)
1334
1462
  }
1335
- }, [tab.visible, sessionId, load])
1463
+
1464
+ schedule()
1465
+ return () => {
1466
+ stopped = true
1467
+ if (timer !== null) clearTimeout(timer)
1468
+ }
1469
+ }, [autoFetchMs, tab.visible, tab.signal, sessionId, load])
1336
1470
 
1337
1471
  // The column's refresh shortcut, when it has one.
1338
1472
  useEffect(
@@ -1469,6 +1603,36 @@ window.__ModuleLoader__.load({
1469
1603
  [run, summary, description, flash, t],
1470
1604
  )
1471
1605
 
1606
+ // The user's own script, run to fill the Summary. Only offered when the
1607
+ // config actually has one, and quiet when it is not configured — a panel
1608
+ // that never sets it should look exactly as it did before this existed.
1609
+ const hasScript = typeof state?.summaryScript === 'string' && state.summaryScript.trim() !== ''
1610
+
1611
+ const runScript = useCallback(async () => {
1612
+ setBusy('script')
1613
+ try {
1614
+ const result = await rpc(ACTIONS.RUN_SCRIPT, { sessionId }, tab.signal)
1615
+ if (!alive.current) return
1616
+ if (result.ran !== true) return
1617
+ const { summary: filled, description: body } = splitScriptOutput(result.stdout)
1618
+ setSummary(filled)
1619
+ if (body !== '') {
1620
+ setDescription(body)
1621
+ setDescriptionOpen(true)
1622
+ }
1623
+ setError(null)
1624
+ if (result.exitCode !== 0) {
1625
+ flash('warn', t('toast.scriptFailed', { code: result.exitCode ?? '?' }))
1626
+ }
1627
+ } catch (failure) {
1628
+ if (alive.current && failure?.name !== 'AbortError') {
1629
+ setError({ code: failure.code ?? 'E_UNKNOWN', message: failure.message })
1630
+ }
1631
+ } finally {
1632
+ if (alive.current) setBusy(null)
1633
+ }
1634
+ }, [sessionId, tab.signal, flash, t])
1635
+
1472
1636
  const runContext = useCallback(() => {
1473
1637
  const action = contextAction(state)
1474
1638
  void run('context', action, undefined, (data) => {
@@ -1658,6 +1822,25 @@ window.__ModuleLoader__.load({
1658
1822
  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && canCommit) commit(false)
1659
1823
  },
1660
1824
  }),
1825
+ // Fills the Summary (and the Description) from the user's script. Only
1826
+ // rendered when there is one, so a panel that never configures it looks
1827
+ // exactly as it did before.
1828
+ hasScript
1829
+ ? h(
1830
+ 'button',
1831
+ {
1832
+ key: 'fill',
1833
+ type: 'button',
1834
+ className: 'gitbar-disclose',
1835
+ title: t('commit.runScript'),
1836
+ 'aria-label': t('commit.runScript'),
1837
+ 'data-gitbar-runscript': '',
1838
+ disabled: busy !== null,
1839
+ onClick: () => void runScript(),
1840
+ },
1841
+ h(WandGlyph, { size: 13 }),
1842
+ )
1843
+ : null,
1661
1844
  h(
1662
1845
  'button',
1663
1846
  {
@@ -2358,6 +2541,7 @@ window.__ModuleLoader__.load({
2358
2541
  formatRelative,
2359
2542
  lastFetchLabel,
2360
2543
  restoreReasonText,
2544
+ splitScriptOutput,
2361
2545
  stagedCount,
2362
2546
  hasConflict,
2363
2547
  contextLabelKey,
package/lib/host/git.js CHANGED
@@ -137,6 +137,32 @@ export function createGit(ctx, config) {
137
137
  */
138
138
  async function run(args, options = {}) {
139
139
  const executablePath = await executable(options.signal)
140
+ return spawnCommand(executablePath, args, options, `git ${args[0] ?? ''}`.trim())
141
+ }
142
+
143
+ /**
144
+ * Run something that is not git, with the same guarantees `run` gives git.
145
+ *
146
+ * The user's Summary script is the one caller: it is a shell, not a git
147
+ * subcommand, so `run` — which always prepends the git executable — cannot
148
+ * launch it. Everything else about the contract is shared on purpose: the same
149
+ * timeout, the same abort handling, the same bounded output, the same
150
+ * environment, and the same "a non-zero exit is data, not a throw" rule.
151
+ *
152
+ * @param {readonly string[]} argv the executable and its arguments, in full
153
+ * @param {object} [options] same as `run`
154
+ * @returns {Promise<{ exitCode: number | null, stdout: string, stderr: string, truncated: boolean }>}
155
+ */
156
+ async function runAny(argv, options = {}) {
157
+ return spawnCommand(argv[0], argv.slice(1), options, String(argv[0] ?? ''))
158
+ }
159
+
160
+ /**
161
+ * Spawn one child and collect it. Every launch in this module goes through
162
+ * here, so there is exactly one place where the timeout, the abort ladder and
163
+ * the output ceiling are decided.
164
+ */
165
+ async function spawnCommand(executablePath, args, options, label) {
140
166
  const timeoutMs = Number.isFinite(config.timeoutMs) ? config.timeoutMs : 30000
141
167
  // A zero/negative timeout means "no deadline of our own": still pass the
142
168
  // caller's signal so an aborted request does not leave a child running.
@@ -164,11 +190,11 @@ export function createGit(ctx, config) {
164
190
  })
165
191
  } catch (error) {
166
192
  // `spawn` refuses a spec it cannot honour *synchronously*: a signal that is
167
- // already aborted, or a cwd/env/grace it considers invalid. Neither is a
168
- // git verdict, and letting the throw escape would reach the route layer
169
- // unclassified -- a generic internal error for a request that was simply
170
- // abandoned. Report it the way the asynchronous abort below does.
171
- throw new GitFailure(`git ${args[0] ?? ''}`.trim(), {
193
+ // already aborted, or an argv/cwd/env/grace it considers invalid. Neither is
194
+ // a verdict about the work, and letting the throw escape would reach the
195
+ // route layer unclassified -- a generic internal error for a request that
196
+ // was simply abandoned. Report it the way the asynchronous abort below does.
197
+ throw new GitFailure(label, {
172
198
  exitCode: null,
173
199
  stdout: '',
174
200
  stderr: abortReason(signal, timeout, timeoutMs) ?? String(error?.message ?? error),
@@ -178,7 +204,7 @@ export function createGit(ctx, config) {
178
204
  const outcome = await handle.done
179
205
 
180
206
  if (signal !== undefined && signal.aborted) {
181
- throw new GitFailure(`git ${args[0] ?? ''}`.trim(), { exitCode: null, stdout: '', stderr: abortReason(signal, timeout, timeoutMs) })
207
+ throw new GitFailure(label, { exitCode: null, stdout: '', stderr: abortReason(signal, timeout, timeoutMs) })
182
208
  }
183
209
 
184
210
  const out = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false }
@@ -237,5 +263,5 @@ export function createGit(ctx, config) {
237
263
  return toplevel
238
264
  }
239
265
 
240
- return { executable, run, must, repoRootOf, isRepoRoot }
266
+ return { executable, run, runAny, must, repoRootOf, isRepoRoot }
241
267
  }
package/lib/host/repo.js CHANGED
@@ -199,6 +199,45 @@ async function fetchTimeOf(gitDirResult) {
199
199
  }
200
200
  }
201
201
 
202
+ /** Resolved once per plugin instance: which shell a script is written for. */
203
+ let scriptShell = null
204
+
205
+ /**
206
+ * The argv prefix that runs one script string.
207
+ *
208
+ * `sh` if the machine has one, and `cmd.exe /d /s /c` otherwise.
209
+ *
210
+ * Why the hunt for `sh` and not simply "use it on Unix, cmd on Windows": the
211
+ * script is text a user types into a settings field, and the shell it is written
212
+ * in decides whether `grep`, `sed` and `awk` work. Git for Windows ships a full
213
+ * `usr/bin` beside the git binary — but it does *not* add it to `PATH`, so a bare
214
+ * `sh` lookup fails on most Windows machines even though one is installed. The
215
+ * git binary's own location is therefore the place to look: it is the one
216
+ * executable this plugin is certain exists, and its neighbour `usr/bin/sh.exe`
217
+ * is the one that has the POSIX tools beside it.
218
+ *
219
+ * @param {object} git the runner from `createGit`, for executable resolution
220
+ * @param {AbortSignal} [signal]
221
+ * @returns {Promise<readonly string[]>}
222
+ */
223
+ async function scriptPrefix(git, signal) {
224
+ if (scriptShell !== null) return scriptShell
225
+ if (process.platform !== 'win32') {
226
+ scriptShell = ['sh', '-c']
227
+ return scriptShell
228
+ }
229
+
230
+ try {
231
+ const gitPath = await git.executable('git', signal)
232
+ const candidate = resolve(dirname(dirname(gitPath)), 'usr', 'bin', 'sh.exe')
233
+ await stat(candidate)
234
+ scriptShell = [candidate, '-c']
235
+ } catch {
236
+ scriptShell = ['cmd.exe', '/d', '/s', '/c']
237
+ }
238
+ return scriptShell
239
+ }
240
+
202
241
  /**
203
242
  * Build the repository facade for one plugin instance.
204
243
  *
@@ -206,6 +245,13 @@ async function fetchTimeOf(gitDirResult) {
206
245
  * @param {object} config resolved plugin config
207
246
  */
208
247
  export function createRepo(git, config) {
248
+ /**
249
+ * How often the panel should fetch on its own, in milliseconds, or 0 for
250
+ * "never". Resolved once: the value rides along with every status payload,
251
+ * because a client bundle cannot read the host's config.
252
+ */
253
+ const autoFetchMs = Number.isFinite(config.autoFetchMs) && config.autoFetchMs > 0 ? Math.floor(config.autoFetchMs) : 0
254
+
209
255
  /**
210
256
  * Identity, branch and working-tree status of one repository.
211
257
  *
@@ -305,6 +351,13 @@ export function createRepo(git, config) {
305
351
  counts,
306
352
  primary,
307
353
  lastFetchAt,
354
+ // The panel cannot read the host's config (a client bundle has no access to
355
+ // it), so the rhythm it should fetch on travels with the status it is
356
+ // already reading. Zero means "never, on your own".
357
+ autoFetchMs,
358
+ // ...and so does the script, so the panel can offer the button only when
359
+ // there is a script to run.
360
+ summaryScript: typeof config.summaryScript === 'string' ? config.summaryScript : '',
308
361
  }
309
362
  }
310
363
 
@@ -932,5 +985,59 @@ export function createRepo(git, config) {
932
985
  return { restored, skipped }
933
986
  }
934
987
 
935
- return { readStatus, statusOf, setStaged, commit, push, fetch, fileDiff, discard, restore, canRestore, scanRepos }
988
+ /**
989
+ * Run the user's Summary script and report what it printed.
990
+ *
991
+ * The script is text from the settings page, run with the repository root as
992
+ * its working directory, so `grep -m1 '^## ' CHANGELOG.md | sed 's/^## //'` is
993
+ * a valid script. It is handed nothing on stdin: the inputs worth naming in a
994
+ * commit are files in the tree, and a script that wants the diff can ask git
995
+ * for it itself.
996
+ *
997
+ * A non-zero exit is reported rather than thrown — a script that finds nothing
998
+ * is an ordinary outcome, and the panel shows its stderr beside the button.
999
+ *
1000
+ * @param {string} repoRoot
1001
+ * @param {AbortSignal} [signal]
1002
+ * @returns {Promise<{ ran: boolean, exitCode?: number, stdout?: string, stderr?: string }>}
1003
+ */
1004
+ async function runScript(repoRoot, signal) {
1005
+ const script = typeof config.summaryScript === 'string' ? config.summaryScript.trim() : ''
1006
+ if (script === '') return { ran: false }
1007
+
1008
+ const shell = await scriptPrefix(git, signal)
1009
+ // Git for Windows' `sh` is a MinGW program whose tools (`grep`, `sed`, `awk`)
1010
+ // live in its own `usr/bin`, which git does *not* add to PATH — so a script
1011
+ // that uses them finds nothing unless that directory is put back. This is
1012
+ // what git's own `git-bash` wrapper does, and it is the difference between a
1013
+ // script that works and one that reports "grep: command not found".
1014
+ const env =
1015
+ process.platform === 'win32' && shell.length > 0 && shell[0].includes('usr')
1016
+ ? { PATH: `${dirname(shell[0])}${process.env.PATH === undefined ? '' : `;${process.env.PATH}`}` }
1017
+ : {}
1018
+
1019
+ const result = await git.runAny([...shell, script], { cwd: repoRoot, signal, env })
1020
+
1021
+ return {
1022
+ ran: true,
1023
+ exitCode: result.exitCode,
1024
+ stdout: result.stdout,
1025
+ stderr: result.stderr,
1026
+ }
1027
+ }
1028
+
1029
+ return {
1030
+ readStatus,
1031
+ statusOf,
1032
+ setStaged,
1033
+ commit,
1034
+ push,
1035
+ fetch,
1036
+ fileDiff,
1037
+ discard,
1038
+ restore,
1039
+ canRestore,
1040
+ scanRepos,
1041
+ runScript,
1042
+ }
936
1043
  }
@@ -305,6 +305,19 @@ export function createHandlers({ ctx, git, repo, store }) {
305
305
  return { repoRoot, diff }
306
306
  },
307
307
 
308
+ /**
309
+ * Run the user's Summary script and hand back what it printed.
310
+ *
311
+ * The panel does the splitting, because the protocol is a presentation rule
312
+ * (`---` separates Summary from Description) rather than anything the
313
+ * repository needs to know about.
314
+ */
315
+ async [ACTIONS.RUN_SCRIPT](payload, request) {
316
+ const { repoRoot } = await requireRepoRoot(payload, request.signal)
317
+ const result = await repo.runScript(repoRoot, request.signal)
318
+ return { repoRoot, ...result }
319
+ },
320
+
308
321
  async [ACTIONS.TRASH_LIST](payload, request) {
309
322
  const workspace = await requireWorkspace(ctx, stringField(payload, 'sessionId'))
310
323
  const prefs = await store.readPrefs()
package/lib/index.js CHANGED
@@ -67,6 +67,25 @@ export const Config = vol(
67
67
  trashLimit: z.number().default(200),
68
68
  /** Directory depth the repository picker scans below the workspace root. */
69
69
  scanDepth: z.number().default(3),
70
+ /**
71
+ * How often the panel fetches on its own, in milliseconds.
72
+ *
73
+ * A fetch only moves remote refs, never the working tree, so it is safe to
74
+ * do unattended — but it does hit the network and it does write
75
+ * `.git/FETCH_HEAD`, which is why it is opt-out rather than opt-in. Set to 0
76
+ * to disable it; the button still works.
77
+ */
78
+ autoFetchMs: z.number().default(300000),
79
+ /**
80
+ * A script that fills in the Summary, run with the repository root as its
81
+ * working directory. Empty means the feature is off.
82
+ *
83
+ * Whatever it prints is the Summary; a line reading `---` splits it, so
84
+ * everything after that line is the Description. Nothing is fed in — the
85
+ * inputs worth naming in a commit are files in the tree, and a script that
86
+ * wants the diff can ask git for it.
87
+ */
88
+ summaryScript: z.string().default(''),
70
89
  }),
71
90
  )
72
91
 
package/lib/shared.js CHANGED
@@ -54,6 +54,15 @@ export const ACTIONS = {
54
54
  * that happened after the archive was taken.
55
55
  */
56
56
  TRASH_RESTORE: 'trashRestore',
57
+ /**
58
+ * Run the user's summary script and return what it printed.
59
+ *
60
+ * The script is the user's own text from the config, run with the repository
61
+ * root as its working directory. The panel turns its output into a Summary
62
+ * (and a Description), which is why this returns the output rather than
63
+ * writing it anywhere itself.
64
+ */
65
+ RUN_SCRIPT: 'runScript',
57
66
  /** Read or write the small persisted UI preferences. */
58
67
  PREFS: 'prefs',
59
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gitbar-lite",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A GitHub Desktop-style Git panel for DSH: changed files, tick-to-stage, Summary + Description, Commit and Commit and Push, discard-to-trash, and diff preview, in the built-in right sidebar.",
5
5
  "type": "module",
6
6
  "license": "MIT",