@xia-sc/dsh-git 0.5.1 → 0.5.2

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,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.
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.