@xia-sc/dsh-git 0.5.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/README.en.md ADDED
@@ -0,0 +1,284 @@
1
+ # @xia-sc/dsh-git
2
+
3
+ [中文](./README.md) | English
4
+
5
+ Complete Git management for the DeepSeek Harness Web GUI, presented as a
6
+ **collapsible floating block** that **follows the current session's workspace
7
+ in real time** — click a different session/workspace in the sidebar and the
8
+ panel rebinds to that repository instantly.
9
+
10
+ **Workflow supported:** branch switch · fetch · pull (fast-forward only) ·
11
+ stage all · commit (with an AI-drafted message) · push · status · recent
12
+ commits · uncommitted-file list · **click a change to see its diff** ·
13
+ new-branch-from-base.
14
+
15
+ ## UI
16
+
17
+ - **Floating block** (`shell.overlay`): collapsed = renders nothing (no
18
+ floating element that could cover the input); expanded = the full Git
19
+ workbench (status line, branch switcher with **dirty-tree pre-check** —
20
+ selecting a branch while uncommitted changes exist shows a warning listing
21
+ the affected files instead of switching, with a "switch anyway" escape
22
+ hatch, fetch/pull actions, a **commit area** — stage-all button, draft-basis
23
+ picker, "✨ AI draft" button, message input and commit button — collapsible
24
+ changes and recent-commit lists, last-operation output). The panel is
25
+ **draggable by its header bar**
26
+ (the top row with the Git title — press, drag, release; it stays where
27
+ dropped and is clamped inside the viewport; header buttons/inputs never
28
+ start a drag; double-click the header to snap back to center).
29
+ A "+ New branch" button beside the branch switcher opens an inline form:
30
+ new branch name + base-branch picker (local branches or full remote refs
31
+ like `origin/feature/x`) — confirming creates the branch from the base and
32
+ switches to it.
33
+ - **Click a change to see its diff**: clicking any row of the change list grows
34
+ the panel from its 400px single column into two panes — the full workbench on
35
+ the left, that file's unified diff on the right (see *The diff viewer* below).
36
+ Clicking the same row again, or the diff header's `×`, folds it back.
37
+ - **Composer dock pill** (`conversation.input.dock`): a compact, left-aligned
38
+ status pill at the textarea's top-left (branch summary or "not a git
39
+ repository"); clicking it toggles the floating panel.
40
+ - Both seats share one store, so they always agree, and both re-bind when the
41
+ current session (and its cwd) changes. Rebinding clears the selected file —
42
+ the panel never shows another repository's content.
43
+
44
+ ### The diff viewer
45
+
46
+ Clicking a row of the change list shows that file's git diff on the right. The
47
+ list is the viewer's navigation: the highlighted row is the file on screen, and
48
+ the first selection expands the list to its full length.
49
+
50
+ - **Resizable**: the handle is the panel's own right edge — dragging it widens the
51
+ panel (the workbench keeps its width) and the diff takes the rest, 1:1 with the
52
+ pointer because the panel anchors its left edge on grab. Double-clicking the
53
+ edge resets the width, and the panel always stays inside the viewport.
54
+ - **Staged / unstaged**: by default the pane follows the data — unstaged when
55
+ that side has anything, staged otherwise — with two chips to switch by hand
56
+ (an empty side is dimmed). When a file has changes on both sides, one click
57
+ shows the side you care about and the other is one chip away.
58
+ - **Self-refreshing**: staging, committing, switching branches, or refreshing
59
+ re-reads the open diff; when the unstaged side empties, the pane falls back to
60
+ the staged side on its own.
61
+ - **Line numbers and colour**: per-side gutters taken from each hunk header,
62
+ `+`/`-` rows in the theme's success/error colours, hunk headers on their own
63
+ row, `` dimmed.
64
+ - **Every edge has its own notice**: nothing on this side / a binary file has no
65
+ text diff / the diff was too large and only its beginning is shown / N more
66
+ untracked files were not expanded / the read failed. A diff past 1500 rows
67
+ renders its first 1500 with a button to expand the rest (so a huge patch never
68
+ becomes tens of thousands of nodes).
69
+ - **Untracked files** are diffed against the empty blob and read as a
70
+ `new file mode` addition; an untracked **directory** is expanded file by file
71
+ (capped at 50, the rest counted in that notice).
72
+ - **Renames** hand git both the old and the new path as the pathspec: naming only
73
+ the new one leaves git unable to pair them and turns a rename into a whole-file
74
+ addition.
75
+
76
+ ### The commit area and AI drafting
77
+
78
+ The commit area follows the real order of operations: **stage all → draft → commit**.
79
+
80
+ - **Stage all**: `git add --all` (deletions and untracked files included).
81
+ Disabled while the tree is clean. This is the answer to "only unstaged
82
+ changes exist, so Commit fails" — committing itself still never stages
83
+ implicitly.
84
+ - **Draft basis**: one of `Staged` (default), `Unstaged`, `Everything` — which
85
+ part of the change set is handed to the model. `Staged` is the default
86
+ because **it is the only basis whose content the commit actually records**;
87
+ a message drafted from anything else may describe changes that will not be
88
+ committed.
89
+ - **✨ AI draft**: sends the selected change set (diffstat + diff, truncated)
90
+ to the model the current session has selected and drops the generated
91
+ message into the input. Edit it, or just write your own.
92
+
93
+ The model route comes from the current session's `modelSelection` projection
94
+ (pending pick first, then last used), falling back to the host's first
95
+ registered route. Failures are reported in the last-operation output with a
96
+ localized sentence (no changes / no model configured / draft failed, …).
97
+
98
+ ## Architecture
99
+
100
+ One dual-face npm package:
101
+
102
+ | Half | File | Role |
103
+ | --- | --- | --- |
104
+ | Host | `lib/index.js` | Cordis plugin (bundle row `dsh-git`) registering the `/dsh-git-rpc` prefix route on its own `ctx.webServer`, speaking the same Connection RPC envelope the browser's `connection.rpc.call` sends and reusing the connection service's Host/Origin + browser-session fence (`connection.requestRejection`). Endpoints: `status`, `branches`, `checkout`, `createBranch`, `fetch`, `pull`, `stage`, `diff`, `commit`, `push`, `log`, `generateMessage`. All git runs via `execFile` (no shell), timeouts (30s local / 120s network), strict input validation. AI drafting goes through the injected `llm` service. |
105
+ | Browser | `lib/client.js` | `dsh.client` bundle (served at `/plugins/@xia-sc/dsh-git/client.js`): floating panel + dock line + shared store, hand-written against the module table (only `react`). |
106
+
107
+ ### Why the route is self-owned (dsh >= 0.1.5-rc.1)
108
+
109
+ Since dsh 0.1.5-rc.1 an outside plugin can no longer call
110
+ `ctx.connection.rpc.handle()`: `HostConnectionService.rpc` closes over the
111
+ connection plugin's **own** Context (`inject` is only `["credentials"]`) and
112
+ registers through it (`owner.effect(() => owner.webServer.register(route))`),
113
+ while that plugin only resolves `webServer` inside an inner
114
+ `ctx.inject(["webServer"], …)` scope. Whatever the caller injects, the row
115
+ therefore failed to mount with `cannot get property "webServer" without inject`
116
+ — which is exactly what 0.3.0 did. This build registers `/dsh-git-rpc` itself
117
+ and implements the same RPC envelope; the request fence still comes from the
118
+ connection service's `requestRejection`, so the channel is exactly as trusted
119
+ as `/api`. `test/host-mount.mjs` guards this against a real Cordis host and the
120
+ real Connection service.
121
+
122
+ ### The session binding belongs to the session-scoped seat (dsh >= 0.1.6-alpha.2)
123
+
124
+ Both faces used to read `current` (the current session id) out of the sessions
125
+ list snapshot. 0.1.6-alpha.2 dropped that field — the snapshot is now `ids` /
126
+ `byId` / `phase` / `subagentsByParent` / `jobsBySession`, and the current
127
+ session reaches **session-scoped** seats through the renderer's scope adapter
128
+ (`SlotScopeAdapter.current`, derived from `retainedBy.mainView`), which a
129
+ root-scoped `shell.overlay` entry cannot read (the symptom is the pill and the
130
+ panel silently disappearing, with no error anywhere).
131
+
132
+ Now the **pill** (`conversation.input.dock`, session-scoped: the framework hands
133
+ it `sessionId`) reads that session's `cwd` from the `useSessions` snapshot and
134
+ calls `store.bindSession(sessionId, cwd)`; the **panel** only reads the shared
135
+ store's `sessionId` / `cwd` (it also uses the identity to resolve the
136
+ `modelSelection` projection behind the AI draft route). `byId[id].retainedBy.mainView`
137
+ keeps an embedded Conversation (a subagent chat tab) from taking the workbench
138
+ over; a snapshot without that count is treated as main-view so the pill never
139
+ vanishes for want of it.
140
+
141
+ ## Install
142
+
143
+ ```sh
144
+ dsh plugin --profile web add @xia-sc/dsh-git
145
+ ```
146
+
147
+ Or install straight from the source (both channels are the same code: the npm
148
+ version is the artifact of the matching tag):
149
+
150
+ ```sh
151
+ dsh plugin --profile web add https://github.com/xia-sc/dsh-git
152
+ ```
153
+
154
+ Then **restart `dsh web`** (bundle rows and the browser roster compose at
155
+ boot). After refresh, the dock pill appears above the composer once the current
156
+ session's workspace is a git repository; click it to open the panel.
157
+
158
+ Requires **dsh >= 0.1.5-rc.1** (the host half owns its `/dsh-git-rpc` route; see
159
+ the architecture note above).
160
+
161
+ Uninstall:
162
+
163
+ ```sh
164
+ dsh plugin --profile web remove @xia-sc/dsh-git
165
+ ```
166
+
167
+ ## RPC contract (`/dsh-git-rpc`)
168
+
169
+ The browser calls `ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args })`;
170
+ the host side is this plugin's own `/dsh-git-rpc/*` prefix route, speaking the
171
+ same Connection envelope as `/api`:
172
+
173
+ - Request: `POST /dsh-git-rpc/<endpoint>`, `content-type: application/json`,
174
+ `{ type: "client-request", rpcId, method: <endpoint>, payload: { args } }`
175
+ - Response: `{ type: "server-response", rpcId, result: { ok: true, value } | { ok: false, error } }`
176
+ - Fence: `connection.requestRejection` (Host/Origin + browser session cookie);
177
+ non-`POST` → 405, non-JSON → 415, oversized body → 413, path outside the
178
+ channel → 404.
179
+
180
+ Payloads use the `{ args }` convention. `cwd` must be an absolute path;
181
+ `branch` matches `^[A-Za-z0-9][A-Za-z0-9._/-]*$` (no leading `-`, no `..`,
182
+ `@{`, `\`, whitespace, control chars); `remote` is a plain segment. The commit
183
+ message is a real message: one subject line plus an optional multi-line body.
184
+ CRLF becomes LF, trailing whitespace is stripped per line, leading/trailing
185
+ blank lines are dropped and runs of blank lines collapse to one (so the single
186
+ blank line between subject and body is preserved); empty, over-long (>10000
187
+ characters), and control-character-carrying messages are rejected with
188
+ `invalid-message`. The message reaches git on stdin via
189
+ `git commit --cleanup=whitespace --file=-`, so spaces, quotes, line feeds,
190
+ shell metacharacters, and leading dashes are all recorded verbatim.
191
+
192
+ | Endpoint | args | Result (`value`) |
193
+ | --- | --- | --- |
194
+ | `status` | `{ cwd }` | `{ repo, branch, detached, oid, upstream, ahead, behind, dirty, changes: [{status, path, index, worktree, file, origFile}] }`. `path` is the display string (a rename reads `old → new`), `file`/`origFile` are the pathspec `diff` needs, and `index`/`worktree` are the two porcelain-v2 letters. |
195
+ | `branches` | `{ cwd }` | `{ repo, current, local: [{name, current, upstream, sha}], remote: [{name, short}] }` |
196
+ | `checkout` | `{ cwd, branch }` | `{ branch, detached, oid, message? }` via `git switch --guess`; the browser pre-checks dirty state and warns before switching; a refusal caused by "local changes would be overwritten" is surfaced with a readable prefix. |
197
+ | `createBranch` | `{ cwd, branch, base? }` | `{ branch, detached, oid, message? }` via `git switch --create <branch> <base>` (omitted base = HEAD); creates the branch from the base branch and switches to it. |
198
+ | `fetch` | `{ cwd, remote? }` | `{ message }` (120s timeout) |
199
+ | `pull` | `{ cwd }` | `{ message }` via `git pull --ff-only` (never implicit-merge) |
200
+ | `stage` | `{ cwd }` | `{ message }` via `git add --all` |
201
+ | `diff` | `{ cwd, path, origPath? }` | `{ repo, path, origPath, untracked, skipped, worktree: {diff, binary, truncated}, index: {…} }`. Both sides are read in one round trip (`git diff [--cached] --no-ext-diff --no-color -- <path> [<origPath>]`); `path` must be repository-relative (absolute paths, `..`, a leading `-`, control characters, and surrounding whitespace are rejected with `invalid-path`). An untracked path is diffed with `git diff --no-index -- /dev/null <path>` (exit code 1 tolerated); an untracked directory is expanded with `git ls-files --others --exclude-standard` (capped at 50, the rest counted in `skipped`). A side past 400k characters is truncated at a line boundary and flagged `truncated`; a binary side is flagged `binary`. **Read-only**: it never writes the index, the working tree, or any config. |
202
+ | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
203
+ | `push` | `{ cwd }` | `{ message }` (120s timeout) |
204
+ | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
205
+ | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`. `mode` is `staged` (default) / `unstaged` / `all`; anything else is `invalid-mode`. Failure code in `error.details.code`: `no-changes`, `no-provider`, `no-model`, `llm-empty`, `cancelled`, `llm-failed`. |
206
+
207
+ > A failed result carries `error.code === "internal"` on the wire (the Connection
208
+ > envelope only requires a string), with the plugin's own diagnostic in
209
+ > `error.details.code`; the client localizes from that code.
210
+
211
+ ## Design decisions & boundaries
212
+
213
+ - **pull is `--ff-only`**: no surprise merge commits; conflicts surface as an
214
+ error the user resolves in their own tooling.
215
+ - **commit does not stage**: it commits what is staged. To commit everything at
216
+ once, use the commit area's **Stage all** button (`git add --all`) rather than
217
+ making commit stage implicitly.
218
+ - **AI drafting sends the change set's diff to whichever model provider you
219
+ configured** — possibly a third-party gateway. It only happens when you click
220
+ "✨ AI draft"; the plugin itself never calls the network. The diff is
221
+ truncated to 12000 characters, and nothing outside the repository is sent.
222
+ - **push/pull credentials** come from the system (Git Credential Manager /
223
+ SSH agent); the plugin never touches credential storage. AI drafting never
224
+ touches credentials either — the model adapter resolves its own API key.
225
+ - **The plugin never mutates git config**; missing author reports a clear
226
+ error instead.
227
+ - **The diff viewer is strictly read-only**: its endpoint runs only `git diff` /
228
+ `git ls-files` — it never writes the index, the working tree, or any config —
229
+ and it deliberately does **not** depend on the host's right-Sidebar tab API
230
+ (that surface is still moving fast). The two panes live inside the panel
231
+ instead, and the diff renderer is written here as well (this bundle depends on
232
+ `react` only): unified-diff parsing, both line-number gutters, `+`/`-`
233
+ colours, and no syntax-highlighting dependency.
234
+ - **A rename needs both names in the pathspec**: git only pairs them when the old
235
+ path is named too; naming the new path alone reports a whole-file addition
236
+ (`test/diff.mjs` guards this).
237
+ - **porcelain-v2 status parsing**: a `2` (rename/copy) record carries its path in
238
+ the 10th field with the old name TAB-separated after it, and a `u` (conflict)
239
+ record carries its path in the 11th field and is always a conflict. Both were
240
+ read from `slice(8)` / `slice(9)` before — off by the score and hash columns —
241
+ and are now built by `changeEntry()`.
242
+ - **The plugin imports no `@deepseek-ai/*` runtime package** (only `node:`
243
+ builtins and `@deepseek-ai/cordis`). Under a pnpm `link:` install the host
244
+ packages do not resolve from the plugin's real source path, so such an import
245
+ would make the plugin fail at load time; the request construction and stream
246
+ assembly generation needs are therefore implemented locally and tested
247
+ directly by `test/generate.mjs`.
248
+ - Panel operations are plain UI actions (like the Cordis panel) and are not
249
+ written to the session log / model prompt. AI drafting only fills the input;
250
+ it never commits by itself.
251
+
252
+ ## Development notes
253
+
254
+ - The browser bundle is hand-written (no build step); edits to `lib/client.js`
255
+ are picked up on refresh (no-cache), host-side edits need a `dsh web`
256
+ restart.
257
+ - Tests (`npm test` runs all four):
258
+ - `node test/smoke.mjs` — route, envelope, endpoint dispatch and input
259
+ validation (no git spawn: the session sandbox blocks child-process piped
260
+ stdio);
261
+ - `node test/host-mount.mjs` — mounts the row on a real Cordis host with the
262
+ real `dsh-client-connection` (resolved from the `DSH_HOME` profile; SKIPs
263
+ when no profile is installed);
264
+ - `node test/generate.mjs` — the AI-draft units: route resolution, prompt
265
+ assembly, truncation, stream assembly (both the `block-end` and the
266
+ delta-only path), terminal failure / abort / empty output;
267
+ - `node test/render.mjs` — real React SSR render of both seats, including the
268
+ three commit-area controls, the diff parser (line numbers, row kinds, a
269
+ removed line starting with `--`), the row renderer, the diff pane's header,
270
+ and `act()`'s result plumbing and localization (needs a react/react-dom copy,
271
+ e.g. via `DSH_GIT_REACT_ROOT`; SKIPs without one).
272
+ - `npm run test:commit` — **end-to-end**: really spawns git in a throwaway
273
+ repository, commits through the plugin's own `/dsh-git-rpc/commit` route, and
274
+ reads the message back with `git log --format=%B` (multi-line, CRLF, non-ASCII,
275
+ leading `-`, shell metacharacters…), then confirms a rejected message creates
276
+ no commit.
277
+ - `npm run test:diff` — **end-to-end**: drives `/dsh-git-rpc/diff` in a throwaway
278
+ repository through every shape the change list can produce — both sides of the
279
+ index, an untracked file and an untracked **directory**, **rename pairing**, a
280
+ deletion, a binary blob, the 400k truncation, path validation, and a directory
281
+ outside any work tree.
282
+ - The last two need piped child-process stdio, so they are deliberately **not**
283
+ part of `npm test` — run them from an ordinary terminal.
284
+ - The git command set is verified end-to-end against the running server.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # @xia-sc/dsh-git
2
+
3
+ [English](./README.en.md) | 中文
4
+
5
+ DeepSeek Harness Web GUI 的完整 Git 管理插件,形态为一个**可折叠的悬浮面板**,
6
+ **实时跟随当前会话的工作区**——在侧边栏点击不同的会话/工作区,面板会瞬间
7
+ 重新绑定到对应仓库。
8
+
9
+ **支持的工作流:** 分支切换 · 拉取更新(fetch) · 拉取合并(pull,仅快进) ·
10
+ 暂存全部 · 提交(commit,可用 AI 起草提交信息) · 推送(push) · 状态(status) ·
11
+ 最近提交 · 未提交文件列表 · **点击变更看差异** · 基于某分支新建分支。
12
+
13
+ ## 界面
14
+
15
+ - **悬浮面板**(`shell.overlay`):折叠时不渲染任何元素(不会遮挡输入框);
16
+ 展开后是完整的 Git 工作台(状态行、带 **脏树预检** 的分支切换器——在有
17
+ 未提交修改时选择分支,会先显示受影响文件列表警告而不是直接切换,并提供
18
+ "仍要切换"按钮;fetch/pull 操作;**提交区**——"暂存全部"按钮、AI 生成依据
19
+ 选择器、"✨ AI 生成"按钮、提交信息输入框(多行文本框,回车换行、
20
+ Ctrl/Cmd+Enter 提交)与提交按钮;可折叠的变更列表与
21
+ 最近提交列表;上次操作输出)。面板**可通过顶栏拖动**(按住带 Git 标题的
22
+ 那一行,拖到哪里就停在哪里,不会拖出视口;顶栏上的按钮/输入框不会触发
23
+ 拖动;双击顶栏回到居中位置)。
24
+ 分支切换器旁边的"+ 新建分支"按钮会展开一个内联表单:新分支名 + 基分支
25
+ 选择器(本地分支或 `origin/feature/x` 这样的完整远端引用)——确认后从
26
+ 该基分支创建新分支并切换过去。
27
+ - **点击变更看差异**:点变更列表里的任意一行,面板会从 400px 的单栏展开成
28
+ 两栏——左边照旧是完整工作台,右边是该文件的 unified diff(见下文
29
+ 「差异查看」)。再点同一行、或点差异标题栏的 `×`,就收回单栏。
30
+ - **输入框胶囊**(`conversation.input.dock`):输入框左上角的紧凑型左对齐
31
+ 状态胶囊(分支摘要,或"当前工作区不是 Git 仓库");点击它展开/收起悬浮面板。
32
+ - 两处界面共享同一个 store,状态永远一致,并都会随当前会话(及其 cwd)
33
+ 切换而重新绑定。切换工作区会清空已选中的文件——绝不让面板显示别的仓库的内容。
34
+
35
+ ### 差异查看
36
+
37
+ 点变更列表里的一行,就在右侧显示该文件的 git diff。左侧列表是它的导航:
38
+ 行高亮表示"正在看这一个",首次点击会自动把变更列表展开成完整列表。
39
+
40
+ - **可拖拽调宽**:拖动**面板最右侧那条边**即改差异栏宽度——左栏(工作台)宽度不变,
41
+ 面板整体变宽、差异栏跟着长;双击这条边复位。面板始终留在视口内,窗口变窄时
42
+ 会自动收回;拖动时面板会就地锚定左边缘,所以指针与这条边是 1:1 跟手的。
43
+ - **未暂存 / 已暂存**:默认自动跟随数据——未暂存有内容就显示未暂存,否则显示
44
+ 已暂存;顶部两个 chip 可手动切换,空的一侧置灰。同一个文件两边都有改动时,
45
+ 一次点击看到的是最关心的那一侧,另一侧一键可达。
46
+ - **自动刷新**:暂存、提交、切分支、刷新之后,打开的差异会自己重读——未暂存
47
+ 变空时会自动落到已暂存。
48
+ - **行号与配色**:左右两列行号(旧/新)取自每个 hunk 头,`+`/`-` 行走主题的
49
+ 成功/错误色,hunk 头单独一行,`` 灰显。
50
+ - **边界都有明确提示**:这一侧没有改动 / 二进制文件没有文本差异 / 差异过大只
51
+ 显示开头一段 / 未跟踪目录里还有 N 个文件未展开 / 读取失败。超过 1500 行的
52
+ 差异先渲染前 1500 行,按钮可展开全部(避免一次渲染几万个节点)。
53
+ - **未跟踪文件**:与空文件对比,显示成 `new file mode` 的新增 diff;未跟踪
54
+ **目录**会把里面的文件逐个展开(上限 50 个,其余计入提示)。
55
+ - **重命名**:同时把旧名与新名作为 pathspec 交给 git,否则 git 无法配对,会把
56
+ 一次重命名报成整文件新增。
57
+
58
+ ### 提交区与 AI 起草
59
+
60
+ 提交区按真实操作顺序排列:**暂存全部 → AI 生成 → 提交**。
61
+
62
+ - **暂存全部**:`git add --all`(含删除与未跟踪文件)。工作区干净时置灰。
63
+ 这是"只有未暂存改动、点提交却报错"的正解——提交本身依然**不会**隐式暂存。
64
+ - **AI 生成依据**:三选一,决定把哪一部分改动交给模型:
65
+ `已暂存`(默认)、`未暂存`、`全部`。默认 `已暂存`,因为**只有它是本次
66
+ 提交真正会记录的内容**——用其他依据生成的描述可能与实际提交不符。
67
+ - **✨ AI 生成**:把选中的改动(diffstat + diff,截断后)交给当前会话所选
68
+ 模型,生成的提交信息直接填入输入框;不满意可改,也可以直接手写。
69
+
70
+ 生成用的模型路由取当前会话的 `modelSelection` 投影(待生效的选择优先,
71
+ 其次是上次实际使用),取不到时回落到宿主注册的第一条路由。失败会以
72
+ 本地化文案显示在"上次操作输出"里(无可用改动 / 未配置模型 / 生成失败等)。
73
+
74
+ ## 架构
75
+
76
+ 一个双面 npm 包:
77
+
78
+ | 半边 | 文件 | 职责 |
79
+ | --- | --- | --- |
80
+ | 宿主 | `lib/index.js` | Cordis 插件(bundle 行 `dsh-git`),在自己的 `ctx.webServer` 上注册 `/dsh-git-rpc` 前缀路由,收发浏览器 `connection.rpc.call` 的同一套 Connection RPC 信封,并复用 connection 服务的 Host/Origin + 浏览器会话围栏(`connection.requestRejection`)。端点:`status`、`branches`、`checkout`、`createBranch`、`fetch`、`pull`、`stage`、`diff`、`commit`、`push`、`log`、`generateMessage`。所有 git 调用都走 `execFile`(无 shell)、带超时(本地 30s / 网络 120s)、严格入参校验。AI 生成走注入的 `llm` 服务。 |
81
+ | 浏览器 | `lib/client.js` | `dsh.client` bundle(服务于 `/plugins/@xia-sc/dsh-git/client.js`):悬浮面板 + dock 行 + 共享 store,对照模块表手写(仅依赖 `react`)。 |
82
+
83
+ ### 为什么自持 HTTP 路由(dsh ≥ 0.1.5-rc.1)
84
+
85
+ dsh 0.1.5-rc.1 起,外部插件不能再调用 `ctx.connection.rpc.handle()`:
86
+ `HostConnectionService.rpc` 闭包持有的是 **connection 插件自己的 Context**(`inject`
87
+ 只有 `["credentials"]`),注册时执行
88
+ `owner.effect(() => owner.webServer.register(route))`,而该插件只在内部的
89
+ `ctx.inject(["webServer"], …)` 作用域里取得到 `webServer`。于是无论调用方 inject 了
90
+ 什么,这一行都会以 `cannot get property "webServer" without inject` 挂载失败
91
+ (0.3.0 正是如此,插件在 0.1.5-rc.1 上装不起来)。本插件因此改为自己注册
92
+ `/dsh-git-rpc` 路由、自己实现同一套 RPC 信封;请求围栏仍交给 connection 服务的
93
+ `requestRejection`,安全等级与 `/api` 完全一致。`test/host-mount.mjs` 在真实 Cordis +
94
+ 真实 Connection 服务上守护这一点。
95
+
96
+ ### 会话绑定由会话座位提供(dsh ≥ 0.1.6-alpha.2)
97
+
98
+ 两个界面此前都从 sessions 列表快照里读 `current`(当前会话 id)。0.1.6-alpha.2 把这个
99
+ 字段去掉了——列表快照只剩 `ids` / `byId` / `phase` / `subagentsByParent` /
100
+ `jobsBySession`,当前会话改由渲染器的作用域适配器(`SlotScopeAdapter.current`,由
101
+ `retainedBy.mainView` 推出)投递给**会话作用域**的座位,根作用域的 `shell.overlay`
102
+ 读不到它(症状是胶囊和面板一起静默消失,没有任何报错)。
103
+
104
+ 现在:**胶囊**(`conversation.input.dock`,会话作用域,框架直接给出 `sessionId`)
105
+ 从 `useSessions` 快照里取该会话的 `cwd`,调用 `store.bindSession(sessionId, cwd)`;
106
+ **面板**只读共享 store 的 `sessionId` / `cwd`(它另外用 `sessionId` 取
107
+ `modelSelection` 投影来定 AI 起草的路由)。`byId[id].retainedBy.mainView` 用来排除
108
+ 右栏里的嵌入式会话(子会话 chat),快照不带这个计数时按主视图放行。
109
+
110
+ ## 安装
111
+
112
+ ```sh
113
+ dsh plugin --profile web add @xia-sc/dsh-git
114
+ ```
115
+
116
+ 也可以直接从源码装(两个渠道同源,npm 上的版本就是对应 tag 的产物):
117
+
118
+ ```sh
119
+ dsh plugin --profile web add https://github.com/xia-sc/dsh-git
120
+ ```
121
+
122
+ 然后**重启 `dsh web`**(bundle 行与浏览器 roster 在启动时组合)。刷新后,
123
+ 当前会话工作区是 git 仓库时,输入框上方会出现 dock 胶囊,点击即可展开面板。
124
+
125
+ 要求 **dsh ≥ 0.1.5-rc.1**(宿主半自持 `/dsh-git-rpc` 路由,见上文架构说明)。
126
+
127
+ 卸载:
128
+
129
+ ```sh
130
+ dsh plugin --profile web remove @xia-sc/dsh-git
131
+ ```
132
+
133
+ ## RPC 约定(`/dsh-git-rpc`)
134
+
135
+ 浏览器侧通过 `ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args })` 调用;宿主侧
136
+ 是本插件自持的 `/dsh-git-rpc/*` 前缀路由,收发与 `/api` 相同的 Connection 信封:
137
+
138
+ - 请求:`POST /dsh-git-rpc/<endpoint>`,`content-type: application/json`,
139
+ `{ type: "client-request", rpcId, method: <endpoint>, payload: { args } }`
140
+ - 响应:`{ type: "server-response", rpcId, result: { ok: true, value } | { ok: false, error } }`
141
+ - 围栏:`connection.requestRejection`(Host/Origin + 浏览器会话 Cookie);非 `POST` → 405,
142
+ 非 JSON → 415,请求体超限 → 413,路径不属于本通道 → 404。
143
+
144
+ 载荷使用 `{ args }` 约定。`cwd` 必须是绝对路径;`branch` 匹配
145
+ `^[A-Za-z0-9][A-Za-z0-9._/-]*$`(不允许前导 `-`、`..`、`@{`、`\`、空白、
146
+ 控制字符);`remote` 为普通单段。提交信息是真正的提交信息:一行主题 +
147
+ 可选的多行正文。CRLF 会归一为 LF,逐行去掉行尾空白,去掉首尾空行并把连续
148
+ 空行折叠为一行(因此主题与正文之间那一行空行会保留);空、超长(>10000
149
+ 字符)、含控制字符的信息报 `invalid-message`。信息经 stdin 以
150
+ `git commit --cleanup=whitespace --file=-` 传入,因此空格、引号、换行、shell
151
+ 元字符、前导 `-` 都会被原样记录。
152
+
153
+ | 端点 | 参数 | 结果(`value`) |
154
+ | --- | --- | --- |
155
+ | `status` | `{ cwd }` | `{ repo, branch, detached, oid, upstream, ahead, behind, dirty, changes: [{status, path, index, worktree, file, origFile}] }`。`path` 是展示串(重命名读作 `old → new`),`file`/`origFile` 是交给 `diff` 的 pathspec,`index`/`worktree` 是 porcelain-v2 的两个字母。 |
156
+ | `branches` | `{ cwd }` | `{ repo, current, local: [{name, current, upstream, sha}], remote: [{name, short}] }` |
157
+ | `checkout` | `{ cwd, branch }` | `{ branch, detached, oid, message? }`,经 `git switch --guess`;浏览器会预检脏树并提前警告;因"本地修改会被覆盖"被拒绝时会带上可读前缀。 |
158
+ | `createBranch` | `{ cwd, branch, base? }` | `{ branch, detached, oid, message? }`,经 `git switch --create <branch> <base>`(缺省 base 即 HEAD);从基分支创建新分支并切换过去。 |
159
+ | `fetch` | `{ cwd, remote? }` | `{ message }`(120s 超时) |
160
+ | `pull` | `{ cwd }` | `{ message }`,经 `git pull --ff-only`(绝不隐式合并) |
161
+ | `stage` | `{ cwd }` | `{ message }`,经 `git add --all` |
162
+ | `diff` | `{ cwd, path, origPath? }` | `{ repo, path, origPath, untracked, skipped, worktree: {diff, binary, truncated}, index: {…} }`。两侧一次读回(`git diff [--cached] --no-ext-diff --no-color -- <path> [<origPath>]`);`path` 必须是仓库内相对路径(拒绝绝对路径、`..`、前导 `-`、控制字符、首尾空白),非法时报 `invalid-path`。未跟踪路径用 `git diff --no-index -- /dev/null <path>`(容忍退出码 1),未跟踪目录用 `git ls-files --others --exclude-standard` 展开(上限 50 个,其余计入 `skipped`)。单侧超过 40 万字符在行边界截断并置 `truncated`;二进制置 `binary`。**只读**,不碰 index / 工作区 / 配置。 |
163
+ | `commit` | `{ cwd, message }` | `{ message }`;未配置 `user.name/email` 时报 `missing-author` 错误 |
164
+ | `push` | `{ cwd }` | `{ message }`(120s 超时) |
165
+ | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }`(钳制 1..50) |
166
+ | `generateMessage` | `{ cwd, mode?, provider?, model? }` | `{ message, mode, provider, model }`。`mode` 为 `staged`(默认)/`unstaged`/`all`,非法值报 `invalid-mode`;失败码见 `error.details.code`:`no-changes`、`no-provider`、`no-model`、`llm-empty`、`cancelled`、`llm-failed`。 |
167
+
168
+ > 失败结果的 `error.code` 在线路上固定为 `"internal"`(Connection 信封只要求它是字符串),
169
+ > 插件自己的诊断码放在 `error.details.code`;客户端按该码做本地化文案。
170
+
171
+ ## 设计决策与边界
172
+
173
+ - **pull 固定 `--ff-only`**:不产生意外的合并提交;冲突以错误形式呈现,
174
+ 由用户在自己的工具里解决。
175
+ - **commit 不暂存**:只提交已暂存的内容。想一次提交全部改动,用提交区的
176
+ "暂存全部"按钮(`git add --all`),而不是让提交隐式暂存。
177
+ - **AI 生成会把改动的 diff 发给你配置的模型提供方**——可能是第三方网关。
178
+ 这是显式点击"✨ AI 生成"才会发生的联网行为;插件本身不联网。diff 截断到
179
+ 12000 字符后发送,且不发送任何仓库外的内容。
180
+ - **push/pull 凭据**来自系统(Git Credential Manager / SSH agent);插件
181
+ 绝不碰凭据存储。AI 生成同样不接触凭据——API key 由模型适配器自己解析。
182
+ - **插件绝不修改 git config**;缺 author 时给出明确错误而不是悄悄补写。
183
+ - **差异查看是纯只读的**:`diff` 端点只跑 `git diff` / `git ls-files`,不写
184
+ index、不动工作区、不改配置;它**不依赖**宿主右侧 Sidebar 那套标签页 API
185
+ (那部分还在快速迭代),而是面板内自带两栏——左侧工作台照旧,右侧差异栏。
186
+ 差异渲染也是自己写的(本 bundle 只依赖 `react`):解析统一 diff、双行号、
187
+ `+`/`-` 配色,不引任何语法高亮依赖。
188
+ - **重命名必须同时传旧名与新名**:git 只在旧名也在 pathspec 里时才配对,只给
189
+ 新名会把一次重命名报成整文件新增(`test/diff.mjs` 守护这一点)。
190
+ - **status 的 porcelain-v2 解析**:`2`(rename/copy)记录的路径在第 10 个字段、
191
+ 与旧名以 TAB 分隔,`u`(冲突)记录的路径在第 11 个字段且状态恒为冲突——这
192
+ 两处曾按 `slice(8)` / `slice(9)` 取值而错位,现在由 `changeEntry()` 统一构造。
193
+ - **插件不导入任何 `@deepseek-ai/*` 运行时包**(只用 `node:` 内置模块和
194
+ `@deepseek-ai/cordis`)。以 pnpm `link:` 方式安装时,宿主包无法从插件的真实
195
+ 源码路径解析,声明这类导入会让插件在加载期就崩溃;生成所需的请求构造与流
196
+ 式拼装因此就近实现。`test/generate.mjs` 直接测这些单元。
197
+ - 面板操作是普通 UI 行为(和 Cordis 面板一样),不会写入会话日志 /
198
+ 模型提示词。AI 生成只填输入框,不会自动提交。
199
+
200
+ ## 开发说明
201
+
202
+ - 浏览器 bundle 为手写(无构建步骤);改 `lib/client.js` 刷新即生效
203
+ (no-cache),改宿主半需要重启 `dsh web`。
204
+ - 测试:
205
+ - `node test/smoke.mjs` —— 路由/信封/端点分发/入参校验(不 spawn git:会话沙箱
206
+ 拦截子进程管道 stdio);
207
+ - `node test/host-mount.mjs` —— 在真实 Cordis + 真实 `dsh-client-connection`
208
+ 上挂载插件行(从 `DSH_HOME` 的 profile 解析 DSH 包,找不到则 SKIP);
209
+ - `node test/generate.mjs` —— AI 生成单元测试:路由解析、prompt 组装、截断、
210
+ 流式拼装(block-end 与纯 delta 两条路径)、终止失败/取消/空输出;
211
+ - `node test/render.mjs` —— 双界面真实 React SSR 渲染,含提交区三个控件、
212
+ diff 解析器(行号 / 分类 / `--` 开头的删除行)、行渲染、差异面板标题栏与
213
+ `act()` 的结果回传/本地化(需要一份 react/react-dom,可用
214
+ `DSH_GIT_REACT_ROOT` 指定,找不到则 SKIP)。
215
+ - 也提供 `npm test`(依次跑四个)。
216
+ - `npm run test:commit` —— **端到端**:在临时仓库里真起 git,走插件的
217
+ `/dsh-git-rpc/commit` 路由提交,再用 `git log --format=%B` 逐字节比对提交
218
+ 信息(多行、CRLF、中文、前导 `-`、shell 元字符等),并确认非法信息被拒且
219
+ 不产生提交。
220
+ - `npm run test:diff` —— **端到端**:临时仓库里逐个验证 `diff` 端点:工作区
221
+ / 已暂存两侧、未跟踪文件与**未跟踪目录**、**重命名配对**、删除、二进制、
222
+ 40 万字符截断、以及路径校验与非仓库目录。
223
+ - 后两个都必须 spawn git 的管道 stdio,故**不在 `npm test` 内**——请在没有
224
+ 该限制的环境(普通终端)单独运行。
225
+ git 命令集对照运行中的服务端做端到端验证。
@@ -0,0 +1,15 @@
1
+ # @xia-sc/dsh-git — browser-surface bundle layer.
2
+ #
3
+ # Applied after the dsh-web-app layer, so the `connection` service (the host
4
+ # half of the web transport) is already available. The host half of this
5
+ # package (lib/index.js) mounts the /dsh-git-rpc RPC channel that the browser
6
+ # half (lib/client.js, a dsh.client row) calls for the full Git workflow:
7
+ # status, branches, checkout, createBranch, fetch, pull, stage, commit, push,
8
+ # log, and AI commit-message generation.
9
+ #
10
+ # No `inject` key here on purpose: the module's own exported `inject`
11
+ # (webServer + connection + llm) is the declaration Cordis reads, and repeating
12
+ # it in the row would only create a second place to keep in sync.
13
+ - insert:
14
+ - id: dsh-git
15
+ name: '@xia-sc/dsh-git'