@xia-sc/dsh-git 0.5.1 → 0.5.3

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
@@ -1,284 +1,308 @@
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.
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). **Operation feedback is pinned to the very
25
+ top of the panel and stays one line tall** the busy label and the last
26
+ operation's result are the first rows of the body. The result is a localized
27
+ phrase ("Pushed", "Staged everything", "Switched to x"); git's own output (a
28
+ push sideband banner, the `LF will be replaced by CRLF` advice) sits behind a
29
+ "Details " toggle on the right, so the change list and the log can never push
30
+ the notification out of sight. The panel is
31
+ **draggable by its header bar**
32
+ (the top row with the Git title — press, drag, release; it stays where
33
+ dropped and is clamped inside the viewport; header buttons/inputs never
34
+ start a drag; double-click the header to snap back to center).
35
+ A "+ New branch" button beside the branch switcher opens an inline form:
36
+ new branch name + base-branch picker (local branches or full remote refs
37
+ like `origin/feature/x`) confirming creates the branch from the base and
38
+ switches to it.
39
+ - **Click a change to see its diff**: clicking any row of the change list grows
40
+ the panel from its 400px single column into two panes the full workbench on
41
+ the left, that file's unified diff on the right (see *The diff viewer* below).
42
+ Clicking the same row again, or the diff header's `×`, folds it back.
43
+ - **Composer dock pill** (`conversation.input.dock`): a compact, left-aligned
44
+ status pill at the textarea's top-left (branch summary or "not a git
45
+ repository"); clicking it toggles the floating panel.
46
+ - Both seats share one store, so they always agree, and both re-bind when the
47
+ current session (and its cwd) changes. Rebinding clears the selected file
48
+ the panel never shows another repository's content.
49
+ - **ANSI colour codes are stripped from git's text output**: remotes colour
50
+ their own banner (gitee's `Powered by GITEE.COM` does), and the panel is a DOM,
51
+ not a terminal the ESC byte has no glyph, so only the parameters survived
52
+ and read as `[0[01;33m`. Only `message` text is stripped; **diff bodies and
53
+ paths stay byte-faithful**.
54
+
55
+ ### The diff viewer
56
+
57
+ Clicking a row of the change list shows that file's git diff on the right. The
58
+ list is the viewer's navigation: the highlighted row is the file on screen, and
59
+ the first selection expands the list to its full length.
60
+
61
+ - **Resizable**: the handle is the panel's own right edge dragging it widens the
62
+ panel (the workbench keeps its width) and the diff takes the rest, 1:1 with the
63
+ pointer because the panel anchors its left edge on grab. Double-clicking the
64
+ edge resets the width, and the panel always stays inside the viewport.
65
+ - **Staged / unstaged**: by default the pane follows the data unstaged when
66
+ that side has anything, staged otherwise with two chips to switch by hand
67
+ (an empty side is dimmed). When a file has changes on both sides, one click
68
+ shows the side you care about and the other is one chip away.
69
+ - **Self-refreshing**: staging, committing, switching branches, or refreshing
70
+ re-reads the open diff; when the unstaged side empties, the pane falls back to
71
+ the staged side on its own.
72
+ - **Line numbers and colour**: per-side gutters taken from each hunk header,
73
+ `+`/`-` rows in the theme's success/error colours, hunk headers on their own
74
+ row, `` dimmed.
75
+ - **Every edge has its own notice**: nothing on this side / a binary file has no
76
+ text diff / the diff was too large and only its beginning is shown / N more
77
+ untracked files were not expanded / the read failed. A diff past 1500 rows
78
+ renders its first 1500 with a button to expand the rest (so a huge patch never
79
+ becomes tens of thousands of nodes).
80
+ - **Untracked files** are diffed against the empty blob and read as a
81
+ `new file mode` addition; an untracked **directory** is expanded file by file
82
+ (capped at 50, the rest counted in that notice).
83
+ - **Renames** hand git both the old and the new path as the pathspec: naming only
84
+ the new one leaves git unable to pair them and turns a rename into a whole-file
85
+ addition.
86
+
87
+ ### The commit area and AI drafting
88
+
89
+ The commit area follows the real order of operations: **stage all → draft → commit**.
90
+
91
+ - **Stage all**: `git add --all` (deletions and untracked files included).
92
+ Disabled while the tree is clean. This is the answer to "only unstaged
93
+ changes exist, so Commit fails" committing itself still never stages
94
+ implicitly.
95
+ - **Draft basis**: one of `Staged` (default), `Unstaged`, `Everything` which
96
+ part of the change set is handed to the model. `Staged` is the default
97
+ because **it is the only basis whose content the commit actually records**;
98
+ a message drafted from anything else may describe changes that will not be
99
+ committed.
100
+ - **✨ AI draft**: sends the selected change set (diffstat + diff, truncated)
101
+ to the model the current session has selected and drops the generated
102
+ message into the input. Edit it, or just write your own.
103
+
104
+ The model route comes from the current session's `modelSelection` projection
105
+ (pending pick first, then last used), falling back to the host's first
106
+ registered route. Failures are reported in the last-operation output with a
107
+ localized sentence (no changes / no model configured / draft failed, …).
108
+
109
+ ## Architecture
110
+
111
+ One dual-face npm package:
112
+
113
+ | Half | File | Role |
114
+ | --- | --- | --- |
115
+ | 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. |
116
+ | 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`). |
117
+
118
+ ### Why the route is self-owned (dsh >= 0.1.5-rc.1)
119
+
120
+ Since dsh 0.1.5-rc.1 an outside plugin can no longer call
121
+ `ctx.connection.rpc.handle()`: `HostConnectionService.rpc` closes over the
122
+ connection plugin's **own** Context (`inject` is only `["credentials"]`) and
123
+ registers through it (`owner.effect(() => owner.webServer.register(route))`),
124
+ while that plugin only resolves `webServer` inside an inner
125
+ `ctx.inject(["webServer"], …)` scope. Whatever the caller injects, the row
126
+ therefore failed to mount with `cannot get property "webServer" without inject`
127
+ which is exactly what 0.3.0 did. This build registers `/dsh-git-rpc` itself
128
+ and implements the same RPC envelope; the request fence still comes from the
129
+ connection service's `requestRejection`, so the channel is exactly as trusted
130
+ as `/api`. `test/host-mount.mjs` guards this against a real Cordis host and the
131
+ real Connection service.
132
+
133
+ ### The session binding belongs to the session-scoped seat (dsh >= 0.1.6-alpha.2)
134
+
135
+ Both faces used to read `current` (the current session id) out of the sessions
136
+ list snapshot. 0.1.6-alpha.2 dropped that field — the snapshot is now `ids` /
137
+ `byId` / `phase` / `subagentsByParent` / `jobsBySession`, and the current
138
+ session reaches **session-scoped** seats through the renderer's scope adapter
139
+ (`SlotScopeAdapter.current`, derived from `retainedBy.mainView`), which a
140
+ root-scoped `shell.overlay` entry cannot read (the symptom is the pill and the
141
+ panel silently disappearing, with no error anywhere).
142
+
143
+ Now the **pill** (`conversation.input.dock`, session-scoped: the framework hands
144
+ it `sessionId`) reads that session's `cwd` from the `useSessions` snapshot and
145
+ calls `store.bindSession(sessionId, cwd)`; the **panel** only reads the shared
146
+ store's `sessionId` / `cwd` (it also uses the identity to resolve the
147
+ `modelSelection` projection behind the AI draft route). `byId[id].retainedBy.mainView`
148
+ keeps an embedded Conversation (a subagent chat tab) from taking the workbench
149
+ over; a snapshot without that count is treated as main-view so the pill never
150
+ vanishes for want of it.
151
+
152
+ ## Install
153
+
154
+ ```sh
155
+ dsh plugin --profile web add @xia-sc/dsh-git
156
+ ```
157
+
158
+ Or install straight from the source (both channels are the same code: the npm
159
+ version is the artifact of the matching tag):
160
+
161
+ ```sh
162
+ dsh plugin --profile web add https://github.com/xia-sc/dsh-git
163
+ ```
164
+
165
+ Then **restart `dsh web`** (bundle rows and the browser roster compose at
166
+ boot). After refresh, the dock pill appears above the composer once the current
167
+ session's workspace is a git repository; click it to open the panel.
168
+
169
+ Requires **dsh >= 0.1.5-rc.1** (the host half owns its `/dsh-git-rpc` route; see
170
+ the architecture note above).
171
+
172
+ Uninstall:
173
+
174
+ ```sh
175
+ dsh plugin --profile web remove @xia-sc/dsh-git
176
+ ```
177
+
178
+ ## RPC contract (`/dsh-git-rpc`)
179
+
180
+ The browser calls `ctx.connection.rpc.call("/dsh-git-rpc", endpoint, { args })`;
181
+ the host side is this plugin's own `/dsh-git-rpc/*` prefix route, speaking the
182
+ same Connection envelope as `/api`:
183
+
184
+ - Request: `POST /dsh-git-rpc/<endpoint>`, `content-type: application/json`,
185
+ `{ type: "client-request", rpcId, method: <endpoint>, payload: { args } }`
186
+ - Response: `{ type: "server-response", rpcId, result: { ok: true, value } | { ok: false, error } }`
187
+ - Fence: `connection.requestRejection` (Host/Origin + browser session cookie);
188
+ non-`POST` → 405, non-JSON 415, oversized body 413, path outside the
189
+ channel 404.
190
+
191
+ Payloads use the `{ args }` convention. `cwd` must be an absolute path;
192
+ `branch` matches `^[A-Za-z0-9][A-Za-z0-9._/-]*$` (no leading `-`, no `..`,
193
+ `@{`, `\`, whitespace, control chars); `remote` is a plain segment. The commit
194
+ message is a real message: one subject line plus an optional multi-line body.
195
+ CRLF becomes LF, trailing whitespace is stripped per line, leading/trailing
196
+ blank lines are dropped and runs of blank lines collapse to one (so the single
197
+ blank line between subject and body is preserved); empty, over-long (>10000
198
+ characters), and control-character-carrying messages are rejected with
199
+ `invalid-message`. The message reaches git on stdin via
200
+ `git commit --cleanup=whitespace --file=-`, so spaces, quotes, line feeds,
201
+ shell metacharacters, and leading dashes are all recorded verbatim.
202
+
203
+ | Endpoint | args | Result (`value`) |
204
+ | --- | --- | --- |
205
+ | `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. |
206
+ | `branches` | `{ cwd }` | `{ repo, current, local: [{name, current, upstream, sha}], remote: [{name, short}] }` |
207
+ | `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. |
208
+ | `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. |
209
+ | `fetch` | `{ cwd, remote? }` | `{ message }` (120s timeout) |
210
+ | `pull` | `{ cwd }` | `{ message }` via `git pull --ff-only` (never implicit-merge) |
211
+ | `stage` | `{ cwd }` | `{ message }` via `git add --all` |
212
+ | `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. |
213
+ | `commit` | `{ cwd, message }` | `{ message }`; `missing-author` error when `user.name/email` unset |
214
+ | `push` | `{ cwd }` | `{ message }` (120s timeout) |
215
+ | `log` | `{ cwd, count? }` | `{ repo, commits: [{sha, author, subject, refs}] }` (clamped 1..50) |
216
+ | `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-truncated` (the output cap ran out before any text was written), `llm-empty`, `cancelled`, `llm-failed`. |
217
+
218
+ > A failed result carries `error.code === "internal"` on the wire (the Connection
219
+ > envelope only requires a string), with the plugin's own diagnostic in
220
+ > `error.details.code`; the client localizes from that code.
221
+ >
222
+ > The `message` of `fetch`/`pull`/`push`/`stage`/`commit` is **git's own output**
223
+ > (ANSI colour codes stripped): an empty string when git said nothing. The panel
224
+ > notifies with an `output.<action>` phrase and keeps that text behind its
225
+ > expandable "Details".
226
+
227
+ ## Design decisions & boundaries
228
+
229
+ - **pull is `--ff-only`**: no surprise merge commits; conflicts surface as an
230
+ error the user resolves in their own tooling.
231
+ - **commit does not stage**: it commits what is staged. To commit everything at
232
+ once, use the commit area's **Stage all** button (`git add --all`) rather than
233
+ making commit stage implicitly.
234
+ - **AI drafting sends the change set's diff to whichever model provider you
235
+ configured** possibly a third-party gateway. It only happens when you click
236
+ "✨ AI draft"; the plugin itself never calls the network. The diff is
237
+ truncated to 12000 characters, and nothing outside the repository is sent.
238
+ The output cap is 8192 tokens: a reasoning model shares that completion budget
239
+ between its thinking and the message, so a cap sized for the message alone
240
+ produces "hit the cap before writing anything" reported as `llm-truncated`
241
+ rather than a vague `llm-empty`.
242
+ - **push/pull credentials** come from the system (Git Credential Manager /
243
+ SSH agent); the plugin never touches credential storage. AI drafting never
244
+ touches credentials either the model adapter resolves its own API key.
245
+ - **The plugin never mutates git config**; missing author reports a clear
246
+ error instead.
247
+ - **The diff viewer is strictly read-only**: its endpoint runs only `git diff` /
248
+ `git ls-files` it never writes the index, the working tree, or any config —
249
+ and it deliberately does **not** depend on the host's right-Sidebar tab API
250
+ (that surface is still moving fast). The two panes live inside the panel
251
+ instead, and the diff renderer is written here as well (this bundle depends on
252
+ `react` only): unified-diff parsing, both line-number gutters, `+`/`-`
253
+ colours, and no syntax-highlighting dependency.
254
+ - **A rename needs both names in the pathspec**: git only pairs them when the old
255
+ path is named too; naming the new path alone reports a whole-file addition
256
+ (`test/diff.mjs` guards this).
257
+ - **porcelain-v2 status parsing**: a `2` (rename/copy) record carries its path in
258
+ the 10th field with the old name TAB-separated after it, and a `u` (conflict)
259
+ record carries its path in the 11th field and is always a conflict. Both were
260
+ read from `slice(8)` / `slice(9)` before — off by the score and hash columns —
261
+ and are now built by `changeEntry()`.
262
+ - **The plugin imports no `@deepseek-ai/*` runtime package** (only `node:`
263
+ builtins and `@deepseek-ai/cordis`). Under a pnpm `link:` install the host
264
+ packages do not resolve from the plugin's real source path, so such an import
265
+ would make the plugin fail at load time; the request construction and stream
266
+ assembly generation needs are therefore implemented locally and tested
267
+ directly by `test/generate.mjs`.
268
+ - Panel operations are plain UI actions (like the Cordis panel) and are not
269
+ written to the session log / model prompt. AI drafting only fills the input;
270
+ it never commits by itself.
271
+
272
+ ## Development notes
273
+
274
+ - The browser bundle is hand-written (no build step); edits to `lib/client.js`
275
+ are picked up on refresh (no-cache), host-side edits need a `dsh web`
276
+ restart.
277
+ - Tests (`npm test` runs all four):
278
+ - `node test/smoke.mjs` route, envelope, endpoint dispatch and input
279
+ validation (no git spawn: the session sandbox blocks child-process piped
280
+ stdio);
281
+ - `node test/host-mount.mjs` — mounts the row on a real Cordis host with the
282
+ real `dsh-client-connection` (resolved from the `DSH_HOME` profile; SKIPs
283
+ when no profile is installed);
284
+ - `node test/generate.mjs` the AI-draft units: route resolution, prompt
285
+ assembly, truncation, stream assembly (both the `block-end` and the
286
+ delta-only path), terminal failure / abort / empty output;
287
+ - `node test/render.mjs` — real React SSR render of both seats, including the
288
+ three commit-area controls, the diff parser (line numbers, row kinds, a
289
+ removed line starting with `--`), the row renderer, the diff pane's header,
290
+ and `act()`'s result plumbing and localization (needs a react/react-dom copy,
291
+ e.g. via `DSH_GIT_REACT_ROOT`; SKIPs without one).
292
+ - `npm run test:commit` — **end-to-end**: really spawns git in a throwaway
293
+ repository, commits through the plugin's own `/dsh-git-rpc/commit` route, and
294
+ reads the message back with `git log --format=%B` (multi-line, CRLF, non-ASCII,
295
+ leading `-`, shell metacharacters…), then confirms a rejected message creates
296
+ no commit.
297
+ - `npm run test:diff` — **end-to-end**: drives `/dsh-git-rpc/diff` in a throwaway
298
+ repository through every shape the change list can produce — both sides of the
299
+ index, an untracked file and an untracked **directory**, **rename pairing**, a
300
+ deletion, a binary blob, the 400k truncation, path validation, and a directory
301
+ outside any work tree.
302
+ - The last two need piped child-process stdio, so they are deliberately **not**
303
+ part of `npm test` — run them from an ordinary terminal.
304
+ - The git command set is verified end-to-end against the running server.
305
+
306
+ ## License
307
+
308
+ MIT — see [LICENSE](LICENSE).