dsh-gitbar-lite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ # dsh-gitbar-lite — implementation contract
2
+
3
+ This document is the part of the design that is *not* visible from the outside:
4
+ the wire between the two halves, where state lives, the invariants the code is
5
+ built around, and — just as importantly — what has **not** been verified.
6
+
7
+ It is written for whoever changes this plugin next, including a future me who has
8
+ forgotten why the discard order is what it is.
9
+
10
+ ---
11
+
12
+ ## 1. What this is, and the shape of the split
13
+
14
+ A GitHub Desktop-shaped Git panel that lives in DSH's built-in right sidebar.
15
+ Three tab types, all session-scoped because that is what the right column is:
16
+
17
+ | Tab | Kind | Address | Opened by |
18
+ |---|---|---|---|
19
+ | The panel | `gitbar` | page (`sidebar://gitbar`) | the guide card in the right column |
20
+ | The trash | `gitbar-trash` | page (`sidebar://gitbar-trash`) | a button in the panel |
21
+ | A diff | `gitbar-diff` | **resource** (`dsh-resource://dsh-gitbar-lite/diff/current`) | double-clicking a file, or a trash entry |
22
+
23
+ The split between the halves is total: the browser half never touches the disk,
24
+ never shells out, and never sees a path it did not get from the host.
25
+
26
+ ```
27
+ right sidebar tab (browser) host process
28
+ ────────────────────────── ─────────────
29
+ GitPanel / TrashBody / DiffBody
30
+ │
31
+ │ POST /api/dsh-gitbar-lite/rpc
32
+ │ { action, ...payload } connection.fetch.register
33
+ ▼ → admit() (fence + cookie)
34
+ createRoute.fetch ─────────────────────► → createHandlers[action]
35
+ │
36
+ ├─ git.js ctx.subprocess
37
+ ├─ repo.js the operations
38
+ └─ store.js <DSH_HOME>/dsh-gitbar-lite
39
+ ```
40
+
41
+ **Why one route and not one action per route.** `connection.fetch` matches on the
42
+ exact pathname and method, and every call already carries a `sessionId` plus the
43
+ few fields the action needs. One `POST` with an `action` field keeps the wire
44
+ contract in one place — mirrored verbatim in the browser half — instead of
45
+ spreading fifteen path literals across two languages.
46
+
47
+ **Why it must be `connection.fetch` and never `webServer.register`.** The web
48
+ server resolves `kind: 'exact'` routes *before* its `/api` prefix route, and
49
+ everything that makes an `/api` request safe hangs off that prefix route:
50
+ `connection.admit(req)` runs the Host/Origin fence and the browser-cookie check
51
+ before `bridge()` dispatches. An exact route on the same path shadows all of it,
52
+ turning this into an unauthenticated git surface reachable by anything that can
53
+ guess the path. `test/index.test.mjs` and `test/check.mjs` both assert the
54
+ carrier, and the check script proves its own assertion is sensitive by running
55
+ the predicate against a deliberately broken copy of the source.
56
+
57
+ ---
58
+
59
+ ## 2. The wire contract
60
+
61
+ `POST /api/dsh-gitbar-lite/rpc`, body `{ action, ...payload }`, `requestBody:
62
+ 'buffered'`.
63
+
64
+ Every response is JSON with `cache-control: no-store`. Success is
65
+ `{ ok: true, ...data }`. Failure is `{ ok: false, error: { code, message } }`
66
+ **and** a meaningful HTTP status — both, on purpose, because the browser half
67
+ reads the body and a non-2xx status must not be the only signal. The `message`
68
+ the host sends is already user-facing (Chinese), and the browser half localises
69
+ `code` when it recognises it, falling back to that message.
70
+
71
+ ### Actions
72
+
73
+ | action | payload | returns |
74
+ |---|---|---|
75
+ | `status` | `sessionId` | the whole panel state (see below), or the picker's candidate list when nothing is bound |
76
+ | `bind` | `sessionId`, `repoRoot: string \| null` | `{ repoRoot, bound }` or `{ cleared: true }` |
77
+ | `repos` | `sessionId` | `{ candidates, scanned, scanTruncated, repoRoot, source }` |
78
+ | `stage` | `sessionId`, `paths: string[]`, `staged: boolean` | fresh status |
79
+ | `commit` | `sessionId`, `summary`, `description?` | `{ commit, ...status }` |
80
+ | `commitAndPush` | same | `{ commit, push: { ok, ... } , ...status }` |
81
+ | `push` | `sessionId` | `{ action: 'push', outcome, ...status }` |
82
+ | `fetch` | `sessionId` | `{ action: 'fetch', outcome, ...status }` |
83
+ | `discard` | `sessionId`, `paths: string[] \| null`, `hardDelete` | `{ discard, ...status }` |
84
+ | `fileDiff` | `sessionId`, `path` | `{ diff }` |
85
+ | `trashList` | `sessionId`, `all?` | `{ entries, total, showAll, repoRoot }` |
86
+ | `trashDiff` | `id` | `{ entry, text }` |
87
+ | `trashDelete` | `id` | `{ removed }` |
88
+ | `trashClear` | `sessionId`, `all?` | `{ removed }` |
89
+ | `prefs` | `sessionId`, `trashHardDelete?`, `trashShowAllRepos?` | `{ prefs }` |
90
+
91
+ `paths: null` on `discard` means *every changed file*; an explicit array means
92
+ exactly those paths. An array that matches nothing is an `E_BAD_REQUEST`, and a
93
+ clean working tree is refused too, so "discard nothing" never writes an empty
94
+ archive.
95
+
96
+ ### The status payload
97
+
98
+ ```
99
+ {
100
+ workspacePath, workspaceTitle, source: 'binding' | 'detected' | 'none',
101
+ repoRoot, repoName, head: { oid, short, subject },
102
+ branch: { oid, head, upstream, ahead, behind, detached, unborn },
103
+ remotes: string[],
104
+ files: [{ path, origPath?, index, worktree, kind, untracked, conflicted,
105
+ staged, partial, added, deleted, binary, synthesized? }],
106
+ counts: { files, staged, partial, conflicted, additions, deletions },
107
+ primary: 'push' | 'publish' | 'fetch',
108
+ prefs: { trashHardDelete, trashShowAllRepos }
109
+ }
110
+ ```
111
+
112
+ `head` is the commit a discarded diff would be taken *against*, which is why the
113
+ trash can say "against `a1b2c3d` *init*" and mean it.
114
+
115
+ ### Failure codes
116
+
117
+ `E_BAD_REQUEST` · `E_UNKNOWN_ACTION` · `E_NO_WORKSPACE` · `E_NO_REPO` ·
118
+ `E_NO_GIT` · `E_EMPTY_SUMMARY` · `E_NOTHING_STAGED` · `E_CONFLICT` ·
119
+ `E_GIT_FAILED` · `E_NO_REMOTE` · `E_NO_UPSTREAM` · `E_AUTH` · `E_PUSH_REJECTED` ·
120
+ `E_TRASH_NOT_FOUND` · `E_INTERNAL`.
121
+
122
+ `E_AUTH` and `E_PUSH_REJECTED` exist so the panel can say the one useful
123
+ sentence for each instead of pasting git's stderr. Both are derived by pattern
124
+ matching git's output in `classifyGitFailure`, which reads **stdout as well as
125
+ stderr** — `git commit` with nothing staged exits 1 with an empty stderr and
126
+ "nothing to commit" on stdout, and a stderr-only matcher reports that as an
127
+ unexplained internal failure.
128
+
129
+ ---
130
+
131
+ ## 3. Where state lives
132
+
133
+ `<DSH_HOME>/dsh-gitbar-lite/`, falling back to `~/.dsh`. **Do not read
134
+ `$DSH_HOME` alone**: the Host process usually does not have it set (the variable
135
+ belongs to the shell DSH spawns), so a state file written under a bare
136
+ `process.env.DSH_HOME` lands somewhere else or fails to resolve at all. This is
137
+ the same trap `dsh-tailscale-remote` documents.
138
+
139
+ ```
140
+ bindings.json { version, bindings: { <canonical workspace path>: { repoRoot, workspacePath, updatedAt } } }
141
+ prefs.json { version, trashHardDelete, trashShowAllRepos }
142
+ trash/index.json { version, entries: [ ... ] }
143
+ trash/<id>.diff the archived diff, one file per discard operation
144
+ ```
145
+
146
+ Every write goes through a temp file plus a rename, so a crash mid-write cannot
147
+ truncate a file, and every read-modify-write is serialized per path — two tabs
148
+ issuing overlapping mutations is the normal case here, not an edge case.
149
+
150
+ **Why not the settings service or a storage domain.** A binding is keyed by an
151
+ arbitrary absolute workspace path, and a `storageDomain` record key is restricted
152
+ to `^[a-zA-Z0-9_-]+$` because it becomes a path segment; `C:\Users\…\<a folder
153
+ with spaces>` cannot be one without hashing it, at which point the "native"
154
+ option is doing less than the twelve lines here. A settings `Config` would put a
155
+ growing map of machine paths into the user-visible settings form, which is worse.
156
+
157
+ **The binding key** is canonical, separator-normalized, and case-folded on
158
+ Windows (`workspaceKey`), so `C:\Foo\`, `C:\Foo` and `c:\foo` are one record. The
159
+ original spelling is stored alongside for display.
160
+
161
+ **A stale binding is dropped, not reported.** If the bound directory is gone, or
162
+ is no longer a repository, `resolveBinding` clears the record and re-detects. A
163
+ broken binding the user cannot clear from the UI is worse than re-deciding.
164
+
165
+ ---
166
+
167
+ ## 4. The invariant: a tick *is* the index
168
+
169
+ This is the claim that makes the plugin safe next to a terminal, and the claim a
170
+ pathspec-limited commit would quietly break.
171
+
172
+ - Ticking a file runs `git add -A -- :(literal)<path>`.
173
+ - Unticking runs `git restore --staged -- :(literal)<path>`.
174
+ - A file reads as ticked when **all** of its change is in the index
175
+ (`index !== '.'`), and as *indeterminate* when the index and the worktree are
176
+ both dirty (`MM`) — one tick cannot represent half a file, so the box shows a
177
+ dash and either click restates the file as a whole.
178
+ - Commit runs a plain `git commit -F -` with **no pathspec**. What the panel
179
+ shows, what `git status` shows, and what gets committed cannot drift apart.
180
+ - Untracked and `.gitignore`d files are never ticked: they are not in the index
181
+ and cannot be.
182
+
183
+ `:(literal)` matters: without it, discarding a file named `docs` would also reach
184
+ `docs/anything`, and a file named `a[1].txt` would be treated as a pattern.
185
+
186
+ The two user-visible consequences, stated plainly: the plugin *does* mutate your
187
+ index (that is the point — GitHub Desktop does the same), and a file you staged
188
+ by hand shows up ticked, because it is.
189
+
190
+ ---
191
+
192
+ ## 5. Discarding, in order, and why
193
+
194
+ ```
195
+ 1. read the full status ── decide the target set
196
+ 2. refuse if any target is conflicted ── E_CONFLICT, one sentence
197
+ 3. build the archive ── BEFORE anything is destroyed
198
+ tracked: git diff --no-color --find-renames HEAD -- <literal paths>
199
+ (+ git diff --numstat … in the same round, for the per-file table)
200
+ untracked: synthesizeNewFileDiff per path, from the file's own bytes
201
+ 4. cap it on a line boundary ── limitDiff, records droppedLines
202
+ 5. write the trash entry + blob ── unless hardDelete
203
+ 6. restore
204
+ tracked: git restore --source=HEAD --staged --worktree -- <literal paths>
205
+ untracked: delete the file, then prune directories the discard emptied
206
+ ```
207
+
208
+ Steps 3 and 6 are not interchangeable. `git restore` destroys the original, so
209
+ the archive has to exist first; that is the whole reason the plugin has a trash.
210
+
211
+ **Why tracked and untracked are restored by different means.** A mixed
212
+ pathspec — one untracked path alongside tracked ones — makes the *entire*
213
+ `git restore` fail with `pathspec … did not match any file(s) known to git` and
214
+ restores nothing (verified on git 2.54). Splitting them is not tidiness; it is
215
+ correctness.
216
+
217
+ **Why untracked paths are deleted rather than restored.** They are not in HEAD,
218
+ so `git restore --source=HEAD` cannot reach them. Their content is captured in
219
+ step 3 or lost, which is exactly why the synthesized diff exists.
220
+
221
+ **One entry per discard operation, not one per file.** Discarding a single file
222
+ archives one blob with one file in it; "Discard all" archives **one blob holding
223
+ every file's diff**, with `scope: 'all'`. That is what was asked for, and it also
224
+ means a whole-tree discard is one write rather than N.
225
+
226
+ **Conflicts are refused, not resolved.** Discarding a conflicted path would mean
227
+ `git restore` against HEAD in the middle of a merge, which can disturb the merge
228
+ state. The panel says one sentence instead, and `test/e2e.mjs` S12 proves both
229
+ the single-file and the discard-all paths refuse.
230
+
231
+ **`hardDelete` is a per-operation choice, remembered.** The confirmation box's
232
+ "delete for good" starts unticked for a first-time user (the safe answer keeps a
233
+ copy) and is persisted after each discard, so it mirrors the user's last
234
+ decision rather than latching.
235
+
236
+ ---
237
+
238
+ ## 6. The trash
239
+
240
+ An entry records, so that a diff can still be read months later:
241
+
242
+ ```
243
+ { id, at, repoRoot, repoName, workspacePath, sessionId,
244
+ scope: 'file' | 'all',
245
+ branch: { head, detached },
246
+ base: { oid, short, subject } | null, ← the commit the diff is against
247
+ files: [{ path, origPath?, kind, added, deleted, binary, synthesized?, oversized? }],
248
+ bytes, truncated, droppedLines, blob }
249
+ ```
250
+
251
+ `base` is HEAD at the moment of the discard. That is the answer to "which version
252
+ is this a diff against" — and it is recorded rather than inferred, because HEAD
253
+ moves.
254
+
255
+ The list is filtered to the currently bound repository by default; `total` still
256
+ reports the whole index, and a toggle shows everything. Clearing with the filter
257
+ on clears only the visible repository's entries.
258
+
259
+ Reading an entry whose `.diff` was deleted by hand still lists it: the metadata
260
+ is worth keeping even when the blob is gone, and the read reports the blob as
261
+ missing rather than pretending the entry vanished.
262
+
263
+ ---
264
+
265
+ ## 7. The right-sidebar tab identities
266
+
267
+ ```
268
+ id: dsh-gitbar-lite/git kind: gitbar page keepMounted, one guide card (order 30)
269
+ id: dsh-gitbar-lite/trash kind: gitbar-trash page
270
+ id: dsh-gitbar-lite/diff kind: gitbar-diff resource, patterns: dsh-resource://dsh-gitbar-lite/diff/**
271
+ ```
272
+
273
+ `definition.id` is **also the slot key** the body and title register under. Two
274
+ consequences the code leans on: a rename in one place without the other silently
275
+ renders nothing, and `test/client.test.mjs` asserts every slot key is one of the
276
+ three ids.
277
+
278
+ **Why the diff tab is a resource with a fixed address.** Re-opening a resource
279
+ address reveals the tab that is already open — the host looks the content id up
280
+ across the whole layout before planning a new tab — and the `params` payload
281
+ re-navigates that tab (each open bumps `navigation.revision`). A page kind would
282
+ reuse only within the active dock pane, so a tab the user had floated would get a
283
+ second copy. The fixed address *is* the "one diff tab, ever" requirement, and the
284
+ diff's own chip is a title seat because it has to read the live
285
+ `navigation.params` to name the file currently on screen — a title captured at
286
+ open time never changes.
287
+
288
+ **Why `keepMounted` on the panel and not the trash.** The panel holds the tick
289
+ state that mirrors the index and a half-written Summary; peeking at a diff must
290
+ not lose either. The trash holds only what it re-reads on every visit, so
291
+ retaining it would buy nothing.
292
+
293
+ **The panel re-reads on `tab.visible`.** Coming back to the panel refreshes
294
+ quietly, because the working tree may have changed under it — an agent in the
295
+ same session is a normal author of those changes.
296
+
297
+ ---
298
+
299
+ ## 8. Configuration
300
+
301
+ Every field is volatile, which is what lets the settings page edit them without a
302
+ restart — and is why `cordis.patch.yml` deliberately carries **no** `config`
303
+ block: values written into the profile's own patch layer must not be shadowed by
304
+ this package on the next upgrade.
305
+
306
+ | field | default | why |
307
+ |---|---|---|
308
+ | `gitPath` | `''` | Empty means "whatever is on PATH". Set it when git is somewhere unusual. |
309
+ | `stateDir` | `''` | Empty means `<DSH_HOME>/dsh-gitbar-lite`. |
310
+ | `timeoutMs` | `30000` | Per-command. A push to a slow remote may need more. |
311
+ | `maxDiffBytes` | `524288` | Ceiling on one archived diff. A discarding user is having a bad day already; a 200 MB generated file must not become a 200 MB write that then fails. |
312
+ | `maxFileBytes` | `2097152` | Larger untracked files are listed but their content is not read, archived, or diffed. |
313
+ | `trashLimit` | `200` | Entries kept; the oldest are dropped and their blobs deleted. |
314
+ | `scanDepth` | `3` | Directory depth the picker scans below the workspace root. |
315
+
316
+ ---
317
+
318
+ ## 9. Verified, and not
319
+
320
+ ### Verified
321
+
322
+ | what | how |
323
+ |---|---|
324
+ | porcelain v2 parsing, including a rename, a staged delete, a conflict, a non-UTF-8-hostile path, and a path containing a space | `test/parse.test.mjs`, 47 cases; the conflict fixture pins the nine-field `u` record |
325
+ | diff assembly, truncation on line boundaries, per-file splitting | `test/diff.test.mjs` |
326
+ | state files, the binding key, corruption tolerance, write serialization | `test/store.test.mjs` |
327
+ | the route carrier, single registration under a hostile service order, error classification | `test/index.test.mjs` |
328
+ | every registration identity, the wire literals, the dictionaries, and the hook-free components' rendered trees | `test/client.test.mjs` |
329
+ | the whole host stack against **real git**, mutation by mutation | `test/e2e.mjs`, 230 assertions over 19 sections that build their own fixtures |
330
+ | the manifest, the patch layer, the tarball's contents, the two copies of the wire contract, the dictionaries, the workflow | `test/check.mjs` |
331
+ | that the client bundle would not abort the Host boot | `scripts/check-client-bundle.mjs` |
332
+
333
+ The e2e suite is deliberately hostile about §4: after ticking one file it reads
334
+ `git status --porcelain` back and asserts git agrees, which is the only way to
335
+ show the index claim rather than assume it.
336
+
337
+ ### Not verified
338
+
339
+ - **`ctx.subprocess` is doubled in the tests.** The shipped service lives inside
340
+ DSH's own bundle and is not importable from a plugin repository, so
341
+ `test/helpers/subprocess.mjs` reproduces its documented shapes (the spawn spec,
342
+ `collected.stdout.readFrom`, `done`) on top of `node:child_process`. It does
343
+ **not** reproduce the Windows Job containment or the environment scrubbing —
344
+ those belong to the real service. The first live run in a real DSH is what
345
+ closes that gap; see `test/live.mjs`.
346
+ - **No headless-DOM render test.** The panel, the trash and the diff are driven
347
+ by hooks, and the three components with no hooks in them (the row, the
348
+ confirmation, the picker) *are* asserted on their element trees. What is not
349
+ covered is real layout: whether the panel actually fills the column, whether a
350
+ diff row's background is the token it claims. A browser harness with a
351
+ hand-written React shim would be needed, and the two bugs that motivate one in
352
+ other plugins in this family were both DOM-measurement bugs — this plugin
353
+ measures nothing and augments no host DOM, so the harness was judged not worth
354
+ its fixture-fidelity cost. That judgement is worth revisiting if the UI grows
355
+ anything hover-dependent or portalled.
356
+ - **What a 401 from `/api` does and does not prove.** `connection.admit()` — the
357
+ Host/Origin fence and the browser-cookie check — runs on the `/api` prefix
358
+ route *before* dispatch, so an unauthenticated request to a path nobody serves
359
+ answers 401 as well. `test/live.mjs` therefore reports 401 as *inconclusive*
360
+ rather than as "mounted"; only a 200 (or, when authenticated, a 404) is
361
+ evidence about whether this plugin is actually registered.
362
+ - **`openTabIn` / `openResourceIn` are outside the published type contract.**
363
+ They are prototype methods on the sidebar-right controller and are called by
364
+ shipped plugins (including `dsh-deepseek-webchat`), but three source comments
365
+ label them "not part of `ISidebarRight`". This is the first thing to re-check
366
+ on a DSH upgrade.
367
+ - **`@deepseek-ai/dsh-client-ui-dockkit` was not available to read**, so the tab
368
+ strip's height and the pane's padding could not be confirmed. The styles
369
+ therefore add no top padding and claim only `height: 100%; min-height: 0`, so
370
+ they fill whatever the kit provides instead of guessing.
371
+ - **Git's path encoding on this machine.** `core.quotepath=false` plus `-z`
372
+ produced UTF-8 paths in every fixture that was checked (including a non-ASCII
373
+ name), and the parsers split on NUL rather than on quoting. A repository whose
374
+ paths git emits in another encoding is untested.
375
+
376
+ ---
377
+
378
+ ## 10. Known limitations
379
+
380
+ - One repository per workspace, one workspace per binding. No submodules, no
381
+ worktrees, no multiple remotes beyond "prefer `origin`, else the first".
382
+ - Push and Fetch always act on a single remote. There is no branch switching, no
383
+ history list, and no stash — that was the agreed scope.
384
+ - `git fetch --prune` is what Fetch does, matching GitHub Desktop. It deletes
385
+ remote-tracking refs whose branch the remote no longer has.
386
+ - Commit hooks run. A hook that blocks or hangs is the user's own hook; the child
387
+ is killed at `graceMs` only if the command times out, and `timeoutMs` is the
388
+ knob for that.
389
+ - The trash keeps the diffs, not the files. Recovery means applying the archived
390
+ diff by hand; the plugin does not offer a restore button, by agreement.
391
+ - Binary content is not archived — the diff says `Binary files … differ`, which
392
+ is honest but is not a backup.
393
+ - An untracked file over `maxFileBytes` is deleted with only a stub in the
394
+ archive. The confirmation box does not yet warn about that specifically.
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg width="24" height="24" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Git bar">
2
+ <title>Git bar</title>
3
+ <!--
4
+ The branch mark the panel itself draws, at the plugin-list size.
5
+ Same 16-grid geometry as the host's IconBranchOutline artwork, so the icon in
6
+ the plugin list and the icon on the tab chip are recognisably one mark;
7
+ stroke-width is raised because this one is rendered at 24px rather than 16.
8
+ -->
9
+ <g stroke="#4F6BED" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round">
10
+ <path d="M1.01503 8.0001L5.6964 8.0001C6.41913 8.0001 6.78049 8.0001 7.12115 7.91951C7.4232 7.84804 7.71233 7.73014 7.97821 7.57C8.27809 7.38939 8.5364 7.13669 9.05303 6.63129L11.3281 4.40564" />
11
+ <path d="M1.01221 7.9999L5.6964 7.9999C6.41913 7.9999 6.78049 7.9999 7.12115 8.08049C7.4232 8.15196 7.71233 8.26986 7.97821 8.43C8.27809 8.61061 8.5364 8.86331 9.05303 9.36871L11.3281 11.5944" />
12
+ <circle cx="12.4502" cy="3.3079" r="1.56962" />
13
+ <circle cx="12.4502" cy="12.6921" r="1.56962" />
14
+ </g>
15
+ </svg>