dsh-git-ui 0.0.1 → 0.0.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.md +22 -4
- package/README.zh.md +22 -4
- package/lib/client.js +34 -34
- package/lib/client.js.map +4 -4
- package/lib/host/actions.d.ts +14 -0
- package/lib/host/core.d.ts +24 -0
- package/lib/host/index.d.ts +13 -8
- package/lib/host/index.js +108 -12
- package/lib/host/index.js.map +3 -3
- package/lib/host/types.d.ts +44 -0
- package/package.json +1 -1
- package/src/client/GitCenter.tsx +261 -0
- package/src/client/GitPill.tsx +29 -5
- package/src/client/controller.ts +48 -1
- package/src/client/index.ts +2 -1
- package/src/client/locales.ts +38 -0
- package/src/client/remote.ts +58 -0
- package/src/client/styles.ts +153 -0
- package/src/host/actions.ts +127 -0
- package/src/host/core.ts +31 -12
- package/src/host/index.ts +34 -17
- package/src/host/types.ts +39 -0
package/src/client/styles.ts
CHANGED
|
@@ -215,6 +215,9 @@ const globalCss = [
|
|
|
215
215
|
'.dsh-git-ui__row:hover { background: var(--dsw-alias-interactive-bg-hover); }',
|
|
216
216
|
'.dsh-git-ui__refresh:hover { color: var(--dsw-alias-state-business-primary); }',
|
|
217
217
|
'.dsh-git-ui__refresh:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }',
|
|
218
|
+
// Git center dialog: widen the platform Modal card (headless mode).
|
|
219
|
+
'.dsh-git-ui__center { width: min(880px, 100vw); max-height: min(720px, calc(100vh - 48px)); }',
|
|
220
|
+
'.dsh-git-ui__commit-input:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: -2px; }',
|
|
218
221
|
].join('\n')
|
|
219
222
|
|
|
220
223
|
/** Ensure the global interaction styles exist (idempotent; browser only). */
|
|
@@ -227,3 +230,153 @@ export function ensureGlobalCss(): void {
|
|
|
227
230
|
tag.textContent = globalCss
|
|
228
231
|
document.head.appendChild(tag)
|
|
229
232
|
}
|
|
233
|
+
|
|
234
|
+
// ── Git center (management panel) styles ──────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export const centerShell: CSSProperties = {
|
|
237
|
+
display: 'flex',
|
|
238
|
+
flexDirection: 'column',
|
|
239
|
+
height: '100%',
|
|
240
|
+
maxHeight: 'min(720px, calc(100vh - 48px))',
|
|
241
|
+
minHeight: 420,
|
|
242
|
+
fontSize: 13,
|
|
243
|
+
lineHeight: '20px',
|
|
244
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const centerHeader: CSSProperties = {
|
|
248
|
+
display: 'flex',
|
|
249
|
+
alignItems: 'center',
|
|
250
|
+
gap: 10,
|
|
251
|
+
padding: '18px 20px 12px',
|
|
252
|
+
borderBottom: '1px solid var(--dsw-alias-border-l1)',
|
|
253
|
+
flex: 'none',
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const centerTitle: CSSProperties = {
|
|
257
|
+
margin: 0,
|
|
258
|
+
fontSize: 16,
|
|
259
|
+
lineHeight: '24px',
|
|
260
|
+
fontWeight: 500,
|
|
261
|
+
flex: 1,
|
|
262
|
+
overflow: 'hidden',
|
|
263
|
+
textOverflow: 'ellipsis',
|
|
264
|
+
whiteSpace: 'nowrap',
|
|
265
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const centerBody: CSSProperties = {
|
|
269
|
+
flex: 1,
|
|
270
|
+
overflowY: 'auto',
|
|
271
|
+
padding: '10px 20px',
|
|
272
|
+
display: 'flex',
|
|
273
|
+
flexDirection: 'column',
|
|
274
|
+
gap: 10,
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const groupTitle: CSSProperties = {
|
|
278
|
+
margin: '8px 0 4px',
|
|
279
|
+
fontSize: 11,
|
|
280
|
+
fontWeight: 600,
|
|
281
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
282
|
+
textTransform: 'uppercase',
|
|
283
|
+
letterSpacing: 0.4,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export const centerRow: CSSProperties = {
|
|
287
|
+
display: 'flex',
|
|
288
|
+
alignItems: 'center',
|
|
289
|
+
gap: 8,
|
|
290
|
+
padding: '2px 8px',
|
|
291
|
+
borderRadius: 8,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const changeCheckbox: CSSProperties = {
|
|
295
|
+
flex: 'none',
|
|
296
|
+
accentColor: 'var(--dsw-alias-state-business-primary)',
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const changePathText: CSSProperties = {
|
|
300
|
+
overflow: 'hidden',
|
|
301
|
+
textOverflow: 'ellipsis',
|
|
302
|
+
whiteSpace: 'nowrap',
|
|
303
|
+
flex: 1,
|
|
304
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
305
|
+
fontSize: 13,
|
|
306
|
+
lineHeight: '20px',
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const toolRow: CSSProperties = {
|
|
310
|
+
display: 'flex',
|
|
311
|
+
alignItems: 'center',
|
|
312
|
+
gap: 8,
|
|
313
|
+
flexWrap: 'wrap',
|
|
314
|
+
padding: '6px 0 2px',
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const commitBox: CSSProperties = {
|
|
318
|
+
borderTop: '1px solid var(--dsw-alias-border-l1)',
|
|
319
|
+
padding: '12px 20px 16px',
|
|
320
|
+
display: 'flex',
|
|
321
|
+
flexDirection: 'column',
|
|
322
|
+
gap: 8,
|
|
323
|
+
flex: 'none',
|
|
324
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export const commitInput: CSSProperties = {
|
|
328
|
+
width: '100%',
|
|
329
|
+
boxSizing: 'border-box',
|
|
330
|
+
minHeight: 64,
|
|
331
|
+
resize: 'vertical',
|
|
332
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
333
|
+
borderRadius: 8,
|
|
334
|
+
padding: '6px 10px',
|
|
335
|
+
fontSize: 14,
|
|
336
|
+
lineHeight: '22px',
|
|
337
|
+
fontFamily: 'inherit',
|
|
338
|
+
background: 'var(--dsw-alias-bg-layer-2)',
|
|
339
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export const commitFooter: CSSProperties = {
|
|
343
|
+
display: 'flex',
|
|
344
|
+
alignItems: 'center',
|
|
345
|
+
justifyContent: 'space-between',
|
|
346
|
+
gap: 8,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const commitHint: CSSProperties = {
|
|
350
|
+
fontSize: 12,
|
|
351
|
+
lineHeight: '18px',
|
|
352
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Inline error banner (panel-level; stays until dismissed or next action). */
|
|
356
|
+
export const feedbackError: CSSProperties = {
|
|
357
|
+
display: 'flex',
|
|
358
|
+
alignItems: 'flex-start',
|
|
359
|
+
gap: 8,
|
|
360
|
+
padding: '8px 10px',
|
|
361
|
+
borderRadius: 8,
|
|
362
|
+
fontSize: 12,
|
|
363
|
+
lineHeight: '18px',
|
|
364
|
+
background: 'var(--dsw-alias-state-error-secondary)',
|
|
365
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
366
|
+
whiteSpace: 'pre-wrap',
|
|
367
|
+
wordBreak: 'break-word',
|
|
368
|
+
maxHeight: 120,
|
|
369
|
+
overflowY: 'auto',
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const feedbackClose: CSSProperties = {
|
|
373
|
+
flex: 'none',
|
|
374
|
+
border: 0,
|
|
375
|
+
background: 'transparent',
|
|
376
|
+
padding: '0 2px',
|
|
377
|
+
cursor: 'pointer',
|
|
378
|
+
color: 'inherit',
|
|
379
|
+
fontSize: 13,
|
|
380
|
+
lineHeight: '18px',
|
|
381
|
+
opacity: 0.7,
|
|
382
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free git management operation runner.
|
|
3
|
+
*
|
|
4
|
+
* Same layering as `core.ts`: every dependency is injected structurally, the
|
|
5
|
+
* whole flow is testable against real temporary git repositories without a
|
|
6
|
+
* cordis runtime, and `GitStatusService` only adapts host services into the
|
|
7
|
+
* `SnapshotDeps` face.
|
|
8
|
+
*
|
|
9
|
+
* Security model: the browser only ever sends a `sessionId` plus
|
|
10
|
+
* repository-relative paths (as listed in a snapshot's `changes`). Paths are
|
|
11
|
+
* validated against the work-tree root (absolute paths and `..` escapes are
|
|
12
|
+
* rejected) and every git invocation uses `--` so a path can never be
|
|
13
|
+
* interpreted as an option. Commands run through the same subprocess adapter
|
|
14
|
+
* as the read-only snapshot flow — no shell is involved.
|
|
15
|
+
*/
|
|
16
|
+
import { resolve, sep } from 'node:path'
|
|
17
|
+
import { resolveWorkspace, runCommand, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
|
|
18
|
+
import type { GitAction, GitActionResult, GitActionRequest } from './types.ts'
|
|
19
|
+
|
|
20
|
+
/** Build the command sequence for one action, validating every path against the root. */
|
|
21
|
+
function buildArgv(action: GitAction, root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
|
|
22
|
+
switch (action.kind) {
|
|
23
|
+
case 'stage':
|
|
24
|
+
return withPaths(['git', 'add', '--'], action.paths, root)
|
|
25
|
+
case 'stage-all':
|
|
26
|
+
return { argv: [['git', 'add', '-A']] }
|
|
27
|
+
case 'unstage':
|
|
28
|
+
return withPaths(['git', 'restore', '--staged', '--'], action.paths, root)
|
|
29
|
+
case 'unstage-all':
|
|
30
|
+
return { argv: [['git', 'restore', '--staged', '--', '.']] }
|
|
31
|
+
case 'discard':
|
|
32
|
+
return withPaths(['git', 'restore', '--'], action.paths, root)
|
|
33
|
+
case 'discard-all':
|
|
34
|
+
// Reset the index to HEAD first, then the work tree to the index — the
|
|
35
|
+
// IDE-style "roll back everything tracked" semantics.
|
|
36
|
+
return { argv: [['git', 'restore', '--staged', '--', '.'], ['git', 'restore', '--', '.']] }
|
|
37
|
+
case 'commit': {
|
|
38
|
+
// Message emptiness is validated by runAction (git-error), not here.
|
|
39
|
+
const message = action.message.trim()
|
|
40
|
+
if (action.paths === undefined || action.paths.length === 0) {
|
|
41
|
+
return { argv: [['git', 'commit', '-m', message]] }
|
|
42
|
+
}
|
|
43
|
+
// git commit -- <paths> stages those paths from the work tree and
|
|
44
|
+
// commits only them (index state of other paths is ignored) — the
|
|
45
|
+
// IDE-style "commit selected files" semantics.
|
|
46
|
+
return withPaths(['git', 'commit', '-m', message, '--'], action.paths, root)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Append validated repo-relative paths behind `--`. */
|
|
52
|
+
function withPaths(prefix: readonly string[], paths: readonly string[], root: string): { readonly argv: readonly (readonly string[])[] } | { readonly error: string } {
|
|
53
|
+
if (paths.length === 0) return { error: 'no paths given' }
|
|
54
|
+
for (const path of paths) {
|
|
55
|
+
if (!isSafePath(path, root)) return { error: `unsafe path: ${path}` }
|
|
56
|
+
}
|
|
57
|
+
return { argv: [[...prefix, ...paths]] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A path is safe when it is repo-relative and stays inside the work tree:
|
|
62
|
+
* reject absolute paths, drive letters / backslashes, and `..` escapes
|
|
63
|
+
* (checked via path resolution against the realpath'd root).
|
|
64
|
+
*/
|
|
65
|
+
export function isSafePath(path: string, root: string): boolean {
|
|
66
|
+
if (path === '') return false
|
|
67
|
+
if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) return false
|
|
68
|
+
const resolved = resolve(root, path)
|
|
69
|
+
const prefix = root.endsWith(sep) ? root : `${root}${sep}`
|
|
70
|
+
return resolved === root || resolved.startsWith(prefix)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map a snapshot-flow failure (which may carry git-unavailable) onto the operation error shape. */
|
|
74
|
+
function operationError(failure: Extract<Awaited<ReturnType<typeof resolveWorkspace>>, { ok: false }>['error']): GitActionResult & { ok: false } {
|
|
75
|
+
if (failure.code === 'git-unavailable') {
|
|
76
|
+
return { ok: false, error: { code: 'git-error', message: failure.detail } }
|
|
77
|
+
}
|
|
78
|
+
return { ok: false, error: failure }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Execute one management action against the session's repository and return
|
|
83
|
+
* the refreshed snapshot on success (the caller re-renders from it, so the
|
|
84
|
+
* UI never waits for the next poll).
|
|
85
|
+
*/
|
|
86
|
+
export async function runAction(
|
|
87
|
+
deps: SnapshotDeps,
|
|
88
|
+
config: GitStatusConfig,
|
|
89
|
+
request: GitActionRequest,
|
|
90
|
+
): Promise<GitActionResult> {
|
|
91
|
+
const workspace = await resolveWorkspace(deps, request.sessionId)
|
|
92
|
+
if (!workspace.ok) return operationError(workspace.error)
|
|
93
|
+
const root = workspace.root
|
|
94
|
+
|
|
95
|
+
if (request.action.kind === 'commit' && request.action.message.trim() === '') {
|
|
96
|
+
return { ok: false, error: { code: 'git-error', message: 'commit message is empty' } }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const built = buildArgv(request.action, root)
|
|
100
|
+
if ('error' in built) return { ok: false, error: { code: 'invalid-path', message: built.error } }
|
|
101
|
+
|
|
102
|
+
// Run the command sequence; a failure stops the rest (the first commands
|
|
103
|
+
// may already have taken effect — they are all idempotent restores).
|
|
104
|
+
let lastStdout = ''
|
|
105
|
+
for (const argv of built.argv) {
|
|
106
|
+
const outcome = await runCommand(deps.run, argv, root, `action ${request.action.kind}`, deps.signal)
|
|
107
|
+
if ('failure' in outcome) return operationError(outcome.failure)
|
|
108
|
+
if (outcome.run.timedOut) return { ok: false, error: { code: 'timeout' } }
|
|
109
|
+
if (outcome.run.exitCode !== 0) {
|
|
110
|
+
// git writes user-facing failures to stderr OR stdout (e.g. a clean
|
|
111
|
+
// repo's `git commit` reports "nothing to commit" on stdout).
|
|
112
|
+
const message = outcome.run.stderr.trim() || outcome.run.stdout.trim()
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: {
|
|
116
|
+
code: 'git-error',
|
|
117
|
+
message: message !== '' ? message : `git ${request.action.kind} exited ${String(outcome.run.exitCode)}`,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lastStdout = outcome.run.stdout.trim()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const snapshot = await snapshotForSession(deps, config, request.sessionId)
|
|
125
|
+
if (!snapshot.ok) return operationError(snapshot.error)
|
|
126
|
+
return { ok: true, snapshot: snapshot.value, ...(lastStdout === '' ? {} : { output: lastStdout }) }
|
|
127
|
+
}
|
package/src/host/core.ts
CHANGED
|
@@ -89,7 +89,7 @@ function runFailure(result: { readonly timedOut: boolean }, detail: string): Ext
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** Run one command, mapping a spawn-level failure to a snapshot failure. */
|
|
92
|
-
async function runCommand(
|
|
92
|
+
export async function runCommand(
|
|
93
93
|
runner: GitRunner,
|
|
94
94
|
argv: readonly string[],
|
|
95
95
|
cwd: string,
|
|
@@ -104,20 +104,18 @@ async function runCommand(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* the
|
|
110
|
-
* 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
|
|
111
|
-
* 2. `git branch --show-current` — null when detached
|
|
112
|
-
* 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
|
|
113
|
-
* 4. `git status --porcelain=v1 -z --branch`
|
|
114
|
-
* 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
|
|
107
|
+
* Resolve a session's repository workspace: cwd (live or persisted), the
|
|
108
|
+
* realpath'd directory, and the git work-tree root via `rev-parse
|
|
109
|
+
* --show-toplevel`. Shared by the snapshot flow and the operation runner.
|
|
115
110
|
*/
|
|
116
|
-
export
|
|
111
|
+
export type WorkspaceResolution =
|
|
112
|
+
| { readonly ok: true; readonly cwd: string; readonly root: string }
|
|
113
|
+
| { readonly ok: false; readonly error: Extract<GitSnapshotResult, { ok: false }>['error'] }
|
|
114
|
+
|
|
115
|
+
export async function resolveWorkspace(
|
|
117
116
|
deps: SnapshotDeps,
|
|
118
|
-
config: GitStatusConfig,
|
|
119
117
|
sessionId: string,
|
|
120
|
-
): Promise<
|
|
118
|
+
): Promise<WorkspaceResolution> {
|
|
121
119
|
const resolved = await resolveCwd(deps.sessions, sessionId)
|
|
122
120
|
if (!resolved.ok) return { ok: false, error: resolved.error }
|
|
123
121
|
|
|
@@ -149,6 +147,27 @@ export async function snapshotForSession(
|
|
|
149
147
|
}
|
|
150
148
|
const root = toplevel.run.stdout.trim()
|
|
151
149
|
if (root === '') return { ok: false, error: { code: 'not-a-git-repo' } }
|
|
150
|
+
return { ok: true, cwd: realCwd, root }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build one frozen GitSnapshot for a session working directory.
|
|
155
|
+
* Command sequence (all read-only; every command after the first runs with
|
|
156
|
+
* the repository root as cwd):
|
|
157
|
+
* 1. `git rev-parse --show-toplevel` — repo detection (exit 128 → not-a-git-repo)
|
|
158
|
+
* 2. `git branch --show-current` — null when detached
|
|
159
|
+
* 3. `git rev-parse --short HEAD` — null + unborn when the repo has no commits
|
|
160
|
+
* 4. `git status --porcelain=v1 -z --branch`
|
|
161
|
+
* 5. `git log -n 5 --format=%H%x1f%h%x1f%s%x1f%an%x1f%aI`
|
|
162
|
+
*/
|
|
163
|
+
export async function snapshotForSession(
|
|
164
|
+
deps: SnapshotDeps,
|
|
165
|
+
config: GitStatusConfig,
|
|
166
|
+
sessionId: string,
|
|
167
|
+
): Promise<GitSnapshotResult> {
|
|
168
|
+
const workspace = await resolveWorkspace(deps, sessionId)
|
|
169
|
+
if (!workspace.ok) return { ok: false, error: workspace.error }
|
|
170
|
+
const root = workspace.root
|
|
152
171
|
|
|
153
172
|
const branchRun = await runCommand(deps.run, ['git', 'branch', '--show-current'], root, 'branch', deps.signal)
|
|
154
173
|
if ('failure' in branchRun) return { ok: false, error: branchRun.failure }
|
package/src/host/index.ts
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-git-ui host half: the `gitInfo` Remote service.
|
|
3
3
|
*
|
|
4
|
-
* Cordis shell only — every behavior lives in `core.ts` behind
|
|
5
|
-
* structural faces, so tests never need a cordis runtime. The class
|
|
6
|
-
* plugin in its own right (class form), mounted by the bundle patch row
|
|
7
|
-
* the package name; the gateway exposes `gitInfo/snapshot`
|
|
8
|
-
* discovery (`typertRemote` binding + `@Remote`
|
|
4
|
+
* Cordis shell only — every behavior lives in `core.ts`/`actions.ts` behind
|
|
5
|
+
* injected structural faces, so tests never need a cordis runtime. The class
|
|
6
|
+
* is a plugin in its own right (class form), mounted by the bundle patch row
|
|
7
|
+
* with the package name; the gateway exposes `gitInfo/snapshot` and
|
|
8
|
+
* `gitInfo/run` through SRC discovery (`typertRemote` binding + `@Remote`
|
|
9
|
+
* marker).
|
|
9
10
|
*/
|
|
10
11
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
11
12
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
13
|
import { realpath, stat } from 'node:fs/promises'
|
|
13
14
|
import { createGitRunner, type SubprocessLike } from './git.ts'
|
|
14
|
-
import { normalizeConfig, snapshotForSession, type GitStatusConfig } from './core.ts'
|
|
15
|
-
import
|
|
15
|
+
import { normalizeConfig, snapshotForSession, type GitStatusConfig, type SnapshotDeps } from './core.ts'
|
|
16
|
+
import { runAction } from './actions.ts'
|
|
17
|
+
import type { GitActionResult, GitActionRequest, GitSnapshotRequest, GitSnapshotResult } from './types.ts'
|
|
16
18
|
|
|
17
|
-
export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange } from './types.ts'
|
|
19
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotFailure, GitSnapshotRequest, GitCommit, GitChange, GitAction, GitActionResult, GitActionRequest } from './types.ts'
|
|
18
20
|
export { normalizeConfig, DEFAULT_CONFIG } from './core.ts'
|
|
19
21
|
export { parseStatusOutput, parseLogOutput, parseBranchOutput } from './parser.ts'
|
|
22
|
+
export { isSafePath, runAction } from './actions.ts'
|
|
20
23
|
|
|
21
24
|
/** Structural face of a live session header. */
|
|
22
25
|
interface SessionLike {
|
|
@@ -33,7 +36,7 @@ interface SessionPersistenceLike {
|
|
|
33
36
|
inspect(id: string): Promise<{ readonly meta: { readonly cwd?: string } }>
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
/** The `gitInfo` service:
|
|
39
|
+
/** The `gitInfo` service: `snapshot` (read) and `run` (management) endpoints. */
|
|
37
40
|
export class GitStatusService extends TypertRemoteService {
|
|
38
41
|
static inject = ['subprocess', 'sessions', 'sessionPersistence']
|
|
39
42
|
|
|
@@ -44,17 +47,17 @@ export class GitStatusService extends TypertRemoteService {
|
|
|
44
47
|
this.config = normalizeConfig(config)
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
/** Adapter face shared by both endpoints (injected services + runner). */
|
|
51
|
+
private deps(signal?: AbortSignal): { readonly deps: SnapshotDeps } | { readonly failure: { readonly code: 'git-unavailable'; readonly detail: string } } {
|
|
49
52
|
const subprocess = this.ctx.get('subprocess') as SubprocessLike | undefined
|
|
50
53
|
if (subprocess === undefined) {
|
|
51
|
-
return {
|
|
54
|
+
return { failure: { code: 'git-unavailable', detail: 'subprocess service unavailable' } }
|
|
52
55
|
}
|
|
53
56
|
const sessions = this.ctx.get('sessions') as SessionsLike | undefined
|
|
54
57
|
const persistence = this.ctx.get('sessionPersistence') as SessionPersistenceLike | undefined
|
|
55
58
|
const runner = createGitRunner(subprocess, this.config.timeoutMs, this.config.maxStatusBytes)
|
|
56
|
-
return
|
|
57
|
-
{
|
|
59
|
+
return {
|
|
60
|
+
deps: {
|
|
58
61
|
run: runner,
|
|
59
62
|
fs: { realpath, stat },
|
|
60
63
|
sessions: {
|
|
@@ -71,9 +74,23 @@ export class GitStatusService extends TypertRemoteService {
|
|
|
71
74
|
},
|
|
72
75
|
signal,
|
|
73
76
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Remote('snapshot')
|
|
81
|
+
async snapshot(request: GitSnapshotRequest, signal?: AbortSignal): Promise<GitSnapshotResult> {
|
|
82
|
+
const adapted = this.deps(signal)
|
|
83
|
+
if ('failure' in adapted) return { ok: false, error: adapted.failure }
|
|
84
|
+
return snapshotForSession(adapted.deps, this.config, request.sessionId)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Remote('run')
|
|
88
|
+
async run(request: GitActionRequest, signal?: AbortSignal): Promise<GitActionResult> {
|
|
89
|
+
const adapted = this.deps(signal)
|
|
90
|
+
if ('failure' in adapted) {
|
|
91
|
+
return { ok: false, error: { code: 'git-error', message: adapted.failure.detail } }
|
|
92
|
+
}
|
|
93
|
+
return runAction(adapted.deps, this.config, request)
|
|
77
94
|
}
|
|
78
95
|
}
|
|
79
96
|
|
package/src/host/types.ts
CHANGED
|
@@ -72,3 +72,42 @@ export type GitChangeStatus =
|
|
|
72
72
|
| 'untracked'
|
|
73
73
|
| 'conflicted'
|
|
74
74
|
| 'typechange'
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One git management operation addressed by the `run` endpoint. Paths are
|
|
78
|
+
* always repository-relative (as listed in a snapshot's `changes`), never
|
|
79
|
+
* absolute — the host validates them against the work tree.
|
|
80
|
+
*/
|
|
81
|
+
export type GitAction =
|
|
82
|
+
| { readonly kind: 'stage'; readonly paths: readonly string[] }
|
|
83
|
+
| { readonly kind: 'stage-all' }
|
|
84
|
+
| { readonly kind: 'unstage'; readonly paths: readonly string[] }
|
|
85
|
+
| { readonly kind: 'unstage-all' }
|
|
86
|
+
| { readonly kind: 'discard'; readonly paths: readonly string[] }
|
|
87
|
+
| { readonly kind: 'discard-all' }
|
|
88
|
+
| {
|
|
89
|
+
readonly kind: 'commit'
|
|
90
|
+
readonly message: string
|
|
91
|
+
/** Commit only these paths (git commit -- <paths> semantics); absent or
|
|
92
|
+
* empty commits everything already staged. */
|
|
93
|
+
readonly paths?: readonly string[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type GitOperationErrorCode =
|
|
97
|
+
| 'session-not-found'
|
|
98
|
+
| 'cwd-unavailable'
|
|
99
|
+
| 'path-not-found'
|
|
100
|
+
| 'not-a-git-repo'
|
|
101
|
+
| 'invalid-path'
|
|
102
|
+
| 'git-error'
|
|
103
|
+
| 'timeout'
|
|
104
|
+
|
|
105
|
+
export type GitActionResult =
|
|
106
|
+
| { readonly ok: true; readonly snapshot: GitSnapshot; readonly output?: string }
|
|
107
|
+
| { readonly ok: false; readonly error: { readonly code: GitOperationErrorCode; readonly message?: string } }
|
|
108
|
+
|
|
109
|
+
/** Wire request of the `run` endpoint. */
|
|
110
|
+
export interface GitActionRequest {
|
|
111
|
+
readonly sessionId: string
|
|
112
|
+
readonly action: GitAction
|
|
113
|
+
}
|