@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 +284 -284
- package/README.md +225 -225
- package/cordis.patch.yml +15 -15
- package/lib/client.js +2286 -2286
- package/lib/index.js +1272 -1272
- package/package.json +1 -1
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.
|